diff --git a/add_utterances.sql b/add_utterances.sql index ba0cd2b..5c8393c 100644 --- a/add_utterances.sql +++ b/add_utterances.sql @@ -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"]', '["潜在学员","兴趣驱动","活动爱好者"]', diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java index 9cca2d4..6a5ec7d 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java @@ -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) { diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java index 53a39a8..e843820 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java @@ -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 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 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()); } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java index d78df65..bb624ec 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java @@ -9,12 +9,31 @@ import com.tencent.wework.Finance; * pull - 拉取一批加密消息元数据(安全,不涉及单条消息解析) * decrypt - 解密单条消息内容(隔离崩溃风险) * - * 用法: - * java -jar app.jar worker pull - * java -jar app.jar worker decrypt + * 用法(secret 优先从环境变量 WECOM_ARCHIVE_SECRET 读取,避免经命令行参数泄露;参数位传入 "-" 占位): + * java -jar app.jar worker pull - + * java -jar app.jar worker decrypt - */ 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 ..."); @@ -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]; diff --git a/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java b/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java index 1578988..c7e9b36 100644 --- a/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java +++ b/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java @@ -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> refreshToken(@RequestHeader("Authorization") String authHeader) { - String oldToken = authHeader.replace("Bearer ", ""); + public ResponseEntity>> 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> getUserInfo(@RequestHeader("Authorization") String authHeader) { - String token = authHeader.replace("Bearer ", ""); + public ResponseEntity>> 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 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 ResponseEntity> unauthorized(String message) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Result.fail(401, message)); } @Value("${wecom.corp-id}") diff --git a/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java b/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java index f828f1d..84f9ff7 100644 --- a/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java +++ b/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java @@ -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 response = restTemplate.getForEntity(url, String.class); - Map 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 response = restTemplate.getForEntity(URI.create(url), String.class); + Map 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() { diff --git a/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java b/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java index e8fd219..60bd24d 100644 --- a/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java +++ b/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java @@ -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> 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> 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> handleHttpMessageNotReadable(HttpMessageNotReadableException e) { + log.warn("请求体不可读: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Result.fail(400, "请求体格式错误,请检查 JSON 格式")); + } + @ExceptionHandler(Exception.class) public Result handleException(Exception e) { log.error("系统异常: {}", e.getMessage(), e); diff --git a/backend/common/src/main/java/com/artedu/common/util/RestTemplateFactory.java b/backend/common/src/main/java/com/artedu/common/util/RestTemplateFactory.java new file mode 100644 index 0000000..374fa22 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/util/RestTemplateFactory.java @@ -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); + } +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java b/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java index 0ef87a6..f7329b9 100644 --- a/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java @@ -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() + .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); } } diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java index 5ff02bb..6c49dd6 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java @@ -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; diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java index efe50ea..29d334a 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java @@ -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; diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java index 77bb282..fdf4643 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java @@ -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; diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java index 1f7f440..1e4b5e0 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java @@ -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 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 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 + "|||"; } } } diff --git a/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java index 4e440b6..403a272 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java @@ -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> request = new HttpEntity<>(requestBody, headers); ResponseEntity 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 result) { if (result == null) return ""; Map output = (Map) result.get("output"); diff --git a/backend/generation-service/src/main/resources/application.yml b/backend/generation-service/src/main/resources/application.yml index 5dedd41..480a89d 100644 --- a/backend/generation-service/src/main/resources/application.yml +++ b/backend/generation-service/src/main/resources/application.yml @@ -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: diff --git a/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/controller/AuthController.java b/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/controller/AuthController.java index c88e455..570ebdb 100644 --- a/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/controller/AuthController.java +++ b/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/controller/AuthController.java @@ -38,13 +38,22 @@ public class AuthController { new LambdaQueryWrapper().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()); } } diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java index 52f21ab..b99237a 100644 --- a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java @@ -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 向量 diff --git a/backend/recommendation-service/src/main/resources/application.yml b/backend/recommendation-service/src/main/resources/application.yml index 4c26539..c6f9a87 100644 --- a/backend/recommendation-service/src/main/resources/application.yml +++ b/backend/recommendation-service/src/main/resources/application.yml @@ -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: diff --git a/frontend/admin/src/pages/ArchiveList.tsx b/frontend/admin/src/pages/ArchiveList.tsx index 7a317ce..dee9cda 100644 --- a/frontend/admin/src/pages/ArchiveList.tsx +++ b/frontend/admin/src/pages/ArchiveList.tsx @@ -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) => { diff --git a/frontend/admin/src/pages/ChatMining.tsx b/frontend/admin/src/pages/ChatMining.tsx index 0ffd07b..813fcb5 100644 --- a/frontend/admin/src/pages/ChatMining.tsx +++ b/frontend/admin/src/pages/ChatMining.tsx @@ -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]) diff --git a/frontend/admin/src/pages/Dashboard.tsx b/frontend/admin/src/pages/Dashboard.tsx index 8e7d0f7..d6fe298 100644 --- a/frontend/admin/src/pages/Dashboard.tsx +++ b/frontend/admin/src/pages/Dashboard.tsx @@ -384,17 +384,17 @@ export default function Dashboard() { {/* 趋势图表 */} - + {trendData.length > 0 ? ( ) : (
暂无数据
@@ -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' }]} /> ) : ( diff --git a/frontend/admin/src/utils/request.ts b/frontend/admin/src/utils/request.ts index 3e3be0e..8526626 100644 --- a/frontend/admin/src/utils/request.ts +++ b/frontend/admin/src/utils/request.ts @@ -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 || '网络错误') } diff --git a/frontend/sidebar/src/components/ScriptRecommend.tsx b/frontend/sidebar/src/components/ScriptRecommend.tsx deleted file mode 100644 index 52595ef..0000000 --- a/frontend/sidebar/src/components/ScriptRecommend.tsx +++ /dev/null @@ -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([]) - 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 = { - '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 = { - '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 ( -
- -
- ) - } - - return ( -
- {/* AI 生成区域 */} -
-
- 🤖 AI 智能生成话术 -
-