From 48c47657ef789564072e98b9d37b074b3da9b37d Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Tue, 29 Sep 2026 15:10:33 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=94=A8=E6=88=B7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E5=86=99=E5=85=A5=E4=B8=9A=E5=8A=A1=E5=BA=93=EF=BC=8C?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E5=8F=AF=E6=8C=89=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E4=B8=8E=E7=94=A8=E6=88=B7=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先 user_feedback 收到内容后直接丢掉。现在落库 userfeedback,后台按应用列出并支持时间、用户和正文筛选。 Co-authored-by: Cursor --- apps/admin/app/pages/feedback.vue | 298 ++++++++++++++++++ apps/admin/app/utils/menus.ts | 1 + apps/services/comm/const.go | 1 + apps/services/modules/console/api_feedback.go | 116 +++++++ .../modules/console/api_feedback_test.go | 28 ++ apps/services/modules/console/server.go | 4 + apps/services/modules/user/api_feedback.go | 35 ++ .../modules/user/api_feedback_test.go | 23 ++ apps/services/modules/user/model_feedback.go | 92 ++++++ apps/services/modules/user/module.go | 24 +- 10 files changed, 611 insertions(+), 11 deletions(-) create mode 100644 apps/admin/app/pages/feedback.vue create mode 100644 apps/services/modules/console/api_feedback.go create mode 100644 apps/services/modules/console/api_feedback_test.go create mode 100644 apps/services/modules/user/api_feedback_test.go create mode 100644 apps/services/modules/user/model_feedback.go diff --git a/apps/admin/app/pages/feedback.vue b/apps/admin/app/pages/feedback.vue new file mode 100644 index 00000000..360690cf --- /dev/null +++ b/apps/admin/app/pages/feedback.vue @@ -0,0 +1,298 @@ + + + diff --git a/apps/admin/app/utils/menus.ts b/apps/admin/app/utils/menus.ts index 204dc243..78180a64 100644 --- a/apps/admin/app/utils/menus.ts +++ b/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' }, ], }, { diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index 030114c2..2d482fa6 100644 --- a/apps/services/comm/const.go +++ b/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服务 diff --git a/apps/services/modules/console/api_feedback.go b/apps/services/modules/console/api_feedback.go new file mode 100644 index 00000000..f62cae29 --- /dev/null +++ b/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}) +} diff --git a/apps/services/modules/console/api_feedback_test.go b/apps/services/modules/console/api_feedback_test.go new file mode 100644 index 00000000..b6fef768 --- /dev/null +++ b/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) + } +} diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go index d1c0547a..89639922 100644 --- a/apps/services/modules/console/server.go +++ b/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) diff --git a/apps/services/modules/user/api_feedback.go b/apps/services/modules/user/api_feedback.go index 5095ed0b..7dab8a7b 100644 --- a/apps/services/modules/user/api_feedback.go +++ b/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 } diff --git a/apps/services/modules/user/api_feedback_test.go b/apps/services/modules/user/api_feedback_test.go new file mode 100644 index 00000000..b06bfcd2 --- /dev/null +++ b/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)) + } +} diff --git a/apps/services/modules/user/model_feedback.go b/apps/services/modules/user/model_feedback.go new file mode 100644 index 00000000..e2e6c758 --- /dev/null +++ b/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) +} diff --git a/apps/services/modules/user/module.go b/apps/services/modules/user/module.go index 9932a1c7..faf5f5a9 100644 --- a/apps/services/modules/user/module.go +++ b/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) }