简介:客户反馈管理系统是一套面向企业客服、产品运营与质量管理人员的反馈处理方案,围绕客户评价的收集、分类归档、分析报告、问题跟踪与满意度评分展开,帮助团队把分散的客户声音沉淀为可追踪、可量化的改进依据,也适合作为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 | 主表的 3~6 倍 |
fb_attachment | 附件元数据,文件落对象存储 | feedback_id、object_key、size | 主表的 1~2 倍 |
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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Map<FeedbackStatus, Set<FeedbackStatus>> 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 8(5.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true 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: 5serverTimezone=Asia/Shanghai一定要写。不写的话,统计看板上"今天的反馈量"会在每天早上八点前集体错位八小时,这个坑非常隐蔽,因为列表页看着完全正常,只有按天聚合的图不对。rewriteBatchedStatements=true是给日志批量插入提速的,流转日志一天几千条,开着能省不少往返。
3.3 打包启动与冒烟验证
后端打包启动:
# 跳过测试先跑通,测试里有依赖本地 Redis 的用例 mvn -q clean package -DskipTests # 用 dev profile 启动,日志级别会打到 DEBUG java -jar target/feedback-admin.jar --spring.profiles.active=dev前端单独起:
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应该是 10,created_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) -- 反查:这个标签下有哪些反馈 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_tag这个反向索引很关键。筛选"标签=崩溃"的时候,MySQL 要从标签找人,没有它就得全表扫关联表。联合主键(feedback_id, tag_id)和这个反向索引刚好覆盖两个方向,别偷懒只留一个。
停用标签用status标记而不是物理删除,否则历史反馈上的标签会凭空消失,详情页看起来像是数据丢了。
4.2 组合筛选靠动态 SQL,别写一坨 OR
筛选面板通常有 6~8 个条件:时间范围、状态、优先级、处理人、分类、标签、关键词。用 MyBatis 的<if>拼条件是最省事的写法,关键是别拼出WHERE 1=1 AND (a=1 OR a IS NULL)这种自废索引的语句:
<select id="pageByFilter" resultType="com.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 test="q.startAt != null">AND f.created_at >= #{q.startAt}</if> <if test="q.endAt != null">AND f.created_at < #{q.endAt}</if> <if test="q.status != null">AND f.status = #{q.status}</if> <if test="q.priority != null">AND f.priority = #{q.priority}</if> <if test="q.assigneeId != null">AND f.assignee_id = #{q.assigneeId}</if> <!-- 标签用 EXISTS 而不是 JOIN,避免一条反馈挂三个标签就被放大成三行 --> <if test="q.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 collection="q.tagIds" item="tid" 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后面必须带id,created_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),最后落档到 1~4。
分数之外,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) -- 扫描只走这个索引 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;扫描线程每 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连打三次确认幂等生效、手工改一次状态确认日志表有记录、用中文关键词搜一次确认字符集没问题。这三件小事花不了十分钟,但能挡住后面两周的返工。希望帮到你。
本文还有配套的精品资源,点击获取