简介客户反馈管理系统是一套面向企业客服、产品运营与质量管理人员的反馈处理方案围绕客户评价的收集、分类归档、分析报告、问题跟踪与满意度评分展开帮助团队把分散的客户声音沉淀为可追踪、可量化的改进依据也适合作为CRM集成与持续改进流程的参考样例。压缩包共16个文件约54.73MB包含msi、exe安装与部署程序xml配置文件和txt产品说明以及gif、png、jpg界面素材与ttf字体兼顾系统安装、配置演示和界面资源替换。目前已有651人学习下载。读者可结合说明书梳理反馈渠道、评分规则与跟踪流程利用配置文件和安装包快速搭建演示环境并参考图像与字体素材调整界面对需要设计客户满意度指标体系、落实问题闭环的团队具备直接的借鉴价值。1. 客户反馈管理系统收上来只是第一步压垮它的是关不掉做后台的人基本都经历过这个场景客户反馈用问卷或表单收前两周还有人看一个月后列表翻到第三页全是待处理再后来谁都不敢点进去。问题不在收集端而在收集之后的流转——分给谁、多久必须解决、解决了怎么验证、无效的怎么归档这些环节没有系统兜底客户反馈就会从资产变成负债。这套客户反馈管理系统解决的就是后半段提交、分派、处理、解决、关闭的完整闭环配上一套能查得动、统计得出的数据模型。适合正在用 Excel 或聊天群凑合的中小团队也适合想给现有工单模块补一层反馈入口的后端同学。下面按我实际拆包的顺序讲先看数据模型为什么这么定再把它在本地跑起来最后是几处不踩不知道的坑。2. 表结构与状态机五张表撑起一条反馈从提交到关闭2.1 先定状态机再回头画表大部分反馈系统的返工都发生在建模阶段先建了一张feedback表字段加了二三十个等到要实现驳回后重新打开才发现状态字段是个裸int前端随便传值就能把已关闭的单子改回待处理。我的习惯是反过来——先把状态和合法跃迁画成一张表再让表结构去服务它。这套系统用了 6 个状态用两位数字段编码留出扩展余量状态码名称含义谁可以进入10待受理已入库未分派系统、任何提交入口20已分派指定了处理人未开始管理员、自动分派规则30处理中处理人已认领处理人本人40已解决给出了解决方案等确认处理人50已关闭终态客户确认或超时归档管理员、定时任务90已驳回重复、无效、非产品问题管理员主动用两位数字而不是 1、2、3是血泪经验——后面要插待客户补充信息这类中间态时80、85 这种码位随手就有不用改枚举顺序也不用迁移历史数据。2.2 五张核心表各自的边界拆包之后核心就五张表职责切得很干净没有那种一张表扛所有事情的写法表名职责关键字段量级预估fb_feedback反馈主表一条反馈一行status、priority、assignee_id、channel_ref主表年十万级fb_feedback_log状态流转日志只追加from_status、to_status、operator_id主表的 36 倍fb_attachment附件元数据文件落对象存储feedback_id、object_key、size主表的 12 倍fb_tag/fb_feedback_tag标签字典与多对多关联tag_id、feedback_id关联表最大fb_notify_outbox通知发件箱异步投递event_type、status、next_at与主表同量级主表建表脚本长这样注意索引不是随手加的CREATE TABLE fb_feedback ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 单团队部署固定为1多租户时做隔离键, source TINYINT NOT NULL DEFAULT 1 COMMENT 1站内 2邮件 3客服转交 4开放API, channel_ref VARCHAR(64) DEFAULT NULL COMMENT 外部渠道单号幂等去重用, title VARCHAR(120) NOT NULL, content MEDIUMTEXT NOT NULL, contact VARCHAR(120) DEFAULT NULL COMMENT 联系方式入库前脱敏, category_id INT UNSIGNED DEFAULT NULL COMMENT 一级分区固定枚举, priority TINYINT NOT NULL DEFAULT 2 COMMENT 1低 2中 3高 4紧急, status TINYINT NOT NULL DEFAULT 10, assignee_id BIGINT UNSIGNED DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, resolved_at DATETIME DEFAULT NULL, closed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_channel (source, channel_ref), KEY idx_status_created (status, created_at), KEY idx_assignee_status (assignee_id, status, created_at), KEY idx_category_created (category_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户反馈主表;三个索引对应三类高频查询按状态看队列、按处理人看我的待办、按分类看趋势。uk_channel是幂等键邮件和开放 API 两条入口重复投递时靠它挡这一点第 5 章还会展开。content用MEDIUMTEXT而不是TEXT是因为带截图说明和复现步骤的长反馈很容易破 64KB字符集必须是utf8mb4客户贴个表情符号进去utf8直接报Incorrect string value这个错我在三个项目里见过。2.3 状态跃迁在服务端兜底状态机不能只活在前端按钮的disabled上那玩意儿改个请求就绕过去了。Java 侧用一个枚举把跃迁表钉死public enum FeedbackStatus { PENDING(10, 待受理), ASSIGNED(20, 已分派), PROCESSING(30, 处理中), RESOLVED(40, 已解决), CLOSED(50, 已关闭), REJECTED(90, 已驳回); private final int code; private final String label; FeedbackStatus(int code, String label) { this.code code; this.label label; } public int code() { return code; } public String label() { return label; } // 合法跃迁表key 当前状态value 允许进入的下一批状态 private static final MapFeedbackStatus, SetFeedbackStatus TRANSITIONS Map.of( PENDING, EnumSet.of(ASSIGNED, PROCESSING, REJECTED), ASSIGNED, EnumSet.of(PROCESSING, REJECTED, PENDING), PROCESSING, EnumSet.of(RESOLVED, ASSIGNED, REJECTED), RESOLVED, EnumSet.of(CLOSED, PROCESSING), // 客户不认退回处理中 CLOSED, EnumSet.of(PROCESSING), // 允许重开但要走审批 REJECTED, EnumSet.of(PENDING) // 误判可以捞回来 ); public static boolean canTransit(FeedbackStatus from, FeedbackStatus to) { return from to || TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } public static FeedbackStatus of(int code) { for (FeedbackStatus s : values()) { if (s.code code) return s; } throw new IllegalArgumentException(未知状态码: code); } }TRANSITIONS这个 Map 是整个模块的规则中心产品经理要改流程只动这一处不用满代码找if (status 30)。from to放行是为了让只改处理人不改状态这类幂等更新能通过。服务层做校验时必须开事务并且加行锁否则两个人同时点关闭和重开日志会写成两条互相矛盾的记录Transactional(rollbackFor Exception.class) public void transit(Long feedbackId, FeedbackStatus target, Long operatorId, String remark) { // SELECT ... FOR UPDATE锁住这一行避免并发跃迁互相覆盖 Feedback row mapper.selectForUpdate(feedbackId); FeedbackStatus current FeedbackStatus.of(row.getStatus()); if (!FeedbackStatus.canTransit(current, target)) { throw new BizException(非法状态跃迁 current.label() - target.label()); } // 状态更新和日志写入必须在同一事务不然会出现状态变了但没记录 mapper.updateStatus(feedbackId, target.code(), operatorId, target FeedbackStatus.RESOLVED ? new Date() : null); logMapper.insert(FeedbackLog.of(feedbackId, current, target, operatorId, remark)); // 通知不在这里发只往发件箱塞一条见 4.4 outboxMapper.enqueue(feedbackId, STATUS_CHANGED, target.code()); }selectForUpdate走的是主键锁粒度是一行压力可控。真正要小心的是updateStatus里别顺手用UPDATE ... WHERE status #{current}做乐观锁——两种锁混用排查时会很痛苦选一种就行。3. 本地跑通从空库到第一条反馈入库的六步3.1 依赖清单和数据库初始化环境就三样JDK 17、MySQL 85.7 也能跑但递归 CTE 用不了标签树那节要改写法、Node 18。后端用 Spring Boot MyBatis前端 Vue 3 Vite Element Plus都是烂大街的组合好处是出了问题搜索引擎里全是答案。第一步建库建账号注意应用账号不要给 DDL 权限CREATE DATABASE IF NOT EXISTS feedback DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE USER fb_app% IDENTIFIED BY change_me_before_use; GRANT SELECT, INSERT, UPDATE, DELETE ON feedback.* TO fb_app%; FLUSH PRIVILEGES;GRANT只给 CRUD建表和改表走单独的迁移账号。这不是洁癖——曾经有个项目的应用账号带DROP一次误配的数据源指向把测试库的反馈表清空了回滚靠的是三天前的备份。3.2 配置文件里真正需要改的四处配置文件不用通读改四个地方就够跑起来spring: datasource: url: jdbc:mysql://127.0.0.1:3306/feedback?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghairewriteBatchedStatementstrue username: fb_app password: ${DB_PASSWORD:change_me_before_use} hikari: maximum-pool-size: 20 # 单机起步够用别一上来给 100 minimum-idle: 5 connection-timeout: 3000 servlet: multipart: max-file-size: 10MB # 和 nginx 的 client_max_body_size 必须对齐 max-request-size: 50MB feedback: sla: urgent-hours: 4 # 紧急 4 小时 high-hours: 8 normal-hours: 24 low-hours: 72 notify: outbox-batch-size: 100 outbox-max-retry: 5serverTimezoneAsia/Shanghai一定要写。不写的话统计看板上今天的反馈量会在每天早上八点前集体错位八小时这个坑非常隐蔽因为列表页看着完全正常只有按天聚合的图不对。rewriteBatchedStatementstrue是给日志批量插入提速的流转日志一天几千条开着能省不少往返。3.3 打包启动与冒烟验证后端打包启动# 跳过测试先跑通测试里有依赖本地 Redis 的用例 mvn -q clean package -DskipTests # 用 dev profile 启动日志级别会打到 DEBUG java -jar target/feedback-admin.jar --spring.profiles.activedev前端单独起npm ci # 用 ci 而不是 install锁版本 npm run dev # 默认 5173代理到 8080 已在 vite.config 里配好服务起来之后别急着开浏览器先用 curl 打一发把接口通不通和页面渲染对不对两个问题拆开curl -s -X POST http://127.0.0.1:8080/api/feedback \ -H Content-Type: application/json \ -H X-Tenant-Id: 1 \ -d { source: 4, channelRef: smoke-20240101-001, title: 导出按钮点了没反应, content: 浏览器 120 版本点导出后一直转圈控制台有 500, priority: 2, tagIds: [3, 7] }返回 200 并且带上id就算入口通了。然后去库里确认落库状态和默认值SELECT id, title, status, priority, created_at FROM fb_feedback ORDER BY id DESC LIMIT 1;status应该是 10created_at应该是当前时间。再拿同一个channelRef打一次同样的请求预期返回的是已存在而不是新增一行——这是幂等键在起作用第 5 章会讲它失效的几种情形。最后一步验证前端用刚返回的id打开详情页把状态从待受理改到已分派然后查日志表SELECT feedback_id, from_status, to_status, operator_id, created_at FROM fb_feedback_log WHERE feedback_id 1 ORDER BY id DESC;一条10 - 20的记录出现说明状态机和日志的事务是通的。这六步走完一条反馈的完整链路就闭环了后面再往上加标签、通知、看板都是增量。4. 分类与筛选标签树、动态 SQL 和叫醒工单的三道机制4.1 两级标签树别做无限层级标签体系最容易过度设计。我见过做成无限层级的上线三个月后标签树有 200 多个节点运营自己都找不到该打哪个最后筛选面板里一屏全是折叠项。这套系统定的是两级一级叫category固定枚举控制在 8 个以内功能缺陷、使用咨询、体验建议、性能问题、账号问题、计费问题、内容相关、其他二级叫tag自由增长一条反馈可以挂多个。category_id直接放主表上因为它是统计口径的锚点报表按它聚合不能被多对多稀释。标签走关联表只服务筛选和检索CREATE TABLE fb_tag ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL, parent_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 0 表示一级标签, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用停用不删历史数据要能查, PRIMARY KEY (id), UNIQUE KEY uk_name_parent (parent_id, name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE fb_feedback_tag ( feedback_id BIGINT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, PRIMARY KEY (feedback_id, tag_id), KEY idx_tag (tag_id, feedback_id) -- 反查这个标签下有哪些反馈 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_tag这个反向索引很关键。筛选标签崩溃的时候MySQL 要从标签找人没有它就得全表扫关联表。联合主键(feedback_id, tag_id)和这个反向索引刚好覆盖两个方向别偷懒只留一个。停用标签用status标记而不是物理删除否则历史反馈上的标签会凭空消失详情页看起来像是数据丢了。4.2 组合筛选靠动态 SQL别写一坨 OR筛选面板通常有 68 个条件时间范围、状态、优先级、处理人、分类、标签、关键词。用 MyBatis 的if拼条件是最省事的写法关键是别拼出WHERE 11 AND (a1 OR a IS NULL)这种自废索引的语句select idpageByFilter resultTypecom.example.feedback.dto.FeedbackRow SELECT f.id, f.title, f.priority, f.status, f.created_at, u.name AS assignee_name FROM fb_feedback f LEFT JOIN fb_user u ON u.id f.assignee_id where if testq.startAt ! nullAND f.created_at gt; #{q.startAt}/if if testq.endAt ! nullAND f.created_at lt; #{q.endAt}/if if testq.status ! nullAND f.status #{q.status}/if if testq.priority ! nullAND f.priority #{q.priority}/if if testq.assigneeId ! nullAND f.assignee_id #{q.assigneeId}/if !-- 标签用 EXISTS 而不是 JOIN避免一条反馈挂三个标签就被放大成三行 -- if testq.tagIds ! null and q.tagIds.size() 0 AND EXISTS ( SELECT 1 FROM fb_feedback_tag ft WHERE ft.feedback_id f.id AND ft.tag_id IN foreach collectionq.tagIds itemtid open( separator, close)#{tid}/foreach ) /if /where ORDER BY f.created_at DESC, f.id DESC LIMIT #{q.offset}, #{q.pageSize} /select三个细节值得掰开说。第一标签筛选用EXISTS不用JOIN用JOIN的话一条反馈挂三个匹配标签就会返回三行前端分页总数直接翻倍。第二ORDER BY后面必须带idcreated_at在同一秒内重复是常态排序不稳定会导致翻页时前后两页出现同一条。第三LIMIT的深翻页问题——offset到几万时性能会塌实务上限制最大翻页深度比如 200 页并引导用户加时间范围比上keyset分页省事得多。索引用哪个取决于用户从哪个入口进来从我的待办进来走(assignee_id, status, created_at)从全部反馈按状态进来走(status, created_at)。这两个都是等值前缀 范围后缀能吃到索引。但如果时间范围特别宽比如查全量优化器可能直接放弃索引去扫主表这时候加个FORCE INDEX或者干脆走异步导出更合适。4.3 优先级由规则算SLA 计时起点要统一priority不要让人手填人一多标准就散了。主流做法是规则打分来源权重付费客户 2、内部转交 1 关键词命中崩溃数据丢失无法登录 3这类词表可配 影响面同一channel_ref前缀下 24 小时内出现 5 条以上 2最后落档到 14。分数之外SLA 的计时起点必须全系统统一。这套系统用的是created_at提交即开始计时而不是assigned_at。用后者的话反馈在队列里躺三天没人分派SLA 反而显示零超时等于给系统装了个黑匣子报表好看但毫无意义。超时判断用 SQL 里的TIMESTAMPDIFF现场算不落冗余字段SELECT f.id, f.title, f.priority, TIMESTAMPDIFF(HOUR, f.created_at, NOW()) AS elapsed_hours FROM fb_feedback f WHERE f.status IN (10, 20, 30) AND TIMESTAMPDIFF(HOUR, f.created_at, NOW()) CASE f.priority WHEN 4 THEN 4 WHEN 3 THEN 8 WHEN 2 THEN 24 ELSE 72 END ORDER BY f.priority DESC, elapsed_hours DESC;阈值和feedback.sla.*配置保持一致别在代码里再写死一份改配置没生效这种事查起来很折磨。4.4 通知走发件箱别在事务里发 HTTP状态一变就同步调邮件接口是新手最容易翻车的地方SMTP 一超时整个事务回滚用户看到操作失败其实状态已经改了半截。正确姿势是发件箱模式——状态变更时只往fb_notify_outbox插一行独立线程扫表投递CREATE TABLE fb_notify_outbox ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, feedback_id BIGINT UNSIGNED NOT NULL, event_type VARCHAR(32) NOT NULL COMMENT STATUS_CHANGED / ASSIGNED / SLA_WARNING, payload JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发 1已发 2失败, retry_count TINYINT NOT NULL DEFAULT 0, next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_pick (status, next_at) -- 扫描只走这个索引 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;扫描线程每 10 秒捞一批status 0 AND next_at NOW()发失败就retry_count 1并把next_at按指数退避往后推1 分钟、5 分钟、15 分钟、1 小时、6 小时超过outbox-max-retry置为 2 并告警。这条链路最大的好处是通知失败不影响业务业务失败不留脏通知。5. 避坑排查上线后最容易翻车的五处5.1 列表翻页出现重复行现象反馈列表翻到第 3 页和第 2 页最后一条是同一单。原因ORDER BY created_at DESC没带唯一键。批量导入的反馈时间戳精确到秒同秒内十几条是常事MySQL 每次查询的排序结果不保证一致LIMIT截出来的窗口就会重叠。解决排序统一改成ORDER BY created_at DESC, id DESC。改完之后顺手检查所有分页查询包括导出。导出用的也是同一套排序就更稳。5.2 同一封邮件入库三次现象邮件渠道的反馈重复标题内容一模一样处理人还以为客户连发了三次。原因邮件拉取任务超时重试而入库逻辑只有INSERT没有幂等键。IMAP 拉取的超时重连特别频繁尤其网络抖动时。解决source channel_ref建唯一索引channel_ref存邮件 Message-ID插入改成INSERT ... ON DUPLICATE KEY UPDATE id id命中即视为已存在直接返回。注意别用INSERT IGNORE它会把其他约束错误一起吞掉出问题时什么线索都没有。5.3 状态改了日志表里没记录现象详情页显示已关闭但流转记录停在处理中时间线断了一截。原因两种可能。一是状态更新和日志写入不在同一事务日志插入失败被吞了二是某些接口比如批量操作直接UPDATE表绕过了服务层。解决所有状态变更收敛到transit()一个方法事务注解加rollbackFor Exception.class。再加一条兜底——每天跑一次对账 SQL找fb_feedback里updated_at变过但fb_feedback_log当天无记录的单子数量大于 0 就告警。5.4 中文关键词搜不到现象搜导出没有结果搜导出按钮反而能搜到。原因全文索引的分词器没配。MySQL 默认的全文索引按空格切词中文整段当一个词于是只有完全匹配才命中。另一种情况是字符集用了utf8四字节字符直接写不进去。解决建索引时指定WITH PARSER ngram并把ngram_token_size设成 2多数中文双字词够用字符集全链路统一utf8mb4。数据量不大的话其实用LIKE %关键词%配合限制时间范围更省事别为了一个搜索框硬上 Elasticsearch。5.5 附件上传报 413 或者后端读到空文件现象小图能传超过 1MB 就报错或者接口返回 200 但文件大小是 0。原因三处限制没对齐——nginx 的client_max_body_size、Spring 的max-file-size、以及前端上传组件的maxSize。任意一处小于实际文件表现都不一样413 是 nginx 挡的空文件多半是 Spring 的max-request-size截断。解决三处同时改成一个值比如 10MB并在接口层再校验一次大小返回明确错误码别让用户看到裸的 413 页面。6. 验收进阶用 SLA 达标率反推系统是否真的在跑系统跑起来不等于跑得对。判断一套客户反馈管理系统有没有真的在运转我只看一个指标SLA 达标率有没有随月份变化。数字长期纹丝不动比如永远 100% 或者永远 62%大概率是计时逻辑或状态流转被绕过了。下面这条 SQL 按月出数SELECT DATE_FORMAT(f.created_at, %Y-%m) AS ym, COUNT(*) AS total, -- 分子在 SLA 阈值内解决的阈值与 feedback.sla.* 配置保持一致 ROUND(100 * SUM( CASE WHEN f.resolved_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, f.created_at, f.resolved_at) CASE f.priority WHEN 4 THEN 4 WHEN 3 THEN 8 WHEN 2 THEN 24 ELSE 72 END THEN 1 ELSE 0 END ) / COUNT(*), 1) AS sla_rate, -- 重开率关闭后又被拉回处理中的比例反映关得快但没解决 ROUND(100 * SUM(CASE WHEN f.closed_at IS NOT NULL AND f.status 30 THEN 1 ELSE 0 END) / COUNT(*), 1) AS reopen_rate FROM fb_feedback f WHERE f.created_at 2024-01-01 AND f.status IN (40, 50) GROUP BY ym ORDER BY ym;sla_rate和reopen_rate要一起看。达标率高但重开率也高说明处理人在赶关单把已解决当成了甩锅按钮两个都低那是真堵住了去看队列积压和分派规则。只看达标率这一个数字很容易被自己骗。第二个进阶技巧是把关单理由做分布。在fb_feedback_log里给关闭动作带上remark按月按分类统计高频理由能直接定位到哪类问题在反复消耗人力。这部分用一条简单的GROUP BY就够不用上 BI 工具SELECT f.category_id, l.remark, COUNT(*) AS cnt FROM fb_feedback_log l JOIN fb_feedback f ON f.id l.feedback_id WHERE l.to_status 50 AND l.created_at DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY f.category_id, l.remark HAVING cnt 5 ORDER BY cnt DESC;HAVING cnt 5这个门槛是我后来加的之前不筛的时候结果里全是客户没再回复这种一次性理由噪声把真正的系统性问题淹了。门槛设多少看量级小团队 3 就够大一点的 10 更清爽。从那以后我每次交付这类系统上线前都强制走一遍三件事拿同一个channel_ref连打三次确认幂等生效、手工改一次状态确认日志表有记录、用中文关键词搜一次确认字符集没问题。这三件小事花不了十分钟但能挡住后面两周的返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?