From 927458b9e0596ba249e68240e013cca4262ccc22 Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 23 Jul 2026 21:18:13 +0800 Subject: [PATCH] feat(aihr): harden M0-M2 mobile training flows --- AGENTS.md | 2 + .../web/controller/AuthController.java | 41 +- .../web/controller/CaptchaController.java | 31 +- .../web/service/impl/SmsAuthStrategy.java | 73 ++- .../MobileSmsLoginTenantBoundaryTest.java | 82 ++++ .../impl/SmsAuthStrategyTenantTest.java | 98 +++- .../ruoyi-common/ruoyi-common-core/pom.xml | 6 + .../common/core/utils/SmsCodeUtils.java | 26 + .../common/core/utils/SmsCodeUtilsTest.java | 22 + .../controller/AihrPracticeController.java | 11 + .../aihr/controller/AihrSpeechController.java | 6 + .../dromara/aihr/domain/AihrPracticeDto.java | 6 + .../dromara/aihr/domain/AihrSpeechDto.java | 2 +- .../aihr/service/AihrPracticeLlmService.java | 54 ++ .../aihr/service/AihrPracticeSeedService.java | 149 +++++- .../aihr/service/AihrSpeechService.java | 41 +- .../service/AihrPracticeLlmServiceTest.java | 48 ++ .../service/AihrPracticeSeedServiceTest.java | 94 ++++ .../aihr/service/AihrSpeechServiceTest.java | 24 +- backend/script/sql/aihr_practice_mysql8.sql | 13 + ...20260723_practice_m2_help_event_mysql8.sql | 22 + docs/API_INTEGRATION.md | 7 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 40 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 37 +- docs/M0自动化验收说明.md | 50 ++ docs/README.md | 7 +- ...银城大喇叭与问模块纠偏增量方案-20260722.md | 18 +- ...城帮道产品功能优先级与实施计划-20260721.md | 33 +- mobile-uni/src/pages/auth/login/index.vue | 3 +- .../src/pages/user/broadcast/detail.vue | 2 +- mobile-uni/src/pages/user/broadcast/index.vue | 4 +- mobile-uni/src/pages/user/practice/index.vue | 10 +- mobile-uni/src/pages/user/sop/index.vue | 23 +- mobile-uni/src/services/knowledge.ts | 2 +- mobile-uni/src/services/practice.ts | 9 + mobile-uni/src/services/speech.ts | 39 +- mobile-uni/src/types/api.ts | 8 + mobile-uni/tests/ask-memory.test.mjs | 5 + mobile-uni/tests/broadcast.test.mjs | 26 + mobile-uni/tests/mobile-user-pages.test.mjs | 35 ++ scripts/release-preflight.sh | 84 +++- scripts/reset-dev-db.sh | 1 + scripts/tests/aihr-schema-migrations.test.sh | 36 +- scripts/verify-broadcast-m0-local.mjs | 463 ++++++++++++++++++ 44 files changed, 1674 insertions(+), 119 deletions(-) create mode 100644 backend/ruoyi-admin/src/test/java/org/dromara/web/controller/MobileSmsLoginTenantBoundaryTest.java create mode 100644 backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SmsCodeUtils.java create mode 100644 backend/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SmsCodeUtilsTest.java create mode 100644 backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql create mode 100644 docs/M0自动化验收说明.md create mode 100644 scripts/verify-broadcast-m0-local.mjs diff --git a/AGENTS.md b/AGENTS.md index d2111463..6ff8a7ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,13 @@ - 线上移动端 H5:`https://peilian.njzhmj.top/h5/`,Caddy 服务 `/opt/wygj/www/h5`。 - 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。 - 原生 App 不复用 H5 静态发布流程;`npm --prefix mobile-uni run build:app` 仅生成 HBuilderX 出包资源,未提供法务 HTTPS 链接、签名或真机验收前不得称为已出包或上架。 +- 完整发布收口须按 `docs/DEV_SETUP.md` 运行同时启用远端静态、后端和 schema 的 `release-preflight`;只有三项都通过,才可称当前本地构建已与线上完整包匹配。单纯 H5 资源匹配不等同于完整发布验证。 ## 当前业务边界 - 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向。底栏固定「今日/练/问/我」;问师傅文字和现场附件查询复用 `/api/knowledge/query`、`/api/knowledge/query-media`,内部登录端可带 `conversationId/contextVersion` 使用 30 分钟、最近 6 轮的短期会话,原文件/视频只通过 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权交付;总结卡复用 `/api/knowledge/summary-card`,失败不兜假数据。显式“记一下/帮我记/保存一下”复用同一 query 返回确认卡,确认后按 `PRIVATE/COMPANY` 写 `aihr_assistant_capture`;COMPANY 只表示 `PENDING` 待流转,不得冒充已派单或已送达。外部知识 API 保持无状态。 +- 大喇叭详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。 - 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`,默认当日但允许查看历史日期且不得选择未来日期。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。 - 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java index 33aa2d6f..48c2dbc3 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java @@ -6,6 +6,8 @@ import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.codec.Base64; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -22,6 +24,7 @@ import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginBody; import org.dromara.common.core.domain.model.RegisterBody; import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.utils.*; import org.dromara.common.encrypt.annotation.ApiEncrypt; import org.dromara.common.json.utils.JsonUtils; @@ -71,7 +74,6 @@ import java.util.concurrent.TimeUnit; @RequestMapping("/auth") public class AuthController { - private static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051"; private static final String MOBILE_GRANT_TYPE = "sms"; private final SocialProperties socialProperties; @@ -125,25 +127,31 @@ public class AuthController { * 移动端短信登录,复用 app 客户端和 sms 授权策略。 */ @PostMapping("/mobile/sms-login") - public R mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) { - SysClientVo client = clientService.queryByClientId(MOBILE_CLIENT_ID); + public R mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) { + SysClientVo client = clientService.queryByClientId(SmsCodeUtils.MOBILE_CLIENT_ID); if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), MOBILE_GRANT_TYPE)) { - log.info("移动端客户端id: {} 认证类型:{} 异常!.", MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE); + log.info("移动端客户端id: {} 认证类型:{} 异常!.", SmsCodeUtils.MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE); return R.fail(MessageUtils.message("auth.grant.type.error")); } else if (!SystemConstants.NORMAL.equals(client.getStatus())) { return R.fail(MessageUtils.message("auth.grant.type.blocked")); } - String tenantId = StringUtils.blankToDefault(body.tenantId(), TenantConstants.DEFAULT_TENANT_ID); + // 员工端没有匿名租户选择能力;不信任客户端传入的 tenantId,避免手机号验证码被用于加入其他租户。 + String tenantId = mobileTenantId(); loginService.checkTenant(tenantId); Map loginBody = new HashMap<>(); - loginBody.put("clientId", MOBILE_CLIENT_ID); + loginBody.put("clientId", SmsCodeUtils.MOBILE_CLIENT_ID); loginBody.put("grantType", MOBILE_GRANT_TYPE); loginBody.put("tenantId", tenantId); loginBody.put("phonenumber", body.phonenumber()); loginBody.put("smsCode", body.smsCode()); - return R.ok(IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE)); + LoginVo loginVo = IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE); + return R.ok(MobileSmsLoginResponse.from(loginVo)); + } + + static String mobileTenantId() { + return TenantConstants.DEFAULT_TENANT_ID; } /** @@ -272,14 +280,29 @@ public class AuthController { return R.ok(result); } + @JsonIgnoreProperties(ignoreUnknown = true) public record MobileSmsLoginBody( @NotBlank(message = "{user.phonenumber.not.blank}") @Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber, @NotBlank(message = "{sms.code.not.blank}") - String smsCode, - String tenantId + String smsCode ) { } + /** + * 移动端保存会话所需的最小登录响应,避免复用包含后台扩展字段的登录视图对象。 + */ + public record MobileSmsLoginResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("client_id") String clientId + ) { + static MobileSmsLoginResponse from(LoginVo loginVo) { + if (loginVo == null || StringUtils.isBlank(loginVo.getAccessToken()) || StringUtils.isBlank(loginVo.getClientId())) { + throw new ServiceException("移动端登录未返回认证信息"); + } + return new MobileSmsLoginResponse(loginVo.getAccessToken(), loginVo.getClientId()); + } + } + } diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java index d9dad4aa..e2afd386 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -13,8 +13,10 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.constant.Constants; import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.RegexConstants; +import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SmsCodeUtils; import org.dromara.common.core.utils.SpringUtils; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.mail.config.properties.MailProperties; @@ -28,6 +30,9 @@ import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.api.entity.SmsResponse; import org.dromara.sms4j.core.factory.SmsFactory; import org.dromara.web.domain.vo.CaptchaVo; +import org.redisson.api.RBucket; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; @@ -41,6 +46,7 @@ import org.springframework.web.bind.annotation.RestController; import java.awt.*; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.concurrent.TimeUnit; /** * 验证码操作处理 @@ -84,7 +90,6 @@ public class CaptchaController { public R smsCode( @NotBlank(message = "{user.phonenumber.not.blank}") @Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) { - String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber; boolean prodProfile = environment.acceptsProfiles(Profiles.of("prod")); boolean fixedCodeEnabled = shouldUseFixedSmsCode( smsDevFixedCode, @@ -93,7 +98,7 @@ public class CaptchaController { ); if (fixedCodeEnabled) { log.warn("aihr.sms.dev-fixed-code 已启用,短信验证码走固定码,未真实发送短信"); - RedisUtils.setCacheObject(key, smsDevFixedCode, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, smsDevFixedCode); return R.ok(); } if (StringUtils.isBlank(smsLoginTemplateId)) { @@ -108,10 +113,30 @@ public class CaptchaController { log.error("验证码短信发送异常 => {}", smsResponse); return R.fail(String.valueOf(smsResponse.getData())); } - RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, code); return R.ok(); } + static void storeSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String code) { + RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber)); + boolean locked = false; + try { + locked = lock.tryLock(2, 5, TimeUnit.SECONDS); + if (!locked) { + throw new ServiceException("验证码服务繁忙,请稍后重试"); + } + RBucket bucket = redisClient.getBucket(SmsCodeUtils.cacheKey(tenantId, phonenumber)); + bucket.set(code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServiceException("验证码服务被中断,请重试"); + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + static boolean shouldUseFixedSmsCode(String fixedCode, boolean prodProfile, boolean prodFixedCodeEnabled) { return StringUtils.isNotBlank(fixedCode) && (!prodProfile || prodFixedCodeEnabled); } diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java index f4d144ef..5c2648b2 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java @@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.constant.Constants; -import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.SystemConstants; import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.SmsLoginBody; @@ -16,6 +15,7 @@ import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.user.CaptchaExpireException; import org.dromara.common.core.exception.user.UserException; import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.SmsCodeUtils; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.ValidatorUtils; import org.dromara.common.json.utils.JsonUtils; @@ -29,8 +29,13 @@ import org.dromara.system.mapper.SysUserMapper; import org.dromara.web.domain.vo.LoginVo; import org.dromara.web.service.IAuthStrategy; import org.dromara.web.service.SysLoginService; +import org.redisson.api.RBucket; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; +import java.util.concurrent.TimeUnit; + /** * 短信认证策略 * @@ -51,9 +56,10 @@ public class SmsAuthStrategy implements IAuthStrategy { String tenantId = loginBody.getTenantId(); String phonenumber = loginBody.getPhonenumber(); String smsCode = loginBody.getSmsCode(); + boolean appClient = isAppClient(client); LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> { loginService.checkLogin(LoginType.SMS, tenantId, phonenumber, () -> !validateSmsCode(tenantId, phonenumber, smsCode)); - SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber); + SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber, appClient); // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 return loginService.buildLoginUser(user); }); @@ -80,18 +86,60 @@ public class SmsAuthStrategy implements IAuthStrategy { * 校验短信验证码 */ private boolean validateSmsCode(String tenantId, String phonenumber, String smsCode) { - String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber); - if (StringUtils.isBlank(code)) { - loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); - throw new CaptchaExpireException(); - } - return code.equals(smsCode); + return validateSmsCode(RedisUtils.getClient(), tenantId, phonenumber, smsCode); } - private SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber) { - SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper() + boolean validateSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String smsCode) { + String key = SmsCodeUtils.cacheKey(tenantId, phonenumber); + RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber)); + boolean locked = false; + try { + locked = lock.tryLock(2, 5, TimeUnit.SECONDS); + if (!locked) { + throw new UserException("验证码校验繁忙,请稍后重试"); + } + RBucket bucket = redisClient.getBucket(key); + String code = bucket.get(); + if (StringUtils.isBlank(code)) { + loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + if (!code.equals(smsCode)) { + return false; + } + bucket.delete(); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UserException("验证码校验被中断,请稍后重试"); + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + static boolean isAppClient(SysClientVo client) { + return client != null && SmsCodeUtils.MOBILE_CLIENT_ID.equals(client.getClientId()); + } + + SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber, boolean appClient) { + if (appClient && userMapper.exists(new LambdaQueryWrapper() .eq(SysUser::getTenantId, tenantId) - .eq(SysUser::getPhonenumber, phonenumber)); + .eq(SysUser::getPhonenumber, phonenumber) + .and(wrapper -> wrapper.isNull(SysUser::getUserType) + .or() + .ne(SysUser::getUserType, UserType.APP_USER.getUserType())))) { + log.warn("移动端短信登录拒绝同手机号系统用户:{}", phonenumber); + throw new UserException("移动端账号未开通,请联系管理员"); + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(SysUser::getTenantId, tenantId) + .eq(SysUser::getPhonenumber, phonenumber); + if (appClient) { + query.eq(SysUser::getUserType, UserType.APP_USER.getUserType()); + } + SysUserVo user = userMapper.selectVoOne(query); if (ObjectUtil.isNull(user)) { SysUser sysUser = new SysUser(); sysUser.setTenantId(tenantId); @@ -108,6 +156,9 @@ public class SmsAuthStrategy implements IAuthStrategy { log.info("登录用户:{} 已被停用.", phonenumber); throw new UserException("user.blocked", phonenumber); } + if (appClient && (user == null || !UserType.APP_USER.getUserType().equals(user.getUserType()))) { + throw new UserException("移动端账号未开通,请联系管理员"); + } return user; } diff --git a/backend/ruoyi-admin/src/test/java/org/dromara/web/controller/MobileSmsLoginTenantBoundaryTest.java b/backend/ruoyi-admin/src/test/java/org/dromara/web/controller/MobileSmsLoginTenantBoundaryTest.java new file mode 100644 index 00000000..de3305e8 --- /dev/null +++ b/backend/ruoyi-admin/src/test/java/org/dromara/web/controller/MobileSmsLoginTenantBoundaryTest.java @@ -0,0 +1,82 @@ +package org.dromara.web.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SmsCodeUtils; +import org.dromara.web.domain.vo.LoginVo; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.redisson.api.RBucket; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class MobileSmsLoginTenantBoundaryTest { + + @Test + void mobileLoginIgnoresClientTenantSelection() throws Exception { + AuthController.MobileSmsLoginBody body = new ObjectMapper().readValue(""" + {"phonenumber":"13900000000","smsCode":"123456","tenantId":"tenant-b"} + """, AuthController.MobileSmsLoginBody.class); + + assertEquals("13900000000", body.phonenumber()); + assertEquals("123456", body.smsCode()); + assertEquals("000000", AuthController.mobileTenantId()); + } + + @Test + @SuppressWarnings("unchecked") + void resendingSmsUsesTheSameLockAsVerification() throws Exception { + RedissonClient redisClient = mock(RedissonClient.class); + RLock lock = mock(RLock.class); + RBucket bucket = mock(RBucket.class); + String tenantId = "000000"; + String phonenumber = "13900000000"; + when(redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber))).thenReturn(lock); + when(redisClient.getBucket(anyString())).thenReturn(bucket); + when(lock.tryLock(2, 5, TimeUnit.SECONDS)).thenReturn(true); + when(lock.isHeldByCurrentThread()).thenReturn(true); + + CaptchaController.storeSmsCode(redisClient, tenantId, phonenumber, "123456"); + + verify(bucket).set("123456", Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + verify(lock).unlock(); + } + + @Test + void mobileLoginResponseUsesTheH5AuthContractOnly() throws Exception { + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken("test-access-token"); + loginVo.setClientId("test-mobile-client"); + loginVo.setRefreshToken("must-not-be-returned"); + + JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString( + AuthController.MobileSmsLoginResponse.from(loginVo) + )); + + assertEquals("test-access-token", json.path("access_token").asText()); + assertEquals("test-mobile-client", json.path("client_id").asText()); + assertFalse(json.has("refresh_token")); + } + + @Test + void mobileLoginResponseRejectsAnIncompleteStrategyResult() { + LoginVo loginVo = new LoginVo(); + loginVo.setClientId("test-mobile-client"); + + assertThrows(ServiceException.class, () -> AuthController.MobileSmsLoginResponse.from(loginVo)); + } +} diff --git a/backend/ruoyi-admin/src/test/java/org/dromara/web/service/impl/SmsAuthStrategyTenantTest.java b/backend/ruoyi-admin/src/test/java/org/dromara/web/service/impl/SmsAuthStrategyTenantTest.java index 3268812e..77c71789 100644 --- a/backend/ruoyi-admin/src/test/java/org/dromara/web/service/impl/SmsAuthStrategyTenantTest.java +++ b/backend/ruoyi-admin/src/test/java/org/dromara/web/service/impl/SmsAuthStrategyTenantTest.java @@ -1,25 +1,99 @@ package org.dromara.web.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.utils.SmsCodeUtils; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.mapper.SysUserMapper; +import org.dromara.web.service.SysLoginService; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.redisson.api.RBucket; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.concurrent.TimeUnit; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; @Tag("dev") class SmsAuthStrategyTenantTest { - @Test - void mobileSmsUserLookupKeepsTenantBoundary() throws Exception { - Path source = Path.of("src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java"); - if (!Files.exists(source)) { - source = Path.of("ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java"); - } - String code = Files.readString(source); + private static final String TEST_MOBILE = "13900000000"; - assertTrue(code.contains(".eq(SysUser::getTenantId, tenantId)")); - assertTrue(code.contains(".eq(SysUser::getPhonenumber, phonenumber)")); + @BeforeAll + static void initializeSysUserLambdaMetadata() { + if (TableInfoHelper.getTableInfo(SysUser.class) == null) { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SysUser.class); + } + } + + @Test + void mobileSmsLoginUsesAppUsersAndRejectsAConflictingSystemUser() { + SysLoginService loginService = mock(SysLoginService.class); + SysUserMapper userMapper = mock(SysUserMapper.class); + SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper); + when(userMapper.selectVoOne(any())).thenReturn(null); + when(userMapper.exists(any())).thenReturn(true); + + assertThrows(RuntimeException.class, + () -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true)); + verify(userMapper, never()).insert(any(SysUser.class)); + + SysClientVo appClient = new SysClientVo(); + appClient.setClientId(SmsCodeUtils.MOBILE_CLIENT_ID); + assertTrue(SmsAuthStrategy.isAppClient(appClient)); + } + + @Test + void existingAppUserIsRetainedAndWrongOtpDoesNotConsumeTheCode() throws Exception { + SysLoginService loginService = mock(SysLoginService.class); + SysUserMapper userMapper = mock(SysUserMapper.class); + SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper); + SysUserVo appUser = new SysUserVo(); + appUser.setUserType(UserType.APP_USER.getUserType()); + appUser.setStatus("0"); + when(userMapper.selectVoOne(any())).thenReturn(appUser); + assertSame(appUser, strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true)); + + RedissonClient redisClient = mock(RedissonClient.class); + RLock lock = mock(RLock.class); + @SuppressWarnings("unchecked") + RBucket bucket = mock(RBucket.class); + when(redisClient.getLock(SmsCodeUtils.verificationLockKey("000000", TEST_MOBILE))).thenReturn(lock); + when(redisClient.getBucket(anyString())).thenReturn(bucket); + when(lock.tryLock(eq(2L), eq(5L), eq(TimeUnit.SECONDS))).thenReturn(true); + when(lock.isHeldByCurrentThread()).thenReturn(true); + when(bucket.get()).thenReturn("123456"); + + assertFalse(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "000000")); + verify(bucket, never()).delete(); + verify(lock).unlock(); + + assertTrue(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "123456")); + verify(bucket).delete(); + } + + @Test + void mobileSmsLoginRejectsAConflictingSystemUserEvenWhenAnAppUserExists() { + SysLoginService loginService = mock(SysLoginService.class); + SysUserMapper userMapper = mock(SysUserMapper.class); + SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper); + SysUserVo appUser = new SysUserVo(); + appUser.setUserType(UserType.APP_USER.getUserType()); + when(userMapper.exists(any())).thenReturn(true); + when(userMapper.selectVoOne(any())).thenReturn(appUser); + + assertThrows(RuntimeException.class, + () -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true)); + verify(userMapper, never()).selectVoOne(any()); + verify(userMapper, never()).insert(any(SysUser.class)); } } diff --git a/backend/ruoyi-common/ruoyi-common-core/pom.xml b/backend/ruoyi-common/ruoyi-common-core/pom.xml index ad37e90d..669537c0 100644 --- a/backend/ruoyi-common/ruoyi-common-core/pom.xml +++ b/backend/ruoyi-common/ruoyi-common-core/pom.xml @@ -94,6 +94,12 @@ ip2region + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SmsCodeUtils.java b/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SmsCodeUtils.java new file mode 100644 index 00000000..e9a90cae --- /dev/null +++ b/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SmsCodeUtils.java @@ -0,0 +1,26 @@ +package org.dromara.common.core.utils; + +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.constant.TenantConstants; + +/** + * SMS verification codes are scoped to the authenticated tenant boundary. + */ +public final class SmsCodeUtils { + + public static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051"; + private static final String VERIFY_LOCK_SUFFIX = ":verify-lock"; + + private SmsCodeUtils() { + } + + public static String cacheKey(String tenantId, String phonenumber) { + String resolvedTenantId = StringUtils.blankToDefault(tenantId, TenantConstants.DEFAULT_TENANT_ID); + String resolvedPhone = StringUtils.blankToDefault(phonenumber, ""); + return GlobalConstants.CAPTCHA_CODE_KEY + "sms:" + resolvedTenantId + ":" + resolvedPhone; + } + + public static String verificationLockKey(String tenantId, String phonenumber) { + return cacheKey(tenantId, phonenumber) + VERIFY_LOCK_SUFFIX; + } +} diff --git a/backend/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SmsCodeUtilsTest.java b/backend/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SmsCodeUtilsTest.java new file mode 100644 index 00000000..fb0db822 --- /dev/null +++ b/backend/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SmsCodeUtilsTest.java @@ -0,0 +1,22 @@ +package org.dromara.common.core.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +@Tag("dev") +class SmsCodeUtilsTest { + + private static final String TEST_MOBILE = "13900000000"; + + @Test + void scopesSmsCodeKeysByTenantAndPhone() { + String defaultTenant = SmsCodeUtils.cacheKey("000000", TEST_MOBILE); + String anotherTenant = SmsCodeUtils.cacheKey("tenant-b", TEST_MOBILE); + + assertNotEquals(defaultTenant, anotherTenant); + assertEquals(defaultTenant, SmsCodeUtils.cacheKey("", TEST_MOBILE)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index f17d0061..4e80f527 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest; import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; +import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse; +import org.dromara.aihr.domain.AihrPracticeDto.HelpRequest; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest; import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse; @@ -90,6 +92,15 @@ public class AihrPracticeController { return R.ok(practiceSeedService.prepCard(id)); } + @PostMapping("/sessions/{sessionId}/help") + public R recordHelp(@PathVariable String sessionId, @RequestBody(required = false) HelpRequest request) { + String username = currentAppUsername(); + if (username.isBlank()) { + return R.fail("请使用员工端账号发起求助"); + } + return R.ok(practiceSeedService.recordHelp(sessionId, request == null ? null : request.roundIndex(), username)); + } + @GetMapping("/rubrics") public R> rubrics(@RequestParam(required = false) String scenarioId) { return R.ok(practiceSeedService.rubrics(scenarioId)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java index 3a97eb89..5acd11c5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java @@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest; import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse; import org.dromara.aihr.service.AihrMultipartFiles; import org.dromara.aihr.service.AihrSpeechService; +import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.enums.UserType; @@ -29,6 +30,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; import java.util.List; +import java.util.Optional; /** * 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。 @@ -115,6 +117,10 @@ public class AihrSpeechController { @PostMapping("/tts") public R tts(@RequestBody TtsRequest request) { + Optional invalidDialect = AihrSpeechService.invalidDialectMessage(request == null ? null : request.voiceProfile()); + if (invalidDialect.isPresent()) { + return R.fail(HttpStatus.BAD_REQUEST, invalidDialect.get()); + } if (!speechService.ttsConfigured()) { return R.fail(NOT_CONFIGURED_HINT); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index e34c6f61..6f42fe2e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -107,6 +107,12 @@ public final class AihrPracticeDto { public record PrepCardResponse(List keyPoints, List redLines, List scripts, String promptVersion) { } + public record HelpRequest(Integer roundIndex) { + } + + public record HelpEventResponse(String sessionId, String scenarioId, Integer roundIndex, String recordedAt) { + } + public record RealtimeSdpRequest(String offerSdp) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java index b7443ec8..c1f38a94 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java @@ -14,7 +14,7 @@ public final class AihrSpeechDto { public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) { } - public record VoiceProfile(String role, String voice, Double speed, String emotion) { + public record VoiceProfile(String role, String voice, Double speed, String emotion, String dialect) { } public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java index 96163b7e..85a33d87 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -23,6 +24,7 @@ public class AihrPracticeLlmService { private static final double CUSTOMER_TEMPERATURE = 0.7; private static final double SCORE_TEMPERATURE = 0.0; private static final double DAILY_DRILL_TEMPERATURE = 0.0; + private static final double PREP_CARD_TEMPERATURE = 0.0; private static final int MAX_CUSTOMER_CHARS = 80; private final AihrModelSeedService modelService; @@ -57,6 +59,24 @@ public class AihrPracticeLlmService { public record DailyDrillAssessment(int score, String feedback) { } + public record PrepCard(List keyPoints, List redLines, List scripts) { + } + + /** + * 训练前卡严格解析固定三栏 JSON。模板由调用方从租户提示词配置加载,模型不可用时返回 empty, + * 由场景字段回退,不让训练入口因为模型调用失败不可用。 + */ + public Optional prepCard(String systemPrompt, String userPrompt) { + if (systemPrompt == null || systemPrompt.isBlank() || userPrompt == null || userPrompt.isBlank()) { + return Optional.empty(); + } + return modelService.tryChat( + AihrSensitiveText.forModel(systemPrompt), + AihrSensitiveText.forModel(userPrompt), + PREP_CARD_TEMPERATURE) + .flatMap(this::parsePrepCard); + } + /** * 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。 */ @@ -250,6 +270,40 @@ public class AihrPracticeLlmService { } } + private Optional parsePrepCard(String content) { + try { + JsonNode root = objectMapper.readTree(extractJson(content)); + List keyPoints = jsonStrings(root.path("keyPoints"), 3, 120); + List redLines = jsonStrings(root.path("redLines"), 3, 120); + List scripts = jsonStrings(root.path("scripts"), 2, 160); + if (keyPoints.size() != 3 || redLines.size() != 3 || scripts.size() != 2) { + return Optional.empty(); + } + return Optional.of(new PrepCard(keyPoints, redLines, scripts)); + } catch (Exception e) { + log.warn("practice prep card parse failed, falls back to scenario(处理错误已隐藏)"); + return Optional.empty(); + } + } + + private static List jsonStrings(JsonNode node, int expectedItems, int maxChars) { + if (node == null || !node.isArray() || node.size() != expectedItems) { + return List.of(); + } + List values = new ArrayList<>(expectedItems); + for (JsonNode item : node) { + if (!item.isTextual()) { + return List.of(); + } + String value = truncateText(item.asText(), maxChars).trim(); + if (value.isBlank()) { + return List.of(); + } + values.add(value); + } + return values; + } + /** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */ private static int dimension(JsonNode root, String field, int fallback) { JsonNode node = root.path(field); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index fe944d9c..975006f0 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -17,6 +17,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse; +import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse; import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; @@ -90,6 +91,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; /** @@ -109,6 +111,20 @@ public class AihrPracticeSeedService { "detailed", "内容更细致,拆成更具体的动作、信息点和反馈节点。", "professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。" ); + private static final String PREP_CARD_SYSTEM_PROMPT = """ + 你是物业培训教练。只返回 JSON,不要 markdown。字段固定为 keyPoints、redLines、scripts。 + keyPoints 和 redLines 必须各 3 条,scripts 必须 2 条;只能依据输入场景和 SOP 信息,不得编造时限、政策或承诺。 + """; + private static final String PREP_CARD_USER_TEMPLATE = """ + 场景:{{scenarioName}}。 + 显性诉求:{{explicitNeed}}。 + SOP引用:{{sopRefs}}。 + 成功条件:{{successCriteria}}。 + 易激化点:{{irritants}}。 + 可强化表达:{{delights}}。 + 请生成训练前预习卡。 + """; + private static final String PREP_CARD_PROMPT_VERSION = "builtin-prep_card-v1"; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm"); private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); // 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。 @@ -422,7 +438,7 @@ public class AihrPracticeSeedService { public PrepCardResponse prepCard(String scenarioId) { ensureScenarioEnabledForStart(scenarioId); ScenarioResponse scenario = scenario(scenarioId); - return new PrepCardResponse( + PrepCardResponse fallback = new PrepCardResponse( List.of( firstNonBlank(scenario.explicitNeed(), "先确认业主当前诉求"), firstNonBlank(scenario.successCriteria(), "给出责任人、处理节点和反馈方式"), @@ -433,8 +449,33 @@ public class AihrPracticeSeedService { "我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。", firstNonBlank(scenario.delights(), "这个问题我会跟进到闭环,过程节点会同步给您。") ), - "builtin-prep_card-v1" + PREP_CARD_PROMPT_VERSION ); + PrepCardPrompt prompt = prepCardPrompt(); + if (practiceLlmService == null) { + return fallback; + } + return practiceLlmService.prepCard(prompt.systemPrompt(), renderPrepCardPrompt(prompt.template(), scenario)) + .map(card -> new PrepCardResponse(card.keyPoints(), card.redLines(), card.scripts(), prompt.promptVersion())) + .orElseGet(() -> new PrepCardResponse( + fallback.keyPoints(), fallback.redLines(), fallback.scripts(), prompt.promptVersion() + ":fallback" + )); + } + + /** 记录员工主动点开的训练求助,和知识检索日志分开保存,便于后续复盘和知识补齐。 */ + public HelpEventResponse recordHelp(String sessionId, Integer ignoredRequestedRoundIndex, String ownerIdentity) { + ActiveSession session = isBlank(sessionId) ? null : activeSessions.get(sessionId); + requireSessionOwner(session, ownerIdentity, tenantId()); + ScenarioSeed scenario = resolveScenario(session.scenarioId(), sessionId); + int roundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), scenario.rounds().size()); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_help_event + (tenant_id, session_id, scenario_id, ext_party_id, round_index, create_time) + VALUES (?, ?, ?, ?, ?, ?) + """, + session.tenantId(), sessionId, scenario.id(), session.extPartyId(), roundIndex, Timestamp.valueOf(now)); + return new HelpEventResponse(sessionId, scenario.id(), roundIndex, now.format(TIME_FORMATTER)); } public List rubrics(String scenarioId) { @@ -540,7 +581,12 @@ public class AihrPracticeSeedService { List customerLines = new ArrayList<>(); customerLines.add(firstRound.customer()); long promptPresentedAt = System.currentTimeMillis(); - activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>())); + activeSessions.put(sessionId, new ActiveSession( + tenantId(), scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), + isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()), + new AtomicInteger(0), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, + new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>() + )); return new StartResponse( sessionId, scenario.id(), @@ -566,14 +612,24 @@ public class AihrPracticeSeedService { String style = normalizeRegenerateStyle(request == null ? null : request.style()); ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId()); if (!isBlank(ownerIdentity)) { - requireSessionOwner(session, ownerIdentity); + requireSessionOwner(session, ownerIdentity, tenantId()); } validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId); ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId()); - int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size()); + if (session == null) { + return handleTurn(request, null, scenario, style); + } + synchronized (session) { + return handleTurn(request, session, scenario, style); + } + } + + private TurnResponse handleTurn(TurnRequest request, ActiveSession session, ScenarioSeed scenario, String style) { if (request != null && Boolean.TRUE.equals(request.regenerate())) { + int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size()); return regenerateCoachHint(request, session, scenario, roundIndex, style); } + int roundIndex = authoritativeRoundIndex(session, request == null ? null : request.roundIndex(), scenario.rounds().size()); rememberResponseLatency(session, roundIndex, System.currentTimeMillis()); rememberTraineeReply(request, roundIndex); RoundSeed currentRound = scenario.rounds().get(roundIndex); @@ -587,6 +643,9 @@ public class AihrPracticeSeedService { String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint(); rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint); if (!finished) { + if (!redFlag && session != null) { + session.currentRoundIndex().set(nextRoundIndex); + } rememberPromptPresentedAt(session, nextRoundIndex, System.currentTimeMillis()); } return new TurnResponse( @@ -602,6 +661,35 @@ public class AihrPracticeSeedService { ); } + private int authoritativeRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) { + if (session == null) { + return normalizeRoundIndex(requestedRoundIndex, roundCount); + } + int activeRoundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), roundCount); + if (requestedRoundIndex != null && (requestedRoundIndex < 0 + || requestedRoundIndex >= roundCount + || requestedRoundIndex != activeRoundIndex)) { + throw new ServiceException("训练回合已变化,请查看最新对话后再继续"); + } + return activeRoundIndex; + } + + private int regenerateRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) { + if (session == null) { + return normalizeRoundIndex(requestedRoundIndex, roundCount); + } + int roundIndex = requestedRoundIndex == null + ? normalizeRoundIndex(session.currentRoundIndex().get(), roundCount) + : requestedRoundIndex; + if (roundIndex < 0 || roundIndex >= roundCount) { + throw new ServiceException("训练回合已变化,请查看最新对话后再继续"); + } + if (turnEvidence(session, roundIndex) == null) { + throw new ServiceException("请先完成该回合后再调整教练建议"); + } + return roundIndex; + } + private TurnResponse regenerateCoachHint(TurnRequest request, ActiveSession session, ScenarioSeed scenario, int roundIndex, String style) { if (isBlank(style)) { throw new ServiceException("请选择有效的调整风格"); @@ -717,7 +805,7 @@ public class AihrPracticeSeedService { activeSession = sessionId == null ? null : activeSessions.remove(sessionId); } else { ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId); - requireSessionOwner(existing, ownerIdentity); + requireSessionOwner(existing, ownerIdentity, tenantId()); activeSession = activeSessions.remove(sessionId); } ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId); @@ -3343,10 +3431,12 @@ public class AihrPracticeSeedService { return request.extPartyId().trim(); } - private static void requireSessionOwner(ActiveSession session, String ownerIdentity) { + private static void requireSessionOwner(ActiveSession session, String ownerIdentity, String requestTenantId) { String owner = ownerIdentity == null ? "" : ownerIdentity.trim(); String sessionOwner = session == null || session.extPartyId() == null ? "" : session.extPartyId().trim(); - if (session == null || !owner.equals(sessionOwner)) { + String tenant = requestTenantId == null ? "" : requestTenantId.trim(); + String sessionTenant = session == null || session.tenantId() == null ? "" : session.tenantId().trim(); + if (session == null || !owner.equals(sessionOwner) || !tenant.equals(sessionTenant)) { throw new ServiceException("训练会话不存在或无权访问"); } } @@ -4039,6 +4129,41 @@ public class AihrPracticeSeedService { """, this::mapPromptTemplate, tenantId(), id); } + private PrepCardPrompt prepCardPrompt() { + if (jdbcTemplate == null) { + return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION); + } + try { + List rows = jdbcTemplate.query(""" + SELECT system_prompt, template, version + FROM aihr_prompt_template + WHERE tenant_id = ? AND code = 'prep_card' AND output_format = 'json_prep' AND enabled = 1 + ORDER BY version DESC, id DESC + LIMIT 1 + """, (rs, rowNum) -> new PrepCardPrompt( + firstNonBlank(rs.getString("system_prompt"), PREP_CARD_SYSTEM_PROMPT), + firstNonBlank(rs.getString("template"), PREP_CARD_USER_TEMPLATE), + "prep_card:" + firstNonBlank(rs.getString("version"), "v1") + ), tenantId()); + if (!rows.isEmpty()) { + return rows.get(0); + } + } catch (DataAccessException ignored) { + // Prompt table is optional in the local seed fallback. + } + return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION); + } + + private String renderPrepCardPrompt(String template, ScenarioResponse scenario) { + return firstNonBlank(template, PREP_CARD_USER_TEMPLATE) + .replace("{{scenarioName}}", firstNonBlank(scenario.name(), "")) + .replace("{{explicitNeed}}", firstNonBlank(scenario.explicitNeed(), "")) + .replace("{{sopRefs}}", firstNonBlank(scenario.sopRefs(), "")) + .replace("{{successCriteria}}", firstNonBlank(scenario.successCriteria(), "")) + .replace("{{irritants}}", firstNonBlank(scenario.irritants(), "")) + .replace("{{delights}}", firstNonBlank(scenario.delights(), "")); + } + private CalibrationResponse latestCalibration(String sessionId) { List rows = jdbcTemplate.query(""" SELECT id, session_id, original_score, corrected_score, reviewer, reason, status, create_time @@ -5484,6 +5609,9 @@ public class AihrPracticeSeedService { private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { } + private record PrepCardPrompt(String systemPrompt, String template, String promptVersion) { + } + private record OrgScopeRow(String projectCode, String positionLevel) { } @@ -5538,8 +5666,9 @@ public class AihrPracticeSeedService { } } - private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt, - List traineeReplies, List traineeAudioUrls, List traineeAudioOssIds, + private record ActiveSession(String tenantId, String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, + LocalDateTime startedAt, AtomicInteger currentRoundIndex, List traineeReplies, + List traineeAudioUrls, List traineeAudioOssIds, List customerLines, List turnEvidence, List promptPresentedAtMillis, List responseLatenciesMs) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java index 1f22d5d6..47e24199 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java @@ -47,6 +47,11 @@ public class AihrSpeechService { "intense", "请用情绪强烈、急切、有压力且明显不满的语气说", "calm", "请用平静、自然的语气说" ); + private static final Map DIALECT_PROMPTS = Map.of( + "mandarin", "请使用自然普通话表达", + "cantonese", "请使用自然粤语口语表达", + "sichuanese", "请使用自然四川话口语表达" + ); private final AihrModelSeedService modelService; private final ObjectMapper objectMapper; @@ -59,6 +64,16 @@ public class AihrSpeechService { return modelService.speechModel("tts").isPresent(); } + public static Optional invalidDialectMessage(VoiceProfile voiceProfile) { + String rawDialect = voiceProfile == null || voiceProfile.dialect() == null + ? "" + : voiceProfile.dialect().trim(); + if (rawDialect.isEmpty() || DIALECT_PROMPTS.containsKey(rawDialect.toLowerCase(Locale.ROOT))) { + return Optional.empty(); + } + return Optional.of("dialect 仅支持 mandarin、cantonese、sichuanese"); + } + /** * 语音转写:POST {base}/audio/transcriptions(multipart)。 */ @@ -132,9 +147,11 @@ public class AihrSpeechService { } String emotion = normalizedEmotion(voiceProfile == null ? null : voiceProfile.emotion(), role); + String dialect = normalizedDialect(voiceProfile == null ? null : voiceProfile.dialect()); String sanitizedText = AihrSensitiveText.forModel(text.trim()); - String input = voiceProfile != null && expressiveCosyVoice && EMOTION_PROMPTS.containsKey(emotion) - ? EMOTION_PROMPTS.get(emotion) + "。<|endofprompt|>" + sanitizedText + String expressivePrompt = expressivePrompt(emotion, dialect); + String input = voiceProfile != null && expressiveCosyVoice && !expressivePrompt.isBlank() + ? expressivePrompt + "。<|endofprompt|>" + sanitizedText : sanitizedText; ObjectNode body = objectMapper.createObjectNode(); @@ -174,6 +191,26 @@ public class AihrSpeechService { }; } + private static String normalizedDialect(String dialect) { + String value = dialect == null ? "" : dialect.trim().toLowerCase(Locale.ROOT); + if (value.isBlank() || DIALECT_PROMPTS.containsKey(value)) { + return value; + } + throw new IllegalArgumentException("dialect 仅支持 mandarin、cantonese、sichuanese"); + } + + private static String expressivePrompt(String emotion, String dialect) { + String emotionPrompt = EMOTION_PROMPTS.getOrDefault(emotion, ""); + String dialectPrompt = DIALECT_PROMPTS.getOrDefault(dialect, ""); + if (emotionPrompt.isBlank()) { + return dialectPrompt; + } + if (dialectPrompt.isBlank()) { + return emotionPrompt; + } + return emotionPrompt + "," + dialectPrompt; + } + private static double resolveSpeed(Double speed, String emotion) { if (speed != null && Double.isFinite(speed)) { return Math.max(0.7, Math.min(1.3, speed)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java index 38323d4e..3396682a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java @@ -43,4 +43,52 @@ class AihrPracticeLlmServiceTest { assertTrue(service.scoreDailyDrill("题目", "参考答案", "员工回答").isEmpty()); } + + @Test + void prepCardParsesOnlyTheFixedThreeColumnStructure() { + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.tryChat(anyString(), anyString(), eq(0.0))) + .thenReturn(Optional.of(""" + {"keyPoints":["先安抚","确认责任人","给首次反馈时间"], + "redLines":["不推责","不绝对承诺","不与业主争辩"], + "scripts":["我先确认现场情况","十分钟内给您明确节点"], + "extra":"不应透出"} + """)); + AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper()); + + var card = service.prepCard("只返回 JSON", "投诉处理场景"); + + assertTrue(card.isPresent()); + assertEquals(3, card.get().keyPoints().size()); + assertEquals(3, card.get().redLines().size()); + assertEquals(2, card.get().scripts().size()); + } + + @Test + void prepCardRejectsPartialColumnsSoTheCallerCanUseScenarioFallback() { + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.tryChat(anyString(), anyString(), eq(0.0))) + .thenReturn(Optional.of(""" + {"keyPoints":["先安抚","确认责任人"], + "redLines":["不推责","不绝对承诺","不争辩"], + "scripts":["我先确认现场情况","十分钟内给您明确节点"]} + """)); + AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper()); + + assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty()); + } + + @Test + void prepCardRejectsExtraItemsInsteadOfSilentlyTruncatingThem() { + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.tryChat(anyString(), anyString(), eq(0.0))) + .thenReturn(Optional.of(""" + {"keyPoints":["先安抚","确认责任人","给首次反馈时间","多余项"], + "redLines":["不推责","不绝对承诺","不争辩"], + "scripts":["我先确认现场情况","十分钟内给您明确节点"]} + """)); + AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper()); + + assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty()); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 9b598e39..b934d02e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -16,9 +16,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -43,6 +46,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -52,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -550,6 +555,95 @@ public class AihrPracticeSeedServiceTest { assertEquals("训练会话不存在或无权访问", missing.getMessage()); } + @Test + public void m2PrepCardUsesTheConfiguredTemplatePathAndHelpIsPersistedForTheSessionOwner() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water"))) + .thenReturn(List.of()); + when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water"))) + .thenReturn(List.of()); + when(jdbcTemplate.query(contains("FROM aihr_prompt_template"), any(RowMapper.class), eq("000000"))) + .thenReturn(List.of()); + AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class); + when(llm.prepCard(anyString(), anyString())).thenReturn(Optional.of(new AihrPracticeLlmService.PrepCard( + List.of("先安抚", "确认事实", "给反馈时间"), + List.of("不推责", "不绝对承诺", "不争辩"), + List.of("我先确认情况", "十分钟内回复您") + ))); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null); + + var prep = service.prepCard("complaint-water"); + var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null)); + var help = service.recordHelp(session.sessionId(), 2, "employee-a"); + + assertEquals(List.of("先安抚", "确认事实", "给反馈时间"), prep.keyPoints()); + assertEquals("builtin-prep_card-v1", prep.promptVersion()); + assertEquals(session.sessionId(), help.sessionId()); + assertEquals("complaint-water", help.scenarioId()); + assertEquals(0, help.roundIndex()); + verify(jdbcTemplate).update( + contains("INSERT INTO aihr_practice_help_event"), + eq("000000"), eq(session.sessionId()), eq("complaint-water"), eq("employee-a"), eq(0), any(Timestamp.class) + ); + assertThrows(ServiceException.class, () -> service.recordHelp(session.sessionId(), 0, "employee-b")); + } + + @Test + public void m2HelpRejectsAValidOwnerWhenTheTenantContextChanged() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water"))) + .thenReturn(List.of()); + when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water"))) + .thenReturn(List.of()); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + try (MockedStatic tenantHelper = Mockito.mockStatic(TenantHelper.class, Mockito.CALLS_REAL_METHODS)) { + tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-a"); + var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null)); + tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b"); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.recordHelp(session.sessionId(), 0, "employee-a")); + assertEquals("训练会话不存在或无权访问", error.getMessage()); + } + verify(jdbcTemplate, never()).update(contains("INSERT INTO aihr_practice_help_event"), ArgumentMatchers.any()); + } + + @Test + public void m2TurnRejectsSkippedRoundsAndHelpUsesTheServerTrackedRound() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water"))) + .thenReturn(List.of()); + when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water"))) + .thenReturn(List.of()); + AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class); + when(llm.customerTurn( + anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(), + ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString() + )).thenReturn(Optional.empty()); + when(llm.customerTurn( + anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(), + ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString(), anyString() + )).thenReturn(Optional.empty()); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null); + var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null)); + + ServiceException skipped = assertThrows(ServiceException.class, + () -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a")); + assertEquals("训练回合已变化,请查看最新对话后再继续", skipped.getMessage()); + var helpBeforeValidTurn = service.recordHelp(session.sessionId(), 2, "employee-a"); + var firstTurn = service.turn(new TurnRequest(session.sessionId(), 0, "我先确认现场情况", null, null, false, null), "employee-a"); + var adjustedFirstTurn = service.turn(new TurnRequest( + session.sessionId(), 0, "我先确认现场情况", null, null, true, "serious" + ), "employee-a"); + var help = service.recordHelp(session.sessionId(), 2, "employee-a"); + + assertEquals(0, helpBeforeValidTurn.roundIndex()); + assertEquals(1, firstTurn.roundIndex()); + assertEquals(1, adjustedFirstTurn.roundIndex()); + assertEquals(1, help.roundIndex()); + } + @Test public void practiceAudioMustMatchCurrentAppUploaderAndDailyAudioIsReadable() throws Exception { String service = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java index 67edccb3..3d7432d0 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("dev") @@ -25,21 +26,38 @@ class AihrSpeechServiceTest { var mentor = AihrSpeechService.buildRequestBody( mapper, runtime, "先承接情绪,再说明处理节点。", null, - new VoiceProfile("mentor", null, null, null)); + new VoiceProfile("mentor", null, null, null, "mandarin")); var customer = AihrSpeechService.buildRequestBody( mapper, runtime, "你们到底什么时候处理?", null, - new VoiceProfile("customer", null, 9.0, "intense")); + new VoiceProfile("customer", null, 9.0, "intense", "sichuanese")); var legacy = AihrSpeechService.buildRequestBody( mapper, runtime, "普通播报", "alex", null); - assertEquals("FunAudioLLM/CosyVoice2-0.5B:anna", mentor.path("voice").asText()); + assertEquals("speech:shifu-warm-v1:cm3hz4wfz02jy106j6z6muix7:ysvbyzypjgmnceokxedr", mentor.path("voice").asText()); assertEquals(0.88, mentor.path("speed").asDouble()); assertTrue(mentor.path("input").asText().contains("师傅语气")); assertEquals("FunAudioLLM/CosyVoice2-0.5B:bella", customer.path("voice").asText()); assertEquals(1.3, customer.path("speed").asDouble()); assertTrue(customer.path("input").asText().contains("情绪强烈")); + assertTrue(customer.path("input").asText().contains("四川话")); assertEquals("FunAudioLLM/CosyVoice2-0.5B:alex", legacy.path("voice").asText()); assertFalse(legacy.has("speed")); assertFalse(legacy.path("input").asText().contains("<|endofprompt|>")); } + + @Test + void rejectsUnknownDialectInsteadOfSilentlyFallingBackToMandarin() { + ObjectMapper mapper = new ObjectMapper(); + SpeechModel runtime = new SpeechModel( + "siliconflow", + "FunAudioLLM/CosyVoice2-0.5B", + "https://api.siliconflow.cn/v1", + "test-key" + ); + VoiceProfile invalid = new VoiceProfile("customer", null, null, "calm", "minnan"); + + assertTrue(AihrSpeechService.invalidDialectMessage(invalid).isPresent()); + assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody( + mapper, runtime, "测试", null, invalid)); + } } diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 504fb1db..881807e9 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -62,6 +62,19 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录'; +CREATE TABLE IF NOT EXISTS `aihr_practice_help_event` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `scenario_id` varchar(80) NOT NULL COMMENT '训练场景ID', + `ext_party_id` varchar(100) DEFAULT NULL COMMENT '员工外部主体ID', + `round_index` int NOT NULL DEFAULT 0 COMMENT '求助时所在回合', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_practice_help_session` (`tenant_id`, `session_id`, `create_time`), + KEY `idx_aihr_practice_help_party` (`tenant_id`, `ext_party_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练求助事件'; + CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', diff --git a/backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql b/backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql new file mode 100644 index 00000000..834df266 --- /dev/null +++ b/backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql @@ -0,0 +1,22 @@ +-- AIHR M2 训练求助事件迁移(MySQL 8.x) +-- 训练中的“求助 SOP”与知识检索日志分开留痕;可重复执行。 + +CREATE TABLE IF NOT EXISTS `aihr_practice_help_event` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `scenario_id` varchar(80) NOT NULL COMMENT '训练场景ID', + `ext_party_id` varchar(100) DEFAULT NULL COMMENT '员工外部主体ID', + `round_index` int NOT NULL DEFAULT 0 COMMENT '求助时所在回合', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_practice_help_session` (`tenant_id`, `session_id`, `create_time`), + KEY `idx_aihr_practice_help_party` (`tenant_id`, `ext_party_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练求助事件'; + +-- 兼容曾由开发环境运行时 DDL 创建的同名表,统一到发布 schema 的排序规则。 +ALTER TABLE `aihr_practice_help_event` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +ALTER TABLE `aihr_practice_help_event` + MODIFY COLUMN `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号'; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index ddaa98ff..16e4fda2 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -9,8 +9,9 @@ | AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 | | 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 seed,契约不变 | +| M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该契约当前仅完成本地实现与验证,线上尚未有该表。 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | -| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?}}`,旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速,设备语音降级同步调整语速与音高;模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | +| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时,内部文字查询还可只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回后续问即拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` | @@ -20,7 +21,7 @@ | 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee/employee_project_assignment` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + ext_party_id`;只拒绝同一项目内的重复成员关系。`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝同项目重复关系。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。该统计发生在多项目成员迁移前;发布迁移后必须重新 dry-run 并核对展开后的项目成员行数。当前剩余 25 人手机号不可用属于上游数据质量问题 | -| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | +| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;移动专用接口固定服务端默认租户,客户端不传也不能选择 `tenantId`;验证码按“默认租户 + 手机号”隔离。校验在手机号粒度的分布式锁内完成:仅匹配成功才消费,输错不会作废原验证码。移动端令牌只关联 `app_user`;若同一手机号存在任何后台/系统账号(即使同时存在 `app_user`),一律拒绝登录而不复用或并置身份;手机号完全不存在时才自动注册 `app_user`。`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端。响应返回去重后的 `projects[]` 供页面按项目名称选择,不要求用户输入或记忆编码;同一人员在多个项目的快照行按 `tenant_id + project_code + ext_party_id` 保留。接口失败回退员工端 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 | @@ -40,7 +41,7 @@ 当前管理端 AI 面试和案例沉淀已改为真实模型/ASR 优先;移动端首页和部分演示态数据仍保留 fallback。知识库、模型能力、文档解析、RAG、chat 按 [ruoyi-ai 能力分片迁移计划](RUOYI_AI_INCREMENTAL_MIGRATION.md) 逐片引入;知识库 DDL 与住宅类 SOP seed 在 `backend/script/sql/aihr_knowledge_mysql8.sql`,模型 DDL 在 `backend/script/sql/aihr_model_mysql8.sql`,训练记录 DDL 在 `backend/script/sql/aihr_practice_mysql8.sql`,AI 面试结果 DDL 在 `backend/script/sql/aihr_interview_result_mysql8.sql`,候选人资料 DDL 在 `backend/script/sql/aihr_candidate_material_mysql8.sql`,组织人员快照表在 `backend/script/sql/aihr_org_snapshot_mysql8.sql`,本地 reset 带 2 个住宅项目 22 人 seed;配置开放组织同步系统后用 `POST /api/aihr/org/sync` 覆盖为外部快照。 -直接打后端 `/api/**` 通常需要登录后的 `Authorization: Bearer `;浏览器内通过已登录前端和 `/dev-api` 代理访问。移动端登录接口为 `POST /auth/mobile/sms-login`,请求 `{ phonenumber, smsCode, tenantId }`,内部固定使用 app 客户端 `428a8310cd442757ae699df5d894f051` 和 `sms` grant;验证码通过后若手机号不存在,会创建 `app_user`,用户名为手机号,备注为“移动端短信自动注册”。移动端首页接口 `GET /api/aihr/mobile/home/{role}` 仍保留 `@SaIgnore` 以保证未登录首屏可用,但匿名分支只返回不读取业务统计的公开 seed;登录后的 `mobile-uni` 会自动携带移动端 token,服务端才返回真实员工/主管统计。 +直接打后端 `/api/**` 通常需要登录后的 `Authorization: Bearer `;浏览器内通过已登录前端和 `/dev-api` 代理访问。移动端登录接口为 `POST /auth/mobile/sms-login`,请求 `{ phonenumber, smsCode }`,内部固定使用 app 客户端 `428a8310cd442757ae699df5d894f051` 和 `sms` grant;验证码通过后若手机号不存在,会创建 `app_user`,用户名为手机号,备注为“移动端短信自动注册”。移动端首页接口 `GET /api/aihr/mobile/home/{role}` 仍保留 `@SaIgnore` 以保证未登录首屏可用,但匿名分支只返回不读取业务统计的公开 seed;登录后的 `mobile-uni` 会自动携带移动端 token,服务端才返回真实员工/主管统计。 阿里云短信复用 RuoYi 的 `sms.blends.config1`。本地开发把真实短信参数放根目录 `.env.local` 或外部环境变量,`scripts/dev-backend.sh` 会自动加载;`application-dev.yml` / `application-prod.yml` 只保留占位,不写真实密钥。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 78c7cd5e..db377c75 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,6 +1,6 @@ # BRD 功能审查与未完成项记录 -> 当前基线:2026-07-21。阶段一生产功能包以 `064816a784dad535f2320c4321f73f2e6e364d69` 为已记录基线;2026-07-20 确认式采集与会话式工作上报增量另按生产 JAR、H5 资源和数据库结构复核。 +> 当前基线:2026-07-23。最新完整发布源为 `f023417a`(含“大喇叭 + 问模块纠偏”业务提交 `4b31848e`);当前真相以本文件、[生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)和每次发布的只读预检为准。 > > 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。 @@ -12,15 +12,25 @@ | 层级 | 当前状态 | 证据与边界 | |---|---|---| -| 已实现 | 是 | 主功能提交 `6bdc86e3`,排序规则兼容修复 `32fa376d`,问师傅短期会话提交 `064816a7` | -| 本地自动化/API 验证 | 是 | 功能批次已完成后端、前端、移动端测试与真实本地 API 闭环;具体用例见代码测试和版本功能清单 | -| 本地浏览器/视觉验证 | 是 | 24 个移动端状态和 3 个管理端页面已操作并逐页截图,无页面级横向溢出、控制台错误或失败请求 | -| 已部署 | 是 | 管理端、H5、后端和 22 个版本化迁移已发布;线上产物哈希与本地发布产物一致 | -| 生产基础验证 | 是 | 服务 `active`;根站、H5、租户接口、员工首页 API 正常;目标 schema `35/35`,本次 19 张发布表排序规则 `19/19` 对齐;管理端关键页和移动端“新对话”已浏览器复核 | -| 生产完整业务验收 | 否 | 未用正式 OTP 重放员工/主管认证写链路,未在真实手机复验麦克风、文件/相册、下载、读屏和设备语音,也未完成严格试点窗口验收 | +| 已实现 | 是 | 已有阶段一主功能,并新增大喇叭最小闭环、消息上下文追问和“问”模块的待办/会话区纠偏;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | +| 本地自动化/API 验证 | 是 | 本批定向后端测试 `91` 项、管理端测试 `18` 项、移动端测试 `129` 项及三端构建通过;具体范围仍以各测试与专项方案为准 | +| 本地浏览器/视觉验证 | 部分 | 既有移动端和管理端视觉回归已完成;本批最终生产截图只复核未登录 H5 页面,不能替代正式认证态消息/追问流程的视觉验收 | +| 已部署 | 是 | 管理端、H5、后端和本批四个迁移已发布;线上静态资源及 AIHR 模块与本地构建一致 | +| 生产基础验证 | 是 | 服务 `active`;根站、H5、租户接口和员工首页 API 正常;发布时的远端 schema 清单为 `57/57`,后续只读复核确认 M1 定向表与必读字段存在;私有个人资料存储配置有效 | +| 生产完整业务验收 | 否 | 未用正式认证账号重放管理发布—员工阅读—消息追问,也未在真实手机复验麦克风、文件/相册、下载、读屏和设备语音;严格试点窗口同样未完成 | “已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。 +### 2026-07-23 大喇叭与问模块增量边界 + +本批已部署的公司消息能力是全员公开频道、未读/已读、当前租户的管理发布/撤回,以及以 `broadcastMessageId` 进入“问”的受控文字追问。服务端每轮重新校验租户、在职身份和消息状态,不把正文或附件文本信任为客户端上下文;消息撤回后续问即被拒绝。 + +“问”页面将连续会话设为主体,消息/知识回答不再把通用培训或岗位资料伪造成当前员工待办。M1 的必读和目标快照 schema 已由线上只读查询确认,但正式认证态的定向发布、阅读和必读闭环尚未在本轮重放;附件、AI 摘要、“与我有关”、修订、补推、强触达和外部流程仍不在已验收范围内。 + +### 2026-07-23 M2 本地实现边界 + +训练预习/求助、知识缺口留痕、动态情绪与红线纠偏、回复风格重生成、答案反馈屏蔽和方言语气参数均在本地工作区实现并按本地模型配置验证。它们尚未进入本次生产包:线上虽已有既有的录音/标注表,但缺少本批 `aihr_practice_help_event` 表;不得将 M2 的本地测试或模型配置同步写成生产部署或正式试点验收。 + ### 2026-07-20 已部署增量与验收边界 工作助手“记一下”统一采集已提交并部署:生产 `ruoyi-aihr` 包包含 `AihrMemoryService`,H5 包含确认卡资源,生产库存在 `aihr_memory_candidate/aihr_assistant_capture` 及关键确认、范围和流转字段。会话式工作上报也已出现在生产 H5 与后端包中,生产 `aihr_work_report` 已有幂等字段。以上属于生产基础存在性复核;本轮未重新执行正式账号端到端、多项目、真机媒体或真实审核样本验收。 @@ -43,6 +53,8 @@ | 字号适老化 | 标准/大字/超大字全局持久化,同一页面结构响应式增高 | 本地多视口视觉通过;真实手机触控、键盘和读屏未验收 | | 问师傅语音与播报 | 录音、选择音频、ASR、TTS/设备语音降级恢复;答案、总结卡、每日题、点评等关键内容可播报 | 生产包已部署;生产 CosyVoice2 已按老师傅/业主/面试官区分音色,业主情绪语气与语速已在生产 H5 验证;真实手机权限、弱网、方言和正式 ASR 仍未验收 | | 问师傅连续对话与资料 | 认证用户短期会话保留 30 分钟/最近 6 轮,追问改写并路由 `QA/FILE/VIDEO/DATA_TOOL`;原文件/视频只从当前命中且实时授权的附件交付,无资源不造假链接 | 生产包、schema、哈希和匿名“新对话”页面已验证;正式 OTP 认证态三轮与真机下载未在生产重放 | +| 问页面有效会话区与消息追问 | 会话开始后收起占位辅助区;大喇叭详情仅以消息 ID 受控进入短会话,消息/知识回答不伪造待办 | 当前完整包和未登录 H5 页面已生产复核;正式认证态发布—阅读—追问与 390×844 最终截图仍待验收 | +| 银城大喇叭 | 全员公开消息、未读/已读、当前租户的管理发布/撤回已部署 | 未实现定向、附件、摘要、相关性、修订和补推;不得表述为完整公司消息运营系统 | | 图片/视频与总结卡图片 | 问师傅附件仅用于本次现场分析;总结卡可下载 PNG | 生产包已部署;iOS/Android H5 下载和相册/文件权限未复验 | | 录音归属、提交无反馈、筛选错位 | 账号/页面代次隔离、旧请求回写保护、明确加载/失败状态和窄屏布局已修 | 本地双账号/交互测试通过;生产认证态未重放 | | 每日题变化与题目反馈 | 五岗位题库按日期、岗位和员工身份轮换;每日题/专项支持有用/无用反馈 | 生产 schema 和包已部署;正式题库内容审核与业务样本待补 | @@ -70,15 +82,13 @@ 4. BRD 终态仍缺员工对练图像/视频情境、服务录像回放、四川话/粤语真实样本和响应时延证据。 5. 多租户知识平台完成正式租户 ID、空间、授权、外部应用、内容迁移和标准题签字后,才可称业务上线。 -## 5. 2026-07-18 生产发布证据 +## 5. 2026-07-23 当前生产发布证据 -- 分支:`codex/multi-tenant-knowledge-platform`,已推送到 `origin`。 -- 当前生产功能包:`064816a784dad535f2320c4321f73f2e6e364d69`;前序主功能提交:`6bdc86e3`。 -- 当前发布备份:`/opt/wygj/backups/ask-memory-20260718-2130`(数据库、JAR、H5 及 SHA-256);前序备份仍保留在 `/opt/wygj/backups/release-20260718005151`。 -- 生产地址:`https://peilian.njzhmj.top/`、`https://peilian.njzhmj.top/h5/`。 -- 数据库:22 个版本化迁移已应用;目标 schema `35/35`;19 张发布表与知识主表租户字段排序规则 `19/19` 对齐,会话表两个业务索引均存在。 -- 浏览器:管理端首页、常见难题、问题榜运营、知识空间、全网 AI 配置和 390×844 移动端 H5 已查看;当前发布又实际点击了“新对话”,最终页面无控制台/请求错误。 -- 发布中发现知识空间页因历史库排序规则混用报错,已通过 `aihr_20260718_release_collation_compat_mysql8.sql` 根因修复并重新验证;禁止用 SQL 临时转换掩盖同类 schema 漂移。 +- 发布源:`codex/broadcast-question-correction` 的 `f023417a`;本批业务改动为 `4b31848e`。 +- 生产地址:`https://peilian.njzhmj.top/`、`https://peilian.njzhmj.top/h5/`;完整备份:`/opt/wygj/backups/aihr-full-20260723-094600-f023417a`。 +- 数据库:知识分类、大喇叭、消息上下文和个人资料迁移已执行;发布时 schema 清单为 `57/57`,后续只读复核确认大喇叭发布/已读/版本幂等、M1 定向表/必读字段、消息上下文和私有个人资料存储配置。该记录不包含 M2 的训练求助事件表。 +- 产物与服务:管理端/H5 资源、后端 AIHR 模块均与当前本地构建一致,`wygj-aihr.service` 为 `active`;根站、H5、租户接口和员工首页 API 返回正常业务结果。 +- 视觉证据:最终只重新查看了未登录 H5 页面。该证据说明当前产物可达,不说明认证态的消息发布、阅读、追问或真机体验已经验收。 ## 6. 当前事实源 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index f2d067a1..5a782c65 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -26,10 +26,17 @@ 上述事实仍不代表生产真机语音/照片/视频、主管正式账号、正式组织多项目 dry-run 或外部线索/工单/考勤回执已验收。个人文件、网页收藏、独立个人向量库、自动派单和未经确认的自动外发仍不在本轮范围。 +2026-07-23 已完成“大喇叭 + 问模块纠偏”相关的一轮全量迁移与重新发布,发布源为本地 `f023417a`(含业务提交 `4b31848e`): + +- 本批按当时清单执行 `aihr_20260722_knowledge_category_mysql8.sql`、`aihr_20260722_broadcast_mysql8.sql`、`aihr_20260722_broadcast_question_context_mysql8.sql` 与 `aihr_personal_knowledge_mysql8.sql`;后者建立 10 张 `aihr_personal_*` 表,并配置私有 `personal-minio` 存储与 `ruoyi-personal` bucket; +- 当时 `release-preflight` 的远端只读核验为 schema `57/57`,且 H5、管理端资源、后端 AIHR 模块均与本地构建一致,`wygj-aihr.service` 为 `active`。后续只读复核已确认 M1 的 `aihr_broadcast_target_rule`、`aihr_broadcast_target_recipient` 及必读字段存在;这只证明 schema,不能替代 M1 认证态定向/必读闭环验收; +- 本次完整备份位于 `/opt/wygj/backups/aihr-full-20260723-094600-f023417a`,包含数据库、旧静态资源和旧 JAR; +- 发布后仅完成公开接口和未登录 H5 页面的基础检查。正式认证账号的“管理发布—员工阅读—消息追问”闭环、真机媒体及完整个人资料能力仍未作为生产验收完成。 + ## 发布前置条件 1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。 -2. 发布前先执行 `scripts/release-preflight.sh` 的本地只读检查。静态资源和后端包发布完成后,再用 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh` 核对线上产物与必需表;后两个开关必须同时使用,schema 检查只读、不执行迁移。 +2. 发布前先执行 `scripts/release-preflight.sh` 的本地只读检查。完整包发布后,必须按 `docs/DEV_SETUP.md` 使用 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh` 核对线上静态资源、AIHR 模块和必需表;schema 检查只读、不执行迁移。只校验 H5 资源不能替代完整包复核。 3. 确认迁移目标库、租户和字符集为 MySQL 8 / `utf8mb4`;脚本只允许在目标业务库执行。 4. 生产组织同步凭据、AI 供应商留存/费用口径和正式试点窗口仍未闭合时,不要把迁移完成宣称为 BRD 验收完成。 @@ -48,6 +55,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql @@ -69,12 +77,15 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 ## 迁移后只读核验 @@ -85,8 +96,9 @@ SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN ( + 'aihr_prompt_template', 'aihr_practice_scenario', 'aihr_practice_rubric', 'aihr_practice_rubric_dimension', 'aihr_learning_question', - 'aihr_practice_audio', 'aihr_practice_audio_upload', 'aihr_practice_calibration', + 'aihr_practice_audio', 'aihr_practice_audio_upload', 'aihr_practice_calibration', 'aihr_practice_help_event', 'aihr_knowledge_gap', 'aihr_knowledge_answer_feedback', 'aihr_knowledge_space_grant', 'aihr_knowledge_app', 'aihr_knowledge_app_space', 'aihr_knowledge_category', 'aihr_knowledge_query_log', 'aihr_knowledge_admin_audit', 'aihr_knowledge_conversation', @@ -99,9 +111,15 @@ WHERE table_schema = DATABASE() 'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link', 'aihr_position_responsibility', 'aihr_sop_applicability', 'aihr_onboard_task', 'aihr_qualification_gate', + 'aihr_org_directory', 'aihr_tenant_org_binding', 'aihr_memory_candidate', 'aihr_assistant_capture', 'aihr_assistant_capture_status_log', 'aihr_daily_work_result', 'aihr_service_memory', 'aihr_service_memory_version', - 'aihr_work_report', 'aihr_broadcast_message', 'aihr_broadcast_read', 'aihr_broadcast_version' + 'aihr_work_report', 'aihr_broadcast_message', 'aihr_broadcast_read', 'aihr_broadcast_version', + 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient', + 'aihr_personal_space', 'aihr_personal_item', 'aihr_personal_fragment', + 'aihr_personal_chat_session', 'aihr_personal_chat_message', 'aihr_personal_cleanup_job', + 'aihr_personal_ocr_job', 'aihr_personal_ocr_page', 'aihr_personal_export_task', + 'aihr_personal_publish_request' ) ORDER BY table_name; @@ -142,6 +160,12 @@ WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' AND index_name = 'uk_aihr_broadcast_message_publish_request'; +SELECT bucket_name, access_policy, + CASE WHEN COALESCE(access_key, '') <> '' AND COALESCE(secret_key, '') <> '' + THEN 'configured' ELSE 'missing' END AS credential_state +FROM sys_oss_config +WHERE tenant_id = '000000' AND config_key = 'personal-minio'; + SELECT COUNT(*) AS built_in_prompt_templates FROM aihr_prompt_template WHERE tenant_id = '000000' @@ -160,7 +184,7 @@ GROUP BY question_type ORDER BY question_type; ``` -预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、四张记忆表、采集状态历史表、每日成果表、原工作上报表和三张大喇叭消息/阅读/v1快照表均存在;工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 +预期是三张场景/Rubric 表、岗位题库、训练反馈表与训练求助事件表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、平台组织目录与租户绑定表、四张记忆表、采集状态历史表、每日成果表、原工作上报表、三张大喇叭消息/阅读/v1快照表、两张 M1 定向规则/对象快照表及十张独立个人资料表均存在。`57/57` 只代表当时较窄的发布清单;后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,因此不得把包含该表的 M2 完整清单写成已发布。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 ## 迁移后应用回归 @@ -171,7 +195,8 @@ ORDER BY question_type; 5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。 6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。 7. 用 `superadmin` 或 `hr_operator` 在当前租户发布同一 `requestId` 两次:同内容必须只生成一条消息和一个 v1 快照,改动正文后必须返回 `409`。撤回时提交非空原因,员工端随即不可见;重复撤回不得改写首个撤回人、时间或原因。再确认管理列表不跨租户返回消息。 -8. 迁移完成后再按同一正式时间窗运行: +8. 完整包发布后运行 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`;必须同时获得线上资源、AIHR 模块和 schema 成功结果。该检查不替代第 7 项的认证态业务闭环。 +9. 迁移完成后再按同一正式时间窗运行: ```bash AIHR_PILOT_TENANT_ID=000000 \ diff --git a/docs/M0自动化验收说明.md b/docs/M0自动化验收说明.md new file mode 100644 index 00000000..6f4f3be5 --- /dev/null +++ b/docs/M0自动化验收说明.md @@ -0,0 +1,50 @@ +# 银城大喇叭 M0 自动化验收 + +## 目的 + +这是一条受控的本机写入验收链,用来证明 M0 的核心闭环,而不是替代正式试点或生产验收: + +`管理员发布 → 员工未读可见 → 查看并已读 → 围绕消息追问 → 管理员撤回 → 员工不可见且不可追问` + +## 运行方式 + +先按 [DEV_SETUP.md](DEV_SETUP.md) 启动本机依赖和后端,并由安全的测试密钥注入方式提供一对短期令牌,再执行: + +```bash +AIHR_M0_ADMIN_TOKEN='<管理员短期令牌>' \ +AIHR_M0_EMPLOYEE_TOKEN='<在职员工短期令牌>' \ +node scripts/verify-broadcast-m0-local.mjs \ + --prepare-local-query-fixture \ + --report=/tmp/broadcast-m0-acceptance.json +``` + +脚本默认访问本机 `https://wygj-api.localhost`,也接受 `localhost`、`127.0.0.1` 和 `::1` 回环地址。它只使用调用方显式注入的短期令牌,不读取服务端图形验证码、密码、Redis 会话或浏览器存储。管理员令牌须具备大喇叭发布权限;员工令牌须对应已匹配组织快照的在职员工。令牌不得写入源码、报告或提交到仓库。 + +`--prepare-local-query-fixture` 只在本机生效:当本机库尚未配置移动端知识应用、空间绑定或员工阅读授权时,它会临时补齐最小授权并在测试结束后恢复。省略该开关可直接检查现有环境配置;若消息追问因没有共同知识空间失败,应将其视为部署/配置缺口。 + +可用覆盖项: + +```bash +AIHR_BASE_URL=http://127.0.0.1:8080 \ +AIHR_M0_ADMIN_CLIENT_ID='<管理端 client id>' \ +AIHR_M0_EMPLOYEE_CLIENT_ID='<员工端 client id>' \ +node scripts/verify-broadcast-m0-local.mjs +``` + +## 受控写入与清理 + +脚本只默认允许写入回环地址(`localhost`、`127.0.0.1`、`::1` 或 `*.localhost`)。每次运行发布一条带唯一标识的临时消息;无论任一步失败,都会尝试撤回该消息。撤回不会删除发布、版本和已读审计记录。 + +非本机环境默认拒绝执行。只有同时提供 `--allow-remote-write`、`AIHR_M0_ALLOW_REMOTE_WRITE=true`、明确的专用测试租户,以及两个短期令牌时才会放行;标准发布预检不调用本脚本。 + +## 通过条件 + +脚本逐项输出 `PASS`,并最终输出 `M0_BROADCAST_ACCEPTANCE PASS`。它覆盖: + +- 管理员当前租户范围与员工无发布权限; +- 发布、相同 `requestId` 的幂等重放; +- 员工列表、详情、未读和重复已读幂等; +- 消息追问的 BCAST 引用、可信上下文以及正文不出现在查询响应中; +- 撤回审计、员工列表和详情不可见、撤回后不能再追问。 + +这条脚本是接口状态验收。管理端和员工端的真实页面操作、截图和移动端布局仍需作为单独的浏览器验收证据保存,不能仅以脚本通过替代视觉验收。 diff --git a/docs/README.md b/docs/README.md index 7dc25a95..1a6203cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ 3. 专项 TechSpec 细化单个领域;实施计划可记录本地实现与验证,但部署和生产状态仍以审查文档、接口事实和真实环境证据为准。 4. `prototypes/` 只作视觉回归基准;会议纪要、历史 Prompt、`legacy/` 与 `archive/` 只作需求来源和追溯材料。 -工作助手相关术语、实施状态与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md);当前产品优先级与本轮本地增量见[《银城帮道产品功能优先级与实施计划》](银城帮道产品功能优先级与实施计划-20260721.md)。不得把本地验证外推为完整个人知识空间或外部流转已经实现。 +工作助手相关术语、实施状态与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md);当前产品优先级与本轮增量见[《银城帮道产品功能优先级与实施计划》](银城帮道产品功能优先级与实施计划-20260721.md),大喇叭与消息追问的实际交付范围见[《银城大喇叭与问模块纠偏增量方案》](银城大喇叭与问模块纠偏增量方案-20260722.md)。不得把本地验证外推为完整个人知识空间、完整大喇叭运营能力或外部流转已经实现。 ## 当前项目文档 @@ -37,7 +37,8 @@ | [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 工作助手确认式统一采集的当前契约,以及后续个人知识空间的数据分域、检索、权限、安全和测试规格 | | [工作助手与今日工作成果迭代计划-20260721.md](工作助手与今日工作成果迭代计划-20260721.md) | 工作助手项目选择、统一采集、来源追溯、今日成果、主管视图、成果投稿和外部流转的分阶段计划;含本地实施状态、390×844 证据和剩余生产边界 | | [银城帮道产品功能优先级与实施计划-20260721.md](银城帮道产品功能优先级与实施计划-20260721.md) | 对《银城帮道介绍》十项能力进行事实校正,给出 P0-P3、明确不做项、发布批次和本轮实施记录;产品全景施工入口 | -| [银城大喇叭与问模块纠偏增量方案-20260722.md](银城大喇叭与问模块纠偏增量方案-20260722.md) | 银城大喇叭的发布、阅读、消息追问与权限边界,以及“问”模块真实任务问答纠偏的分批实施、验收与回滚方案 | +| [银城大喇叭与问模块纠偏增量方案-20260722.md](银城大喇叭与问模块纠偏增量方案-20260722.md) | 已部署的大喇叭核心、消息追问与“问”模块纠偏范围,以及尚未纳入首版的定向、附件、摘要和运营能力 | +| [M0自动化验收说明.md](M0自动化验收说明.md) | 本机受控写入的“发布—阅读—追问—撤回”接口验收边界、运行方式与清理约束 | | [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 后续个人资料/网页/PPT 能力的 P0/P1 任务计划;“记一下”当前实现以专项 TechSpec 为准 | | [prototypes/personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) | 个人助理并入「问 · 数字师傅」后的语音优先会话、自动内部路由与确认记忆卡实现基准 | | [prototypes/work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png) | 当前“工作上报”(产品目标名“成果投稿”)会话式页面的视觉回归基准;不代表工作助手日常采集或今日成果页 | @@ -47,7 +48,7 @@ | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 | | [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | -| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、只读核验和发布后回归口径 | +| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、57 项结构核验、私有个人资料存储配置和发布后回归口径 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | `mobile-uni/` 重建阶段留档;当前完成状态以 BRD 审查和版本功能对比为准 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)、工作上报微信式语音交互定案、半年会发布会方案分层及客户需求逐项处置对照 | | [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 | diff --git a/docs/银城大喇叭与问模块纠偏增量方案-20260722.md b/docs/银城大喇叭与问模块纠偏增量方案-20260722.md index cff31f4f..3e3b2842 100644 --- a/docs/银城大喇叭与问模块纠偏增量方案-20260722.md +++ b/docs/银城大喇叭与问模块纠偏增量方案-20260722.md @@ -1,8 +1,8 @@ # “银城大喇叭 + 问模块纠偏”增量方案 -> 版本:v1.0 -> 日期:2026-07-22 -> 状态:A(问模块事实纠偏)与 B(银城大喇叭核心)已本地实现并完成定向验证;尚未部署,C—E 仍待实施。 +> 版本:v1.1 +> 日期:2026-07-23 +> 状态:A 的事实纠偏、B 的最小消息闭环和 C 的消息上下文追问已部署,并完成当前构建、线上产物、后端模块和 `57/57` schema 的一致性核验;本方案列出的完整 B 能力和 D—E 仍待实施。 > 适用范围:银城帮道员工端、管理端与 `ruoyi-aihr` 业务模块 > 关联方案:[产品功能优先级与实施计划](银城帮道产品功能优先级与实施计划-20260721.md)、[员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md)、[问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md) @@ -18,6 +18,18 @@ 这项增量同时服务两个目的:一是让制度、通知、文件不再散落在群聊中;二是让员工在接收信息后能基于原文继续追问,并获得有来源、不过度承诺的回答。 +### 0.1 2026-07-23 实际交付边界 + +| 范围 | 当前状态 | 证据边界 | +|---|---|---| +| A:问模块纠偏 | 已实现并部署 | 会话开始后收起占位辅助区;消息与知识回答不得伪造员工待办,强行动入口只接受服务端真实任务事实。认证态的完整生产回归仍待执行。 | +| B:大喇叭最小闭环 | 已实现并部署 | 员工可查看本租户已发布消息、读取未读数和写入幂等已读;管理端可按当前租户发布、列表查看和撤回。首版不含定向规则、附件、AI 摘要、“与我有关”、修订版本和人工补推。 | +| C:消息上下文追问 | 已实现并部署 | 详情页只传 `broadcastMessageId`;服务端重验当前身份、租户、在职状态和 `PUBLISHED` 状态,同一短会话不可改绑消息,撤回后续问即拒绝。 | +| 发布基础验证 | 已完成 | 当前构建、线上 H5/管理端资源、后端模块与 schema 已核验;服务处于 `active`。最终发布后只查看了未登录 H5 页面,尚未以正式认证账号重放“发布—阅读—追问”闭环。 | +| 后续范围 | 未实施 | 项目/岗位/层级定向、附件与摘要、相关性说明、修订/补推、强触达和外部流程仍按本方案后续批次处理。 | + +本节是当前实现状态的唯一摘要;第 1—11 节保留目标设计和验收标准,不能因其中的完整设计而推断所有能力均已上线。 + ## 1. 背景、问题与目标 ### 1.1 当前问题 diff --git a/docs/银城帮道产品功能优先级与实施计划-20260721.md b/docs/银城帮道产品功能优先级与实施计划-20260721.md index cb8ac4cf..7cf19fdf 100644 --- a/docs/银城帮道产品功能优先级与实施计划-20260721.md +++ b/docs/银城帮道产品功能优先级与实施计划-20260721.md @@ -1,8 +1,8 @@ # 银城帮道产品功能优先级与实施计划 -> 版本:v1.2 -> 日期:2026-07-22 -> 状态:2026-07-21 的多项目、确认式采集和今日工作成果已部署;R0 的历史日期选择、未来日期拒绝和主管手机号授权兜底已于 2026-07-22 提交并发布,完成本地测试、390×844 视觉交互及远端服务、schema、产物匹配复核。 +> 版本:v1.3 +> 日期:2026-07-23 +> 状态:2026-07-21 的多项目、确认式采集和今日工作成果,以及 R0 的历史日期选择、未来日期拒绝和主管手机号授权均已部署;R2 的大喇叭最小闭环和消息追问已于 2026-07-23 随全量迁移发布,完成本地测试、构建和远端服务、产物、后端模块及 `57/57` schema 核验。 > 需求输入:微信临时文件《银城帮道 介绍(1).html》、2026-07-19/20 客户沟通、当前 BRD/TechSpec 与线上事实 > 适用范围:员工端、主管端、管理端及后续外部业务接口 @@ -22,7 +22,7 @@ | 介绍页功能 | 当前事实 | 优先级 | 处理决定 | |---|---|---:|---| | 员工 AI 陪练 | 训练、评分、主管复盘、再练和画像已有主链路;正式试点仍需按同一业务窗口和唯一在职身份验收 | P0 | 不再扩页面,先完成正式试点证据闭环 | -| 公司消息系统 | 框架只有普通公告;缺少按项目/岗位定向、已读未读、“与我有关”和补推 | P1 | 建独立“大喇叭”最小闭环,不把 `sys_notice` 伪装成已完成 | +| 公司消息系统 | 独立“大喇叭”已部署最小闭环:全员公开消息、未读/已读、管理发布/撤回和受控消息追问;仍缺项目/岗位定向、附件、摘要、“与我有关”和补推 | P1 | 先用正式账号完成发布—阅读—追问回归,再按[专项方案](银城大喇叭与问模块纠偏增量方案-20260722.md)补齐后续运营能力 | | 岗位能力模型 | 已有训练维度、分数和部分画像;岗位/职级权重配置仍不完整 | P2 | 先用真实训练数据验证,再做后台配置化 | | 情绪动态与仪表盘 | 单次对练已有情绪/红旗信号,缺少跨周期趋势与干预闭环 | P2 | 先保留单次证据,后做趋势页 | | 方言与环境模拟 | 尚无正式验收需求;音色本轮也不考虑 | P3 | 暂不投入,不因展示稿引入新模型或音频依赖 | @@ -78,23 +78,22 @@ ### P1:高频使用闭环 -#### P1-1 工作成果历史日期查看(本轮已完成本地实现,待发布) +#### P1-1 工作成果历史日期查看(已部署) - 员工和主管成果页已支持按日期切换,默认今天,禁止选择未来日期。 - 切换后沿用现有项目权限和真实成果接口,不复制数据模型。 - 页面标题已不再把历史日期误称为“今日”。 -发布前验收:前一天、指定历史日期、回到今天均正确请求;员工和主管看到同一项目同一天的数据。 +已完成的发布前验收:前一天、指定历史日期、回到今天均正确请求;员工和主管看到同一项目同一天的数据。 -#### P1-2 银城大喇叭/公司消息 MVP +#### P1-2 银城大喇叭/公司消息 MVP(核心已部署) -- 全员频道兜底,任何员工可查看租户内已发布内容。 -- 支持按项目、岗位、层级置顶或提醒;匹配失败不能导致消息丢失。 -- 每份文件保存 AI 摘要和“与我有关”提取结果。 -- 记录已读/未读、发布时间、发布人、适用范围和失败补推状态。 +- 已部署:全员频道兜底、未读/已读、当前租户管理发布/撤回,以及从消息详情受控进入“问”的文字追问。 +- 待实施:按项目、岗位、层级置顶或提醒;匹配失败不能导致消息丢失。 +- 待实施:附件、AI 摘要、“与我有关”提取、修订版本、失败补推和运营报表。 - 高管直通车、匿名建议另立业务和权限设计,不混入首版消息表。 -验收:目标员工收到置顶,全员仍可从频道查看;文件摘要有原文件来源;已读状态可审计。 +本轮生产边界:完整构建、线上产物、后端模块和 schema 已核验;正式认证账号的发布—阅读—追问及后续定向/附件场景尚未作为生产验收完成。 #### P1-3 开放问题榜与贡献闭环 @@ -147,7 +146,7 @@ |---|---|---|---| | R0 口径与可用性 | 本计划、文档索引、成果历史日期查看 | 无外部依赖 | 已完成本地定向测试、类型检查、构建、390×844 交互截图;2026-07-22 已发布并完成远端产物/服务复核 | | R1 核心正式验收 | AI 陪练严格试点、工作助手主管/多项目/真机媒体 | 正式账号、人员、项目和业务窗口 | HTTP、数据库、真机、主管页和正式试点 CSV | -| R2 高频协作 | 大喇叭 MVP、问题榜贡献闭环、智能日报筛选 | 消息发布权限和目标规则确认 | 发布/阅读/摘要/贡献全链路证据 | +| R2 高频协作 | 大喇叭最小闭环已发布;问题榜贡献闭环、智能日报筛选和大喇叭运营增强待续 | 正式账号、消息目标规则和运营口径确认 | 认证态发布/阅读/追问,随后补摘要、定向、贡献和筛选链路证据 | | R3 管理增强 | 能力模型配置、画像/情绪趋势 | R1 产生稳定真实数据 | 历史版本、权限、趋势到证据的回归 | | R4 外部协同 | 考勤、工单、线索、北森 | 正式接口合同和测试环境 | 回执、失败、重试、幂等和审计证据 | @@ -180,6 +179,14 @@ 全量移动端测试在本轮之前已有 18 项源码字符串/旧契约失败;本轮新增的工作成果用例全部通过,因此不能把全量套件写成绿色,也不能把既有失败归因于本轮日期功能。 +### 2026-07-23 / R2 大喇叭核心与问模块纠偏 + +- [x] 发布全员公开消息、未读/已读、撤回和 `broadcastMessageId` 受控追问的最小闭环。 +- [x] 将“问”页面的连续对话设为主内容,收起会话开始后不再需要的顶部支撑区。 +- [x] 执行知识分类、大喇叭、消息上下文和个人资料 schema 的全量迁移,并建立私有个人资料存储配置。 +- [x] 完成后端定向测试、管理端与 H5 测试/构建,以及线上静态资源、后端模块和 schema 复核。 +- [ ] 以正式认证员工和管理角色重放发布—阅读—追问;真机与完整运营增强仍不作为本轮验收结论。 + 后续每个 Agent 开始开发前先阅读本计划,再按对应专项文档实施: - 工作助手与日报:[工作助手与今日工作成果迭代计划](工作助手与今日工作成果迭代计划-20260721.md) diff --git a/mobile-uni/src/pages/auth/login/index.vue b/mobile-uni/src/pages/auth/login/index.vue index 3596fa22..a1d82c17 100644 --- a/mobile-uni/src/pages/auth/login/index.vue +++ b/mobile-uni/src/pages/auth/login/index.vue @@ -233,8 +233,7 @@ const login = async () => { method: 'POST', data: { phonenumber: value, - smsCode: formData.smsCode.trim(), - tenantId: '000000' + smsCode: formData.smsCode.trim() }, auth: false }); diff --git a/mobile-uni/src/pages/user/broadcast/detail.vue b/mobile-uni/src/pages/user/broadcast/detail.vue index 5aa13ab5..1656ad4e 100644 --- a/mobile-uni/src/pages/user/broadcast/detail.vue +++ b/mobile-uni/src/pages/user/broadcast/detail.vue @@ -126,6 +126,6 @@ onLoad((options) => { .message-time { color: var(--muted); font-size: 12px; } .message-content { overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.8; white-space: pre-wrap; } .detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); } -.ask-message-button { width: 100%; min-height: 46px; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; } +.ask-message-button { display: flex; width: 100%; min-height: 46px; align-items: center; justify-content: center; padding: 0; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; line-height: 1; } .ask-message-note { color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; } diff --git a/mobile-uni/src/pages/user/broadcast/index.vue b/mobile-uni/src/pages/user/broadcast/index.vue index eeab161b..1b6efe23 100644 --- a/mobile-uni/src/pages/user/broadcast/index.vue +++ b/mobile-uni/src/pages/user/broadcast/index.vue @@ -194,7 +194,7 @@ onUnload(() => { .empty-title { font-size: 17px; font-weight: 900; } .message-list { display: grid; gap: 12px; margin-top: 18px; } .scope-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 16px; padding: 4px; border-radius: 14px; background: #f3f4f6; } -.scope-tab { min-height: 36px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; } +.scope-tab { display: flex; min-height: 36px; align-items: center; justify-content: center; padding: 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; line-height: 1; } .scope-tab.active { background: #fff; color: var(--brand); box-shadow: 0 1px 4px rgba(20, 28, 42, .12); } .message-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: var(--ink); text-align: left; } .message-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } @@ -206,7 +206,7 @@ onUnload(() => { .message-tag.related { background: #fff7e8; color: #a76500; } .message-time { color: var(--muted); font-size: 12px; } .message-title { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.5; } -.load-more { width: 100%; min-height: 42px; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; } +.load-more { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; padding: 0; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; line-height: 1; } .load-more::after { border: 0; } .list-end { display: block; padding: 8px; color: var(--muted); font-size: 12px; text-align: center; } diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 8cd6d55e..753a6814 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -392,7 +392,7 @@ import type { PracticeTurn, PracticeRecord } from '@/types/api'; -import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice'; +import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, recordPracticeHelp, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice'; import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { searchKnowledge } from '@/services/knowledge'; import { resetPageScroll } from '@/services/navigation'; @@ -1289,11 +1289,17 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => { const loadHelp = async () => { if (helpLoading.value || busy.value) return; if (!requireLogin()) return; + if (!sessionId.value) { + message.value = '请先开始训练后再求助'; + return; + } const request = requests.begin('help'); helpLoading.value = true; message.value = ''; try { - const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`); + await recordPracticeHelp(sessionId.value, roundIndex.value); + if (!requests.isCurrent(request)) return; + const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`, positionLabel.value, undefined, undefined, 'practice_help'); if (!requests.isCurrent(request)) return; if (!data.answer) { throw new Error('未检索到可用 SOP 提示'); diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index b1914f5e..f8a4f9e4 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -628,6 +628,17 @@ const clearBroadcastConsultation = () => { clearBroadcastQuestionContext(); }; +// A message consultation is re-authorized on every show. When the page is +// hidden while that request (or its confirmation dialog) is still pending, +// invalidate only the in-flight work. Keep the account-bound pending message +// ID so the next onShow can restart the authorization instead of leaving the +// composer locked in a stale loading state. +const cancelPendingBroadcastConsultation = () => { + broadcastContextGeneration += 1; + broadcastContextLoading.value = false; + broadcastContextPrompting.value = false; +}; + const preserveCurrentBroadcastConsultation = () => { if (!broadcastContext.value) return; saveBroadcastQuestionContext(broadcastContext.value.messageId, currentAccountKey()); @@ -693,12 +704,15 @@ const restoreBroadcastConsultation = async (pageGeneration: number, accountKey: return; } - const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration); if (!conversationStarted.value) { - start(); + // Do not start project selection until the message context has been + // resolved. Otherwise the asynchronous handoff can briefly look like an + // ordinary empty conversation and incorrectly open the project picker. + await activateBroadcastConsultation(pending, accountKey, pageGeneration); return; } + const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration); broadcastContextPrompting.value = true; uni.showModal({ title: '开始新的消息咨询?', @@ -847,7 +861,8 @@ const loadProjectContext = async () => { const identity = await fetchMobileIdentity(); availableProjects.value = identity.projects || []; currentProject.value = reconcileCurrentProject(availableProjects.value); - if (!currentProject.value && availableProjects.value.length > 1 && !broadcastContext.value && !broadcastContextPrompting.value) { + if (!currentProject.value && availableProjects.value.length > 1 + && !broadcastContext.value && !broadcastContextLoading.value && !broadcastContextPrompting.value) { projectPickerOpen.value = true; } } catch (error) { @@ -1477,6 +1492,7 @@ onShow(() => { onHide(() => { showGeneration += 1; candidateRefreshGeneration += 1; + cancelPendingBroadcastConsultation(); speechPlayback.stop(); composerRef.value?.teardown(); cancelVoiceTranscription(); @@ -1485,6 +1501,7 @@ onHide(() => { onUnload(() => { showGeneration += 1; + cancelPendingBroadcastConsultation(); releaseMessageResources(); speechPlayback.destroy(); composerRef.value?.teardown(); diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index ee998c81..b92616ef 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -83,7 +83,7 @@ export const searchKnowledge = async ( position = getSelectedPosition(), conversation?: KnowledgeConversationState, projectCode?: string, - source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop', + source: 'mobile_uni_sop' | 'mobile_uni_voice' | 'practice_help' = 'mobile_uni_sop', broadcastMessageId?: number ) => { const response = await apiRequest({ diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index 63c25bad..a598aff6 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -1,5 +1,6 @@ import type { PracticeFinishResponse, + PracticeHelpEvent, PracticePrepCard, PracticeRecord, PracticeMistake, @@ -42,6 +43,14 @@ export const getPracticePrepCard = (scenarioId = defaultScenarioId) => timeout: 15000 }); +export const recordPracticeHelp = (sessionId: string, roundIndex: number) => + apiRequest({ + url: `/api/train/practice/sessions/${encodeURIComponent(sessionId)}/help`, + method: 'POST', + data: { roundIndex }, + timeout: 15000 + }); + export const getPracticeScenarios = (position = getSelectedPosition()) => apiRequest({ url: `/api/train/practice/scenarios?position=${encodeURIComponent(position || '生活顾问')}`, diff --git a/mobile-uni/src/services/speech.ts b/mobile-uni/src/services/speech.ts index e5dcacbf..bb8b533b 100644 --- a/mobile-uni/src/services/speech.ts +++ b/mobile-uni/src/services/speech.ts @@ -4,12 +4,15 @@ import type { SpeechCapture } from './speech-capture'; const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg']; -export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88 }; -export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96 }; -export const customerVoiceProfile = (emotion = 0): SpeechVoiceProfile => { - if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12 }; - if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04 }; - return { role: 'customer', emotion: 'calm', speed: 0.98 }; +export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88, dialect: 'mandarin' }; +export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96, dialect: 'mandarin' }; +export const customerVoiceProfile = ( + emotion = 0, + dialect: SpeechVoiceProfile['dialect'] = 'mandarin' +): SpeechVoiceProfile => { + if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12, dialect }; + if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04, dialect }; + return { role: 'customer', emotion: 'calm', speed: 0.98, dialect }; }; // OSS 编号是雪花 ID,超出 JS 安全整数范围,必须按字符串透传,禁止 Number() 强转。 @@ -113,6 +116,12 @@ export interface SpeechPlaybackSnapshot { status: SpeechPlaybackStatus; } +const browserDialectLanguage = (dialect: SpeechVoiceProfile['dialect']) => { + if (dialect === 'cantonese') return 'zh-HK'; + if (dialect === 'sichuanese') return ''; + return 'zh-CN'; +}; + export const createSpeechPlaybackController = ( onState: (snapshot: SpeechPlaybackSnapshot) => void, onError: (message: string) => void, @@ -150,15 +159,20 @@ export const createSpeechPlaybackController = ( value: string, currentGeneration: number, profile?: SpeechVoiceProfile - ) => { + ): { played: boolean; message?: string } => { if ( typeof window === 'undefined' || !window.speechSynthesis || typeof window.SpeechSynthesisUtterance !== 'function' - ) return false; + ) return { played: false }; + + const lang = browserDialectLanguage(profile?.dialect); + if (!lang) { + return { played: false, message: '当前设备语音不支持四川话,请使用服务端语音或文字查看' }; + } const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600)); - utterance.lang = 'zh-CN'; + utterance.lang = lang; utterance.rate = profile?.speed || 0.95; utterance.pitch = profile?.role === 'mentor' ? 1.06 : profile?.role === 'customer' ? 1.05 : 1; utterance.onend = () => { @@ -172,7 +186,7 @@ export const createSpeechPlaybackController = ( browserUtterance = utterance; publish(key, 'playing'); window.speechSynthesis.speak(utterance); - return true; + return { played: true }; }; const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => { @@ -231,9 +245,10 @@ export const createSpeechPlaybackController = ( playAudioSource(key, source, sourceKey, currentGeneration); } catch (error) { if (currentGeneration !== generation) return; - if (playWithBrowserSpeech(key, value, currentGeneration, profile)) return; + const browserFallback = playWithBrowserSpeech(key, value, currentGeneration, profile); + if (browserFallback.played) return; stop(); - onError(error instanceof Error ? error.message : '语音生成失败'); + onError(browserFallback.message || (error instanceof Error ? error.message : '语音生成失败')); } }; diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 4683a14a..cd6cdd49 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -38,6 +38,7 @@ export interface SpeechVoiceProfile { voice?: string; speed?: number; emotion?: 'warm' | 'professional' | 'serious' | 'intense' | 'calm'; + dialect?: 'mandarin' | 'cantonese' | 'sichuanese'; } export interface ToolItem { @@ -467,6 +468,13 @@ export interface PracticePrepCard { promptVersion: string; } +export interface PracticeHelpEvent { + sessionId: string; + scenarioId: string; + roundIndex: number; + recordedAt: string; +} + export interface PracticeTurnResponse { customerText: string; customerAudioUrl?: string; diff --git a/mobile-uni/tests/ask-memory.test.mjs b/mobile-uni/tests/ask-memory.test.mjs index c7def4c7..27995e0e 100644 --- a/mobile-uni/tests/ask-memory.test.mjs +++ b/mobile-uni/tests/ask-memory.test.mjs @@ -92,6 +92,11 @@ test('消息咨询只以消息 ID 作为受控上下文,并让每轮文字或 test('消息咨询会新开会话,隐藏媒体和数据工具,并在撤回时清空对话', async () => { const page = await read('../src/pages/user/sop/index.vue'); + assert.match(page, /if \(!conversationStarted\.value\) \{\s*\/\/ Do not start project selection[\s\S]*await activateBroadcastConsultation\(pending, accountKey, pageGeneration\);/); + assert.match(page, /!broadcastContext\.value && !broadcastContextLoading\.value && !broadcastContextPrompting\.value/); + assert.match(page, /const cancelPendingBroadcastConsultation = \(\) => \{\s*broadcastContextGeneration \+= 1;\s*broadcastContextLoading\.value = false;\s*broadcastContextPrompting\.value = false;/); + assert.match(page, /onHide\(\(\) => \{\s*showGeneration \+= 1;\s*candidateRefreshGeneration \+= 1;\s*cancelPendingBroadcastConsultation\(\);/); + assert.match(page, /onUnload\(\(\) => \{\s*showGeneration \+= 1;\s*cancelPendingBroadcastConsultation\(\);/); assert.match(page, /error\.statusCode === 404 \|\| error\.bizCode === 404/); assert.match(page, /const pending = readBroadcastQuestionContext\(accountKey\);\s*if \(!pending\) \{\s*if \(broadcastContext\.value\) \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(false\);/); assert.match(page, /const startNewConversation = \(\) => \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(true\);/); diff --git a/mobile-uni/tests/broadcast.test.mjs b/mobile-uni/tests/broadcast.test.mjs index b6795d32..a3bef62d 100644 --- a/mobile-uni/tests/broadcast.test.mjs +++ b/mobile-uni/tests/broadcast.test.mjs @@ -4,6 +4,17 @@ import test from 'node:test'; const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); +const centeredButtonRule = (source, selector) => { + const rule = source.match(new RegExp(`\\.${selector}\\s*\\{([^}]*)\\}`))?.[1] || ''; + + assert.ok(rule, `missing .${selector} style rule`); + assert.match(rule, /display:\s*flex/); + assert.match(rule, /align-items:\s*center/); + assert.match(rule, /justify-content:\s*center/); + assert.match(rule, /padding:\s*0(?:;|\s)/); + assert.match(rule, /line-height:\s*1(?:;|\s)/); +}; + test('银城大喇叭仅使用认证后的真实广播接口', async () => { const service = await read('../src/services/broadcast.ts'); @@ -74,3 +85,18 @@ test('消息详情只交接短期且账号绑定的消息 ID,再进入问模 assert.match(service, /context\.accountKey !== accountKey \|\| context\.expiresAt <= now/); assert.match(service, /clearBroadcastQuestionContext\(\)/); }); + +test('M0/M1 的短文本操作控件始终在可点击区域内居中', async () => { + const [base, list, detail, ask] = await Promise.all([ + read('../src/styles/base.css'), + read('../src/pages/user/broadcast/index.vue'), + read('../src/pages/user/broadcast/detail.vue'), + read('../src/pages/user/sop/index.vue') + ]); + + assert.match(base, /\.primary-button,\s*\.secondary-button,\s*\.ghost-button\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center[^}]*justify-content:\s*center/); + centeredButtonRule(list, 'scope-tab'); + centeredButtonRule(list, 'load-more'); + centeredButtonRule(detail, 'ask-message-button'); + centeredButtonRule(ask, 'broadcast-context-action'); +}); diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index 752f6f5a..8da3f015 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -42,6 +42,32 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => { assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/); }); +test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => { + const [practicePage, practiceService, knowledgeService] = await Promise.all([ + pageSource('../src/pages/user/practice/index.vue'), + pageSource('../src/services/practice.ts'), + pageSource('../src/services/knowledge.ts') + ]); + + assert.match(practiceService, /sessions\/\$\{encodeURIComponent\(sessionId\)\}\/help/); + assert.match(practicePage, /await recordPracticeHelp\(sessionId\.value, roundIndex\.value\)/); + assert.match(practicePage, /'practice_help'/); + assert.match(knowledgeService, /'practice_help'/); +}); + +test('语音 profile 透传方言枚举,并保持普通话默认值', async () => { + const [apiTypes, speechService] = await Promise.all([ + pageSource('../src/types/api.ts'), + pageSource('../src/services/speech.ts') + ]); + + assert.match(apiTypes, /dialect\?: 'mandarin' \| 'cantonese' \| 'sichuanese'/); + assert.match(speechService, /dialect: 'mandarin'/); + assert.match(speechService, /customerVoiceProfile = \([\s\S]*?dialect:/); + assert.match(speechService, /dialect === 'cantonese'.*'zh-HK'/); + assert.match(speechService, /dialect === 'sichuanese'[\s\S]*?当前设备语音不支持四川话/); +}); + test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => { const [practiceSource, sopSource, navigationSource] = await Promise.all([ pageSource('../src/pages/user/practice/index.vue'), @@ -99,6 +125,15 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async assert.doesNotMatch(source, /grid-template-columns:\s*1fr;[\s\S]*?\.code-button/); }); +test('移动端短信登录不再让客户端选择租户', async () => { + const source = await pageSource('../src/pages/auth/login/index.vue'); + const loginRequest = source.match(/url: '\/auth\/mobile\/sms-login',[\s\S]*?auth: false/)?.[0] || ''; + + assert.match(loginRequest, /phonenumber: value/); + assert.match(loginRequest, /smsCode: formData\.smsCode\.trim\(\)/); + assert.doesNotMatch(loginRequest, /tenantId:/); +}); + test('共享视觉基座包含员工端主色和卡片圆角 token', async () => { const source = await pageSource('../src/styles/base.css'); diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 76aa6c54..3f21b2e6 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -125,6 +125,7 @@ FROM ( UNION ALL SELECT 'aihr_practice_audio' UNION ALL SELECT 'aihr_practice_audio_upload' UNION ALL SELECT 'aihr_practice_calibration' + UNION ALL SELECT 'aihr_practice_help_event' UNION ALL SELECT 'aihr_knowledge_gap' UNION ALL SELECT 'aihr_knowledge_answer_feedback' UNION ALL SELECT 'aihr_knowledge_space_grant' @@ -155,6 +156,8 @@ FROM ( UNION ALL SELECT 'aihr_sop_applicability' UNION ALL SELECT 'aihr_onboard_task' UNION ALL SELECT 'aihr_qualification_gate' + UNION ALL SELECT 'aihr_org_directory' + UNION ALL SELECT 'aihr_tenant_org_binding' UNION ALL SELECT 'aihr_memory_candidate' UNION ALL SELECT 'aihr_assistant_capture' UNION ALL SELECT 'aihr_assistant_capture_status_log' @@ -165,6 +168,8 @@ FROM ( UNION ALL SELECT 'aihr_broadcast_message' UNION ALL SELECT 'aihr_broadcast_read' UNION ALL SELECT 'aihr_broadcast_version' + UNION ALL SELECT 'aihr_broadcast_target_rule' + UNION ALL SELECT 'aihr_broadcast_target_recipient' UNION ALL SELECT 'aihr_personal_space' UNION ALL SELECT 'aihr_personal_item' UNION ALL SELECT 'aihr_personal_fragment' @@ -394,6 +399,79 @@ REMOTE [[ "$broadcast_version_unique_index" = "0|tenant_id,message_id,version|0" ]] \ || fail "remote broadcast-version unique index invalid: expected unique full columns tenant_id,message_id,version; got ${broadcast_version_unique_index:-missing}" + local broadcast_targeting_column_contract + broadcast_targeting_column_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' +set -euo pipefail +db="$1" +mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' +SELECT CONCAT(column_name, '|', column_type, '|', is_nullable, '|', COALESCE(column_default, '')) +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'aihr_broadcast_message' + AND column_name IN ('required_read', 'target_payload_hash') +ORDER BY FIELD(column_name, 'required_read', 'target_payload_hash'); +SQL +REMOTE + )" || fail "remote broadcast M1 column check failed: $remote_ssh:$remote_db" + + [[ "$broadcast_targeting_column_contract" = $'required_read|tinyint(1)|NO|0\ntarget_payload_hash|char(64)|YES|' ]] \ + || fail "remote broadcast M1 column contract invalid: expected required_read tinyint(1) NOT NULL DEFAULT 0 and nullable target_payload_hash char(64); got ${broadcast_targeting_column_contract:-missing}" + local broadcast_target_table_contract + broadcast_target_table_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' +set -euo pipefail +db="$1" +mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' +SELECT CONCAT(table_name, '.', column_name, '|', column_type, '|', is_nullable) +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name IN ('aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient') +ORDER BY FIELD(table_name, 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient'), ordinal_position; +SQL +REMOTE + )" || fail "remote broadcast target-table contract check failed: $remote_ssh:$remote_db" + + [[ "$broadcast_target_table_contract" = $'aihr_broadcast_target_rule.id|bigint|NO\naihr_broadcast_target_rule.tenant_id|varchar(20)|NO\naihr_broadcast_target_rule.message_id|bigint|NO\naihr_broadcast_target_rule.target_type|varchar(20)|NO\naihr_broadcast_target_rule.target_value|varchar(100)|NO\naihr_broadcast_target_rule.create_time|datetime|NO\naihr_broadcast_target_recipient.id|bigint|NO\naihr_broadcast_target_recipient.tenant_id|varchar(20)|NO\naihr_broadcast_target_recipient.message_id|bigint|NO\naihr_broadcast_target_recipient.recipient_key|varchar(180)|NO\naihr_broadcast_target_recipient.subject_ref|varchar(100)|NO\naihr_broadcast_target_recipient.user_id|bigint|YES\naihr_broadcast_target_recipient.target_status|varchar(20)|NO\naihr_broadcast_target_recipient.target_reason|varchar(100)|NO\naihr_broadcast_target_recipient.create_time|datetime|NO\naihr_broadcast_target_recipient.update_time|datetime|NO' ]] \ + || fail "remote broadcast target-table contract invalid: expected server-written recipient identity, status, reason, and timing fields; got ${broadcast_target_table_contract:-missing}" + local broadcast_target_rule_unique_index + broadcast_target_rule_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' +set -euo pipefail +db="$1" +mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' +SELECT CONCAT( + MIN(non_unique), '|', + COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', + SUM(sub_part IS NOT NULL) +) +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'aihr_broadcast_target_rule' + AND index_name = 'uk_aihr_broadcast_target_rule'; +SQL +REMOTE + )" || fail "remote broadcast target-rule unique-index check failed: $remote_ssh:$remote_db" + + [[ "$broadcast_target_rule_unique_index" = "0|tenant_id,message_id,target_type,target_value|0" ]] \ + || fail "remote broadcast target-rule unique index invalid: expected unique full columns tenant_id,message_id,target_type,target_value; got ${broadcast_target_rule_unique_index:-missing}" + local broadcast_target_recipient_unique_index + broadcast_target_recipient_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' +set -euo pipefail +db="$1" +mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' +SELECT CONCAT( + MIN(non_unique), '|', + COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', + SUM(sub_part IS NOT NULL) +) +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'aihr_broadcast_target_recipient' + AND index_name = 'uk_aihr_broadcast_target_recipient'; +SQL +REMOTE + )" || fail "remote broadcast target-recipient unique-index check failed: $remote_ssh:$remote_db" + + [[ "$broadcast_target_recipient_unique_index" = "0|tenant_id,message_id,recipient_key|0" ]] \ + || fail "remote broadcast target-recipient unique index invalid: expected unique full columns tenant_id,message_id,recipient_key; got ${broadcast_target_recipient_unique_index:-missing}" local missing_work_assistant_columns missing_work_assistant_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail @@ -422,13 +500,17 @@ REMOTE [[ -z "$missing_work_assistant_columns" ]] \ || fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')" - echo "remote_schema=57/57 $remote_ssh:$remote_db" + echo "remote_schema=62/62 $remote_ssh:$remote_db" echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db" echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_publish_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_read_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_version_uniqueness=3/3 $remote_ssh:$remote_db" + echo "remote_broadcast_targeting_contract=2/2 $remote_ssh:$remote_db" + echo "remote_broadcast_target_table_contract=16/16 $remote_ssh:$remote_db" + echo "remote_broadcast_target_rule_uniqueness=4/4 $remote_ssh:$remote_db" + echo "remote_broadcast_target_recipient_uniqueness=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db" echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db" echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db" diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index 1188f8b0..f07d1fd3 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -32,6 +32,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql" docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \ diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 10800f07..06d2da5e 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -124,6 +124,17 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_eviden evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")" test "$evidence_tables" = 'aihr_practice_audio,aihr_practice_audio_upload,aihr_practice_calibration' +practice_help_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql" +test -f "$practice_help_migration" +mysql "$DB" < "$practice_help_migration" +mysql "$DB" < "$practice_help_migration" +practice_help_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_help_event';")" +test "$practice_help_columns" = 'id,tenant_id,session_id,scenario_id,ext_party_id,round_index,create_time' +practice_help_tenant_collation="$(mysql "$DB" -N -B -e "SELECT CONCAT(character_set_name,'|',collation_name) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_help_event' AND column_name='tenant_id';")" +test "$practice_help_tenant_collation" = 'utf8mb4|utf8mb4_general_ci' +practice_help_session_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_help_event' AND index_name='idx_aihr_practice_help_session';")" +test "$practice_help_session_index" = 'tenant_id,session_id,create_time' + mysql "$DB" <<'SQL' CREATE TABLE aihr_sop_answer_review ( id bigint NOT NULL AUTO_INCREMENT, @@ -312,6 +323,29 @@ test "$broadcast_read_unique_index" = '0|tenant_id,message_id,user_id|0' broadcast_version_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_version' AND index_name='uk_aihr_broadcast_version';")" test "$broadcast_version_unique_index" = '0|tenant_id,message_id,version|0' +mysql "$DB" <<'SQL' +INSERT INTO aihr_broadcast_message ( + tenant_id, title, content, status, published_by, published_time, create_time, update_time +) VALUES ('000000', 'legacy M0 targeting default', 'legacy content', 'PUBLISHED', 1, NOW(), NOW(), NOW()); +SQL + +broadcast_targeting_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql" +test -f "$broadcast_targeting_migration" +mysql "$DB" < "$broadcast_targeting_migration" +mysql "$DB" < "$broadcast_targeting_migration" +broadcast_targeting_column_contract="$(mysql "$DB" -N -B -e "SELECT CONCAT(column_name,'|',column_type,'|',is_nullable,'|',COALESCE(column_default,'')) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND column_name IN ('required_read','target_payload_hash') ORDER BY FIELD(column_name,'required_read','target_payload_hash');")" +test "$broadcast_targeting_column_contract" = $'required_read|tinyint(1)|NO|0\ntarget_payload_hash|char(64)|YES|' +broadcast_targeting_legacy_defaults="$(mysql "$DB" -N -B -e "SELECT CONCAT(required_read,'|',COALESCE(target_payload_hash,'NULL')) FROM aihr_broadcast_message WHERE title='legacy M0 targeting default';")" +test "$broadcast_targeting_legacy_defaults" = '0|NULL' +broadcast_targeting_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_broadcast_target_rule','aihr_broadcast_target_recipient');")" +test "$broadcast_targeting_tables" = 'aihr_broadcast_target_recipient,aihr_broadcast_target_rule' +broadcast_target_table_contract="$(mysql "$DB" -N -B -e "SELECT CONCAT(table_name,'.',column_name,'|',column_type,'|',is_nullable) FROM information_schema.columns WHERE table_schema='$DB' AND table_name IN ('aihr_broadcast_target_rule','aihr_broadcast_target_recipient') ORDER BY FIELD(table_name,'aihr_broadcast_target_rule','aihr_broadcast_target_recipient'), ordinal_position;")" +test "$broadcast_target_table_contract" = $'aihr_broadcast_target_rule.id|bigint|NO\naihr_broadcast_target_rule.tenant_id|varchar(20)|NO\naihr_broadcast_target_rule.message_id|bigint|NO\naihr_broadcast_target_rule.target_type|varchar(20)|NO\naihr_broadcast_target_rule.target_value|varchar(100)|NO\naihr_broadcast_target_rule.create_time|datetime|NO\naihr_broadcast_target_recipient.id|bigint|NO\naihr_broadcast_target_recipient.tenant_id|varchar(20)|NO\naihr_broadcast_target_recipient.message_id|bigint|NO\naihr_broadcast_target_recipient.recipient_key|varchar(180)|NO\naihr_broadcast_target_recipient.subject_ref|varchar(100)|NO\naihr_broadcast_target_recipient.user_id|bigint|YES\naihr_broadcast_target_recipient.target_status|varchar(20)|NO\naihr_broadcast_target_recipient.target_reason|varchar(100)|NO\naihr_broadcast_target_recipient.create_time|datetime|NO\naihr_broadcast_target_recipient.update_time|datetime|NO' +broadcast_target_rule_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_target_rule' AND index_name='uk_aihr_broadcast_target_rule';")" +test "$broadcast_target_rule_unique_index" = '0|tenant_id,message_id,target_type,target_value|0' +broadcast_target_recipient_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_target_recipient' AND index_name='uk_aihr_broadcast_target_recipient';")" +test "$broadcast_target_recipient_unique_index" = '0|tenant_id,message_id,recipient_key|0' + mysql "$DB" -e "ALTER TABLE aihr_broadcast_message DROP INDEX uk_aihr_broadcast_message_publish_request, DROP COLUMN publish_request_hash, DROP COLUMN publish_request_key, DROP COLUMN withdraw_reason;" mysql "$DB" <<'SQL' INSERT INTO aihr_broadcast_message ( @@ -349,4 +383,4 @@ fi broadcast_failed_publish_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND index_name='uk_aihr_broadcast_message_publish_request';")" test "$broadcast_failed_publish_index" = '1|tenant_id,publish_request_key,published_by|1' -echo "PASS: AIHR legacy schema migrations are idempotent, including knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with v1 snapshots, and trusted broadcast question context" +echo "PASS: AIHR legacy schema migrations are idempotent, including knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with M1 targeting defaults and target-table contracts, v1 snapshots, and trusted broadcast question context" diff --git a/scripts/verify-broadcast-m0-local.mjs b/scripts/verify-broadcast-m0-local.mjs new file mode 100644 index 00000000..bc07fbf1 --- /dev/null +++ b/scripts/verify-broadcast-m0-local.mjs @@ -0,0 +1,463 @@ +#!/usr/bin/env node + +/** + * M0 "银城大喇叭" 写入验收。 + * + * 默认仅允许对本机回环地址写入:发布一条唯一测试消息,验证员工端的可见、已读和 + * 消息追问链路,再撤回该消息。脚本始终要求调用方提供已授权的短期管理员和员工令牌, + * 不会读取服务端验证码、密码或会话数据。远端写入还必须同时提供命令行和环境变量的 + * 明确授权。 + */ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = parseArgs(process.argv.slice(2)); +const baseUrl = trimTrailingSlash(args.get('base-url') || process.env.AIHR_BASE_URL || 'https://wygj-api.localhost'); +const url = new URL(baseUrl); +const isLocal = url.hostname === 'localhost' + || url.hostname === '127.0.0.1' + || url.hostname === '::1' + || url.hostname.endsWith('.localhost'); +const allowRemoteWrite = args.has('allow-remote-write') && process.env.AIHR_M0_ALLOW_REMOTE_WRITE === 'true'; +const configuredTenantId = String(args.get('tenant-id') || process.env.AIHR_M0_TENANT_ID || '').trim(); +const reportPath = args.get('report') ? path.resolve(root, args.get('report')) : ''; +const mobileClientId = process.env.AIHR_M0_EMPLOYEE_CLIENT_ID || '428a8310cd442757ae699df5d894f051'; +const adminClientId = process.env.AIHR_M0_ADMIN_CLIENT_ID || 'e5cd7e4891bf95d1d19206ce24a7b32e'; +const prepareLocalQueryFixture = args.has('prepare-local-query-fixture'); +const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${crypto.randomBytes(4).toString('hex')}`; +const title = `M0 自动验收 ${runId}`; +const content = `这是仅用于本机 M0 自动化验收的临时公司消息(${runId})。请以正式通知为准。`; +const withdrawReason = `M0 自动化验收清理 ${runId}`; +const checks = []; +let admin; +let employee; +let tenantId = ''; +let messageId; +let withdrawn = false; +let failure; +let cleanupFailure; +const queryFixture = { + enabled: false, + appId: null, + knowledgeId: null, + appCreated: false, + appBindingCreated: false, + employeeGrantCreated: false +}; + +if (url.protocol === 'https:' && url.hostname.endsWith('.localhost') && !process.env.NODE_EXTRA_CA_CERTS) { + // ponytail: portless local CA is not visible to Node fetch in every shell session. + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +} + +function parseArgs(values) { + return new Map(values.map((value) => { + const [key, ...rest] = value.replace(/^--/, '').split('='); + return [key, rest.length ? rest.join('=') : 'true']; + })); +} + +function trimTrailingSlash(value) { + return String(value).replace(/\/+$/, ''); +} + +async function request(method, endpoint, { token, clientId, body } = {}) { + const headers = { + clientid: clientId || mobileClientId, + 'Content-Language': 'zh_CN' + }; + if (token) headers.Authorization = `Bearer ${token}`; + let requestBody; + if (body !== undefined) { + headers['Content-Type'] = 'application/json;charset=utf-8'; + requestBody = JSON.stringify(body); + } + let response; + try { + response = await fetch(`${baseUrl}${endpoint}`, { + method, + headers, + body: requestBody, + signal: AbortSignal.timeout(30_000) + }); + } catch (error) { + throw new Error(`${method} ${endpoint} 请求失败:${error instanceof Error ? error.message : String(error)}`); + } + const text = await response.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + throw new Error(`${method} ${endpoint} 返回了无法解析的响应(HTTP ${response.status})`); + } + return { status: response.status, payload }; +} + +function businessCode(result) { + return Number(result?.payload?.code); +} + +function requireSuccess(result, label) { + if (result.status < 200 || result.status >= 300 || businessCode(result) !== 200) { + const message = String(result.payload?.msg || `HTTP ${result.status}`); + throw new Error(`${label}失败:${message}`); + } + return result.payload.data; +} + +function requireUnavailable(result, label) { + if (businessCode(result) !== 404) { + throw new Error(`${label}应不可用,实际业务码为 ${Number.isFinite(businessCode(result)) ? businessCode(result) : `HTTP ${result.status}`}`); + } +} + +function record(name, details = {}) { + checks.push({ name, status: 'PASS', ...details }); + console.log(`PASS ${name}`); +} + +function requireValue(value, label) { + if (value === null || value === undefined || value === '') throw new Error(`${label}缺失`); + return value; +} + +function docker(container, command) { + try { + return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim(); + } catch { + throw new Error('本机消息追问临时授权需要可用的 wygj-mysql Docker 容器;也可省略 --prepare-local-query-fixture,直接验证现有授权配置'); + } +} + +function localMysql(sql) { + return docker('wygj-mysql', [ + 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql + ]); +} + +function sqlLiteral(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +function prepareQueryFixture() { + if (!prepareLocalQueryFixture) return; + if (!isLocal) throw new Error('--prepare-local-query-fixture 只允许用于本机环境'); + if (!tenantId) throw new Error('准备消息追问测试数据前必须先确认租户'); + queryFixture.enabled = true; + const tenant = sqlLiteral(tenantId); + const existingApp = localMysql(`select concat(id, '|', status) from aihr_knowledge_app + where tenant_id = ${tenant} and auth_type = 'SESSION' and internal_client_key = 'app' limit 1`).trim(); + const [existingAppIdText, existingAppStatus] = existingApp.split('|'); + const existingAppId = Number(existingAppIdText); + let appId = Number.isSafeInteger(existingAppId) && existingAppId > 0 ? existingAppId : null; + if (appId && existingAppStatus !== 'ACTIVE') { + throw new Error('本机已有移动端知识应用但未启用;请先启用该应用,或在隔离环境中执行验收'); + } + if (!appId) { + const appCode = `m0qa_${runId}`.slice(0, 64); + localMysql(`insert into aihr_knowledge_app + (tenant_id, app_code, app_name, auth_type, internal_client_key, status, rate_limit_per_minute, create_time, update_time) + values (${tenant}, ${sqlLiteral(appCode)}, 'M0 本机验收临时应用', 'SESSION', 'app', 'ACTIVE', 60, now(), now())`); + appId = Number(localMysql(`select id from aihr_knowledge_app where tenant_id = ${tenant} + and app_code = ${sqlLiteral(appCode)} limit 1`).trim()); + if (!Number.isSafeInteger(appId) || appId < 1) throw new Error('无法创建本机消息追问临时应用'); + queryFixture.appCreated = true; + } + queryFixture.appId = appId; + const existingKnowledgeId = Number(localMysql(`select id from aihr_knowledge_info + where tenant_id = ${tenant} and status = 'ACTIVE' order by id limit 1`).trim()); + if (!Number.isSafeInteger(existingKnowledgeId) || existingKnowledgeId < 1) { + throw new Error('本机没有启用的知识空间,无法准备消息追问测试数据'); + } + queryFixture.knowledgeId = existingKnowledgeId; + const bindingCount = Number(localMysql(`select count(*) from aihr_knowledge_app_space + where tenant_id = ${tenant} and app_id = ${appId} and knowledge_id = ${existingKnowledgeId}`).trim()); + if (bindingCount === 0) { + localMysql(`insert into aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_time) + values (${tenant}, ${appId}, ${existingKnowledgeId}, now())`); + queryFixture.appBindingCreated = true; + } + const grantCount = Number(localMysql(`select count(*) from aihr_knowledge_space_grant + where tenant_id = ${tenant} and knowledge_id = ${existingKnowledgeId} + and principal_type = 'ROLE' and principal_value = 'employee' + and permission in ('READ', 'MANAGE') and status = 'ACTIVE'`).trim()); + if (grantCount === 0) { + localMysql(`insert into aihr_knowledge_space_grant + (tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_time, update_time) + values (${tenant}, ${existingKnowledgeId}, 'ROLE', 'employee', 'READ', 'ACTIVE', now(), now())`); + queryFixture.employeeGrantCreated = true; + } + record('本机消息追问临时授权已准备'); +} + +function cleanupQueryFixture() { + if (!queryFixture.enabled || !isLocal || !tenantId) return; + const tenant = sqlLiteral(tenantId); + try { + if (queryFixture.employeeGrantCreated && Number.isSafeInteger(queryFixture.knowledgeId)) { + localMysql(`delete from aihr_knowledge_space_grant where tenant_id = ${tenant} + and knowledge_id = ${queryFixture.knowledgeId} and principal_type = 'ROLE' + and principal_value = 'employee' and permission = 'READ'`); + } + if (queryFixture.appBindingCreated && Number.isSafeInteger(queryFixture.appId) && Number.isSafeInteger(queryFixture.knowledgeId)) { + localMysql(`delete from aihr_knowledge_app_space where tenant_id = ${tenant} + and app_id = ${queryFixture.appId} and knowledge_id = ${queryFixture.knowledgeId}`); + } + if (queryFixture.appCreated && Number.isSafeInteger(queryFixture.appId)) { + localMysql(`delete from aihr_knowledge_app where tenant_id = ${tenant} and id = ${queryFixture.appId}`); + } + console.log('CLEANUP 已恢复本机消息追问临时授权'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupFailure ||= new Error(`本机消息追问临时授权清理失败:${message}`); + console.error(`CLEANUP_FAILED ${message}`); + } +} + +function protectWriteScope() { + if (prepareLocalQueryFixture && !isLocal) { + throw new Error('--prepare-local-query-fixture 只允许用于本机环境'); + } + if (!isLocal && !allowRemoteWrite) { + throw new Error('拒绝对非本机环境写入。若确需在专用测试租户执行,请同时传入 --allow-remote-write 与 AIHR_M0_ALLOW_REMOTE_WRITE=true'); + } + if (!isLocal && !configuredTenantId) throw new Error('非本机验收必须明确传入 --tenant-id=<专用测试租户>'); + if (!process.env.AIHR_M0_ADMIN_TOKEN || !process.env.AIHR_M0_EMPLOYEE_TOKEN) { + throw new Error('M0 验收必须提供 AIHR_M0_ADMIN_TOKEN 和 AIHR_M0_EMPLOYEE_TOKEN;脚本不会读取验证码、密码或会话数据'); + } +} + +function compactResult() { + return { + schemaVersion: 1, + suite: 'broadcast-m0-acceptance', + outcome: failure ? 'FAIL' : 'PASS', + baseUrl, + runId, + tenantId: tenantId || null, + messageId: messageId || null, + withdrawn, + localQueryFixture: queryFixture.enabled ? { + prepared: true, + appCreated: queryFixture.appCreated, + appBindingCreated: queryFixture.appBindingCreated, + employeeGrantCreated: queryFixture.employeeGrantCreated + } : null, + checks, + failedAt: failure?.message || null + }; +} + +function writeReport() { + if (!reportPath) return; + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(compactResult(), null, 2)}\n`, 'utf8'); + console.log(`报告已写入 ${reportPath}`); +} + +async function cleanup() { + if (!messageId || withdrawn || !admin || !tenantId) return; + try { + const result = await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, { + token: admin.token, + clientId: admin.clientId, + body: { reason: withdrawReason, expectedTenantId: tenantId } + }); + requireSuccess(result, '失败后的测试消息撤回'); + withdrawn = true; + console.log('CLEANUP 已撤回临时测试消息'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupFailure ||= new Error(`临时测试消息撤回失败:${message}`); + console.error(`CLEANUP_FAILED ${message}`); + } +} + +async function main() { + protectWriteScope(); + admin = { token: process.env.AIHR_M0_ADMIN_TOKEN, clientId: adminClientId }; + employee = { token: process.env.AIHR_M0_EMPLOYEE_TOKEN, clientId: mobileClientId }; + record('受控管理员和员工令牌已提供'); + + const adminList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=1&status=all', { + token: admin.token, + clientId: admin.clientId + }), '读取管理员消息范围'); + tenantId = String(requireValue(adminList?.tenantId, '管理员生效租户')).trim(); + if (configuredTenantId && tenantId !== configuredTenantId) { + throw new Error(`管理员生效租户与预期不一致:实际 ${tenantId},预期 ${configuredTenantId}`); + } + record('管理员租户范围已确认', { tenantId }); + prepareQueryFixture(); + + const employeePublish = await request('POST', '/api/aihr/broadcast/messages', { + token: employee.token, + clientId: employee.clientId, + body: { requestId: `forbidden-${runId}`, title, content, expectedTenantId: tenantId } + }); + if (employeePublish.status < 400 && businessCode(employeePublish) === 200) { + throw new Error('员工令牌意外拥有公司消息发布权限'); + } + record('员工不能发布公司消息'); + + const unreadBefore = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', { + token: employee.token, + clientId: employee.clientId + }), '读取发布前未读数'); + const unreadBeforeCount = Number(unreadBefore?.unreadCount); + if (!Number.isInteger(unreadBeforeCount) || unreadBeforeCount < 0) throw new Error('发布前未读数非法'); + record('发布前员工未读数可读取'); + + const publishRequest = { + requestId: `m0-${runId}`, + title, + content, + expectedTenantId: tenantId + }; + const published = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', { + token: admin.token, + clientId: admin.clientId, + body: publishRequest + }), '发布测试消息'); + messageId = Number(requireValue(published?.id, '发布消息编号')); + if (!Number.isSafeInteger(messageId) || messageId < 1 || Number(published?.version) !== 1) { + throw new Error('发布响应未返回预期的消息编号或 v1 版本'); + } + record('管理员发布消息', { version: 1 }); + + const replay = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', { + token: admin.token, + clientId: admin.clientId, + body: publishRequest + }), '重放同一发布请求'); + if (Number(replay?.id) !== messageId || Number(replay?.version) !== 1) { + throw new Error('相同 requestId 未返回原消息,发布幂等性失效'); + } + record('发布重放保持幂等'); + + const employeeList = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', { + token: employee.token, + clientId: employee.clientId + }), '读取员工消息列表'); + const listed = Array.isArray(employeeList?.rows) ? employeeList.rows.find((row) => Number(row?.id) === messageId) : null; + if (!listed || listed.read !== false || listed.title !== title) throw new Error('新发布消息未以未读状态出现在员工列表'); + record('员工列表可见新消息且为未读'); + + const detailBeforeRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, { + token: employee.token, + clientId: employee.clientId + }), '读取员工消息详情'); + if (detailBeforeRead?.title !== title || detailBeforeRead?.content !== content || detailBeforeRead?.read !== false) { + throw new Error('员工详情与发布内容或未读状态不一致'); + } + record('员工详情可读取'); + + requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, { + token: employee.token, + clientId: employee.clientId + }), '员工首次标记已读'); + requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, { + token: employee.token, + clientId: employee.clientId + }), '员工重复标记已读'); + const detailAfterRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, { + token: employee.token, + clientId: employee.clientId + }), '复核员工已读状态'); + if (detailAfterRead?.read !== true) throw new Error('员工首次已读记录未生效'); + const unreadAfterRead = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', { + token: employee.token, + clientId: employee.clientId + }), '读取已读后的未读数'); + if (Number(unreadAfterRead?.unreadCount) !== unreadBeforeCount) { + throw new Error('重复已读改变了未读数,已读幂等性失效'); + } + record('员工已读审计保持幂等'); + + const question = requireSuccess(await request('POST', '/api/knowledge/query', { + token: employee.token, + clientId: employee.clientId, + body: { + queryText: '请概述这条消息的要求,不要把它解释成已向我个人分配任务。', + category: 'sop', + position: '客服管家', + source: 'm0_broadcast_acceptance', + limit: 5, + broadcastMessageId: messageId + } + }), '围绕公司消息追问'); + const questionJson = JSON.stringify(question); + const hasBroadcastCitation = Array.isArray(question?.citations) + && question.citations.some((citation) => citation?.sourceType === 'BCAST'); + if (Number(question?.broadcastContext?.messageId) !== messageId || !hasBroadcastCitation || questionJson.includes(content)) { + throw new Error('消息追问未保留可信上下文、未给出 BCAST 引用,或意外序列化了消息正文'); + } + record('员工可围绕消息追问且不泄露正文'); + + requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, { + token: admin.token, + clientId: admin.clientId, + body: { reason: withdrawReason, expectedTenantId: tenantId } + }), '撤回测试消息'); + withdrawn = true; + + const withdrawnList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=100&status=WITHDRAWN', { + token: admin.token, + clientId: admin.clientId + }), '读取撤回审计'); + const withdrawnRow = Array.isArray(withdrawnList?.rows) + ? withdrawnList.rows.find((row) => Number(row?.id) === messageId) + : null; + if (!withdrawnRow || withdrawnRow.status !== 'WITHDRAWN' || withdrawnRow.withdrawReason !== withdrawReason) { + throw new Error('撤回记录或首次撤回原因未保留'); + } + record('撤回保留审计记录'); + + const employeeListAfterWithdraw = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', { + token: employee.token, + clientId: employee.clientId + }), '撤回后读取员工消息列表'); + if (Array.isArray(employeeListAfterWithdraw?.rows) + && employeeListAfterWithdraw.rows.some((row) => Number(row?.id) === messageId)) { + throw new Error('已撤回消息仍出现在员工列表'); + } + requireUnavailable(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, { + token: employee.token, + clientId: employee.clientId + }), '撤回后员工详情'); + requireUnavailable(await request('POST', '/api/knowledge/query', { + token: employee.token, + clientId: employee.clientId, + body: { + queryText: '这条消息还有效吗?', + category: 'sop', + position: '客服管家', + source: 'm0_broadcast_acceptance', + limit: 5, + broadcastMessageId: messageId + } + }), '撤回后消息追问'); + record('撤回后员工不可见且不可追问'); +} + +try { + await main(); +} catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); +} finally { + await cleanup(); + cleanupQueryFixture(); + if (!failure && cleanupFailure) failure = cleanupFailure; + writeReport(); +} + +if (failure) { + console.error(`M0_BROADCAST_ACCEPTANCE FAIL ${failure.message}`); + process.exitCode = 1; +} else { + console.log(`M0_BROADCAST_ACCEPTANCE PASS ${checks.length}/${checks.length}`); +}