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:
zizi 2026-06-10 09:40:10 +00:00
parent 8a59a35133
commit b0850a2ad9
66 changed files with 4109 additions and 40 deletions

View File

@ -0,0 +1,308 @@
openapi: 3.0.3
# 契约 #8 API | 模块passport真实鉴权 / 匿名玩家 / 邀请码受限激活)| ownerpassportsystem 内扩展,方案乙),全员 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****0000token 不落日志验收5
# 防枚举口径send-sms-code 响应不区分手机号是否已注册(见端点 description
# 边界 OUT不做三方社交登录 / 不开多租户tenant.enable=false新表 tenant_id=1/ 不做实名收集流程(仅表字段预留)/
# 不做匿名影子账号合并拍板5=A/ 不做服务端 anonId 校验签名拍板7=B 既定代价)/ game-admin 前端零改动(邀请码经 Knife4j/脚本调 admin-apiRBAC 管控§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-090anonId 可选,落 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_flagA2 种子白名单的运营动作落点拍板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 的 userIduserType=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: 创建时间 }

View File

@ -0,0 +1,88 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题真实鉴权与匿名玩家身份HJ-PASSPORT-EXEC-001 §4.2| ownerpassportsystem 内扩展,方案乙)
-- 文件V11.0.0__create_passport_player_invite.sqlFlyway只新增/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 的 userIduserType=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 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 让位声明§4.1V10 已被黄金闭环占、V11 被本件占Wave4 community/biz 原规划的 V10/V11 顺延 V12/V13。
-- 约定InnoDB + utf8mb4显式列含 Yudao 审计列 + tenant_idMVP 单租户=1tenant.enable=false状态机用 tinyint非法流转由服务端拒绝。
-- 错误码段:玩家身份 = 1-002-090-***;邀请码 = 1-002-091-***(落 system 段,见 passport.yaml 与 ErrorCodeConstants
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表game_player —— 玩家/创作者最小身份system passport 扩展,方案乙)
-- id 即 OAuth2 token 的 userIduserType=MEMBER登录主键=mobile。
-- 拍板6real_name/id_card_no 仅预留AES 密文,应用层加密),提现前才收集,本件不写入。
-- 拍板4creator_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 的 userIduserType=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 '租户 IDYudao 多租户兼容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 '租户 IDMVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`, `deleted`, `tenant_id`) COMMENT '码值唯一(含 deleted/tenant_id 适配逻辑删+多租户)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '邀请码报备前玩家受限激活旁路报备后退役拍板1C/2';
-- -----------------------------------------------------------------------------
-- ALTERgame_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.3V3 存量行 player_user_id 均有值mock 喂 1MODIFY 放宽 NULL 不破坏存量;
-- anon_id DEFAULT '' 兼容存量。回滚V11.0.1)还原 NOT NULL 前须确认无 NULL 行anon_id 列可留存不删,无害。
-- 口径anon_id 与 telemetry game_telemetry_event.anon_idV5同口径纯客户端 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';

View File

@ -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" },

View File

@ -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>

View File

@ -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缺一不得合入两计费端点 @RateLimiterIP 维度幂等已有(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 初始化用;对齐契约#7providerSlotId 不下发明文")
@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 = "计费级,幂等键=traceIdmock 算 eCPM 收入落台账")
@PermitAll // 2026-06-10 鉴权件放行匿名合法匿名曝光计费不丢§6.1 #13 / §6.2 裁决进免登清单绑定三条件
// §6.2 条件1 限频IP 维度ClientIpRateLimiterKeyResolver60 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 限频条件1mock 算 eCPM 收入落台账")
public CommonResult<AdReportResultVO> reportImpression(@Valid @RequestBody AdImpressionReqVO reqVO) {
return success(adRevenueService.reportImpression(reqVO));
}
@PostMapping("/report/reward")
@Operation(summary = "激励视频完成校验", description = "幂等键=traceIdmock 直接校验通过并计激励收入")
@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 限频条件1mock 直接校验通过并计激励收入")
public CommonResult<AdRewardResultVO> reportReward(@Valid @RequestBody AdRewardReqVO reqVO) {
return success(adRevenueService.reportReward(reqVO));
}

View File

@ -46,6 +46,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 system 的 -api直发生成入口校验创作者白名单PlayerApi#validateCreator2026-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>

View File

@ -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;
/**
* 创作者白名单校验 seam2026-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);

View File

@ -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.3submitGenerate 入层创作者白名单校验依赖
// ============================== 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拍板4validateCreator 返回 code0.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

View File

@ -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")

View File

@ -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,

View File

@ -52,6 +52,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 system 的 -api创建项目/提交发布入口校验创作者白名单PlayerApi#validateCreator2026-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>

View File

@ -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;
/**
* 创作者白名单校验 seam2026-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

View File

@ -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-publishAPPROVE 时调用
@Mock
private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3createProject/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拍板4createProject 在白名单校验处即中断不落库可信边界在后端挡直连 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

View File

@ -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 场景门禁在 Servicestatus 权威判定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 #7userId 可空时落 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);

View File

@ -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;
}

View File

@ -39,8 +39,16 @@ public class RuntimeSessionDO extends TenantBaseDO {
private Long versionId;
/**
* 试玩玩家用户 IDDataPermission预览场景=创作者本人
*
* 2026-06-10 鉴权件匿名试玩为 NULLV11 ALTER 放宽 player_user_id NOT NULLNULL归属落 anonId
*/
private Long playerUserId;
/**
* 客户端匿名 ID匿名会话归属对应列 anon_idV11 ADD COLUMN
*
* telemetry game_telemetry_event.anon_id 同口径纯客户端 anonId拍板7=B登录态会话为空串
*/
private String anonId;
/**
* 客户端本局唯一 token幂等键防抖/重连不重复计数uk_play_token 唯一
*/

View File

@ -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=NULLV11 已放宽 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;
}

View File

@ -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 NULLV11 已放宽 NOT NULLanonId 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

View File

@ -46,6 +46,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 system 的 -api建草稿/提交生成入口校验创作者白名单PlayerApi#validateCreator2026-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>

View File

@ -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;
/**
* 创作者白名单校验 seam2026-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);

View File

@ -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.3createDraft/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 返回 code0PLAYER_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; })

View File

@ -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();

View File

@ -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 = "纯客户端生成的稳定匿名 IDlocalStorage 持久化;身份不可信,防伪造/刷量由聚合侧按 anonId/IP 异常剔除兜底——2026-06-10 鉴权拍板 §9-7=B取代旧'匿名 token 派生'机制)", example = "anon-xyz")
private String anonId;
}

View File

@ -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);
}
/**

View File

@ -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 条件 3ad 反作弊接同一判定输出
* - 超阈值粗暴初值 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 请求来源 IPIP 维度计数为空时退化为仅 anonId 维度
* @return true=应剔除不进聚合false=正常进聚合
*/
boolean shouldExclude(EnvelopeReqVO envelope, String ip);
}

View File

@ -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 × eventKindanonId 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/sharequality 重算输入刷这些会污染质量分
* - 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;
}
}

View File

@ -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() {
// 剔除桩命中§8engagement 事件被判异常刷量 原始事件照常落库+ 置已聚合不进聚合不触发 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

View File

@ -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 + gameIdINCR 返回 50100 不剔除
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 条件3ad 计费事件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;
}
}

View File

@ -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 服务 - 玩家身份 APIpassport 对外创作者白名单校验契约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_WHITELIST1-002-090
* 3) 行存在 creator_flag 判定1 放行0 PLAYER_CREATE_NOT_IN_WHITELISTstatus=1 禁用直接拒PLAYER_IS_DISABLED
* 校验失败由 server 侧抛对应错误码CommonResult 承载 code0调用方 .getCheckedData() / .checkError() 触发异常向上传播
*
* @param userId 当前登录玩家用户 ID token 解析非前端入参
* @return CommonResult<Boolean>data=true 表示校验通过放行校验失败时 code0
*/
@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);
}

View File

@ -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 userIduserType=MEMBER
*/
private Long userId;
/**
* 状态0正常 1禁用对齐 CommonStatusEnum
*/
private Integer status;
/**
* 创作者白名单位0玩家 1创作者A2 种子名单拍板4
*/
private Integer creatorFlag;
}

View File

@ -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, "创作功能限内测白名单,当前账号暂无创作权限"); // 拍板4creator_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 多次冲突重试仍失败兜底极低概率
}

View File

@ -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 发码端点 @RateLimiterIP 维度限频R6 §7.2)需此 starter2026-06-10 鉴权件取消注释 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-protection</artifactId>
</dependency>
<!-- Test 测试相关 -->
<dependency>

View File

@ -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 code0 + 服务端 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 code0
// 调用方 .getCheckedData()/.checkError() 触发异常向上传播拒绝创作动作
passportService.validateCreator(userId);
return success(Boolean.TRUE);
}
@Override
public CommonResult<PlayerRespDTO> getPlayer(Long userId) {
return success(passportService.getPlayer(userId));
}
}

View File

@ -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);
}
}

View File

@ -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_flagA2 种子白名单拍板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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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对齐契约 PlayerSetCreatorReqVOA2 名单入口拍板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;
}

View File

@ -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 /小时/IPR6 §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.2IP 维度限频 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) {
// 取真实客户端 IPIP 维度频控 + 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));
}
}

View File

@ -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;
}

View File

@ -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对齐契约 LoginRespVOsms-login invite-register 共用
*
* 安全基线token 不落日志验收5手机号不在此返回
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 登录/注册成功响应 VO")
@Data
public class LoginRespVO {
@Schema(description = "玩家编号(即 OAuth2 token 的 userIduserType=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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
/**
* 邀请码/口令生成时随机大写字母数字避易混淆字符 0O1Iuk_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;
}

View File

@ -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_playersystem passport 扩展方案乙
*
* 字段严格对齐契约 contracts/db-schemas/V11.0.0__create_passport_player_invite.sql
* id OAuth2 token userIduserType=MEMBER(1)登录主键=mobileuk_mobile deleted/tenant_id
* 继承 {@link TenantBaseDO}自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* 拍板6real_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 userIduserType=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;
}

View File

@ -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 1wrapper 式只更 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));
}
}

View File

@ -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;
/**
* 玩家/创作者最小身份 Mappersystem passport 扩展
*
* MyBatis-Plus 范式照仓内既有 game 模块 MapperProjectMapper/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);
}
}

View File

@ -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 频控计数 RedisDAOR6 切渠道安全前置§7.2
*
* passport 发码 Service 入层补IP 每日/每小时上限不改上游 SmsCodeServiceImpl避免 fork 侵入
* 上游 :65-66 该项为 TODO Redis 自增计数 + 首次设过期实现滑动自然窗口按日历日/小时分桶
* KEY 格式passport_sms_ip_day:{ip}:{yyyyMMdd} / passport_sms_ip_hour:{ip}:{yyyyMMddHH}
* VALUEString 计数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;
}
}

View File

@ -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 count100 / 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);
}

View File

@ -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=0code={}, id={}", code, codeDO.getId());
throw exception(INVITE_CODE_EXHAUSTED);
}
log.info("[validateAndRedeem] 邀请码核销成功 id={}, batchNo={}", codeDO.getId(), codeDO.getBatchNo());
return codeDO.getId();
}
}

View File

@ -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 收敛于此
* tokenOAuth2TokenService.createAccessToken(userId, UserTypeEnum.MEMBER, clientId, scopes)
*
* @author 造梦AI
*/
public interface PassportService {
/**
* 发送登录短信验证码场景固定 SmsSceneEnum.MEMBER_LOGIN复用 SmsCodeService
*
* 防枚举口径不校验手机号是否已注册已注册登录未注册自动注册均发码
* R6 频控复用上游手机号维度频控 + 本层 IP 每日/每小时 Redis 计数上限不改上游 SmsCodeServiceImpl
*
* @param mobile 手机号
* @param clientIp 请求 IPIP 维度频控 + 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 玩家信息 VOmobile 脱敏
*/
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);
}

View File

@ -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=2592000s30d满足验收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.4staging 默认 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 tokenuserType=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 核销的邀请码 IDsms 通道传 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);
// 目标3anonId 仅注册时落一次缺失落空串归因降级不阻断登录§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=defaultscopes=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());
// 安全基线仅记 userIdtoken 不落日志验收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=truegame_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);
}
}

View File

@ -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;
}
}

View File

@ -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 频控超限抛 429validateCreator 三分支 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;
}
}

View File

@ -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 刷量)

View File

@ -0,0 +1,88 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题真实鉴权与匿名玩家身份HJ-PASSPORT-EXEC-001 §4.2| ownerpassportsystem 内扩展,方案乙)
-- 文件V11.0.0__create_passport_player_invite.sqlFlyway只新增/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 的 userIduserType=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 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 让位声明§4.1V10 已被黄金闭环占、V11 被本件占Wave4 community/biz 原规划的 V10/V11 顺延 V12/V13。
-- 约定InnoDB + utf8mb4显式列含 Yudao 审计列 + tenant_idMVP 单租户=1tenant.enable=false状态机用 tinyint非法流转由服务端拒绝。
-- 错误码段:玩家身份 = 1-002-090-***;邀请码 = 1-002-091-***(落 system 段,见 passport.yaml 与 ErrorCodeConstants
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表game_player —— 玩家/创作者最小身份system passport 扩展,方案乙)
-- id 即 OAuth2 token 的 userIduserType=MEMBER登录主键=mobile。
-- 拍板6real_name/id_card_no 仅预留AES 密文,应用层加密),提现前才收集,本件不写入。
-- 拍板4creator_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 的 userIduserType=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 '租户 IDYudao 多租户兼容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 '租户 IDMVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`, `deleted`, `tenant_id`) COMMENT '码值唯一(含 deleted/tenant_id 适配逻辑删+多租户)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '邀请码报备前玩家受限激活旁路报备后退役拍板1C/2';
-- -----------------------------------------------------------------------------
-- ALTERgame_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.3V3 存量行 player_user_id 均有值mock 喂 1MODIFY 放宽 NULL 不破坏存量;
-- anon_id DEFAULT '' 兼容存量。回滚V11.0.1)还原 NOT NULL 前须确认无 NULL 行anon_id 列可留存不删,无害。
-- 口径anon_id 与 telemetry game_telemetry_event.anon_idV5同口径纯客户端 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';

