fix(archive): 优化归档服务签名验证与AES工具初始化线程安全

- 修改归档回调控制器中AES解密工具的初始化方式,采用双重检查锁定保证线程安全
- 调整签名验证失败日志,避免打印敏感token信息,提升安全性
- 更新归档拉取服务调用worker命令时secret参数为占位符,改为通过环境变量传递真实secret
- 优化归档拉取循环中last_seq推进逻辑,仅更新内存记录,防止因落库失败导致消息丢失
- 修改数据库初始化脚本,调整媒体文件sdkfileid字段类型为TEXT,修正初始化话术状态为ACTIVE
This commit is contained in:
jqb 2026-08-13 09:39:24 +08:00
parent 7ba9c0837d
commit 7722c4e7e3
25 changed files with 438 additions and 443 deletions

View File

@ -1,7 +1,7 @@
-- 九艺AI坐席辅助系统 - 核心话术库初始化
-- 覆盖8个阶段、40个意图分类
INSERT INTO utterances (
INSERT IGNORE INTO utterances (
utterance_id, corp_id, title, content, content_text, status, priority,
stage_tags, intent_tags, profile_tags, course_type_tags, emotion_tags, topic_tags,
used_count, selected_count, success_rate, source, created_by
@ -10,7 +10,7 @@ INSERT INTO utterances (
('UTT-001-001', 'corp001', '标准开场白-首次咨询',
'您好!欢迎咨询第九联盟艺术教育,我是您的专属课程顾问{{顾问姓名}}。请问怎么称呼您呢?是想了解咱们哪方面的课程呀?',
'您好!欢迎咨询第九联盟艺术教育,我是您的专属课程顾问。请问怎么称呼您呢?是想了解咱们哪方面的课程呀?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-01"]',
'["INT-COURSE-01","INT-COURSE-02"]',
'["新客户","未分类"]',
@ -22,7 +22,7 @@ INSERT INTO utterances (
('UTT-001-002', 'corp001', '开场白-转介绍学员',
'您好!听说您是{{推荐人}}介绍来的,真是太好了!咱们第九联盟在{{课程方向}}方向口碑一直很好,我先给您简单介绍一下?',
'您好!听说您是朋友介绍来的,真是太好了!咱们第九联盟在课程方向口碑一直很好,我先给您简单介绍一下?',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-01"]',
'["INT-COURSE-01","INT-QUAL-02"]',
'["转介绍","高信任度"]',
@ -34,7 +34,7 @@ INSERT INTO utterances (
('UTT-001-003', 'corp001', '开场白-二次到访',
'{{学员姓名}}您好!上次咱们聊到{{上次话题}},这次过来是想进一步了解哪方面呢?',
'您好!上次咱们聊到课程话题,这次过来是想进一步了解哪方面呢?',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-01"]',
'["INT-COURSE-01","INT-COURSE-04"]',
'["回访客户","有初步意向"]',
@ -47,7 +47,7 @@ INSERT INTO utterances (
('UTT-002-001', 'corp001', '需求探询-零基础背景',
'完全没问题的!咱们{{课程名称}}就是专门为零基础学员设计的。先跟您确认几个信息:您现在是学生还是在职?每天能抽出多少时间学习?',
'完全没问题的!咱们课程就是专门为零基础学员设计的。先跟您确认几个信息:您现在是学生还是在职?每天能抽出多少时间学习?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-02"]',
'["INT-BASIC-03","INT-COURSE-07","INT-COURSE-08"]',
'["零基础","时间有限","转行意向"]',
@ -59,7 +59,7 @@ INSERT INTO utterances (
('UTT-002-002', 'corp001', '需求探询-职业提升',
'明白了,您是想通过系统学习来提升职业技能。方便问一下您目前从事什么行业吗?以及您希望学完后达到什么水平?这样我可以给您更精准的建议。',
'明白了,您是想通过系统学习来提升职业技能。方便问一下您目前从事什么行业吗?以及您希望学完后达到什么水平?这样我可以给您更精准的建议。',
'APPROVED', 8,
'ACTIVE', 4,
'["STAGE-02"]',
'["INT-JOB-01","INT-COURSE-02","INT-JOB-06"]',
'["在职提升","有明确目标","中级基础"]',
@ -71,7 +71,7 @@ INSERT INTO utterances (
('UTT-002-003', 'corp001', '需求探询-学生党',
'了解!作为学生您的时间相对灵活,这是个很大的优势。您现在大几?是本专业相关还是想跨专业学习?毕业后的打算是什么?',
'了解!作为学生您的时间相对灵活,这是个很大的优势。您现在大几?是本专业相关还是想跨专业学习?毕业后的打算是什么?',
'APPROVED', 8,
'ACTIVE', 4,
'["STAGE-02"]',
'["INT-BASIC-01","INT-JOB-01","INT-COURSE-02"]',
'["在校学生","时间充裕","职业规划期"]',
@ -84,7 +84,7 @@ INSERT INTO utterances (
('UTT-003-001', 'corp001', '课程推荐-原画方向',
'根据您的情况,我强烈推荐咱们的**原画概念设计班**。这个课程从素描基础到角色设计、场景设计全覆盖,6个月就能达到入行水平。咱们往期学员平均就业率是92%,起薪在8-12K。',
'根据您的情况,我强烈推荐咱们的原画概念设计班。这个课程从素描基础到角色设计、场景设计全覆盖,6个月就能达到入行水平。咱们往期学员平均就业率是92%,起薪在8-12K。',
'APPROVED', 10,
'ACTIVE', 2,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-01","INT-JOB-02"]',
'["零基础","转行意向","美术爱好者"]',
@ -96,7 +96,7 @@ INSERT INTO utterances (
('UTT-003-002', 'corp001', '课程推荐-3D建模方向',
'基于您的背景,**3D游戏建模班**非常适合您!课程涵盖ZBrush雕刻、Maya建模、次世代流程,4个月系统学习。咱们和腾讯、网易、米哈游都有合作关系,优秀学员可以直接内推。',
'基于您的背景,3D游戏建模班非常适合您!课程涵盖ZBrush雕刻、Maya建模、次世代流程,4个月系统学习。咱们和腾讯、网易、米哈游都有合作关系,优秀学员可以直接内推。',
'APPROVED', 10,
'ACTIVE', 2,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-05","INT-JOB-04"]',
'["有基础","技术导向","就业优先"]',
@ -108,7 +108,7 @@ INSERT INTO utterances (
('UTT-003-003', 'corp001', '课程推荐-影视后期方向',
'如果您对影视感兴趣,**影视后期特效班**是咱们王牌课程之一。学完后可以从事电影特效、广告包装、短视频制作等方向,就业面非常广。现在报名还能享受早鸟优惠!',
'如果您对影视感兴趣,影视后期特效班是咱们王牌课程之一。学完后可以从事电影特效、广告包装、短视频制作等方向,就业面非常广。现在报名还能享受早鸟优惠!',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-01"]',
'["兴趣驱动","创意型","影视爱好者"]',
@ -120,7 +120,7 @@ INSERT INTO utterances (
('UTT-003-004', 'corp001', '课程推荐-试听邀请',
'光听我介绍可能不够直观,建议您先来听一节**免费试听课**。我可以帮您预约明天下午3点的原画体验课,您看时间方便吗?',
'光听我介绍可能不够直观,建议您先来听一节免费试听课。我可以帮您预约明天下午3点的原画体验课,您看时间方便吗?',
'APPROVED', 9,
'ACTIVE', 1,
'["STAGE-03"]',
'["INT-COURSE-04","INT-COURSE-01"]',
'["犹豫中","需要体验","决策期"]',
@ -133,7 +133,7 @@ INSERT INTO utterances (
('UTT-004-001', 'corp001', '价值塑造-师资力量',
'咱们的讲师团队非常强大,主讲老师都是来自一线大厂的高级美术总监,比如原画班的李老师之前在网易任职8年,参与过《阴阳师》《第五人格》的角色设计。您学的不是理论,是实打实的行业经验!',
'咱们的讲师团队非常强大,主讲老师都是来自一线大厂的高级美术总监,比如原画班的李老师之前在网易任职8年,参与过知名游戏的角色设计。您学的不是理论,是实打实的行业经验!',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-04"]',
'["INT-TEACH-01","INT-QUAL-01","INT-TEACH-05"]',
'["注重师资","品质导向"]',
@ -145,7 +145,7 @@ INSERT INTO utterances (
('UTT-004-002', 'corp001', '价值塑造-教学模式',
'咱们采用**小班制+项目实战**的教学模式,每个班不超过20人,保证老师能照顾到每位学员。而且课程中会有4-5个真实商业项目贯穿,毕业时您已经有能直接放进作品集的项目经验了。',
'咱们采用小班制+项目实战的教学模式,每个班不超过20人,保证老师能照顾到每位学员。而且课程中会有4-5个真实商业项目贯穿,毕业时您已经有能直接放进作品集的项目经验了。',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-04"]',
'["INT-TEACH-02","INT-TEACH-04","INT-JOB-06"]',
'["注重效果","实践导向"]',
@ -157,7 +157,7 @@ INSERT INTO utterances (
('UTT-004-003', 'corp001', '价值塑造-学习保障',
'咱们有完善的**学习效果保障体系**:第一,每个阶段都有考核,没通过可以免费重修;第二,毕业后6个月内未就业,全额退费;第三,提供终身免费复训。您完全不用担心学不会或者学完后找不到工作。',
'咱们有完善的学习效果保障体系:第一,每个阶段都有考核,没通过可以免费重修;第二,毕业后6个月内未就业,全额退费;第三,提供终身免费复训。您完全不用担心学不会或者学完后找不到工作。',
'APPROVED', 10,
'ACTIVE', 3,
'["STAGE-04"]',
'["INT-TEACH-05","INT-PRICE-04","INT-QUAL-01"]',
'["风险厌恶","谨慎型","需要保障"]',
@ -170,7 +170,7 @@ INSERT INTO utterances (
('UTT-005-001', 'corp001', '报价沟通-透明报价',
'咱们的收费标准非常透明,{{课程名称}}全程6个月,总费用是{{价格}}元。这个费用包含了全部课程、教材、软件授权、项目指导,没有任何隐形收费。您可以和其他机构对比一下,咱们的性价比是非常高的。',
'咱们的收费标准非常透明,课程全程6个月,总费用是市场价。这个费用包含了全部课程、教材、软件授权、项目指导,没有任何隐形收费。您可以和其他机构对比一下,咱们的性价比是非常高的。',
'APPROVED', 8,
'ACTIVE', 2,
'["STAGE-05"]',
'["INT-PRICE-01","INT-PRICE-05"]',
'["价格敏感","理性对比"]',
@ -182,7 +182,7 @@ INSERT INTO utterances (
('UTT-005-002', 'corp001', '报价沟通-分期方案',
'如果一次性付款有压力,咱们支持**12期免息分期**,每个月只要还几百块,对您的生活完全没有影响。而且分期和全款享受的优惠是一样的。我可以帮您算一下具体的月供?',
'如果一次性付款有压力,咱们支持12期免息分期,每个月只要还几百块,对您的生活完全没有影响。而且分期和全款享受的优惠是一样的。我可以帮您算一下具体的月供?',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-05"]',
'["INT-PRICE-03","INT-PRICE-01"]',
'["预算有限","学生党","在职转行"]',
@ -194,7 +194,7 @@ INSERT INTO utterances (
('UTT-005-003', 'corp001', '报价沟通-优惠活动',
'您来得正好!这个月是咱们**周年庆活动**,报名可以享受三重优惠:第一,学费直减2000元;第二,赠送价值1500元的数位板;第三,赠送3节大师公开课。这个优惠到月底就结束了,建议您尽快决定!',
'您来得正好!这个月是咱们周年庆活动,报名可以享受三重优惠:第一,学费直减2000元;第二,赠送价值1500元的数位板;第三,赠送3节大师公开课。这个优惠到月底就结束了,建议您尽快决定!',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-05"]',
'["INT-PRICE-02","INT-PRICE-01"]',
'["价格敏感","犹豫中","等待优惠"]',
@ -207,7 +207,7 @@ INSERT INTO utterances (
('UTT-006-001', 'corp001', '异议处理-担心学不会',
'我完全理解您的担心!其实咱们80%的学员都是零基础开始的。关键是咱们有**一对一学习跟进**,每节课后助教都会检查您的作业,有问题随时答疑。而且如果阶段考核没通过,可以免费重修,直到学会为止。',
'我完全理解您的担心!其实咱们80%的学员都是零基础开始的。关键是咱们有一对一学习跟进,每节课后助教都会检查您的作业,有问题随时答疑。而且如果阶段考核没通过,可以免费重修,直到学会为止。',
'APPROVED', 10,
'ACTIVE', 1,
'["STAGE-06"]',
'["INT-BASIC-03","INT-TEACH-05","INT-TEACH-03"]',
'["零基础","自信心不足","风险厌恶"]',
@ -219,7 +219,7 @@ INSERT INTO utterances (
('UTT-006-002', 'corp001', '异议处理-担心找不到工作',
'这个您放心!咱们有专门的**就业服务团队**,从简历优化、作品集整理到模拟面试、企业内推,全程陪伴。而且咱们和200多家游戏、影视公司有人才合作,往期学员就业率是92%,平均薪资8-12K。',
'这个您放心!咱们有专门的就业服务团队,从简历优化、作品集整理到模拟面试、企业内推,全程陪伴。而且咱们和200多家游戏、影视公司有人才合作,往期学员就业率是92%,平均薪资8-12K。',
'APPROVED', 10,
'ACTIVE', 1,
'["STAGE-06"]',
'["INT-JOB-03","INT-JOB-04","INT-JOB-05"]',
'["就业导向","谨慎型","需要数据支撑"]',
@ -231,7 +231,7 @@ INSERT INTO utterances (
('UTT-006-003', 'corp001', '异议处理-要和家人商量',
'当然应该和家人商量!这是大事嘛。我帮您准备一份**详细的课程资料包**,包括课程大纲、师资介绍、就业案例、学费明细,您拿回去给家人看。如果需要,我也可以和您家人直接沟通,解答他们的疑问。',
'当然应该和家人商量!这是大事嘛。我帮您准备一份详细的课程资料包,包括课程大纲、师资介绍、就业案例、学费明细,您拿回去给家人看。如果需要,我也可以和您家人直接沟通,解答他们的疑问。',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-06"]',
'["INT-OTHER-04","INT-QUAL-02"]',
'["决策依赖","家庭决策","犹豫中"]',
@ -243,7 +243,7 @@ INSERT INTO utterances (
('UTT-006-004', 'corp001', '异议处理-想再比较比较',
'完全理解,货比三家是应该的!我建议您从这几个维度去对比:**师资背景**(是否一线大厂出身)、**项目实战**(是否有真实商业项目)、**就业数据**(是否有真实就业案例)、**后续服务**(是否有就业保障)。这几点恰恰是咱们最强的优势。',
'完全理解,货比三家是应该的!我建议您从这几个维度去对比:师资背景(是否一线大厂出身)、项目实战(是否有真实商业项目)、就业数据(是否有真实就业案例)、后续服务(是否有就业保障)。这几点恰恰是咱们最强的优势。',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-06"]',
'["INT-QUAL-02","INT-PRICE-05","INT-QUAL-01"]',
'["理性对比","谨慎型","多家咨询"]',
@ -256,7 +256,7 @@ INSERT INTO utterances (
('UTT-007-001', 'corp001', '促成报名-限时优惠',
'{{学员姓名}},我理解您还需要考虑,但这个**周年庆优惠今天就是最后一天**了。现在报名立减2000,还送数位板。错过这个活动,同样的课程要多花3500块。我先帮您预留一个名额,您今天内确认就行,好吗?',
'我理解您还需要考虑,但这个周年庆优惠今天就是最后一天了。现在报名立减2000,还送数位板。错过这个活动,同样的课程要多花3500块。我先帮您预留一个名额,您今天内确认就行,好吗?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-07"]',
'["INT-PRICE-02","INT-COURSE-04"]',
'["犹豫中","价格敏感","决策期"]',
@ -268,7 +268,7 @@ INSERT INTO utterances (
('UTT-007-002', 'corp001', '促成报名-从众心理',
'这个月已经有12个学员报名了原画班,其中3个是和您情况很像的零基础转行者。他们上周已经开始上课了,反馈都特别好。您要是这周报名,还能赶上同一期,大家起点一样,学习氛围会更好。',
'这个月已经有12个学员报名了原画班,其中3个是和您情况很像的零基础转行者。他们上周已经开始上课了,反馈都特别好。您要是这周报名,还能赶上同一期,大家起点一样,学习氛围会更好。',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-07"]',
'["INT-COURSE-04","INT-QUAL-02"]',
'["社交型","从众心理","决策期"]',
@ -280,7 +280,7 @@ INSERT INTO utterances (
('UTT-007-003', 'corp001', '促成报名-降低门槛',
'这样吧,您先交**500元定金**锁定今天的优惠和名额,这500元抵1000元学费。给您3天时间考虑,如果最终决定不报名,定金全额退还。这样您既没有损失,又能保留优惠,您看行吗?',
'这样吧,您先交500元定金锁定今天的优惠和名额,这500元抵1000元学费。给您3天时间考虑,如果最终决定不报名,定金全额退还。这样您既没有损失,又能保留优惠,您看行吗?',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-07"]',
'["INT-PRICE-03","INT-PRICE-04"]',
'["谨慎型","风险厌恶","犹豫中"]',
@ -293,7 +293,7 @@ INSERT INTO utterances (
('UTT-008-001', 'corp001', '跟进维护-未成交回访',
'{{学员姓名}}您好!上次咱们聊完后,您考虑得怎么样了?我这边又整理了几个和您背景相似的学员案例,以及最新的就业数据,发您微信了,您有空看看。有任何问题随时找我!',
'您好!上次咱们聊完后,您考虑得怎么样了?我这边又整理了几个和您背景相似的学员案例,以及最新的就业数据,发您微信了,您有空看看。有任何问题随时找我!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-QUAL-02","INT-JOB-03"]',
'["未成交","犹豫中","需要推动"]',
@ -305,7 +305,7 @@ INSERT INTO utterances (
('UTT-008-002', 'corp001', '跟进维护-已报名关怀',
'{{学员姓名}},上课一周了,感觉怎么样?有没有哪里跟不上的?咱们助教老师每周都会做学习反馈,如果您有任何问题,不管是学习上的还是生活里的,都可以随时找我。希望您在咱们这儿学习愉快!',
'上课一周了,感觉怎么样?有没有哪里跟不上的?咱们助教老师每周都会做学习反馈,如果您有任何问题,不管是学习上的还是生活里的,都可以随时找我。希望您在咱们这儿学习愉快!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-TEACH-03","INT-TEACH-05"]',
'["已报名","学习期","需要关怀"]',
@ -317,7 +317,7 @@ INSERT INTO utterances (
('UTT-008-003', 'corp001', '跟进维护-新活动通知',
'{{学员姓名}},告诉您个好消息!咱们下周六有个**行业大咖分享会**,邀请到了网易美术总监来讲《游戏原画的职业发展路径》。您要是有兴趣,我可以给您预留个名额,免费的!',
'告诉您个好消息!咱们下周六有个行业大咖分享会,邀请到了网易美术总监来讲游戏原画的职业发展路径。您要是有兴趣,我可以给您预留个名额,免费的!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-OTHER-01","INT-JOB-01","INT-TEACH-01"]',
'["潜在学员","兴趣驱动","活动爱好者"]',

View File

@ -44,17 +44,24 @@ public class ArchiveCallbackController {
@Value("${wecom.archive.callback-aeskey:}")
private String callbackAesKey;
private WeComCryptUtil cryptUtil;
private volatile WeComCryptUtil cryptUtil;
private WeComCryptUtil getCryptUtil() {
if (cryptUtil == null && callbackAesKey != null && !callbackAesKey.isEmpty()) {
try {
cryptUtil = new WeComCryptUtil(callbackAesKey);
} catch (Exception e) {
log.error("初始化AES解密工具失败: {}", e.getMessage());
WeComCryptUtil local = cryptUtil;
if (local == null && callbackAesKey != null && !callbackAesKey.isEmpty()) {
synchronized (this) {
local = cryptUtil;
if (local == null) {
try {
local = new WeComCryptUtil(callbackAesKey);
cryptUtil = local;
} catch (Exception e) {
log.error("初始化AES解密工具失败: {}", e.getMessage());
}
}
}
}
return cryptUtil;
return local;
}
/**
@ -174,7 +181,8 @@ public class ArchiveCallbackController {
String computed = hex.toString();
boolean match = computed.equals(signature);
if (!match) {
log.error("签名验证失败: token={}, sortResult={}, computed={}, expected={}", callbackToken, content.toString(), computed, signature);
// 不打印 token 及包含 token 的排序串,避免敏感信息泄露到日志
log.error("签名验证失败: computed={}, expected={}, tokenLength={}", computed, signature, callbackToken.length());
}
return match;
} catch (Exception e) {

View File

@ -74,6 +74,9 @@ public class ArchivePullService {
private static final String LAST_SEQ_KEY = "archive:last_seq:";
/** worker 命令行中 secret 占位符,真实 secret 通过环境变量 WECOM_ARCHIVE_SECRET 传递 */
private static final String SECRET_PLACEHOLDER = "-";
@PostConstruct
public void init() {
// 从文件加载RSA私钥(当环境变量未设置时)
@ -117,7 +120,7 @@ public class ArchivePullService {
int safeLimit = Math.min(limit, 100);
for (int loop = 0; loop < maxLoops; loop++) {
String jsonData = callWorker("pull", corpId, secret, String.valueOf(currentSeq), String.valueOf(safeLimit), sdkPath);
String jsonData = callWorker("pull", corpId, SECRET_PLACEHOLDER, String.valueOf(currentSeq), String.valueOf(safeLimit), sdkPath);
if (jsonData == null) {
log.warn("Worker拉取返回null, seq={}", currentSeq);
break;
@ -156,9 +159,9 @@ public class ArchivePullService {
}
}
// 更新 last_seq
// 仅推进内存中的 currentSeq 用于继续循环拉取;
// 不在此处写 Redis last_seq,避免消息尚未落库就推进 seq 导致落库失败时丢消息(由 saveAndNotify 在落库成功后推进)
if (maxSeq > currentSeq) {
redisTemplate.opsForValue().set(LAST_SEQ_KEY + corpId, maxSeq, 7, TimeUnit.DAYS);
currentSeq = maxSeq;
}
@ -187,7 +190,7 @@ public class ArchivePullService {
return null;
}
String decryptedMsg = callWorker("decrypt", corpId, secret, sdkPath, encryptKey, encryptChatMsg);
String decryptedMsg = callWorker("decrypt", corpId, SECRET_PLACEHOLDER, sdkPath, encryptKey, encryptChatMsg);
if (decryptedMsg == null) {
log.error("解密消息内容失败(worker崩溃或超时), seq={}", msgSeq);
return null;
@ -229,7 +232,7 @@ public class ArchivePullService {
}
String json = JsonUtils.toJson(inputs);
String result = callWorkerWithStdin(new String[]{"batchdecrypt", corpId, secret, sdkPath}, json);
String result = callWorkerWithStdin(new String[]{"batchdecrypt", corpId, SECRET_PLACEHOLDER, sdkPath}, json);
if (result == null) {
log.error("批量解密返回null, count={}", inputs.size());
return Collections.emptyList();
@ -355,6 +358,7 @@ public class ArchivePullService {
}
private String callWorkerWithStdin(String[] workerArgs, String stdinData) {
// secret 不再经命令行参数传递(ps 可见),改为环境变量传递给 worker 子进程
List<String> cmd = new ArrayList<>();
cmd.add("java");
cmd.add("-Djava.library.path=" + sdkPath);
@ -368,6 +372,9 @@ public class ArchivePullService {
try {
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
if (secret != null && !secret.isEmpty()) {
pb.environment().put("WECOM_ARCHIVE_SECRET", secret);
}
process = pb.start();
// 如果有 stdin 数据,写入子进程标准输入
@ -442,8 +449,15 @@ public class ArchivePullService {
}
String fromUser = cleanUserId(message.getFromUser());
String toUser = cleanUserId(message.getToUser());
// fromUser/toUser 缺失时用 msgid 兜底,避免大量消息拼出 corpId_null_null 归入同一会话造成串扰
if (fromUser == null || fromUser.isEmpty() || toUser == null || toUser.isEmpty()) {
if (message.getMsgid() != null && !message.getMsgid().isEmpty()) {
return corpId + "_msg_" + message.getMsgid();
}
return corpId + "_msg_" + message.getSeq();
}
// 按字典序排序,确保双向聊天的 sessionId 一致
if (fromUser != null && toUser != null && fromUser.compareTo(toUser) > 0) {
if (fromUser.compareTo(toUser) > 0) {
String temp = fromUser;
fromUser = toUser;
toUser = temp;
@ -483,21 +497,36 @@ public class ArchivePullService {
public void saveAndNotify(List<ArchiveMessage> messages, boolean immediateCosUpload) {
log.info("开始保存消息, 共{}条, immediateCosUpload={}", messages.size(), immediateCosUpload);
int saved = 0;
int updated = 0;
int cosUploaded = 0;
// 落库成功后才推进 Redis last_seq,且只向前推进不回退(避免历史修复等低 seq 消息把位点拉回去)
long lastSeqInRedis = 0L;
try {
Object current = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId);
if (current != null) {
lastSeqInRedis = ((Number) current).longValue();
}
} catch (Exception e) {
log.warn("读取当前 last_seq 失败,从0开始跟踪: {}", e.getMessage());
}
for (ArchiveMessage message : messages) {
try {
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
if (count > 0) {
log.debug("消息已存在,更新内容: msgid={}", message.getMsgid());
archiveMessageMapper.updateByMsgId(message);
updated++;
// 补充风控检测(历史消息修复时敏感词库可能已更新)
archiveRiskService.processMessageRisk(message);
// 更新 seq 推进进度
redisTemplate.opsForValue().set(
LAST_SEQ_KEY + corpId,
message.getSeq(),
7, TimeUnit.DAYS
);
// 落库成功后推进 seq
if (message.getSeq() != null && message.getSeq() > lastSeqInRedis) {
lastSeqInRedis = message.getSeq();
redisTemplate.opsForValue().set(
LAST_SEQ_KEY + corpId,
lastSeqInRedis,
7, TimeUnit.DAYS
);
}
continue;
}
@ -531,17 +560,27 @@ public class ArchivePullService {
JsonUtils.toJson(message)
);
redisTemplate.opsForValue().set(
LAST_SEQ_KEY + corpId,
message.getSeq(),
7, TimeUnit.DAYS
);
// 消息成功落库后才推进 last_seq,避免提前推进导致丢消息
if (message.getSeq() != null && message.getSeq() > lastSeqInRedis) {
lastSeqInRedis = message.getSeq();
redisTemplate.opsForValue().set(
LAST_SEQ_KEY + corpId,
lastSeqInRedis,
7, TimeUnit.DAYS
);
}
} catch (Exception e) {
log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage(), e);
}
}
log.info("保存消息完成, 成功{}条/共{}条, 预上传COS{}条", saved, messages.size(), cosUploaded);
// 毒批次告警:本批拉到数据但全部落库失败,last_seq 不会前进会被反复重拉,提示运维排查
if (!messages.isEmpty() && saved == 0 && updated == 0) {
long minSeq = messages.stream().map(ArchiveMessage::getSeq).filter(java.util.Objects::nonNull).mapToLong(Long::longValue).min().orElse(-1L);
long maxSeq = messages.stream().map(ArchiveMessage::getSeq).filter(java.util.Objects::nonNull).mapToLong(Long::longValue).max().orElse(-1L);
log.warn("毒批次告警: 本批{}条消息全部落库失败, seq范围=[{}, {}], last_seq 未前进将被反复重拉,请排查", messages.size(), minSeq, maxSeq);
}
}
public long getLastSeq() {
@ -574,7 +613,7 @@ public class ArchivePullService {
log.warn("企微存档配置不完整,无法下载媒体");
return false;
}
String result = callWorker("media", corpId, secret, sdkPath, sdkfileid, outputPath);
String result = callWorker("media", corpId, SECRET_PLACEHOLDER, sdkPath, sdkfileid, outputPath);
if (result == null) {
log.error("下载媒体失败(worker超时或崩溃): sdkfileid={}", sdkfileid);
return false;
@ -641,11 +680,7 @@ public class ArchivePullService {
archiveContactService.processMessage(message);
}
archiveRiskService.processMessageRisk(message);
redisTemplate.opsForValue().set(
LAST_SEQ_KEY + corpId,
message.getSeq(),
7, TimeUnit.DAYS
);
// 修复任务从历史低 seq 拉取,不写 Redis last_seq,避免把增量拉取位点回退(与注释语义保持一致)
} catch (Exception e) {
log.error("修复单条消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage());
}

View File

@ -9,12 +9,31 @@ import com.tencent.wework.Finance;
* pull - 拉取一批加密消息元数据(安全,不涉及单条消息解析)
* decrypt - 解密单条消息内容(隔离崩溃风险)
*
* 用法:
* java -jar app.jar worker pull <corpId> <secret> <seq> <limit> <sdkPath>
* java -jar app.jar worker decrypt <corpId> <secret> <sdkPath> <encryptKey> <encryptMsg>
* 用法(secret 优先从环境变量 WECOM_ARCHIVE_SECRET 读取,避免经命令行参数泄露;参数位传入 "-" 占位):
* java -jar app.jar worker pull <corpId> - <seq> <limit> <sdkPath>
* java -jar app.jar worker decrypt <corpId> - <sdkPath> <encryptKey> <encryptMsg>
*/
public class SdkWorker {
/** secret 环境变量名 */
private static final String SECRET_ENV_KEY = "WECOM_ARCHIVE_SECRET";
/**
* 解析 secret:优先环境变量,其次命令行参数(兼容旧调用方式)
*/
private static String resolveSecret(String argSecret) {
String envSecret = System.getenv(SECRET_ENV_KEY);
if (envSecret != null && !envSecret.isEmpty()) {
return envSecret;
}
if (argSecret != null && !argSecret.isEmpty() && !"-".equals(argSecret)) {
return argSecret;
}
System.err.println("ERROR: secret not provided via env " + SECRET_ENV_KEY + " or args");
System.exit(1);
return null;
}
public static void run(String[] args) throws Exception {
if (args.length < 1) {
System.err.println("Usage: worker <pull|decrypt|batchdecrypt|media> ...");
@ -41,7 +60,7 @@ public class SdkWorker {
System.exit(1);
}
String corpId = args[1];
String secret = args[2];
String secret = resolveSecret(args[2]);
long seq = Long.parseLong(args[3]);
int limit = Integer.parseInt(args[4]);
String sdkPath = args[5];
@ -69,7 +88,7 @@ public class SdkWorker {
System.exit(1);
}
String corpId = args[1];
String secret = args[2];
String secret = resolveSecret(args[2]);
String sdkPath = args[3];
String encryptKey = args[4];
String encryptMsg = args[5];
@ -97,7 +116,7 @@ public class SdkWorker {
System.exit(1);
}
String corpId = args[1];
String secret = args[2];
String secret = resolveSecret(args[2]);
String sdkPath = args[3];
// 从 stdin 读取 JSON 输入,避免命令行参数过长(error=7 Argument list too long)
@ -158,7 +177,7 @@ public class SdkWorker {
System.exit(1);
}
String corpId = args[1];
String secret = args[2];
String secret = resolveSecret(args[2]);
String sdkPath = args[3];
String sdkfileid = args[4];
String outputPath = args[5];

View File

@ -7,6 +7,8 @@ import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@ -56,11 +58,15 @@ public class AuthController {
}
@PostMapping("/refresh")
public Result<Map<String, Object>> refreshToken(@RequestHeader("Authorization") String authHeader) {
String oldToken = authHeader.replace("Bearer ", "");
public ResponseEntity<Result<Map<String, Object>>> refreshToken(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String oldToken = extractBearerToken(authHeader);
if (oldToken == null || oldToken.isEmpty()) {
return unauthorized("缺少或非法的 Authorization 头");
}
if (jwtService.isTokenExpired(oldToken)) {
return Result.fail("Token已过期,请重新登录");
return ResponseEntity.ok(Result.fail("Token已过期,请重新登录"));
}
io.jsonwebtoken.Claims claims = jwtService.validateToken(oldToken);
@ -76,12 +82,16 @@ public class AuthController {
result.put("tokenType", "Bearer");
result.put("expiresIn", 86400);
return Result.success(result);
return ResponseEntity.ok(Result.success(result));
}
@GetMapping("/info")
public Result<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
public ResponseEntity<Result<Map<String, Object>>> getUserInfo(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String token = extractBearerToken(authHeader);
if (token == null || token.isEmpty()) {
return unauthorized("缺少或非法的 Authorization 头");
}
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
Map<String, Object> result = new HashMap<>();
@ -90,7 +100,27 @@ public class AuthController {
result.put("corpId", claims.get("corpId"));
result.put("role", claims.get("role"));
return Result.success(result);
return ResponseEntity.ok(Result.success(result));
}
/**
* 大小写不敏感地解析 Bearer token,非法格式返回 null
*/
private String extractBearerToken(String authHeader) {
if (authHeader == null) {
return null;
}
String trimmed = authHeader.trim();
String prefix = "Bearer ";
if (trimmed.length() > prefix.length()
&& trimmed.regionMatches(true, 0, prefix, 0, prefix.length())) {
return trimmed.substring(prefix.length()).trim();
}
return null;
}
private <T> ResponseEntity<Result<T>> unauthorized(String message) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Result.fail(401, message));
}
@Value("${wecom.corp-id}")

View File

@ -12,7 +12,9 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -48,24 +50,41 @@ public class WeComOAuthService {
private static final String USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token={accessToken}&code={code}";
private static final String USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={accessToken}&userid={userId}";
/** AccessToken 刷新锁,避免并发下重复刷新企微 token */
private final Object accessTokenLock = new Object();
public String getAccessToken() {
String token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
if (token != null) {
return token;
}
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
synchronized (accessTokenLock) {
// 双重检查:并发请求中只有一个线程真正刷新,其余等锁后直接读缓存
token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
if (token != null) {
return token;
}
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
log.error("获取AccessToken失败: {}", response.getBody());
throw new BusinessException("获取企微AccessToken失败");
String url = UriComponentsBuilder.fromHttpUrl("https://qyapi.weixin.qq.com/cgi-bin/gettoken")
.queryParam("corpid", corpId)
.queryParam("corpsecret", secret)
.build()
.encode()
.toUriString();
// 使用 URI 重载,避免 RestTemplate 对已编码 URL 二次编码
ResponseEntity<String> response = restTemplate.getForEntity(URI.create(url), String.class);
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
log.error("获取AccessToken失败: {}", response.getBody());
throw new BusinessException("获取企微AccessToken失败");
}
token = (String) result.get("access_token");
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
return token;
}
token = (String) result.get("access_token");
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
return token;
}
public String getJsApiTicket() {

View File

@ -2,8 +2,13 @@ package com.artedu.common.exception;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -72,6 +77,40 @@ public class GlobalExceptionHandler {
return Result.fail(400, "参数不能为空: " + e.getParameterName());
}
/**
* 处理缺少请求头:缺 Authorization 返回真实 HTTP 401(前端依赖其跳登录),其他请求头返回 HTTP 400
*/
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<Result<Void>> handleMissingHeader(MissingRequestHeaderException e) {
log.warn("缺少请求头: {}", e.getMessage());
if ("Authorization".equalsIgnoreCase(e.getHeaderName())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(401, "未登录或缺少 Authorization 请求头"));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(400, "缺少请求头: " + e.getHeaderName()));
}
/**
* 处理请求方法不支持(返回真实 HTTP 405)
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Result<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("请求方法不支持: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(Result.fail(405, "请求方法不支持: " + e.getMethod()));
}
/**
* 处理请求体不可读(JSON 格式错误/缺失等,返回真实 HTTP 400)
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Result<Void>> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
log.warn("请求体不可读: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(400, "请求体格式错误,请检查 JSON 格式"));
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常: {}", e.getMessage(), e);

View File

@ -0,0 +1,49 @@
package com.artedu.common.util;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 工厂
* 统一为各 LLM/HTTP 客户端提供带超时控制的 RestTemplate,
* 避免 new RestTemplate() 默认无超时导致线程被慢调用无限挂起。
*/
public final class RestTemplateFactory {
/** 默认连接超时:5 秒 */
private static final int DEFAULT_CONNECT_TIMEOUT = 5_000;
/** 默认读取超时:60 秒(普通同步 LLM/HTTP 调用) */
private static final int DEFAULT_READ_TIMEOUT = 60_000;
/** 流式/长耗时调用读取超时:300 秒 */
private static final int STREAMING_READ_TIMEOUT = 300_000;
private RestTemplateFactory() {
}
/**
* 创建标准 RestTemplate:connectTimeout=5s, readTimeout=60s
*/
public static RestTemplate create() {
return create(DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT);
}
/**
* 创建流式/长耗时调用专用 RestTemplate:connectTimeout=5s, readTimeout=300s
*/
public static RestTemplate createStreaming() {
return create(DEFAULT_CONNECT_TIMEOUT, STREAMING_READ_TIMEOUT);
}
/**
* 按指定超时创建 RestTemplate
*
* @param connectTimeoutMillis 连接超时(毫秒)
* @param readTimeoutMillis 读取超时(毫秒)
*/
public static RestTemplate create(int connectTimeoutMillis, int readTimeoutMillis) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(connectTimeoutMillis);
factory.setReadTimeout(readTimeoutMillis);
return new RestTemplate(factory);
}
}

View File

@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@ -76,8 +77,20 @@ public class ConversationManager {
conv.setStageConfidence(0.0);
conv.setRoundCount(0);
conv.setStartTime(LocalDateTime.now());
conversationMapper.insert(conv);
log.info("创建新会话: sessionId={}", sessionId);
try {
conversationMapper.insert(conv);
log.info("创建新会话: sessionId={}", sessionId);
} catch (DuplicateKeyException e) {
// 并发下另一线程已创建同一会话,重新查询并继续使用,保证消息不丢
log.warn("会话已被并发创建,重新查询: sessionId={}", sessionId);
conv = conversationMapper.selectOne(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getSessionId, sessionId)
);
if (conv == null) {
throw new IllegalStateException("并发创建后仍未查到会话: " + sessionId, e);
}
}
}
return conv;
@ -216,7 +229,8 @@ public class ConversationManager {
}
} catch (Exception e) {
log.error("处理新消息事件失败: {}", e.getMessage(), e);
// 不抛回 MQ(避免无限重投),但完整记录消息内容便于事后补偿
log.error("处理新消息事件失败: error={}, messageJson={}", e.getMessage(), messageJson, e);
}
}

View File

@ -1,6 +1,7 @@
package com.artedu.generation.client;
import com.artedu.common.util.JsonUtils;
import com.artedu.common.util.RestTemplateFactory;
import com.artedu.generation.entity.LlmModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
@ -20,7 +21,8 @@ import java.util.Map;
public class BailianClient implements LLMClient {
private LlmModel model;
private final RestTemplate restTemplate = new RestTemplate();
// LLM 生成调用可能耗时较长,使用 300s 读超时,避免慢调用被 60s 超时误杀
private final RestTemplate restTemplate = RestTemplateFactory.createStreaming();
public BailianClient(LlmModel model) {
this.model = model;

View File

@ -1,6 +1,7 @@
package com.artedu.generation.client;
import com.artedu.common.util.JsonUtils;
import com.artedu.common.util.RestTemplateFactory;
import com.artedu.generation.entity.LlmModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
@ -20,7 +21,8 @@ import java.util.Map;
public class DoubaoClient implements LLMClient {
private LlmModel model;
private final RestTemplate restTemplate = new RestTemplate();
// LLM 生成调用可能耗时较长,使用 300s 读超时,避免慢调用被 60s 超时误杀
private final RestTemplate restTemplate = RestTemplateFactory.createStreaming();
public DoubaoClient(LlmModel model) {
this.model = model;

View File

@ -1,5 +1,6 @@
package com.artedu.generation.client;
import com.artedu.common.util.RestTemplateFactory;
import com.artedu.generation.entity.LlmModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
@ -19,7 +20,8 @@ import java.util.Map;
public class HunyuanClient implements LLMClient {
private LlmModel model;
private final RestTemplate restTemplate = new RestTemplate();
// LLM 生成调用可能耗时较长,使用 300s 读超时,避免慢调用被 60s 超时误杀
private final RestTemplate restTemplate = RestTemplateFactory.createStreaming();
public HunyuanClient(LlmModel model) {
this.model = model;

View File

@ -138,7 +138,15 @@ public class GenerationController {
@RequestParam(value = "roomid", required = false) String roomid,
@RequestParam(value = "staffName", required = false) String staffName) {
// 入口校验:fromUser/toUser/roomid 三者全缺时直接返回 400,避免后续 NPE
if (StringUtils.isBlank(fromUser) && StringUtils.isBlank(toUser) && StringUtils.isBlank(roomid)) {
return Result.fail(400, "fromUser、toUser、roomid 不能全部为空,请至少提供一个会话标识");
}
List<ArchiveMessage> messages = buildChatContext(fromUser, toUser, roomid);
if (messages == null) {
messages = new ArrayList<>();
}
// 遍历消息:只取前三条是客户的消息,组装 查询知识库
String external_str = ""; //原始问题
// 历史消息上下文
@ -249,7 +257,7 @@ public class GenerationController {
} else {
if (fromUser == null || fromUser.isEmpty()) {
log.warn("单聊模式需要fromUser参数");
return null;
return new ArrayList<>();
}
// toUser 为空或无效时,用兜底查询(根据 fromUser 查所有相关记录)
if (toUser == null || toUser.isEmpty() || "staff_001".equals(toUser)) {
@ -270,15 +278,15 @@ public class GenerationController {
}
private long getTodayStartTime() {
return java.time.LocalDate.now().atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli();
return LocalDate.now(SHANGHAI_ZONE).atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli();
}
private long getTodayEndTime() {
return java.time.LocalDate.now().plusDays(1).atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli() - 1;
return LocalDate.now(SHANGHAI_ZONE).plusDays(1).atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli() - 1;
}
private long get7DaysAgoStartTime() {
return java.time.LocalDate.now().minusDays(7).atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli();
return LocalDate.now(SHANGHAI_ZONE).minusDays(7).atStartOfDay(SHANGHAI_ZONE).toInstant().toEpochMilli();
}
/**
@ -303,6 +311,9 @@ public class GenerationController {
emitter.send(SseEmitter.event().name("start").data(startEvent));
List<ArchiveMessage> messages = buildChatContext(fromUser, toUser, roomid);
if (messages == null) {
messages = new ArrayList<>();
}
// 遍历消息:只取前三条是客户的消息,组装 查询知识库
StringBuilder externalStrBuilder = new StringBuilder(); //原始问题
// 历史消息上下文
@ -451,6 +462,10 @@ public class GenerationController {
for (String li : list) {
String promptNew = promptEngine.buildAnswerPrompt(li);
String res = qianwenClient.generate(promptNew, 800);
// LLM 返回 null/空白时跳过,避免拼出字符串 "null"
if (StringUtils.isBlank(res)) {
continue;
}
generated = generated + res + "|||";
}
if (StringUtils.isNotBlank(generated)) {
@ -459,7 +474,11 @@ public class GenerationController {
for (int i = 0; i < 3 - parts.length; i++) {
String systemPrompt = promptEngine.buildPrompt(chatContext, staffName, externalStr);
String generatedNew = qianwenClient.generate(systemPrompt, "", 1200);
generated = generated + generatedNew;
if (StringUtils.isBlank(generatedNew)) {
continue;
}
// 补齐时同样追加 "|||" 分隔符,保证 parseMultiStyle 能正确切分
generated = generated + generatedNew + "|||";
}
}
}

View File

@ -1,10 +1,12 @@
package com.artedu.generation.service;
import com.artedu.common.util.JsonUtils;
import com.artedu.common.util.RestTemplateFactory;
import com.artedu.generation.client.LLMClient;
import com.artedu.generation.entity.LlmModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@ -16,10 +18,19 @@ import java.util.Map;
@Service
public class QianwenClient {
/** 内置兜底 API key(与 application.yml 占位符默认值保持一致) */
private static final String FALLBACK_API_KEY = "sk-39ceb8d746014b349109e76f893acb18";
@Autowired
private LlmModelService llmModelService;
private final RestTemplate restTemplate = new RestTemplate();
/**
* 百炼 API key:优先 QIANWEN_API_KEY,其次 DASHSCOPE_API_KEY,均未配置时使用内置兼容值
*/
@Value("${llm.qianwen.api-key:" + FALLBACK_API_KEY + "}")
private String apiKey;
private final RestTemplate restTemplate = RestTemplateFactory.createStreaming();
private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
/**
@ -103,7 +114,7 @@ public class QianwenClient {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer sk-39ceb8d746014b349109e76f893acb18");
headers.set("Authorization", "Bearer " + resolveApiKey());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(API_URL, request, String.class);
@ -126,6 +137,18 @@ public class QianwenClient {
}
}
/**
* 解析实际使用的 API key:注入值为 null/空白时回退到内置兜底 key
* (防御 docker-compose 注入空字符串导致 api-key 变空的场景)
*/
private String resolveApiKey() {
if (apiKey == null || apiKey.trim().isEmpty()) {
log.warn("llm.qianwen.api-key 未配置或为空白,使用内置兜底 key");
return FALLBACK_API_KEY;
}
return apiKey;
}
private String extractText(Map<String, Object> result) {
if (result == null) return "";
Map<String, Object> output = (Map<String, Object>) result.get("output");

View File

@ -32,7 +32,7 @@ spring:
llm:
qianwen:
api-key: sk-39ceb8d746014b349109e76f893acb18
api-key: ${QIANWEN_API_KEY:${DASHSCOPE_API_KEY:sk-39ceb8d746014b349109e76f893acb18}}
model: qwen-plus
qa:

View File

@ -38,13 +38,22 @@ public class AuthController {
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, "admin")
);
if (admin != null) {
String newHash = passwordEncoder.encode("admin123");
admin.setPassword(newHash);
adminMapper.updateById(admin);
log.info("Admin 密码已自动同步为 admin123");
// 已存在则跳过,避免每次启动重置管理员修改过的密码
log.info("Admin 用户已存在,跳过初始化");
return;
}
admin = new Admin();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin123"));
admin.setName("系统管理员");
admin.setRole("ADMIN");
admin.setStatus("ACTIVE");
admin.setCreatedAt(java.time.LocalDateTime.now());
admin.setUpdatedAt(java.time.LocalDateTime.now());
adminMapper.insert(admin);
log.info("Admin 用户不存在,已自动创建初始账号(默认密码 admin123)");
} catch (Exception e) {
log.error("同步 admin 密码失败: {}", e.getMessage());
log.error("初始化 admin 账号失败: {}", e.getMessage());
}
}

View File

@ -1,6 +1,7 @@
package com.artedu.recommend.service;
import com.artedu.common.util.JsonUtils;
import com.artedu.common.util.RestTemplateFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
@ -16,7 +17,7 @@ public class EmbeddingClient {
@Value("${generation.service.url:http://generation-service:8086}")
private String generationServiceUrl;
private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate restTemplate = RestTemplateFactory.create();
/**
* 获取文本的 Embedding 向量

View File

@ -32,7 +32,7 @@ spring:
llm:
qianwen:
api-key: ${QIANWEN_API_KEY:sk-39ceb8d746014b349109e76f893acb18}
api-key: ${QIANWEN_API_KEY:${DASHSCOPE_API_KEY:sk-39ceb8d746014b349109e76f893acb18}}
embedding-model: text-embedding-v1
mybatis-plus:

View File

@ -45,17 +45,21 @@ export default function ArchiveList() {
EXTERNAL: '客户',
}
const loadData = async (page = 1, pageSize = 20) => {
const loadData = async (
page = 1,
pageSize = 20,
filters?: { keyword?: string; msgtype?: string; fromUser?: string; fromRole?: string }
) => {
setLoading(true)
try {
const res: any = await request.get('/v1/archive/messages', {
params: {
page,
size: pageSize,
keyword: keyword || undefined,
msgtype: msgtype || undefined,
fromUser: fromUser || undefined,
fromRole: fromRole || undefined,
keyword: (filters?.keyword ?? keyword) || undefined,
msgtype: (filters?.msgtype ?? msgtype) || undefined,
fromUser: (filters?.fromUser ?? fromUser) || undefined,
fromRole: (filters?.fromRole ?? fromRole) || undefined,
}
})
setData(res.data.records || [])
@ -90,7 +94,8 @@ export default function ArchiveList() {
setMsgtype('')
setFromUser('')
setFromRole('')
loadData(1, pagination.pageSize)
// 显式传入空筛选值,避免 setState 尚未生效时闭包读到旧值
loadData(1, pagination.pageSize, { keyword: '', msgtype: '', fromUser: '', fromRole: '' })
}
const handleTableChange = (newPagination: any) => {

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Card, Button, Table, Tabs, Tag, Modal, Form, message,
DatePicker, Select, InputNumber, Row, Col, Statistic, Popconfirm, Space, Tooltip, Badge, Typography, Input, Spin, Empty, Alert
@ -120,7 +120,13 @@ export default function ChatMining() {
setSelectedRowKeys([])
}, [activeTab])
// 首屏挂载时 activeTab 的 effect 已触发过请求,跳过首次执行避免重复请求
const isFirstTypeFilterRun = useRef(true)
useEffect(() => {
if (isFirstTypeFilterRun.current) {
isFirstTypeFilterRun.current = false
return
}
loadCandidates(activeTab, 1, pagination.pageSize, targetTypeFilter)
setSelectedRowKeys([])
}, [targetTypeFilter])

View File

@ -384,17 +384,17 @@ export default function Dashboard() {
{/* 趋势图表 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={16}>
<Card title="消息/风险/敏感词趋势(近{days}天)">
<Card title={`消息/风险/敏感词趋势(近${days}天)`}>
{trendData.length > 0 ? (
<Line
data={trendData}
xField="date"
yField="value"
seriesField="category"
legend={{ position: 'top' }}
smooth
point={{ size: 3 }}
color={['#1677ff', '#cf1322', '#fa8c16']}
colorField="category"
shapeField="smooth"
legend={{ color: { position: 'top' } }}
point={{ shapeField: 'point', sizeField: 3 }}
scale={{ color: { range: ['#1677ff', '#cf1322', '#fa8c16'] } }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无数据</div>
@ -409,7 +409,16 @@ export default function Dashboard() {
angleField="value"
colorField="type"
radius={0.8}
label={{ type: 'outer', content: '{name} {percentage}' }}
label={{
position: 'outside',
text: (d: { type: string; value: number }) => {
const total = msgTypeData.reduce((sum: number, i: { value: number }) => sum + i.value, 0)
const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : '0.0'
return `${d.type} ${pct}%`
}
}}
legend={{ color: { position: 'bottom' } }}
tooltip={{ title: (d: { type: string }) => d.type }}
interactions={[{ type: 'element-active' }]}
/>
) : (

View File

@ -35,7 +35,7 @@ request.interceptors.response.use(
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
window.location.href = '/login'
window.location.href = '/admin/login'
} else {
message.error(error.message || '网络错误')
}

View File

@ -1,296 +0,0 @@
import { useEffect, useState } from 'react'
import { Button, Toast, SpinLoading, TextArea } from 'antd-mobile'
import type { Recommendation } from '../types'
interface Props {
userInfo?: any
customerId?: string
}
export default function ScriptRecommend({ userInfo, customerId: propCustomerId }: Props) {
const [scripts, setScripts] = useState<Recommendation[]>([])
const [loading, setLoading] = useState(false)
const [generating, setGenerating] = useState(false)
const [customerMsg, setCustomerMsg] = useState('')
const [generated, setGenerated] = useState('')
// 从 localStorage 或企微环境获取真实身份信息
const sessionId = localStorage.getItem('current_session_id') || `session_${Date.now()}`
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || 'wx_001'
const staffId = userInfo?.userId || 'staff_001'
const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a'
// 动态获取最后一条学员消息(直接从 archive_messages 表查询,延迟更低)
const fetchLastCustomerMessage = async () => {
if (!customerId || customerId === 'wx_001') return
try {
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&corpId=${corpId}`)
const data = await res.json()
if (data.code === 0 && data.data && data.data.content) {
setCustomerMsg(data.data.content)
}
} catch (e) {
console.error('获取最后一条学员消息失败:', e)
}
}
useEffect(() => {
loadRecommendations()
}, [])
useEffect(() => {
fetchLastCustomerMessage()
}, [customerId])
const loadRecommendations = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
sessionId,
customerId,
staffId,
corpId,
intentCode: 'INT-COURSE-01',
stage: 'STAGE-03',
studentType: '零基础',
message: customerMsg
})
const res = await fetch(`/api/v1/recommend/get?${params.toString()}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
const list: Recommendation[] = data.data.map((item: any) => ({
utteranceId: item.utteranceId,
title: item.title,
content: item.contentText || item.content,
successRate: item.successRate,
source: item.source || '推荐'
}))
setScripts(list)
} else {
Toast.show({ content: data.message || '加载失败', icon: 'fail' })
}
} catch (e) {
Toast.show({ content: '网络错误', icon: 'fail' })
} finally {
setLoading(false)
}
}
// 阶段/意图代码映射为中文描述
const getStageLabel = (stage?: string) => {
const map: Record<string, string> = {
'STAGE-01': '开场白',
'STAGE-02': '需求挖掘',
'STAGE-03': '方案呈现',
'STAGE-04': '异议处理',
'STAGE-05': '促成签约',
'STAGE-06': '跟进维护',
'STAGE-07': '转介绍',
'STAGE-08': '沉默激活'
}
return map[stage || ''] || stage || '未知'
}
const getIntentLabel = (intent?: string) => {
const map: Record<string, string> = {
'INT-COURSE-01': '课程咨询',
'INT-GREET-01': '问候',
'INT-NEED-01': '需求探询',
'INT-PRICE-01': '价格咨询',
'INT-EMPLOY-01': '就业咨询',
'INT-OTHER': '其他咨询'
}
return map[intent || ''] || intent || '未知'
}
const handleGenerate = async () => {
if (!customerMsg.trim()) {
Toast.show({ content: '请输入学员消息', icon: 'fail' })
return
}
setGenerating(true)
try {
// 并行获取:客户画像 + 对话历史 + 参考话术
const [profileRes, contextRes] = await Promise.all([
fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`),
fetch(`/api/v1/conversations/${sessionId}/context`).catch(() => null)
])
// 解析客户画像
let customerProfileStr = ''
const profileData = await profileRes.json()
if (profileData.code === 0 && profileData.data) {
const p = profileData.data
const courses = p.interestedCourses ? (typeof p.interestedCourses === 'string' ? JSON.parse(p.interestedCourses) : p.interestedCourses) : []
const lines: string[] = []
if (p.currentOccupation) lines.push(`- 身份/职业:${p.currentOccupation}`)
if (p.studentType) lines.push(`- 学员类型:${p.studentType}`)
if (p.skillLevel) lines.push(`- 基础水平:${p.skillLevel}`)
if (p.intentLevel) lines.push(`- 意向度:${p.intentLevel}(${p.intentScore != null ? Math.round(p.intentScore) : 0}分)`)
if (p.concernFocus) lines.push(`- 关注方向:${p.concernFocus}`)
if (p.decisionStage) lines.push(`- 决策阶段:${p.decisionStage}`)
if (courses.length > 0) lines.push(`- 意向课程:${courses.join('、')}`)
if (p.budgetHint) lines.push(`- 预算范围:${p.budgetHint}`)
if (p.preferredCity) lines.push(`- 意向城市:${p.preferredCity}`)
if (p.age) lines.push(`- 年龄:${p.age}岁`)
if (p.education) lines.push(`- 学历:${p.education}`)
customerProfileStr = lines.join('\n')
}
// 解析对话历史
let conversationContextStr = ''
if (contextRes) {
const contextData = await contextRes.json()
if (contextData.code === 0 && contextData.data && contextData.data.messages) {
const msgs = contextData.data.messages as Array<{ role: string; content: string }>
// 取最近 5 条
const recent = msgs.slice(-5)
conversationContextStr = recent.map(m => {
const roleName = m.role === 'student' ? '学员' : '顾问'
return `${roleName}:${m.content}`
}).join('\n')
}
}
// 参考话术:取话术库推荐的前 3 条
let referenceUtterancesStr = ''
if (scripts.length > 0) {
const top3 = scripts.slice(0, 3)
referenceUtterancesStr = top3.map((s, i) => `${i + 1}. "${s.content}"`).join('\n')
}
const params = new URLSearchParams({
message: customerMsg,
intent: 'INT-COURSE-01',
stage: 'STAGE-03',
stageLabel: getStageLabel('STAGE-03'),
intentLabel: getIntentLabel('INT-COURSE-01'),
...(customerProfileStr ? { customerProfile: customerProfileStr } : {}),
...(conversationContextStr ? { conversationContext: conversationContextStr } : {}),
...(referenceUtterancesStr ? { referenceUtterances: referenceUtterancesStr } : {})
})
const res = await fetch(`/api/v1/generation/utterance?${params.toString()}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
setGenerated(data.data.content || '生成失败,请稍后重试')
Toast.show({ content: '生成成功', icon: 'success' })
} else {
Toast.show({ content: data.message || '生成失败', icon: 'fail' })
}
} catch (e) {
Toast.show({ content: '网络错误', icon: 'fail' })
} finally {
setGenerating(false)
}
}
const handleSend = (content: string) => {
const wx = (window as any).wx
if (wx && wx.invoke) {
wx.invoke('sendChatMessage', {
msgtype: 'text',
text: { content }
}, (res: any) => {
console.log('sendChatMessage result:', res)
if (res && res.err_msg === 'sendChatMessage:ok') {
Toast.show({ content: '发送成功', icon: 'success' })
} else if (res && res.err_msg) {
Toast.show({ content: '发送失败: ' + res.err_msg, icon: 'fail' })
} else {
// JS-SDK 未正确初始化,回退到复制
navigator.clipboard.writeText(content)
Toast.show({ content: '已复制到剪贴板(企微SDK未就绪)', icon: 'success' })
}
})
} else {
navigator.clipboard.writeText(content)
Toast.show({ content: '已复制到剪贴板', icon: 'success' })
}
}
if (loading) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<SpinLoading />
</div>
)
}
return (
<div style={{ padding: 12 }}>
{/* AI 生成区域 */}
<div style={{ background: '#f0f5ff', borderRadius: 8, padding: 12, marginBottom: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#1677ff' }}>
🤖 AI 智能生成话术
</div>
<TextArea
placeholder="输入学员最新消息..."
value={customerMsg}
onChange={val => setCustomerMsg(val)}
rows={2}
style={{ marginBottom: 8 }}
/>
<Button
color="primary"
size="small"
loading={generating}
onClick={handleGenerate}
block
>
{generating ? '生成中...' : 'AI 生成回复话术'}
</Button>
{generated && (
<div style={{ marginTop: 12, background: '#fff', borderRadius: 6, padding: 10, fontSize: 13, lineHeight: 1.6 }}>
<div style={{ color: '#666', marginBottom: 4, fontSize: 12 }}>AI 生成结果:</div>
<div style={{ color: '#333' }}>{generated}</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<Button color="primary" size="mini" onClick={() => handleSend(generated)}>一键发送</Button>
<Button size="mini" fill="outline" onClick={() => { navigator.clipboard.writeText(generated); Toast.show({ content: '已复制', icon: 'success' }) }}>复制</Button>
</div>
</div>
)}
</div>
{/* 话术推荐列表 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>📚 话术库推荐</div>
<Button size="mini" fill="outline" onClick={loadRecommendations}>刷新</Button>
</div>
{scripts.length === 0 && (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
暂无推荐话术
</div>
)}
{scripts.map(script => (
<div key={script.utteranceId} style={{
background: '#fff',
borderRadius: 8,
padding: 12,
marginBottom: 12,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, color: '#1677ff' }}>
{script.title}
</div>
<div style={{ fontSize: 13, color: '#333', lineHeight: 1.6, whiteSpace: 'pre-wrap', marginBottom: 8 }}>
{script.content}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: '#999' }}>
<span>成功率: {(script.successRate * 100).toFixed(0)}%</span>
<span>来源: {script.source}</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<Button color="primary" size="small" onClick={() => handleSend(script.content)}>
一键发送
</Button>
<Button size="small" fill="outline" onClick={() => { navigator.clipboard.writeText(script.content); Toast.show({ content: '已复制', icon: 'success' }) }}>
复制
</Button>
</div>
</div>
))}
</div>
)
}

View File

@ -211,7 +211,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setGenerating(false)
retryCountRef.current = 0
} catch (e) {
// 连接类错误自动重试一次,避免网络抖动导致用户看到"连接中断"
// 连接类错误最多自动重试两次,避免网络抖动导致用户看到"连接中断"
if (retryCountRef.current < 2) {
retryCountRef.current += 1
console.warn(`SSE 连接中断,第 ${retryCountRef.current} 次重试...`)
@ -348,7 +348,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
</div>
</div>
)
})}\
})}
{/* 重新生成 */}
{!generating && utterances.some(u => u.isComplete) && (

View File

@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS `archive_media_files` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
`msg_id` VARCHAR(255) NOT NULL COMMENT '企微消息 msgid',
`archive_message_id` BIGINT(20) NOT NULL COMMENT 'archive_messages.id',
`sdkfileid` VARCHAR(512) DEFAULT NULL COMMENT '企微媒体文件 sdkfileid',
`sdkfileid` TEXT DEFAULT NULL COMMENT '企微媒体文件 sdkfileid',
`cos_url` VARCHAR(1024) NOT NULL COMMENT 'COS 文件访问 URL',
`file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)',
`file_type` VARCHAR(32) DEFAULT NULL COMMENT '文件类型:image/voice/video/file',
@ -508,7 +508,7 @@ INSERT INTO utterances (
('UTT-001-001', 'corp001', '标准开场白-首次咨询',
'您好!欢迎咨询第九联盟艺术教育,我是您的专属课程顾问{{顾问姓名}}。请问怎么称呼您呢?是想了解咱们哪方面的课程呀?',
'您好!欢迎咨询第九联盟艺术教育,我是您的专属课程顾问。请问怎么称呼您呢?是想了解咱们哪方面的课程呀?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-01"]',
'["INT-COURSE-01","INT-COURSE-02"]',
'["新客户","未分类"]',
@ -520,7 +520,7 @@ INSERT INTO utterances (
('UTT-001-002', 'corp001', '开场白-转介绍学员',
'您好!听说您是{{推荐人}}介绍来的,真是太好了!咱们第九联盟在{{课程方向}}方向口碑一直很好,我先给您简单介绍一下?',
'您好!听说您是朋友介绍来的,真是太好了!咱们第九联盟在课程方向口碑一直很好,我先给您简单介绍一下?',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-01"]',
'["INT-COURSE-01","INT-QUAL-02"]',
'["转介绍","高信任度"]',
@ -532,7 +532,7 @@ INSERT INTO utterances (
('UTT-001-003', 'corp001', '开场白-二次到访',
'{{学员姓名}}您好!上次咱们聊到{{上次话题}},这次过来是想进一步了解哪方面呢?',
'您好!上次咱们聊到课程话题,这次过来是想进一步了解哪方面呢?',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-01"]',
'["INT-COURSE-01","INT-COURSE-04"]',
'["回访客户","有初步意向"]',
@ -545,7 +545,7 @@ INSERT INTO utterances (
('UTT-002-001', 'corp001', '需求探询-零基础背景',
'完全没问题的!咱们{{课程名称}}就是专门为零基础学员设计的。先跟您确认几个信息:您现在是学生还是在职?每天能抽出多少时间学习?',
'完全没问题的!咱们课程就是专门为零基础学员设计的。先跟您确认几个信息:您现在是学生还是在职?每天能抽出多少时间学习?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-02"]',
'["INT-BASIC-03","INT-COURSE-07","INT-COURSE-08"]',
'["零基础","时间有限","转行意向"]',
@ -557,7 +557,7 @@ INSERT INTO utterances (
('UTT-002-002', 'corp001', '需求探询-职业提升',
'明白了,您是想通过系统学习来提升职业技能。方便问一下您目前从事什么行业吗?以及您希望学完后达到什么水平?这样我可以给您更精准的建议。',
'明白了,您是想通过系统学习来提升职业技能。方便问一下您目前从事什么行业吗?以及您希望学完后达到什么水平?这样我可以给您更精准的建议。',
'APPROVED', 8,
'ACTIVE', 4,
'["STAGE-02"]',
'["INT-JOB-01","INT-COURSE-02","INT-JOB-06"]',
'["在职提升","有明确目标","中级基础"]',
@ -569,7 +569,7 @@ INSERT INTO utterances (
('UTT-002-003', 'corp001', '需求探询-学生党',
'了解!作为学生您的时间相对灵活,这是个很大的优势。您现在大几?是本专业相关还是想跨专业学习?毕业后的打算是什么?',
'了解!作为学生您的时间相对灵活,这是个很大的优势。您现在大几?是本专业相关还是想跨专业学习?毕业后的打算是什么?',
'APPROVED', 8,
'ACTIVE', 4,
'["STAGE-02"]',
'["INT-BASIC-01","INT-JOB-01","INT-COURSE-02"]',
'["在校学生","时间充裕","职业规划期"]',
@ -582,7 +582,7 @@ INSERT INTO utterances (
('UTT-003-001', 'corp001', '课程推荐-原画方向',
'根据您的情况,我强烈推荐咱们的**原画概念设计班**。这个课程从素描基础到角色设计、场景设计全覆盖,6个月就能达到入行水平。咱们往期学员平均就业率是92%,起薪在8-12K。',
'根据您的情况,我强烈推荐咱们的原画概念设计班。这个课程从素描基础到角色设计、场景设计全覆盖,6个月就能达到入行水平。咱们往期学员平均就业率是92%,起薪在8-12K。',
'APPROVED', 10,
'ACTIVE', 2,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-01","INT-JOB-02"]',
'["零基础","转行意向","美术爱好者"]',
@ -594,7 +594,7 @@ INSERT INTO utterances (
('UTT-003-002', 'corp001', '课程推荐-3D建模方向',
'基于您的背景,**3D游戏建模班**非常适合您!课程涵盖ZBrush雕刻、Maya建模、次世代流程,4个月系统学习。咱们和腾讯、网易、米哈游都有合作关系,优秀学员可以直接内推。',
'基于您的背景,3D游戏建模班非常适合您!课程涵盖ZBrush雕刻、Maya建模、次世代流程,4个月系统学习。咱们和腾讯、网易、米哈游都有合作关系,优秀学员可以直接内推。',
'APPROVED', 10,
'ACTIVE', 2,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-05","INT-JOB-04"]',
'["有基础","技术导向","就业优先"]',
@ -606,7 +606,7 @@ INSERT INTO utterances (
('UTT-003-003', 'corp001', '课程推荐-影视后期方向',
'如果您对影视感兴趣,**影视后期特效班**是咱们王牌课程之一。学完后可以从事电影特效、广告包装、短视频制作等方向,就业面非常广。现在报名还能享受早鸟优惠!',
'如果您对影视感兴趣,影视后期特效班是咱们王牌课程之一。学完后可以从事电影特效、广告包装、短视频制作等方向,就业面非常广。现在报名还能享受早鸟优惠!',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-03"]',
'["INT-COURSE-01","INT-COURSE-02","INT-JOB-01"]',
'["兴趣驱动","创意型","影视爱好者"]',
@ -618,7 +618,7 @@ INSERT INTO utterances (
('UTT-003-004', 'corp001', '课程推荐-试听邀请',
'光听我介绍可能不够直观,建议您先来听一节**免费试听课**。我可以帮您预约明天下午3点的原画体验课,您看时间方便吗?',
'光听我介绍可能不够直观,建议您先来听一节免费试听课。我可以帮您预约明天下午3点的原画体验课,您看时间方便吗?',
'APPROVED', 9,
'ACTIVE', 1,
'["STAGE-03"]',
'["INT-COURSE-04","INT-COURSE-01"]',
'["犹豫中","需要体验","决策期"]',
@ -631,7 +631,7 @@ INSERT INTO utterances (
('UTT-004-001', 'corp001', '价值塑造-师资力量',
'咱们的讲师团队非常强大,主讲老师都是来自一线大厂的高级美术总监,比如原画班的李老师之前在网易任职8年,参与过《阴阳师》《第五人格》的角色设计。您学的不是理论,是实打实的行业经验!',
'咱们的讲师团队非常强大,主讲老师都是来自一线大厂的高级美术总监,比如原画班的李老师之前在网易任职8年,参与过知名游戏的角色设计。您学的不是理论,是实打实的行业经验!',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-04"]',
'["INT-TEACH-01","INT-QUAL-01","INT-TEACH-05"]',
'["注重师资","品质导向"]',
@ -643,7 +643,7 @@ INSERT INTO utterances (
('UTT-004-002', 'corp001', '价值塑造-教学模式',
'咱们采用**小班制+项目实战**的教学模式,每个班不超过20人,保证老师能照顾到每位学员。而且课程中会有4-5个真实商业项目贯穿,毕业时您已经有能直接放进作品集的项目经验了。',
'咱们采用小班制+项目实战的教学模式,每个班不超过20人,保证老师能照顾到每位学员。而且课程中会有4-5个真实商业项目贯穿,毕业时您已经有能直接放进作品集的项目经验了。',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-04"]',
'["INT-TEACH-02","INT-TEACH-04","INT-JOB-06"]',
'["注重效果","实践导向"]',
@ -655,7 +655,7 @@ INSERT INTO utterances (
('UTT-004-003', 'corp001', '价值塑造-学习保障',
'咱们有完善的**学习效果保障体系**:第一,每个阶段都有考核,没通过可以免费重修;第二,毕业后6个月内未就业,全额退费;第三,提供终身免费复训。您完全不用担心学不会或者学完后找不到工作。',
'咱们有完善的学习效果保障体系:第一,每个阶段都有考核,没通过可以免费重修;第二,毕业后6个月内未就业,全额退费;第三,提供终身免费复训。您完全不用担心学不会或者学完后找不到工作。',
'APPROVED', 10,
'ACTIVE', 3,
'["STAGE-04"]',
'["INT-TEACH-05","INT-PRICE-04","INT-QUAL-01"]',
'["风险厌恶","谨慎型","需要保障"]',
@ -668,7 +668,7 @@ INSERT INTO utterances (
('UTT-005-001', 'corp001', '报价沟通-透明报价',
'咱们的收费标准非常透明,{{课程名称}}全程6个月,总费用是{{价格}}元。这个费用包含了全部课程、教材、软件授权、项目指导,没有任何隐形收费。您可以和其他机构对比一下,咱们的性价比是非常高的。',
'咱们的收费标准非常透明,课程全程6个月,总费用是市场价。这个费用包含了全部课程、教材、软件授权、项目指导,没有任何隐形收费。您可以和其他机构对比一下,咱们的性价比是非常高的。',
'APPROVED', 8,
'ACTIVE', 2,
'["STAGE-05"]',
'["INT-PRICE-01","INT-PRICE-05"]',
'["价格敏感","理性对比"]',
@ -680,7 +680,7 @@ INSERT INTO utterances (
('UTT-005-002', 'corp001', '报价沟通-分期方案',
'如果一次性付款有压力,咱们支持**12期免息分期**,每个月只要还几百块,对您的生活完全没有影响。而且分期和全款享受的优惠是一样的。我可以帮您算一下具体的月供?',
'如果一次性付款有压力,咱们支持12期免息分期,每个月只要还几百块,对您的生活完全没有影响。而且分期和全款享受的优惠是一样的。我可以帮您算一下具体的月供?',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-05"]',
'["INT-PRICE-03","INT-PRICE-01"]',
'["预算有限","学生党","在职转行"]',
@ -692,7 +692,7 @@ INSERT INTO utterances (
('UTT-005-003', 'corp001', '报价沟通-优惠活动',
'您来得正好!这个月是咱们**周年庆活动**,报名可以享受三重优惠:第一,学费直减2000元;第二,赠送价值1500元的数位板;第三,赠送3节大师公开课。这个优惠到月底就结束了,建议您尽快决定!',
'您来得正好!这个月是咱们周年庆活动,报名可以享受三重优惠:第一,学费直减2000元;第二,赠送价值1500元的数位板;第三,赠送3节大师公开课。这个优惠到月底就结束了,建议您尽快决定!',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-05"]',
'["INT-PRICE-02","INT-PRICE-01"]',
'["价格敏感","犹豫中","等待优惠"]',
@ -705,7 +705,7 @@ INSERT INTO utterances (
('UTT-006-001', 'corp001', '异议处理-担心学不会',
'我完全理解您的担心!其实咱们80%的学员都是零基础开始的。关键是咱们有**一对一学习跟进**,每节课后助教都会检查您的作业,有问题随时答疑。而且如果阶段考核没通过,可以免费重修,直到学会为止。',
'我完全理解您的担心!其实咱们80%的学员都是零基础开始的。关键是咱们有一对一学习跟进,每节课后助教都会检查您的作业,有问题随时答疑。而且如果阶段考核没通过,可以免费重修,直到学会为止。',
'APPROVED', 10,
'ACTIVE', 1,
'["STAGE-06"]',
'["INT-BASIC-03","INT-TEACH-05","INT-TEACH-03"]',
'["零基础","自信心不足","风险厌恶"]',
@ -717,7 +717,7 @@ INSERT INTO utterances (
('UTT-006-002', 'corp001', '异议处理-担心找不到工作',
'这个您放心!咱们有专门的**就业服务团队**,从简历优化、作品集整理到模拟面试、企业内推,全程陪伴。而且咱们和200多家游戏、影视公司有人才合作,往期学员就业率是92%,平均薪资8-12K。',
'这个您放心!咱们有专门的就业服务团队,从简历优化、作品集整理到模拟面试、企业内推,全程陪伴。而且咱们和200多家游戏、影视公司有人才合作,往期学员就业率是92%,平均薪资8-12K。',
'APPROVED', 10,
'ACTIVE', 1,
'["STAGE-06"]',
'["INT-JOB-03","INT-JOB-04","INT-JOB-05"]',
'["就业导向","谨慎型","需要数据支撑"]',
@ -729,7 +729,7 @@ INSERT INTO utterances (
('UTT-006-003', 'corp001', '异议处理-要和家人商量',
'当然应该和家人商量!这是大事嘛。我帮您准备一份**详细的课程资料包**,包括课程大纲、师资介绍、就业案例、学费明细,您拿回去给家人看。如果需要,我也可以和您家人直接沟通,解答他们的疑问。',
'当然应该和家人商量!这是大事嘛。我帮您准备一份详细的课程资料包,包括课程大纲、师资介绍、就业案例、学费明细,您拿回去给家人看。如果需要,我也可以和您家人直接沟通,解答他们的疑问。',
'APPROVED', 9,
'ACTIVE', 4,
'["STAGE-06"]',
'["INT-OTHER-04","INT-QUAL-02"]',
'["决策依赖","家庭决策","犹豫中"]',
@ -741,7 +741,7 @@ INSERT INTO utterances (
('UTT-006-004', 'corp001', '异议处理-想再比较比较',
'完全理解,货比三家是应该的!我建议您从这几个维度去对比:**师资背景**(是否一线大厂出身)、**项目实战**(是否有真实商业项目)、**就业数据**(是否有真实就业案例)、**后续服务**(是否有就业保障)。这几点恰恰是咱们最强的优势。',
'完全理解,货比三家是应该的!我建议您从这几个维度去对比:师资背景(是否一线大厂出身)、项目实战(是否有真实商业项目)、就业数据(是否有真实就业案例)、后续服务(是否有就业保障)。这几点恰恰是咱们最强的优势。',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-06"]',
'["INT-QUAL-02","INT-PRICE-05","INT-QUAL-01"]',
'["理性对比","谨慎型","多家咨询"]',
@ -754,7 +754,7 @@ INSERT INTO utterances (
('UTT-007-001', 'corp001', '促成报名-限时优惠',
'{{学员姓名}},我理解您还需要考虑,但这个**周年庆优惠今天就是最后一天**了。现在报名立减2000,还送数位板。错过这个活动,同样的课程要多花3500块。我先帮您预留一个名额,您今天内确认就行,好吗?',
'我理解您还需要考虑,但这个周年庆优惠今天就是最后一天了。现在报名立减2000,还送数位板。错过这个活动,同样的课程要多花3500块。我先帮您预留一个名额,您今天内确认就行,好吗?',
'APPROVED', 9,
'ACTIVE', 2,
'["STAGE-07"]',
'["INT-PRICE-02","INT-COURSE-04"]',
'["犹豫中","价格敏感","决策期"]',
@ -766,7 +766,7 @@ INSERT INTO utterances (
('UTT-007-002', 'corp001', '促成报名-从众心理',
'这个月已经有12个学员报名了原画班,其中3个是和您情况很像的零基础转行者。他们上周已经开始上课了,反馈都特别好。您要是这周报名,还能赶上同一期,大家起点一样,学习氛围会更好。',
'这个月已经有12个学员报名了原画班,其中3个是和您情况很像的零基础转行者。他们上周已经开始上课了,反馈都特别好。您要是这周报名,还能赶上同一期,大家起点一样,学习氛围会更好。',
'APPROVED', 8,
'ACTIVE', 5,
'["STAGE-07"]',
'["INT-COURSE-04","INT-QUAL-02"]',
'["社交型","从众心理","决策期"]',
@ -778,7 +778,7 @@ INSERT INTO utterances (
('UTT-007-003', 'corp001', '促成报名-降低门槛',
'这样吧,您先交**500元定金**锁定今天的优惠和名额,这500元抵1000元学费。给您3天时间考虑,如果最终决定不报名,定金全额退还。这样您既没有损失,又能保留优惠,您看行吗?',
'这样吧,您先交500元定金锁定今天的优惠和名额,这500元抵1000元学费。给您3天时间考虑,如果最终决定不报名,定金全额退还。这样您既没有损失,又能保留优惠,您看行吗?',
'APPROVED', 9,
'ACTIVE', 3,
'["STAGE-07"]',
'["INT-PRICE-03","INT-PRICE-04"]',
'["谨慎型","风险厌恶","犹豫中"]',
@ -791,7 +791,7 @@ INSERT INTO utterances (
('UTT-008-001', 'corp001', '跟进维护-未成交回访',
'{{学员姓名}}您好!上次咱们聊完后,您考虑得怎么样了?我这边又整理了几个和您背景相似的学员案例,以及最新的就业数据,发您微信了,您有空看看。有任何问题随时找我!',
'您好!上次咱们聊完后,您考虑得怎么样了?我这边又整理了几个和您背景相似的学员案例,以及最新的就业数据,发您微信了,您有空看看。有任何问题随时找我!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-QUAL-02","INT-JOB-03"]',
'["未成交","犹豫中","需要推动"]',
@ -803,7 +803,7 @@ INSERT INTO utterances (
('UTT-008-002', 'corp001', '跟进维护-已报名关怀',
'{{学员姓名}},上课一周了,感觉怎么样?有没有哪里跟不上的?咱们助教老师每周都会做学习反馈,如果您有任何问题,不管是学习上的还是生活里的,都可以随时找我。希望您在咱们这儿学习愉快!',
'上课一周了,感觉怎么样?有没有哪里跟不上的?咱们助教老师每周都会做学习反馈,如果您有任何问题,不管是学习上的还是生活里的,都可以随时找我。希望您在咱们这儿学习愉快!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-TEACH-03","INT-TEACH-05"]',
'["已报名","学习期","需要关怀"]',
@ -815,7 +815,7 @@ INSERT INTO utterances (
('UTT-008-003', 'corp001', '跟进维护-新活动通知',
'{{学员姓名}},告诉您个好消息!咱们下周六有个**行业大咖分享会**,邀请到了网易美术总监来讲《游戏原画的职业发展路径》。您要是有兴趣,我可以给您预留个名额,免费的!',
'告诉您个好消息!咱们下周六有个行业大咖分享会,邀请到了网易美术总监来讲游戏原画的职业发展路径。您要是有兴趣,我可以给您预留个名额,免费的!',
'APPROVED', 7,
'ACTIVE', 7,
'["STAGE-08"]',
'["INT-OTHER-01","INT-JOB-01","INT-TEACH-01"]',
'["潜在学员","兴趣驱动","活动爱好者"]',