feat(passport): 鉴权建设波——V11契约+system passport子包+14端点@PermitAll+白名单挂点+剔除桩+前端登录守卫
- 契约先行: V11.0.0 双落点(game_player/game_invite_code/runtime_session匿名兼容ALTER) + passport.yaml(4 app+4 admin) + events登记user_login - B1 system侧: passport独立子包(fork隔离), 验证码+邀请码旁路登录注册一体, OAuth2 MEMBER token(client=default已核), 错误码1_002_090/091远段, PlayerApi @Primary本地化(mock豁免裁决=无行+mockEnable放行), R6限频(IP 10次/h+Redis日/时双闸), 25单测 - B2 存量: @PermitAll恰好14端点(主agent grep复核, 不放行名单零触碰), 匿名session(player_user_id可空+anon_id), 白名单5挂点(studio/aigc/project), ad计费限频, telemetry剔除桩(照落库不进聚合+Redis降级放行), EnvelopeReqVO文案回写 - F1 前端: 登录页两Tab+占位协议, isRealLogin守卫(isLogin恒真不可用), 401统一拦截, 互动转正onInteract单入口gating, user_login遥测双带; test1批跑路径原样保留(主agent复核) - 三面对抗核验全pass零blocker; 构建门待mini-desktop(本机禁构建铁律) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8a59a35133
commit
b0850a2ad9
308
contracts/api-schemas/passport.yaml
Normal file
308
contracts/api-schemas/passport.yaml
Normal file
@ -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: 创建时间 }
|
||||
@ -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)';
|
||||
@ -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" },
|
||||
|
||||
@ -66,6 +66,12 @@
|
||||
<artifactId>yudao-spring-boot-starter-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 服务保证(限流):计费上报两端点 @RateLimiter IP 维度限频(2026-06-10 鉴权件 §6.2 条件1,挡匿名批量伪造曝光) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>yudao-spring-boot-starter-protection</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RPC 远程调用(实现本模块 AdRevenueApi + 调 project 的 ProjectApi 做归因) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -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<List<AdSlotConfigVO>> 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<AdReportResultVO> 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<AdRewardResultVO> reportReward(@Valid @RequestBody AdRewardReqVO reqVO) {
|
||||
return success(adRevenueService.reportReward(reqVO));
|
||||
}
|
||||
|
||||
@ -46,6 +46,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 system 的 -api:直发生成入口校验创作者白名单(PlayerApi#validateCreator,2026-06-10 鉴权件 §6.3,拍板4 可信边界) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>yudao-module-system-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限(创作者只见自己任务)+ 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -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<TemplateRespVO> 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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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<FeedStreamRespVO> 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<List<ZoneRespVO>> 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<FeedStreamRespVO> 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<ShareMetaRespVO> getShareMeta(@PathVariable("gameId") Long gameId,
|
||||
|
||||
@ -52,6 +52,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 system 的 -api:创建项目/提交发布入口校验创作者白名单(PlayerApi#validateCreator,2026-06-10 鉴权件 §6.3,拍板4 可信边界) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>yudao-module-system-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限(创作者只见自己数据)+ 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -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<ProjectDO> 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<SessionStartRespVO> 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<Boolean> endSession(@Valid @RequestBody SessionEndReqVO reqVO) {
|
||||
// 匿名会话同样可结束(userId 可空),endSession 以 sessionId 定位、不依赖 userId 鉴权
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
runtimeSessionService.endSession(reqVO, userId);
|
||||
return success(true);
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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 唯一)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<RuntimeSessionDO> 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<RuntimeSessionDO> captor = ArgumentCaptor.forClass(RuntimeSessionDO.class);
|
||||
verify(runtimeSessionMapper).insert(captor.capture());
|
||||
assertEquals("", captor.getValue().getAnonId(), "anonId 缺失应落空串");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStartSession_illegalSceneFallbackToPlay() {
|
||||
// 非法/未知 scene → 回落 play
|
||||
|
||||
@ -46,6 +46,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 system 的 -api:建草稿/提交生成入口校验创作者白名单(PlayerApi#validateCreator,2026-06-10 鉴权件 §6.3,拍板4 可信边界) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>yudao-module-system-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -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<AigcTemplateDTO> 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);
|
||||
|
||||
|
||||
@ -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; })
|
||||
|
||||
@ -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<EventBatchResultVO> 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<Boolean> ingestBeacon(@Valid @RequestBody EnvelopeReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<String, Object> 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<TelemetryEventDO> 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
|
||||
|
||||
@ -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<String, String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<Boolean>:data=true 表示校验通过(放行);校验失败时 code≠0
|
||||
*/
|
||||
@GetMapping(PREFIX + "/validate-creator")
|
||||
@Operation(summary = "校验玩家是否为白名单创作者")
|
||||
@Parameter(name = "userId", description = "玩家用户 ID", required = true, example = "1024")
|
||||
CommonResult<Boolean> validateCreator(@RequestParam("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 读玩家身份摘要(最小字段,跨模块按需取 creatorFlag/status)
|
||||
*
|
||||
* @param userId 玩家用户 ID
|
||||
* @return CommonResult<PlayerRespDTO>:玩家不存在时 data=null(调用方判空,不抛)
|
||||
*/
|
||||
@GetMapping(PREFIX + "/get")
|
||||
@Operation(summary = "读玩家身份摘要")
|
||||
@Parameter(name = "userId", description = "玩家用户 ID", required = true, example = "1024")
|
||||
CommonResult<PlayerRespDTO> getPlayer(@RequestParam("userId") Long userId);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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 多次冲突重试仍失败兜底(极低概率)
|
||||
|
||||
}
|
||||
|
||||
@ -97,11 +97,11 @@
|
||||
<artifactId>yudao-spring-boot-starter-mq</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 服务保障相关 TODO 芋艿:暂时去掉 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>cn.iocoder.cloud</groupId>-->
|
||||
<!-- <artifactId>yudao-spring-boot-starter-protection</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- 服务保障相关:passport 发码端点 @RateLimiter(IP 维度限频,R6 §7.2)需此 starter,2026-06-10 鉴权件取消注释 -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>yudao-spring-boot-starter-protection</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test 测试相关 -->
|
||||
<dependency>
|
||||
|
||||
@ -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<Boolean> validateCreator(Long userId) {
|
||||
// 校验不过由 Service 抛 1-002-090 段 ServiceException(被 GlobalExceptionHandler 转 CommonResult code≠0),
|
||||
// 调用方 .getCheckedData()/.checkError() 触发异常向上传播,拒绝创作动作。
|
||||
passportService.validateCreator(userId);
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<PlayerRespDTO> getPlayer(Long userId) {
|
||||
return success(passportService.getPlayer(userId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<List<InviteCodeRespVO>> generate(@Valid @RequestBody InviteCodeGenerateReqVO reqVO) {
|
||||
List<InviteCodeDO> 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<PageResult<InviteCodeRespVO>> getPage(@Valid InviteCodePageReqVO reqVO) {
|
||||
PageResult<InviteCodeDO> 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<Boolean> disable(@Valid @RequestBody InviteCodeDisableReqVO reqVO) {
|
||||
inviteCodeService.disableInviteCode(reqVO.getId());
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<Boolean> setCreator(@Valid @RequestBody PlayerSetCreatorReqVO reqVO) {
|
||||
passportService.setCreatorFlag(reqVO.getUserId(), reqVO.getCreatorFlag());
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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<Boolean> 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<LoginRespVO> 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<LoginRespVO> inviteRegister(@Valid @RequestBody InviteRegisterReqVO reqVO) {
|
||||
return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP()));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "当前登录玩家信息", description = "登录态必需(无 @PermitAll,未登录 401);手机号脱敏返回")
|
||||
public CommonResult<PlayerMeRespVO> getPlayerMe() {
|
||||
// 登录态:从 token 解析当前玩家 ID(非前端入参,保证数据边界)
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return success(passportService.getPlayerMe(userId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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<InviteCodeDO> {
|
||||
|
||||
/**
|
||||
* 按码值查邀请码(命中 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<InviteCodeDO> update = new LambdaUpdateWrapper<InviteCodeDO>()
|
||||
.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<InviteCodeDO> update = new LambdaUpdateWrapper<InviteCodeDO>()
|
||||
.set(InviteCodeDO::getStatus, 1)
|
||||
.eq(InviteCodeDO::getId, id);
|
||||
return update(null, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请码分页查询(admin 端,按 batchNo/status 过滤,含 used_count 核销进度)
|
||||
*
|
||||
* @param reqVO 分页 + 可选 batchNo/status 过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
default PageResult<InviteCodeDO> selectPage(InviteCodePageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<InviteCodeDO>()
|
||||
.eqIfPresent(InviteCodeDO::getBatchNo, reqVO.getBatchNo())
|
||||
.eqIfPresent(InviteCodeDO::getStatus, reqVO.getStatus())
|
||||
.orderByDesc(InviteCodeDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<PlayerDO> {
|
||||
|
||||
/**
|
||||
* 按手机号查玩家(命中 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<PlayerDO> update = new LambdaUpdateWrapper<PlayerDO>()
|
||||
.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<PlayerDO> update = new LambdaUpdateWrapper<PlayerDO>()
|
||||
.set(PlayerDO::getLoginDate, loginDate)
|
||||
.eq(PlayerDO::getId, userId);
|
||||
return update(null, update);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<InviteCodeDO> generateInviteCodes(InviteCodeGenerateReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 邀请码分页查询(admin 端,含核销进度)
|
||||
*
|
||||
* @param reqVO 分页 + batchNo/status 过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<InviteCodeDO> 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);
|
||||
|
||||
}
|
||||
@ -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<InviteCodeDO> generateInviteCodes(InviteCodeGenerateReqVO reqVO) {
|
||||
// 数量上界由 VO @Max(100) 兜底,此处不再重复校验
|
||||
List<InviteCodeDO> 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<InviteCodeDO> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<InviteCodeDO> codes = inviteCodeService.generateInviteCodes(reqVO);
|
||||
|
||||
// 数量正确
|
||||
assertEquals(50, codes.size());
|
||||
Set<String> 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<Long> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO> 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<PlayerDO> 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<PlayerDO> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 刷量)
|
||||
|
||||
@ -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)';
|
||||
130
game-studio/src/api/passport.ts
Normal file
130
game-studio/src/api/passport.ts
Normal file
@ -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<boolean> {
|
||||
return request<boolean>({
|
||||
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<LoginResp> {
|
||||
return request<LoginResp>({
|
||||
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<LoginResp> {
|
||||
return request<LoginResp>({
|
||||
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<PlayerMeResp> {
|
||||
return request<PlayerMeResp>({
|
||||
url: '/app-api/passport/me',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@ -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<void> {
|
||||
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<T> {
|
||||
/** 0=成功;非 0=各模块独占错误码段(如 feed 1-103-***-***) */
|
||||
@ -89,6 +132,11 @@ function onFulfilled(response: AxiosResponse<CommonResult<unknown>>): 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'
|
||||
|
||||
@ -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<boolean> {
|
||||
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
|
||||
|
||||
@ -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<string>((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token')
|
||||
/** 用户 ID(mock 固定) */
|
||||
const userId = ref<string>('1001')
|
||||
/** 昵称(mock 固定) */
|
||||
const nickname = ref<string>('绘境创作者')
|
||||
/** 头像(mock 固定占位) */
|
||||
/** 登录 Token:localStorage 真 token > env VITE_STUDIO_TOKEN(=test1) > 'mock-studio-token' 占位(§9.2) */
|
||||
const token = ref<string>(resolveInitToken())
|
||||
/** 用户 ID:真实登录持久化优先,否则 mock 固定 '1001' */
|
||||
const userId = ref<string>(resolveInitUserId())
|
||||
/** 昵称(真实登录持久化优先,否则 mock 默认) */
|
||||
const nickname = ref<string>(lsGet(LS_NICKNAME) ?? '绘境创作者')
|
||||
/** 头像(mock 固定占位;passport 暂不回头像,保留内存态) */
|
||||
const avatar = ref<string>('')
|
||||
/** 创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)。真实登录持久化优先,否则 0。 */
|
||||
const creatorFlag = ref<number>(Number(lsGet(LS_CREATOR_FLAG) ?? '0'))
|
||||
/** 稳定匿名 ID(遥测 anonId) */
|
||||
const anonId = ref<string>(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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<void> {
|
||||
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(() => {})
|
||||
|
||||
388
game-studio/src/views/login/Login.vue
Normal file
388
game-studio/src/views/login/Login.vue
Normal file
@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登录页 | Login.vue(路由 /login,真实鉴权件 spec §9.1)
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责:
|
||||
* 1. 两 Tab 登录/注册:
|
||||
* ① 手机号 + 验证码(主路径,拍板1C):send-sms-code 发码 → sms-login 登录/自动注册一体;
|
||||
* ② 邀请码注册(受限激活期陌生玩家入口,拍板2/3):mobile + inviteCode → invite-register,
|
||||
* 页内文案注明「内测邀请」。
|
||||
* 2. 隐私政策/用户协议占位勾选(P-ACC-02 占位提前;未勾选不可提交)。
|
||||
* 3. 携带 useUserStore().anonId 入参(匿名↔登录身份衔接,拍板7=B)。
|
||||
* 4. 支持 ?redirect= 回跳(守卫/互动跳转登录后回到原页)。
|
||||
* 5. 登录成功:setLogin 落持久化 → 补发 user_login 遥测(双带 userId+anonId)→ 回跳。
|
||||
*
|
||||
* 契约纪律:仅消费 api/passport(严格贴 passport.yaml)与 store/user / telemetry,不直连后端、不发明端点。
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { useUserStore } from '../../store/user'
|
||||
import { sendSmsCode, smsLogin, inviteRegister, type LoginResp } from '../../api/passport'
|
||||
import { track } from '../../telemetry'
|
||||
import { AppButton } from '../../components'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 */
|
||||
const activeTab = ref<'sms' | 'invite'>('sms')
|
||||
|
||||
/** 手机号(两 Tab 共用) */
|
||||
const mobile = ref<string>('')
|
||||
/** 短信验证码(sms Tab) */
|
||||
const code = ref<string>('')
|
||||
/** 邀请码(invite Tab) */
|
||||
const inviteCode = ref<string>('')
|
||||
/** 隐私政策/用户协议勾选(占位;未勾选不可提交) */
|
||||
const agreed = ref<boolean>(false)
|
||||
|
||||
/** 发码倒计时秒数(>0 时禁用发码按钮,防频繁发码) */
|
||||
const countdown = ref<number>(0)
|
||||
/** 发码请求中(防重复点击) */
|
||||
const sending = ref<boolean>(false)
|
||||
/** 登录/注册提交中(防重复提交) */
|
||||
const submitting = ref<boolean>(false)
|
||||
|
||||
/** 中国大陆手机号简单校验(11 位,1 开头第二位 3-9) */
|
||||
const MOBILE_RE = /^1[3-9]\d{9}$/
|
||||
/** 手机号是否合法 */
|
||||
const mobileValid = computed(() => MOBILE_RE.test(mobile.value.trim()))
|
||||
|
||||
/**
|
||||
* 解析回跳地址(?redirect=)。
|
||||
* 仅接受站内相对路径(以 / 开头且非 //),防开放重定向;缺省回 /feed。
|
||||
*/
|
||||
const redirectTarget = computed<string>(() => {
|
||||
const r = route.query.redirect
|
||||
const raw = Array.isArray(r) ? r[0] : r
|
||||
if (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) {
|
||||
return raw
|
||||
}
|
||||
return '/feed'
|
||||
})
|
||||
|
||||
/** 发码按钮是否可点:手机号合法 + 不在发码中 + 倒计时结束 */
|
||||
const canSend = computed(() => mobileValid.value && !sending.value && countdown.value === 0)
|
||||
|
||||
/** 提交按钮是否可点(按当前 Tab 校验必填项 + 已勾选协议 + 不在提交中) */
|
||||
const canSubmit = computed(() => {
|
||||
if (!agreed.value || submitting.value || !mobileValid.value) return false
|
||||
if (activeTab.value === 'sms') return code.value.trim().length > 0
|
||||
return inviteCode.value.trim().length > 0
|
||||
})
|
||||
|
||||
/** 启动 60s 发码倒计时 */
|
||||
function startCountdown(): void {
|
||||
countdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码(sms Tab)。
|
||||
* 调 passport.sendSmsCode;防枚举:后端不区分手机号是否已注册,统一受理。
|
||||
* 失败由 request.ts 拦截器统一 Toast 错误码(此处仅解锁按钮,不重复弹)。
|
||||
*/
|
||||
async function onSendCode(): Promise<void> {
|
||||
if (!canSend.value) {
|
||||
if (!mobileValid.value) showToast({ message: '请输入正确的手机号', type: 'fail' })
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await sendSmsCode({ mobile: mobile.value.trim() })
|
||||
showToast({ message: '验证码已发送(种子期 Debug 渠道,后台查码)', type: 'success' })
|
||||
startCountdown()
|
||||
} catch {
|
||||
// 错误码已由拦截器 Toast;此处仅解锁
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功收口:落登录态持久化 → 补发 user_login 遥测 → 回跳。
|
||||
* 顺序铁律:先 setLogin(此后 token=真 token、userId=登录 ID),再 track,
|
||||
* 这样 telemetry buildEnvelope 读取的 user 才是「真 userId + 此前匿名 anonId」双带(§9.5)。
|
||||
* @param resp 登录/注册响应
|
||||
*/
|
||||
function afterLoginSuccess(resp: LoginResp): void {
|
||||
userStore.setLogin({
|
||||
token: resp.accessToken,
|
||||
userId: String(resp.userId),
|
||||
nickname: resp.nickname,
|
||||
creatorFlag: resp.creatorFlag,
|
||||
refreshToken: resp.refreshToken,
|
||||
})
|
||||
// 补发登录遥测:buildEnvelope 自动双带 userId(已置真值)+anonId(匿名↔登录身份衔接)
|
||||
track('user_login', { channel: activeTab.value })
|
||||
showToast({ message: '登录成功', type: 'success' })
|
||||
// 回跳原页(replace 避免回退键回到登录页)
|
||||
router.replace(redirectTarget.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交(按当前 Tab 分派):
|
||||
* - sms:smsLogin(已注册登录 / 未注册自动注册一体);
|
||||
* - invite:inviteRegister(手机号+邀请码受限激活)。
|
||||
* 两路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码,仅解锁。
|
||||
*/
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (!canSubmit.value) {
|
||||
if (!agreed.value) showToast({ message: '请先阅读并勾选用户协议与隐私政策', type: 'fail' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const anonId = userStore.anonId
|
||||
let resp: LoginResp
|
||||
if (activeTab.value === 'sms') {
|
||||
resp = await smsLogin({ mobile: mobile.value.trim(), code: code.value.trim(), anonId })
|
||||
} else {
|
||||
resp = await inviteRegister({
|
||||
mobile: mobile.value.trim(),
|
||||
inviteCode: inviteCode.value.trim(),
|
||||
anonId,
|
||||
})
|
||||
}
|
||||
afterLoginSuccess(resp)
|
||||
} catch {
|
||||
// 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 已注册等 1-002-090/091)已由拦截器 Toast;解锁停留本页
|
||||
submitting.value = false
|
||||
}
|
||||
// 成功后 router.replace 跳转,本组件卸载,无需复位 submitting
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 顶部品牌区 -->
|
||||
<header class="login-head">
|
||||
<h1 class="login-title">绘境AI</h1>
|
||||
<p class="login-sub">登录后即可创作、互动你的小游戏</p>
|
||||
</header>
|
||||
|
||||
<!-- Tab 切换:验证码登录 / 邀请码注册 -->
|
||||
<div class="login-tabs">
|
||||
<button
|
||||
class="login-tab"
|
||||
:class="{ 'is-active': activeTab === 'sms' }"
|
||||
type="button"
|
||||
@click="activeTab = 'sms'"
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
<button
|
||||
class="login-tab"
|
||||
:class="{ 'is-active': activeTab === 'invite' }"
|
||||
type="button"
|
||||
@click="activeTab = 'invite'"
|
||||
>
|
||||
邀请码注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表单区 -->
|
||||
<section class="login-form">
|
||||
<!-- 手机号(两 Tab 共用) -->
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="mobile"
|
||||
class="field-input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 验证码登录:验证码 + 发码按钮 -->
|
||||
<template v-if="activeTab === 'sms'">
|
||||
<div class="field field--code">
|
||||
<input
|
||||
v-model="code"
|
||||
class="field-input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入短信验证码"
|
||||
/>
|
||||
<button class="code-btn" type="button" :disabled="!canSend" @click="onSendCode">
|
||||
{{ countdown > 0 ? `${countdown}s 后重发` : sending ? '发送中…' : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="login-hint">未注册手机号将自动注册并登录</p>
|
||||
</template>
|
||||
|
||||
<!-- 邀请码注册:邀请码(受限激活期陌生玩家入口) -->
|
||||
<template v-else>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="inviteCode"
|
||||
class="field-input"
|
||||
type="text"
|
||||
maxlength="16"
|
||||
placeholder="请输入内测邀请码"
|
||||
/>
|
||||
</div>
|
||||
<p class="login-hint">内测邀请:当前为受限激活期,需邀请码注册</p>
|
||||
</template>
|
||||
|
||||
<!-- 隐私政策/用户协议占位勾选(未勾选不可提交) -->
|
||||
<label class="agree">
|
||||
<input v-model="agreed" type="checkbox" class="agree-box" />
|
||||
<span class="agree-text">
|
||||
我已阅读并同意
|
||||
<a class="agree-link" href="javascript:void(0)">《用户协议》</a>
|
||||
与
|
||||
<a class="agree-link" href="javascript:void(0)">《隐私政策》</a>
|
||||
<span class="agree-placeholder">(占位,正式文案待法务)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- 提交 -->
|
||||
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
|
||||
{{ activeTab === 'sms' ? '登录 / 注册' : '注册并登录' }}
|
||||
</AppButton>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* D4 深色科技风(token 来自全局 src/styles/tokens.css) */
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 48px 20px calc(20px + var(--safe-bottom));
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* —— 品牌区 —— */
|
||||
.login-head {
|
||||
margin-bottom: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
/* 青→紫渐变标题,呼应科技风 */
|
||||
background: linear-gradient(90deg, var(--cyan), var(--violet));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.login-sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* —— Tab 切换 —— */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.login-tab.is-active {
|
||||
border-color: var(--cyan);
|
||||
color: var(--cyan);
|
||||
box-shadow: 0 0 0 1px var(--cyan), 0 0 12px rgba(49, 230, 255, 0.2);
|
||||
}
|
||||
|
||||
/* —— 表单区 —— */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 12px;
|
||||
}
|
||||
.field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* 验证码行:右侧发码按钮 */
|
||||
.field--code .code-btn {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cyan);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.field--code .code-btn:disabled {
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* —— 提示文案 —— */
|
||||
.login-hint {
|
||||
margin: -6px 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* —— 协议勾选 —— */
|
||||
.agree {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 2px 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.agree-box {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--cyan);
|
||||
}
|
||||
.agree-link {
|
||||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
.agree-placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user