View 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
}
/**
* / LoginRespVOsms-login invite-register
* token 线
*/
export interface LoginResp {
/** 玩家编号(即 OAuth2 token 的 userIduserType=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,
})
}
/**
* / @PermitAll3
* 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,
})
}
/**
* +@PermitAll1C/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',
})
}

View File

@ -8,6 +8,8 @@
* code!==0 Vant Toast msg reject
* - token userStore mock token
* - // 200 Toastspec §6 mock
* - 401 §9.4HTTP 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 真 tokenisRealLogin 此后为 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'

View File

@ -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

View File

@ -1,11 +1,51 @@
/**
* useUserStore MVP mock
* token axios AuthorizationuserId/nickname anonId
* MVP
* useUserStore + 2026-06-10
* token axios AuthorizationuserId/nickname/creatorFlag anonId
*
* spec §9.2
* - setLogin token/userId/nickname/creatorFlag localStorage
* - logout localStorage
* - token localStorage token > VITE_STUDIO_TOKEN(envstaging =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 {
/* 忽略 */
}
}
/** 生成/读取稳定匿名 IDlocalStorage 持久化,匿名玩家遥测对账用) */
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=test1mock
*/
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', () => {
/** 登录 Tokenstaging 联调取 env VITE_STUDIO_TOKEN=test1后端可识别的登录态缺省回退 mock 占位§2.4)。 */
const token = ref<string>((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token')
/** 用户 IDmock 固定) */
const userId = ref<string>('1001')
/** 昵称mock 固定) */
const nickname = ref<string>('绘境创作者')
/** 头像mock 固定占位) */
/** 登录 TokenlocalStorage 真 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 0refreshToken
*/
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 IDanonId
* token tokenenv/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,
}
})

View File

@ -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'

View File

@ -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 storeToRefsactions 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体验优先不直接发请求吃 401request.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
}
// =3OG share
if (action === 3) {
await feedStore.interact(card.gameId, 3).catch(() => {})

View File

@ -0,0 +1,388 @@
<script setup lang="ts">
/**
* 登录页 | Login.vue路由 /login真实鉴权件 spec §9.1
* ----------------------------------------------------------------------------
* 职责
* 1. Tab 登录/注册
* 手机号 + 验证码主路径拍板1Csend-sms-code 发码 sms-login 登录/自动注册一体
* 邀请码注册受限激活期陌生玩家入口拍板2/3mobile + 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= tokenuserId=登录 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 分派
* - smssmsLogin已注册登录 / 未注册自动注册一体
* - inviteinviteRegister手机号+邀请码受限激活
* 两路均携带 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>