diff --git a/contracts/api-schemas/passport.yaml b/contracts/api-schemas/passport.yaml new file mode 100644 index 00000000..764d4b70 --- /dev/null +++ b/contracts/api-schemas/passport.yaml @@ -0,0 +1,308 @@ +openapi: 3.0.3 +# 契约 #8 API | 模块:passport(真实鉴权 / 匿名玩家 / 邀请码受限激活)| owner:passport(system 内扩展,方案乙),全员 review +# 端:/app-api(产品端 game-studio:手机号验证码登录 / 邀请码注册 / 当前用户) +# /admin-api(管理端 game-admin:邀请码生成/分页/停用 + 玩家创作者白名单置位,RBAC) +# 前缀由 yudao 框架按 controller.app/admin 包名自动添加;本文件 path 直接写含前缀的完整对外路径。 +# 错误码段(落 system 段 1-002-***-***,模块归属决定,不占 game 业务 1-1xx 段;取远段避上游增长区,现最大 1-002-028): +# 1-002-090-*** = 玩家身份(手机号已注册/未注册/被禁用/非白名单创作被拒…) +# 1-002-091-*** = 邀请码(无效/已停用/已过期/已耗尽/旁路已退役…) +# 响应统一 Yudao CommonResult 信封:{ code, data, msg };code=0 成功。 +# 拍板依据:2026-06-10 鉴权七项(评审版 §9.1)——拍板1=C 验证码为主+邀请码旁路 / 拍板3 开放注册(受限期凭码)/ +# 拍板4 创作限 A2 白名单 / 拍板5 互动即弹一键登录 / 拍板7=B 纯客户端 anonId。 +# 鉴权口径:send-sms-code / sms-login / invite-register 三个 @PermitAll(免登);me 登录态(不加 @PermitAll)。 +# 发 token=OAuth2TokenService.createAccessToken(userId, UserTypeEnum.MEMBER(1), clientId, scopes);refresh 7d/30d 由 client 配置承载。 +# 安全基线:日志与响应手机号一律脱敏(如 138****0000);token 不落日志(验收5)。 +# 防枚举口径:send-sms-code 响应不区分手机号是否已注册(见端点 description)。 +# 边界 OUT:不做三方社交登录 / 不开多租户(tenant.enable=false,新表 tenant_id=1)/ 不做实名收集流程(仅表字段预留)/ +# 不做匿名影子账号合并(拍板5=A)/ 不做服务端 anonId 校验签名(拍板7=B 既定代价)/ game-admin 前端零改动(邀请码经 Knife4j/脚本调 admin-api,RBAC 管控,§5.3)。 +info: + title: 绘境AI passport 模块 API + version: 1.0.0 + description: >- + 真实鉴权与匿名玩家身份:种子创作者手机号验证码登录(发真 userType=MEMBER OAuth2 token)+ + 陌生玩家邀请码受限激活注册(零短信依赖,报备后退役)+ 当前用户信息(前端守卫/白名单 UI 依据)+ + 管理端邀请码全生命周期运营(生成/分页/停用)与创作者白名单置位(A2 名单)。 + 匿名玩家免登刷 feed/试玩/遥测(读端点 @PermitAll,见 feed/runtime/telemetry 契约),互动才弹一键登录。 + 覆盖产品功能 P-ACC(账号鉴权)受限激活与 A2 白名单口径。 + 前端据此 vite-plugin-mock 自动生成 mock。 + +servers: + - url: http://localhost:48080 + description: 本地(Swagger/Knife4j http://localhost:48080/doc.html) + +paths: + # =========================================================================== + # app 端(产品端 game-studio):验证码登录 / 邀请码注册 / 当前用户 + # =========================================================================== + /app-api/passport/send-sms-code: + post: + tags: [app-passport] + summary: 发送登录短信验证码(@PermitAll;拍板1C 主路径) + description: >- + 场景固定 SmsSceneEnum.MEMBER_LOGIN(1);种子期走 Debug 渠道(码落 system_sms_code 表,运营后台查码)。 + 鉴权:@PermitAll 免登(R6 安全前置:发码端点加 @RateLimiter IP 维度限频 + IP 每日/每小时上限,切真实渠道前补图形验证码,见 execution §7)。 + 防枚举口径:响应不区分手机号是否已注册(已注册→登录路径用;未注册→自动注册路径用,均发码),统一返回成功,避免手机号是否注册被探测。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/SendSmsCodeReqVO' } + responses: + '200': + description: 成功(发码受理;不区分手机号是否已注册) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultBoolean' } + + /app-api/passport/sms-login: + post: + tags: [app-passport] + summary: 手机号验证码登录(已注册登录 / 未注册自动注册一体,@PermitAll;拍板3 开放注册) + description: >- + 鉴权:@PermitAll 免登。已注册→登录;未注册→自动注册(register_channel=sms)+ 登录一体(验证码即占有证明)。 + anonId 可选:仅注册时落 game_player.first_anon_id 一次(匿名↔登录归因衔接,目标3;缺失落空串,归因降级不阻断登录)。 + 竞态兜底:uk_mobile 唯一键冲突 → 静默转登录语义(与防枚举口径协调)。 + 前端登录成功后另补发 user_login 遥测事件(envelope.user 双带 userId+anonId,见 events.schema.json)。 + 响应安全基线:手机号脱敏、token 不落日志。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/SmsLoginReqVO' } + responses: + '200': + description: 成功(返回真 OAuth2 token 与玩家身份摘要) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultLogin' } + + /app-api/passport/invite-register: + post: + tags: [app-passport] + summary: 邀请码受限激活注册(手机号+邀请码,零短信依赖,@PermitAll;拍板1C/2/3) + description: >- + 鉴权:@PermitAll 免登,受退役开关 wanxiang.passport.invite-register-enabled 管控(§5.4,关闭后返回 1-002-091 旁路已退役错误码)。 + 报备前陌生玩家旁路:mobile+inviteCode 注册(register_channel=invite,关联核销的 invite_code_id),零短信依赖(拍板1C)。 + 核销语义:核销与建号同一本地事务,条件更新原子累加(耗尽/停用/过期由前置查询给精确 1-002-091 错误码,条件更新做最终一致兜底),建号失败整体回滚。 + 手机号已注册 → 提示直接验证码登录(错误码 1-002-090);anonId 可选,落 first_anon_id(同 sms-login 口径)。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/InviteRegisterReqVO' } + responses: + '200': + description: 成功(返回真 OAuth2 token 与玩家身份摘要,同 sms-login 响应) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultLogin' } + + /app-api/passport/me: + get: + tags: [app-passport] + summary: 当前登录玩家信息(登录态,不加 @PermitAll;前端守卫/白名单 UI 依据) + description: >- + 鉴权:登录态必需(无 @PermitAll,未登录返回 401)。 + 返回当前 token 对应玩家身份摘要,手机号脱敏(138****0000)。前端据 creatorFlag 渲染创作入口/守卫。 + responses: + '200': + description: 成功 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultPlayerMe' } + + # =========================================================================== + # admin 端(管理端 game-admin):邀请码生成/分页/停用 + 玩家创作者白名单置位 + # RBAC 权限位:邀请码 system:passport-invite:*;玩家 system:passport-player:*(信任边界在后端,不依赖前端,§5.3) + # =========================================================================== + /admin-api/passport/invite-code/generate: + post: + tags: [admin-passport] + summary: 批量生成邀请码(RBAC system:passport-invite:create;拍板2 受限激活) + description: >- + 随机生成 count 个邀请码(大写字母数字、避易混淆字符,uk_code 冲突重试),统一批次号/限量/有效期。 + 权限位 system:passport-invite:create。运营经 Knife4j(/doc.html) 或脚本调用(game-admin 零页面,§5.3)。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/InviteCodeGenerateReqVO' } + responses: + '200': + description: 成功(返回生成的码值列表) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultInviteCodeList' } + + /admin-api/passport/invite-code/page: + get: + tags: [admin-passport] + summary: 邀请码分页查询(RBAC system:passport-invite:query) + description: 按 batchNo/status 过滤分页查邀请码,含 used_count 核销进度。权限位 system:passport-invite:query。 + parameters: + - { name: batchNo, in: query, required: false, schema: { type: string }, description: 批次号过滤 } + - { name: status, in: query, required: false, schema: { type: integer, enum: [0, 1] }, description: '状态过滤:0启用 1停用' } + - { name: pageNo, in: query, required: false, schema: { type: integer, default: 1 } } + - { name: pageSize, in: query, required: false, schema: { type: integer, default: 10 } } + responses: + '200': + description: 成功 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultInviteCodePage' } + + /admin-api/passport/invite-code/disable: + put: + tags: [admin-passport] + summary: 单码停用/退役(RBAC system:passport-invite:update) + description: >- + 将指定邀请码 status 置 1(单码退役;全量旁路退役走配置开关 §5.4)。权限位 system:passport-invite:update。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/InviteCodeDisableReqVO' } + responses: + '200': + description: 成功 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultBoolean' } + + /admin-api/passport/player/set-creator: + put: + tags: [admin-passport] + summary: 玩家创作者白名单置位(RBAC system:passport-player:update;拍板4 A2 名单入口) + description: >- + 置位/取消玩家的 creator_flag(A2 种子白名单的运营动作落点,拍板4)。 + 置位后该玩家方可走创作/发布写入口(后端 PlayerApi#validateCreator 校验,信任边界在后端)。 + 权限位 system:passport-player:update。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PlayerSetCreatorReqVO' } + responses: + '200': + description: 成功 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultBoolean' } + +components: + schemas: + # ---- Yudao CommonResult 信封 ---- + CommonResultBoolean: + type: object + properties: + code: { type: integer, description: '0=成功,非0=错误码 1-002-090/091-***' } + data: { type: boolean } + msg: { type: string } + CommonResultLogin: + type: object + properties: + code: { type: integer } + data: { $ref: '#/components/schemas/LoginRespVO' } + msg: { type: string } + CommonResultPlayerMe: + type: object + properties: + code: { type: integer } + data: { $ref: '#/components/schemas/PlayerMeRespVO' } + msg: { type: string } + CommonResultInviteCodeList: + type: object + properties: + code: { type: integer } + data: { type: array, items: { $ref: '#/components/schemas/InviteCodeRespVO' } } + msg: { type: string } + CommonResultInviteCodePage: + type: object + properties: + code: { type: integer } + data: + type: object + properties: + list: { type: array, items: { $ref: '#/components/schemas/InviteCodeRespVO' } } + total: { type: integer, format: int64 } + msg: { type: string } + + # ---- app 端请求 ---- + SendSmsCodeReqVO: + type: object + required: [mobile] + properties: + mobile: { type: string, maxLength: 11, description: '手机号(场景固定 MEMBER_LOGIN;响应不区分是否已注册,防枚举)' } + SmsLoginReqVO: + type: object + required: [mobile, code] + properties: + mobile: { type: string, maxLength: 11, description: 手机号(登录主键) } + code: { type: string, description: '短信验证码(Debug 渠道运营查 system_sms_code 表)' } + anonId: { type: string, maxLength: 64, description: '客户端 anonId(可选;仅注册时落 first_anon_id 一次,匿名↔登录归因,拍板7=B)' } + InviteRegisterReqVO: + type: object + required: [mobile, inviteCode] + properties: + mobile: { type: string, maxLength: 11, description: 手机号(登录主键) } + inviteCode: { type: string, maxLength: 16, description: '邀请码(核销语义见 execution §4.2;无效/停用/过期/耗尽返回 1-002-091)' } + anonId: { type: string, maxLength: 64, description: '客户端 anonId(可选;同 sms-login 口径)' } + nickname: { type: string, maxLength: 30, description: '昵称(可选;不传则默认生成)' } + + # ---- app 端响应 ---- + LoginRespVO: + type: object + description: 登录/注册成功响应(sms-login 与 invite-register 共用);token 不落日志,手机号不在此返回 + properties: + userId: { type: integer, format: int64, description: '玩家编号(即 OAuth2 token 的 userId,userType=MEMBER)' } + accessToken: { type: string, description: '访问令牌(Bearer);不得落日志' } + refreshToken: { type: string, description: '刷新令牌(refresh 7d/30d 由 OAuth2 client 配置承载,§3.4)' } + expiresTime: { type: integer, format: int64, description: '访问令牌过期时间戳(毫秒)' } + nickname: { type: string, description: 昵称 } + creatorFlag: { type: integer, description: '创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)' } + PlayerMeRespVO: + type: object + description: 当前登录玩家信息(前端守卫/白名单 UI 依据);手机号脱敏返回 + properties: + userId: { type: integer, format: int64, description: 玩家编号 } + mobile: { type: string, description: '脱敏手机号(如 138****0000;安全基线,禁回明文)' } + nickname: { type: string, description: 昵称 } + avatar: { type: string, description: 头像 URL } + creatorFlag: { type: integer, description: '创作者白名单位:0玩家 1创作者' } + + # ---- admin 端请求 ---- + InviteCodeGenerateReqVO: + type: object + required: [count, maxUses] + properties: + count: { type: integer, minimum: 1, maximum: 100, description: '本批生成数量(≤100)' } + maxUses: { type: integer, minimum: 1, default: 1, description: '单码最大核销次数(限量)' } + expireTime: { type: string, format: date-time, description: '有效期(可选;不传=不过期)' } + batchNo: { type: string, maxLength: 32, description: '批次号(可选;运营按渠道发放归因,如 batch-xhs-01)' } + remark: { type: string, maxLength: 255, description: '备注(可选;发放对象/用途)' } + InviteCodeDisableReqVO: + type: object + required: [id] + properties: + id: { type: integer, format: int64, description: '邀请码编号(停用即 status=1,单码退役)' } + PlayerSetCreatorReqVO: + type: object + required: [userId, creatorFlag] + properties: + userId: { type: integer, format: int64, description: '玩家编号(game_player.id)' } + creatorFlag: { type: integer, enum: [0, 1], description: '创作者白名单位:0取消 1置位(A2 名单,拍板4)' } + + # ---- admin 端响应 ---- + InviteCodeRespVO: + type: object + description: 邀请码(生成返回 / 分页项;含核销进度) + properties: + id: { type: integer, format: int64, description: 编号 } + code: { type: string, description: 邀请码/口令 } + batchNo: { type: string, description: 批次号 } + maxUses: { type: integer, description: 最大核销次数 } + usedCount: { type: integer, description: '已核销次数(核销进度)' } + expireTime: { type: string, format: date-time, description: '有效期(NULL=不过期)' } + status: { type: integer, description: '状态:0启用 1停用' } + remark: { type: string, description: 备注 } + createTime: { type: string, format: date-time, description: 创建时间 } diff --git a/contracts/db-schemas/V11.0.0__create_passport_player_invite.sql b/contracts/db-schemas/V11.0.0__create_passport_player_invite.sql new file mode 100644 index 00000000..4ca09e8f --- /dev/null +++ b/contracts/db-schemas/V11.0.0__create_passport_player_invite.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:真实鉴权与匿名玩家身份(HJ-PASSPORT-EXEC-001 §4.2)| owner:passport(system 内扩展,方案乙) +-- 文件:V11.0.0__create_passport_player_invite.sql(Flyway,只新增/ALTER;已合入禁止修改,回滚写新补偿迁移 V11.0.1) +-- 拍板依据:2026-06-10 鉴权七项(评审版 §9.1,全拍)—— +-- 拍板1=C 验证码为主+邀请码旁路 / 拍板2 报备前受限激活 / 拍板4 创作限 A2 白名单 / 拍板6 实名提现前收集 / 拍板7=B 纯客户端 anonId。 +-- 内容:passport 扩展的两张新表 + 一处 ALTER(共三段): +-- 1. game_player —— 玩家/创作者最小身份(即 OAuth2 token 的 userId,userType=MEMBER;实名字段仅预留); +-- 2. game_invite_code —— 邀请码(报备前受限激活旁路,生成/核销/限量/有效期/退役全生命周期;报备后走配置开关退役); +-- 3. game_runtime_session ALTER —— player_user_id 放宽 NULL(匿名试玩)+ 补 anon_id(匿名会话归属)。 +-- 守门①(必守,沿用 V10 守门②):本 V11 跨 system(player/invite) + runtime(session) 多表,只放仓根 contracts/db-schemas/(源) +-- + yudao-server 执行目录(唯一执行副本),不放任何单模块 -server/db/migration/(避免同版本 V11 出现在多个 classpath jar 触发 Flyway 重复校验失败)。 +-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4。 +-- 让位声明(§4.1):V10 已被黄金闭环占、V11 被本件占,Wave4 community/biz 原规划的 V10/V11 顺延 V12/V13。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 + tenant_id(MVP 单租户=1,tenant.enable=false);状态机用 tinyint,非法流转由服务端拒绝。 +-- 错误码段:玩家身份 = 1-002-090-***;邀请码 = 1-002-091-***(落 system 段,见 passport.yaml 与 ErrorCodeConstants)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_player —— 玩家/创作者最小身份(system passport 扩展,方案乙) +-- id 即 OAuth2 token 的 userId(userType=MEMBER),登录主键=mobile。 +-- 拍板6:real_name/id_card_no 仅预留(AES 密文,应用层加密),提现前才收集,本件不写入。 +-- 拍板4:creator_flag 白名单位,A2 种子名单(~5 人),置位走 admin 端点 /admin-api/passport/player/set-creator。 +-- 目标3(评审版):first_anon_id 落注册时携带的客户端 anonId,做匿名↔登录归因衔接。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_player` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '玩家编号(即 OAuth2 token 的 userId,userType=MEMBER)', + `mobile` VARCHAR(11) NOT NULL COMMENT '手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文+唯一键)', + `nickname` VARCHAR(30) NOT NULL DEFAULT '' COMMENT '昵称(注册默认生成,可改)', + `avatar` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像 URL', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0正常 1禁用(对齐 CommonStatusEnum)', + `creator_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '创作者白名单位:0玩家 1创作者(A2 种子名单,拍板4;置位走 admin 端点)', + `real_name` VARCHAR(256) NULL COMMENT '真实姓名(AES 密文预留;提现前收集,拍板6,本件不写入)', + `id_card_no` VARCHAR(256) NULL COMMENT '身份证号(AES 密文预留;同上,本件不写入)', + `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道:sms验证码 / invite邀请码旁路(漏斗归因)', + `register_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册 IP(风控/审计)', + `invite_code_id` BIGINT NULL COMMENT '核销的邀请码编号(invite 通道必填,关联 game_invite_code.id)', + `first_anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId(匿名↔登录归因衔接,评审版目标3;缺失落空串,归因降级不阻断登录)', + `login_date` DATETIME NULL COMMENT '最近登录时间', + -- 标准审计列(与既有 game_* 表同范式,对齐 V3 game_runtime_session) + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_mobile` (`mobile`, `deleted`, `tenant_id`) COMMENT '手机号唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 V3 uk_version 范式;并发重复注册由此键兜底)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '玩家/创作者最小身份(system passport 扩展,方案乙)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_invite_code —— 邀请码(拍板1=C / 拍板2 受限激活:生成/核销/限量/有效期/退役全生命周期) +-- 报备前陌生玩家凭「手机号+邀请码」零短信注册;报备后 invite-register 走配置开关退役(§5.4)。 +-- 核销语义(防并发超发,写明给实现 agent):核销与建号同一本地事务; +-- UPDATE game_invite_code SET used_count = used_count + 1 +-- WHERE id = ? AND status = 0 AND used_count < max_uses AND (expire_time IS NULL OR expire_time > NOW()); +-- affected=0 即核销失败(耗尽/停用/过期分支由前置查询给精确错误码,条件更新做最终一致兜底,建号事务整体回滚)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_invite_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号', + `code` VARCHAR(16) NOT NULL COMMENT '邀请码/口令(生成时随机大写字母数字,避易混淆字符;uk_code 冲突重试)', + `batch_no` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '批次号(运营按渠道发放归因,如 batch-xhs-01)', + `max_uses` INT NOT NULL DEFAULT 1 COMMENT '限量:最大核销次数', + `used_count` INT NOT NULL DEFAULT 0 COMMENT '已核销次数(条件更新原子累加,见核销语义)', + `expire_time` DATETIME NULL COMMENT '有效期(NULL=不过期)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0启用 1停用(停用=单码退役;全量退役走配置开关 §5.4)', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(发放对象/用途)', + -- 标准审计列(与 game_player 同范式) + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(MVP 单租户=0)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`, `deleted`, `tenant_id`) COMMENT '码值唯一(含 deleted/tenant_id 适配逻辑删+多租户)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '邀请码(报备前玩家受限激活旁路;报备后退役,拍板1C/2)'; + +-- ----------------------------------------------------------------------------- +-- ALTER:game_runtime_session 匿名试玩兼容(§2.3 ⚠ 硬边界 / §4.2 / §4.3) +-- 背景:V3 建表 player_user_id BIGINT NOT NULL;匿名开试玩会话(session/start userId=null)会撞 NOT NULL, +-- RuntimeSessionServiceImpl.setPlayerUserId(null) 直落 → "匿名试玩" 半截。本件放宽 NULL + 补 anon_id。 +-- 存量兼容(§4.3):V3 存量行 player_user_id 均有值(mock 喂 1),MODIFY 放宽 NULL 不破坏存量; +-- anon_id DEFAULT '' 兼容存量。回滚(V11.0.1)还原 NOT NULL 前须确认无 NULL 行;anon_id 列可留存不删,无害。 +-- 口径:anon_id 与 telemetry game_telemetry_event.anon_id(V5)同口径(纯客户端 anonId,拍板7=B)。 +-- ----------------------------------------------------------------------------- +ALTER TABLE `game_runtime_session` + MODIFY COLUMN `player_user_id` BIGINT NULL COMMENT '试玩玩家用户 ID(匿名试玩为空,2026-06-10 鉴权件放开;预览场景=创作者本人)', + ADD COLUMN `anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端 anonId(匿名会话归属;与 telemetry anon_id 同口径,拍板7=B)'; diff --git a/contracts/events.schema.json b/contracts/events.schema.json index ab8608e4..3e9faf05 100644 --- a/contracts/events.schema.json +++ b/contracts/events.schema.json @@ -54,6 +54,7 @@ "preview_play": { "description": "预览试玩", "type": "object" }, "publish_submit": { "description": "提交发布审核", "type": "object" }, "feed_view": { "description": "进入游戏流", "type": "object" }, + "user_login": { "description": "登录成功后前端补发(匿名↔登录身份衔接,2026-06-10 鉴权件)。envelope.user 双带 userId+anonId,把此前匿名 anonId 与登录 userId 关联,消除 1001/1 错位", "type": "object" }, "game_impression": { "description": "游戏卡片曝光(首屏封面)。feed 排序信号", "type": "object" }, "game_load_failed": { "description": "加载失败(超时5s跳过触发)。props: reason。降权信号", "type": "object" }, "game_play_start": { "description": "试玩开始", "type": "object" }, diff --git a/game-cloud/game-module-ad/game-module-ad-server/pom.xml b/game-cloud/game-module-ad/game-module-ad-server/pom.xml index b8bc76bd..acba893a 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/pom.xml +++ b/game-cloud/game-module-ad/game-module-ad-server/pom.xml @@ -66,6 +66,12 @@ yudao-spring-boot-starter-redis + + + cn.iocoder.cloud + yudao-spring-boot-starter-protection + + cn.iocoder.cloud diff --git a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/cn/wanxiang/game/module/ad/controller/app/AdController.java b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/cn/wanxiang/game/module/ad/controller/app/AdController.java index b68b8030..b2fde086 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/cn/wanxiang/game/module/ad/controller/app/AdController.java +++ b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/cn/wanxiang/game/module/ad/controller/app/AdController.java @@ -10,11 +10,15 @@ import cn.wanxiang.game.module.ad.dal.dataobject.slot.AdSlotDO; import cn.wanxiang.game.module.ad.service.revenue.AdRevenueService; import cn.wanxiang.game.module.ad.service.slot.AdSlotService; import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; +import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; +import java.util.concurrent.TimeUnit; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -26,7 +30,10 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; * 产品端(game-studio)- 广告控制器(SDK Plugin.Ad) * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。 - * 鉴权:拉广告位配置/计费上报可匿名(用户或匿名 anon Token);收入归因按 gameId 反查游戏作者,不依赖调用方身份。 + * 鉴权:2026-06-10 鉴权件放行,匿名合法——拉广告位配置 + 计费上报三端点加 @PermitAll,匿名玩家曝光不丢(§6.1 #12/#13/#14、§6.2)。 + * 收入归因按 gameId 反查游戏作者,不依赖调用方身份。 + * 计费链反作弊三条件(§6.2,缺一不得合入):①两计费端点 @RateLimiter(IP 维度);②幂等已有(traceId)保持; + * ③聚合侧剔除桩(telemetry AnomalyFilterService)同时覆盖 ad 计费事件——本控制器落条件①,②保持,③在 telemetry。 * * @author 造梦AI */ @@ -36,6 +43,17 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @Validated public class AdController { + /** + * 计费上报 IP 限频窗口秒(§6.2 条件1,可配置常量):60 秒窗口。 + * 与限频次数共同界定「单 IP 每分钟最多 N 次计费上报」,挡匿名场景批量伪造曝光。 + */ + private static final int REPORT_RATE_LIMIT_TIME_SECONDS = 60; + /** + * 计费上报 IP 限频次数(§6.2 条件1,推断初值,可配置):单 IP 60 秒内最多 60 次。 + * 正常玩家单局曝光/激励上报远低于此;阈值偏宽以零误伤真人,作变造刷量的粗暴上限,精细反作弊由聚合侧剔除桩承载(条件3)。 + */ + private static final int REPORT_RATE_LIMIT_COUNT = 60; + @Resource private AdSlotService adSlotService; @@ -43,6 +61,7 @@ public class AdController { private AdRevenueService adRevenueService; @GetMapping("/slot/list-enabled") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:拉配置非计费、无 userId 依赖(§6.1 #12) @Operation(summary = "拉取启用广告位配置", description = "SDK Plugin.Ad 初始化用;对齐契约#7,providerSlotId 不下发明文") @Parameter(name = "placement", description = "触发场景过滤:game_end/pause/feed(空则全部)", example = "game_end") public CommonResult> listEnabledSlots(@RequestParam(value = "placement", required = false) String placement) { @@ -51,13 +70,21 @@ public class AdController { } @PostMapping("/report/impression") - @Operation(summary = "有效曝光计费上报", description = "计费级,幂等键=traceId;mock 算 eCPM 收入落台账") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名曝光计费不丢(§6.1 #13 / §6.2 裁决:进免登清单,绑定三条件) + // §6.2 条件1 限频:IP 维度(ClientIpRateLimiterKeyResolver),60 秒 60 次粗暴上限,挡匿名批量伪造曝光(精细反作弊由聚合侧剔除桩承载) + @RateLimiter(time = REPORT_RATE_LIMIT_TIME_SECONDS, timeUnit = TimeUnit.SECONDS, count = REPORT_RATE_LIMIT_COUNT, + keyResolver = ClientIpRateLimiterKeyResolver.class) + @Operation(summary = "有效曝光计费上报", description = "计费级,幂等键=traceId(条件2保持);IP 限频(条件1);mock 算 eCPM 收入落台账") public CommonResult reportImpression(@Valid @RequestBody AdImpressionReqVO reqVO) { return success(adRevenueService.reportImpression(reqVO)); } @PostMapping("/report/reward") - @Operation(summary = "激励视频完成校验", description = "幂等键=traceId;mock 直接校验通过并计激励收入") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名激励计费不丢(§6.1 #14 / §6.2,同 #13 绑定三条件) + // §6.2 条件1 限频:IP 维度,同 impression 阈值 + @RateLimiter(time = REPORT_RATE_LIMIT_TIME_SECONDS, timeUnit = TimeUnit.SECONDS, count = REPORT_RATE_LIMIT_COUNT, + keyResolver = ClientIpRateLimiterKeyResolver.class) + @Operation(summary = "激励视频完成校验", description = "幂等键=traceId(条件2保持);IP 限频(条件1);mock 直接校验通过并计激励收入") public CommonResult reportReward(@Valid @RequestBody AdRewardReqVO reqVO) { return success(adRevenueService.reportReward(reqVO)); } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index b5e96d56..cf28969d 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -46,6 +46,13 @@ ${revision} + + + cn.iocoder.cloud + yudao-module-system-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java index dc5fcdc0..3d8e109b 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java @@ -8,6 +8,7 @@ import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO; import cn.wanxiang.game.module.aigc.dal.mysql.task.AigcTaskMapper; import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -44,6 +45,14 @@ public class AigcTaskServiceImpl implements AigcTaskService { @Resource private AigcTaskMapper aigcTaskMapper; + /** + * 创作者白名单校验 seam(2026-06-10 鉴权件 §6.3,拍板4=限 A2 白名单)。 + * 依据:前端守卫挡不住直连 API,直发生成是创作写入口,必须后端可信边界校验;非 creator_flag=1 抛 1-002-090-003。 + * MVP 单体由 system 的 PlayerApiImpl(@RestController @Primary) 就地解析;mock 身份豁免由其落地保批跑零中断。 + */ + @Resource + private PlayerApi playerApi; + @Override public List getTemplateList() { // T-AGC-03 模板注册表的 MVP 最小形态(HJ-AGENT-LOOP-EXEC-002 §8.4 T6,主 agent 裁决 #1 纳入): @@ -60,6 +69,8 @@ public class AigcTaskServiceImpl implements AigcTaskService { @Override public AigcTaskDO submitGenerate(AigcGenerateReqVO reqVO, Long userId) { + // 创作者白名单可信边界校验(2026-06-10 鉴权件 §6.3,拍板4):直发生成是创作写入口,非白名单拒(抛 1-002-090-003) + playerApi.validateCreator(userId).getCheckedData(); // 提交前置校验:prompt 非空(契约 generate 描述) if (!StringUtils.hasText(reqVO.getPrompt())) { throw exception(AIGC_PROMPT_REQUIRED); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java index a500f3b2..6e138b02 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java @@ -5,13 +5,16 @@ import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO; import cn.wanxiang.game.module.aigc.dal.mysql.task.AigcTaskMapper; import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import static cn.wanxiang.game.module.aigc.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.PLAYER_CREATE_NOT_IN_WHITELIST; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -34,11 +37,15 @@ class AigcTaskServiceImplTest extends BaseMockitoUnitTest { @Mock private AigcTaskMapper aigcTaskMapper; + @Mock + private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3:submitGenerate 入层创作者白名单校验依赖 // ============================== submitGenerate ============================== @Test void testSubmitGenerate_defaultsAndQueued() { + // 白名单校验放行(§6.3):本用例聚焦提交默认值,创作者视为已通过 + when(playerApi.validateCreator(99L)).thenReturn(CommonResult.success(true)); // 准备:insert 时回填自增 ID doAnswer(inv -> { AigcTaskDO d = inv.getArgument(0); @@ -69,6 +76,8 @@ class AigcTaskServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitGenerate_promptRequired() { + // 白名单先过(校验在 prompt 兜底之前),再触发空 prompt 兜底 + when(playerApi.validateCreator(99L)).thenReturn(CommonResult.success(true)); AigcGenerateReqVO reqVO = new AigcGenerateReqVO(); reqVO.setPrompt(" "); // 空白 prompt → 命中前置校验 reqVO.setTemplateId("dodge"); @@ -79,6 +88,21 @@ class AigcTaskServiceImplTest extends BaseMockitoUnitTest { verify(aigcTaskMapper, never()).insert(any(AigcTaskDO.class)); // 带类型消歧 } + @Test + void testSubmitGenerate_notCreator_throws() { + // 白名单拒分支(§6.3,拍板4):validateCreator 返回 code≠0,.getCheckedData() 抛 ServiceException → + // submitGenerate 在白名单校验处即中断,不触达 prompt 校验/落库(可信边界在后端,挡直连 API) + when(playerApi.validateCreator(99L)).thenReturn(CommonResult.error(PLAYER_CREATE_NOT_IN_WHITELIST)); + AigcGenerateReqVO reqVO = new AigcGenerateReqVO(); + reqVO.setPrompt("做一个躲避陨石的小游戏"); + reqVO.setTemplateId("dodge"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> aigcTaskService.submitGenerate(reqVO, 99L)); + assertEquals(PLAYER_CREATE_NOT_IN_WHITELIST.getCode(), ex.getCode()); + verify(aigcTaskMapper, never()).insert(any(AigcTaskDO.class)); // 非白名单不落库 + } + // ============================== getTask 归属校验 ============================== @Test diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java index 0a4ffbf4..c4c6010c 100644 --- a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,7 +22,8 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; * 产品端(game-studio)- 内容分级控制器(只读) * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。 - * 鉴权:分级只读,用户/匿名 Token 可访问。分级写入仅由 Gate evaluate 落定 + admin 复核产生(本控制器不写)。 + * 鉴权:2026-06-10 鉴权件放行,匿名合法——分级只读、无 userId 依赖,加 @PermitAll 供匿名玩家读分级(§6.1 #11)。 + * 分级写入仅由 Gate evaluate 落定 + admin 复核产生(本控制器不写)。 * * @author 造梦AI */ @@ -35,6 +37,7 @@ public class ComplianceRatingController { private ContentRatingService contentRatingService; @GetMapping("/get") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:只读分级无 userId(§6.1 #11,评审版清单未列、本执行版补入) @Operation(summary = "取内容分级", description = "按游戏+版本读分级(all/8+/12+/16+);无记录报 COMPLIANCE_RATING_NOT_FOUND") @Parameter(name = "gameId", description = "游戏 ID", required = true, example = "1024") @Parameter(name = "versionId", description = "版本 ID(无版本传 0)", example = "0") diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/AppFeedController.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/AppFeedController.java index 546de929..0b61f0d6 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/AppFeedController.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/AppFeedController.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -26,7 +27,8 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; * 产品端(game-studio)- 游戏流控制器 * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。 - * 鉴权:游戏流/专区/分享可匿名浏览(userId 可空,互动态恒 false);互动写入需用户 Token。 + * 鉴权:游戏流/专区/分享可匿名浏览(2026-06-10 鉴权件放行,匿名合法:stream/zones/zone-stream/share 四读端点加 @PermitAll, + * userId 可空、互动态恒 false);互动写入(interact)是游客转正转化点(拍板5),保持强制登录、零改动。 * * @author 绘境AI */ @@ -40,6 +42,7 @@ public class AppFeedController { private FeedService feedService; @GetMapping("/stream") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:玩家区零门槛刷流(§6.1 #1,互动态恒 false) @Operation(summary = "游戏流", description = "竖屏即玩,cursor 分页,按 quality_score 降序(P-FED-01/02/13)") public CommonResult getFeedStream(@Valid FeedStreamReqVO reqVO) { // 匿名亦可浏览:未登录时 userId 为 null,互动态恒 false @@ -48,12 +51,14 @@ public class AppFeedController { } @GetMapping("/zones") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:专区列表无 userId 依赖(§6.1 #2) @Operation(summary = "双轨专区列表", description = "官方精选 / UGC 普通(P-PLZ-01/02)") public CommonResult> getZones() { return success(feedService.getZones()); } @GetMapping("/zone/stream") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:分区流同 #1 玩家区零门槛(§6.1 #3) @Operation(summary = "按 Zone 分区游戏流", description = "双区独立候选,cursor 分页(P-PLZ-01/02)") public CommonResult getZoneStream(@Valid ZoneStreamReqVO reqVO) { Long userId = SecurityFrameworkUtils.getLoginUserId(); @@ -69,6 +74,7 @@ public class AppFeedController { } @GetMapping("/share/{gameId}") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:分享落地页 OG 是玩家漏斗入口(§6.1 #4) @Operation(summary = "分享落地页 OG 元数据", description = "带渠道参数的分享链接(P-FED-07/08/09)") @Parameter(name = "gameId", description = "游戏编号", required = true, example = "1024") public CommonResult getShareMeta(@PathVariable("gameId") Long gameId, diff --git a/game-cloud/game-module-project/game-module-project-server/pom.xml b/game-cloud/game-module-project/game-module-project-server/pom.xml index c2039fd3..76dca383 100644 --- a/game-cloud/game-module-project/game-module-project-server/pom.xml +++ b/game-cloud/game-module-project/game-module-project-server/pom.xml @@ -52,6 +52,13 @@ ${revision} + + + cn.iocoder.cloud + yudao-module-system-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java index 217b35e6..8d159f39 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java @@ -23,6 +23,7 @@ import cn.wanxiang.game.module.compliance.dto.GateVerdict; import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,6 +72,15 @@ public class ProjectServiceImpl implements ProjectService { @Resource private PublishOrchestrationService publishOrchestrationService; + /** + * 创作者白名单校验 seam(2026-06-10 鉴权件 §6.3,拍板4=限 A2 白名单)。 + * 依据:前端守卫挡不住直连 API,创建项目/提交发布是创作写入口,必须后端可信边界校验;非 creator_flag=1 抛 1-002-090-003。 + * 注:studio 建草稿经 ProjectApi.createProject 进本入口会叠加一次校验(幂等无副作用,均同一创作者)。 + * MVP 单体由 system 的 PlayerApiImpl(@RestController @Primary) 就地解析;mock 身份豁免由其落地保批跑零中断。 + */ + @Resource + private PlayerApi playerApi; + @Override public PageResult getMyProjectPage(ProjectPageReqVO reqVO, Long userId) { return projectMapper.selectMyPage(reqVO, userId); @@ -78,6 +88,8 @@ public class ProjectServiceImpl implements ProjectService { @Override public Long createProject(ProjectCreateReqVO reqVO, Long userId) { + // 创作者白名单可信边界校验(2026-06-10 鉴权件 §6.3,拍板4):创建项目是创作写入口,非白名单拒(抛 1-002-090-003) + playerApi.validateCreator(userId).getCheckedData(); // 拷贝 title/templateId,其余服务端字段显式置默认值 ProjectDO project = BeanUtils.toBean(reqVO, ProjectDO.class); project.setCreatorUserId(userId); @@ -119,6 +131,8 @@ public class ProjectServiceImpl implements ProjectService { @Override public ProjectPublishRespVO submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId) { // ===== 安全前置(失败抛异常,不进 gates)===== + // 创作者白名单可信边界校验(2026-06-10 鉴权件 §6.3,拍板4):提交发布是创作写入口,非白名单拒(抛 1-002-090-003),置于归属校验前 + playerApi.validateCreator(userId).getCheckedData(); // 归属/存在:非本人或不存在 → 抛 PROJECT_NOT_OWNER / PROJECT_NOT_EXISTS(数据边界,服务端强校验) ProjectDO project = validateProjectOwner(id, userId); // 状态机:仅草稿可提交 → 非草稿抛 PROJECT_STATUS_NOT_DRAFT diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java index 6ea67bc1..0d58a5ba 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java @@ -17,12 +17,14 @@ import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import static cn.wanxiang.game.module.project.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.PLAYER_CREATE_NOT_IN_WHITELIST; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -48,11 +50,22 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入) @Mock private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排(α auto-publish,APPROVE 时调用) + @Mock + private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3:createProject/submitPublish 入层创作者白名单校验依赖 + + /** + * 桩白名单校验放行(2026-06-10 鉴权件 §6.3):聚焦项目/发布编排逻辑,创作者视为已通过。 + * createProject/submitPublish 两入口均先调 validateCreator,故凡触达这两入口的用例都需调用本桩(STRICT_STUBS 下不冗余)。 + */ + private void stubCreatorPass() { + when(playerApi.validateCreator(99L)).thenReturn(CommonResult.success(true)); + } // ============================== createProject ============================== @Test void testCreateProject_defaultsAndDraft() { + stubCreatorPass(); // 白名单校验放行(§6.3) // 准备:insert 时回填自增 ID doAnswer(inv -> { ProjectDO d = inv.getArgument(0); @@ -119,6 +132,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_ageRatingRequired() { + stubCreatorPass(); // 白名单校验放行(§6.3) // publish 唯一化:适龄门禁拦截不再抛异常,返回 admitted=false 且不改状态机 ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus()); project.setAgeRating(""); // 适龄空 → 命中门禁⑦(age gate block) @@ -137,6 +151,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_success() { + stubCreatorPass(); // 白名单校验放行(§6.3) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus()); project.setAgeRating("all"); when(projectMapper.selectById(1L)).thenReturn(project); @@ -156,6 +171,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_complianceBlock() { + stubCreatorPass(); // 白名单校验放行(§6.3) // 合规拦截:compliance 返回 block → admitted=false、含 compliance pass=false、不改状态机不写库 ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus()); project.setAgeRating("all"); // age 通过,仅 compliance 拦截 @@ -174,6 +190,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_complianceReviewStillAdmitted() { + stubCreatorPass(); // 白名单校验放行(§6.3) // 需人工复核:compliance 返回 review → 仍 admitted=true 进 REVIEWING(人工在审核态处理) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus()); project.setAgeRating("all"); @@ -195,6 +212,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_notDraftRejected() { + stubCreatorPass(); // 白名单先过(校验在状态机校验之前),再触发非草稿拒绝 // 安全前置:非草稿态仍抛 PROJECT_STATUS_NOT_DRAFT(不进 gates) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); project.setAgeRating("all"); @@ -208,6 +226,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_notOwner() { + stubCreatorPass(); // 白名单先过(校验在归属校验之前),再触发非归属拒绝 // 安全前置:非归属仍抛 PROJECT_NOT_OWNER(不进 gates) ProjectDO project = ownedProject(1L, 11L, ProjectStatusEnum.DRAFT.getStatus()); // 属于他人 when(projectMapper.selectById(1L)).thenReturn(project); @@ -218,6 +237,20 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { verify(projectMapper, never()).updateById(any(ProjectDO.class)); // 带类型消歧 } + @Test + void testCreateProject_notCreator_throws() { + // 白名单拒分支(§6.3,拍板4):createProject 在白名单校验处即中断,不落库(可信边界在后端,挡直连 API) + when(playerApi.validateCreator(99L)).thenReturn(CommonResult.error(PLAYER_CREATE_NOT_IN_WHITELIST)); + ProjectCreateReqVO reqVO = new ProjectCreateReqVO(); + reqVO.setTitle("我的放置小镇"); + reqVO.setTemplateId("idle"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectService.createProject(reqVO, 99L)); + assertEquals(PLAYER_CREATE_NOT_IN_WHITELIST.getCode(), ex.getCode()); + verify(projectMapper, never()).insert(any(ProjectDO.class)); // 非白名单不落库 + } + // ============================== reviewProject 流转 ============================== @Test diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java index 98edfa93..e4cfb6d8 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import org.springframework.http.MediaType; import org.springframework.util.StringUtils; @@ -32,7 +33,9 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; * 产品端(game-studio)- runtime 控制器(预览/试玩宿主取包清单 + 试玩会话开收) * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(见 WebProperties),@RequestMapping 只写 /runtime。 - * 鉴权:所有端点需用户 Token;玩家/创作者只能操作自己的数据,归属/门禁校验在 Service 强制(不依赖前端)。 + * 鉴权:2026-06-10 鉴权件放行,匿名合法——取包/manifest/会话开收四端点加 @PermitAll,匿名玩家可零门槛试玩。 + * 门禁仍在 Service 强制(不依赖前端):play 场景由 status 权威判定,preview 场景 userId=null 即拒(匿名不可越权预览); + * 会话落库兼容 userId=null(匿名 player_user_id=NULL + anon_id 归属,对齐 V11 ALTER)。 * * @author 绘境AI */ @@ -55,6 +58,7 @@ public class AppRuntimeController { private PackageStore packageStore; @GetMapping("/package/{versionId}") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:play 场景门禁在 Service(status 权威判定),preview 场景 userId=null 即拒(§6.1 #5) @Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") @Parameter(name = "scene", description = "preview 创作者预览 / play 玩家试玩", example = "play") @@ -69,6 +73,7 @@ public class AppRuntimeController { } @GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE) + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:与 #5 同源门禁(复用 getPackageManifest),匿名宿主可取 manifest 试玩(§6.1 #6) @Operation(summary = "取版本运行包 manifest 原始 JSON", description = "§3.4 C4:返回 manifest 原始 JSON 文本,不包 CommonResult、不 parse/re-serialize;宿主对响应文本算 sha256 与 checksum 严格比对后注入运行容器") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") @@ -91,13 +96,16 @@ public class AppRuntimeController { } @PostMapping("/session/start") - @Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名试玩开会话(§6.1 #7);userId 可空时落 player_user_id=NULL + reqVO.anonId 归属(对齐 V11 ALTER) + @Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等;匿名携带 anonId 归属") public CommonResult startSession(@Valid @RequestBody SessionStartReqVO reqVO) { + // 匿名玩家无登录态:getLoginUserId 可能为 null(合法,不强制鉴权),由 Service 兼容落 anon_id 归属 Long userId = SecurityFrameworkUtils.getLoginUserId(); // 场景缺省回落 play(与契约 default 一致) if (reqVO.getScene() == null) { reqVO.setScene(RuntimeSceneEnum.PLAY.getScene()); } + // anonId 由 reqVO 透传至 Service(匿名会话归属,与 telemetry anon_id 同口径) RuntimeSessionDO session = runtimeSessionService.startSession(reqVO, userId); // 仅回 sessionId + startTime(其余会话字段不外露) SessionStartRespVO resp = BeanUtils.toBean(session, SessionStartRespVO.class, @@ -106,8 +114,10 @@ public class AppRuntimeController { } @PostMapping("/session/end") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名会话同链路收口,幂等兜底(§6.1 #8) @Operation(summary = "结束试玩会话", description = "宿主在 game_end 或卸载(sendBeacon 兜底)时调用;服务端以 end-start 计时,幂等") public CommonResult endSession(@Valid @RequestBody SessionEndReqVO reqVO) { + // 匿名会话同样可结束(userId 可空),endSession 以 sessionId 定位、不依赖 userId 鉴权 Long userId = SecurityFrameworkUtils.getLoginUserId(); runtimeSessionService.endSession(reqVO, userId); return success(true); diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/SessionStartReqVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/SessionStartReqVO.java index e847e171..7299d862 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/SessionStartReqVO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/SessionStartReqVO.java @@ -37,4 +37,9 @@ public class SessionStartReqVO { @Schema(description = "贯穿链路 trace_id(透传至 telemetry 回灌)", example = "trace_8f3a1c") private String traceId; + @Schema(description = "客户端匿名 ID(可选;匿名试玩时落 game_runtime_session.anon_id 作归属,与 telemetry anon_id 同口径,2026-06-10 鉴权件 §6.1#7)", + example = "anon-xyz") + @Size(max = 64, message = "匿名 ID 不能超过 64 个字符") + private String anonId; + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/session/RuntimeSessionDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/session/RuntimeSessionDO.java index 9b0c0b37..b3436a3e 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/session/RuntimeSessionDO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/session/RuntimeSessionDO.java @@ -39,8 +39,16 @@ public class RuntimeSessionDO extends TenantBaseDO { private Long versionId; /** * 试玩玩家用户 ID(DataPermission;预览场景=创作者本人) + * + * 2026-06-10 鉴权件:匿名试玩为 NULL(V11 ALTER 放宽 player_user_id NOT NULL→NULL),归属落 anonId。 */ private Long playerUserId; + /** + * 客户端匿名 ID(匿名会话归属,对应列 anon_id,V11 ADD COLUMN) + * + * 与 telemetry game_telemetry_event.anon_id 同口径(纯客户端 anonId,拍板7=B);登录态会话为空串。 + */ + private String anonId; /** * 客户端本局唯一 token(幂等键,防抖/重连不重复计数;uk_play_token 唯一) */ diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImpl.java index e091c5c0..0d6855fb 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImpl.java @@ -47,7 +47,10 @@ public class RuntimeSessionServiceImpl implements RuntimeSessionService { RuntimeSessionDO session = new RuntimeSessionDO(); session.setGameId(reqVO.getGameId()); session.setVersionId(reqVO.getVersionId()); + // 2026-06-10 鉴权件:匿名试玩 userId=null(直落 player_user_id=NULL,V11 已放宽 NOT NULL);登录态落真实 userId session.setPlayerUserId(userId); + // 匿名会话归属:anonId 落 anon_id(与 telemetry anon_id 同口径);缺失/登录态落空串(DEFAULT '' 兼容,不破坏 NOT NULL) + session.setAnonId(StringUtils.hasText(reqVO.getAnonId()) ? reqVO.getAnonId() : ""); session.setClientPlayToken(reqVO.getClientPlayToken()); // 场景默认 play(未传或非法值统一回落 play 玩家试玩) session.setScene(RuntimeSceneEnum.isPreview(reqVO.getScene()) @@ -57,8 +60,9 @@ public class RuntimeSessionServiceImpl implements RuntimeSessionService { session.setTraceId(reqVO.getTraceId()); session.setStartTime(LocalDateTime.now()); // 服务端记开局时间(时长以服务端 end-start 为准) runtimeSessionMapper.insert(session); - log.info("[startSession] 新建试玩会话 sessionId={}, gameId={}, versionId={}, scene={}", - session.getId(), reqVO.getGameId(), reqVO.getVersionId(), session.getScene()); + // 关键链路日志:含归属维度(userId 可空=匿名,anonId 归属),便于匿名/登录会话排障与对账 + log.info("[startSession] 新建试玩会话 sessionId={}, gameId={}, versionId={}, scene={}, userId={}, anonId={}", + session.getId(), reqVO.getGameId(), reqVO.getVersionId(), session.getScene(), userId, session.getAnonId()); return session; } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImplTest.java index 0f0c1ef0..1d0cc7e4 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImplTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/session/RuntimeSessionServiceImplTest.java @@ -73,6 +73,38 @@ class RuntimeSessionServiceImplTest extends BaseMockitoUnitTest { assertNotNull(saved.getStartTime()); // 服务端记开局时间 } + @Test + void testStartSession_anonymous_playerUserIdNull_anonIdPersisted() { + // 2026-06-10 鉴权件 §6.1#7:匿名试玩 userId=null → player_user_id 落 NULL(V11 已放宽 NOT NULL),anonId 落 anon_id 归属 + when(runtimeSessionMapper.selectByClientPlayToken("ptk_anon")).thenReturn(null); + doAnswer(inv -> { ((RuntimeSessionDO) inv.getArgument(0)).setId(1000L); return 1; }) + .when(runtimeSessionMapper).insert(any(RuntimeSessionDO.class)); + SessionStartReqVO reqVO = startReq("ptk_anon", "play"); + reqVO.setAnonId("anon-abc"); // 匿名归属 + + runtimeSessionService.startSession(reqVO, null); // userId=null=匿名 + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeSessionDO.class); + verify(runtimeSessionMapper).insert(captor.capture()); + RuntimeSessionDO saved = captor.getValue(); + assertNull(saved.getPlayerUserId(), "匿名会话 player_user_id 应为 NULL"); + assertEquals("anon-abc", saved.getAnonId(), "匿名会话应落 anonId 归属"); + assertEquals(SessionStatusEnum.PLAYING.getStatus(), saved.getStatus()); + } + + @Test + void testStartSession_anonIdMissing_fallbackEmptyString() { + // anonId 缺失(可选入参)→ 落空串(DEFAULT '' 兼容 NOT NULL),归因降级不阻断会话开局 + when(runtimeSessionMapper.selectByClientPlayToken("ptk_noanon")).thenReturn(null); + when(runtimeSessionMapper.insert(any(RuntimeSessionDO.class))).thenReturn(1); + + runtimeSessionService.startSession(startReq("ptk_noanon", "play"), null); // 匿名且无 anonId + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeSessionDO.class); + verify(runtimeSessionMapper).insert(captor.capture()); + assertEquals("", captor.getValue().getAnonId(), "anonId 缺失应落空串"); + } + @Test void testStartSession_illegalSceneFallbackToPlay() { // 非法/未知 scene → 回落 play diff --git a/game-cloud/game-module-studio/game-module-studio-server/pom.xml b/game-cloud/game-module-studio/game-module-studio-server/pom.xml index eb2e127a..7ec10d81 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/pom.xml +++ b/game-cloud/game-module-studio/game-module-studio-server/pom.xml @@ -46,6 +46,13 @@ ${revision} + + + cn.iocoder.cloud + yudao-module-system-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java index c03d74c3..340aa018 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java @@ -14,6 +14,7 @@ import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -63,6 +64,14 @@ public class StudioServiceImpl implements StudioService { @Resource private AigcApi aigcApi; + /** + * 创作者白名单校验 seam(2026-06-10 鉴权件 §6.3,拍板4=限 A2 白名单)。 + * 依据:前端守卫挡不住直连 API,创作写入口必须后端可信边界校验。MVP 单体由 system 的 PlayerApiImpl(@RestController @Primary) 就地解析; + * 非 creator_flag=1 抛 1-002-090-003「创作功能限内测白名单」;mock 身份豁免由 PlayerApiImpl 落地保批跑零中断。 + */ + @Resource + private PlayerApi playerApi; + @Override public List getTemplateList() { // 模板浏览(P-TPL-01/03):透传 aigc 模板注册表(MVP 骨架返回空列表占位) @@ -71,6 +80,9 @@ public class StudioServiceImpl implements StudioService { @Override public StudioSessionDO createDraft(StudioDraftCreateReqVO reqVO, Long userId) { + // 创作者白名单可信边界校验(2026-06-10 鉴权件 §6.3,拍板4):非 A2 白名单创作者直接拒(抛 1-002-090-003), + // 置于建草稿最前,避免非白名单用户触达后续 project 建游戏副作用。校验失败由 .getCheckedData() 触发异常向上传播。 + playerApi.validateCreator(userId).getCheckedData(); // 模板必填兜底校验(VO 已 @NotBlank,此处服务端兜底) if (!StringUtils.hasText(reqVO.getTemplateId())) { throw exception(STUDIO_TEMPLATE_REQUIRED); @@ -107,6 +119,8 @@ public class StudioServiceImpl implements StudioService { @Override public StudioTaskChainDO generate(StudioGenerateReqVO reqVO, Long userId) { + // 创作者白名单可信边界校验(2026-06-10 鉴权件 §6.3,拍板4):提交生成是创作写入口,非白名单拒(抛 1-002-090-003) + playerApi.validateCreator(userId).getCheckedData(); // 1) 校验 session 存在 + 归属(创作者只能操作自己的会话) StudioSessionDO session = validateSessionOwner(reqVO.getSessionId(), userId); diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java index 7a68e18c..15c987d0 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java @@ -13,6 +13,7 @@ import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO; import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.iocoder.yudao.module.system.api.passport.PlayerApi; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; @@ -22,6 +23,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import static cn.wanxiang.game.module.studio.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.PLAYER_CREATE_NOT_IN_WHITELIST; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -53,13 +55,24 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { private ProjectApi projectApi; @Mock private AigcApi aigcApi; + @Mock + private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3:createDraft/generate 入层创作者白名单校验依赖 private static final Long USER_ID = 99L; + /** + * 桩白名单校验放行(2026-06-10 鉴权件 §6.3):本测试聚焦编排逻辑,创作者校验视为已通过。 + * createDraft/generate 两入口均先调 validateCreator,故凡触达这两入口的用例都需调用本桩(STRICT_STUBS 下不会冗余)。 + */ + private void stubCreatorPass() { + when(playerApi.validateCreator(USER_ID)).thenReturn(CommonResult.success(true)); + } + // ============================== createDraft 委托 project-api ============================== @Test void testCreateDraft_delegatesProjectCreate_andWritesBackGameId() { + stubCreatorPass(); // 白名单校验放行(§6.3) // project createProject 返回 gameId=2048 when(projectApi.createProject(eq(USER_ID), eq("我的放置小镇"), eq("idle"))) .thenReturn(CommonResult.success(2048L)); @@ -84,6 +97,7 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { @Test void testCreateDraft_projectReturnsNullGameId_throws() { + stubCreatorPass(); // 白名单校验放行(§6.3) // project 返回空 gameId(硬前置不允许恒空兜底)→ 抛移交失败错误码,不落会话 when(projectApi.createProject(any(), any(), any())).thenReturn(CommonResult.success(null)); @@ -95,6 +109,7 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { @Test void testCreateDraft_templateRequired_throws() { + stubCreatorPass(); // 白名单先过(校验在模板兜底之前),再触发模板为空兜底 // 模板为空 → 服务端兜底校验抛错,不调 project ServiceException ex = assertThrows(ServiceException.class, () -> studioService.createDraft(draftReq("t", ""), USER_ID)); @@ -102,10 +117,39 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { verify(projectApi, never()).createProject(any(), any(), any()); } + // ============================== 创作者白名单校验(2026-06-10 鉴权件 §6.3,拍板4)============================== + + @Test + void testCreateDraft_notCreator_throws() { + // 白名单拒分支:validateCreator 返回 code≠0(PLAYER_CREATE_NOT_IN_WHITELIST),.getCheckedData() 抛 ServiceException → + // createDraft 在白名单校验处即中断,不触达模板校验/project 建游戏(可信边界在后端,挡直连 API) + when(playerApi.validateCreator(USER_ID)).thenReturn(CommonResult.error(PLAYER_CREATE_NOT_IN_WHITELIST)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.createDraft(draftReq("我的放置小镇", "idle"), USER_ID)); + assertEquals(PLAYER_CREATE_NOT_IN_WHITELIST.getCode(), ex.getCode()); + // 非白名单:不建游戏、不落会话 + verify(projectApi, never()).createProject(any(), any(), any()); + verify(studioSessionMapper, never()).insert(any(StudioSessionDO.class)); + } + + @Test + void testGenerate_notCreator_throws() { + // 白名单拒分支:generate 入层校验未过即中断,不触达会话归属校验/aigc 委托 + when(playerApi.validateCreator(USER_ID)).thenReturn(CommonResult.error(PLAYER_CREATE_NOT_IN_WHITELIST)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.generate(generateReq(512L, "p", null), USER_ID)); + assertEquals(PLAYER_CREATE_NOT_IN_WHITELIST.getCode(), ex.getCode()); + verify(studioSessionMapper, never()).selectById(any()); + verify(aigcApi, never()).submitGenerate(any()); + } + // ============================== generate 委托 aigc ============================== @Test void testGenerate_delegatesAigc_andWritesBackAigcTaskId() { + stubCreatorPass(); // 白名单校验放行(§6.3) // 会话存在且归属 StudioSessionDO session = session(512L, USER_ID, 2048L, "idle"); when(studioSessionMapper.selectById(512L)).thenReturn(session); @@ -141,6 +185,7 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { @Test void testGenerate_sessionNotOwner_throws() { + stubCreatorPass(); // 白名单先过(校验在归属校验之前),再触发归属失败 // 会话属于他人 → 归属校验失败 StudioSessionDO others = session(512L, 1L, 2048L, "idle"); when(studioSessionMapper.selectById(512L)).thenReturn(others); @@ -153,6 +198,7 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { @Test void testGenerate_aigcReturnsNull_marksFailed_andThrows() { + stubCreatorPass(); // 白名单校验放行(§6.3) StudioSessionDO session = session(512L, USER_ID, 2048L, "idle"); when(studioSessionMapper.selectById(512L)).thenReturn(session); doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1000L); return 1; }) diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/AppTelemetryController.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/AppTelemetryController.java index 58517ed6..9a2d259d 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/AppTelemetryController.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/AppTelemetryController.java @@ -9,6 +9,7 @@ import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; @@ -22,7 +23,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; * 产品端(game-studio)- 遥测上报控制器 * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(见 WebProperties)。 - * 鉴权:用户 Token 或匿名 anon Token;高频写入通道,快速 ACK 后投递 MQ 异步入库(不同步聚合)。 + * 鉴权:2026-06-10 鉴权件放行,匿名合法——两上报端点加 @PermitAll,匿名玩家免登上报(拍板7=B 纯客户端 anonId)。 * userId 由 token 解析(匿名为 null),仅用于补全信封 user,不做数据归属过滤。 * * @author 绘境AI @@ -37,6 +38,7 @@ public class AppTelemetryController { private EventIngestService eventIngestService; @PostMapping("/events/batch") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名玩家批量上报,信封 anonId 已留位(§6.1 #9) @Operation(summary = "事件批量摄取", description = "轻量校验后投递 MQ 异步入库,返回 accepted/rejected 计数(非整批失败语义)") public CommonResult ingestBatch(@Valid @RequestBody EventBatchReqVO reqVO) { // 匿名玩家可能无登录态,getLoginUserId 可能为 null(合法,不强制鉴权) @@ -45,6 +47,7 @@ public class AppTelemetryController { } @PostMapping("/perf/beacon") + @PermitAll // 2026-06-10 鉴权件放行,匿名合法:sendBeacon 兜底通道,匿名同样免登(§6.1 #10) @Operation(summary = "性能埋点兜底通道", description = "sendBeacon 页面卸载场景;单条信封,永远受理成功(不阻塞前端卸载)") public CommonResult ingestBeacon(@Valid @RequestBody EnvelopeReqVO reqVO) { Long userId = SecurityFrameworkUtils.getLoginUserId(); diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java index 65bbeca0..81744c37 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java @@ -61,7 +61,7 @@ public class EnvelopeReqVO { public static class UserVO { @Schema(description = "登录用户 ID", example = "10086") private String userId; - @Schema(description = "匿名 token 派生的稳定匿名 ID", example = "anon-xyz") + @Schema(description = "纯客户端生成的稳定匿名 ID(localStorage 持久化;身份不可信,防伪造/刷量由聚合侧按 anonId/IP 异常剔除兜底——2026-06-10 鉴权拍板 §9-7=B,取代旧'匿名 token 派生'机制)", example = "anon-xyz") private String anonId; } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java index b3575488..99a57d7a 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java @@ -9,9 +9,11 @@ import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper; import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper; import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum; import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum; +import cn.wanxiang.game.module.telemetry.service.quality.AnomalyFilterService; import cn.wanxiang.game.module.feed.api.FeedApi; import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -85,6 +87,13 @@ public class EventIngestServiceImpl implements EventIngestService { @Resource private FeedApi feedApi; + /** + * 聚合侧异常剔除桩(§8,拍板 7=B 配套):按 anonId/IP × gameId 滑动窗口计数,超阈值的 engagement / ad 计费事件 + * 原始照常落库但不进聚合/不触发 quality 重算。Redis 故障由桩内部降级放行,不阻断本主链。 + */ + @Resource + private AnomalyFilterService anomalyFilterService; + /* * TODO 对接点(MQ 生产,§3.5 仅留契约+TODO 不实现):拆"上报快速 ACK + 异步消费聚合"时, * 注入 RocketMQ 生产者投递 topic=telemetry-event(幂等键=eventId,对应 uk_event_id),消费侧复用本类同步写聚合逻辑。 @@ -160,6 +169,20 @@ public class EventIngestServiceImpl implements EventIngestService { TelemetryEventDO eventDO = buildEventDO(envelope, userId); telemetryEventMapper.insert(eventDO); + // 2.5) 聚合侧异常剔除桩闸门(§8,拍板 7=B 配套):超阈值的 engagement / ad 计费事件原始已落库(上一步), + // 但本步起【不进聚合、不触发 quality 重算】(直接跳到置已聚合)。 + // IP 取当前请求来源(ServletUtils.getClientIP,无请求上下文/MQ 消费时为 null,桩内按仅 anonId 维度处理); + // Redis 故障由桩内部降级放行(返回 false),不阻断本同步写主链(§12)。 + String clientIp = ServletUtils.getClientIP(); + if (anomalyFilterService.shouldExclude(envelope, clientIp)) { + // 命中剔除:跳过聚合/算分(原始保真供未来回溯/审计),中文日志在桩内已记剔除原因+维度 + log.info("[ingestOne] 命中聚合侧异常剔除,原始已落库但不进聚合 eventId={}, event={}, ip={}", + envelope.getEventId(), envelope.getEvent(), clientIp); + // 置该事件已聚合(语义=已处理完毕,不再被聚合链拾取;原始数据保留) + markAggregated(eventDO.getId()); + return; + } + // 3) 仅当事件落到具体游戏(game_id 非空)才进入聚合/算分链路;否则只落事件、置已聚合(无聚合落点) Long gameId = parseLong(envelope.getContext() == null ? null : envelope.getContext().getGameId()); if (gameId != null) { @@ -209,11 +232,22 @@ public class EventIngestServiceImpl implements EventIngestService { } // 6) 置该事件 process_status=1 已聚合(无 game_id / 零增量事件同样置已聚合:原始事件已保留,曝光数据供未来 CTR 分析) + markAggregated(eventDO.getId()); + log.info("[ingestOne] 同步写完成 eventId={}, event={}, gameId={}", envelope.getEventId(), envelope.getEvent(), gameId); + } + + /** + * 置事件 process_status=1 已聚合(仅状态位更新,避免整对象覆盖) + * + * 两处复用:正常同步写收尾、异常剔除收尾(剔除事件原始照常落库 + 标记已处理,不进聚合)。 + * + * @param eventId 事件 DO 主键 id + */ + private void markAggregated(Long eventId) { TelemetryEventDO statusUpdate = new TelemetryEventDO(); - statusUpdate.setId(eventDO.getId()); + statusUpdate.setId(eventId); statusUpdate.setProcessStatus(EventProcessStatusEnum.AGGREGATED.getStatus()); telemetryEventMapper.updateById(statusUpdate); - log.info("[ingestOne] 同步写完成 eventId={}, event={}, gameId={}", envelope.getEventId(), envelope.getEvent(), gameId); } /** diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterService.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterService.java new file mode 100644 index 00000000..7555253b --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterService.java @@ -0,0 +1,37 @@ +package cn.wanxiang.game.module.telemetry.service.quality; + +import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO; + +/** + * 聚合侧异常剔除桩 Service(拍板 7=B 的配套义务,2026-06-10 鉴权件 §8) + * + * 背景:拍板 7=B 选「纯客户端 anonId + 不做服务端票据」,身份不可信,故防伪造/刷量必须由聚合侧按 + * anonId/IP 维度异常剔除兜底。本桩边界先立、实现可粗暴(阈值粗、单机 Redis 计数),是 ad 反作弊(owner=compliance)的判定输出前身。 + * + * 最小实现边界(§8,本件只做这么多): + * - 维度:单 anonId / 单请求 IP 对同一 gameId 的 engagement 事件做计数(滑动窗口,Redis); + * - 覆盖:engagement 事件(quality 重算输入)+ ad 计费事件(§6.2 条件 3,ad 反作弊接同一判定输出); + * - 超阈值(粗暴初值 100 次/小时,可配置)→ 该事件判为异常应剔除(原始照常落库,仅不进聚合/不触发 quality 重算); + * - Redis 不可用 → 降级放行(返回 false)+ WARN(桩失效不可阻断遥测主链,§12)。 + * + * 不做(§8):离线回溯重算、设备指纹、模型识别(v2.0,技术架构与模块.md 补技术项后排期)。 + * + * @author 造梦AI + */ +public interface AnomalyFilterService { + + /** + * 判定单条事件是否应从聚合中剔除(异常刷量兜底) + * + * 仅对「需计数维度」的事件(engagement + ad 计费)按 anonId/IP × gameId 滑动窗口计数; + * 其余事件(无 gameId、非计数维度)直接放行(返回 false,不计数)。 + * 计数到达阈值后,本次及之后的同维度事件均判为应剔除(返回 true)。 + * 异常容错:Redis 故障一律降级放行(返回 false)+ WARN,绝不阻断遥测主链。 + * + * @param envelope 单条信封(取 event / context.gameId / user.anonId) + * @param ip 请求来源 IP(IP 维度计数;为空时退化为仅 anonId 维度) + * @return true=应剔除(不进聚合);false=正常(进聚合) + */ + boolean shouldExclude(EnvelopeReqVO envelope, String ip); + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImpl.java new file mode 100644 index 00000000..4ea16d2c --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImpl.java @@ -0,0 +1,129 @@ +package cn.wanxiang.game.module.telemetry.service.quality; + +import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO; +import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import jakarta.annotation.Resource; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * 聚合侧异常剔除桩实现(§8,拍板 7=B 配套;Redis 滑动窗口计数,最小可粗暴) + * + * 计数模型(粗暴但够用):以「整点小时桶」做近似滑动窗口——key 含 epochHour,单维度每自然小时一个计数桶, + * 首次 INCR 即设 1 小时过期,计数 > 阈值判为异常。整点切换天然换桶(旧桶过期自动回收),实现零依赖、原子(Redis INCR)。 + * 维度 = (anonId | ip) × gameId × eventKind,anonId 与 IP 各独立计一份(任一超阈即剔除),挡「单 anonId 刷」与「单 IP 多 anonId 刷」两面。 + * + * 容错铁律(§12):任何 Redis 异常一律降级放行(返回 false)+ WARN,绝不让桩故障阻断遥测主链。 + * + * @author 造梦AI + */ +@Slf4j +@Service +public class AnomalyFilterServiceImpl implements AnomalyFilterService { + + /** + * 单维度单小时计数阈值(§8 推断初值 100 次/小时,可配置)。 + * 正常玩家单小时对同一游戏的 engagement/广告计费事件远低于此;阈值偏宽以零误伤真人,作变造刷量的粗暴上限。 + * 超过该值(严格大于)即判异常剔除——精细识别(设备指纹/模型)留 v2.0。 + */ + private static final long THRESHOLD_PER_HOUR = 100L; + + /** 计数桶 Redis Key 前缀(telemetry 异常剔除桩专用,与其它模块 key 段隔离) */ + private static final String KEY_PREFIX = "telemetry:anomaly:"; + + /** 计数桶过期秒数:2 小时(>1 小时窗口,覆盖整点切换边界,旧桶自动回收,避免内存堆积) */ + private static final long BUCKET_TTL_SECONDS = 2 * 60 * 60L; + + /** + * 需做计数判定的事件集合(§8 + §6.2 条件 3): + * - engagement 事件(game_play_end/like/favorite/share):quality 重算输入,刷这些会污染质量分; + * - ad 计费事件(ad_impression/ad_reward):变现计费,刷这些会污染 mock 台账(ad 反作弊接同一判定输出,§6.2 条件 3)。 + * 集合外事件(曝光/性能/创作漏斗等)不计数、直接放行——避免无意义计数与误伤。 + */ + private static final Set COUNTED_EVENTS = Set.of( + TelemetryEventEnum.GAME_PLAY_END.getEvent(), + TelemetryEventEnum.LIKE.getEvent(), + TelemetryEventEnum.FAVORITE.getEvent(), + TelemetryEventEnum.SHARE.getEvent(), + // ad 计费事件(§6.2 条件 3):判定输出须同时覆盖,供 ad 反作弊(owner=compliance)后续接入 + TelemetryEventEnum.AD_IMPRESSION.getEvent(), + TelemetryEventEnum.AD_REWARD.getEvent()); + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Override + public boolean shouldExclude(EnvelopeReqVO envelope, String ip) { + // 1) 前置过滤:仅对「计数维度」事件判定(engagement + ad 计费),其余直接放行不计数 + if (envelope == null || !COUNTED_EVENTS.contains(envelope.getEvent())) { + return false; + } + // 2) 必须落到具体 gameId 才有计数维度(无 gameId 无法定位刷量目标)→ 放行 + String gameId = envelope.getContext() == null ? null : envelope.getContext().getGameId(); + if (!StringUtils.hasText(gameId)) { + return false; + } + String anonId = envelope.getUser() == null ? null : envelope.getUser().getAnonId(); + // 3) anonId 与 IP 任一维度超阈即剔除(任一为空则跳过该维度计数) + long epochHour = Instant.ofEpochMilli(System.currentTimeMillis()).getEpochSecond() / 3600L; + boolean anonExceeded = StringUtils.hasText(anonId) + && incrAndCheck(buildKey("anon", anonId, gameId, envelope.getEvent(), epochHour)); + boolean ipExceeded = StringUtils.hasText(ip) + && incrAndCheck(buildKey("ip", ip, gameId, envelope.getEvent(), epochHour)); + boolean exclude = anonExceeded || ipExceeded; + if (exclude) { + // 剔除原因 + 计数维度可审计(中文日志,§8 要求) + log.warn("[shouldExclude] 异常刷量剔除:超 {}次/小时阈值,gameId={}, event={}, anonId={}, ip={}, anonExceeded={}, ipExceeded={}", + THRESHOLD_PER_HOUR, gameId, envelope.getEvent(), anonId, ip, anonExceeded, ipExceeded); + } + return exclude; + } + + /** + * 对单维度计数桶 +1 并判定是否超阈值(容错:Redis 异常降级放行,§12) + * + * @param key 计数桶 Redis Key + * @return true=已超阈值应剔除;false=未超阈值 / Redis 故障降级放行 + */ + private boolean incrAndCheck(String key) { + try { + // Redis INCR 原子自增(key 不存在则从 0 起算返回 1) + Long count = stringRedisTemplate.opsForValue().increment(key); + if (count == null) { + // 理论不可达(INCR 必返回值);保守降级放行 + return false; + } + // 首次创建该桶时设过期(count==1 即本小时首条),避免 key 永驻;后续不重复设以保留滑动语义 + if (count == 1L) { + stringRedisTemplate.expire(key, BUCKET_TTL_SECONDS, TimeUnit.SECONDS); + } + // 严格大于阈值才剔除(第 101 条起剔除,前 100 条正常计入聚合) + return count > THRESHOLD_PER_HOUR; + } catch (Exception ex) { + // 桩失效不可阻断遥测主链:Redis 不可用一律降级放行 + WARN(§12 边界失败路径) + log.warn("[incrAndCheck] 剔除桩 Redis 计数失败,降级放行 key={}", key, ex); + return false; + } + } + + /** + * 组装计数桶 Key:前缀 + 维度类型 + 维度值 + gameId + 事件名 + 整点小时桶 + * + * @param dim 维度类型(anon / ip) + * @param dimVal 维度值(anonId 或 IP) + * @param gameId 游戏 ID + * @param event 事件名 + * @param epochHour 整点小时桶(epochSecond/3600) + * @return Redis Key + */ + private static String buildKey(String dim, String dimVal, String gameId, String event, long epochHour) { + return KEY_PREFIX + dim + ':' + dimVal + ':' + gameId + ':' + event + ':' + epochHour; + } + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java index 11faaeb4..fcf421d5 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java @@ -8,6 +8,7 @@ import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO; import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper; import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper; import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum; +import cn.wanxiang.game.module.telemetry.service.quality.AnomalyFilterService; import cn.wanxiang.game.module.feed.api.FeedApi; import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -54,6 +55,8 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { private GameStatMapper gameStatMapper; @Mock private FeedApi feedApi; // §3.5 算分回灌 seam + @Mock + private AnomalyFilterService anomalyFilterService; // §8 聚合侧异常剔除桩(默认 false=不剔除,保留既有聚合行为断言) // ============================== ingestBatch 批量上限 ============================== @@ -202,6 +205,34 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { verify(telemetryEventMapper).updateById(any(TelemetryEventDO.class)); } + // ============================== 聚合侧异常剔除桩闸门(§8,拍板7=B 配套)============================== + + @Test + void testIngestOne_anomalyExcluded_rawPersistedButNotAggregated() { + // 剔除桩命中(§8):engagement 事件被判异常刷量 → 原始事件【照常落库】+ 置已聚合,但【不进聚合、不触发 quality 重算】。 + // 锁死规约:剔除只挡聚合,不挡原始保真(供未来回溯/审计);ad 反作弊接同一判定输出(§6.2 条件3)。 + Map props = new HashMap<>(); + props.put("completed", true); + props.put("duration_ms", 5000); + EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props); + when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); + // 桩判定:剔除(任意 IP——单测无请求上下文 IP 为 null,桩按仅 anonId 维度,结果由本 stub 强制为 true) + when(anomalyFilterService.shouldExclude(eq(env), any())).thenReturn(true); + + EventBatchResultVO result = eventIngestService.ingestBatch(batchOf(env), 99L); + + // 仍计 accepted(已受理),原始事件真插入发生 + assertEquals(1, result.getAccepted()); + verify(telemetryEventMapper).insert(any(TelemetryEventDO.class)); + // 置已聚合发生(process_status=1)——剔除事件标记已处理,不被聚合链再拾取 + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(TelemetryEventDO.class); + verify(telemetryEventMapper).updateById(statusCaptor.capture()); + assertEquals(EventProcessStatusEnum.AGGREGATED.getStatus(), statusCaptor.getValue().getProcessStatus()); + // 关键断言:不进聚合(GameStatMapper 零交互)+ 不触发 quality 重算/回灌(feed 零交互) + verifyNoInteractions(gameStatMapper); + verifyNoInteractions(feedApi); + } + // ============================== props JSON 序列化回归守卫(B2 实测 bug)============================== @Test diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImplTest.java new file mode 100644 index 00000000..7eed5cc5 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/quality/AnomalyFilterServiceImplTest.java @@ -0,0 +1,166 @@ +package cn.wanxiang.game.module.telemetry.service.quality; + +import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link AnomalyFilterServiceImpl} 单元测试(纯 Mockito,不依赖真实 Redis) + * + * 覆盖 §8 聚合侧异常剔除桩的两类关键路径: + * - 计数 ≤ 阈值放行 / 计数 > 阈值剔除(含 ad 计费事件覆盖,§6.2 条件3); + * - 非计数维度事件(曝光/无 gameId)直接放行不计数; + * - Redis 故障降级放行(返回 false)——桩失效不可阻断遥测主链(§12 铁律)。 + * + * @author 造梦AI + */ +class AnomalyFilterServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private AnomalyFilterServiceImpl anomalyFilterService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + @Mock + private ValueOperations valueOperations; + + private static final long THRESHOLD = 100L; // 与实现常量 THRESHOLD_PER_HOUR 一致 + + // ============================== 计数未超阈值:放行 ============================== + + @Test + void testShouldExclude_underThreshold_pass() { + // engagement 事件(like)带 anonId + gameId,INCR 返回 50(≤100)→ 不剔除 + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(50L); + + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("like", "anon-x", "1024"), "1.2.3.4"); + + assertFalse(exclude, "计数未超阈值应放行"); + } + + @Test + void testShouldExclude_firstHitSetsExpire() { + // 本小时首条(INCR 返回 1)→ 设过期,避免计数桶永驻 + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(1L); + + anomalyFilterService.shouldExclude(engagementEnvelope("share", "anon-x", "1024"), null); + + // 首条对 anon 维度设过期(IP 为空,仅 anon 维度计数) + verify(stringRedisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.SECONDS)); + } + + // ============================== 计数超阈值:剔除 ============================== + + @Test + void testShouldExclude_overThreshold_excluded() { + // engagement 事件 INCR 返回 101(>100)→ 剔除(严格大于阈值) + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(THRESHOLD + 1); + + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("like", "anon-x", "1024"), "1.2.3.4"); + + assertTrue(exclude, "计数超阈值应剔除"); + } + + @Test + void testShouldExclude_adBillingEventCovered() { + // §6.2 条件3:ad 计费事件(ad_impression)须同样纳入计数判定——超阈值剔除(供 ad 反作弊接同一判定输出) + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(THRESHOLD + 5); + + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("ad_impression", "anon-x", "1024"), "1.2.3.4"); + + assertTrue(exclude, "ad 计费事件应被剔除判定覆盖(§6.2 条件3)"); + } + + // ============================== 非计数维度:直接放行不计数 ============================== + + @Test + void testShouldExclude_nonCountedEvent_passWithoutCounting() { + // 曝光事件(game_impression,非 engagement 非 ad 计费)→ 直接放行,不触碰 Redis(零计数) + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("game_impression", "anon-x", "1024"), "1.2.3.4"); + + assertFalse(exclude); + verify(stringRedisTemplate, never()).opsForValue(); // 非计数维度不计数 + } + + @Test + void testShouldExclude_noGameId_pass() { + // 计数维度事件但无 gameId(无法定位刷量目标)→ 放行不计数 + EnvelopeReqVO env = engagementEnvelope("like", "anon-x", null); + boolean exclude = anomalyFilterService.shouldExclude(env, "1.2.3.4"); + + assertFalse(exclude); + verify(stringRedisTemplate, never()).opsForValue(); + } + + @Test + void testShouldExclude_nullEnvelope_pass() { + // 空信封防御:直接放行 + assertFalse(anomalyFilterService.shouldExclude(null, "1.2.3.4")); + } + + // ============================== Redis 故障:降级放行(§12 铁律)============================== + + @Test + void testShouldExclude_redisFailure_degradePass() { + // Redis 不可用(INCR 抛异常)→ 一律降级放行(返回 false),桩失效不阻断遥测主链 + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenThrow(new RuntimeException("Redis connection refused")); + + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("like", "anon-x", "1024"), "1.2.3.4"); + + assertFalse(exclude, "Redis 故障必须降级放行,不得剔除/抛错"); + } + + @Test + void testShouldExclude_ipDimensionExceeds_excluded() { + // 仅 IP 维度超阈(anonId 为空,仅 IP 计数)→ 任一维度超阈即剔除 + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(THRESHOLD + 1); + + boolean exclude = anomalyFilterService.shouldExclude(engagementEnvelope("like", null, "1024"), "9.9.9.9"); + + assertTrue(exclude, "IP 维度超阈应剔除"); + } + + // ============================== 测试夹具 ============================== + + /** + * 构造一条计数维度信封:event + anonId(可空)+ gameId(可空),用于剔除判定 + */ + private static EnvelopeReqVO engagementEnvelope(String event, String anonId, String gameId) { + EnvelopeReqVO env = new EnvelopeReqVO(); + env.setEvent(event); + if (anonId != null) { + EnvelopeReqVO.UserVO user = new EnvelopeReqVO.UserVO(); + user.setAnonId(anonId); + env.setUser(user); + } + if (gameId != null) { + EnvelopeReqVO.ContextVO ctx = new EnvelopeReqVO.ContextVO(); + ctx.setGameId(gameId); + env.setContext(ctx); + } + return env; + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApi.java b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApi.java new file mode 100644 index 00000000..ec9629ef --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApi.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.system.api.passport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.system.api.passport.dto.PlayerRespDTO; +import cn.iocoder.yudao.module.system.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * RPC 服务 - 玩家身份 API(passport 对外创作者白名单校验契约,HJ-PASSPORT-EXEC-001 §6.3) + * + * 用途:创作/发布 5 个写入口(studio 建草稿/提交生成、aigc 直发生成、project 创建/提交发布)在 Service 入层调 + * {@link #validateCreator} 做 A2 白名单可信边界校验——前端守卫挡不住直连 API,必须后端校验(拍板4)。 + * + * 设计纪律:跨模块只依赖 system 的 -api(本接口 + PlayerRespDTO),上游 game-module-{studio,aigc,project} 增本依赖。 + * 单体内由 system-server 的 {@code PlayerApiImpl}(@RestController @Primary) 就地解析(同 OAuth2TokenApiImpl 范式,M1 装配教训); + * 拆微服务后按 {@link ApiConstants#NAME}=system-server 走真实 Feign。 + * + * @author 造梦AI + */ +@FeignClient(name = ApiConstants.NAME) // 与 @Primary 本地实现配套:单体内本地胜出,拆微服务走 Feign +@Tag(name = "RPC 服务 - 玩家身份") +public interface PlayerApi { + + String PREFIX = ApiConstants.PREFIX + "/passport/player"; + + /** + * 校验玩家是否为白名单创作者(拍板4 A2 名单可信边界) + * + * 语义(主 agent 裁决,§6.3 + §16-4): + * 1) game_player 查无此行 且 mock-enable=true → 放行并记 WARN 中文日志(staging mock 身份豁免,保批跑零中断); + * 2) game_player 查无此行 且 mock-enable=false → 拒(抛 PLAYER_NOT_EXISTS / PLAYER_CREATE_NOT_IN_WHITELIST,1-002-090 段); + * 3) 行存在 → 按 creator_flag 判定:1 放行;0 拒(抛 PLAYER_CREATE_NOT_IN_WHITELIST);status=1 禁用直接拒(PLAYER_IS_DISABLED)。 + * 校验失败由 server 侧抛对应错误码(CommonResult 承载 code≠0),调用方 .getCheckedData() / .checkError() 触发异常向上传播。 + * + * @param userId 当前登录玩家用户 ID(由 token 解析,非前端入参) + * @return CommonResult:data=true 表示校验通过(放行);校验失败时 code≠0 + */ + @GetMapping(PREFIX + "/validate-creator") + @Operation(summary = "校验玩家是否为白名单创作者") + @Parameter(name = "userId", description = "玩家用户 ID", required = true, example = "1024") + CommonResult validateCreator(@RequestParam("userId") Long userId); + + /** + * 读玩家身份摘要(最小字段,跨模块按需取 creatorFlag/status) + * + * @param userId 玩家用户 ID + * @return CommonResult:玩家不存在时 data=null(调用方判空,不抛) + */ + @GetMapping(PREFIX + "/get") + @Operation(summary = "读玩家身份摘要") + @Parameter(name = "userId", description = "玩家用户 ID", required = true, example = "1024") + CommonResult getPlayer(@RequestParam("userId") Long userId); + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/dto/PlayerRespDTO.java b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/dto/PlayerRespDTO.java new file mode 100644 index 00000000..e8464b00 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/passport/dto/PlayerRespDTO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.system.api.passport.dto; + +import lombok.Data; + +/** + * 玩家身份 RPC 响应 DTO(→ 跨模块创作者白名单校验,HJ-PASSPORT-EXEC-001 §6.3) + * + * 仅承载创作/发布写入口校验所需的最小身份字段,禁带手机号等敏感信息(跨模块最小暴露面)。 + * 上游 game-module-{studio,aigc,project} 据 creatorFlag 判定是否放行创作动作(信任边界在后端,非前端守卫)。 + * + * @author 造梦AI + */ +@Data +public class PlayerRespDTO { + + /** + * 玩家编号(即 OAuth2 token 的 userId,userType=MEMBER) + */ + private Long userId; + + /** + * 状态:0正常 1禁用(对齐 CommonStatusEnum) + */ + private Integer status; + + /** + * 创作者白名单位:0玩家 1创作者(A2 种子名单,拍板4) + */ + private Integer creatorFlag; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index c89a5dcd..389be43f 100644 --- a/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/game-cloud/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -168,4 +168,25 @@ public interface ErrorCodeConstants { // ========== 站内信发送 1-002-028-000 ========== ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失"); + // ========================================================================================== + // 造梦AI passport 扩展段(真实鉴权与匿名玩家,HJ-PASSPORT-EXEC-001 §3.3) + // 与上游段隔离:账号 owner=system 基座(需求模块映射),故落 system 段;为避开上游 yudao 增长区 + // (上游现最大 1-002-028),取远段 1-002-090(玩家身份)/ 1-002-091(邀请码),两段互不交叉。 + // 落点契约同步:contracts/api-schemas/passport.yaml 头部错误码段声明逐字对齐。 + // ========================================================================================== + + // ========== passport 玩家身份 1-002-090-000 ========== + ErrorCode PLAYER_MOBILE_ALREADY_REGISTERED = new ErrorCode(1_002_090_000, "该手机号已注册,请直接使用验证码登录"); // invite-register 路径专用(sms-login 走防枚举静默转登录,不抛此码) + ErrorCode PLAYER_NOT_EXISTS = new ErrorCode(1_002_090_001, "玩家不存在"); + ErrorCode PLAYER_IS_DISABLED = new ErrorCode(1_002_090_002, "该账号已被禁用"); + ErrorCode PLAYER_CREATE_NOT_IN_WHITELIST = new ErrorCode(1_002_090_003, "创作功能限内测白名单,当前账号暂无创作权限"); // 拍板4:creator_flag 白名单校验未过(PlayerApi#validateCreator) + + // ========== passport 邀请码 1-002-091-000 ========== + ErrorCode INVITE_CODE_NOT_EXISTS = new ErrorCode(1_002_091_000, "邀请码无效"); // 码不存在(与已停用/已过期/已耗尽区分,便于运营定位) + ErrorCode INVITE_CODE_DISABLED = new ErrorCode(1_002_091_001, "邀请码已停用"); + ErrorCode INVITE_CODE_EXPIRED = new ErrorCode(1_002_091_002, "邀请码已过期"); + ErrorCode INVITE_CODE_EXHAUSTED = new ErrorCode(1_002_091_003, "邀请码已被领完"); // used_count 达 max_uses,或并发核销条件更新 affected=0 + ErrorCode INVITE_REGISTER_DISABLED = new ErrorCode(1_002_091_004, "邀请码注册通道已关闭,请使用手机号验证码登录"); // §5.4 报备后旁路退役开关 wanxiang.passport.invite-register-enabled=false + ErrorCode INVITE_CODE_GENERATE_CONFLICT = new ErrorCode(1_002_091_005, "邀请码生成冲突,请重试"); // uk_code 多次冲突重试仍失败兜底(极低概率) + } diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/pom.xml b/game-cloud/yudao-module-system/yudao-module-system-server/pom.xml index fc1c451c..88595fc3 100644 --- a/game-cloud/yudao-module-system/yudao-module-system-server/pom.xml +++ b/game-cloud/yudao-module-system/yudao-module-system-server/pom.xml @@ -97,11 +97,11 @@ yudao-spring-boot-starter-mq - - - - - + + + cn.iocoder.cloud + yudao-spring-boot-starter-protection + diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApiImpl.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApiImpl.java new file mode 100644 index 00000000..1a00e8e1 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/passport/PlayerApiImpl.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.system.api.passport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.system.api.passport.dto.PlayerRespDTO; +import cn.iocoder.yudao.module.system.service.passport.PassportService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 玩家身份 API 实现(passport 对外创作者白名单校验,HJ-PASSPORT-EXEC-001 §6.3) + * + * @RestController + @Primary:与 PlayerApi(@FeignClient) 同名 Bean 冲突时本地实现优先(M1 装配教训, + * 同 OAuth2TokenApiImpl 范式)——单体内同进程调用就地解析,拆微服务后走真实 Feign。 + * 事务原子:上游(5 个创作写入口)在其本地 @Transactional 内调用本 @Primary bean, + * validateCreator 抛错(CommonResult code≠0 + 服务端 ServiceException)则上游整体回滚。 + * 仅委托 {@link PassportService},不写业务逻辑(白名单三分支 + mock 豁免在 Service)。 + * + * @author 造梦AI + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析) +@Validated +public class PlayerApiImpl implements PlayerApi { + + @Resource + private PassportService passportService; + + @Override + public CommonResult validateCreator(Long userId) { + // 校验不过由 Service 抛 1-002-090 段 ServiceException(被 GlobalExceptionHandler 转 CommonResult code≠0), + // 调用方 .getCheckedData()/.checkError() 触发异常向上传播,拒绝创作动作。 + passportService.validateCreator(userId); + return success(Boolean.TRUE); + } + + @Override + public CommonResult getPlayer(Long userId) { + return success(passportService.getPlayer(userId)); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminInviteCodeController.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminInviteCodeController.java new file mode 100644 index 00000000..68768bea --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminInviteCodeController.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeDisableReqVO; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeGenerateReqVO; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodePageReqVO; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeRespVO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.InviteCodeDO; +import cn.iocoder.yudao.module.system.service.passport.InviteCodeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 管理后台(game-admin)- passport 邀请码管理控制器(§5.2/§5.3) + * + * 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。 + * 鉴权:RBAC,@PreAuthorize 权限位 system:passport-invite:*(信任边界在后端,不依赖前端)。 + * 裁决(§5.3):给 admin-api 端点不建 game-admin 页面,运营经 Knife4j(/doc.html) 或脚本调用。 + * + * @author 造梦AI + */ +@Tag(name = "管理后台 - 邀请码管理") +@RestController +@RequestMapping("/passport/invite-code") +@Validated +public class AdminInviteCodeController { + + @Resource + private InviteCodeService inviteCodeService; + + @PostMapping("/generate") + @Operation(summary = "批量生成邀请码", description = "随机大写字母数字、避易混淆字符,uk_code 冲突重试") + @PreAuthorize("@ss.hasPermission('system:passport-invite:create')") + public CommonResult> generate(@Valid @RequestBody InviteCodeGenerateReqVO reqVO) { + List codes = inviteCodeService.generateInviteCodes(reqVO); + return success(BeanUtils.toBean(codes, InviteCodeRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "邀请码分页查询", description = "按 batchNo/status 过滤,含 used_count 核销进度") + @PreAuthorize("@ss.hasPermission('system:passport-invite:query')") + public CommonResult> getPage(@Valid InviteCodePageReqVO reqVO) { + PageResult pageResult = inviteCodeService.getInviteCodePage(reqVO); + return success(BeanUtils.toBean(pageResult, InviteCodeRespVO.class)); + } + + @PutMapping("/disable") + @Operation(summary = "单码停用/退役", description = "status 置 1,单码退役") + @PreAuthorize("@ss.hasPermission('system:passport-invite:update')") + public CommonResult disable(@Valid @RequestBody InviteCodeDisableReqVO reqVO) { + inviteCodeService.disableInviteCode(reqVO.getId()); + return success(Boolean.TRUE); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminPassportPlayerController.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminPassportPlayerController.java new file mode 100644 index 00000000..012585bf --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/AdminPassportPlayerController.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.PlayerSetCreatorReqVO; +import cn.iocoder.yudao.module.system.service.passport.PassportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 管理后台(game-admin)- passport 玩家创作者白名单控制器(拍板4 A2 名单运营入口,§5.2) + * + * 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。 + * 鉴权:RBAC,@PreAuthorize 权限位 system:passport-player:update(置位后该玩家方可走创作/发布写入口, + * 后端 PlayerApi#validateCreator 校验,信任边界在后端)。 + * + * @author 造梦AI + */ +@Tag(name = "管理后台 - 玩家创作者白名单") +@RestController +@RequestMapping("/passport/player") +@Validated +public class AdminPassportPlayerController { + + @Resource + private PassportService passportService; + + @PutMapping("/set-creator") + @Operation(summary = "玩家创作者白名单置位", description = "置位/取消 creator_flag(A2 种子白名单,拍板4)") + @PreAuthorize("@ss.hasPermission('system:passport-player:update')") + public CommonResult setCreator(@Valid @RequestBody PlayerSetCreatorReqVO reqVO) { + passportService.setCreatorFlag(reqVO.getUserId(), reqVO.getCreatorFlag()); + return success(Boolean.TRUE); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeDisableReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeDisableReqVO.java new file mode 100644 index 00000000..e014201a --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeDisableReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 单码停用/退役 Request VO(对齐契约 InviteCodeDisableReqVO) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 单码停用 Request VO") +@Data +public class InviteCodeDisableReqVO { + + @Schema(description = "邀请码编号(停用即 status=1,单码退役)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "邀请码编号不能为空") + private Long id; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeGenerateReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeGenerateReqVO.java new file mode 100644 index 00000000..c99a1119 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeGenerateReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 批量生成邀请码 Request VO(对齐契约 InviteCodeGenerateReqVO) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 批量生成邀请码 Request VO") +@Data +public class InviteCodeGenerateReqVO { + + @Schema(description = "本批生成数量(≤100)", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + @NotNull(message = "生成数量不能为空") + @Min(value = 1, message = "生成数量最小为 1") + @Max(value = 100, message = "生成数量最大为 100") // 契约硬约束:单批 ≤100,防一次性海量生成 + private Integer count; + + @Schema(description = "单码最大核销次数(限量)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "单码最大核销次数不能为空") + @Min(value = 1, message = "单码最大核销次数最小为 1") + private Integer maxUses; + + @Schema(description = "有效期(可选;不传=不过期)", example = "2026-12-31 23:59:59") + private LocalDateTime expireTime; + + @Schema(description = "批次号(可选;运营按渠道发放归因,如 batch-xhs-01)", example = "batch-xhs-01") + @Size(max = 32, message = "批次号不能超过 32 个字符") + private String batchNo; + + @Schema(description = "备注(可选;发放对象/用途)", example = "小红书首批种子") + @Size(max = 255, message = "备注不能超过 255 个字符") + private String remark; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodePageReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodePageReqVO.java new file mode 100644 index 00000000..30ab82bf --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodePageReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 邀请码分页查询 Request VO(对齐契约 invite-code/page 入参) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 邀请码分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class InviteCodePageReqVO extends PageParam { + + @Schema(description = "批次号过滤", example = "batch-xhs-01") + private String batchNo; + + @Schema(description = "状态过滤:0启用 1停用", example = "0") + private Integer status; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeRespVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeRespVO.java new file mode 100644 index 00000000..301ae924 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/InviteCodeRespVO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 邀请码响应 VO(对齐契约 InviteCodeRespVO;生成返回 / 分页项,含核销进度) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 邀请码响应 VO") +@Data +public class InviteCodeRespVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "邀请码/口令", example = "ABCD2345") + private String code; + + @Schema(description = "批次号", example = "batch-xhs-01") + private String batchNo; + + @Schema(description = "最大核销次数", example = "1") + private Integer maxUses; + + @Schema(description = "已核销次数(核销进度)", example = "0") + private Integer usedCount; + + @Schema(description = "有效期(NULL=不过期)", example = "2026-12-31 23:59:59") + private LocalDateTime expireTime; + + @Schema(description = "状态:0启用 1停用", example = "0") + private Integer status; + + @Schema(description = "备注", example = "小红书首批种子") + private String remark; + + @Schema(description = "创建时间", example = "2026-06-10 12:00:00") + private LocalDateTime createTime; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/PlayerSetCreatorReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/PlayerSetCreatorReqVO.java new file mode 100644 index 00000000..3111ce8d --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/passport/vo/PlayerSetCreatorReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.system.controller.admin.passport.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 玩家创作者白名单置位 Request VO(对齐契约 PlayerSetCreatorReqVO;A2 名单入口,拍板4) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 玩家创作者白名单置位 Request VO") +@Data +public class PlayerSetCreatorReqVO { + + @Schema(description = "玩家编号(game_player.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "玩家编号不能为空") + private Long userId; + + @Schema(description = "创作者白名单位:0取消 1置位(A2 名单,拍板4)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "创作者白名单位不能为空") + @InEnum(value = CommonStatusEnum.class, message = "创作者白名单位必须是 0 或 1") // 复用 CommonStatusEnum(0/1) 约束取值,与 creator_flag 0/1 语义对齐 + private Integer creatorFlag; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/AppPassportController.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/AppPassportController.java new file mode 100644 index 00000000..53edc929 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/AppPassportController.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.system.controller.app.passport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; +import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; +import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.InviteRegisterReqVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.LoginRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.PlayerMeRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.SendSmsCodeReqVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.SmsLoginReqVO; +import cn.iocoder.yudao.module.system.service.passport.PassportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import java.util.concurrent.TimeUnit; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 产品端(game-studio)- passport 鉴权控制器(真实鉴权与匿名玩家,HJ-PASSPORT-EXEC-001 §5.1) + * + * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(WebProperties appApi=("/app-api","**.controller.app.**"))。 + * 鉴权口径:send-sms-code / sms-login / invite-register 三个 @PermitAll(免登);me 走登录态(不加 @PermitAll,未登录 401)。 + * 安全基线:响应不区分手机号是否已注册(防枚举);手机号脱敏、token 不落日志(验收5)。 + * + * @author 造梦AI + */ +@Tag(name = "用户 App - 鉴权 passport") +@RestController +@RequestMapping("/passport") +@Validated +public class AppPassportController { + + /** + * 发码端点 IP 限频阈值:10 次/小时/IP(R6 §7.2,推断初值;@RateLimiter 注解需编译期常量, + * 故此处与 Service 层 IP 计数上限 wanxiang.passport.sms-ip-limit-per-hour 并行,双闸兜底)。 + */ + private static final int SMS_RATE_LIMIT_COUNT = 10; + + @Resource + private PassportService passportService; + + @PostMapping("/send-sms-code") + @PermitAll // 免登:种子期 Debug 渠道(码落 system_sms_code 表,运营后台查码) + @Operation(summary = "发送登录短信验证码", description = "场景固定 MEMBER_LOGIN;响应不区分手机号是否已注册(防枚举)") + // R6 安全前置(§7.2):IP 维度限频 10 次/小时/IP,配合 Service 层 IP 每日/每小时 Redis 计数双闸;切真实渠道前另补图形验证码 + @RateLimiter(time = 1, timeUnit = TimeUnit.HOURS, count = SMS_RATE_LIMIT_COUNT, + keyResolver = ClientIpRateLimiterKeyResolver.class) + public CommonResult sendSmsCode(@Valid @RequestBody SendSmsCodeReqVO reqVO) { + // 取真实客户端 IP(IP 维度频控 + 落 system_sms_code.create_ip) + passportService.sendSmsCode(reqVO.getMobile(), ServletUtils.getClientIP()); + return success(Boolean.TRUE); + } + + @PostMapping("/sms-login") + @PermitAll // 免登:已注册登录 / 未注册自动注册一体(验证码即占有证明,拍板3) + @Operation(summary = "手机号验证码登录", description = "已注册→登录;未注册→自动注册一体(register_channel=sms)") + public CommonResult smsLogin(@Valid @RequestBody SmsLoginReqVO reqVO) { + return success(passportService.smsLogin(reqVO, ServletUtils.getClientIP())); + } + + @PostMapping("/invite-register") + @PermitAll // 免登:报备前陌生玩家旁路(受退役开关管控,§5.4) + @Operation(summary = "邀请码受限激活注册", description = "手机号+邀请码,零短信依赖;受退役开关 wanxiang.passport.invite-register-enabled 管控") + public CommonResult inviteRegister(@Valid @RequestBody InviteRegisterReqVO reqVO) { + return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP())); + } + + @GetMapping("/me") + @Operation(summary = "当前登录玩家信息", description = "登录态必需(无 @PermitAll,未登录 401);手机号脱敏返回") + public CommonResult getPlayerMe() { + // 登录态:从 token 解析当前玩家 ID(非前端入参,保证数据边界) + Long userId = SecurityFrameworkUtils.getLoginUserId(); + return success(passportService.getPlayerMe(userId)); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/InviteRegisterReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/InviteRegisterReqVO.java new file mode 100644 index 00000000..b75de561 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/InviteRegisterReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.system.controller.app.passport.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 邀请码受限激活注册 Request VO(对齐契约 InviteRegisterReqVO) + * + * 报备前陌生玩家旁路:mobile+inviteCode 注册(register_channel=invite),零短信依赖(拍板1C)。 + * 受退役开关 wanxiang.passport.invite-register-enabled 管控(§5.4)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 邀请码受限激活注册 Request VO") +@Data +public class InviteRegisterReqVO { + + @Schema(description = "手机号(登录主键)", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800000000") + @Mobile + @NotEmpty(message = "手机号不能为空") + private String mobile; + + @Schema(description = "邀请码", requiredMode = Schema.RequiredMode.REQUIRED, example = "ABCD2345") + @NotEmpty(message = "邀请码不能为空") + @Size(max = 16, message = "邀请码不能超过 16 个字符") + private String inviteCode; + + @Schema(description = "客户端 anonId(可选;同 sms-login 口径,落 first_anon_id)", example = "anon-xyz") + @Size(max = 64, message = "anonId 不能超过 64 个字符") + private String anonId; + + @Schema(description = "昵称(可选;不传则默认生成)", example = "新玩家") + @Size(max = 30, message = "昵称不能超过 30 个字符") + private String nickname; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/LoginRespVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/LoginRespVO.java new file mode 100644 index 00000000..04ab0123 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/LoginRespVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.system.controller.app.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 登录/注册成功响应 VO(对齐契约 LoginRespVO;sms-login 与 invite-register 共用) + * + * 安全基线:token 不落日志(验收5),手机号不在此返回。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 登录/注册成功响应 VO") +@Data +public class LoginRespVO { + + @Schema(description = "玩家编号(即 OAuth2 token 的 userId,userType=MEMBER)", example = "1024") + private Long userId; + + @Schema(description = "访问令牌(Bearer);不得落日志", example = "happy") + private String accessToken; + + @Schema(description = "刷新令牌(refresh 由 OAuth2 client 配置承载,§3.4)", example = "nice") + private String refreshToken; + + @Schema(description = "访问令牌过期时间戳(毫秒)", example = "1700000000000") + private Long expiresTime; + + @Schema(description = "昵称", example = "新玩家") + private String nickname; + + @Schema(description = "创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)", example = "0") + private Integer creatorFlag; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/PlayerMeRespVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/PlayerMeRespVO.java new file mode 100644 index 00000000..0a225bd5 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/PlayerMeRespVO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.system.controller.app.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 当前登录玩家信息 VO(对齐契约 PlayerMeRespVO;前端守卫/白名单 UI 依据) + * + * 安全基线:手机号脱敏返回(如 138****0000),禁回明文。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 当前登录玩家信息 VO") +@Data +public class PlayerMeRespVO { + + @Schema(description = "玩家编号", example = "1024") + private Long userId; + + @Schema(description = "脱敏手机号(如 138****0000;安全基线,禁回明文)", example = "138****0000") + private String mobile; + + @Schema(description = "昵称", example = "新玩家") + private String nickname; + + @Schema(description = "头像 URL", example = "https://www.iocoder.cn/avatar.png") + private String avatar; + + @Schema(description = "创作者白名单位:0玩家 1创作者", example = "0") + private Integer creatorFlag; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SendSmsCodeReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SendSmsCodeReqVO.java new file mode 100644 index 00000000..e8049474 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SendSmsCodeReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.system.controller.app.passport.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * 发送登录短信验证码 Request VO(对齐契约 SendSmsCodeReqVO) + * + * 场景固定 SmsSceneEnum.MEMBER_LOGIN(1);响应不区分手机号是否已注册(防枚举,见 AppPassportController)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 发送登录短信验证码 Request VO") +@Data +public class SendSmsCodeReqVO { + + @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800000000") + @Mobile // 复用 yudao 既有手机号格式校验 + @NotEmpty(message = "手机号不能为空") + private String mobile; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SmsLoginReqVO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SmsLoginReqVO.java new file mode 100644 index 00000000..262d7ea0 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/passport/vo/SmsLoginReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.system.controller.app.passport.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 手机号验证码登录 Request VO(对齐契约 SmsLoginReqVO) + * + * 已注册→登录;未注册→自动注册(register_channel=sms)+ 登录一体(验证码即占有证明,拍板3 开放注册)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 手机号验证码登录 Request VO") +@Data +public class SmsLoginReqVO { + + @Schema(description = "手机号(登录主键)", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800000000") + @Mobile + @NotEmpty(message = "手机号不能为空") + private String mobile; + + @Schema(description = "短信验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotEmpty(message = "验证码不能为空") + private String code; + + @Schema(description = "客户端 anonId(可选;仅注册时落 first_anon_id 一次,匿名↔登录归因,拍板7=B)", example = "anon-xyz") + @Size(max = 64, message = "anonId 不能超过 64 个字符") + private String anonId; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/InviteCodeDO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/InviteCodeDO.java new file mode 100644 index 00000000..a641c230 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/InviteCodeDO.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.system.dal.dataobject.passport; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 邀请码 DO(对应表 game_invite_code,拍板1=C/拍板2 受限激活:生成/核销/限量/有效期/退役全生命周期) + * + * 字段严格对齐契约 contracts/db-schemas/V11.0.0__create_passport_player_invite.sql。 + * 报备前陌生玩家凭「手机号+邀请码」零短信注册;报备后 invite-register 走配置开关退役(§5.4)。 + * 核销语义见 {@code InviteCodeMapper#redeem}:条件更新原子累加(防并发超发),核销与建号同一本地事务。 + * 继承 {@link TenantBaseDO}:自动携带审计列 + tenant_id。 + * + * @author 造梦AI + */ +@TableName("game_invite_code") +@KeySequence("game_invite_code_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class InviteCodeDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 邀请码/口令(生成时随机大写字母数字,避易混淆字符 0O1I;uk_code 含 deleted/tenant_id 唯一) + */ + private String code; + /** + * 批次号(运营按渠道发放归因,如 batch-xhs-01) + */ + private String batchNo; + /** + * 限量:最大核销次数 + */ + private Integer maxUses; + /** + * 已核销次数(条件更新原子累加,见 InviteCodeMapper#redeem 核销语义) + */ + private Integer usedCount; + /** + * 有效期(NULL=不过期) + */ + private LocalDateTime expireTime; + /** + * 状态:0启用 1停用(停用=单码退役;全量退役走配置开关 §5.4) + */ + private Integer status; + /** + * 备注(发放对象/用途) + */ + private String remark; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/PlayerDO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/PlayerDO.java new file mode 100644 index 00000000..bb223a47 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/passport/PlayerDO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.system.dal.dataobject.passport; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 玩家/创作者最小身份 DO(对应表 game_player,system passport 扩展,方案乙) + * + * 字段严格对齐契约 contracts/db-schemas/V11.0.0__create_passport_player_invite.sql。 + * id 即 OAuth2 token 的 userId(userType=MEMBER(1)),登录主键=mobile(uk_mobile 含 deleted/tenant_id)。 + * 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。 + * 拍板6:real_name/id_card_no 仅预留(AES 密文,应用层加密),提现前才收集,本件不写入。 + * + * @author 造梦AI + */ +@TableName("game_player") +@KeySequence("game_player_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class PlayerDO extends TenantBaseDO { + + /** + * 玩家编号(即 OAuth2 token 的 userId,userType=MEMBER) + */ + @TableId + private Long id; + /** + * 手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文 + uk_mobile 唯一键兜底并发重复注册) + */ + private String mobile; + /** + * 昵称(注册默认生成,可改) + */ + private String nickname; + /** + * 头像 URL + */ + private String avatar; + /** + * 状态:0正常 1禁用(对齐 CommonStatusEnum) + */ + private Integer status; + /** + * 创作者白名单位:0玩家 1创作者(A2 种子名单,拍板4;置位走 admin 端点 /admin-api/passport/player/set-creator) + */ + private Integer creatorFlag; + /** + * 真实姓名(AES 密文预留;提现前收集,拍板6,本件不写入) + */ + private String realName; + /** + * 身份证号(AES 密文预留;同上,本件不写入) + */ + private String idCardNo; + /** + * 注册通道:sms验证码 / invite邀请码旁路(漏斗归因) + */ + private String registerChannel; + /** + * 注册 IP(风控/审计) + */ + private String registerIp; + /** + * 核销的邀请码编号(invite 通道必填,关联 game_invite_code.id) + */ + private Long inviteCodeId; + /** + * 注册时携带的客户端 anonId(匿名↔登录归因衔接,评审版目标3;缺失落空串,归因降级不阻断登录) + */ + private String firstAnonId; + /** + * 最近登录时间 + */ + private LocalDateTime loginDate; + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/InviteCodeMapper.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/InviteCodeMapper.java new file mode 100644 index 00000000..6d47d601 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/InviteCodeMapper.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.system.dal.mysql.passport; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodePageReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.InviteCodeDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 邀请码 Mapper(生成/核销/限量/有效期/退役全生命周期) + * + * 核心是 {@link #redeem} 的条件更新原子核销(防并发超发),与仓内 AccountMapper#freezeForWithdraw 同范式: + * 把「未停用 + 未耗尽 + 未过期」充足校验下沉到 SQL 的 WHERE,避免「先查后扣」的并发竞态(TOCTOU)。 + * + * @author 造梦AI + */ +@Mapper +public interface InviteCodeMapper extends BaseMapperX { + + /** + * 按码值查邀请码(命中 uk_code;前置查询给精确错误码用) + * + * @param code 邀请码/口令 + * @return 邀请码 DO;不存在返回 null + */ + default InviteCodeDO selectByCode(String code) { + return selectOne(InviteCodeDO::getCode, code); + } + + /** + * 原子核销(防并发超发,§4.2 核销语义):used_count += 1,仅当「启用 且 未耗尽 且 未过期」 + * + * SQL 等价: + * UPDATE game_invite_code SET used_count = used_count + 1 + * WHERE id = ? AND status = 0 AND used_count < max_uses AND (expire_time IS NULL OR expire_time > NOW()) + * affected=0 即核销失败(耗尽/停用/过期,或并发抢核已被他人扣满)——调用方据「更新行数=0」抛 INVITE_CODE_EXHAUSTED, + * 建号事务整体回滚(无半成品 game_player 行)。前置 selectByCode 仅用于给停用/过期/不存在精确错误码, + * 条件更新做最终一致兜底(前置查询到核销之间的并发窗口由本 WHERE 封死)。 + * used_count < max_uses 用列对列比较(setSql 拼字段名,非用户输入,无注入面)。 + * + * @param id 邀请码编号(命中主键) + * @param now 当前时间(由 Service 传入,便于单测可控;与 expire_time 比较) + * @return 实际更新行数(1=核销成功;0=耗尽/停用/过期) + */ + default int redeem(@Param("id") Long id, @Param("now") LocalDateTime now) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .setSql("used_count = used_count + 1") // 原子累加(行锁内 +1) + .eq(InviteCodeDO::getId, id) // 命中主键 + .eq(InviteCodeDO::getStatus, 0) // 启用态才可核销(0=启用) + .apply("used_count < max_uses") // CAS:未耗尽才核销(列对列比较,字段名非用户输入) + .and(w -> w.isNull(InviteCodeDO::getExpireTime) + .or().gt(InviteCodeDO::getExpireTime, now)); // 未过期(NULL=不过期 OR 过期时间晚于现在) + return update(null, update); + } + + /** + * 单码停用/退役(status 置 1):wrapper 式只更 status 单列 + * + * @param id 邀请码编号 + * @return 实际更新行数(1=成功;0=码不存在) + */ + default int disableById(Long id) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .set(InviteCodeDO::getStatus, 1) + .eq(InviteCodeDO::getId, id); + return update(null, update); + } + + /** + * 邀请码分页查询(admin 端,按 batchNo/status 过滤,含 used_count 核销进度) + * + * @param reqVO 分页 + 可选 batchNo/status 过滤 + * @return 分页结果 + */ + default PageResult selectPage(InviteCodePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(InviteCodeDO::getBatchNo, reqVO.getBatchNo()) + .eqIfPresent(InviteCodeDO::getStatus, reqVO.getStatus()) + .orderByDesc(InviteCodeDO::getId)); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/PlayerMapper.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/PlayerMapper.java new file mode 100644 index 00000000..ce369258 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/passport/PlayerMapper.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.system.dal.mysql.passport; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.PlayerDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 玩家/创作者最小身份 Mapper(system passport 扩展) + * + * MyBatis-Plus 范式照仓内既有 game 模块 Mapper(ProjectMapper/AccountMapper):显式条件命中唯一键、禁裸 select*。 + * tenant_id 由 Yudao 多租户拦截器自动追加到 WHERE,不在此显式拼。 + * + * @author 造梦AI + */ +@Mapper +public interface PlayerMapper extends BaseMapperX { + + /** + * 按手机号查玩家(命中 uk_mobile;登录主键查询) + * + * @param mobile 手机号 + * @return 玩家 DO;不存在返回 null + */ + default PlayerDO selectByMobile(String mobile) { + return selectOne(PlayerDO::getMobile, mobile); + } + + /** + * 创作者白名单置位(拍板4 A2 名单运营动作):按 userId 更新 creator_flag + * + * 用 wrapper 式 update 只更 creator_flag 单列,不触碰其余字段(避免 BeanUtils 全量覆盖误清空)。 + * + * @param userId 玩家编号(game_player.id) + * @param creatorFlag 创作者白名单位:0取消 1置位 + * @return 实际更新行数(1=成功;0=玩家不存在) + */ + default int updateCreatorFlag(@Param("userId") Long userId, @Param("creatorFlag") Integer creatorFlag) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .set(PlayerDO::getCreatorFlag, creatorFlag) + .eq(PlayerDO::getId, userId); + return update(null, update); + } + + /** + * 刷新最近登录时间(登录成功后调用;wrapper 式只更 login_date 单列) + * + * @param userId 玩家编号 + * @param loginDate 登录时间 + * @return 实际更新行数 + */ + default int updateLoginDate(@Param("userId") Long userId, LocalDateTime loginDate) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .set(PlayerDO::getLoginDate, loginDate) + .eq(PlayerDO::getId, userId); + return update(null, update); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java new file mode 100644 index 00000000..38793a30 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.system.dal.redis.passport; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import jakarta.annotation.Resource; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; + +/** + * passport 发码 IP 频控计数 RedisDAO(R6 切渠道安全前置,§7.2) + * + * 在 passport 发码 Service 入层补「IP 每日/每小时上限」,不改上游 SmsCodeServiceImpl(避免 fork 侵入, + * 上游 :65-66 该项为 TODO)。以 Redis 自增计数 + 首次设过期实现滑动自然窗口(按日历日/小时分桶)。 + * KEY 格式:passport_sms_ip_day:{ip}:{yyyyMMdd} / passport_sms_ip_hour:{ip}:{yyyyMMddHH} + * VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。 + * + * @author 造梦AI + */ +@Repository +public class PassportSmsIpCounterRedisDAO { + + /** + * 日维度计数 KEY 前缀。 + */ + private static final String KEY_DAY = "passport_sms_ip_day:%s:%s"; + /** + * 小时维度计数 KEY 前缀。 + */ + private static final String KEY_HOUR = "passport_sms_ip_hour:%s:%s"; + + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH"); + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 自增当日计数并返回自增后的值(首次自增时设 1 天 TTL) + * + * @param ip 请求 IP + * @return 自增后的当日计数 + */ + public long incrDayAndGet(String ip) { + String key = String.format(KEY_DAY, ip, LocalDate.now().format(DAY_FMT)); + Long val = stringRedisTemplate.opsForValue().increment(key); + if (val != null && val == 1L) { + // 首次计数:设过期,避免 key 永久堆积(日桶 1 天) + stringRedisTemplate.expire(key, 1, TimeUnit.DAYS); + } + return val == null ? 0L : val; + } + + /** + * 自增当前小时计数并返回自增后的值(首次自增时设 1 小时 TTL) + * + * @param ip 请求 IP + * @return 自增后的当前小时计数 + */ + public long incrHourAndGet(String ip) { + String key = String.format(KEY_HOUR, ip, LocalDateTime.now().format(HOUR_FMT)); + Long val = stringRedisTemplate.opsForValue().increment(key); + if (val != null && val == 1L) { + // 首次计数:设过期(小时桶 1 小时) + stringRedisTemplate.expire(key, 1, TimeUnit.HOURS); + } + return val == null ? 0L : val; + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeService.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeService.java new file mode 100644 index 00000000..0bb15eed --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeService.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeGenerateReqVO; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodePageReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.InviteCodeDO; + +import java.util.List; + +/** + * 邀请码 Service 接口(生成/分页/停用 全生命周期,admin 运营 + invite-register 核销) + * + * @author 造梦AI + */ +public interface InviteCodeService { + + /** + * 批量生成邀请码(admin 端):随机大写字母数字、避易混淆字符,uk_code 冲突重试 + * + * @param reqVO count≤100 / maxUses / expireTime? / batchNo? / remark? + * @return 生成的邀请码 DO 列表(含码值) + */ + List generateInviteCodes(InviteCodeGenerateReqVO reqVO); + + /** + * 邀请码分页查询(admin 端,含核销进度) + * + * @param reqVO 分页 + batchNo/status 过滤 + * @return 分页结果 + */ + PageResult getInviteCodePage(InviteCodePageReqVO reqVO); + + /** + * 单码停用/退役(admin 端,status 置 1) + * + * @param id 邀请码编号 + */ + void disableInviteCode(Long id); + + /** + * 校验并原子核销邀请码(invite-register 调用,§4.2 核销语义) + * + * 前置查询给精确错误码(不存在/停用/过期/耗尽),条件更新做最终一致兜底(防并发超发)。 + * 必须与建号在同一本地事务内调用(由 PassportService 的 @Transactional 承载),核销失败抛错则整体回滚。 + * + * @param code 邀请码/口令 + * @return 核销成功的邀请码 ID(落 game_player.invite_code_id) + */ + Long validateAndRedeem(String code); + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImpl.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImpl.java new file mode 100644 index 00000000..fb9ca788 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImpl.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.hutool.core.util.RandomUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeGenerateReqVO; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodePageReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.InviteCodeDO; +import cn.iocoder.yudao.module.system.dal.mysql.passport.InviteCodeMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import lombok.extern.slf4j.Slf4j; + +import jakarta.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; + +/** + * 邀请码 Service 实现 + * + * 业务规则(生成限量/易混淆字符规避/uk_code 冲突重试 + 核销条件更新语义)全在此承载,可单测、可追溯。 + * + * @author 造梦AI + */ +@Service +@Slf4j +public class InviteCodeServiceImpl implements InviteCodeService { + + /** + * 邀请码长度(8 位大写字母数字,可用空间足够大、人工录入友好)。 + */ + private static final int CODE_LENGTH = 8; + + /** + * 邀请码可用字符集:大写字母 + 数字,去除易混淆字符 0/O/1/I(防运营/玩家手抄误读,契约约定)。 + */ + private static final String CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + /** + * uk_code 冲突重试次数上限(单码生成几乎不冲突,留 5 次兜底;超限抛 INVITE_CODE_GENERATE_CONFLICT)。 + */ + private static final int CODE_GEN_MAX_RETRY = 5; + + @Resource + private InviteCodeMapper inviteCodeMapper; + + @Override + public List generateInviteCodes(InviteCodeGenerateReqVO reqVO) { + // 数量上界由 VO @Max(100) 兜底,此处不再重复校验 + List result = new ArrayList<>(reqVO.getCount()); + for (int i = 0; i < reqVO.getCount(); i++) { + // 逐码生成 + uk_code 冲突重试:随机串重复概率极低,重试是并发/极端碰撞兜底 + InviteCodeDO saved = insertWithRetry(reqVO); + result.add(saved); + } + log.info("[generateInviteCodes] 批量生成邀请码完成 count={}, batchNo={}, maxUses={}", + reqVO.getCount(), reqVO.getBatchNo(), reqVO.getMaxUses()); + return result; + } + + /** + * 单码插入 + uk_code 冲突重试 + * + * @param reqVO 生成参数 + * @return 落库成功的邀请码 DO + */ + private InviteCodeDO insertWithRetry(InviteCodeGenerateReqVO reqVO) { + for (int attempt = 1; attempt <= CODE_GEN_MAX_RETRY; attempt++) { + InviteCodeDO codeDO = new InviteCodeDO(); + codeDO.setCode(randomCode()); + codeDO.setBatchNo(StringUtils.hasText(reqVO.getBatchNo()) ? reqVO.getBatchNo() : ""); + codeDO.setMaxUses(reqVO.getMaxUses()); + codeDO.setUsedCount(0); + codeDO.setExpireTime(reqVO.getExpireTime()); + codeDO.setStatus(0); // 启用 + codeDO.setRemark(StringUtils.hasText(reqVO.getRemark()) ? reqVO.getRemark() : ""); + try { + inviteCodeMapper.insert(codeDO); + return codeDO; + } catch (DuplicateKeyException e) { + // uk_code 唯一键冲突(极低概率):换码重试,记 WARN 便于观察碰撞率 + log.warn("[generateInviteCodes] 邀请码 code 冲突重试 attempt={}, code={}", attempt, codeDO.getCode()); + } + } + // 连续多次冲突仍失败(异常情况):抛错不静默吞,避免运营拿到缺码批次 + throw exception(INVITE_CODE_GENERATE_CONFLICT); + } + + /** + * 生成一个随机邀请码(大写字母数字,规避易混淆字符 0O1I) + * + * @return 长度 {@link #CODE_LENGTH} 的随机码 + */ + private static String randomCode() { + return RandomUtil.randomString(CODE_ALPHABET, CODE_LENGTH); + } + + @Override + public PageResult getInviteCodePage(InviteCodePageReqVO reqVO) { + return inviteCodeMapper.selectPage(reqVO); + } + + @Override + public void disableInviteCode(Long id) { + // 幂等:码不存在时 disableById 返回 0,抛 INVITE_CODE_NOT_EXISTS 让运营定位(重复停用已停用码无副作用) + int affected = inviteCodeMapper.disableById(id); + if (affected == 0) { + throw exception(INVITE_CODE_NOT_EXISTS); + } + log.info("[disableInviteCode] 邀请码停用 id={}", id); + } + + @Override + public Long validateAndRedeem(String code) { + // 1) 前置查询给精确错误码(不存在/停用/过期/耗尽)——便于玩家/运营定位失败原因 + InviteCodeDO codeDO = inviteCodeMapper.selectByCode(code); + if (codeDO == null) { + throw exception(INVITE_CODE_NOT_EXISTS); + } + if (codeDO.getStatus() != null && codeDO.getStatus() == 1) { + throw exception(INVITE_CODE_DISABLED); + } + LocalDateTime now = LocalDateTime.now(); + if (codeDO.getExpireTime() != null && !codeDO.getExpireTime().isAfter(now)) { + throw exception(INVITE_CODE_EXPIRED); + } + if (codeDO.getUsedCount() != null && codeDO.getMaxUses() != null + && codeDO.getUsedCount() >= codeDO.getMaxUses()) { + throw exception(INVITE_CODE_EXHAUSTED); + } + // 2) 原子核销(条件更新做最终一致兜底):前置查询到此处之间的并发窗口由 WHERE 封死, + // affected=0 = 被并发抢核扣满(或刚被停用/过期),统一抛已耗尽错误码,建号事务整体回滚。 + int affected = inviteCodeMapper.redeem(codeDO.getId(), now); + if (affected == 0) { + log.warn("[validateAndRedeem] 邀请码并发核销失败(条件更新 affected=0)code={}, id={}", code, codeDO.getId()); + throw exception(INVITE_CODE_EXHAUSTED); + } + log.info("[validateAndRedeem] 邀请码核销成功 id={}, batchNo={}", codeDO.getId(), codeDO.getBatchNo()); + return codeDO.getId(); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportService.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportService.java new file mode 100644 index 00000000..9698fc04 --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportService.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.iocoder.yudao.module.system.api.passport.dto.PlayerRespDTO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.InviteRegisterReqVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.LoginRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.PlayerMeRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.SmsLoginReqVO; + +/** + * passport 玩家身份 Service 接口(验证码登录 / 邀请码注册 / 当前用户 / 创作者白名单) + * + * 账号 owner=system 基座(需求模块映射既定),故 player 身份读写 + 发 token 收敛于此。 + * 发 token:OAuth2TokenService.createAccessToken(userId, UserTypeEnum.MEMBER, clientId, scopes)。 + * + * @author 造梦AI + */ +public interface PassportService { + + /** + * 发送登录短信验证码(场景固定 SmsSceneEnum.MEMBER_LOGIN,复用 SmsCodeService) + * + * 防枚举口径:不校验手机号是否已注册(已注册→登录、未注册→自动注册均发码)。 + * R6 频控:复用上游手机号维度频控 + 本层 IP 每日/每小时 Redis 计数上限(不改上游 SmsCodeServiceImpl)。 + * + * @param mobile 手机号 + * @param clientIp 请求 IP(IP 维度频控 + 落 system_sms_code.create_ip) + */ + void sendSmsCode(String mobile, String clientIp); + + /** + * 手机号验证码登录(已注册登录 / 未注册自动注册一体,register_channel=sms) + * + * anonId 仅注册时落 first_anon_id 一次;竞态兜底:uk_mobile 冲突静默转登录(与防枚举口径协调)。 + * + * @param reqVO mobile/code/anonId? + * @param clientIp 请求 IP(落 register_ip + 验证码 usedIp) + * @return 登录响应(真 OAuth2 token + 身份摘要) + */ + LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp); + + /** + * 邀请码受限激活注册(手机号+邀请码,零短信依赖,register_channel=invite) + * + * 受退役开关 wanxiang.passport.invite-register-enabled 管控(关闭抛 INVITE_REGISTER_DISABLED)。 + * 核销语义:核销与建号同一本地事务,条件更新原子累加(耗尽/停用/过期精确错误码 + 兜底)。 + * 手机号已注册 → 抛 PLAYER_MOBILE_ALREADY_REGISTERED(提示直接验证码登录)。 + * + * @param reqVO mobile/inviteCode/anonId?/nickname? + * @param clientIp 请求 IP(落 register_ip) + * @return 登录响应(同 sms-login) + */ + LoginRespVO inviteRegister(InviteRegisterReqVO reqVO, String clientIp); + + /** + * 当前登录玩家信息(me 端点,登录态);手机号脱敏返回 + * + * @param userId 当前 token 对应玩家 ID + * @return 玩家信息 VO(mobile 脱敏) + */ + PlayerMeRespVO getPlayerMe(Long userId); + + /** + * 读玩家身份摘要(PlayerApi#getPlayer 用);玩家不存在返回 null + * + * @param userId 玩家 ID + * @return 身份摘要 DTO;不存在返回 null + */ + PlayerRespDTO getPlayer(Long userId); + + /** + * 校验玩家是否为白名单创作者(PlayerApi#validateCreator 用,拍板4 可信边界) + * + * 三分支语义见 {@link cn.iocoder.yudao.module.system.api.passport.PlayerApi#validateCreator}(含 mock 豁免)。 + * 校验不过抛 1-002-090 段错误码。 + * + * @param userId 玩家 ID + */ + void validateCreator(Long userId); + + /** + * 创作者白名单置位/取消(admin set-creator 用,A2 名单运营动作) + * + * @param userId 玩家 ID + * @param creatorFlag 0取消 1置位 + */ + void setCreatorFlag(Long userId, Integer creatorFlag); + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java new file mode 100644 index 00000000..94dae06b --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java @@ -0,0 +1,303 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.hutool.core.util.DesensitizedUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.framework.security.config.SecurityProperties; +import cn.iocoder.yudao.module.system.api.passport.dto.PlayerRespDTO; +import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO; +import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.InviteRegisterReqVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.LoginRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.PlayerMeRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.SmsLoginReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.PlayerDO; +import cn.iocoder.yudao.module.system.dal.mysql.passport.PlayerMapper; +import cn.iocoder.yudao.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO; +import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants; +import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum; +import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService; +import cn.iocoder.yudao.module.system.service.sms.SmsCodeService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import jakarta.annotation.Resource; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; + +/** + * passport 玩家身份 Service 实现 + * + * 全部业务规则(防枚举发码 + IP 频控、自动注册一体、邀请码核销同事务、白名单 mock 豁免)在此承载,可单测、可追溯。 + * 发 token 复用 OAuth2TokenService.createAccessToken(userId, MEMBER, clientId, scopes),clientId 取 + * OAuth2ClientConstants.CLIENT_ID_DEFAULT="default"(已核 system_oauth2_client id=1 client_id='default', + * refresh_token_validity=2592000s≈30d,满足验收5 refresh 轮换)。 + * + * @author 造梦AI + */ +@Service +@Slf4j +public class PassportServiceImpl implements PassportService { + + /** + * MEMBER token 默认昵称前缀(注册时不传 nickname 则按「玩家+手机号后4位」生成)。 + */ + private static final String DEFAULT_NICKNAME_PREFIX = "玩家"; + + @Resource + private SmsCodeService smsCodeService; + @Resource + private OAuth2TokenService oauth2TokenService; + @Resource + private PlayerMapper playerMapper; + @Resource + private InviteCodeService inviteCodeService; + @Resource + private SecurityProperties securityProperties; // mock 豁免判定依据(§6.3) + @Resource + private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; // R6 IP 频控(§7.2) + + /** + * 发码 IP 每小时上限(R6 §7.2,推断初值 10 次/小时/IP,可配置;与运营确认后调整)。 + */ + @Value("${wanxiang.passport.sms-ip-limit-per-hour:10}") + private int smsIpLimitPerHour; + + /** + * 发码 IP 每日上限(R6 §7.2,推断初值 30 次/日/IP,可配置;日桶兜底防单 IP 刷量)。 + */ + @Value("${wanxiang.passport.sms-ip-limit-per-day:30}") + private int smsIpLimitPerDay; + + /** + * 邀请码注册退役开关(§5.4,staging 默认 true;关闭后 invite-register 返回旁路已退役)。 + * 缺省 true,与 application-staging.yaml wanxiang.passport.invite-register-enabled 对齐。 + */ + @Value("${wanxiang.passport.invite-register-enabled:true}") + private boolean inviteRegisterEnabled; + + @Override + public void sendSmsCode(String mobile, String clientIp) { + // R6 §7.2:先做 IP 维度频控(每小时/每日上限),不改上游 SmsCodeServiceImpl(手机号维度频控由其内部承载)。 + // 计数即自增(先增后判),超限抛 TOO_MANY_REQUESTS(复用全局错误码,避免新增)。 + if (StringUtils.hasText(clientIp)) { + long hourCount = smsIpCounterRedisDAO.incrHourAndGet(clientIp); + long dayCount = smsIpCounterRedisDAO.incrDayAndGet(clientIp); + if (hourCount > smsIpLimitPerHour || dayCount > smsIpLimitPerDay) { + log.warn("[sendSmsCode] 发码 IP 频控触发 ip={}, hourCount={}/{}, dayCount={}/{}", + clientIp, hourCount, smsIpLimitPerHour, dayCount, smsIpLimitPerDay); + // 不区分小时/日,统一返回请求过快(与防枚举一致,不泄露内部阈值细节) + throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS); + } + } + // 防枚举:不校验手机号是否已注册(已注册→登录、未注册→自动注册均发码),统一受理。 + SmsCodeSendReqDTO reqDTO = new SmsCodeSendReqDTO(); + reqDTO.setMobile(mobile); + reqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene()); // 场景固定 MEMBER_LOGIN(1) + reqDTO.setCreateIp(clientIp); + smsCodeService.sendSmsCode(reqDTO); // 通道失败由 SmsCodeService 抛异常(不吞),验证码未落库不计发送频次 + // 安全基线:手机号脱敏入日志,不落原文 + log.info("[sendSmsCode] 登录验证码已受理 mobile={}", DesensitizedUtil.mobilePhone(mobile)); + } + + @Override + @Transactional(rollbackFor = Exception.class) // 自动注册建号与验证码使用需同一本地事务(避免验证码已用但建号失败的半截态) + public LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp) { + // 1) 校验并使用验证码(场景 MEMBER_LOGIN);不存在/过期/已用由 SmsCodeService 抛精确错误码 + SmsCodeUseReqDTO useReqDTO = new SmsCodeUseReqDTO(); + useReqDTO.setMobile(reqVO.getMobile()); + useReqDTO.setCode(reqVO.getCode()); + useReqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene()); + useReqDTO.setUsedIp(clientIp); + smsCodeService.useSmsCode(useReqDTO); + + // 2) 已注册→登录;未注册→自动注册(register_channel=sms,验证码即占有证明,拍板3) + PlayerDO player = playerMapper.selectByMobile(reqVO.getMobile()); + if (player == null) { + // sms-login 自动注册不收昵称(验证码路径无昵称入参),传 null 走默认生成 + player = registerPlayer(reqVO.getMobile(), "sms", null, reqVO.getAnonId(), null, clientIp); + } else { + // 已注册:禁用态拒登录;正常态刷新最近登录时间 + assertPlayerEnabled(player); + playerMapper.updateLoginDate(player.getId(), LocalDateTime.now()); + } + // 3) 发真 OAuth2 token(userType=MEMBER),返回身份摘要 + return buildLoginResp(player); + } + + @Override + @Transactional(rollbackFor = Exception.class) // 核销邀请码与建号同一本地事务(核销失败/建号失败整体回滚,§4.2) + public LoginRespVO inviteRegister(InviteRegisterReqVO reqVO, String clientIp) { + // 1) 退役开关(§5.4):关闭后旁路已退役 + if (!inviteRegisterEnabled) { + throw exception(INVITE_REGISTER_DISABLED); + } + // 2) 手机号已注册 → 提示直接验证码登录(invite-register 路径明确报已注册,与 sms-login 防枚举口径区分) + PlayerDO exist = playerMapper.selectByMobile(reqVO.getMobile()); + if (exist != null) { + throw exception(PLAYER_MOBILE_ALREADY_REGISTERED); + } + // 3) 核销邀请码(条件更新原子累加,同事务):耗尽/停用/过期/不存在抛 1-002-091 段错误码 → 整体回滚 + Long inviteCodeId = inviteCodeService.validateAndRedeem(reqVO.getInviteCode()); + // 4) 建号(register_channel=invite,关联核销的 invite_code_id)+ 发 token + PlayerDO player = registerPlayer(reqVO.getMobile(), "invite", inviteCodeId, + reqVO.getAnonId(), reqVO.getNickname(), clientIp); + return buildLoginResp(player); + } + + /** + * 建玩家(自动注册 / 邀请码注册共用) + * + * 竞态兜底:uk_mobile 唯一键冲突(并发同号注册)→ 回查转登录语义(与防枚举口径协调)。 + * 注:invite 通道命中冲突属极端竞态(前置已查无),回查后按已存在玩家继续(邀请码已核销不退,记 WARN)。 + * + * @param mobile 手机号 + * @param channel 注册通道:sms / invite + * @param inviteCodeId 核销的邀请码 ID(sms 通道传 null) + * @param anonId 客户端 anonId(仅注册时落 first_anon_id;缺失落空串) + * @param nickname 昵称(不传则默认生成) + * @param clientIp 注册 IP + * @return 玩家 DO(含回填 id) + */ + private PlayerDO registerPlayer(String mobile, String channel, Long inviteCodeId, + String anonId, String nickname, String clientIp) { + PlayerDO player = new PlayerDO(); + player.setMobile(mobile); + player.setNickname(StringUtils.hasText(nickname) ? nickname : generateNickname(mobile)); + player.setAvatar(""); + player.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 0 正常 + player.setCreatorFlag(0); // 默认玩家,非创作者(A2 白名单由 admin set-creator 后置) + player.setRegisterChannel(channel); + player.setRegisterIp(StringUtils.hasText(clientIp) ? clientIp : ""); + player.setInviteCodeId(inviteCodeId); + // 目标3:anonId 仅注册时落一次;缺失落空串(归因降级不阻断登录,§12) + player.setFirstAnonId(StringUtils.hasText(anonId) ? anonId : ""); + player.setLoginDate(LocalDateTime.now()); + try { + playerMapper.insert(player); + log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasAnonId={}", + player.getId(), channel, DesensitizedUtil.mobilePhone(mobile), StringUtils.hasText(anonId)); + return player; + } catch (DuplicateKeyException e) { + // uk_mobile 并发冲突:回查转登录(同手机号竞态兜底,§12) + log.warn("[registerPlayer] 手机号并发注册冲突,转登录语义 mobile={}, channel={}", + DesensitizedUtil.mobilePhone(mobile), channel); + PlayerDO existed = playerMapper.selectByMobile(mobile); + if (existed == null) { + // 极端:冲突后回查仍无(理论不该发生)→ 抛已注册错误码,不静默 + throw exception(PLAYER_MOBILE_ALREADY_REGISTERED); + } + assertPlayerEnabled(existed); + return existed; + } + } + + /** + * 校验玩家未被禁用(禁用态拒登录) + */ + private void assertPlayerEnabled(PlayerDO player) { + if (player.getStatus() != null && CommonStatusEnum.DISABLE.getStatus().equals(player.getStatus())) { + throw exception(PLAYER_IS_DISABLED); + } + } + + /** + * 生成默认昵称:玩家+手机号后4位(手机号不足4位时取全量,兜底空串) + */ + private static String generateNickname(String mobile) { + String suffix = StrUtil.isNotBlank(mobile) && mobile.length() >= 4 + ? mobile.substring(mobile.length() - 4) : StrUtil.nullToEmpty(mobile); + return DEFAULT_NICKNAME_PREFIX + suffix; + } + + /** + * 发 OAuth2 token 并组装登录响应(token 不落日志) + */ + private LoginRespVO buildLoginResp(PlayerDO player) { + // userType=MEMBER(1),clientId=default,scopes=null(同 AdminAuthServiceImpl.createTokenAfterLoginSuccess 范式) + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken( + player.getId(), UserTypeEnum.MEMBER.getValue(), OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); + LoginRespVO resp = new LoginRespVO(); + resp.setUserId(player.getId()); + resp.setAccessToken(accessTokenDO.getAccessToken()); + resp.setRefreshToken(accessTokenDO.getRefreshToken()); + resp.setExpiresTime(accessTokenDO.getExpiresTime() == null ? null + : accessTokenDO.getExpiresTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + resp.setNickname(player.getNickname()); + resp.setCreatorFlag(player.getCreatorFlag()); + // 安全基线:仅记 userId,token 不落日志(验收5) + log.info("[buildLoginResp] 玩家登录发 token 成功 userId={}", player.getId()); + return resp; + } + + @Override + public PlayerMeRespVO getPlayerMe(Long userId) { + PlayerDO player = playerMapper.selectById(userId); + if (player == null) { + // 登录态下查无身份(如 mock 身份 userId=1 无 game_player 行)→ 抛不存在,前端据此降级 + throw exception(PLAYER_NOT_EXISTS); + } + PlayerMeRespVO resp = new PlayerMeRespVO(); + resp.setUserId(player.getId()); + resp.setMobile(DesensitizedUtil.mobilePhone(player.getMobile())); // 安全基线:脱敏返回,禁回明文 + resp.setNickname(player.getNickname()); + resp.setAvatar(player.getAvatar()); + resp.setCreatorFlag(player.getCreatorFlag()); + return resp; + } + + @Override + public PlayerRespDTO getPlayer(Long userId) { + PlayerDO player = playerMapper.selectById(userId); + if (player == null) { + return null; // 不存在返回 null,由调用方判空(PlayerApi#getPlayer 契约) + } + PlayerRespDTO dto = new PlayerRespDTO(); + dto.setUserId(player.getId()); + dto.setStatus(player.getStatus()); + dto.setCreatorFlag(player.getCreatorFlag()); + return dto; + } + + @Override + public void validateCreator(Long userId) { + // 拍板4 可信边界(主 agent 裁决,§6.3 + §16-4): + PlayerDO player = playerMapper.selectById(userId); + if (player == null) { + // 查无此行:mock-enable=true → 放行(staging mock 身份豁免,保批跑零中断,记 WARN 可审计) + if (Boolean.TRUE.equals(securityProperties.getMockEnable())) { + log.warn("[validateCreator] mock 身份创作豁免放行 userId={}(staging mock-enable=true,game_player 无此行)", userId); + return; + } + // mock-enable=false(如生产):查无身份即拒(1-002-090 段) + throw exception(PLAYER_NOT_EXISTS); + } + // 行存在:禁用态直接拒 + assertPlayerEnabled(player); + // 按 creator_flag 判定:非 1(创作者)即拒 + if (player.getCreatorFlag() == null || player.getCreatorFlag() != 1) { + throw exception(PLAYER_CREATE_NOT_IN_WHITELIST); + } + } + + @Override + public void setCreatorFlag(Long userId, Integer creatorFlag) { + // 幂等:玩家不存在时 updateCreatorFlag 返回 0,抛不存在错误码让运营定位 + int affected = playerMapper.updateCreatorFlag(userId, creatorFlag); + if (affected == 0) { + throw exception(PLAYER_NOT_EXISTS); + } + log.info("[setCreatorFlag] 创作者白名单置位 userId={}, creatorFlag={}", userId, creatorFlag); + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImplTest.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImplTest.java new file mode 100644 index 00000000..2014fc6d --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/InviteCodeServiceImplTest.java @@ -0,0 +1,209 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.controller.admin.passport.vo.InviteCodeGenerateReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.InviteCodeDO; +import cn.iocoder.yudao.module.system.dal.mysql.passport.InviteCodeMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * {@link InviteCodeServiceImpl} 单元测试(纯 Mockito,不依赖 DB) + * + * 覆盖核心业务守卫:邀请码生成唯一性/易混淆字符规避、核销条件更新 affected=0 分支(并发超发防护)、 + * 前置查询的精确错误码(不存在/停用/过期/耗尽)。 + * + * @author 造梦AI + */ +class InviteCodeServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private InviteCodeServiceImpl inviteCodeService; + + @Mock + private InviteCodeMapper inviteCodeMapper; + + // ============================== generateInviteCodes ============================== + + @Test + @DisplayName("生成邀请码:数量正确 + 码值唯一 + 仅含大写字母数字且无易混淆字符 0O1I") + void testGenerate_uniqueAndNoConfusingChars() { + // insert 不抛冲突(正常路径),回填自增 ID + when(inviteCodeMapper.insert(any(InviteCodeDO.class))).thenReturn(1); + InviteCodeGenerateReqVO reqVO = new InviteCodeGenerateReqVO(); + reqVO.setCount(50); + reqVO.setMaxUses(1); + reqVO.setBatchNo("batch-xhs-01"); + + List codes = inviteCodeService.generateInviteCodes(reqVO); + + // 数量正确 + assertEquals(50, codes.size()); + Set seen = new HashSet<>(); + for (InviteCodeDO c : codes) { + // 码值非空、长度 8 + assertNotNull(c.getCode()); + assertEquals(8, c.getCode().length()); + // 仅含约定字符集(大写字母数字,无 0/O/1/I) + assertTrue(c.getCode().matches("[A-HJ-NP-Z2-9]+"), + "码值含非法/易混淆字符: " + c.getCode()); + // 批量内不重复(本测试随机串重复概率极低,作唯一性冒烟) + assertTrue(seen.add(c.getCode()), "批量内出现重复码: " + c.getCode()); + // 服务端默认字段 + assertEquals(0, c.getUsedCount()); + assertEquals(0, c.getStatus()); + assertEquals("batch-xhs-01", c.getBatchNo()); + } + } + + @Test + @DisplayName("生成邀请码:uk_code 连续冲突重试耗尽 → 抛 INVITE_CODE_GENERATE_CONFLICT") + void testGenerate_conflictRetryExhausted() { + // insert 始终抛唯一键冲突(模拟极端碰撞),重试耗尽后抛错 + when(inviteCodeMapper.insert(any(InviteCodeDO.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("uk_code")); + InviteCodeGenerateReqVO reqVO = new InviteCodeGenerateReqVO(); + reqVO.setCount(1); + reqVO.setMaxUses(1); + + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.generateInviteCodes(reqVO)); + assertEquals(INVITE_CODE_GENERATE_CONFLICT.getCode(), ex.getCode()); + } + + // ============================== validateAndRedeem 核销语义 ============================== + + @Test + @DisplayName("核销:码不存在 → 抛 INVITE_CODE_NOT_EXISTS(前置查询精确错误码)") + void testRedeem_notExists() { + when(inviteCodeMapper.selectByCode("BADCODE")).thenReturn(null); + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.validateAndRedeem("BADCODE")); + assertEquals(INVITE_CODE_NOT_EXISTS.getCode(), ex.getCode()); + // 前置即拒,不触发条件更新 + verify(inviteCodeMapper, never()).redeem(anyLong(), any(LocalDateTime.class)); + } + + @Test + @DisplayName("核销:码已停用 → 抛 INVITE_CODE_DISABLED") + void testRedeem_disabled() { + InviteCodeDO codeDO = code(1L, 0, 1, null); // status=1 停用 + when(inviteCodeMapper.selectByCode("CODE")).thenReturn(codeDO); + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.validateAndRedeem("CODE")); + assertEquals(INVITE_CODE_DISABLED.getCode(), ex.getCode()); + verify(inviteCodeMapper, never()).redeem(anyLong(), any(LocalDateTime.class)); + } + + @Test + @DisplayName("核销:码已过期 → 抛 INVITE_CODE_EXPIRED") + void testRedeem_expired() { + InviteCodeDO codeDO = code(1L, 0, 0, LocalDateTime.now().minusDays(1)); // 过期时间在过去 + when(inviteCodeMapper.selectByCode("CODE")).thenReturn(codeDO); + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.validateAndRedeem("CODE")); + assertEquals(INVITE_CODE_EXPIRED.getCode(), ex.getCode()); + } + + @Test + @DisplayName("核销:已达 max_uses → 抛 INVITE_CODE_EXHAUSTED(前置查询命中耗尽)") + void testRedeem_exhaustedByPreCheck() { + InviteCodeDO codeDO = code(1L, 1, 0, null); + codeDO.setMaxUses(1); + codeDO.setUsedCount(1); // used_count >= max_uses + when(inviteCodeMapper.selectByCode("CODE")).thenReturn(codeDO); + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.validateAndRedeem("CODE")); + assertEquals(INVITE_CODE_EXHAUSTED.getCode(), ex.getCode()); + } + + @Test + @DisplayName("核销:并发竞态——前置通过但条件更新 affected=0 → 抛 INVITE_CODE_EXHAUSTED(最终一致兜底)") + void testRedeem_concurrentAffectedZero() { + // 前置查询:启用、未过期、未耗尽(看似可核销) + InviteCodeDO codeDO = code(1L, 0, 0, null); + codeDO.setMaxUses(2); + codeDO.setUsedCount(1); + when(inviteCodeMapper.selectByCode("CODE")).thenReturn(codeDO); + // 条件更新返回 0:被并发实例抢核扣满(前置查询到核销之间的窗口) + when(inviteCodeMapper.redeem(eq(1L), any(LocalDateTime.class))).thenReturn(0); + + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.validateAndRedeem("CODE")); + assertEquals(INVITE_CODE_EXHAUSTED.getCode(), ex.getCode()); + // 确实走到了条件更新(最终一致兜底,非仅前置) + verify(inviteCodeMapper).redeem(eq(1L), any(LocalDateTime.class)); + } + + @Test + @DisplayName("核销:条件更新 affected=1 → 成功返回邀请码 ID") + void testRedeem_success() { + InviteCodeDO codeDO = code(1L, 0, 0, null); + codeDO.setMaxUses(5); + codeDO.setUsedCount(0); + when(inviteCodeMapper.selectByCode("CODE")).thenReturn(codeDO); + when(inviteCodeMapper.redeem(eq(1L), any(LocalDateTime.class))).thenReturn(1); + + Long id = inviteCodeService.validateAndRedeem("CODE"); + assertEquals(1L, id); + } + + // ============================== disableInviteCode ============================== + + @Test + @DisplayName("停用:码不存在(affected=0)→ 抛 INVITE_CODE_NOT_EXISTS") + void testDisable_notExists() { + when(inviteCodeMapper.disableById(9L)).thenReturn(0); + ServiceException ex = assertThrows(ServiceException.class, + () -> inviteCodeService.disableInviteCode(9L)); + assertEquals(INVITE_CODE_NOT_EXISTS.getCode(), ex.getCode()); + } + + @Test + @DisplayName("停用:码存在(affected=1)→ 正常返回") + void testDisable_success() { + when(inviteCodeMapper.disableById(1L)).thenReturn(1); + assertDoesNotThrow(() -> inviteCodeService.disableInviteCode(1L)); + ArgumentCaptor idCaptor = ArgumentCaptor.forClass(Long.class); + verify(inviteCodeMapper).disableById(idCaptor.capture()); + assertEquals(1L, idCaptor.getValue()); + } + + // ============================== 测试夹具 ============================== + + /** + * 构造邀请码 DO + * + * @param id 编号 + * @param usedCount 已核销次数 + * @param status 状态 0启用 1停用 + * @param expireTime 有效期(null=不过期) + */ + private static InviteCodeDO code(Long id, int usedCount, int status, LocalDateTime expireTime) { + InviteCodeDO d = new InviteCodeDO(); + d.setId(id); + d.setCode("ABCD2345"); + d.setMaxUses(10); + d.setUsedCount(usedCount); + d.setStatus(status); + d.setExpireTime(expireTime); + d.setBatchNo("batch-test"); + return d; + } + +} diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java new file mode 100644 index 00000000..ee63ca7d --- /dev/null +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java @@ -0,0 +1,336 @@ +package cn.iocoder.yudao.module.system.service.passport; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.security.config.SecurityProperties; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.InviteRegisterReqVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.LoginRespVO; +import cn.iocoder.yudao.module.system.controller.app.passport.vo.SmsLoginReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import cn.iocoder.yudao.module.system.dal.dataobject.passport.PlayerDO; +import cn.iocoder.yudao.module.system.dal.mysql.passport.PlayerMapper; +import cn.iocoder.yudao.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO; +import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants; +import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum; +import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService; +import cn.iocoder.yudao.module.system.service.sms.SmsCodeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link PassportServiceImpl} 单元测试(纯 Mockito,不依赖 DB/Redis) + * + * 覆盖核心业务守卫:发码 IP 频控(超限抛 429)、validateCreator 三分支(含 mock 豁免)、 + * sms-login 自动注册一体、invite-register 退役开关与已注册拦截。 + * + * @author 造梦AI + */ +class PassportServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private PassportServiceImpl passportService; + + @Mock + private SmsCodeService smsCodeService; + @Mock + private OAuth2TokenService oauth2TokenService; + @Mock + private PlayerMapper playerMapper; + @Mock + private InviteCodeService inviteCodeService; + @Mock + private SecurityProperties securityProperties; + @Mock + private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; + + @BeforeEach + void setUp() { + // @Value 注入字段在纯 Mockito 下不生效,手动反射注入(与生产默认值一致) + ReflectionTestUtils.setField(passportService, "smsIpLimitPerHour", 10); + ReflectionTestUtils.setField(passportService, "smsIpLimitPerDay", 30); + ReflectionTestUtils.setField(passportService, "inviteRegisterEnabled", true); + } + + // ============================== sendSmsCode IP 频控 ============================== + + @Test + @DisplayName("发码:IP 未超限 → 透传 SmsCodeService 发码(场景 MEMBER_LOGIN)") + void testSendSmsCode_underLimit() { + when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + + passportService.sendSmsCode("13800000000", "1.2.3.4"); + + // 透传发码,场景固定 MEMBER_LOGIN(1) + ArgumentCaptor captor = + ArgumentCaptor.forClass(cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO.class); + verify(smsCodeService).sendSmsCode(captor.capture()); + assertEquals(SmsSceneEnum.MEMBER_LOGIN.getScene(), captor.getValue().getScene()); + assertEquals("13800000000", captor.getValue().getMobile()); + } + + @Test + @DisplayName("发码:IP 每小时超限 → 抛 429 且不发码(不区分小时/日,防泄露阈值)") + void testSendSmsCode_hourLimitExceeded() { + when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(11L); // > 10 + when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(11L); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.sendSmsCode("13800000000", "1.2.3.4")); + assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode()); + // 频控触发即拒,绝不发码 + verify(smsCodeService, never()).sendSmsCode(any()); + } + + @Test + @DisplayName("发码:IP 每日超限 → 抛 429 且不发码") + void testSendSmsCode_dayLimitExceeded() { + when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(3L); // 小时未超 + when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(31L); // 日超 30 + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.sendSmsCode("13800000000", "1.2.3.4")); + assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode()); + verify(smsCodeService, never()).sendSmsCode(any()); + } + + // ============================== validateCreator 三分支(含 mock 豁免) ============================== + + @Test + @DisplayName("白名单校验:查无此行 + mock-enable=true → 豁免放行(staging 批跑零中断)") + void testValidateCreator_mockExemptWhenNoRow() { + when(playerMapper.selectById(1L)).thenReturn(null); + when(securityProperties.getMockEnable()).thenReturn(true); + + // 不抛异常即放行 + assertDoesNotThrow(() -> passportService.validateCreator(1L)); + } + + @Test + @DisplayName("白名单校验:查无此行 + mock-enable=false → 抛 PLAYER_NOT_EXISTS(生产拒)") + void testValidateCreator_rejectWhenNoRowAndNoMock() { + when(playerMapper.selectById(1L)).thenReturn(null); + when(securityProperties.getMockEnable()).thenReturn(false); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.validateCreator(1L)); + assertEquals(PLAYER_NOT_EXISTS.getCode(), ex.getCode()); + } + + @Test + @DisplayName("白名单校验:行存在 creator_flag=0 → 抛 PLAYER_CREATE_NOT_IN_WHITELIST(非白名单拒)") + void testValidateCreator_rejectWhenNotCreator() { + PlayerDO player = player(1024L, 0, 0); // creator_flag=0 + when(playerMapper.selectById(1024L)).thenReturn(player); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.validateCreator(1024L)); + assertEquals(PLAYER_CREATE_NOT_IN_WHITELIST.getCode(), ex.getCode()); + } + + @Test + @DisplayName("白名单校验:行存在 creator_flag=1 → 放行") + void testValidateCreator_passWhenCreator() { + PlayerDO player = player(1024L, 0, 1); // creator_flag=1 + when(playerMapper.selectById(1024L)).thenReturn(player); + + assertDoesNotThrow(() -> passportService.validateCreator(1024L)); + } + + @Test + @DisplayName("白名单校验:行存在但已禁用(status=1)→ 抛 PLAYER_IS_DISABLED") + void testValidateCreator_rejectWhenDisabled() { + PlayerDO player = player(1024L, 1, 1); // status=1 禁用(即便是创作者也拒) + when(playerMapper.selectById(1024L)).thenReturn(player); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.validateCreator(1024L)); + assertEquals(PLAYER_IS_DISABLED.getCode(), ex.getCode()); + } + + // ============================== smsLogin 自动注册一体 ============================== + + @Test + @DisplayName("验证码登录:未注册 → 自动注册(register_channel=sms)+ anonId 落 first_anon_id + 发 MEMBER token") + void testSmsLogin_autoRegisterWhenAbsent() { + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("1024"); + reqVO.setAnonId("anon-x"); + // 未注册 + when(playerMapper.selectByMobile("13800000000")).thenReturn(null); + // insert 回填自增 ID + doAnswer(inv -> { + PlayerDO d = inv.getArgument(0); + d.setId(2048L); + return 1; + }).when(playerMapper).insert(any(PlayerDO.class)); + stubToken(2048L); + + LoginRespVO resp = passportService.smsLogin(reqVO, "1.2.3.4"); + + // 验证码被使用(场景 MEMBER_LOGIN) + verify(smsCodeService).useSmsCode(any()); + // 自动注册:register_channel=sms + anonId 落 first_anon_id + ArgumentCaptor captor = ArgumentCaptor.forClass(PlayerDO.class); + verify(playerMapper).insert(captor.capture()); + assertEquals("sms", captor.getValue().getRegisterChannel()); + assertEquals("anon-x", captor.getValue().getFirstAnonId()); + assertEquals(0, captor.getValue().getCreatorFlag()); // 默认非创作者 + // 发真 token,响应带 userId + assertEquals(2048L, resp.getUserId()); + assertEquals("happy-token", resp.getAccessToken()); + } + + @Test + @DisplayName("验证码登录:已注册 → 刷新登录时间 + 不重复注册 + 发 token") + void testSmsLogin_loginWhenExists() { + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("1024"); + PlayerDO exist = player(2048L, 0, 0); + when(playerMapper.selectByMobile("13800000000")).thenReturn(exist); + stubToken(2048L); + + LoginRespVO resp = passportService.smsLogin(reqVO, "1.2.3.4"); + + verify(smsCodeService).useSmsCode(any()); + verify(playerMapper, never()).insert(any(PlayerDO.class)); // 不重复注册 + verify(playerMapper).updateLoginDate(eq(2048L), any(LocalDateTime.class)); + assertEquals(2048L, resp.getUserId()); + } + + @Test + @DisplayName("验证码登录:已注册但被禁用 → 抛 PLAYER_IS_DISABLED") + void testSmsLogin_disabledRejected() { + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("1024"); + PlayerDO exist = player(2048L, 1, 0); // status=1 禁用 + when(playerMapper.selectByMobile("13800000000")).thenReturn(exist); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.smsLogin(reqVO, "1.2.3.4")); + assertEquals(PLAYER_IS_DISABLED.getCode(), ex.getCode()); + // 验证码已使用但建号/发 token 未发生(事务整体回滚由 @Transactional 承载) + verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any()); + } + + // ============================== inviteRegister 退役开关 + 已注册拦截 ============================== + + @Test + @DisplayName("邀请码注册:退役开关关闭 → 抛 INVITE_REGISTER_DISABLED(旁路已退役)") + void testInviteRegister_disabledSwitch() { + ReflectionTestUtils.setField(passportService, "inviteRegisterEnabled", false); + InviteRegisterReqVO reqVO = inviteReq("13800000000", "ABCD2345"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.inviteRegister(reqVO, "1.2.3.4")); + assertEquals(INVITE_REGISTER_DISABLED.getCode(), ex.getCode()); + // 开关关:连码都不查(最短路径拒) + verify(inviteCodeService, never()).validateAndRedeem(any()); + } + + @Test + @DisplayName("邀请码注册:手机号已注册 → 抛 PLAYER_MOBILE_ALREADY_REGISTERED(提示验证码登录)") + void testInviteRegister_mobileAlreadyRegistered() { + InviteRegisterReqVO reqVO = inviteReq("13800000000", "ABCD2345"); + when(playerMapper.selectByMobile("13800000000")).thenReturn(player(2048L, 0, 0)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.inviteRegister(reqVO, "1.2.3.4")); + assertEquals(PLAYER_MOBILE_ALREADY_REGISTERED.getCode(), ex.getCode()); + // 已注册即拒,不核销邀请码(避免无谓消耗) + verify(inviteCodeService, never()).validateAndRedeem(any()); + } + + @Test + @DisplayName("邀请码注册:核销成功 → 建号 register_channel=invite + 关联 invite_code_id + 发 token") + void testInviteRegister_success() { + InviteRegisterReqVO reqVO = inviteReq("13800000000", "ABCD2345"); + reqVO.setAnonId("anon-y"); + reqVO.setNickname("小明"); + when(playerMapper.selectByMobile("13800000000")).thenReturn(null); // 未注册 + when(inviteCodeService.validateAndRedeem("ABCD2345")).thenReturn(77L); // 核销成功返回码 ID + doAnswer(inv -> { + PlayerDO d = inv.getArgument(0); + d.setId(2048L); + return 1; + }).when(playerMapper).insert(any(PlayerDO.class)); + stubToken(2048L); + + LoginRespVO resp = passportService.inviteRegister(reqVO, "1.2.3.4"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlayerDO.class); + verify(playerMapper).insert(captor.capture()); + assertEquals("invite", captor.getValue().getRegisterChannel()); + assertEquals(77L, captor.getValue().getInviteCodeId()); // 关联核销的邀请码 + assertEquals("anon-y", captor.getValue().getFirstAnonId()); + assertEquals("小明", captor.getValue().getNickname()); // 传入昵称生效 + assertEquals(2048L, resp.getUserId()); + } + + // ============================== setCreatorFlag ============================== + + @Test + @DisplayName("白名单置位:玩家不存在(affected=0)→ 抛 PLAYER_NOT_EXISTS") + void testSetCreatorFlag_notExists() { + when(playerMapper.updateCreatorFlag(9L, 1)).thenReturn(0); + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.setCreatorFlag(9L, 1)); + assertEquals(PLAYER_NOT_EXISTS.getCode(), ex.getCode()); + } + + // ============================== 测试夹具 ============================== + + /** stub 发 token 返回固定 access/refresh/过期时间 */ + private void stubToken(Long userId) { + OAuth2AccessTokenDO tokenDO = new OAuth2AccessTokenDO(); + tokenDO.setAccessToken("happy-token"); + tokenDO.setRefreshToken("nice-refresh"); + tokenDO.setExpiresTime(LocalDateTime.now().plusMinutes(30)); + when(oauth2TokenService.createAccessToken(eq(userId), eq(UserTypeEnum.MEMBER.getValue()), + eq(OAuth2ClientConstants.CLIENT_ID_DEFAULT), eq(null))).thenReturn(tokenDO); + } + + /** + * 构造玩家 DO + * + * @param id 编号 + * @param status 状态 0正常 1禁用 + * @param creatorFlag 创作者白名单位 0玩家 1创作者 + */ + private static PlayerDO player(Long id, int status, int creatorFlag) { + PlayerDO p = new PlayerDO(); + p.setId(id); + p.setMobile("13800000000"); + p.setNickname("玩家0000"); + p.setStatus(status); + p.setCreatorFlag(creatorFlag); + return p; + } + + private static InviteRegisterReqVO inviteReq(String mobile, String inviteCode) { + InviteRegisterReqVO reqVO = new InviteRegisterReqVO(); + reqVO.setMobile(mobile); + reqVO.setInviteCode(inviteCode); + return reqVO; + } + +} diff --git a/game-cloud/yudao-server/src/main/resources/application-staging.yaml b/game-cloud/yudao-server/src/main/resources/application-staging.yaml index b9f666bf..d7e5bd92 100644 --- a/game-cloud/yudao-server/src/main/resources/application-staging.yaml +++ b/game-cloud/yudao-server/src/main/resources/application-staging.yaml @@ -170,3 +170,10 @@ aigc: executor: enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关 api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo + +--- #################### passport 鉴权(真实鉴权与匿名玩家 HJ-PASSPORT-EXEC-001)#################### +wanxiang: + passport: + invite-register-enabled: true # 邀请码注册旁路开关(§5.4):报备前 true,报备后置 false 退役旁路 + sms-ip-limit-per-hour: 10 # 发码 IP 每小时上限(R6 §7.2,推断初值,与运营确认后调整) + sms-ip-limit-per-day: 30 # 发码 IP 每日上限(R6 §7.2,日桶兜底防单 IP 刷量) diff --git a/game-cloud/yudao-server/src/main/resources/db/migration/V11.0.0__create_passport_player_invite.sql b/game-cloud/yudao-server/src/main/resources/db/migration/V11.0.0__create_passport_player_invite.sql new file mode 100644 index 00000000..4ca09e8f --- /dev/null +++ b/game-cloud/yudao-server/src/main/resources/db/migration/V11.0.0__create_passport_player_invite.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:真实鉴权与匿名玩家身份(HJ-PASSPORT-EXEC-001 §4.2)| owner:passport(system 内扩展,方案乙) +-- 文件:V11.0.0__create_passport_player_invite.sql(Flyway,只新增/ALTER;已合入禁止修改,回滚写新补偿迁移 V11.0.1) +-- 拍板依据:2026-06-10 鉴权七项(评审版 §9.1,全拍)—— +-- 拍板1=C 验证码为主+邀请码旁路 / 拍板2 报备前受限激活 / 拍板4 创作限 A2 白名单 / 拍板6 实名提现前收集 / 拍板7=B 纯客户端 anonId。 +-- 内容:passport 扩展的两张新表 + 一处 ALTER(共三段): +-- 1. game_player —— 玩家/创作者最小身份(即 OAuth2 token 的 userId,userType=MEMBER;实名字段仅预留); +-- 2. game_invite_code —— 邀请码(报备前受限激活旁路,生成/核销/限量/有效期/退役全生命周期;报备后走配置开关退役); +-- 3. game_runtime_session ALTER —— player_user_id 放宽 NULL(匿名试玩)+ 补 anon_id(匿名会话归属)。 +-- 守门①(必守,沿用 V10 守门②):本 V11 跨 system(player/invite) + runtime(session) 多表,只放仓根 contracts/db-schemas/(源) +-- + yudao-server 执行目录(唯一执行副本),不放任何单模块 -server/db/migration/(避免同版本 V11 出现在多个 classpath jar 触发 Flyway 重复校验失败)。 +-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4。 +-- 让位声明(§4.1):V10 已被黄金闭环占、V11 被本件占,Wave4 community/biz 原规划的 V10/V11 顺延 V12/V13。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 + tenant_id(MVP 单租户=1,tenant.enable=false);状态机用 tinyint,非法流转由服务端拒绝。 +-- 错误码段:玩家身份 = 1-002-090-***;邀请码 = 1-002-091-***(落 system 段,见 passport.yaml 与 ErrorCodeConstants)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_player —— 玩家/创作者最小身份(system passport 扩展,方案乙) +-- id 即 OAuth2 token 的 userId(userType=MEMBER),登录主键=mobile。 +-- 拍板6:real_name/id_card_no 仅预留(AES 密文,应用层加密),提现前才收集,本件不写入。 +-- 拍板4:creator_flag 白名单位,A2 种子名单(~5 人),置位走 admin 端点 /admin-api/passport/player/set-creator。 +-- 目标3(评审版):first_anon_id 落注册时携带的客户端 anonId,做匿名↔登录归因衔接。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_player` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '玩家编号(即 OAuth2 token 的 userId,userType=MEMBER)', + `mobile` VARCHAR(11) NOT NULL COMMENT '手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文+唯一键)', + `nickname` VARCHAR(30) NOT NULL DEFAULT '' COMMENT '昵称(注册默认生成,可改)', + `avatar` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像 URL', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0正常 1禁用(对齐 CommonStatusEnum)', + `creator_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '创作者白名单位:0玩家 1创作者(A2 种子名单,拍板4;置位走 admin 端点)', + `real_name` VARCHAR(256) NULL COMMENT '真实姓名(AES 密文预留;提现前收集,拍板6,本件不写入)', + `id_card_no` VARCHAR(256) NULL COMMENT '身份证号(AES 密文预留;同上,本件不写入)', + `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道:sms验证码 / invite邀请码旁路(漏斗归因)', + `register_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册 IP(风控/审计)', + `invite_code_id` BIGINT NULL COMMENT '核销的邀请码编号(invite 通道必填,关联 game_invite_code.id)', + `first_anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId(匿名↔登录归因衔接,评审版目标3;缺失落空串,归因降级不阻断登录)', + `login_date` DATETIME NULL COMMENT '最近登录时间', + -- 标准审计列(与既有 game_* 表同范式,对齐 V3 game_runtime_session) + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_mobile` (`mobile`, `deleted`, `tenant_id`) COMMENT '手机号唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 V3 uk_version 范式;并发重复注册由此键兜底)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '玩家/创作者最小身份(system passport 扩展,方案乙)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_invite_code —— 邀请码(拍板1=C / 拍板2 受限激活:生成/核销/限量/有效期/退役全生命周期) +-- 报备前陌生玩家凭「手机号+邀请码」零短信注册;报备后 invite-register 走配置开关退役(§5.4)。 +-- 核销语义(防并发超发,写明给实现 agent):核销与建号同一本地事务; +-- UPDATE game_invite_code SET used_count = used_count + 1 +-- WHERE id = ? AND status = 0 AND used_count < max_uses AND (expire_time IS NULL OR expire_time > NOW()); +-- affected=0 即核销失败(耗尽/停用/过期分支由前置查询给精确错误码,条件更新做最终一致兜底,建号事务整体回滚)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_invite_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号', + `code` VARCHAR(16) NOT NULL COMMENT '邀请码/口令(生成时随机大写字母数字,避易混淆字符;uk_code 冲突重试)', + `batch_no` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '批次号(运营按渠道发放归因,如 batch-xhs-01)', + `max_uses` INT NOT NULL DEFAULT 1 COMMENT '限量:最大核销次数', + `used_count` INT NOT NULL DEFAULT 0 COMMENT '已核销次数(条件更新原子累加,见核销语义)', + `expire_time` DATETIME NULL COMMENT '有效期(NULL=不过期)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0启用 1停用(停用=单码退役;全量退役走配置开关 §5.4)', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(发放对象/用途)', + -- 标准审计列(与 game_player 同范式) + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(MVP 单租户=0)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`, `deleted`, `tenant_id`) COMMENT '码值唯一(含 deleted/tenant_id 适配逻辑删+多租户)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '邀请码(报备前玩家受限激活旁路;报备后退役,拍板1C/2)'; + +-- ----------------------------------------------------------------------------- +-- ALTER:game_runtime_session 匿名试玩兼容(§2.3 ⚠ 硬边界 / §4.2 / §4.3) +-- 背景:V3 建表 player_user_id BIGINT NOT NULL;匿名开试玩会话(session/start userId=null)会撞 NOT NULL, +-- RuntimeSessionServiceImpl.setPlayerUserId(null) 直落 → "匿名试玩" 半截。本件放宽 NULL + 补 anon_id。 +-- 存量兼容(§4.3):V3 存量行 player_user_id 均有值(mock 喂 1),MODIFY 放宽 NULL 不破坏存量; +-- anon_id DEFAULT '' 兼容存量。回滚(V11.0.1)还原 NOT NULL 前须确认无 NULL 行;anon_id 列可留存不删,无害。 +-- 口径:anon_id 与 telemetry game_telemetry_event.anon_id(V5)同口径(纯客户端 anonId,拍板7=B)。 +-- ----------------------------------------------------------------------------- +ALTER TABLE `game_runtime_session` + MODIFY COLUMN `player_user_id` BIGINT NULL COMMENT '试玩玩家用户 ID(匿名试玩为空,2026-06-10 鉴权件放开;预览场景=创作者本人)', + ADD COLUMN `anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端 anonId(匿名会话归属;与 telemetry anon_id 同口径,拍板7=B)'; diff --git a/game-studio/src/api/passport.ts b/game-studio/src/api/passport.ts new file mode 100644 index 00000000..5b773fd8 --- /dev/null +++ b/game-studio/src/api/passport.ts @@ -0,0 +1,130 @@ +/** + * passport 模块 API(严格贴 contracts/api-schemas/passport.yaml)。 + * 端:/app-api/passport/*(产品端 game-studio 用到的 4 个 app 端点;admin 端点经 Knife4j/脚本调用,前端不封装)。 + * + * 设计要点(对齐 spec §9.1 / §9.4): + * - 复用 ./request 的 yudao CommonResult 信封解包:拦截器已把 {code,data,msg} 解包为 data, + * 故各函数返回 Promise<解包后的 data 类型>(与 feed/project/aigc 等既有 api 文件一致范式)。 + * - send-sms-code / sms-login / invite-register 三个为免登端点(后端 @PermitAll), + * me 为登录态端点(未登录返回 401,由 request.ts 401 拦截统一兜底)。 + * - anonId 为可选入参(匿名↔登录身份衔接,拍板7=B 纯客户端 anonId):缺失不阻断登录(后端落空串)。 + */ +import { request } from './request' + +/** 发码请求体(贴 SendSmsCodeReqVO:场景固定 MEMBER_LOGIN,响应不区分手机号是否已注册以防枚举) */ +export interface SendSmsCodeReq { + /** 手机号(11 位) */ + mobile: string +} + +/** 验证码登录请求体(贴 SmsLoginReqVO:已注册登录 / 未注册自动注册一体) */ +export interface SmsLoginReq { + /** 手机号(登录主键) */ + mobile: string + /** 短信验证码(Debug 渠道运营查 system_sms_code 表取码) */ + code: string + /** 客户端 anonId(可选;仅注册时落 first_anon_id 一次,匿名↔登录归因) */ + anonId?: string +} + +/** 邀请码注册请求体(贴 InviteRegisterReqVO:受限激活期陌生玩家旁路,零短信依赖) */ +export interface InviteRegisterReq { + /** 手机号(登录主键) */ + mobile: string + /** 邀请码(无效/停用/过期/耗尽返回 1-002-091 错误码) */ + inviteCode: string + /** 客户端 anonId(可选;同 sms-login 口径) */ + anonId?: string + /** 昵称(可选;不传则后端默认生成) */ + nickname?: string +} + +/** + * 登录/注册成功响应(贴 LoginRespVO;sms-login 与 invite-register 共用)。 + * token 不落日志、手机号不在此返回(安全基线)。 + */ +export interface LoginResp { + /** 玩家编号(即 OAuth2 token 的 userId,userType=MEMBER) */ + userId: number + /** 访问令牌(Bearer);前端落 localStorage,注入 Authorization 头 */ + accessToken: string + /** 刷新令牌(refresh 有效期由 OAuth2 client 配置承载) */ + refreshToken: string + /** 访问令牌过期时间戳(毫秒) */ + expiresTime: number + /** 昵称 */ + nickname: string + /** 创作者白名单位:0玩家 1创作者(前端据此渲染创作入口/守卫) */ + creatorFlag: number +} + +/** + * 当前登录玩家信息响应(贴 PlayerMeRespVO;前端守卫/白名单 UI 依据)。 + * 手机号脱敏返回(如 138****0000)。 + */ +export interface PlayerMeResp { + /** 玩家编号 */ + userId: number + /** 脱敏手机号(如 138****0000;后端禁回明文) */ + mobile: string + /** 昵称 */ + nickname: string + /** 头像 URL */ + avatar: string + /** 创作者白名单位:0玩家 1创作者 */ + creatorFlag: number +} + +/** + * 发送登录短信验证码(@PermitAll 免登;拍板1C 主路径)。 + * POST /app-api/passport/send-sms-code + * 防枚举:响应不区分手机号是否已注册,统一返回成功(data=true)。 + * @param dto 发码请求体(仅手机号) + */ +export function sendSmsCode(dto: SendSmsCodeReq): Promise { + return request({ + url: '/app-api/passport/send-sms-code', + method: 'post', + data: dto, + }) +} + +/** + * 手机号验证码登录(已注册登录 / 未注册自动注册一体,@PermitAll;拍板3 开放注册)。 + * POST /app-api/passport/sms-login + * @param dto 登录请求体(mobile + code + 可选 anonId) + * @returns 真 OAuth2 token 与玩家身份摘要 + */ +export function smsLogin(dto: SmsLoginReq): Promise { + return request({ + url: '/app-api/passport/sms-login', + method: 'post', + data: dto, + }) +} + +/** + * 邀请码受限激活注册(手机号+邀请码,零短信依赖,@PermitAll;拍板1C/2/3)。 + * POST /app-api/passport/invite-register + * @param dto 注册请求体(mobile + inviteCode + 可选 anonId/nickname) + * @returns 真 OAuth2 token 与玩家身份摘要(同 sms-login 响应) + */ +export function inviteRegister(dto: InviteRegisterReq): Promise { + return request({ + url: '/app-api/passport/invite-register', + method: 'post', + data: dto, + }) +} + +/** + * 当前登录玩家信息(登录态必需;未登录返回 401,由 request.ts 401 拦截兜底)。 + * GET /app-api/passport/me + * @returns 当前 token 对应玩家身份摘要(手机号脱敏) + */ +export function getMe(): Promise { + return request({ + url: '/app-api/passport/me', + method: 'get', + }) +} diff --git a/game-studio/src/api/request.ts b/game-studio/src/api/request.ts index 09ec07b6..dbeb04af 100644 --- a/game-studio/src/api/request.ts +++ b/game-studio/src/api/request.ts @@ -8,6 +8,8 @@ * 业务失败(code!==0)→ Vant Toast 提示 msg 并 reject,避免页面误把错误当成功数据。 * - 请求拦截:注入登录态 token(来自 userStore 持有的固定 mock token)。 * - 网络错误(超时/断网/非 200)→ 统一 Toast,不白屏(spec §6 mock 网络抖动)。 + * - 401 统一拦截(真实鉴权件 §9.4):HTTP 401 或业务包络 code===401 → 清登录态 + Toast + 跳 /login?redirect=当前路由 + * (信任边界在后端:免登端点照常 200,写入口/登录态端点未登录才 401;前端兜底引导登录)。 */ import axios, { type AxiosInstance, @@ -16,6 +18,47 @@ import axios, { } from 'axios' import { showToast } from 'vant' +/** 是否正在处理 401(防并发多请求同时触发重复清态/重复跳转/Toast 风暴) */ +let handling401 = false + +/** + * 401 统一处置(真实鉴权件 §9.4):清登录态 → Toast → 跳 /login 带回跳。 + * 懒取 store/router(与请求拦截器一致范式,规避循环依赖 + 确保 Pinia/Router 已挂载); + * 已在登录页则不重复跳,避免回跳地址套娃。 + */ +async function handle401(): Promise { + if (handling401) return + handling401 = true + try { + // 清登录态(清 localStorage 真 token;isRealLogin 此后为 false) + try { + const { useUserStore } = await import('../store/user') + useUserStore().logout() + } catch { + /* Pinia 未就绪:跳过清态,仍继续跳登录 */ + } + showToast({ message: '登录已失效,请重新登录', type: 'fail' }) + try { + const { default: router } = await import('../router') + const current = router.currentRoute.value + // 已在登录页则不重复跳(避免 redirect 套娃) + if (current.name !== 'login') { + await router.replace({ path: '/login', query: { redirect: current.fullPath } }) + } + } catch { + /* Router 未就绪:降级为整页跳转兜底 */ + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.assign('/login') + } + } + } finally { + // 跳转后短暂释放锁,避免极端场景永久卡死(下次 401 仍可处理) + setTimeout(() => { + handling401 = false + }, 1000) + } +} + /** yudao 统一响应包络结构(单一事实源 = 各契约 YAML 的 CommonResult*) */ export interface CommonResult { /** 0=成功;非 0=各模块独占错误码段(如 feed 1-103-***-***) */ @@ -89,6 +132,11 @@ function onFulfilled(response: AxiosResponse>): unknown { // 解包:业务层只关心 data return body.data } + // 业务包络 401:登录态失效(yudao 部分链路用包络 code 返回 401)→ 统一清态跳登录 + if (body.code === 401) { + void handle401() + return Promise.reject(Object.assign(new Error(body.msg || '登录已失效'), { code: 401 })) + } // 业务错误码:统一提示并中断 const message = body.msg || `请求失败(错误码 ${body.code})` showToast({ message, type: 'fail' }) @@ -99,6 +147,11 @@ service.interceptors.response.use( // 断言对齐 axios onFulfilled 签名(运行时返回解包后的 data) onFulfilled as (response: AxiosResponse) => AxiosResponse, (error) => { + // HTTP 401:登录态失效(token 过期/无效)→ 统一清态跳登录(不再走通用 Toast,避免重复提示) + if (error?.response?.status === 401) { + void handle401() + return Promise.reject(error) + } // 网络层错误(超时/断网/5xx 等):统一兜底提示,不白屏 const message = error?.code === 'ECONNABORTED' diff --git a/game-studio/src/router/index.ts b/game-studio/src/router/index.ts index 40a883bb..cd4bb2b7 100644 --- a/game-studio/src/router/index.ts +++ b/game-studio/src/router/index.ts @@ -9,6 +9,20 @@ */ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +/** + * 路由守卫所需的真实登录判定(懒取 userStore,避免模块顶层在 Pinia 挂载前初始化)。 + * 用 isRealLogin()(仅 localStorage 真 token 才算)——isLogin() 因 token 永有兜底值恒 true,不可用作守卫(§9.2/§9.3)。 + */ +async function isRealLogin(): Promise { + try { + const { useUserStore } = await import('../store/user') + return useUserStore().isRealLogin() + } catch { + // Pinia 极早期未就绪:保守视为未登录(守卫只放行已确认真实登录者) + return false + } +} + /** 路由记录(严格对齐 spec §3.1 的 path / 区 / 承载;component 均为真实 view 懒加载) */ const routes: RouteRecordRaw[] = [ // 根重定向到游戏流(默认首页) @@ -42,26 +56,34 @@ const routes: RouteRecordRaw[] = [ meta: { title: '分享', tab: 'feed', hideTabBar: true }, props: true, }, + // 登录/注册页(玩家区零门槛,守卫放行;支持 ?redirect= 回跳;真实鉴权件 §9.3) + { + path: '/login', + name: 'login', + component: () => import('../views/login/Login.vue'), + meta: { title: '登录', hideTabBar: true }, + }, // —— 创作区 —— { path: '/create', name: 'create', component: () => import('../views/create/Create.vue'), // B2 - meta: { title: '创作', tab: 'create' }, + // requiresAuth:创作区为登录态写入口(拍板4 白名单创作),未真实登录跳 /login(§9.3) + meta: { title: '创作', tab: 'create', requiresAuth: true }, }, { path: '/create/task/:taskId', name: 'create-task', component: () => import('../views/create/Task.vue'), // B2 进度轮询 - meta: { title: '生成中', tab: 'create', hideTabBar: true }, + meta: { title: '生成中', tab: 'create', hideTabBar: true, requiresAuth: true }, props: true, }, { path: '/create/preview/:versionId', name: 'create-preview', component: () => import('../views/create/Preview.vue'), // B2 + A2 GamePlayer - meta: { title: '预览', tab: 'create', hideTabBar: true }, + meta: { title: '预览', tab: 'create', hideTabBar: true, requiresAuth: true }, props: true, }, @@ -70,20 +92,21 @@ const routes: RouteRecordRaw[] = [ path: '/project', name: 'project', component: () => import('../views/project/Project.vue'), // B3 - meta: { title: '我的项目', tab: 'project' }, + // requiresAuth:我的项目区为登录态私有数据,未真实登录跳 /login(§9.3) + meta: { title: '我的项目', tab: 'project', requiresAuth: true }, }, { path: '/project/:id', name: 'project-detail', component: () => import('../views/project/Detail.vue'), // B3 - meta: { title: '项目详情', tab: 'project' }, + meta: { title: '项目详情', tab: 'project', requiresAuth: true }, props: true, }, { path: '/project/:id/publish', name: 'project-publish', component: () => import('../views/project/Publish.vue'), // B3 发布门禁 - meta: { title: '发布', tab: 'project', hideTabBar: true }, + meta: { title: '发布', tab: 'project', hideTabBar: true, requiresAuth: true }, props: true, }, @@ -101,4 +124,17 @@ const router = createRouter({ }, }) +/** + * 全局前置守卫(真实鉴权件 §9.3): + * - 目标路由 meta.requiresAuth 且未真实登录 → 跳 /login?redirect=<原目标全路径>; + * - feed/play/share 与 /login 等无 requiresAuth 路由一律放行(玩家区零门槛)。 + * 仅创作/项目区(写入口)受守卫;staging 批跑/agent 走 env token 直连 API,不经此 UI 守卫(零中断)。 + */ +router.beforeEach(async (to) => { + if (!to.meta?.requiresAuth) return true // 玩家区/登录页:放行 + if (await isRealLogin()) return true // 已真实登录:放行 + // 未登录访问受保护路由:导向登录页并带回跳地址(fullPath 含 query/hash) + return { path: '/login', query: { redirect: to.fullPath } } +}) + export default router diff --git a/game-studio/src/store/user.ts b/game-studio/src/store/user.ts index a3047232..9eab8b89 100644 --- a/game-studio/src/store/user.ts +++ b/game-studio/src/store/user.ts @@ -1,11 +1,51 @@ /** - * useUserStore —— 登录态(MVP 固定 mock 登录态)。 - * 提供 token(注入 axios Authorization)、userId/nickname、以及匿名 anonId(遥测用)。 - * 真实联调时由登录流程写入;MVP 阶段直接置一份固定假登录态,保证全链路可跑。 + * useUserStore —— 登录态(真实鉴权 + 匿名玩家身份,2026-06-10 鉴权件)。 + * 提供 token(注入 axios Authorization)、userId/nickname/creatorFlag、以及匿名 anonId(遥测用)。 + * + * 鉴权改造(spec §9.2): + * - setLogin:真实登录成功后调用,新增 token/userId/nickname/creatorFlag 落 localStorage 持久化(刷新不丢); + * - logout:同步清 localStorage; + * - token 初值优先级:localStorage 真 token > VITE_STUDIO_TOKEN(env,staging 批跑=test1) > 'mock-studio-token' 占位; + * - isRealLogin():仅「localStorage 真 token」视为真实登录——守卫/互动弹窗一律用它(isLogin() 因 token 永有兜底值恒 true,不可用作守卫,保留不动)。 + * staging 批跑/agent 走 env token 直连 API,不写 localStorage,故 isRealLogin()=false,不触发任何 UI 守卫,零中断。 */ import { defineStore } from 'pinia' import { ref } from 'vue' +/** localStorage 键名(鉴权持久化;与匿名 ID 键 wanxiang_anon_id 区分) */ +const LS_TOKEN = 'wanxiang_token' +const LS_REFRESH_TOKEN = 'wanxiang_refresh_token' +const LS_USER_ID = 'wanxiang_user_id' +const LS_NICKNAME = 'wanxiang_nickname' +const LS_CREATOR_FLAG = 'wanxiang_creator_flag' + +/** 安全读 localStorage(隐私模式等不可用时返回 null,不抛错) */ +function lsGet(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +/** 安全写 localStorage(不可用时静默跳过,登录态退化为内存态,不阻断流程) */ +function lsSet(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + /* 隐私模式等不可用:登录态仅内存生效(刷新即丢),不影响本次会话 */ + } +} + +/** 安全删 localStorage(登出/清态用) */ +function lsRemove(key: string): void { + try { + localStorage.removeItem(key) + } catch { + /* 忽略 */ + } +} + /** 生成/读取稳定匿名 ID(localStorage 持久化,匿名玩家遥测对账用) */ function ensureAnonId(): string { const KEY = 'wanxiang_anon_id' @@ -23,38 +63,112 @@ function ensureAnonId(): string { } } +/** + * token 初值解析(优先级:localStorage 真 token > env VITE_STUDIO_TOKEN > mock 占位)。 + * 真 token 来自上次真实登录的持久化;env token 服务 staging 批跑/agent(=test1);mock 占位保证全链路可跑。 + */ +function resolveInitToken(): string { + const real = lsGet(LS_TOKEN) + if (real) return real + const envToken = import.meta.env.VITE_STUDIO_TOKEN as string | undefined + if (envToken) return envToken + return 'mock-studio-token' +} + +/** userId 初值:真实登录持久化优先,否则 mock 固定 '1001'(mock 路径维持现状,evaluation R4) */ +function resolveInitUserId(): string { + return lsGet(LS_USER_ID) ?? '1001' +} + export const useUserStore = defineStore('user', () => { - /** 登录 Token:staging 联调取 env VITE_STUDIO_TOKEN(=test1,后端可识别的登录态);缺省回退 mock 占位(§2.4)。 */ - const token = ref((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token') - /** 用户 ID(mock 固定) */ - const userId = ref('1001') - /** 昵称(mock 固定) */ - const nickname = ref('绘境创作者') - /** 头像(mock 固定占位) */ + /** 登录 Token:localStorage 真 token > env VITE_STUDIO_TOKEN(=test1) > 'mock-studio-token' 占位(§9.2) */ + const token = ref(resolveInitToken()) + /** 用户 ID:真实登录持久化优先,否则 mock 固定 '1001' */ + const userId = ref(resolveInitUserId()) + /** 昵称(真实登录持久化优先,否则 mock 默认) */ + const nickname = ref(lsGet(LS_NICKNAME) ?? '绘境创作者') + /** 头像(mock 固定占位;passport 暂不回头像,保留内存态) */ const avatar = ref('') + /** 创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)。真实登录持久化优先,否则 0。 */ + const creatorFlag = ref(Number(lsGet(LS_CREATOR_FLAG) ?? '0')) /** 稳定匿名 ID(遥测 anonId) */ const anonId = ref(ensureAnonId()) - /** 是否已登录(有 token 即视为登录态) */ + /** + * 是否已登录(有 token 即视为登录态)。 + * 注意:因 token 永有 env/mock 兜底值,此函数恒返回 true,**不可用作路由守卫/互动弹窗判定**; + * 守卫与互动一律用 isRealLogin()。本函数保留不动(无现存调用方,避免连带改动)。 + */ function isLogin(): boolean { return !!token.value } - /** 设置登录态(联调登录成功后调用;MVP 一般不触发) */ - function setLogin(payload: { token: string; userId: string; nickname?: string; avatar?: string }) { + /** + * 是否「真实登录」:仅 localStorage 存在真 token 才算(§9.2 守卫/互动唯一判据)。 + * staging 批跑/agent 走 env token,不写 localStorage,故此处 false,不触发 UI 守卫(零中断)。 + */ + function isRealLogin(): boolean { + return !!lsGet(LS_TOKEN) + } + + /** + * 设置登录态(真实登录成功后调用,passport sms-login/invite-register 响应回填)。 + * 新增:token/userId/nickname/creatorFlag 落 localStorage 持久化(刷新不丢,§9.2); + * userId 由登录响应覆盖(1001/1 错位在真实路径消除,evaluation R4)。 + * @param payload 登录响应摘要(token 必填;creatorFlag 默认 0;refreshToken 可选持久化备查) + */ + function setLogin(payload: { + token: string + userId: string + nickname?: string + avatar?: string + creatorFlag?: number + refreshToken?: string + }) { token.value = payload.token userId.value = payload.userId if (payload.nickname) nickname.value = payload.nickname if (payload.avatar) avatar.value = payload.avatar + creatorFlag.value = payload.creatorFlag ?? 0 + + // 持久化真实登录态(isRealLogin 据 LS_TOKEN 判定,故真 token 必落) + lsSet(LS_TOKEN, payload.token) + lsSet(LS_USER_ID, payload.userId) + lsSet(LS_NICKNAME, nickname.value) + lsSet(LS_CREATOR_FLAG, String(creatorFlag.value)) + if (payload.refreshToken) lsSet(LS_REFRESH_TOKEN, payload.refreshToken) } - /** 退出登录(清空登录态,保留匿名 ID) */ + /** + * 退出登录 / 清登录态(主动登出 + 401 拦截清态共用)。 + * 同步清 localStorage 持久化态(清后 isRealLogin()=false);保留匿名 ID(anonId 不随登出失效)。 + * token 内存值清空:此后请求头不再带真 token(env/mock 不再回灌内存,避免 401 后仍用旧态)。 + */ function logout() { token.value = '' userId.value = '' nickname.value = '' avatar.value = '' + creatorFlag.value = 0 + + // 清持久化态 + lsRemove(LS_TOKEN) + lsRemove(LS_REFRESH_TOKEN) + lsRemove(LS_USER_ID) + lsRemove(LS_NICKNAME) + lsRemove(LS_CREATOR_FLAG) } - return { token, userId, nickname, avatar, anonId, isLogin, setLogin, logout } + return { + token, + userId, + nickname, + avatar, + creatorFlag, + anonId, + isLogin, + isRealLogin, + setLogin, + logout, + } }) diff --git a/game-studio/src/telemetry/index.ts b/game-studio/src/telemetry/index.ts index b0f3a10e..ca47a8ac 100644 --- a/game-studio/src/telemetry/index.ts +++ b/game-studio/src/telemetry/index.ts @@ -32,6 +32,9 @@ const BEACON_URL = API_BASE + '/app-api/telemetry/perf/beacon' */ export type TelemetryEventName = | 'create_start' + // 登录成功后前端补发(匿名↔登录身份衔接,2026-06-10 鉴权件):envelope.user 双带 userId+anonId, + // 已在 contracts/events.schema.json eventRegistry 登记(§9.5 契约先行) + | 'user_login' | 'generate_submit' | 'generate_succeeded' | 'generate_failed' diff --git a/game-studio/src/views/feed/Feed.vue b/game-studio/src/views/feed/Feed.vue index df788099..cb7cb884 100644 --- a/game-studio/src/views/feed/Feed.vue +++ b/game-studio/src/views/feed/Feed.vue @@ -14,9 +14,11 @@ * 既是 /feed 默认混合流,也复用为 /feed/zone/:zoneId 专区流(由 props.zoneId 决定)。 */ import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { storeToRefs } from 'pinia' +import { showConfirmDialog } from 'vant' import { useFeedStore } from '../../store/feed' +import { useUserStore } from '../../store/user' import { GameCard, InteractBar, LoadingBar, EmptyState } from '../../components' import { track } from '../../telemetry' import type { FeedAction } from '../../api/types' @@ -28,8 +30,10 @@ import type { FeedAction } from '../../api/types' */ const props = defineProps<{ zoneId?: string }>() +const route = useRoute() const router = useRouter() const feedStore = useFeedStore() +const userStore = useUserStore() // 注意:仅对 state 用 storeToRefs(保持响应式);actions 直接从 store 解构(函数无需 ref)。 const { list, hasMore, loading, zones } = storeToRefs(feedStore) @@ -140,11 +144,31 @@ function reportImpression(idx: number): void { * 分享(3):跳分享落地页;举报(4):弹原因输入(骨架用 prompt 极简兜底)。 * 赞(1)/藏(2):直接 store.interact。 * 同时按契约「两路互不替代」补发 telemetry 互动事件(feed.yaml §interact 描述)。 + * + * 互动转正(真实鉴权件 §9.4 拍板5):互动为登录态动作,入口最前置 isRealLogin() 判定, + * 未真实登录弹 Vant Dialog 引导跳 /login(体验优先,不直接发请求吃 401;request.ts 401 拦截作兜底)。 */ async function onInteract(action: FeedAction): Promise { const card = activeCard.value if (!card) return + // 互动转正前置门:未真实登录 → 弹引导登录(确认跳 /login 带回跳,取消则留在当前流) + if (!userStore.isRealLogin()) { + try { + await showConfirmDialog({ + title: '登录后才能互动', + message: '登录账号即可点赞、收藏、分享你喜欢的小游戏', + confirmButtonText: '去登录', + cancelButtonText: '再看看', + }) + // 确认:跳登录并带当前路由回跳(登录成功回到本流继续互动) + router.push({ path: '/login', query: { redirect: route.fullPath } }) + } catch { + // 取消/关闭弹窗(showConfirmDialog reject):不打断浏览,静默返回 + } + return + } + // 分享:契约动作=3,同时跳分享落地页(OG 元数据由 share 页拉取) if (action === 3) { await feedStore.interact(card.gameId, 3).catch(() => {}) diff --git a/game-studio/src/views/login/Login.vue b/game-studio/src/views/login/Login.vue new file mode 100644 index 00000000..3e553903 --- /dev/null +++ b/game-studio/src/views/login/Login.vue @@ -0,0 +1,388 @@ + + + + + + + 绘境AI + 登录后即可创作、互动你的小游戏 + + + + + + 验证码登录 + + + 邀请码注册 + + + + + + + + + + + + + + + + {{ countdown > 0 ? `${countdown}s 后重发` : sending ? '发送中…' : '获取验证码' }} + + + 未注册手机号将自动注册并登录 + + + + + + + + 内测邀请:当前为受限激活期,需邀请码注册 + + + + + + + 我已阅读并同意 + 《用户协议》 + 与 + 《隐私政策》 + (占位,正式文案待法务) + + + + + + {{ activeTab === 'sms' ? '登录 / 注册' : '注册并登录' }} + + + + + +
登录后即可创作、互动你的小游戏
未注册手机号将自动注册并登录
内测邀请:当前为受限激活期,需邀请码注册