fix(archive): 优化归档服务签名验证与AES工具初始化线程安全
- 修改归档回调控制器中AES解密工具的初始化方式,采用双重检查锁定保证线程安全 - 调整签名验证失败日志,避免打印敏感token信息,提升安全性 - 更新归档拉取服务调用worker命令时secret参数为占位符,改为通过环境变量传递真实secret - 优化归档拉取循环中last_seq推进逻辑,仅更新内存记录,防止因落库失败导致消息丢失 - 修改数据库初始化脚本,调整媒体文件sdkfileid字段类型为TEXT,修正初始化话术状态为ACTIVE
This commit is contained in:
parent
7ba9c0837d
commit
7722c4e7e3
@ -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"]',
|
||||
'["潜在学员","兴趣驱动","活动爱好者"]',
|
||||
|
||||
@ -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()) {
|
||||
WeComCryptUtil local = cryptUtil;
|
||||
if (local == null && callbackAesKey != null && !callbackAesKey.isEmpty()) {
|
||||
synchronized (this) {
|
||||
local = cryptUtil;
|
||||
if (local == null) {
|
||||
try {
|
||||
cryptUtil = new WeComCryptUtil(callbackAesKey);
|
||||
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) {
|
||||
|
||||
@ -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 推进进度
|
||||
// 落库成功后推进 seq
|
||||
if (message.getSeq() != null && message.getSeq() > lastSeqInRedis) {
|
||||
lastSeqInRedis = message.getSeq();
|
||||
redisTemplate.opsForValue().set(
|
||||
LAST_SEQ_KEY + corpId,
|
||||
message.getSeq(),
|
||||
lastSeqInRedis,
|
||||
7, TimeUnit.DAYS
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -531,17 +560,27 @@ public class ArchivePullService {
|
||||
JsonUtils.toJson(message)
|
||||
);
|
||||
|
||||
// 消息成功落库后才推进 last_seq,避免提前推进导致丢消息
|
||||
if (message.getSeq() != null && message.getSeq() > lastSeqInRedis) {
|
||||
lastSeqInRedis = message.getSeq();
|
||||
redisTemplate.opsForValue().set(
|
||||
LAST_SEQ_KEY + corpId,
|
||||
message.getSeq(),
|
||||
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());
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,14 +50,30 @@ 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);
|
||||
synchronized (accessTokenLock) {
|
||||
// 双重检查:并发请求中只有一个线程真正刷新,其余等锁后直接读缓存
|
||||
token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
|
||||
if (token != null) {
|
||||
return token;
|
||||
}
|
||||
|
||||
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"))) {
|
||||
@ -67,6 +85,7 @@ public class WeComOAuthService {
|
||||
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
public String getJsApiTicket() {
|
||||
String ticket = (String) redisTemplate.opsForValue().get(JSAPI_TICKET_KEY);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 + "|||";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 向量
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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' }]}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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 || '网络错误')
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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) && (
|
||||
|
||||
54
init.sql
54
init.sql
@ -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"]',
|
||||
'["潜在学员","兴趣驱动","活动爱好者"]',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user