From f1d40db3224e052b44aa7588ab2871a3426369fc Mon Sep 17 00:00:00 2001 From: En Date: Thu, 9 Apr 2026 21:31:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=20h5Url=20=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=BB=A3=E7=90=86=20Content-Type=20=E5=AF=BC=E8=87=B4=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 LeaiTokenVO.h5Url 字段、LeaiConfig.h5Url 配置及 yml 中的 h5-url - 删除 LeaiAuthController.authRedirect() 方法和 LeaiAuthRedirectDTO - 移除前端 authRedirectUrl 状态及 WelcomeView 企业认证按钮死代码 - 修复 LeaiProxyController 返回 text/plain 导致前端无法解析 JSON 的问题 (改用 ResponseEntity + application/json Content-Type) - 修复前端 aicreate 所有视图组件中 res.data 双重取值问题 (publicApi 拦截器已自动解包,无需再取 .data) - 同步更新 E2E 测试 mock 数据移除 h5Url Co-Authored-By: Claude Opus 4.6 --- backend-java/pom.xml | 8 + .../common/constants/CacheConstants.java | 24 +++ .../competition/common/enums/ErrorCode.java | 7 + .../exception/GlobalExceptionHandler.java | 49 +++++ .../modules/leai/config/LeaiConfig.java | 3 - .../leai/controller/LeaiAuthController.java | 45 ---- .../leai/controller/LeaiProxyController.java | 197 ++++++++++++++++++ .../modules/leai/dto/LeaiAuthRedirectDTO.java | 15 -- .../modules/leai/service/LeaiApiClient.java | 146 +++++++++++++ .../modules/leai/service/LeaiSyncService.java | 54 ++++- .../modules/leai/task/LeaiReconcileTask.java | 6 +- .../modules/leai/vo/LeaiTokenVO.java | 6 - .../modules/pub/config/SmsConfig.java | 29 +++ .../pub/controller/PublicAuthController.java | 12 ++ .../modules/pub/dto/PublicRegisterDto.java | 5 + .../modules/pub/dto/SendSmsCodeDto.java | 19 ++ .../pub/service/PublicAuthService.java | 4 + .../modules/pub/service/SmsCodeService.java | 132 ++++++++++++ .../modules/pub/service/SmsService.java | 81 +++++++ .../modules/sys/entity/SysUser.java | 2 +- .../src/main/resources/application-dev.yml | 10 +- .../src/main/resources/application-prod.yml | 17 +- .../src/main/resources/application-test.yml | 10 +- .../V13__fix_user_unique_indexes.sql | 43 ++++ frontend/e2e/leai/auth-api.spec.ts | 43 +--- frontend/e2e/leai/creation-iframe.spec.ts | 4 - frontend/e2e/leai/e2e-flow.spec.ts | 5 +- .../e2e/leai/keepalive-tab-switch.spec.ts | 1 - frontend/e2e/leai/postmessage.spec.ts | 1 - frontend/e2e/utils/webhook-helper.ts | 1 - frontend/src/api/aicreate/index.ts | 170 ++------------- frontend/src/api/public.ts | 22 +- frontend/src/layouts/PublicLayout.vue | 9 +- frontend/src/stores/aicreate.ts | 35 +--- frontend/src/utils/aicreate/hmac.ts | 26 --- frontend/src/views/public/Login.vue | 75 ++++++- frontend/src/views/public/create/Index.vue | 5 +- .../public/create/views/BookReaderView.vue | 6 +- .../public/create/views/CharactersView.vue | 2 +- .../public/create/views/CreatingView.vue | 2 +- .../views/public/create/views/DubbingView.vue | 8 +- .../public/create/views/EditInfoView.vue | 2 +- .../views/public/create/views/PreviewView.vue | 2 +- .../public/create/views/SaveSuccessView.vue | 2 +- .../views/public/create/views/UploadView.vue | 2 +- .../views/public/create/views/WelcomeView.vue | 24 +-- frontend/test-results/.last-run.json | 45 +--- .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 150 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 143 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 143 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 143 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 143 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 143 ------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../error-context.md | 185 ---------------- .../test-failed-1.png | Bin 4254 -> 0 bytes 127 files changed, 989 insertions(+), 7582 deletions(-) create mode 100644 backend-java/src/main/java/com/competition/modules/leai/controller/LeaiProxyController.java delete mode 100644 backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java create mode 100644 backend-java/src/main/java/com/competition/modules/pub/config/SmsConfig.java create mode 100644 backend-java/src/main/java/com/competition/modules/pub/dto/SendSmsCodeDto.java create mode 100644 backend-java/src/main/java/com/competition/modules/pub/service/SmsCodeService.java create mode 100644 backend-java/src/main/java/com/competition/modules/pub/service/SmsService.java create mode 100644 backend-java/src/main/resources/db/migration/V13__fix_user_unique_indexes.sql delete mode 100644 frontend/src/utils/aicreate/hmac.ts delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-01-创建活动页表单渲染-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-01-创建活动页表单渲染-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-02-必填字段校验-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-02-必填字段校验-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-03-填写活动信息-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-03-填写活动信息-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-04-时间范围选择器可见-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-04-时间范围选择器可见-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-05-返回按钮功能-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contest-create-创建活动-CC-05-返回按钮功能-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-01-活动列表页正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-01-活动列表页正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-02-搜索功能正常-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-02-搜索功能正常-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-03-活动阶段筛选正常-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-03-活动阶段筛选正常-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-04-分页功能正常-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-04-分页功能正常-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-05-点击活动查看详情-chromium/error-context.md delete mode 100644 frontend/test-results/admin-contests-活动管理列表-C-05-点击活动查看详情-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-01-工作台页面正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-01-工作台页面正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-02-统计卡片数据展示-chromium/error-context.md delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-02-统计卡片数据展示-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-03-快捷入口可点击-chromium/error-context.md delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-03-快捷入口可点击-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-04-顶部信息栏正确-chromium/error-context.md delete mode 100644 frontend/test-results/admin-dashboard-工作台-仪表盘-D-04-顶部信息栏正确-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-01-管理端登录页正常渲染-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-01-管理端登录页正常渲染-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-02-空表单提交显示校验错误-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-02-空表单提交显示校验错误-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-03-错误密码登录失败-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-03-错误密码登录失败-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-04-正确凭据登录成功跳转-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-04-正确凭据登录成功跳转-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-05-登录后-Token-存储正确-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-05-登录后-Token-存储正确-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-06-退出登录清除状态-chromium/error-context.md delete mode 100644 frontend/test-results/admin-login-管理端登录流程-L-06-退出登录清除状态-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-01-侧边栏菜单渲染-chromium/error-context.md delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-01-侧边栏菜单渲染-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-02-菜单点击导航---工作台-chromium/error-context.md delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-02-菜单点击导航---工作台-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-03-菜单点击导航---活动管理子菜单-chromium/error-context.md delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-03-菜单点击导航---活动管理子菜单-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-04-浏览器刷新保持状态-chromium/error-context.md delete mode 100644 frontend/test-results/admin-navigation-侧边栏导航-N-04-浏览器刷新保持状态-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-01-报名列表页正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-01-报名列表页正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-02-搜索报名记录-chromium/error-context.md delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-02-搜索报名记录-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-03-审核状态筛选-chromium/error-context.md delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-03-审核状态筛选-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-04-查看报名详情-chromium/error-context.md delete mode 100644 frontend/test-results/admin-registrations-报名管理-R-04-查看报名详情-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-01-评审规则列表正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-01-评审规则列表正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-02-新建评审规则弹窗-chromium/error-context.md delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-02-新建评审规则弹窗-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-03-评委管理页面-chromium/error-context.md delete mode 100644 frontend/test-results/admin-reviews-评审管理-RV-03-评委管理页面-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-users-用户管理-U-01-用户列表页正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-users-用户管理-U-01-用户列表页正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-users-用户管理-U-02-搜索用户-chromium/error-context.md delete mode 100644 frontend/test-results/admin-users-用户管理-U-02-搜索用户-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-users-用户管理-U-03-用户状态筛选-chromium/error-context.md delete mode 100644 frontend/test-results/admin-users-用户管理-U-03-用户状态筛选-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-users-用户管理-U-04-创建用户弹窗-chromium/error-context.md delete mode 100644 frontend/test-results/admin-users-用户管理-U-04-创建用户弹窗-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-users-用户管理-U-05-用户操作菜单-chromium/error-context.md delete mode 100644 frontend/test-results/admin-users-用户管理-U-05-用户操作菜单-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-works-作品管理-W-01-作品列表页正常加载-chromium/error-context.md delete mode 100644 frontend/test-results/admin-works-作品管理-W-01-作品列表页正常加载-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-works-作品管理-W-02-搜索作品-chromium/error-context.md delete mode 100644 frontend/test-results/admin-works-作品管理-W-02-搜索作品-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-works-作品管理-W-03-作品状态筛选-chromium/error-context.md delete mode 100644 frontend/test-results/admin-works-作品管理-W-03-作品状态筛选-chromium/test-failed-1.png delete mode 100644 frontend/test-results/admin-works-作品管理-W-04-作品表格操作按钮-chromium/error-context.md delete mode 100644 frontend/test-results/admin-works-作品管理-W-04-作品表格操作按钮-chromium/test-failed-1.png diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 2be4c9a..660836f 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -27,6 +27,7 @@ 5.8.32 2.0.53 3.17.1 + 2.0.24 @@ -137,6 +138,13 @@ ${aliyun-oss.version} + + + com.aliyun + dysmsapi20170525 + ${aliyun-dysmsapi.version} + + org.projectlombok diff --git a/backend-java/src/main/java/com/competition/common/constants/CacheConstants.java b/backend-java/src/main/java/com/competition/common/constants/CacheConstants.java index e50560a..411a900 100644 --- a/backend-java/src/main/java/com/competition/common/constants/CacheConstants.java +++ b/backend-java/src/main/java/com/competition/common/constants/CacheConstants.java @@ -18,4 +18,28 @@ public final class CacheConstants { /** Token 黑名单 key 前缀(用于登出/密码修改后使旧 Token 失效) */ public static final String TOKEN_BLACKLIST_PREFIX = "token:blacklist:"; + + /** 短信验证码 key 前缀(值: 6位数字验证码,TTL: 5分钟) */ + public static final String SMS_CODE_PREFIX = "sms:code:"; + + /** 短信发送间隔 key 前缀(值: "1",TTL: 60秒) */ + public static final String SMS_INTERVAL_PREFIX = "sms:interval:"; + + /** 短信每日发送次数 key 前缀(值: 当日发送次数,TTL: 到当天24:00) */ + public static final String SMS_DAILY_PREFIX = "sms:daily:"; + + /** 短信验证失败次数 key 前缀(值: 失败次数,TTL: 5分钟) */ + public static final String SMS_VERIFY_PREFIX = "sms:verify:"; + + /** 短信验证码有效期(分钟) */ + public static final int SMS_CODE_EXPIRE_MINUTES = 5; + + /** 短信发送间隔(秒) */ + public static final int SMS_INTERVAL_SECONDS = 60; + + /** 短信每日发送上限 */ + public static final int SMS_DAILY_LIMIT = 15; + + /** 短信验证失败上限(超过后需重新获取验证码) */ + public static final int SMS_VERIFY_FAIL_LIMIT = 5; } diff --git a/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java b/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java index e9e5b04..a9a7c96 100644 --- a/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java +++ b/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java @@ -28,6 +28,13 @@ public enum ErrorCode { USER_DUPLICATE(1004, "用户名已存在"), USER_PHONE_DUPLICATE(1005, "手机号已注册"), + // ====== 短信验证码 10xx(接续用户模块) ====== + SMS_SEND_TOO_FREQUENT(1006, "发送验证码太频繁,请稍后再试"), + SMS_DAILY_LIMIT_EXCEEDED(1007, "今日验证码发送次数已达上限"), + SMS_CODE_EXPIRED(1008, "验证码已过期,请重新获取"), + SMS_CODE_INVALID(1009, "验证码错误"), + SMS_SEND_FAILED(1010, "验证码发送失败,请稍后再试"), + // ====== 活动模块 20xx ====== CONTEST_NOT_FOUND(2001, "活动不存在"), CONTEST_ALREADY_PUBLISHED(2002, "活动已发布"), diff --git a/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java index 465890f..1565af6 100644 --- a/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java +++ b/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java @@ -4,6 +4,8 @@ import com.competition.common.enums.ErrorCode; import com.competition.common.result.Result; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; @@ -73,6 +75,24 @@ public class GlobalExceptionHandler { return Result.error(404, ErrorCode.NOT_FOUND.getMessage(), request.getRequestURI()); } + /** 数据完整性违反(唯一约束冲突) */ + @ExceptionHandler(DataIntegrityViolationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public Result handleDataIntegrityViolation(DataIntegrityViolationException e, HttpServletRequest request) { + String message = parseDuplicateMessage(e); + log.warn("数据冲突,路径:{},消息:{}", request.getRequestURI(), message); + return Result.error(409, message, request.getRequestURI()); + } + + /** 主键/唯一键重复(MySQL 驱动可能直接抛此异常) */ + @ExceptionHandler(DuplicateKeyException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public Result handleDuplicateKey(DuplicateKeyException e, HttpServletRequest request) { + String message = parseDuplicateMessage(e); + log.warn("唯一键冲突,路径:{},消息:{}", request.getRequestURI(), message); + return Result.error(409, message, request.getRequestURI()); + } + /** 兜底:未知异常 */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @@ -80,4 +100,33 @@ public class GlobalExceptionHandler { log.error("系统异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage(), e); return Result.error(500, ErrorCode.INTERNAL_ERROR.getMessage(), request.getRequestURI()); } + + /** + * 解析数据库唯一约束冲突的具体字段,返回友好提示 + */ + private String parseDuplicateMessage(Exception e) { + String rootMsg = getRootCauseMessage(e); + if (rootMsg == null) { + return ErrorCode.CONFLICT.getMessage(); + } + String lowerMsg = rootMsg.toLowerCase(); + if (lowerMsg.contains("phone")) { + return ErrorCode.USER_PHONE_DUPLICATE.getMessage(); + } + if (lowerMsg.contains("username")) { + return ErrorCode.USER_DUPLICATE.getMessage(); + } + return ErrorCode.CONFLICT.getMessage(); + } + + /** + * 获取异常链中的根因消息 + */ + private String getRootCauseMessage(Throwable e) { + Throwable cause = e; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage(); + } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/config/LeaiConfig.java b/backend-java/src/main/java/com/competition/modules/leai/config/LeaiConfig.java index 613178a..f261843 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/config/LeaiConfig.java +++ b/backend-java/src/main/java/com/competition/modules/leai/config/LeaiConfig.java @@ -22,7 +22,4 @@ public class LeaiConfig { /** 乐读派后端 API 地址 */ private String apiUrl = "http://192.168.1.120:8080"; - - /** 乐读派 H5 前端地址 */ - private String h5Url = "http://192.168.1.120:3001"; } diff --git a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java index 6e807e5..159edc7 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java +++ b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java @@ -4,22 +4,16 @@ import com.competition.common.exception.BusinessException; import com.competition.common.result.Result; import com.competition.common.util.SecurityUtil; import com.competition.modules.leai.config.LeaiConfig; -import com.competition.modules.leai.dto.LeaiAuthRedirectDTO; import com.competition.modules.leai.service.LeaiApiClient; import com.competition.modules.leai.vo.LeaiTokenVO; import com.competition.modules.sys.entity.SysUser; import com.competition.modules.sys.service.ISysUserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - /** * 乐读派认证入口控制器 * 前端 iframe 模式的主入口 @@ -61,8 +55,6 @@ public class LeaiAuthController { LeaiTokenVO vo = new LeaiTokenVO(); vo.setToken(token); vo.setOrgId(leaiConfig.getOrgId()); // 即租户 tenant_code - vo.setH5Url(leaiConfig.getH5Url()); - vo.setPhone(phone); log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone); return Result.success(vo); @@ -75,42 +67,6 @@ public class LeaiAuthController { } } - /** - * 跳转模式备选:换 token + 302 重定向到 H5 - * 需要登录认证 - */ - @GetMapping - @Operation(summary = "重定向到乐读派 H5 创作页") - public void authRedirect(LeaiAuthRedirectDTO dto, HttpServletResponse response) throws IOException { - Long userId = SecurityUtil.getCurrentUserId(); - SysUser user = sysUserService.getById(userId); - if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) { - response.sendError(401, "请先登录并绑定手机号"); - return; - } - - String phone = user.getPhone(); - try { - String token = leaiApiClient.exchangeToken(phone); - - StringBuilder url = new StringBuilder(leaiConfig.getH5Url()) - .append("/?token=").append(URLEncoder.encode(token, StandardCharsets.UTF_8)) - .append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8)) - .append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8)); - - if (dto.getReturnPath() != null && !dto.getReturnPath().isEmpty()) { - url.append("&returnPath=").append(URLEncoder.encode(dto.getReturnPath(), StandardCharsets.UTF_8)); - } - - log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone); - response.sendRedirect(url.toString()); - - } catch (Exception e) { - log.error("[乐读派] 重定向失败, userId={}", userId, e); - response.sendError(500, "跳转乐读派失败: " + e.getMessage()); - } - } - /** * iframe 内 Token 刷新接口 * 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口 @@ -132,7 +88,6 @@ public class LeaiAuthController { LeaiTokenVO vo = new LeaiTokenVO(); vo.setToken(token); vo.setOrgId(leaiConfig.getOrgId()); - vo.setPhone(phone); log.info("[乐读派] Token刷新成功, userId={}", userId); return Result.success(vo); diff --git a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiProxyController.java b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiProxyController.java new file mode 100644 index 0000000..f10d1ed --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiProxyController.java @@ -0,0 +1,197 @@ +package com.competition.modules.leai.controller; + +import com.competition.common.exception.BusinessException; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.leai.config.LeaiConfig; +import com.competition.modules.leai.service.LeaiApiClient; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.service.ISysUserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +/** + * 乐读派代理控制器 + * 前端所有乐读派请求经由本控制器代理转发,前端不持有 phone/orgId/appSecret + * 后端自动注入 orgId + phone,使用 HMAC 签名与乐读派通信 + */ +@Slf4j +@Tag(name = "乐读派代理") +@RestController +@RequestMapping("/leai-proxy") +@RequiredArgsConstructor +public class LeaiProxyController { + + private final LeaiApiClient leaiApiClient; + private final LeaiConfig leaiConfig; + private final ISysUserService sysUserService; + private final ObjectMapper objectMapper; + + /** + * 获取当前用户手机号,校验非空 + */ + private String getPhoneOrThrow() { + Long userId = SecurityUtil.getCurrentUserId(); + SysUser user = sysUserService.getById(userId); + if (user == null) { + throw new BusinessException(404, "用户不存在"); + } + String phone = user.getPhone(); + if (phone == null || phone.isEmpty()) { + throw new BusinessException(400, "用户未绑定手机号,无法使用创作功能"); + } + return phone; + } + + /** + * 构建带 orgId + phone 的基础 body + */ + private Map buildBaseBody() { + Map body = new HashMap<>(); + body.put("orgId", leaiConfig.getOrgId()); + body.put("phone", getPhoneOrThrow()); + return body; + } + + /** + * 将乐读派原始 JSON 响应以 application/json 返回给前端 + * (String 返回类型默认 Content-Type 为 text/plain,需手动设为 JSON) + */ + private ResponseEntity jsonOk(String body) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } + + // ═══════════════════════════════════════════════════════════ + // 代理接口 + // ═══════════════════════════════════════════════════════════ + + /** + * 图片上传 + * POST /leai-proxy/upload → 乐读派 /api/v1/creation/upload + */ + @PostMapping("/upload") + @Operation(summary = "图片上传代理") + public ResponseEntity proxyUpload(@RequestParam("file") MultipartFile file) { + log.info("[乐读派代理] 图片上传, fileName={}, size={}", file.getOriginalFilename(), file.getSize()); + return jsonOk(leaiApiClient.proxyUpload("/creation/upload", file)); + } + + /** + * 角色提取 + * POST /leai-proxy/extract → 乐读派 /api/v1/creation/extract-original + */ + @PostMapping("/extract") + @Operation(summary = "角色提取代理") + public ResponseEntity proxyExtract(@RequestBody Map requestBody) { + Map body = buildBaseBody(); + body.putAll(requestBody); + log.info("[乐读派代理] 角色提取, imageUrl={}", requestBody.get("imageUrl")); + return jsonOk(leaiApiClient.proxyPost("/creation/extract-original", body)); + } + + /** + * 故事创作 + * POST /leai-proxy/create-story → 乐读派 /api/v1/creation/image-story + */ + @PostMapping("/create-story") + @Operation(summary = "故事创作代理") + public ResponseEntity proxyCreateStory(@RequestBody Map requestBody) { + Map body = buildBaseBody(); + body.putAll(requestBody); + // 默认关闭自动配音 + body.putIfAbsent("enableVoice", false); + log.info("[乐读派代理] 故事创作, imageUrl={}", requestBody.get("imageUrl")); + return jsonOk(leaiApiClient.proxyPost("/creation/image-story", body)); + } + + /** + * 查询作品详情 + * GET /leai-proxy/work/{id} → 乐读派 /api/v1/query/work/{id} + */ + @GetMapping("/work/{id}") + @Operation(summary = "查询作品详情代理") + public ResponseEntity proxyGetWork(@PathVariable String id) { + Map params = new HashMap<>(); + params.put("orgId", leaiConfig.getOrgId()); + params.put("phone", getPhoneOrThrow()); + log.info("[乐读派代理] 查询作品详情, workId={}", id); + return jsonOk(leaiApiClient.proxyGet("/query/work/" + id, params)); + } + + /** + * 额度校验 + * POST /leai-proxy/validate → 乐读派 /api/v1/query/validate + */ + @PostMapping("/validate") + @Operation(summary = "额度校验代理") + public ResponseEntity proxyValidate(@RequestBody(required = false) Map requestBody) { + Map body = buildBaseBody(); + if (requestBody != null) { + body.putAll(requestBody); + } + body.putIfAbsent("apiType", "A3"); + log.info("[乐读派代理] 额度校验"); + return jsonOk(leaiApiClient.proxyPost("/query/validate", body)); + } + + /** + * 编辑作品 + * PUT /leai-proxy/work/{id} → 乐读派 /api/v1/update/work/{id} + */ + @PutMapping("/work/{id}") + @Operation(summary = "编辑作品代理") + public ResponseEntity proxyUpdateWork(@PathVariable String id, @RequestBody Map requestBody) { + Map body = new HashMap<>(requestBody); + log.info("[乐读派代理] 编辑作品, workId={}", id); + return jsonOk(leaiApiClient.proxyPut("/update/work/" + id, body)); + } + + /** + * 批量更新配音 + * POST /leai-proxy/batch-audio → 乐读派 /api/v1/update/batch-audio + */ + @PostMapping("/batch-audio") + @Operation(summary = "批量更新配音代理") + public ResponseEntity proxyBatchAudio(@RequestBody Map requestBody) { + Map body = buildBaseBody(); + body.putAll(requestBody); + log.info("[乐读派代理] 批量更新配音, workId={}", requestBody.get("workId")); + return jsonOk(leaiApiClient.proxyPost("/update/batch-audio", body)); + } + + /** + * AI 配音 + * POST /leai-proxy/voice → 乐读派 /api/v1/creation/voice + */ + @PostMapping("/voice") + @Operation(summary = "AI配音代理") + public ResponseEntity proxyVoice(@RequestBody Map requestBody) { + Map body = buildBaseBody(); + body.putAll(requestBody); + log.info("[乐读派代理] AI配音"); + return jsonOk(leaiApiClient.proxyPost("/creation/voice", body)); + } + + /** + * STS 临时凭证 + * POST /leai-proxy/sts-token → 乐读派 /api/v1/oss/sts-token + */ + @PostMapping("/sts-token") + @Operation(summary = "STS临时凭证代理") + public ResponseEntity proxyStsToken() { + Map body = buildBaseBody(); + log.info("[乐读派代理] 获取STS凭证"); + return jsonOk(leaiApiClient.proxyPost("/oss/sts-token", body)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java b/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java deleted file mode 100644 index ed0505b..0000000 --- a/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.competition.modules.leai.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -/** - * 乐读派重定向请求 DTO - */ -@Data -@Schema(description = "乐读派重定向请求") -public class LeaiAuthRedirectDTO { - - @Schema(description = "重定向后的路径") - private String returnPath; -} diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java index c0c1d5a..ed72484 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java @@ -11,8 +11,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; @@ -269,4 +272,147 @@ public class LeaiApiClient { return ZonedDateTime.ofInstant(Instant.now().minusSeconds(7200), ZoneOffset.UTC) .format(DateTimeFormatter.ISO_INSTANT); } + + // ═══════════════════════════════════════════════════════════ + // 通用代理方法(后端代理前端请求,HMAC 签名) + // ═══════════════════════════════════════════════════════════ + + private static final int PROXY_TIMEOUT_MS = 120_000; + + /** + * 通用 GET 代理(HMAC 签名) + * @param path 乐读派 API 路径,如 /query/work/123 + * @param params 查询参数 + * @return 乐读派原始 JSON 响应 + */ + public String proxyGet(String path, Map params) { + Map queryParams = new TreeMap<>(params); + Map hmacHeaders = buildHmacHeaders(queryParams); + + String url = leaiConfig.getApiUrl() + "/api/v1" + path; + log.debug("[乐读派代理] GET {}", url); + + try { + HttpResponse httpResponse = HttpRequest.get(url) + .form(queryParams) + .addHeaders(hmacHeaders) + .timeout(PROXY_TIMEOUT_MS) + .execute(); + + String responseBody = httpResponse.body(); + log.debug("[乐读派代理] GET {} 响应: {}", url, responseBody); + return responseBody; + + } catch (Exception e) { + log.error("[乐读派代理] GET {} 失败", url, e); + throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage()); + } + } + + /** + * 通用 POST 代理(HMAC 签名 + JSON body) + * @param path 乐读派 API 路径,如 /creation/extract-original + * @param body 请求体(会自动注入 orgId) + * @return 乐读派原始 JSON 响应 + */ + public String proxyPost(String path, Map body) { + // POST body 不参与签名,签名仅基于 query params + Map signParams = new TreeMap<>(); + Map hmacHeaders = buildHmacHeaders(signParams); + + String url = leaiConfig.getApiUrl() + "/api/v1" + path; + log.debug("[乐读派代理] POST {} body={}", url, body); + + try { + String jsonBody = objectMapper.writeValueAsString(body); + + HttpResponse httpResponse = HttpRequest.post(url) + .body(jsonBody) + .contentType("application/json") + .addHeaders(hmacHeaders) + .timeout(PROXY_TIMEOUT_MS) + .execute(); + + String responseBody = httpResponse.body(); + log.debug("[乐读派代理] POST {} 响应: {}", url, responseBody); + return responseBody; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("[乐读派代理] POST {} 失败", url, e); + throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage()); + } + } + + /** + * 通用 PUT 代理(HMAC 签名 + JSON body) + * @param path 乐读派 API 路径,如 /update/work/123 + * @param body 请求体 + * @return 乐读派原始 JSON 响应 + */ + public String proxyPut(String path, Map body) { + Map signParams = new TreeMap<>(); + Map hmacHeaders = buildHmacHeaders(signParams); + + String url = leaiConfig.getApiUrl() + "/api/v1" + path; + log.debug("[乐读派代理] PUT {} body={}", url, body); + + try { + String jsonBody = objectMapper.writeValueAsString(body); + + HttpResponse httpResponse = HttpRequest.put(url) + .body(jsonBody) + .contentType("application/json") + .addHeaders(hmacHeaders) + .timeout(PROXY_TIMEOUT_MS) + .execute(); + + String responseBody = httpResponse.body(); + log.debug("[乐读派代理] PUT {} 响应: {}", url, responseBody); + return responseBody; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("[乐读派代理] PUT {} 失败", url, e); + throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage()); + } + } + + /** + * 文件上传代理(multipart → 乐读派) + * @param path 乐读派 API 路径,如 /creation/upload + * @param file 上传的文件 + * @return 乐读派原始 JSON 响应 + */ + public String proxyUpload(String path, MultipartFile file) { + Map signParams = new TreeMap<>(); + Map hmacHeaders = buildHmacHeaders(signParams); + + String url = leaiConfig.getApiUrl() + "/api/v1" + path; + log.debug("[乐读派代理] UPLOAD {} fileName={}", url, file.getOriginalFilename()); + + try { + byte[] fileBytes = file.getBytes(); + String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "upload"; + + HttpResponse httpResponse = HttpRequest.post(url) + .addHeaders(hmacHeaders) + .form("file", fileBytes, originalFilename) + .contentType("multipart/form-data") + .timeout(PROXY_TIMEOUT_MS) + .execute(); + + String responseBody = httpResponse.body(); + log.debug("[乐读派代理] UPLOAD {} 响应: {}", url, responseBody); + return responseBody; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("[乐读派代理] UPLOAD {} 失败", url, e); + throw new BusinessException(502, "乐读派代理上传失败: " + e.getMessage()); + } + } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java index 62a83fd..4c93c37 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java @@ -88,8 +88,14 @@ public class LeaiSyncService implements ILeaiSyncService { return; } - // 旧数据或重复推送,忽略 - log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus); + // 旧数据或重复推送,忽略状态更新 + // 但如果 remoteStatus >= 3 且本地缺少页面数据,需要补充拉取 + if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED && !hasPages(localWork.getId())) { + log.info("[{}] 状态未变但页面缺失,补充拉取: remoteWorkId={}, status={}", source, remoteWorkId, remoteStatus); + ensurePagesSaved(localWork.getId(), remoteWorkId, remoteData.get("pageList")); + } else { + log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus); + } } /** @@ -143,9 +149,9 @@ public class LeaiSyncService implements ILeaiSyncService { ugcWorkMapper.insert(work); - // 如果有 pageList 且 status >= 3,保存页面数据 + // 如果 status >= 3,确保页面数据已保存 if (work.getStatus() != null && work.getStatus() >= LeaiApiClient.STATUS_COMPLETED) { - savePageList(work.getId(), remoteData.get("pageList")); + ensurePagesSaved(work.getId(), remoteWorkId, remoteData.get("pageList")); } } @@ -246,9 +252,9 @@ public class LeaiSyncService implements ILeaiSyncService { return; } - // status >= 3 时保存 pageList + // status >= 3 时确保页面数据已保存 if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED) { - savePageList(work.getId(), remoteData.get("pageList")); + ensurePagesSaved(work.getId(), work.getRemoteWorkId(), remoteData.get("pageList")); } } @@ -290,6 +296,42 @@ public class LeaiSyncService implements ILeaiSyncService { log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size()); } + /** + * 检查本地是否已有页面记录 + */ + private boolean hasPages(Long workId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWorkPage::getWorkId, workId); + return ugcWorkPageMapper.selectCount(wrapper) > 0; + } + + /** + * 确保页面数据已保存 + * 如果传入的 pageListObj 不为空 → 直接保存 + * 如果为空 → 主动调用 B2 接口拉取完整数据,再保存页面 + */ + private void ensurePagesSaved(Long workId, String remoteWorkId, Object pageListObj) { + if (pageListObj != null) { + savePageList(workId, pageListObj); + return; + } + + // 本地已有页面数据,无需补充拉取 + if (hasPages(workId)) { + log.debug("[乐读派] 本地已有页面数据,跳过补充拉取: workId={}", workId); + return; + } + + // pageListObj 为空且本地无页面 → 主动拉取完整数据 + log.info("[乐读派] 页面数据缺失,主动拉取完整数据: workId={}, remoteWorkId={}", workId, remoteWorkId); + Map fullData = leaiApiClient.fetchWorkDetail(remoteWorkId); + if (fullData != null) { + savePageList(workId, fullData.get("pageList")); + } else { + log.warn("[乐读派] 补充拉取页面数据失败: workId={}, remoteWorkId={}", workId, remoteWorkId); + } + } + /** * 通过 remoteWorkId 查找本地作品 */ diff --git a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java index 0eab89d..ef9a739 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java +++ b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java @@ -16,7 +16,7 @@ import java.util.Map; /** * 定时任务:B3 对账 + Webhook 失败重试 *

- * 1. 每 30 分钟调用 B3 接口对账,补偿 Webhook 遗漏 + * 1. B3 对账:开发/测试环境每 1 分钟,生产环境每 30 分钟(配置项 leai.reconcile-interval) * 2. 每 10 分钟重试失败的 Webhook 事件(最多 3 次) */ @Slf4j @@ -35,9 +35,9 @@ public class LeaiReconcileTask { /** * B3 定时对账 - * 每 30 分钟执行,初始延迟 60 秒 + * 间隔由 leai.reconcile-interval 配置(开发/测试: 1分钟,生产: 30分钟) */ - @Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000) + @Scheduled(fixedRateString = "${leai.reconcile-interval:1800000}", initialDelayString = "${leai.reconcile-initial-delay:60000}") public void reconcile() { log.info("[B3对账] 开始执行..."); diff --git a/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java b/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java index 7d766c4..bec3c40 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java +++ b/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java @@ -15,10 +15,4 @@ public class LeaiTokenVO { @Schema(description = "机构ID(对应本项目的租户 code,即 tenant_code)") private String orgId; - - @Schema(description = "H5 前端地址") - private String h5Url; - - @Schema(description = "用户手机号") - private String phone; } diff --git a/backend-java/src/main/java/com/competition/modules/pub/config/SmsConfig.java b/backend-java/src/main/java/com/competition/modules/pub/config/SmsConfig.java new file mode 100644 index 0000000..cc42252 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/config/SmsConfig.java @@ -0,0 +1,29 @@ +package com.competition.modules.pub.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 阿里云短信服务配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "aliyun.sms") +public class SmsConfig { + + /** 阿里云短信 AccessKey ID */ + private String accessKeyId; + + /** 阿里云短信 AccessKey Secret */ + private String accessKeySecret; + + /** 短信签名名称 */ + private String signName = "乐绘世界"; + + /** 验证码模板 CODE */ + private String templateCode; + + /** 是否启用真实短信发送(false 时验证码输出到日志) */ + private boolean enabled = false; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java index 6a85a11..963fb92 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java @@ -5,7 +5,9 @@ import com.competition.common.result.Result; import com.competition.common.util.SecurityUtil; import com.competition.modules.pub.dto.PublicLoginDto; import com.competition.modules.pub.dto.PublicRegisterDto; +import com.competition.modules.pub.dto.SendSmsCodeDto; import com.competition.modules.pub.service.PublicAuthService; +import com.competition.modules.pub.service.SmsCodeService; import com.competition.security.annotation.Public; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,6 +24,7 @@ import java.util.Map; public class PublicAuthController { private final PublicAuthService publicAuthService; + private final SmsCodeService smsCodeService; @Public @PostMapping("/register") @@ -39,6 +42,15 @@ public class PublicAuthController { return Result.success(publicAuthService.login(dto)); } + @Public + @PostMapping("/sms/send") + @RateLimit(permits = 1, duration = 60) + @Operation(summary = "发送短信验证码") + public Result sendSmsCode(@Valid @RequestBody SendSmsCodeDto dto) { + smsCodeService.sendCode(dto.getPhone()); + return Result.success(null); + } + @PostMapping("/switch-child") @Operation(summary = "切换到子女账号") public Result> switchChild(@RequestBody Map body) { diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java index 60661a2..f77c5ea 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java @@ -30,6 +30,11 @@ public class PublicRegisterDto { @Schema(description = "手机号") private String phone; + @NotBlank(message = "短信验证码不能为空") + @Size(min = 6, max = 6, message = "验证码为6位数字") + @Schema(description = "短信验证码") + private String smsCode; + @Schema(description = "城市") private String city; } diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/SendSmsCodeDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/SendSmsCodeDto.java new file mode 100644 index 0000000..2979cf0 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/SendSmsCodeDto.java @@ -0,0 +1,19 @@ +package com.competition.modules.pub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 发送短信验证码请求 + */ +@Data +@Schema(description = "发送短信验证码请求") +public class SendSmsCodeDto { + + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + @Schema(description = "手机号") + private String phone; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java index 1640e8f..67dc843 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java @@ -43,12 +43,16 @@ public class PublicAuthService { private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final StringRedisTemplate stringRedisTemplate; + private final SmsCodeService smsCodeService; /** * 公众端注册 */ @Transactional public Map register(PublicRegisterDto dto) { + // 校验短信验证码 + smsCodeService.verifyCode(dto.getPhone(), dto.getSmsCode()); + // 查找公众租户 SysTenant publicTenant = sysTenantMapper.selectOne( new LambdaQueryWrapper().eq(SysTenant::getCode, TenantConstants.CODE_PUBLIC)); diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/SmsCodeService.java b/backend-java/src/main/java/com/competition/modules/pub/service/SmsCodeService.java new file mode 100644 index 0000000..543d66b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/SmsCodeService.java @@ -0,0 +1,132 @@ +package com.competition.modules.pub.service; + +import com.competition.common.constants.CacheConstants; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.concurrent.TimeUnit; + +/** + * 短信验证码业务服务 + * 负责:生成验证码、存储到 Redis、校验验证码 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsCodeService { + + private final SmsService smsService; + private final StringRedisTemplate stringRedisTemplate; + + /** + * 发送短信验证码 + * + * @param phone 手机号 + */ + public void sendCode(String phone) { + // 检查 60 秒发送间隔 + String intervalKey = CacheConstants.SMS_INTERVAL_PREFIX + phone; + if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(intervalKey))) { + throw new BusinessException(ErrorCode.SMS_SEND_TOO_FREQUENT.getCode(), + ErrorCode.SMS_SEND_TOO_FREQUENT.getMessage()); + } + + // 检查每日发送次数上限 + String dailyKey = CacheConstants.SMS_DAILY_PREFIX + phone; + String dailyCountStr = stringRedisTemplate.opsForValue().get(dailyKey); + int dailyCount = dailyCountStr != null ? Integer.parseInt(dailyCountStr) : 0; + if (dailyCount >= CacheConstants.SMS_DAILY_LIMIT) { + throw new BusinessException(ErrorCode.SMS_DAILY_LIMIT_EXCEEDED.getCode(), + ErrorCode.SMS_DAILY_LIMIT_EXCEEDED.getMessage()); + } + + // 生成验证码 + String code = smsService.generateCode(); + + // 调用短信服务发送 + smsService.sendVerifyCode(phone, code); + + // 存储验证码到 Redis(5 分钟有效) + String codeKey = CacheConstants.SMS_CODE_PREFIX + phone; + stringRedisTemplate.opsForValue().set(codeKey, code, + Duration.ofMinutes(CacheConstants.SMS_CODE_EXPIRE_MINUTES)); + + // 设置发送间隔(60 秒) + stringRedisTemplate.opsForValue().set(intervalKey, "1", + Duration.ofSeconds(CacheConstants.SMS_INTERVAL_SECONDS)); + + // 递增每日发送次数(TTL 到当天 24:00) + stringRedisTemplate.opsForValue().increment(dailyKey); + if (dailyCount == 0) { + // 首次发送,设置过期时间到当天结束 + long secondsUntilMidnight = Duration.between( + LocalDateTime.now(), + LocalDateTime.now().toLocalDate().plusDays(1).atTime(LocalTime.MIDNIGHT) + ).getSeconds(); + stringRedisTemplate.expire(dailyKey, secondsUntilMidnight, TimeUnit.SECONDS); + } + + log.info("验证码已发送:手机号={}", phone); + } + + /** + * 校验短信验证码 + * + * @param phone 手机号 + * @param code 用户输入的验证码 + * @return 校验是否通过 + */ + public boolean verifyCode(String phone, String code) { + if (code == null || code.isBlank()) { + throw new BusinessException(ErrorCode.SMS_CODE_INVALID.getCode(), + "验证码不能为空"); + } + + String codeKey = CacheConstants.SMS_CODE_PREFIX + phone; + String storedCode = stringRedisTemplate.opsForValue().get(codeKey); + + // 验证码不存在或已过期 + if (storedCode == null) { + throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED.getCode(), + ErrorCode.SMS_CODE_EXPIRED.getMessage()); + } + + // 验证码匹配 + if (storedCode.equals(code)) { + // 验证成功:删除验证码(一次性使用) + stringRedisTemplate.delete(codeKey); + // 清除验证失败次数 + stringRedisTemplate.delete(CacheConstants.SMS_VERIFY_PREFIX + phone); + log.info("验证码校验通过:手机号={}", phone); + return true; + } + + // 验证失败:递增失败次数 + String verifyKey = CacheConstants.SMS_VERIFY_PREFIX + phone; + Long failCount = stringRedisTemplate.opsForValue().increment(verifyKey); + if (failCount != null && failCount == 1) { + // 首次失败,设置过期时间 + stringRedisTemplate.expire(verifyKey, + Duration.ofMinutes(CacheConstants.SMS_CODE_EXPIRE_MINUTES)); + } + + // 失败次数达到上限,删除验证码 + if (failCount != null && failCount >= CacheConstants.SMS_VERIFY_FAIL_LIMIT) { + stringRedisTemplate.delete(codeKey); + stringRedisTemplate.delete(verifyKey); + log.warn("验证码错误次数超限,已清除:手机号={}", phone); + throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED.getCode(), + "验证码错误次数过多,请重新获取验证码"); + } + + throw new BusinessException(ErrorCode.SMS_CODE_INVALID.getCode(), + ErrorCode.SMS_CODE_INVALID.getMessage()); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/SmsService.java b/backend-java/src/main/java/com/competition/modules/pub/service/SmsService.java new file mode 100644 index 0000000..78b4aa4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/SmsService.java @@ -0,0 +1,81 @@ +package com.competition.modules.pub.service; + +import cn.hutool.core.util.RandomUtil; +import com.competition.modules.pub.config.SmsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 短信发送服务 + * 封装阿里云短信 SDK,开发模式下验证码输出到日志 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsService { + + private final SmsConfig smsConfig; + + /** + * 发送验证码短信 + * + * @param phone 手机号 + * @param code 验证码 + */ + public void sendVerifyCode(String phone, String code) { + if (!smsConfig.isEnabled()) { + // 开发模式:验证码输出到日志 + log.info("【开发模式】短信验证码:手机号={}, 验证码={}", phone, code); + return; + } + + // 生产模式:调用阿里云短信 SDK + try { + com.aliyun.dysmsapi20170525.Client client = createClient(); + com.aliyun.dysmsapi20170525.models.SendSmsRequest request = + new com.aliyun.dysmsapi20170525.models.SendSmsRequest() + .setPhoneNumbers(phone) + .setSignName(smsConfig.getSignName()) + .setTemplateCode(smsConfig.getTemplateCode()) + .setTemplateParam("{\"code\":\"" + code + "\"}"); + + com.aliyun.dysmsapi20170525.models.SendSmsResponse response = client.sendSms(request); + + if (response.getBody() == null || !"OK".equals(response.getBody().getCode())) { + String errMsg = response.getBody() != null + ? response.getBody().getMessage() : "响应为空"; + log.error("短信发送失败:手机号={}, 错误码={}, 错误信息={}", + phone, + response.getBody() != null ? response.getBody().getCode() : "null", + errMsg); + throw new RuntimeException("短信发送失败:" + errMsg); + } + + log.info("短信发送成功:手机号={}", phone); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + log.error("短信发送异常:手机号={}", phone, e); + throw new RuntimeException("短信发送异常", e); + } + } + + /** + * 生成6位数字验证码 + */ + public String generateCode() { + return RandomUtil.randomNumbers(6); + } + + /** + * 创建阿里云短信客户端 + */ + private com.aliyun.dysmsapi20170525.Client createClient() throws Exception { + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(smsConfig.getAccessKeyId()) + .setAccessKeySecret(smsConfig.getAccessKeySecret()); + config.endpoint = "dysmsapi.aliyuncs.com"; + return new com.aliyun.dysmsapi20170525.Client(config); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java index 767c2f3..665299c 100644 --- a/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java @@ -34,7 +34,7 @@ public class SysUser extends BaseEntity { @Schema(description = "邮箱") private String email; - @Schema(description = "手机号(全局唯一)") + @Schema(description = "手机号(租户内唯一)") private String phone; @Schema(description = "微信OpenID") diff --git a/backend-java/src/main/resources/application-dev.yml b/backend-java/src/main/resources/application-dev.yml index 387a543..c29d42e 100644 --- a/backend-java/src/main/resources/application-dev.yml +++ b/backend-java/src/main/resources/application-dev.yml @@ -48,6 +48,13 @@ aliyun: # 前端直传跨域:启动时自动配置 OSS CORS cors-enabled: ${OSS_CORS_ENABLED:true} cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + # 短信服务配置(开发环境关闭真实发送,验证码输出到日志) + sms: + access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + sign-name: ${SMS_SIGN_NAME:乐读派} + template-code: ${SMS_TEMPLATE_CODE:SMS_490225426} + enabled: true logging: level: @@ -62,4 +69,5 @@ leai: org-id: ${LEAI_ORG_ID:gdlib} app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y} api-url: ${LEAI_API_URL:http://192.168.1.120:8080} - h5-url: ${LEAI_H5_URL:http://192.168.1.120:3001} + reconcile-interval: 60000 # B3对账间隔:1分钟(开发环境) + reconcile-initial-delay: 30000 # 初始延迟:30秒 diff --git a/backend-java/src/main/resources/application-prod.yml b/backend-java/src/main/resources/application-prod.yml index 24caf0f..f515f5b 100644 --- a/backend-java/src/main/resources/application-prod.yml +++ b/backend-java/src/main/resources/application-prod.yml @@ -43,6 +43,14 @@ logging: level: com.competition: info +# 乐读派 AI 创作系统配置 +leai: + org-id: ${LEAI_ORG_ID:gdlib} + app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y} + api-url: ${LEAI_API_URL:http://192.168.1.120:8080} + reconcile-interval: 1800000 # B3对账间隔:30分钟(生产环境) + reconcile-initial-delay: 60000 # 初始延迟:60秒 + # 阿里云 OSS 配置(开发环境) aliyun: oss: @@ -53,4 +61,11 @@ aliyun: max-file-size: ${OSS_MAX_FILE_SIZE:10485760} # 前端直传跨域:启动时自动配置 OSS CORS cors-enabled: ${OSS_CORS_ENABLED:true} - cors-allowed-origins: ${OSS_CORS_ORIGINS:*} \ No newline at end of file + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + # 短信服务配置(生产环境) + sms: + access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + sign-name: ${SMS_SIGN_NAME:乐读派} + template-code: ${SMS_TEMPLATE_CODE:SMS_490225426} + enabled: true diff --git a/backend-java/src/main/resources/application-test.yml b/backend-java/src/main/resources/application-test.yml index 2e2149d..34fc7d0 100644 --- a/backend-java/src/main/resources/application-test.yml +++ b/backend-java/src/main/resources/application-test.yml @@ -44,6 +44,13 @@ aliyun: # 前端直传跨域:启动时自动配置 OSS CORS cors-enabled: ${OSS_CORS_ENABLED:true} cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + # 短信服务配置(测试环境) + sms: + access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + sign-name: ${SMS_SIGN_NAME:乐读派} + template-code: ${SMS_TEMPLATE_CODE:SMS_490225426} + enabled: true logging: level: @@ -54,4 +61,5 @@ leai: org-id: ${LEAI_ORG_ID:gdlib} app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y} api-url: ${LEAI_API_URL:http://192.168.1.120:8080} - h5-url: ${LEAI_H5_URL:http://192.168.1.120:3001} + reconcile-interval: 60000 # B3对账间隔:1分钟(测试环境) + reconcile-initial-delay: 30000 # 初始延迟:30秒 diff --git a/backend-java/src/main/resources/db/migration/V13__fix_user_unique_indexes.sql b/backend-java/src/main/resources/db/migration/V13__fix_user_unique_indexes.sql new file mode 100644 index 0000000..f5a322f --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V13__fix_user_unique_indexes.sql @@ -0,0 +1,43 @@ +-- V13: 修复 t_sys_user 唯一索引 — phone/username 改为 (tenant_id, ...) 联合唯一 +-- 日期: 2026-04-09 +-- 背景: 多租户场景下同一手机号/用户名应可在不同租户下注册,需改为联合唯一索引 + +-- ===================================================== +-- 1. phone: 删除旧的独立唯一索引,创建 (tenant_id, phone) 联合唯一索引 +-- ===================================================== + +-- 安全删除已存在的 phone 唯一索引(索引名可能是 phone 或其他自定义名) +SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_sys_user' + AND INDEX_NAME = 'phone' + AND NON_UNIQUE = 0); +SET @sqlstmt := IF(@exist > 0, + 'ALTER TABLE t_sys_user DROP INDEX phone', + 'SELECT ''phone 唯一索引不存在,跳过删除'''); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 创建联合唯一索引(IF NOT EXISTS 防止重复创建) +CREATE UNIQUE INDEX uk_sys_user_tenant_phone ON t_sys_user(tenant_id, phone); + +-- ===================================================== +-- 2. username: 删除旧的独立唯一索引,创建 (tenant_id, username) 联合唯一索引 +-- ===================================================== + +-- 安全删除已存在的 username 唯一索引 +SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_sys_user' + AND INDEX_NAME = 'username' + AND NON_UNIQUE = 0); +SET @sqlstmt := IF(@exist > 0, + 'ALTER TABLE t_sys_user DROP INDEX username', + 'SELECT ''username 唯一索引不存在,跳过删除'''); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 创建联合唯一索引 +CREATE UNIQUE INDEX uk_sys_user_tenant_username ON t_sys_user(tenant_id, username); diff --git a/frontend/e2e/leai/auth-api.spec.ts b/frontend/e2e/leai/auth-api.spec.ts index a41b52c..29a9fbd 100644 --- a/frontend/e2e/leai/auth-api.spec.ts +++ b/frontend/e2e/leai/auth-api.spec.ts @@ -3,9 +3,8 @@ import { test, expect } from '../fixtures/auth.fixture' /** * P0: 认证 API 测试 * - * 测试 LeaiAuthController 的三个接口: + * 测试 LeaiAuthController 的两个接口: * - GET /leai-auth/token(iframe 主入口) - * - GET /leai-auth(302 重定向) * - GET /leai-auth/refresh-token(Token 刷新) */ @@ -19,7 +18,7 @@ test.describe('乐读派认证 API', () => { expect(resp.status()).toBe(401) }) - test('已登录 — 返回 token + orgId + h5Url + phone', async ({ authedApi }) => { + test('已登录 — 返回 token + orgId', async ({ authedApi }) => { const resp = await authedApi.get(`${API_BASE}/leai-auth/token`) expect(resp.status()).toBe(200) @@ -30,11 +29,8 @@ test.describe('乐读派认证 API', () => { const data = json.data expect(data).toHaveProperty('token') expect(data).toHaveProperty('orgId') - expect(data).toHaveProperty('h5Url') - expect(data).toHaveProperty('phone') expect(data.token).toBeTruthy() expect(data.orgId).toBeTruthy() - expect(data.h5Url).toContain('http') }) test('返回的 token 为非空字符串', async ({ authedApi }) => { @@ -59,7 +55,6 @@ test.describe('乐读派认证 API', () => { expect(json.code).toBe(200) expect(json.data).toHaveProperty('token') expect(json.data).toHaveProperty('orgId') - expect(json.data).toHaveProperty('phone') }) test('连续两次刷新返回不同 token', async ({ authedApi }) => { @@ -78,38 +73,4 @@ test.describe('乐读派认证 API', () => { expect(json1.data.token).not.toBe(json2.data.token) }) }) - - test.describe('GET /leai-auth(302 重定向)', () => { - test('未登录 — 返回 401', async ({ request }) => { - const resp = await request.get(`${API_BASE}/leai-auth`, { - maxRedirects: 0, - }) - // 可能是 401 或 302 到登录页 - expect([302, 401]).toContain(resp.status()) - }) - - test('已登录 — 302 重定向到 H5', async ({ authedApi }) => { - const resp = await authedApi.get(`${API_BASE}/leai-auth`, { - maxRedirects: 0, - }) - expect(resp.status()).toBe(302) - - const location = resp.headers()['location'] - expect(location).toBeDefined() - expect(location).toContain('token=') - expect(location).toContain('orgId=') - expect(location).toContain('phone=') - }) - - test('带 returnPath — 重定向 URL 包含 returnPath', async ({ authedApi }) => { - const resp = await authedApi.get(`${API_BASE}/leai-auth?returnPath=/edit-info/test123`, { - maxRedirects: 0, - }) - expect(resp.status()).toBe(302) - - const location = resp.headers()['location'] - expect(location).toContain('returnPath=') - expect(location).toContain('edit-info') - }) - }) }) diff --git a/frontend/e2e/leai/creation-iframe.spec.ts b/frontend/e2e/leai/creation-iframe.spec.ts index 5a11e68..40702fc 100644 --- a/frontend/e2e/leai/creation-iframe.spec.ts +++ b/frontend/e2e/leai/creation-iframe.spec.ts @@ -56,7 +56,6 @@ test.describe('创作页 iframe 嵌入', () => { data: { token: 'mock_session_token_xxx', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), @@ -106,7 +105,6 @@ test.describe('创作页 iframe 嵌入', () => { data: { token: 'mock_token', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), @@ -175,7 +173,6 @@ test.describe('创作页 iframe 嵌入', () => { data: { token: 'retry_token_success', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), @@ -216,7 +213,6 @@ test.describe('创作页 iframe 嵌入', () => { data: { token: 'mock_token', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), diff --git a/frontend/e2e/leai/e2e-flow.spec.ts b/frontend/e2e/leai/e2e-flow.spec.ts index e3c56c0..5697e71 100644 --- a/frontend/e2e/leai/e2e-flow.spec.ts +++ b/frontend/e2e/leai/e2e-flow.spec.ts @@ -25,7 +25,6 @@ test.describe('端到端:创作完整流程', () => { data: { token: 'e2e_test_token', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), @@ -100,7 +99,7 @@ test.describe('端到端:创作完整流程', () => { contentType: 'application/json', body: JSON.stringify({ code: 200, - data: { token: 'initial_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' }, + data: { token: 'initial_token', orgId: 'gdlib', phone: '13800001111' }, }), }) }) @@ -198,7 +197,7 @@ test.describe('端到端:创作完整流程', () => { contentType: 'application/json', body: JSON.stringify({ code: 200, - data: { token: 'retry_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' }, + data: { token: 'retry_token', orgId: 'gdlib', phone: '13800001111' }, }), }) }) diff --git a/frontend/e2e/leai/keepalive-tab-switch.spec.ts b/frontend/e2e/leai/keepalive-tab-switch.spec.ts index 6d5ce95..d720c80 100644 --- a/frontend/e2e/leai/keepalive-tab-switch.spec.ts +++ b/frontend/e2e/leai/keepalive-tab-switch.spec.ts @@ -28,7 +28,6 @@ async function setupMockRoutes(page: import('@playwright/test').Page) { data: { token: 'mock_keepalive_token', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), diff --git a/frontend/e2e/leai/postmessage.spec.ts b/frontend/e2e/leai/postmessage.spec.ts index 864e8e6..3acfe2f 100644 --- a/frontend/e2e/leai/postmessage.spec.ts +++ b/frontend/e2e/leai/postmessage.spec.ts @@ -49,7 +49,6 @@ test.describe('postMessage 通信', () => { data: { token: 'mock_token_for_postmessage_test', orgId: 'gdlib', - h5Url: 'http://localhost:3001', phone: '13800001111', }, }), diff --git a/frontend/e2e/utils/webhook-helper.ts b/frontend/e2e/utils/webhook-helper.ts index 4c50d50..2f2307a 100644 --- a/frontend/e2e/utils/webhook-helper.ts +++ b/frontend/e2e/utils/webhook-helper.ts @@ -10,7 +10,6 @@ export const LEAI_TEST_CONFIG = { orgId: 'gdlib', appSecret: 'leai_mnoi9q1a_mtcawrn8y', apiUrl: 'http://192.168.1.120:8080', - h5Url: 'http://192.168.1.120:3001', testPhone: '13800001111', } diff --git a/frontend/src/api/aicreate/index.ts b/frontend/src/api/aicreate/index.ts index 3b45bb1..189bfe3 100644 --- a/frontend/src/api/aicreate/index.ts +++ b/frontend/src/api/aicreate/index.ts @@ -1,122 +1,23 @@ /** * AI 创作 API 层 - * 从 lesingle-aicreate-client/src/api/index.js 迁移 * - * 独立 axios 实例,直连乐读派后端(VITE_LEAI_API_URL + /api/v1) - * 认证:Bearer sessionToken(企业模式) + * 所有请求经由后端代理 /leai-proxy/* 转发到乐读派 + * 前端不持有 phone/orgId/appSecret,仅通过 JWT 认证调后端代理 + * 后端自动注入 orgId + phone 并使用 HMAC 签名 */ -import axios from 'axios' import OSS from 'ali-oss' -import { signRequest } from '@/utils/aicreate/hmac' -import { useAicreateStore } from '@/stores/aicreate' -import { leaiApi } from '@/api/public' +import publicApi from '@/api/public' import type { StsTokenData, CreateStoryParams } from './types' -// 乐读派后端地址(从环境变量读取,直连,不走代理) -const leaiBaseUrl = import.meta.env.VITE_LEAI_API_URL || '' - -const api = axios.create({ - baseURL: leaiBaseUrl ? leaiBaseUrl + '/api/v1' : '/api/v1', - timeout: 120000 -}) - -// ─── 请求拦截器:双模式认证 ─── -api.interceptors.request.use((config) => { - // 需要从 store 获取最新状态,不能用模块级缓存 - const store = useAicreateStore() - if (store.sessionToken) { - config.headers['Authorization'] = 'Bearer ' + store.sessionToken - } else if (store.orgId && store.appSecret) { - const queryParams: Record = {} - if (config.params) { - Object.entries(config.params).forEach(([k, v]) => { - if (v != null) queryParams[k] = String(v) - }) - } - const headers = signRequest(store.orgId, store.appSecret, queryParams) - Object.assign(config.headers, headers) - } - return config -}) - -// ─── Token 刷新状态管理 ─── -let isRefreshing = false -let pendingRequests: Array<(token: string | null) => void> = [] - -async function handleTokenExpired(failedConfig: any): Promise { - if (!isRefreshing) { - isRefreshing = true - try { - const data = await leaiApi.refreshToken() - const store = useAicreateStore() - store.setSession(data.orgId || store.orgId, data.token) - if (data.phone) store.setPhone(data.phone) - isRefreshing = false - pendingRequests.forEach(cb => cb(data.token)) - pendingRequests = [] - } catch { - isRefreshing = false - pendingRequests.forEach(cb => cb(null)) - pendingRequests = [] - } - } - return new Promise((resolve, reject) => { - if (pendingRequests.length >= 20) { - reject(new Error('TOO_MANY_PENDING_REQUESTS')) - return - } - pendingRequests.push((newToken) => { - if (newToken) { - if (failedConfig.__retried) { - reject(new Error('TOKEN_REFRESH_FAILED')) - return - } - failedConfig.__retried = true - failedConfig.headers['Authorization'] = 'Bearer ' + newToken - delete failedConfig.headers['X-App-Key'] - delete failedConfig.headers['X-Timestamp'] - delete failedConfig.headers['X-Nonce'] - delete failedConfig.headers['X-Signature'] - resolve(api(failedConfig)) - } else { - reject(new Error('TOKEN_REFRESH_FAILED')) - } - }) - }) -} - -// ─── 响应拦截器 ─── -api.interceptors.response.use( - (res) => { - const d = res.data - if (d?.code !== 0 && d?.code !== 200) { - const store = useAicreateStore() - // Token 过期 - if (store.sessionToken && (d?.code === 20010 || d?.code === 20009)) { - return handleTokenExpired(res.config) - } - return Promise.reject(new Error(d?.msg || '请求失败')) - } - return d - }, - (err) => { - const store = useAicreateStore() - if (store.sessionToken && err.response?.status === 401) { - return handleTokenExpired(err.config) - } - return Promise.reject(err) - } -) - // ═══════════════════════════════════════════════════════════ -// API 函数 +// API 函数(全部走后端代理) // ═══════════════════════════════════════════════════════════ /** 图片上传 */ export function uploadImage(file: File) { const form = new FormData() form.append('file', file) - return api.post('/creation/upload', form, { + return publicApi.post('/leai-proxy/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 30000 }) @@ -124,23 +25,15 @@ export function uploadImage(file: File) { /** 角色提取 */ export function extractCharacters(imageUrl: string, opts: { saveOriginal?: boolean; title?: string } = {}) { - const store = useAicreateStore() - const body: Record = { - orgId: store.orgId, - phone: store.phone, - imageUrl - } + const body: Record = { imageUrl } if (opts.saveOriginal) body.saveOriginal = true if (opts.title) body.title = opts.title - return api.post('/creation/extract-original', body, { timeout: 120000 }) + return publicApi.post('/leai-proxy/extract', body, { timeout: 120000 }) } /** 图片故事创作 */ export function createStory(params: CreateStoryParams) { - const store = useAicreateStore() const body: Record = { - orgId: store.orgId, - phone: store.phone, imageUrl: params.imageUrl, storyHint: params.storyHint, style: params.style, @@ -151,41 +44,27 @@ export function createStory(params: CreateStoryParams) { if (params.author) body.author = params.author if (params.heroCharId) body.heroCharId = params.heroCharId if (params.extractId) body.extractId = params.extractId - return api.post('/creation/image-story', body) + return publicApi.post('/leai-proxy/create-story', body) } /** 查询作品详情 */ export function getWorkDetail(workId: string) { - const store = useAicreateStore() - return api.get(`/query/work/${workId}`, { - params: { orgId: store.orgId, phone: store.phone } - }) + return publicApi.get(`/leai-proxy/work/${workId}`) } /** 额度校验 */ export function checkQuota() { - const store = useAicreateStore() - return api.post('/query/validate', { - orgId: store.orgId, - phone: store.phone, - apiType: 'A3' - }) + return publicApi.post('/leai-proxy/validate', { apiType: 'A3' }) } /** 编辑绘本信息 */ export function updateWork(workId: string, data: Record) { - return api.put(`/update/work/${workId}`, data) + return publicApi.put(`/leai-proxy/work/${workId}`, data) } /** 批量更新配音 URL */ export function batchUpdateAudio(workId: string, pages: any[]) { - const store = useAicreateStore() - return api.post('/update/batch-audio', { - orgId: store.orgId, - phone: store.phone, - workId, - pages - }) + return publicApi.post('/leai-proxy/batch-audio', { workId, pages }) } /** 完成配音 */ @@ -195,24 +74,15 @@ export function finishDubbing(workId: string) { /** AI 配音 */ export function voicePage(data: Record) { - const store = useAicreateStore() - return api.post('/creation/voice', { - orgId: store.orgId, - phone: store.phone, - ...data - }, { timeout: 120000 }) + return publicApi.post('/leai-proxy/voice', data, { timeout: 120000 }) } -/** STS 临时凭证 */ +/** STS 临时凭证(经后端代理获取) */ export function getStsToken() { - const store = useAicreateStore() - return api.post('/oss/sts-token', { - orgId: store.orgId, - phone: store.phone - }) + return publicApi.post('/leai-proxy/sts-token') } -// ─── OSS 直传 ─── +// ─── OSS 直传 ── let _ossClient: OSS | null = null let _stsData: StsTokenData | null = null @@ -224,7 +94,7 @@ async function getOssClient() { } } const res = await getStsToken() - _stsData = res.data + _stsData = res as StsTokenData _ossClient = new OSS({ region: _stsData.region, accessKeyId: _stsData.accessKeyId, @@ -234,7 +104,7 @@ async function getOssClient() { endpoint: _stsData.endpoint, refreshSTSToken: async () => { const r = await getStsToken() - _stsData = r.data + _stsData = r as StsTokenData return { accessKeyId: _stsData.accessKeyId, accessKeySecret: _stsData.accessKeySecret, @@ -284,5 +154,3 @@ export async function ossListFiles() { url: obj.url })) } - -export default api diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 54598a5..c1aae60 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -45,11 +45,17 @@ function isTokenExpired(token: string): boolean { publicApi.interceptors.response.use( (response) => { // 后端返回格式:{ code: 200, message: "success", data: xxx } - // 当 data 为 null 时,直接返回 null - if (response.data) { - return response.data.data !== undefined ? response.data.data : response.data + // 检查业务状态码,非 200 视为业务错误 + const resData = response.data + if (resData && resData.code !== undefined && resData.code !== 200) { + const error: any = new Error(resData.message || "请求失败") + error.response = { data: resData } + return Promise.reject(error) } - return response.data + if (resData) { + return resData.data !== undefined ? resData.data : resData + } + return resData }, (error) => { if (error.response?.status === 401) { @@ -71,6 +77,7 @@ export interface PublicRegisterParams { password: string nickname: string phone?: string + smsCode?: string city?: string } @@ -108,6 +115,10 @@ export const publicAuthApi = { login: (data: PublicLoginParams): Promise => publicApi.post("/public/auth/login", data), + + /** 发送短信验证码 */ + sendSmsCode: (phone: string): Promise => + publicApi.post("/public/auth/sms/send", { phone }), } // ==================== 个人信息 ==================== @@ -503,15 +514,12 @@ export const leaiApi = { getToken: (): Promise<{ token: string orgId: string - h5Url: string - phone: string }> => publicApi.get("/leai-auth/token"), // 刷新 Token(TOKEN_EXPIRED 时调用) refreshToken: (): Promise<{ token: string orgId: string - phone: string }> => publicApi.get("/leai-auth/refresh-token"), } diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index f957f31..5a7e0f2 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -108,7 +108,14 @@ const aicreateStore = useAicreateStore() const isLoggedIn = computed(() => !!localStorage.getItem("public_token")) const user = computed(() => { const data = localStorage.getItem("public_user") - return data ? JSON.parse(data) : null + if (!data || data === "undefined" || data === "null") return null + try { + return JSON.parse(data) + } catch { + // 数据损坏时清除并返回 null + localStorage.removeItem("public_user") + return null + } }) const userAvatar = computed(() => user.value?.avatar || undefined) diff --git a/frontend/src/stores/aicreate.ts b/frontend/src/stores/aicreate.ts index 7610a8d..64ba2ba 100644 --- a/frontend/src/stores/aicreate.ts +++ b/frontend/src/stores/aicreate.ts @@ -1,17 +1,15 @@ /** * AI 创作全局状态(Pinia Store) * - * 从 lesingle-aicreate-client/utils/store.js 迁移 - * 保留原有字段和方法,适配 Pinia setup 语法 + * 敏感信息(phone/orgId/appSecret)不再存储在 localStorage + * orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化 */ import { defineStore } from 'pinia' import { ref } from 'vue' export const useAicreateStore = defineStore('aicreate', () => { - // ─── 认证信息 ─── - const phone = ref(localStorage.getItem('le_phone') || '') - const orgId = ref(localStorage.getItem('le_orgId') || '') - const appSecret = ref(localStorage.getItem('le_appSecret') || '') + // ─── 认证信息(不再存储敏感信息到 localStorage) ─── + const orgId = ref(sessionStorage.getItem('le_orgId') || '') const sessionToken = ref(sessionStorage.getItem('le_sessionToken') || '') // ─── 创作流程数据 ─── @@ -23,35 +21,23 @@ export const useAicreateStore = defineStore('aicreate', () => { const storyData = ref(null) const workId = ref('') const workDetail = ref(null) - const authRedirectUrl = ref('') // ─── Tab 切换状态保存 ─── const lastCreateRoute = ref('') // ─── 方法 ─── - function setPhone(val: string) { - phone.value = val - localStorage.setItem('le_phone', val) - } - - function setOrg(id: string, secret: string) { - orgId.value = id - appSecret.value = secret - localStorage.setItem('le_orgId', id) - localStorage.setItem('le_appSecret', secret) - } - function setSession(id: string, token: string) { orgId.value = id sessionToken.value = token - localStorage.setItem('le_orgId', id) sessionStorage.setItem('le_orgId', id) sessionStorage.setItem('le_sessionToken', token) } function clearSession() { sessionToken.value = '' + orgId.value = '' sessionStorage.removeItem('le_sessionToken') + sessionStorage.removeItem('le_orgId') } function setLastCreateRoute(path: string) { @@ -72,11 +58,8 @@ export const useAicreateStore = defineStore('aicreate', () => { workId.value = '' workDetail.value = null lastCreateRoute.value = '' - // 清除所有 localStorage 中的创作相关数据 + // 只清除创作流程数据,保留认证信息 localStorage.removeItem('le_workId') - localStorage.removeItem('le_phone') - localStorage.removeItem('le_orgId') - localStorage.removeItem('le_appSecret') // 清除 sessionStorage 中的恢复数据 sessionStorage.removeItem('le_recovery') } @@ -116,8 +99,8 @@ export const useAicreateStore = defineStore('aicreate', () => { return { // 认证 - phone, orgId, appSecret, sessionToken, authRedirectUrl, - setPhone, setOrg, setSession, clearSession, + orgId, sessionToken, + setSession, clearSession, // 创作流程 imageUrl, extractId, characters, selectedCharacter, selectedStyle, storyData, workId, workDetail, diff --git a/frontend/src/utils/aicreate/hmac.ts b/frontend/src/utils/aicreate/hmac.ts deleted file mode 100644 index a2885bd..0000000 --- a/frontend/src/utils/aicreate/hmac.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * HMAC-SHA256 签名工具 - * 从 lesingle-aicreate-client/utils/hmac.js 迁移 - * - * 签名规则:排序的 query params + nonce + timestamp,用 & 拼接 - * POST JSON body 不参与签名 - */ -import CryptoJS from 'crypto-js' - -export function signRequest(orgId: string, appSecret: string, queryParams: Record = {}) { - const timestamp = String(Date.now()) - const nonce = Math.random().toString(36).substring(2, 15) + Date.now().toString(36) - - const allParams: Record = { ...queryParams, nonce, timestamp } - const sorted = Object.keys(allParams).sort() - const signStr = sorted.map(k => `${k}=${allParams[k]}`).join('&') - - const signature = CryptoJS.HmacSHA256(signStr, appSecret).toString(CryptoJS.enc.Hex) - - return { - 'X-App-Key': orgId, - 'X-Timestamp': timestamp, - 'X-Nonce': nonce, - 'X-Signature': signature - } -} diff --git a/frontend/src/views/public/Login.vue b/frontend/src/views/public/Login.vue index a7c80d1..e16ecef 100644 --- a/frontend/src/views/public/Login.vue +++ b/frontend/src/views/public/Login.vue @@ -31,6 +31,27 @@ /> + +

+ + + {{ smsCountdown > 0 ? `${smsCountdown}s 后重发` : "获取验证码" }} + +
+ +