Browse Source

feat:用户反馈写入业务库,管理后台可按时间与用户查询

原先 user_feedback 收到内容后直接丢掉。现在落库 userfeedback,后台按应用列出并支持时间、用户和正文筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
along-test
Rodger-Wang 2 weeks ago
parent
commit
48c47657ef
  1. 298
      apps/admin/app/pages/feedback.vue
  2. 1
      apps/admin/app/utils/menus.ts
  3. 1
      apps/services/comm/const.go
  4. 116
      apps/services/modules/console/api_feedback.go
  5. 28
      apps/services/modules/console/api_feedback_test.go
  6. 4
      apps/services/modules/console/server.go
  7. 35
      apps/services/modules/user/api_feedback.go
  8. 23
      apps/services/modules/user/api_feedback_test.go
  9. 92
      apps/services/modules/user/model_feedback.go
  10. 24
      apps/services/modules/user/module.go

298
apps/admin/app/pages/feedback.vue

@ -0,0 +1,298 @@
<template>
<div class="space-y-5">
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
<div class="flex items-end gap-3 flex-wrap">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label>
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange">
<option v-if="!appNames.length" value="">无可用应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label>
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload">
<option v-if="!regionItems.length" value="">无可用区域</option>
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option>
</select>
</div>
<div class="form-control flex-1" style="min-width:220px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">用户</span></label>
<input
v-model="q.user" type="text" class="input input-bordered input-sm w-full"
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号" @keydown.enter.prevent="reload" />
</div>
<div class="form-control flex-1" style="min-width:180px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">内容</span></label>
<input
v-model="q.content" type="text" class="input input-bordered input-sm w-full"
placeholder="反馈正文关键词" @keydown.enter.prevent="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交起</span></label>
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交止</span></label>
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button>
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
<h3 class="text-sm font-semibold text-slate-700">
用户反馈<span class="text-xs text-slate-400 font-normal ml-2">按提交时间倒序,点一行看全文</span>
</h3>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">共 {{ total }} 条</span>
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload">
<option :value="20">20/页</option>
<option :value="50">50/页</option>
<option :value="100">100/页</option>
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra text-sm">
<thead>
<tr>
<th>时间</th><th>用户</th><th>内容</th><th>版本</th><th>机型</th><th>渠道</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="6" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!qRegion"><td colspan="6" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
<tr v-else-if="listError"><td colspan="6" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
<tr v-else-if="!rows.length"><td colspan="6" class="text-center text-slate-400 py-10">没有符合条件的反馈</td></tr>
<tr v-for="r in rows" v-else :key="r.id" class="hover cursor-pointer" @click="openDetail(r)">
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(r.createtime) }}</td>
<td class="text-xs">
<div class="font-medium text-slate-700">{{ r.name || '(未设置昵称)' }}</div>
<div class="text-slate-400 font-mono">{{ r.uid }}</div>
<div v-if="r.phone" class="text-slate-500">{{ r.phone }}</div>
<div v-if="r.mail" class="text-slate-500 truncate max-w-[180px]" :title="r.mail">{{ r.mail }}</div>
</td>
<td class="text-xs text-slate-700 max-w-[360px]">
<div class="line-clamp-2 whitespace-pre-wrap break-words">{{ r.message }}</div>
</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ r.version || '-' }}</td>
<td class="text-xs text-slate-500 max-w-[140px] truncate" :title="r.phonemodel">{{ r.phonemodel || '-' }}</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ channelName(r.channel) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
<span class="text-xs text-slate-500">{{ pageInfo }}</span>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button>
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span>
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button>
</div>
</div>
</div>
<dialog ref="detailModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-semibold text-slate-800">反馈全文</h3>
<div v-if="current" class="mt-3 space-y-3">
<div class="text-xs text-slate-500 flex flex-wrap gap-x-4 gap-y-1">
<span>{{ fmtTs(current.createtime) }}</span>
<span>{{ current.name || '(未设置昵称)' }}</span>
<span class="font-mono">{{ current.uid }}</span>
<span v-if="current.phone">{{ current.phone }}</span>
<span v-if="current.mail">{{ current.mail }}</span>
<span v-if="current.account">账号 {{ current.account }}</span>
<span>版本 {{ current.version || '-' }}</span>
<span>{{ current.phonemodel || '-' }}</span>
<span>{{ channelName(current.channel) }}</span>
</div>
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words bg-slate-50 rounded-lg p-4">{{ current.message }}</div>
</div>
<div class="modal-action">
<button class="btn btn-sm" @click="closeDetail">关闭</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useAuthStore } from '~/stores/auth'
import { regionLabel } from '~/utils/regions'
definePageMeta({ title: '用户反馈' })
interface AppItem {
id: number
name: string
app_name?: string
region: string
enabled?: boolean
}
interface FeedbackRow {
id: number
uid: string
message: string
version: string
phonemodel: string
channel: number
createtime: number
name: string
phone: string
mail: string
account: string
}
const CHANNELS: Record<number, string> = {
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
100: '官方包', 101: 'App Store', 102: 'Google Play',
}
function channelName(v: number) {
if (!v) return '-'
return CHANNELS[v] ?? '渠道' + v
}
const { webApi, consoleApi } = useApi()
const auth = useAuthStore()
const apps = ref<AppItem[]>([])
const qApp = ref('')
const qRegion = ref<number | ''>('')
const rows = ref<FeedbackRow[]>([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const q = reactive({ user: '', content: '', startDate: '', endDate: '', page: 0, pageSize: 20 })
const current = ref<FeedbackRow | null>(null)
const detailModal = ref<HTMLDialogElement | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize)))
const pageInfo = computed(() => {
if (!total.value) return '-'
const from = q.page * q.pageSize + 1
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条`
})
const appNames = computed(() => {
const names: string[] = []
apps.value.forEach((a) => {
const n = a.app_name || a.name
if (n && !names.includes(n)) names.push(n)
})
return names
})
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value))
function onAppChange() {
const items = regionItems.value
qRegion.value = items.length ? items[0].id : ''
reload()
}
function fmtTs(ts: number) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString()
}
function dateToUnix(s: string, endOfDay: boolean): number {
if (!s) return 0
const d = new Date(s + 'T00:00:00')
if (isNaN(d.getTime())) return 0
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0)
}
async function loadList() {
if (!qApp.value || !qRegion.value) {
rows.value = []
total.value = 0
return
}
auth.setCurrentApp(Number(qRegion.value))
loading.value = true
listError.value = ''
try {
const d = await webApi<{ items: FeedbackRow[]; total: number }>('getuserfeedbacks', {
user: q.user.trim(),
content: q.content.trim(),
start: dateToUnix(q.startDate, false),
end: dateToUnix(q.endDate, true),
page: q.page,
page_size: q.pageSize,
})
rows.value = d?.items ?? []
total.value = d?.total ?? 0
} catch (e: any) {
listError.value = e?.message || '查询失败'
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
function reload() {
q.page = 0
loadList()
}
function changePage(d: number) {
const next = q.page + d
if (next < 0 || next >= totalPages.value) return
q.page = next
loadList()
}
function resetFilters() {
q.user = ''
q.content = ''
q.startDate = ''
q.endDate = ''
reload()
}
function openDetail(row: FeedbackRow) {
current.value = row
detailModal.value?.showModal()
}
function closeDetail() {
detailModal.value?.close()
}
function buildAppSelect() {
const names = appNames.value
if (!names.length) {
qApp.value = ''
qRegion.value = ''
return
}
qApp.value = names[0]
const items = regionItems.value
qRegion.value = items.length ? items[0].id : ''
}
async function loadApps() {
if (!auth.isAdmin) {
try {
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
apps.value = d?.apps ?? []
} catch {
apps.value = (auth.appbinds || []).map((a) => ({
id: a.id, name: a.name, app_name: a.app_name, region: a.region, enabled: true,
}))
}
buildAppSelect()
loadList()
return
}
try {
const list = await consoleApi<AppItem[]>('apps/list', {})
apps.value = (list || []).filter((a) => a.enabled)
} catch {
apps.value = []
}
buildAppSelect()
loadList()
}
onMounted(loadApps)
</script>

1
apps/admin/app/utils/menus.ts

@ -55,6 +55,7 @@ export const MENU_GROUPS: MenuGroup[] = [
label: '用户管理',
items: [
{ id: 'user', label: '用户查询', to: '/users', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ id: 'userfeedback', label: '用户反馈', to: '/feedback', icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' },
],
},
{

1
apps/services/comm/const.go

@ -45,6 +45,7 @@ const (
TableUserdevice = "userdevice" //用户设备列表
TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希)
TableUserIdVerifyLog = "useridverify_log" //实名绑定变更留痕(换人/注销),只存掩码+指纹,不存姓名
TableUserFeedback = "userfeedback" //App「用户反馈」提交记录(业务库,按应用分离)
TableRecord = "record" //记录表
TableAgent = "agent" //智能体id
TableMcp = "mcp" //Mcp服务

116
apps/services/modules/console/api_feedback.go

@ -0,0 +1,116 @@
package console
import (
"strings"
"yunyan/comm"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// userFeedbackListReq 用户反馈列表筛选。
//
// User 一栏多用:UID 精确,手机号 / 邮箱 / 用户名 / 登录账号模糊。
// 客服转来的线索形态不定,让人先选「按什么查」只是多一步。
// Content 只搜反馈正文。时间区间是 [start, end),结束日由前端按当天 24:00 换算。
type userFeedbackListReq struct {
User string `json:"user"`
Content string `json:"content"`
Start int64 `json:"start"`
End int64 `json:"end"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// userFeedbackItem 列表行。用户字段来自提交时仍在库里的 user 行;
// 账号已注销删掉时这几列为空,uid 仍在。
type userFeedbackItem struct {
Id uint64 `json:"id" gorm:"column:id"`
Uid string `json:"uid" gorm:"column:uid"`
Message string `json:"message" gorm:"column:message"`
Version string `json:"version" gorm:"column:version"`
Phonemodel string `json:"phonemodel" gorm:"column:phonemodel"`
Channel int32 `json:"channel" gorm:"column:channel"`
Createtime int64 `json:"createtime" gorm:"column:createtime"`
Name string `json:"name" gorm:"column:name"`
Phone string `json:"phone" gorm:"column:phone"`
Mail string `json:"mail" gorm:"column:mail"`
Account string `json:"account" gorm:"column:account"`
}
// buildUserFeedbackWhere 拼筛选条件。表别名 f = userfeedback,u = user。
func buildUserFeedbackWhere(req *userFeedbackListReq) (string, []interface{}) {
conds := make([]string, 0, 4)
args := make([]interface{}, 0, 8)
if kw := strings.TrimSpace(req.User); kw != "" {
like := "%" + kw + "%"
conds = append(conds, "(f.uid = ? OR u.phone LIKE ? OR u.mail LIKE ? OR u.name LIKE ? OR u.account LIKE ?)")
args = append(args, kw, like, like, like, like)
}
if kw := strings.TrimSpace(req.Content); kw != "" {
conds = append(conds, "f.message LIKE ?")
args = append(args, "%"+kw+"%")
}
if req.Start > 0 {
conds = append(conds, "f.createtime >= ?")
args = append(args, req.Start)
}
if req.End > 0 {
conds = append(conds, "f.createtime < ?")
args = append(args, req.End)
}
if len(conds) == 0 {
return "1=1", args
}
return strings.Join(conds, " AND "), args
}
// getUserFeedbacks 所选应用业务库里的用户反馈,按提交时间倒序。
func (this *serverComp) getUserFeedbacks(c *gin.Context) {
appId := parseAppId(c.GetHeader("X-App-Id"))
if !this.requireAppScope(c, appId) {
return
}
conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
req := &userFeedbackListReq{}
_ = c.ShouldBindJSON(req)
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > userListPageMax {
req.PageSize = userListPageMax
}
if req.Page < 0 {
req.Page = 0
}
where, args := buildUserFeedbackWhere(req)
base := conn.Table(comm.TableUserFeedback+" AS f").
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid").
Where(where, args...)
var total int64
if err := base.Count(&total).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
rows := make([]*userFeedbackItem, 0, req.PageSize)
if total > 0 {
// Count 已经用过这条链,再查要重新起一条,否则 Limit 会叠在已执行的语句上。
if err := conn.Table(comm.TableUserFeedback+" AS f").
Select("f.id, f.uid, f.message, f.version, f.phonemodel, f.channel, f.createtime, u.name, u.phone, u.mail, u.account").
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid").
Where(where, args...).
Order("f.createtime DESC, f.id DESC").
Limit(req.PageSize).Offset(req.Page * req.PageSize).
Find(&rows).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
writeOK(c, gin.H{"items": rows, "total": total, "page": req.Page, "page_size": req.PageSize})
}

28
apps/services/modules/console/api_feedback_test.go

@ -0,0 +1,28 @@
package console
import (
"strings"
"testing"
)
func TestBuildUserFeedbackWhere(t *testing.T) {
where, args := buildUserFeedbackWhere(&userFeedbackListReq{})
if where != "1=1" || len(args) != 0 {
t.Fatalf("empty: where %q args %v", where, args)
}
where, args = buildUserFeedbackWhere(&userFeedbackListReq{
User: "138", Content: "耳机", Start: 10, End: 20,
})
for _, sub := range []string{"f.uid = ?", "u.phone LIKE ?", "f.message LIKE ?", "f.createtime >= ?", "f.createtime < ?"} {
if !strings.Contains(where, sub) {
t.Fatalf("where %q missing %q", where, sub)
}
}
if len(args) != 8 {
t.Fatalf("args len = %d, want 8: %v", len(args), args)
}
if args[0] != "138" || args[1] != "%138%" || args[5] != "%耳机%" || args[6] != int64(10) || args[7] != int64(20) {
t.Fatalf("args = %v", args)
}
}

4
apps/services/modules/console/server.go

@ -453,6 +453,10 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_getusers":
this.getUsers(c)
return
// 用户反馈:App「我的 → 用户反馈」写入业务库 userfeedback,按选中应用查询。
case "api_getuserfeedbacks":
this.getUserFeedbacks(c)
return
// 修改用户可运营参数(状态/VIP/积分余额)。仅超管/管理员——代理发资源必须走赠送那条扣余额的路。
case "api_updateuser":
this.updateUser(c)

35
apps/services/modules/user/api_feedback.go

@ -1,10 +1,30 @@
package user
import (
"strings"
"unicode/utf8"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// feedbackMessageMax 与客户端输入框上限(500)之间留出余量。
// 超限直接拒,不截断——截断后用户以为整段都交上去了。
const feedbackMessageMax = 2000
// normalizeFeedbackMessage 去掉首尾空白并校验长度。第二个返回值非空表示拒绝原因。
func normalizeFeedbackMessage(s string) (string, string) {
s = strings.TrimSpace(s)
if s == "" {
return "", "请填写反馈内容"
}
if utf8.RuneCountInString(s) > feedbackMessageMax {
return "", "反馈内容过长"
}
return s, ""
}
// @Summary 用户反馈
// @Description 用户反馈
// @Tags User
@ -16,5 +36,20 @@ import (
// @Router /api/home/user_feedback [post]
func (this *apiComp) Feedback(session comm.IUserSession, req *pb.UserFeedbackReq) (resp *pb.UserFeedbackResp, errdata *pb.ErrorData) {
resp = &pb.UserFeedbackResp{}
uid := session.GetUserId()
if uid == "" {
errdata = &pb.ErrorData{Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String()}
return
}
message, reason := normalizeFeedbackMessage(req.GetMessage())
if reason != "" {
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: reason}
return
}
if err := this.module.feedbackmodel.insert(uid, message); err != nil {
this.module.Error("用户反馈写入失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()})
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: pb.ErrorCode_DBError.String()}
return
}
return
}

23
apps/services/modules/user/api_feedback_test.go

@ -0,0 +1,23 @@
package user
import (
"strings"
"testing"
)
func TestNormalizeFeedbackMessage(t *testing.T) {
if got, reason := normalizeFeedbackMessage(" 耳机连不上 "); reason != "" || got != "耳机连不上" {
t.Fatalf("trim: got %q reason %q", got, reason)
}
if _, reason := normalizeFeedbackMessage(" "); reason == "" {
t.Fatal("blank should be rejected")
}
long := strings.Repeat("字", feedbackMessageMax+1)
if _, reason := normalizeFeedbackMessage(long); reason == "" {
t.Fatal("overlong should be rejected")
}
edge := strings.Repeat("a", feedbackMessageMax)
if got, reason := normalizeFeedbackMessage(edge); reason != "" || got != edge {
t.Fatalf("max length should pass, reason %q len %d", reason, len(got))
}
}

92
apps/services/modules/user/model_feedback.go

@ -0,0 +1,92 @@
package user
import (
"errors"
"time"
"yunyan/comm"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
)
// dbUserFeedback App 用户反馈。本地 gorm 结构,不进 pb:
// 客户端只提交、不回读;后台列表由 console 直连业务库查询。
//
// version / phonemodel / channel 是提交当时从 user 行抄下来的快照。
// 用户之后升级或换机,这条记录仍停在提交时的环境上,排查「哪一版出的问题」靠它。
type dbUserFeedback struct {
Id uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Uid string `gorm:"column:uid;size:50;index" json:"uid"`
Message string `gorm:"column:message;size:2000" json:"message"`
Version string `gorm:"column:version;size:32" json:"version"`
Phonemodel string `gorm:"column:phonemodel;size:64" json:"phonemodel"`
Channel int32 `gorm:"column:channel" json:"channel"`
Createtime int64 `gorm:"column:createtime;index" json:"createtime"`
}
type modelFeedbackComp struct {
cbase.ModuleCompBase
module *User
}
func (this *modelFeedbackComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) {
this.ModuleCompBase.Init(service, module, comp, opt)
this.module = module.(*User)
if err = mysql.CreateTable(comm.TableUserFeedback, &dbUserFeedback{}); err != nil {
this.module.Errorln(err)
}
// 后台列表按手机号/昵称筛时要 JOIN user。user 表多半是老库的 utf8mb4_general_ci,
// 新表按服务器默认建会是 utf8mb4_0900_ai_ci,联表直接 1267。对齐后再查。
this.alignCollation()
return
}
func (this *modelFeedbackComp) alignCollation() {
target := this.tableCollation(comm.TableUser)
charset, ok := collationCharset(target)
if !ok {
this.module.Warn("用户反馈表排序规则对齐:user 表排序规则读不到或不合法,跳过", log.Field{Key: "collation", Value: target})
return
}
cur := this.tableCollation(comm.TableUserFeedback)
if cur == "" || cur == target {
return
}
if res := mysql.Exec("ALTER TABLE `" + comm.TableUserFeedback + "` CONVERT TO CHARACTER SET " + charset + " COLLATE " + target); res.Error != nil {
this.module.Error("用户反馈表排序规则对齐失败", log.Field{Key: "from", Value: cur},
log.Field{Key: "to", Value: target}, log.Field{Key: "err", Value: res.Error.Error()})
return
}
this.module.Infof("用户反馈表排序规则对齐:%s → %s", cur, target)
}
func (this *modelFeedbackComp) tableCollation(table string) string {
var coll string
res := mysql.Raw("SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", table).Scan(&coll)
if res.Error != nil {
this.module.Warn("读表排序规则失败", log.Field{Key: "table", Value: table}, log.Field{Key: "err", Value: res.Error.Error()})
return ""
}
return coll
}
// insert 写入一条反馈。用户资料读不到时快照留空,内容照样落库——
// 反馈本身比「附带的版本号」重要,不能因为查用户失败就把内容丢掉。
func (this *modelFeedbackComp) insert(uid, message string) error {
row := &dbUserFeedback{
Uid: uid,
Message: message,
Createtime: time.Now().Unix(),
}
if user, err := this.module.model.findforid(uid); err == nil && user != nil {
row.Version = user.Version
row.Phonemodel = user.Phonemodel
row.Channel = int32(user.Channel)
} else if err != nil && !errors.Is(err, mysql.ErrNoDocuments) {
this.module.Warn("用户反馈:读用户快照失败,仍写入内容",
log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()})
}
return mysql.Insert(comm.TableUserFeedback, row)
}

24
apps/services/modules/user/module.go

@ -21,18 +21,19 @@ func NewModule() core.IModule {
type User struct {
modules.ModuleBase
service base.IRPCXService //rpcx服务接口 主要client->server
api *apiComp
apiv2 *apiV2Comp
apiv3 *apiV3Comp
model *modelUserComp
configmodel *modelConfigComp
idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验)
service base.IRPCXService //rpcx服务接口 主要client->server
api *apiComp
apiv2 *apiV2Comp
apiv3 *apiV3Comp
model *modelUserComp
configmodel *modelConfigComp
idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验)
feedbackmodel *modelFeedbackComp //用户反馈(App「我的 → 用户反馈」落库)
translatemodel *modelTranslateComp //实时机器翻译(走第三方服务配置里的 MT 服务)
cache *modelCacheComp
options *Options
analyze comm.IAnalyze
echomeet comm.IEchomeet // 会议记录能力(同进程 echomeet 模块提供,v3 配置下发用;可能为 nil)
cache *modelCacheComp
options *Options
analyze comm.IAnalyze
echomeet comm.IEchomeet // 会议记录能力(同进程 echomeet 模块提供,v3 配置下发用;可能为 nil)
}
func (this *User) GetType() core.M_Modules {
@ -68,5 +69,6 @@ func (this *User) OnInstallComp() {
this.cache = this.RegisterComp(new(modelCacheComp)).(*modelCacheComp)
this.configmodel = this.RegisterComp(new(modelConfigComp)).(*modelConfigComp)
this.idverifymodel = this.RegisterComp(new(modelIdVerifyComp)).(*modelIdVerifyComp)
this.feedbackmodel = this.RegisterComp(new(modelFeedbackComp)).(*modelFeedbackComp)
this.translatemodel = this.RegisterComp(new(modelTranslateComp)).(*modelTranslateComp)
}

Loading…
Cancel
Save