feat(trade): U2 R-ECON admin赋余额+会员订阅(独立流水表+幂等)

codex 评审 NO-MERGE→修 P0(订阅幂等漏洞:独立幂等账本表 game_trade_subscription_grant+uk_biz_no,延期前先写、同事务)+P1(VO @Size/totalIncome注释对齐/权限种子SQL)。V21。plan 2026-06-18-002 U2。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-18 03:56:58 +00:00
parent 9f2b9f9feb
commit ca53a0db0c
31 changed files with 1917 additions and 10 deletions

View File

@ -125,3 +125,6 @@ game-module-{name}/
| 修改已存在的 Flyway 迁移文件 | `flyway validate` 失败、CI 阻断 | 新建迁移补偿,绝不改旧文件(开发团队版 §9 |
| 单元测试连真实 DB | 测试慢、不稳定 | 业务逻辑用 Mock真需要 DB 用集成测试 + Testcontainers |
| `select *` / 大表无索引查询 | 慢查询、性能事故 | 必须指定字段;大表查询必须命中索引(开发团队版 §4.1 |
| 「运营赋值类」资金admin 赋余额/补偿)混进业务收益流水表 | 污染营收报表 gross/net 聚合 + 破坏对账锚点 | **另建专用流水表**trade U2`game_trade_grant`,独立 `uk_biz_no` 幂等行),账户余额仍走原子增(`balance += amount, total_income += amount` 守恒 `balance+frozen+total_withdraw=total_income`);流水写入 + 余额增同 `@Transactional`先写流水行uk 防并发)再增余额,余额增 0 行抛错回滚(禁「流水已记、余额未增」半态)。范本 `AccountServiceImpl.grant` |
| 「一用户一态」聚合域(订阅/会员)按每次操作建新行 | uk_user 撞键 / 状态分散难查 | **一行一用户 upsert**`uk_user`,首建 insert / 已有 CAS 续期);幂等键**内联在行**`last_grant_biz_no`,同 bizNo 不重复延长 = 重试安全),区别于流水域的独立 uk_biz_no 行;续期叠加 `新到期=max(now,旧expire)+时长`(不丢未用时长,已过期从 now 起算CAS`WHERE expire_time=读时快照`)防并发 lost-updatemiss 回查重试有限轮次(禁死循环)。范本 `SubscriptionServiceImpl` |
| 「有效期/过期态」依赖定时 job 物化 status 列 | 无 cronMVP RocketMQ/job 多为远期)时过期态不准 | **有效性以 `expire_time>now` 查询时实时回算**`effectiveStatus`/`active` 在 Convert 层算),落库 status 仅初始态 + 后续物化预留;不依赖定时 job 也能正确反映过期。范本 `TradeConvert.toSubscriptionVO` |

View File

@ -225,6 +225,75 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/CommonResultRevenueReport' }
# ===========================================================================
# U2 经济admin 赋余额 / admin 赋订阅admin 端RBAC
# ===========================================================================
/admin-api/trade/account/grant:
post:
tags: [admin-trade]
summary: admin 赋余额U2运营手动给创作者账户充值real 入账+流水可对账)
description: >-
运营手动给创作者收益账户赋余额(活动奖励/客诉补偿等)。
- 幂等:以 bizNo 命中 game_trade_grant.uk_biz_no 去重——同一 bizNo 重复提交返回已有流水 ID不重复入账、不重复发钱
- 流水佐证:赋余额「不」走 game_trade_income避免污染营收报表 gross/net 聚合),单独记 game_trade_grant 流水可对账;
- 账户余额原子增balance += amount、total_income += amount维持不变式 balance+frozen+total_withdraw=total_income
- 操作人:取自 tokengetLoginUserId()),落 game_trade_grant.operator_user_id审计可溯非前端入参
- 校验amount 必须 > 0赋余额只增不减1-106-005-000
RBAC@ss.hasPermission('trade:account:grant')。返回赋余额流水 ID。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/GrantBalanceReqVO' }
responses:
'200':
description: 成功(含幂等命中——重复 bizNo 返回已有流水 ID
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultLong' }
/admin-api/trade/subscription/grant:
post:
tags: [admin-trade]
summary: admin 赋订阅U2运营/B 端手动开通会员,三档套餐 + 续期叠加)
description: >-
运营/B 端手动给用户开通会员订阅MVP 不接真实支付收单,归 pay 日历闸门;本轮 admin 手动赋)。
- 套餐 plan1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由 durationDays 决定;非法 1-106-006-000
- 续期叠加:已有未过期订阅则新到期 = max(now, 旧 expire) + durationDays 天(不丢未用时长);已过期/无记录从 now 起算;
- 幂等:以 bizNo 内联在订阅行 last_grant_biz_no——同一 bizNo 重复提交不重复延长时长(重试安全);
- 一用户一行game_trade_subscription.uk_userupsert 同一行CAS 防并发续期 lost-update
- 操作人取自 token落 operator_user_id审计可溯durationDays 必须 > 01-106-006-001
RBAC@ss.hasPermission('trade:subscription:grant')。返回赋值后到期时间expireTime
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/GrantSubscriptionReqVO' }
responses:
'200':
description: 成功(返回最新到期时间;同 bizNo 幂等返回当前到期时间不重复延长)
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultLocalDateTime' }
# ===========================================================================
# U2 经济C 端查订阅态app 端,用户 Token归属隔离
# ===========================================================================
/app-api/trade/subscription/mine:
get:
tags: [app-trade]
summary: 我的会员订阅态U2是否有效/套餐/到期时间)
description: >-
返回当前用户会员订阅态game_trade_subscriptionDataPermission 限本人 user_id
有效性以 expire_time>now 实时判定active=true 即会员权益开启effectiveStatus=0 生效中/1 已过期)——
不依赖 cron 物化过期也能正确反映过期态。从未订阅时返回空态subscribed=false、active=false不返 null。
responses:
'200':
description: 成功
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultSubscription' }
components:
schemas:
# ---- Huijing CommonResult 信封 ----
@ -272,6 +341,18 @@ components:
code: { type: integer }
data: { $ref: '#/components/schemas/RevenueReportVO' }
msg: { type: string }
CommonResultLocalDateTime:
type: object
properties:
code: { type: integer }
data: { type: string, format: date-time, description: '赋订阅后到期时间expireTime' }
msg: { type: string }
CommonResultSubscription:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/SubscriptionRespVO' }
msg: { type: string }
# ---- 请求 VO ----
WithdrawApplyReqVO:
@ -291,6 +372,25 @@ components:
decision: { type: integer, enum: [1, 2], description: '审核决策1通过(0→1→mock 打款 2) 2驳回(0→3冻结退回余额)' }
rejectReason: { type: string, maxLength: 255, description: '驳回理由decision=2 必填,回填创作者)' }
remark: { type: string, maxLength: 255, description: 审核备注(可空) }
GrantBalanceReqVO:
type: object
description: admin 赋余额入参U2管理端。bizNo 幂等键amount 单位分、只增不减。操作人取自 token非入参。
required: [userId, amount, bizNo]
properties:
userId: { type: integer, format: int64, description: 被赋余额的创作者用户 ID }
amount: { type: integer, format: int64, minimum: 1, description: '赋余额金额(单位:分,只增不减,>0' }
bizNo: { type: string, minLength: 1, description: '赋余额业务单号 = 幂等键(调用端生成唯一,对应 game_trade_grant.uk_biz_no重复提交返回已有流水、不重复入账' }
remark: { type: string, maxLength: 255, description: '赋余额备注(赋值原因,可空)' }
GrantSubscriptionReqVO:
type: object
description: admin 赋订阅入参U2管理端。bizNo 幂等键(同 bizNo 不重复延长plan 套餐durationDays 续期叠加。操作人取自 token非入参。
required: [userId, plan, durationDays, bizNo]
properties:
userId: { type: integer, format: int64, description: 订阅用户 ID }
plan: { type: integer, enum: [1, 2, 3], description: '订阅套餐1 月度 / 2 季度 / 3 年度' }
durationDays: { type: integer, minimum: 1, description: '赋订阅时长(天,续期叠加,>0' }
bizNo: { type: string, minLength: 1, description: '赋订阅业务单号 = 幂等键(调用端生成唯一;同 bizNo 不重复延长时长,重试安全)' }
remark: { type: string, maxLength: 255, description: '订阅备注(开通原因,可空)' }
# ---- 响应 VO ----
TradeAccountRespVO:
@ -302,7 +402,7 @@ components:
userId: { type: integer, format: int64, description: 创作者用户 ID }
balance: { type: integer, format: int64, description: '可提现余额(单位:分)' }
frozen: { type: integer, format: int64, description: '冻结中(提现处理中占用,单位:分)' }
totalIncome: { type: integer, format: int64, description: '累计收益(历史分账入账总额,单位:分' }
totalIncome: { type: integer, format: int64, description: '累计入账(= 分账入账 + admin 赋余额之和单位U2 起含 admin 赋余额,维持 balance+frozen+total_withdraw 恒等式' }
totalWithdraw: { type: integer, format: int64, description: '累计已提现(已打款成功总额,单位:分)' }
updateTime: { type: string, format: date-time }
IncomeRespVO:
@ -348,6 +448,19 @@ components:
totalNet: { type: integer, format: int64, description: '区间内创作者实得合计(单位:分)' }
totalPlatform: { type: integer, format: int64, description: '区间内平台分成合计 = totalGross - totalNet单位' }
totalWithdrawPaid: { type: integer, format: int64, description: '区间内已打款提现合计(单位:分)' }
# ---- U2 新增会员订阅态C 端查) ----
SubscriptionRespVO:
type: object
description: >-
会员订阅态game_trade_subscription。有效性以 expire_time>now 实时判定active=true 即会员权益开启)——
不依赖 cron 物化过期。从未订阅时返回空态subscribed=false、active=false其余字段为 null。
properties:
subscribed: { type: boolean, description: '是否曾订阅过有订阅记录false=从未订阅' }
active: { type: boolean, description: '是否当前有效expire_time>now 实时判定C 端据此开启会员权益' }
plan: { type: integer, enum: [1, 2, 3], description: '订阅套餐1 月度 / 2 季度 / 3 年度(未订阅为 null' }
effectiveStatus: { type: integer, enum: [0, 1], description: '有效状态(按 expire_time 实时回算0 生效中 / 1 已过期(未订阅为 null' }
startTime: { type: string, format: date-time, description: '本次订阅生效起始时间(未订阅为 null' }
expireTime: { type: string, format: date-time, description: '到期时间权威字段expire_time>now 即有效;未订阅为 null' }
# ---- M4 新增我的收益汇总T-TRD-09 看板转真) ----
IncomeSummaryVO:
type: object

View File

@ -0,0 +1,114 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块tradegame-module-tradeU2 经济admin 赋余额 + 会员订阅)| owner6c6g 后端线
-- 文件V21.0.0__create_game_trade_subscription.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移
-- 内容U2 R-ECON 三张新表 ——
-- ① game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细,余额增的来源凭证,可对账)
-- ② game_trade_subscription —— 会员订阅(三档套餐 plan + 到期时间 expire_time + 状态 statusadmin 赋订阅 + C 端查订阅态)
-- ③ game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行 + uk_biz_no挡任意历史 bizNo 重放重复续期)
-- 背景AGENTS §1 三条变现线:广告分成 / 订阅会员 / B 端定制):
-- - 钱包/收益/提现已 realV7/V14本轮补「admin 赋余额」运营给余额real 入账 + 流水)与「订阅域」(实体表 + 赋订阅 + 查态)。
-- - admin 赋余额「不」走 game_trade_incomeincome 是「广告/打赏分账」流水,进平台营收报表 gross/net 聚合;
-- admin 手动赋余额是运营动作,混入 income 会污染营收报表与对账锚点source_ref 对齐 ad 台账),故单独建 game_trade_grant 流水表。
-- - 账户 game_trade_account 余额仍由原子增balance += amount、total_income += amount维持不变式 balance+frozen+total_withdraw=total_income
-- 范围边界:真实支付收单/自助购买订阅(接 pay 下单回调开通)= 日历闸门,本轮不做;订阅由 admin 手动赋(运营/B 端开通)。
-- 约定InnoDB + utf8mb4显式列含 Huijing 审计列 creator/create_time/updater/update_time/deleted + tenant_id
-- 金额一律「分」(BIGINT) 禁浮点;资金写入有幂等键(biz_no + uk);状态机用 tinyint中文列注释关键查询建索引、禁裸 select*。
-- 错误码段trade = 1-106-***-***(赋余额 005 / 订阅 006
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细凭证;余额增的来源,可对账)
-- 写入点admin 赋余额AccountService.grant→ 写一条 grant 流水 + 账户 balance/total_income 原子增(同事务)。
-- 幂等核心uk_biz_no(biz_no, tenant_id) 防重复赋余额——同一 biz_no 重复提交返回已有记录,不重复入账(不重复发钱)。
-- biz_no 由调用端admin 前端/运营)生成唯一;幂等范式与 game_trade_withdraw.uk_biz_no 一致。
-- 归属user_id = 被赋余额的创作者operator_user_id = 操作的运营/admin审计可溯区别于 Huijing 审计列 creator
-- 金额单位amount 一律「分」(BIGINT) 禁浮点赋余额只增不减amount > 0由 Service 校验)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_grant` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '赋余额流水 ID',
`user_id` BIGINT NOT NULL COMMENT '被赋余额的创作者用户 ID账户归属',
`amount` BIGINT NOT NULL COMMENT '赋余额金额单位BIGINT 禁浮点;只增不减,>0',
`biz_no` VARCHAR(64) NOT NULL COMMENT '赋余额业务单号 = 幂等键(调用端生成唯一;重复提交同一 biz_no 不重复入账)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '赋余额来源标识(固定 admin_grant区别于收益流水 income 的 ad/tip 分账来源)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '赋余额备注(运营填写赋值原因,如「活动奖励」「客诉补偿」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`, `tenant_id`) COMMENT '赋余额幂等键:同一 biz_no 只入账一次(防重复赋余额/重复发钱)',
KEY `idx_user` (`user_id`) COMMENT '按创作者查赋余额记录(运营对账)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'admin 赋余额流水表运营手动充值明细余额增来源uk 防重复入账)';
-- -----------------------------------------------------------------------------
-- 表game_trade_subscription —— 会员订阅(三档套餐 + 到期时间 + 状态admin 赋订阅 + C 端查订阅态)
-- 一个用户一条订阅记录uk_user(user_id, tenant_id) —— 赋订阅/续期 upsert 同一行(叠加 expire_time不为每次赋值建新行。
-- 赋订阅幂等P0 修复):由独立账本表 game_trade_subscription_grant.uk_biz_no 保证——挡「任意历史 bizNo」重放重复续期。
-- 注:本行 last_grant_biz_no 仅记「最近一次」赋订阅 bizNo 供审计追溯,**不再作幂等判据**(行内键只能挡最近一次重放,
-- 挡不住历史 bizNo 重放A 续期→键=AB 续期→键=B重放 A 时键(B)≠A 会误判新请求重复续期)。订阅是「一用户一行」状态聚合,
-- 续期叠加在同一行,但幂等必须靠独立账本(与赋余额 grant、提现 withdraw 同范式:每笔一行 + uk_biz_no
-- 套餐 plan枚举 TradeSubscriptionPlanEnum1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由赋订阅时长决定,不写死天数)。
-- 状态 status枚举 TradeSubscriptionStatusEnum0 生效中 / 1 已过期。
-- MVP 无 cron 物化过期:「是否有效」以 expire_time 与当前时间比较为权威实时判定Service 查询时按 expire_time>now 回算 effectiveStatus
-- status 列落「赋订阅初始态」与后续定时 job 物化预留C 端查态不依赖定时 job 也能正确反映过期。
-- 续期叠加:赋订阅时若已有未过期订阅,新到期 = max(now, 旧 expire_time) + 时长(不丢未用时长);已过期则从 now 起算。
-- 范围边界:真实支付收单/自助购买 = 日历闸门,本轮 admin 手动赋source 标识赋值来源admin_grant
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_subscription` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订阅记录 ID',
`user_id` BIGINT NOT NULL COMMENT '订阅用户 IDDataPermission用户只见自己订阅',
`plan` TINYINT NOT NULL COMMENT '订阅套餐1 月度 / 2 季度 / 3 年度(枚举 TradeSubscriptionPlanEnum',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态0 生效中 / 1 已过期(枚举 TradeSubscriptionStatusEnum权威有效性以 expire_time>now 实时判定)',
`start_time` DATETIME NOT NULL COMMENT '本次订阅生效起始时间(首次赋订阅或续期起算点)',
`expire_time` DATETIME NOT NULL COMMENT '到期时间权威字段expire_time>now 即有效;续期时叠加,不丢未用时长)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '订阅来源标识(固定 admin_grant真实自助购买 later 接 pay 回调)',
`last_grant_biz_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次赋订阅业务单号(仅审计追溯;幂等由 game_trade_subscription_grant.uk_biz_no 保证,本列非幂等键)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/admin 赋订阅RBAC审计可溯',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '订阅备注运营填写开通原因如「B 端定制开通」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user` (`user_id`, `tenant_id`) COMMENT '一个用户一条订阅记录(赋订阅/续期 upsert 同一行,防并发重复建)',
KEY `idx_expire` (`expire_time`) COMMENT '按到期时间扫描(后续过期物化 job 用)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员订阅表(三档套餐+到期时间+状态admin 赋订阅expire_time 权威判有效)';
-- -----------------------------------------------------------------------------
-- 表game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行;挡任意历史 bizNo 重放重复续期)
-- 写入点admin 赋订阅SubscriptionService.grantSubscription→ 续期前先 insert 一行账本 + 订阅 upsert 续期(同事务)。
-- 【为什么需要本表P0 NO-MERGE 修复)】
-- 订阅是「一用户一行」状态聚合game_trade_subscription.uk_user续期叠加 expire_time其行内 last_grant_biz_no
-- 只记「最近一次」赋订阅 bizNo——只能挡最近一次重放挡不住历史 bizNo 重放:
-- bizNo=A 续期 → 行内键=AbizNo=B 续期 → 行内键=B此时重放 bizNo=A行内键(B)≠A → 误判新请求再次续期(重复发权益)。
-- 故照搬赋余额 game_trade_grant 的「独立流水表 + uk_biz_no」范式每笔赋订阅先 insert 本表,撞键即「已处理」幂等返回。
-- 幂等核心uk_biz_no(biz_no, tenant_id) 防重复赋订阅——同一 biz_no 重复提交返回当前订阅态,不重复延长(重试安全)。
-- biz_no 由调用端admin 前端/运营)生成唯一;幂等范式与 game_trade_grant.uk_biz_no / game_trade_withdraw.uk_biz_no 一致。
-- 归属user_id = 被赋订阅的用户operator_user_id = 操作的运营/admin审计可溯区别于 Huijing 审计列 creator
-- 落账快照plan/duration_days 记本次赋订阅入参expire_time_after 记赋值后到期时间(续期成功后回填,供对账与幂等回显)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_subscription_grant` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '赋订阅流水 ID',
`user_id` BIGINT NOT NULL COMMENT '被赋订阅的用户 ID订阅归属',
`biz_no` VARCHAR(64) NOT NULL COMMENT '赋订阅业务单号 = 幂等键(调用端生成唯一;重复提交同一 biz_no 不重复延长权益)',
`plan` TINYINT NOT NULL COMMENT '本次赋订阅套餐1 月度 / 2 季度 / 3 年度(枚举 TradeSubscriptionPlanEnum落账快照',
`duration_days` INT NOT NULL COMMENT '本次赋订阅时长(天,落账快照;续期叠加的时长来源凭证,>0',
`expire_time_after` DATETIME NULL COMMENT '本次赋值后到期时间(续期成功后回填的快照,供对账与幂等命中回显)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '赋订阅来源标识(固定 admin_grant真实自助购买 later 接 pay 回调)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '赋订阅备注运营填写开通原因如「B 端定制开通」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`, `tenant_id`) COMMENT '赋订阅幂等键:同一 biz_no 只赋一次(防重复延长订阅权益/重复发会员)',
KEY `idx_user` (`user_id`) COMMENT '按用户查赋订阅记录(运营对账)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员订阅赋值幂等账本每笔赋订阅一行uk 挡任意历史 bizNo 重放重复续期)';

View File

@ -21,9 +21,11 @@
-- -----------------------------------------------------------------------------
-- 表game_trade_account —— 创作者收益账户(物化汇总;余额变更必有对应流水/提现单)
-- 不变式total_income = Σ game_trade_income.net_amount本人
-- balance + frozen + total_withdraw = total_income余额 + 冻结中 + 已提现 = 累计收益)
-- 不变式total_income = Σ game_trade_income.net_amount本人分账+ Σ game_trade_grant.amount本人 admin 赋余额)——
-- 即「累计入账」= 分账入账 + admin 赋余额两类资金来源之和U2 起 admin 赋余额也计入,维持下方账面恒等式);
-- balance + frozen + total_withdraw = total_income余额 + 冻结中 + 已提现 = 累计入账,恒等式始终成立)
-- 写入点:① 分账入账SettlementJob→ total_income += net、balance += net
-- ②′ admin 赋余额U2AccountService.grant→ total_income += amount、balance += amount记 game_trade_grant 流水佐证);
-- ② 提现申请 → balance -= amount、frozen += amount冻结
-- ③ 打款成功 → frozen -= amount、total_withdraw += amount
-- ④ 驳回/打款失败 → frozen -= amount、balance += amount退回
@ -34,7 +36,7 @@ CREATE TABLE `game_trade_account` (
`user_id` BIGINT NOT NULL COMMENT '创作者用户 IDDataPermission创作者只见自己账户',
`balance` BIGINT NOT NULL DEFAULT 0 COMMENT '可提现余额单位BIGINT 禁浮点)',
`frozen` BIGINT NOT NULL DEFAULT 0 COMMENT '冻结中(提现处理中占用,单位:分;提现申请时从 balance 转入)',
`total_income` BIGINT NOT NULL DEFAULT 0 COMMENT '累计收益(历史分账入账总额,单位:分;= Σ 本人收益流水 net_amount',
`total_income` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账(单位:分;= Σ 本人收益流水 net_amount + Σ 本人 admin 赋余额 grant.amountU2 起含赋余额,维持 balance+frozen+total_withdraw 恒等式',
`total_withdraw` BIGINT NOT NULL DEFAULT 0 COMMENT '累计已提现(已打款成功总额,单位:分)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(账户初始化时间)',

View File

@ -13,6 +13,8 @@ import com.wanxiang.huijing.framework.common.exception.ErrorCode;
* - 1-106-002-***提现申请提现门槛余额不足幂等冲突
* - 1-106-003-***提现状态机非法流转必填校验
* - 1-106-004-***打款渠道M4渠道不可用 fail-fast发起转账失败回调单不存在
* - 1-106-005-***admin 赋余额U2金额非法幂等冲突
* - 1-106-006-***会员订阅U2套餐非法时长非法幂等冲突
*
* @author 造梦AI
*/
@ -57,4 +59,18 @@ public interface ErrorCodeConstants {
/** 打款回调:提现单不存在(按 withdrawId 查不到) */
ErrorCode TRADE_PAYOUT_WITHDRAW_NOT_EXISTS = new ErrorCode(1_106_004_002, "打款回调:提现单不存在");
// ========== admin 赋余额 1-106-005-***U2 经济运营手动给创作者账户赋余额==========
/** 赋余额金额非法amount ≤ 0赋余额只增不减必须为正整数分 */
ErrorCode TRADE_GRANT_AMOUNT_INVALID = new ErrorCode(1_106_005_000, "赋余额金额必须大于 0");
/** 赋余额幂等冲突(同一 bizNo 并发提交,唯一键 uk_biz_no 冲突且回查不到——非幂等的并发异常) */
ErrorCode TRADE_GRANT_BIZ_NO_CONFLICT = new ErrorCode(1_106_005_001, "赋余额提交冲突,请重试");
// ========== 会员订阅 1-106-006-***U2 经济admin 赋订阅 + C 端查订阅态==========
/** 订阅套餐非法plan 不在 {@link com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum} 内) */
ErrorCode TRADE_SUBSCRIPTION_PLAN_INVALID = new ErrorCode(1_106_006_000, "订阅套餐非法");
/** 订阅时长非法durationDays ≤ 0赋订阅必须为正整数天 */
ErrorCode TRADE_SUBSCRIPTION_DURATION_INVALID = new ErrorCode(1_106_006_001, "订阅时长必须大于 0 天");
/** 订阅赋值幂等冲突(同一 bizNo 并发提交,唯一键 uk_biz_no 冲突且回查不到——非幂等的并发异常) */
ErrorCode TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT = new ErrorCode(1_106_006_002, "订阅赋值提交冲突,请重试");
}

View File

@ -0,0 +1,41 @@
package com.wanxiang.huijing.game.module.trade.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Objects;
/**
* 会员订阅套餐枚举对应表 game_trade_subscription.plantinyint
*
* 三档变现订阅AGENTS §1订阅会员为三条变现线之一1 月度 / 2 季度 / 3 年度
* MVP 不接真实支付收单支付收单归 pay日历闸门本轮订阅由 admin 手动赋订阅运营/B 端开通
* 真实自助购买 pay 下单回调开通= later套餐仅标识权益档位到期时间由赋订阅时长决定不在此枚举写死天数
*
* @author 造梦AI
*/
@Getter
@AllArgsConstructor
public enum TradeSubscriptionPlanEnum {
MONTHLY(1, "月度会员"),
QUARTERLY(2, "季度会员"),
YEARLY(3, "年度会员");
/** 套餐值(落库 tinyint */
private final Integer plan;
/** 套餐名(展示用) */
private final String name;
/**
* 校验是否为合法套餐值
*
* @param plan 套餐值
* @return true 合法
*/
public static boolean isValid(Integer plan) {
return Arrays.stream(values()).anyMatch(e -> Objects.equals(e.plan, plan));
}
}

View File

@ -0,0 +1,29 @@
package com.wanxiang.huijing.game.module.trade.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 会员订阅状态枚举对应表 game_trade_subscription.statustinyint
*
* 状态机0 生效中 1 已过期
* - 生效中ACTIVE,0赋订阅/续期后 expire_time > now
* - 已过期EXPIRED,1expire_time now由查询时按 expire_time 实时判定或后续定时 job 物化
* 设计取舍MVP cron 物化过期订阅是否有效 expire_time 与当前时间比较为权威实时判定 SubscriptionService.getMySubscription
* status 列仅落赋订阅时的初始态/续期态与后续物化预留C 端查订阅态时以expire_time > now回算 effectiveStatus不依赖定时 job 也能正确反映过期
*
* @author 造梦AI
*/
@Getter
@AllArgsConstructor
public enum TradeSubscriptionStatusEnum {
ACTIVE(0, "生效中"),
EXPIRED(1, "已过期");
/** 状态值(落库 tinyint */
private final Integer status;
/** 状态名(展示用) */
private final String name;
}

View File

@ -1,5 +1,7 @@
package com.wanxiang.huijing.game.module.trade.controller.admin;
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantBalanceReqVO;
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantSubscriptionReqVO;
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.IncomePageReqVO;
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.RevenueReportVO;
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.WithdrawAuditReqVO;
@ -10,7 +12,9 @@ import com.wanxiang.huijing.game.module.trade.controller.app.vo.WithdrawRespVO;
import com.wanxiang.huijing.game.module.trade.convert.TradeConvert;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.IncomeDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.WithdrawDO;
import com.wanxiang.huijing.game.module.trade.service.account.AccountService;
import com.wanxiang.huijing.game.module.trade.service.income.IncomeService;
import com.wanxiang.huijing.game.module.trade.service.subscription.SubscriptionService;
import com.wanxiang.huijing.game.module.trade.service.withdraw.WithdrawService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
@ -35,14 +39,16 @@ import java.time.LocalDate;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 管理后台game-admin- 提现审核 / 收益对账 / 营收报表控制器
* 管理后台game-admin- 提现审核 / 收益对账 / 营收报表 / 赋余额 / 赋订阅控制器
*
* 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加RBAC 权限用 @PreAuthorize
* 提现审核驱动状态机非法流转 Service 拒绝收益流水/营收报表只读供运营对账与看分账结构
* U2 经济赋余额运营手动给创作者账户充值real 入账 + 流水可对账/ 赋订阅运营/B 端手动开通会员
* 入口落 trade controller.admin经济 admin 强共享game-admin 前端调不另起 admin 后端模块
*
* @author 造梦AI
*/
@Tag(name = "管理后台 - 提现审核/收益对账/营收报表")
@Tag(name = "管理后台 - 提现审核/收益对账/营收报表/赋余额/赋订阅")
@RestController
@RequestMapping("/trade")
@Validated
@ -54,6 +60,14 @@ public class TradeAdminController {
@Resource
private IncomeService incomeService;
/** 账户 ServiceU2admin 赋余额 credit/grant */
@Resource
private AccountService accountService;
/** 订阅 ServiceU2admin 赋订阅) */
@Resource
private SubscriptionService subscriptionService;
@GetMapping("/withdraw/page")
@Operation(summary = "提现审核队列分页", description = "跨创作者,按状态/用户筛选(默认 status=0 待审核)")
@PreAuthorize("@ss.hasPermission('trade:withdraw:query')")
@ -113,4 +127,27 @@ public class TradeAdminController {
return success(report);
}
// ============================== U2 经济赋余额 / 赋订阅 ==============================
@PostMapping("/account/grant")
@Operation(summary = "admin 赋余额U2", description = "运营手动给创作者账户赋余额(活动奖励/客诉补偿);幂等键=bizNoreal 入账+game_trade_grant 流水可对账;返回赋余额流水 ID")
@PreAuthorize("@ss.hasPermission('trade:account:grant')")
public CommonResult<Long> grantBalance(@Valid @RequestBody GrantBalanceReqVO reqVO) {
// 操作人取自 token 解析getLoginUserId()非前端入参审计可溯 game_trade_grant.operator_user_id
Long operatorUserId = SecurityFrameworkUtils.getLoginUserId();
Long grantId = accountService.grant(reqVO.getUserId(), reqVO.getAmount(),
reqVO.getBizNo(), operatorUserId, reqVO.getRemark());
return success(grantId);
}
@PostMapping("/subscription/grant")
@Operation(summary = "admin 赋订阅U2", description = "运营/B 端手动给用户开通会员1月度/2季度/3年度幂等键=bizNo同 bizNo 不重复延长),续期叠加不丢未用时长;返回到期时间")
@PreAuthorize("@ss.hasPermission('trade:subscription:grant')")
public CommonResult<java.time.LocalDateTime> grantSubscription(@Valid @RequestBody GrantSubscriptionReqVO reqVO) {
Long operatorUserId = SecurityFrameworkUtils.getLoginUserId();
// 赋订阅 upsert首建/续期叠加 + bizNo 幂等返回赋值后到期时间供运营确认
return success(subscriptionService.grantSubscription(reqVO.getUserId(), reqVO.getPlan(),
reqVO.getDurationDays(), reqVO.getBizNo(), operatorUserId, reqVO.getRemark()).getExpireTime());
}
}

View File

@ -0,0 +1,40 @@
package com.wanxiang.huijing.game.module.trade.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* admin 赋余额 Request VOadmin U2 经济对齐 trade.yaml GrantBalanceReqVO
*
* 运营手动给创作者账户赋余额活动奖励/客诉补偿等bizNo 为幂等键调用端生成唯一对应 game_trade_grant.uk_biz_no
* amount 单位分只增不减>0操作人由 token 解析getLoginUserId()非前端入参保证审计可溯
*
* @author 造梦AI
*/
@Schema(description = "管理后台 - admin 赋余额 Request VO")
@Data
public class GrantBalanceReqVO {
@Schema(description = "被赋余额的创作者用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "99")
@NotNull(message = "用户 ID 不能为空")
private Long userId;
@Schema(description = "赋余额金额(单位:分,只增不减)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000")
@NotNull(message = "赋余额金额不能为空")
@Min(value = 1, message = "赋余额金额必须大于 0")
private Long amount;
@Schema(description = "赋余额业务单号 = 幂等键(调用端生成唯一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "GR20260618000001")
@NotEmpty(message = "赋余额业务单号不能为空")
@Size(max = 64, message = "赋余额业务单号长度不能超过 64") // 对齐 game_trade_grant.biz_no VARCHAR(64)超长在入口拦截避免触 DB 截断/500
private String bizNo;
@Schema(description = "赋余额备注(赋值原因,可空)", example = "活动奖励")
@Size(max = 255, message = "备注长度不能超过 255")
private String remark;
}

View File

@ -0,0 +1,44 @@
package com.wanxiang.huijing.game.module.trade.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* admin 赋订阅 Request VOadmin U2 经济对齐 trade.yaml GrantSubscriptionReqVO
*
* 运营/B 端手动给用户开通会员订阅MVP 不接真实支付收单 pay 日历闸门bizNo 为幂等键 bizNo 不重复延长重试安全
* plan=1月度/2季度/3年度枚举校验在 ServicedurationDays>0 赋订阅时长续期叠加操作人由 token 解析
*
* @author 造梦AI
*/
@Schema(description = "管理后台 - admin 赋订阅 Request VO")
@Data
public class GrantSubscriptionReqVO {
@Schema(description = "订阅用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "99")
@NotNull(message = "用户 ID 不能为空")
private Long userId;
@Schema(description = "订阅套餐1 月度 / 2 季度 / 3 年度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "订阅套餐不能为空")
private Integer plan;
@Schema(description = "赋订阅时长(天,续期叠加)", requiredMode = Schema.RequiredMode.REQUIRED, example = "30")
@NotNull(message = "订阅时长不能为空")
@Min(value = 1, message = "订阅时长必须大于 0 天")
private Integer durationDays;
@Schema(description = "赋订阅业务单号 = 幂等键(调用端生成唯一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "SUB20260618000001")
@NotEmpty(message = "赋订阅业务单号不能为空")
@Size(max = 64, message = "赋订阅业务单号长度不能超过 64") // 对齐 game_trade_subscription_grant.biz_no VARCHAR(64)超长在入口拦截避免触 DB 500
private String bizNo;
@Schema(description = "订阅备注(开通原因,可空)", example = "B 端定制开通")
@Size(max = 255, message = "备注长度不能超过 255")
private String remark;
}

View File

@ -4,15 +4,18 @@ import com.wanxiang.huijing.game.module.trade.controller.app.vo.AppIncomePageReq
import com.wanxiang.huijing.game.module.trade.controller.app.vo.AppWithdrawPageReqVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.IncomeRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.IncomeSummaryVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.SubscriptionRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.TradeAccountRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.WithdrawApplyReqVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.WithdrawRespVO;
import com.wanxiang.huijing.game.module.trade.convert.TradeConvert;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.AccountDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.IncomeDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.WithdrawDO;
import com.wanxiang.huijing.game.module.trade.service.account.AccountService;
import com.wanxiang.huijing.game.module.trade.service.income.IncomeService;
import com.wanxiang.huijing.game.module.trade.service.subscription.SubscriptionService;
import com.wanxiang.huijing.game.module.trade.service.withdraw.WithdrawService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
@ -53,6 +56,10 @@ public class TradeController {
@Resource
private WithdrawService withdrawService;
/** 订阅 ServiceU2C 端查订阅态) */
@Resource
private SubscriptionService subscriptionService;
@GetMapping("/account/mine")
@Operation(summary = "我的收益账户", description = "余额/累计收益/累计提现/冻结中(单位分);账户不存在时按需初始化为零值账户")
public CommonResult<TradeAccountRespVO> getMyAccount() {
@ -93,4 +100,14 @@ public class TradeController {
return success(TradeConvert.toWithdrawPage(pageResult));
}
@GetMapping("/subscription/mine")
@Operation(summary = "我的会员订阅态U2", description = "C 端查订阅态:是否有效(active)/套餐/到期时间;有效性按 expire_time>now 实时判定(无 cron 也准);从未订阅返回空态(subscribed=false)")
public CommonResult<SubscriptionRespVO> getMySubscription() {
// 归属隔离硬边界userId 取自 token 解析的 getLoginUserId()命中 uk_user 限本人
Long userId = SecurityFrameworkUtils.getLoginUserId();
SubscriptionDO sub = subscriptionService.getMySubscription(userId);
// 有效性回算 + 空态兜底统一在 TradeConvert.toSubscriptionVO expire_time>now
return success(TradeConvert.toSubscriptionVO(sub));
}
}

View File

@ -0,0 +1,40 @@
package com.wanxiang.huijing.game.module.trade.controller.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 会员订阅态 Response VOapp C 端查订阅态对齐 trade.yaml SubscriptionRespVO
*
* 有效性以 expire_time>now 实时判定 TradeConvert.toSubscriptionVO
* - active是否当前有效expire_time>nowC 端据此判会员权益是否开启
* - effectiveStatus0 生效中 / 1 已过期 expire_time 实时回算非依赖落库 status 不依赖定时 job
* 从未订阅时由 Controller 返回subscribed=false 的空态 VOactive=false不返 null便于前端统一处理
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 会员订阅态 Response VO")
@Data
public class SubscriptionRespVO {
@Schema(description = "是否曾订阅过有订阅记录false=从未订阅", example = "true")
private Boolean subscribed;
@Schema(description = "是否当前有效expire_time>now 实时判定C 端据此开启会员权益", example = "true")
private Boolean active;
@Schema(description = "订阅套餐1 月度 / 2 季度 / 3 年度(未订阅为 null", example = "1")
private Integer plan;
@Schema(description = "有效状态(按 expire_time 实时回算0 生效中 / 1 已过期(未订阅为 null", example = "0")
private Integer effectiveStatus;
@Schema(description = "本次订阅生效起始时间(未订阅为 null")
private LocalDateTime startTime;
@Schema(description = "到期时间权威字段expire_time>now 即有效;未订阅为 null")
private LocalDateTime expireTime;
}

View File

@ -1,14 +1,18 @@
package com.wanxiang.huijing.game.module.trade.convert;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.IncomeRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.SubscriptionRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.TradeAccountRespVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.WithdrawRespVO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.AccountDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.IncomeDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.WithdrawDO;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionStatusEnum;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
import com.wanxiang.huijing.framework.common.util.object.BeanUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@ -84,4 +88,37 @@ public class TradeConvert {
return new PageResult<>(list, page.getTotal());
}
// ============================== 会员订阅 ==============================
/**
* 订阅 DO C 端订阅态 VO有效性 active/effectiveStatus expire_time>now 实时回算
*
* 单一回算点订阅是否有效以 expire_time 当前时间比较为权威expire_time>now 即有效active=trueeffectiveStatus=生效中
* 不依赖落库 status status 仅赋值初始态/物化预留故无 cron 物化过期也能正确反映过期态
* 入参为 null从未订阅时返回subscribed=false 的空态 VOactive=false不返 null便于前端统一处理
*
* @param sub 订阅 DO可空从未订阅
* @return 订阅态 VO保证非 null
*/
public static SubscriptionRespVO toSubscriptionVO(SubscriptionDO sub) {
SubscriptionRespVO vo = new SubscriptionRespVO();
if (sub == null) {
// 从未订阅空态前端统一处理未订阅
vo.setSubscribed(false);
vo.setActive(false);
return vo;
}
// 有效性实时回算expire_time>now 即有效
boolean active = sub.getExpireTime() != null && sub.getExpireTime().isAfter(LocalDateTime.now());
vo.setSubscribed(true);
vo.setActive(active);
vo.setPlan(sub.getPlan());
vo.setEffectiveStatus(active
? TradeSubscriptionStatusEnum.ACTIVE.getStatus()
: TradeSubscriptionStatusEnum.EXPIRED.getStatus());
vo.setStartTime(sub.getStartTime());
vo.setExpireTime(sub.getExpireTime());
return vo;
}
}

View File

@ -41,7 +41,8 @@ public class AccountDO extends TenantBaseDO {
*/
private Long frozen;
/**
* 累计收益历史分账入账总额单位= Σ 本人收益流水 net_amount
* 累计入账单位= Σ 本人收益流水 net_amount + Σ 本人 admin 赋余额 grant.amount
* U2 admin 赋余额也计入 total_income赋余额同步 +balance +total_income维持账面恒等式 balance+frozen+total_withdraw=total_income
*/
private Long totalIncome;
/**

View File

@ -0,0 +1,55 @@
package com.wanxiang.huijing.game.module.trade.dal.dataobject;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* admin 赋余额流水 DO对应表 game_trade_grant运营手动给创作者账户充值的明细凭证余额增的来源
*
* 继承 {@link TenantBaseDO}自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* 设计动机admin 手动赋余额 game_trade_income那是广告/打赏分账流水进平台营收报表 gross/net 聚合
* 混入会污染营收报表与对账锚点source_ref 对齐 ad 台账故单独建本表记一笔账户余额仍原子增
* 幂等核心uk_biz_no(biz_no, tenant_id) 防重复赋余额同一 biz_no 重复提交返回已有记录不重复入账不重复发钱
* 金额单位amount 一律(BIGINT) 禁浮点赋余额只增不减amount > 0 Service 校验
*
* @author 造梦AI
*/
@TableName("game_trade_grant")
@KeySequence("game_trade_grant_seq") // Oracle/PostgreSQL 等主键自增用MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class GrantDO extends TenantBaseDO {
/**
* 赋余额流水 ID
*/
private Long id;
/**
* 被赋余额的创作者用户 ID账户归属
*/
private Long userId;
/**
* 赋余额金额单位BIGINT 禁浮点只增不减>0
*/
private Long amount;
/**
* 赋余额业务单号 = 幂等键调用端生成唯一重复提交同一 biz_no 不重复入账
*/
private String bizNo;
/**
* 赋余额来源标识固定 admin_grant区别于收益流水 income ad/tip 分账来源
*/
private String source;
/**
* 操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator
*/
private Long operatorUserId;
/**
* 赋余额备注运营填写赋值原因活动奖励客诉补偿
*/
private String remark;
}

View File

@ -0,0 +1,72 @@
package com.wanxiang.huijing.game.module.trade.dal.dataobject;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionStatusEnum;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 会员订阅 DO对应表 game_trade_subscription三档套餐 + 到期时间 + 状态admin 赋订阅 + C 端查订阅态
*
* 继承 {@link TenantBaseDO}自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* 一个用户一条记录uk_user(user_id, tenant_id)赋订阅/续期 upsert 同一行叠加 expire_time不为每次赋值建新行
* 套餐 plan枚举 {@link TradeSubscriptionPlanEnum}1 月度 / 2 季度 / 3 年度仅标识权益档位到期时间由赋订阅时长决定
* 状态 status枚举 {@link TradeSubscriptionStatusEnum}0 生效中 / 1 已过期
* MVP cron 物化过期有效性以 expire_time 与当前时间比较为权威实时判定Service 查询时按 expire_time>now 回算 effectiveStatus
* status 列落赋订阅初始态与后续定时 job 物化预留C 端查态不依赖定时 job 也能正确反映过期
*
* @author 造梦AI
*/
@TableName("game_trade_subscription")
@KeySequence("game_trade_subscription_seq") // Oracle/PostgreSQL 等主键自增用MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class SubscriptionDO extends TenantBaseDO {
/**
* 订阅记录 ID
*/
private Long id;
/**
* 订阅用户 IDDataPermission用户只见自己订阅
*/
private Long userId;
/**
* 订阅套餐1 月度 / 2 季度 / 3 年度枚举 {@link TradeSubscriptionPlanEnum}
*/
private Integer plan;
/**
* 状态0 生效中 / 1 已过期枚举 {@link TradeSubscriptionStatusEnum}权威有效性以 expire_time>now 实时判定
*/
private Integer status;
/**
* 本次订阅生效起始时间首次赋订阅或续期起算点
*/
private LocalDateTime startTime;
/**
* 到期时间权威字段expire_time>now 即有效续期时叠加不丢未用时长
*/
private LocalDateTime expireTime;
/**
* 订阅来源标识固定 admin_grant真实自助购买 later pay 回调
*/
private String source;
/**
* 最近一次赋订阅业务单号 = 幂等键同一 bizNo 重复赋订阅不重复延长时长重试安全
*/
private String lastGrantBizNo;
/**
* 操作人用户 ID运营/admin 赋订阅RBAC审计可溯
*/
private Long operatorUserId;
/**
* 订阅备注运营填写开通原因B 端定制开通
*/
private String remark;
}

View File

@ -0,0 +1,71 @@
package com.wanxiang.huijing.game.module.trade.dal.dataobject;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 会员订阅赋值幂等账本 DO对应表 game_trade_subscription_grant每笔赋订阅记一行可对账 + bizNo 幂等
*
* 为什么需要本表P0 NO-MERGE 修复
* 订阅是一用户一行的状态聚合game_trade_subscription.uk_user续期叠加 expire_time其行内字段
* last_grant_biz_no 只记最近一次赋订阅 bizNo只能挡最近一次重放挡不住历史 bizNo重放
* bizNo=A 续期 行内键=AbizNo=B 续期 行内键=B此时重放 bizNo=A行内键(B)A 误判为新请求再次续期重复发权益
* 故照搬赋余额侧 game_trade_grant 独立流水表 + uk_biz_no范式每笔赋订阅先在本表 insert 一行幂等账本
* uk_biz_no 撞键即 bizNo 已处理幂等返回从根上杜绝任意历史 bizNo 重放重复续期
*
* 继承 {@link TenantBaseDO}自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* 幂等核心uk_biz_no(biz_no, tenant_id)同一 biz_no 只赋一次防重复延长权益biz_no 由调用端生成唯一
* 归属user_id = 被赋订阅的用户operator_user_id = 操作的运营/admin审计可溯区别于 Huijing 审计列 creator
* 落账快照plan/duration_days/expire_time_after 记本次赋订阅的入参与赋值后到期时间供对账与幂等命中时回填返回
*
* @author 造梦AI
*/
@TableName("game_trade_subscription_grant")
@KeySequence("game_trade_subscription_grant_seq") // Oracle/PostgreSQL 等主键自增用MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class SubscriptionGrantDO extends TenantBaseDO {
/**
* 赋订阅流水 ID
*/
private Long id;
/**
* 被赋订阅的用户 ID订阅归属
*/
private Long userId;
/**
* 赋订阅业务单号 = 幂等键调用端生成唯一重复提交同一 biz_no 不重复延长权益
*/
private String bizNo;
/**
* 本次赋订阅套餐1 月度 / 2 季度 / 3 年度枚举 TradeSubscriptionPlanEnum落账快照
*/
private Integer plan;
/**
* 本次赋订阅时长落账快照续期叠加的时长来源凭证
*/
private Integer durationDays;
/**
* 本次赋值后到期时间落账快照赋订阅成功后订阅行的最新到期时间供幂等命中时回填返回
*/
private LocalDateTime expireTimeAfter;
/**
* 赋订阅来源标识固定 admin_grant区别于真实自助购买 later pay 回调
*/
private String source;
/**
* 操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator
*/
private Long operatorUserId;
/**
* 赋订阅备注运营填写开通原因B 端定制开通
*/
private String remark;
}

View File

@ -53,6 +53,29 @@ public interface AccountMapper extends BaseMapperX<AccountDO> {
return update(null, update);
}
/**
* admin 赋余额原子balance += amounttotal_income += amount
*
* U2 经济运营手动给创作者账户赋余额时调用AccountService.grant账户余额随之增加
* {@link #addIncome} 同形balance/total_income 同增维持不变式 balance+frozen+total_withdraw=total_income
* 单列方法语义独立赋余额 vs 分账入账是两类资金来源分别有 game_trade_grant / game_trade_income 流水佐证便于审计追溯与日志区分
* setSql 做列自增SET ... = ... + ?并发安全行锁WHERE 命中 uk_user
*
* SQL 注入约束拼入的 amount 必须是服务端已校验过的 Long>0非用户请求原样透传见本类头部红线
*
* @param userId 创作者用户 ID
* @param amount 赋余额金额单位>0
* @return 实际更新行数1=成功0=账户不存在调用方需先确保账户已建
*/
default int grantBalance(@Param("userId") Long userId, @Param("amount") Long amount) {
LambdaUpdateWrapper<AccountDO> update = new LambdaUpdateWrapper<AccountDO>()
// 可提现余额 += amount累计收益 += amountadmin 赋余额计入累计收益维持账面不变式
.setSql(" balance = balance + " + amount
+ ", total_income = total_income + " + amount)
.eq(AccountDO::getUserId, userId);
return update(null, update);
}
/**
* 提现申请冻结原子带余额充足校验防超扣balance -= amountfrozen += amount仅当 balance >= amount
*

View File

@ -0,0 +1,30 @@
package com.wanxiang.huijing.game.module.trade.dal.mysql;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.GrantDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import org.apache.ibatis.annotations.Mapper;
/**
* admin 赋余额流水 Mapper
*
* 显式列查询命中索引禁裸 select*selectByBizNo 命中 uk_biz_no赋余额幂等查重
* tenant_id Huijing 多租户拦截器自动追加到 WHERE不在此显式拼
*
* @author 造梦AI
*/
@Mapper
public interface GrantMapper extends BaseMapperX<GrantDO> {
/**
* 按赋余额幂等键查流水biz_no命中 uk_biz_no
*
* 赋余额时先查重命中即返回已有记录不重复入账不重复发钱
*
* @param bizNo 赋余额业务单号幂等键
* @return 赋余额流水 DO不存在返回 null
*/
default GrantDO selectByBizNo(String bizNo) {
return selectOne(GrantDO::getBizNo, bizNo);
}
}

View File

@ -0,0 +1,31 @@
package com.wanxiang.huijing.game.module.trade.dal.mysql;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionGrantDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import org.apache.ibatis.annotations.Mapper;
/**
* 会员订阅赋值幂等账本 Mapper
*
* 显式列查询命中索引禁裸 select*selectByBizNo 命中 uk_biz_no赋订阅幂等查重
* 范式与赋余额侧 {@link GrantMapper} 完全一致赋订阅前先 insert 幂等账本行 uk_biz_no 已处理幂等返回
* tenant_id Huijing 多租户拦截器自动追加到 WHERE不在此显式拼
*
* @author 造梦AI
*/
@Mapper
public interface SubscriptionGrantMapper extends BaseMapperX<SubscriptionGrantDO> {
/**
* 按赋订阅幂等键查账本biz_no命中 uk_biz_no
*
* 赋订阅时先查重命中即返回已有账本不重复延长订阅权益重试安全
*
* @param bizNo 赋订阅业务单号幂等键
* @return 赋订阅账本 DO不存在返回 null
*/
default SubscriptionGrantDO selectByBizNo(String bizNo) {
return selectOne(SubscriptionGrantDO::getBizNo, bizNo);
}
}

View File

@ -0,0 +1,68 @@
package com.wanxiang.huijing.game.module.trade.dal.mysql;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
/**
* 会员订阅 Mapper
*
* 显式列查询命中索引禁裸 select*selectByUserId 命中 uk_user一个用户一条订阅
* 续期用乐观 CASUPDATE ... SET expire_time=新值 WHERE id=? AND expire_time=旧值防并发续期 lost-update
* tenant_id Huijing 多租户拦截器自动追加到 WHERE不在此显式拼
*
* @author 造梦AI
*/
@Mapper
public interface SubscriptionMapper extends BaseMapperX<SubscriptionDO> {
/**
* 按用户 ID 查订阅记录命中 uk_user
*
* 赋订阅先查有则续期叠加 expire_time无则新建C 端查订阅态也用它
*
* @param userId 订阅用户 ID
* @return 订阅 DO不存在返回 null
*/
default SubscriptionDO selectByUserId(Long userId) {
return selectOne(SubscriptionDO::getUserId, userId);
}
/**
* 续期已有订阅乐观 CAS仅当 expire_time = expectExpireTime 时更新为新值并写 plan/status/start_time/操作人/备注
*
* 防并发续期 lost-updateWHERE expire_time=expectExpireTime读时快照并发两次续期时第二次命中 0 expire 已变
* Service 更新行数=0回查重试或返回不丢任一次续期时长
* 续期语义 expire = max(now, expire) + 时长 Service 计算后传入 newExpireTime续期后状态置生效中(0)
*
* @param id 订阅记录 ID
* @param expectExpireTime 期望的当前到期时间CAS 比较值读时快照
* @param newExpireTime 续期后的新到期时间Service 计算max(now,旧expire)+时长
* @param plan 套餐续期时更新为本次套餐档位
* @param status 状态续期后置生效中 0
* @param startTime 本次续期起算点
* @param lastGrantBizNo 本次赋订阅 bizNo落幂等键供下次同 bizNo 重试判重
* @param operatorUserId 操作人用户 ID运营/admin
* @param remark 备注
* @return 实际更新行数1=续期成功0=并发续期到期时间已变需回查重试
*/
default int renewByCas(Long id, LocalDateTime expectExpireTime, LocalDateTime newExpireTime,
Integer plan, Integer status, LocalDateTime startTime,
String lastGrantBizNo, Long operatorUserId, String remark) {
LambdaUpdateWrapper<SubscriptionDO> update = new LambdaUpdateWrapper<SubscriptionDO>()
.set(SubscriptionDO::getExpireTime, newExpireTime)
.set(SubscriptionDO::getPlan, plan)
.set(SubscriptionDO::getStatus, status)
.set(SubscriptionDO::getStartTime, startTime)
.set(SubscriptionDO::getLastGrantBizNo, lastGrantBizNo)
.set(operatorUserId != null, SubscriptionDO::getOperatorUserId, operatorUserId)
.set(remark != null, SubscriptionDO::getRemark, remark)
.eq(SubscriptionDO::getId, id)
.eq(SubscriptionDO::getExpireTime, expectExpireTime); // CAS到期时间未被并发改才续期
return update(null, update);
}
}

View File

@ -35,6 +35,24 @@ public interface AccountService {
*/
void addIncome(Long userId, Long net);
/**
* admin 赋余额U2 经济幂等写一条 game_trade_grant 流水 + 账户余额原子增同事务
*
* 运营手动给创作者账户赋余额活动奖励/客诉补偿等资金安全与可对账
* - 幂等 bizNo 命中 game_trade_grant.uk_biz_no 去重同一 bizNo 重复提交返回已有记录 ID不重复入账不重复发钱
* - 流水佐证赋余额 game_trade_income避免污染营收报表 gross/net 聚合单独记 game_trade_grant 流水可对账
* - 账户余额原子增balance += amounttotal_income += amount维持不变式 balance+frozen+total_withdraw=total_income
* - 校验amount 必须 > 0赋余额只增不减非法抛 TRADE_GRANT_AMOUNT_INVALID
*
* @param userId 被赋余额的创作者用户 ID
* @param amount 赋余额金额单位>0
* @param bizNo 赋余额业务单号 = 幂等键调用端生成唯一
* @param operatorUserId 操作人用户 ID运营/admin审计可溯可空
* @param remark 赋余额备注赋值原因可空
* @return 赋余额流水记录 ID新建或幂等命中的已有记录 ID
*/
Long grant(Long userId, Long amount, String bizNo, Long operatorUserId, String remark);
/**
* 提现冻结原子带余额充足校验防超扣balance -= amountfrozen += amount
*

View File

@ -1,15 +1,20 @@
package com.wanxiang.huijing.game.module.trade.service.account;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.AccountDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.GrantDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.AccountMapper;
import com.wanxiang.huijing.game.module.trade.dal.mysql.GrantMapper;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_ACCOUNT_BALANCE_NOT_ENOUGH;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_ACCOUNT_FROZEN_NOT_ENOUGH;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_GRANT_AMOUNT_INVALID;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_GRANT_BIZ_NO_CONFLICT;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
@ -30,6 +35,13 @@ public class AccountServiceImpl implements AccountService {
@Resource
private AccountMapper accountMapper;
/** admin 赋余额流水 MapperU2 经济:赋余额记一笔可对账 + bizNo 幂等) */
@Resource
private GrantMapper grantMapper;
/** 赋余额来源标识(落 game_trade_grant.source区别于收益流水的 ad/tip 分账来源) */
private static final String GRANT_SOURCE_ADMIN = "admin_grant";
@Override
public AccountDO getOrInitAccount(Long userId) {
// 1) 先查已存在直接返回
@ -69,6 +81,57 @@ public class AccountServiceImpl implements AccountService {
}
}
@Override
@Transactional(rollbackFor = Exception.class) // 写赋余额流水 + 账户余额原子增需同事务要么都成要么都回滚
public Long grant(Long userId, Long amount, String bizNo, Long operatorUserId, String remark) {
// 1) 校验赋余额只增不减amount 必须 > 0
if (amount == null || amount <= 0) {
throw exception(TRADE_GRANT_AMOUNT_INVALID);
}
// 2) bizNo 幂等先查 bizNo 已赋过 返回已有记录 ID不重复入账不重复发钱
GrantDO existing = grantMapper.selectByBizNo(bizNo);
if (existing != null) {
log.info("[grant] 命中赋余额幂等键,返回已有记录 bizNo={} id={}", bizNo, existing.getId());
return existing.getId();
}
// 3) 确保账户存在按需初始化为零值账户
getOrInitAccount(userId);
// 4) 写赋余额流水uk_biz_no 保证并发同 bizNo 只一条 insert 再增余额insert 失败不会动余额
GrantDO grant = new GrantDO();
grant.setUserId(userId);
grant.setAmount(amount);
grant.setBizNo(bizNo);
grant.setSource(GRANT_SOURCE_ADMIN);
grant.setOperatorUserId(operatorUserId);
grant.setRemark(remark == null ? "" : remark);
try {
grantMapper.insert(grant);
} catch (DuplicateKeyException e) {
// 并发兜底先查与插入之间被并发提交同 bizNo 回查返回已有不重复入账
log.info("[grant] 并发命中赋余额幂等键,回查已有记录 bizNo={}", bizNo);
GrantDO concurrent = grantMapper.selectByBizNo(bizNo);
if (concurrent == null) {
// 唯一键冲突却查不到非幂等的并发异常抛错让事务回滚
throw exception(TRADE_GRANT_BIZ_NO_CONFLICT);
}
return concurrent.getId();
}
// 5) 账户余额原子增balance += amounttotal_income += amount同事务本流水即增余额来源
int updated = accountMapper.grantBalance(userId, amount);
if (updated == 0) {
// 理论不可达上一步已确保账户存在抛错让事务回滚绝不允许流水已记余额未增半态
log.error("[grant] 赋余额更新 0 行账户异常缺失userId={} amount={} bizNo={}", userId, amount, bizNo);
throw exception(TRADE_ACCOUNT_BALANCE_NOT_ENOUGH);
}
log.info("[grant] admin 赋余额成功 userId={} amount={} bizNo={} operator={} grantId={}",
userId, amount, bizNo, operatorUserId, grant.getId());
return grant.getId();
}
@Override
public void freeze(Long userId, Long amount) {
// 原子扣减带余额充足校验更新 0 = 余额不足防超扣

View File

@ -0,0 +1,49 @@
package com.wanxiang.huijing.game.module.trade.service.subscription;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
/**
* 会员订阅 ServiceU2 经济admin 赋订阅 + C 端查订阅态
*
* 三档变现订阅AGENTS §1订阅会员为三条变现线之一月度/季度/年度MVP 不接真实支付收单 pay日历闸门
* 本轮订阅由 admin 手动赋运营/B 端开通真实自助购买 pay 下单回调开通= later
*
* 有效性判定 cron 物化过期 expire_time 与当前时间比较为权威实时判定
* 查询时按 expire_time>now 回算是否有效不依赖定时 job 也能正确反映过期态
*
* @author 造梦AI
*/
public interface SubscriptionService {
/**
* admin 赋订阅幂等续期叠加upsert 用户订阅记录 + 叠加到期时间
*
* 资金/权益安全与可对账
* - 幂等 bizNo 去重同一 bizNo 重复提交不重复延长时长返回当前订阅记录幂等
* - 续期叠加已有未过期订阅则新到期 = max(now, expire) + 时长不丢未用时长已过期/无记录则从 now 起算
* - 校验plan 必须合法{@link com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum}
* durationDays 必须 > 0非法分别抛 TRADE_SUBSCRIPTION_PLAN_INVALID / TRADE_SUBSCRIPTION_DURATION_INVALID
*
* @param userId 订阅用户 ID
* @param plan 订阅套餐1 月度 / 2 季度 / 3 年度
* @param durationDays 赋订阅时长>0
* @param bizNo 赋订阅业务单号 = 幂等键调用端生成唯一
* @param operatorUserId 操作人用户 ID运营/admin审计可溯可空
* @param remark 订阅备注开通原因可空
* @return 赋值后的订阅记录含最新 expire_time
*/
SubscriptionDO grantSubscription(Long userId, Integer plan, Integer durationDays,
String bizNo, Long operatorUserId, String remark);
/**
* 查询用户当前订阅记录C 端查订阅态 expire_time>now 实时判定有效性
*
* 归属隔离userId token 解析getLoginUserId()非前端入参
* 无订阅记录返回 null Controller 未订阅
*
* @param userId 订阅用户 ID
* @return 订阅 DO从未订阅返回 null
*/
SubscriptionDO getMySubscription(Long userId);
}

View File

@ -0,0 +1,217 @@
package com.wanxiang.huijing.game.module.trade.service.subscription;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionGrantDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.SubscriptionGrantMapper;
import com.wanxiang.huijing.game.module.trade.dal.mysql.SubscriptionMapper;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionStatusEnum;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_DURATION_INVALID;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_PLAN_INVALID;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 会员订阅 Service 实现U2 经济admin 赋订阅 + C 端查订阅态
*
* 赋订阅核心
* - 幂等P0 修复每笔赋订阅先在独立账本表 game_trade_subscription_grant 写一行uk_biz_no
* 同一 bizNo 撞键即已处理幂等返回当前订阅从根上杜绝任意历史 bizNo 重放重复续期
* 订阅行内 last_grant_biz_no 只能挡最近一次重放挡不住历史 bizNo 重放故改用独立账本照搬赋余额 grant 范式
* - 一用户一行uk_user首次赋订阅 insert now 起算已有则续期叠加CAS 防并发 lost-update
* - 续期叠加新到期 = max(now, expire) + 时长不丢未用时长已过期则等价从 now 起算
* - 校验plan 合法durationDays>0非法抛对应错误码
*
* 有效性判定 cron 物化过期getMySubscription 返回原始记录effectiveStatus是否有效由调用方/Controller
* expire_time>now 实时回算status 列仅为初始态/物化预留保证不依赖定时 job 也能正确反映过期
*
* @author 造梦AI
*/
@Service
public class SubscriptionServiceImpl implements SubscriptionService {
private static final Logger log = LoggerFactory.getLogger(SubscriptionServiceImpl.class);
/** 订阅来源标识(落 game_trade_subscription.source */
private static final String SUBSCRIPTION_SOURCE_ADMIN = "admin_grant";
@Resource
private SubscriptionMapper subscriptionMapper;
/** 赋订阅幂等账本 MapperP0每笔赋订阅记一行 + uk_biz_no 幂等,挡任意历史 bizNo 重放) */
@Resource
private SubscriptionGrantMapper subscriptionGrantMapper;
@Override
@Transactional(rollbackFor = Exception.class) // 写幂等账本 + 订阅 upsert 同事务账本入与续期要么都成要么都回滚
public SubscriptionDO grantSubscription(Long userId, Integer plan, Integer durationDays,
String bizNo, Long operatorUserId, String remark) {
// 1) 校验套餐合法 + 时长 > 0
if (!TradeSubscriptionPlanEnum.isValid(plan)) {
throw exception(TRADE_SUBSCRIPTION_PLAN_INVALID);
}
if (durationDays == null || durationDays <= 0) {
throw exception(TRADE_SUBSCRIPTION_DURATION_INVALID);
}
// 2) bizNo 幂等先查账本 bizNo 已赋过 不重复延长直接返回当前订阅态订阅行是权威含已叠加到期时间
// P0 关键独立账本表挡任意历史 bizNo重放行内 last_grant_biz_no 只能挡最近一次
SubscriptionGrantDO existingGrant = subscriptionGrantMapper.selectByBizNo(bizNo);
if (existingGrant != null) {
log.info("[grantSubscription] 命中订阅赋值幂等账本,跳过重复延长 userId={} bizNo={} grantId={}",
userId, bizNo, existingGrant.getId());
return loadCurrentForIdempotentReturn(userId, existingGrant);
}
// 3) 写赋订阅幂等账本uk_biz_no 保证并发同 bizNo 只一条 insert撞键即已处理不再续期
// expire_time_after 先留空此时尚未续期不知最终到期续期成功后回填快照对账用
SubscriptionGrantDO grant = new SubscriptionGrantDO();
grant.setUserId(userId);
grant.setBizNo(bizNo);
grant.setPlan(plan);
grant.setDurationDays(durationDays);
grant.setSource(SUBSCRIPTION_SOURCE_ADMIN);
grant.setOperatorUserId(operatorUserId);
grant.setRemark(remark == null ? "" : remark);
try {
subscriptionGrantMapper.insert(grant);
} catch (DuplicateKeyException e) {
// 并发兜底先查与插入之间被并发提交同 bizNo 回查账本幂等返回不重复续期
log.info("[grantSubscription] 并发命中订阅赋值幂等账本,回查已有账本 userId={} bizNo={}", userId, bizNo);
SubscriptionGrantDO concurrent = subscriptionGrantMapper.selectByBizNo(bizNo);
if (concurrent == null) {
// 唯一键冲突却查不到非幂等的并发异常抛错让事务回滚
throw exception(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT);
}
return loadCurrentForIdempotentReturn(userId, concurrent);
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime plusDuration = now.plusDays(durationDays);
// 4) 查现有订阅uk_user并做 upsert 续期
SubscriptionDO existing = subscriptionMapper.selectByUserId(userId);
SubscriptionDO result;
// 4a) 无记录 首次赋订阅insert now 起算到期 = now + 时长
if (existing == null) {
SubscriptionDO created = new SubscriptionDO();
created.setUserId(userId);
created.setPlan(plan);
created.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus());
created.setStartTime(now);
created.setExpireTime(plusDuration);
created.setSource(SUBSCRIPTION_SOURCE_ADMIN);
created.setLastGrantBizNo(bizNo);
created.setOperatorUserId(operatorUserId);
created.setRemark(remark == null ? "" : remark);
try {
subscriptionMapper.insert(created);
log.info("[grantSubscription] 首次赋订阅 userId={} plan={} durationDays={} expire={} bizNo={}",
userId, plan, durationDays, plusDuration, bizNo);
result = created;
} catch (DuplicateKeyException e) {
// 并发兜底先查与插入之间被并发赋订阅命中 uk_user 回查走续期分支
log.info("[grantSubscription] 并发命中订阅 uk_user回查转续期 userId={}", userId);
existing = subscriptionMapper.selectByUserId(userId);
if (existing == null) {
throw exception(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT);
}
result = renewWithRetry(existing, userId, plan, durationDays, bizNo, operatorUserId, remark);
}
} else {
// 4b) 已有记录 续期叠加CAS 防并发 lost-update最多重试一轮
result = renewWithRetry(existing, userId, plan, durationDays, bizNo, operatorUserId, remark);
}
// 5) 回填账本到期快照expire_time_after对账用与订阅 upsert 同事务
grant.setExpireTimeAfter(result.getExpireTime());
subscriptionGrantMapper.updateById(grant);
return result;
}
/**
* 幂等命中时加载返回值订阅行uk_user是权威态返回它含已叠加的到期时间
* 理论上账本存在则订阅行必存在同事务写入兜底为 null 时退回账本快照构造最小返回对象
*
* @param userId 订阅用户 ID
* @param hitGrant 命中的幂等账本携带本次赋值后到期快照 expire_time_after
* @return 当前订阅记录
*/
private SubscriptionDO loadCurrentForIdempotentReturn(Long userId, SubscriptionGrantDO hitGrant) {
SubscriptionDO current = subscriptionMapper.selectByUserId(userId);
if (current != null) {
return current;
}
// 兜底理论不可达账本与订阅同事务写入用账本快照构造最小返回保证幂等返回有到期时间
log.warn("[grantSubscription] 幂等命中但订阅行缺失(数据异常?),退回账本快照 userId={} grantId={}",
userId, hitGrant.getId());
SubscriptionDO fallback = new SubscriptionDO();
fallback.setUserId(userId);
fallback.setPlan(hitGrant.getPlan());
fallback.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus());
fallback.setExpireTime(hitGrant.getExpireTimeAfter());
fallback.setSource(hitGrant.getSource());
fallback.setLastGrantBizNo(hitGrant.getBizNo());
fallback.setOperatorUserId(hitGrant.getOperatorUserId());
fallback.setRemark(hitGrant.getRemark());
return fallback;
}
/**
* 续期叠加CAS新到期 = max(now,旧expire)+时长CAS 更新幂等已由调用方账本表前置保证此处不再判 bizNo
*
* CAS 命中 0 = 并发续期改了 expire_time 回查重试一次再失败抛冲突错误码避免死循环
*
* @return 续期后的订阅记录
*/
private SubscriptionDO renewWithRetry(SubscriptionDO current, Long userId, Integer plan, Integer durationDays,
String bizNo, Long operatorUserId, String remark) {
for (int attempt = 0; attempt < 2; attempt++) {
LocalDateTime now = LocalDateTime.now();
// 续期基点 = max(now, 旧到期)未过期则从旧到期叠加不丢未用时长已过期则从 now 起算
LocalDateTime base = current.getExpireTime().isAfter(now) ? current.getExpireTime() : now;
LocalDateTime newExpire = base.plusDays(durationDays);
int moved = subscriptionMapper.renewByCas(current.getId(), current.getExpireTime(), newExpire,
plan, TradeSubscriptionStatusEnum.ACTIVE.getStatus(), now, bizNo, operatorUserId, remark);
if (moved == 1) {
log.info("[grantSubscription] 续期叠加成功 userId={} plan={} durationDays={} 旧expire={} 新expire={} bizNo={}",
userId, plan, durationDays, current.getExpireTime(), newExpire, bizNo);
// 回填内存对象返回避免再查库
current.setPlan(plan);
current.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus());
current.setStartTime(now);
current.setExpireTime(newExpire);
current.setLastGrantBizNo(bizNo);
current.setOperatorUserId(operatorUserId);
current.setRemark(remark == null ? "" : remark);
return current;
}
// CAS 未命中并发续期改了 expire_time 回查最新再重试一轮
log.info("[grantSubscription] 续期 CAS 命中 0 行(并发续期),回查重试 userId={} attempt={}", userId, attempt);
current = subscriptionMapper.selectByUserId(userId);
if (current == null) {
throw exception(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT);
}
}
// 两轮 CAS 仍失败高并发持续撞抛冲突让调用方重试不死循环
throw exception(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT);
}
@Override
public SubscriptionDO getMySubscription(Long userId) {
// 归属隔离userId 取自 token 解析的 getLoginUserId()Controller 传入命中 uk_user
return subscriptionMapper.selectByUserId(userId);
}
}

View File

@ -21,9 +21,11 @@
-- -----------------------------------------------------------------------------
-- 表game_trade_account —— 创作者收益账户(物化汇总;余额变更必有对应流水/提现单)
-- 不变式total_income = Σ game_trade_income.net_amount本人
-- balance + frozen + total_withdraw = total_income余额 + 冻结中 + 已提现 = 累计收益)
-- 不变式total_income = Σ game_trade_income.net_amount本人分账+ Σ game_trade_grant.amount本人 admin 赋余额)——
-- 即「累计入账」= 分账入账 + admin 赋余额两类资金来源之和U2 起 admin 赋余额也计入,维持下方账面恒等式);
-- balance + frozen + total_withdraw = total_income余额 + 冻结中 + 已提现 = 累计入账,恒等式始终成立)
-- 写入点:① 分账入账SettlementJob→ total_income += net、balance += net
-- ②′ admin 赋余额U2AccountService.grant→ total_income += amount、balance += amount记 game_trade_grant 流水佐证);
-- ② 提现申请 → balance -= amount、frozen += amount冻结
-- ③ 打款成功 → frozen -= amount、total_withdraw += amount
-- ④ 驳回/打款失败 → frozen -= amount、balance += amount退回
@ -34,7 +36,7 @@ CREATE TABLE `game_trade_account` (
`user_id` BIGINT NOT NULL COMMENT '创作者用户 IDDataPermission创作者只见自己账户',
`balance` BIGINT NOT NULL DEFAULT 0 COMMENT '可提现余额单位BIGINT 禁浮点)',
`frozen` BIGINT NOT NULL DEFAULT 0 COMMENT '冻结中(提现处理中占用,单位:分;提现申请时从 balance 转入)',
`total_income` BIGINT NOT NULL DEFAULT 0 COMMENT '累计收益(历史分账入账总额,单位:分;= Σ 本人收益流水 net_amount',
`total_income` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账(单位:分;= Σ 本人收益流水 net_amount + Σ 本人 admin 赋余额 grant.amountU2 起含赋余额,维持 balance+frozen+total_withdraw 恒等式',
`total_withdraw` BIGINT NOT NULL DEFAULT 0 COMMENT '累计已提现(已打款成功总额,单位:分)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Yudao 审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(账户初始化时间)',

View File

@ -0,0 +1,78 @@
package com.wanxiang.huijing.game.module.trade.convert;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.SubscriptionRespVO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionStatusEnum;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link TradeConvert#toSubscriptionVO} 单元测试订阅过期态实时判定
*
* 验证 C 端查订阅态的有效性回算expire_time>now这是 cron 物化过期也能正确反映过期的核心
* - 未过期expire>nowactive=trueeffectiveStatus=生效中(0)
* - 已过期expirenowactive=falseeffectiveStatus=已过期(1)即便落库 status 仍为 0
* - 从未订阅null空态 subscribed=falseactive=false不抛 NPE
*
* @author 造梦AI
*/
class TradeConvertSubscriptionTest {
@Test
void testToSubscriptionVO_activeWhenNotExpired() {
// 到期在未来 有效
SubscriptionDO sub = sub(TradeSubscriptionPlanEnum.YEARLY.getPlan(),
LocalDateTime.now().plusDays(100));
SubscriptionRespVO vo = TradeConvert.toSubscriptionVO(sub);
assertTrue(vo.getSubscribed());
assertTrue(vo.getActive());
assertEquals(TradeSubscriptionPlanEnum.YEARLY.getPlan(), vo.getPlan());
assertEquals(TradeSubscriptionStatusEnum.ACTIVE.getStatus(), vo.getEffectiveStatus());
assertNotNull(vo.getExpireTime());
}
@Test
void testToSubscriptionVO_expiredWhenPastEvenIfStatusActive() {
// 到期已过去但落库 status 仍为生效中(0) 实时回算应判已过期不依赖落库 status cron 也准
SubscriptionDO sub = sub(TradeSubscriptionPlanEnum.MONTHLY.getPlan(),
LocalDateTime.now().minusSeconds(1));
sub.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus()); // 故意留旧的生效中态
SubscriptionRespVO vo = TradeConvert.toSubscriptionVO(sub);
assertTrue(vo.getSubscribed());
assertFalse(vo.getActive()); // 实时判定已过期
assertEquals(TradeSubscriptionStatusEnum.EXPIRED.getStatus(), vo.getEffectiveStatus());
}
@Test
void testToSubscriptionVO_neverSubscribedEmptyState() {
// 从未订阅null 空态不抛 NPE
SubscriptionRespVO vo = TradeConvert.toSubscriptionVO(null);
assertFalse(vo.getSubscribed());
assertFalse(vo.getActive());
assertNull(vo.getPlan());
assertNull(vo.getEffectiveStatus());
assertNull(vo.getExpireTime());
}
private static SubscriptionDO sub(Integer plan, LocalDateTime expire) {
SubscriptionDO s = new SubscriptionDO();
s.setId(1L);
s.setUserId(99L);
s.setPlan(plan);
s.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus());
s.setStartTime(LocalDateTime.now().minusDays(1));
s.setExpireTime(expire);
s.setSource("admin_grant");
s.setLastGrantBizNo("X");
return s;
}
}

View File

@ -1,7 +1,9 @@
package com.wanxiang.huijing.game.module.trade.service.account;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.AccountDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.GrantDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.AccountMapper;
import com.wanxiang.huijing.game.module.trade.dal.mysql.GrantMapper;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
@ -12,8 +14,11 @@ import org.springframework.dao.DuplicateKeyException;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_ACCOUNT_BALANCE_NOT_ENOUGH;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_ACCOUNT_FROZEN_NOT_ENOUGH;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_GRANT_AMOUNT_INVALID;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_GRANT_BIZ_NO_CONFLICT;
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.*;
@ -33,6 +38,9 @@ class AccountServiceImplTest extends BaseMockitoUnitTest {
@Mock
private AccountMapper accountMapper;
@Mock
private GrantMapper grantMapper; // U2赋余额流水bizNo 幂等
// ============================== 建账户幂等 ==============================
@Test
@ -151,6 +159,101 @@ class AccountServiceImplTest extends BaseMockitoUnitTest {
assertEquals(TRADE_ACCOUNT_FROZEN_NOT_ENOUGH.getCode(), ex.getCode());
}
// ============================== U2admin 赋余额流水 + 余额增 + bizNo 幂等 + 校验==============================
@Test
void testGrant_writesGrantFlowAndIncrementsBalance() {
// 无重复 bizNo 首次赋余额 game_trade_grant 流水 + 账户余额原子增balance/total_income += amount
when(grantMapper.selectByBizNo("GR-1")).thenReturn(null);
when(accountMapper.selectByUserId(99L)).thenReturn(account(99L, 1000L)); // getOrInitAccount 命中已存在
when(grantMapper.insert(any(GrantDO.class))).thenReturn(1);
when(accountMapper.grantBalance(99L, 500L)).thenReturn(1);
Long grantId = accountService.grant(99L, 500L, "GR-1", 8L, "活动奖励");
// 校验落库的赋余额流水字段金额/bizNo/来源 admin_grant/操作人/备注
ArgumentCaptor<GrantDO> captor = ArgumentCaptor.forClass(GrantDO.class);
verify(grantMapper).insert(captor.capture());
GrantDO saved = captor.getValue();
assertEquals(99L, saved.getUserId());
assertEquals(500L, saved.getAmount());
assertEquals("GR-1", saved.getBizNo());
assertEquals("admin_grant", saved.getSource()); // 固定来源标识区别 income ad/tip
assertEquals(8L, saved.getOperatorUserId());
assertEquals("活动奖励", saved.getRemark());
// 账户余额原子增balance += amounttotal_income += amount
verify(accountMapper).grantBalance(99L, 500L);
assertNotNull(grantId);
}
@Test
void testGrant_idempotentSameBizNoNoDoubleCredit() {
// 同一 bizNo 已赋过 返回已有记录 ID insert不增余额不重复发钱
GrantDO existed = new GrantDO();
existed.setId(2048L);
when(grantMapper.selectByBizNo("GR-1")).thenReturn(existed);
Long grantId = accountService.grant(99L, 500L, "GR-1", 8L, "活动奖励");
assertEquals(2048L, grantId);
verify(grantMapper, never()).insert(any(GrantDO.class)); // 幂等不重复入账
verify(accountMapper, never()).grantBalance(anyLong(), anyLong()); // 不重复增余额
}
@Test
void testGrant_concurrentDuplicateKeyReturnsExisting() {
// 并发兜底先查无insert 命中 uk_biz_no DuplicateKeyException 回查返回已有不重复入账
GrantDO concurrent = new GrantDO();
concurrent.setId(3072L);
when(grantMapper.selectByBizNo("GR-1")).thenReturn(null) // 先查无
.thenReturn(concurrent); // 兜底回查命中
when(accountMapper.selectByUserId(99L)).thenReturn(account(99L, 1000L));
when(grantMapper.insert(any(GrantDO.class))).thenThrow(new DuplicateKeyException("uk_biz_no"));
Long grantId = accountService.grant(99L, 500L, "GR-1", 8L, null);
assertEquals(3072L, grantId);
verify(accountMapper, never()).grantBalance(anyLong(), anyLong()); // 并发重复不增余额
}
@Test
void testGrant_concurrentDuplicateKeyButRequeryMissThrows() {
// 并发命中 uk_biz_no 却回查不到极端竞态抛冲突错误码让事务回滚
when(grantMapper.selectByBizNo("GR-1")).thenReturn(null).thenReturn(null);
when(accountMapper.selectByUserId(99L)).thenReturn(account(99L, 1000L));
when(grantMapper.insert(any(GrantDO.class))).thenThrow(new DuplicateKeyException("uk_biz_no"));
ServiceException ex = assertThrows(ServiceException.class,
() -> accountService.grant(99L, 500L, "GR-1", 8L, null));
assertEquals(TRADE_GRANT_BIZ_NO_CONFLICT.getCode(), ex.getCode());
}
@Test
void testGrant_amountNotPositiveRejected() {
// 赋余额只增不减amount 0 抛非法0 与负数均拒绝不查 bizNo不增余额
ServiceException ex0 = assertThrows(ServiceException.class,
() -> accountService.grant(99L, 0L, "GR-1", 8L, null));
assertEquals(TRADE_GRANT_AMOUNT_INVALID.getCode(), ex0.getCode());
ServiceException exNeg = assertThrows(ServiceException.class,
() -> accountService.grant(99L, -100L, "GR-2", 8L, null));
assertEquals(TRADE_GRANT_AMOUNT_INVALID.getCode(), exNeg.getCode());
verify(grantMapper, never()).selectByBizNo(any());
verify(accountMapper, never()).grantBalance(anyLong(), anyLong());
}
@Test
void testGrant_balanceUpdateZeroRowsThrows() {
// 流水已写但余额增 0 账户异常缺失抛错让事务回滚绝不允许流水已记余额未增半态
when(grantMapper.selectByBizNo("GR-1")).thenReturn(null);
when(accountMapper.selectByUserId(99L)).thenReturn(account(99L, 1000L));
when(grantMapper.insert(any(GrantDO.class))).thenReturn(1);
when(accountMapper.grantBalance(99L, 500L)).thenReturn(0); // 余额增命中 0 异常态
ServiceException ex = assertThrows(ServiceException.class,
() -> accountService.grant(99L, 500L, "GR-1", 8L, null));
assertEquals(TRADE_ACCOUNT_BALANCE_NOT_ENOUGH.getCode(), ex.getCode());
}
// ============================== 测试夹具 ==============================
private static AccountDO account(Long userId, Long balance) {

View File

@ -0,0 +1,342 @@
package com.wanxiang.huijing.game.module.trade.service.subscription;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionGrantDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.SubscriptionGrantMapper;
import com.wanxiang.huijing.game.module.trade.dal.mysql.SubscriptionMapper;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionPlanEnum;
import com.wanxiang.huijing.game.module.trade.enums.TradeSubscriptionStatusEnum;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_DURATION_INVALID;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_SUBSCRIPTION_PLAN_INVALID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link SubscriptionServiceImpl} 单元测试 Mockito不依赖 DB
*
* 覆盖 U2 会员订阅赋值/续期守卫P0 修复后幂等由独立账本 game_trade_subscription_grant.uk_biz_no 保证
* - 首次赋订阅先写账本 订阅 insert到期 = now + 时长
* - 续期叠加未过期先写账本 新到期 = expire + 时长不丢未用时长CAS
* - 续期已过期 now 起算不把过去时长算进去
* - bizNo 幂等账本命中不写账本不延长 CAS/insert返回当前订阅态
* - 历史 bizNo 重放P0 核心回归账本表挡住不再重复续期
* - 并发同 bizNo账本 insert uk_biz_no回查账本幂等返回
* - 并发续期 CAS 命中 0 回查重试一轮
* - 校验plan 非法 / durationDays 非法拒绝不查库不写账本
* 到期判定expire_time>now TradeConvert.toSubscriptionVO TradeConvertSubscriptionTest
*
* @author 造梦AI
*/
class SubscriptionServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private SubscriptionServiceImpl subscriptionService;
@Mock
private SubscriptionMapper subscriptionMapper;
/** P0赋订阅幂等账本 Mapperuk_biz_no 挡任意历史 bizNo 重放) */
@Mock
private SubscriptionGrantMapper subscriptionGrantMapper;
private static final Integer PLAN_MONTHLY = TradeSubscriptionPlanEnum.MONTHLY.getPlan();
// ============================== 首次赋订阅 ==============================
@Test
void testGrant_firstTimeInsertFromNow() {
// 账本未命中首次提交该 bizNo 无订阅记录 首次赋订阅insert到期 = now + 30 误差容忍 1 分钟
when(subscriptionGrantMapper.selectByBizNo("SUB-1")).thenReturn(null);
when(subscriptionMapper.selectByUserId(99L)).thenReturn(null);
when(subscriptionMapper.insert(any(SubscriptionDO.class))).thenReturn(1);
LocalDateTime before = LocalDateTime.now();
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-1", 8L, "首充");
// 账本先写一行幂等凭证
verify(subscriptionGrantMapper).insert(any(SubscriptionGrantDO.class));
ArgumentCaptor<SubscriptionDO> captor = ArgumentCaptor.forClass(SubscriptionDO.class);
verify(subscriptionMapper).insert(captor.capture());
SubscriptionDO saved = captor.getValue();
assertEquals(99L, saved.getUserId());
assertEquals(PLAN_MONTHLY, saved.getPlan());
assertEquals(TradeSubscriptionStatusEnum.ACTIVE.getStatus(), saved.getStatus());
assertEquals("admin_grant", saved.getSource());
assertEquals("SUB-1", saved.getLastGrantBizNo()); // 行内仅审计记最近一次 bizNo
assertEquals(8L, saved.getOperatorUserId());
// 到期 = now + 30 首次从 now 起算
assertTrue(saved.getExpireTime().isAfter(before.plusDays(30).minusMinutes(1)));
assertTrue(saved.getExpireTime().isBefore(LocalDateTime.now().plusDays(30).plusMinutes(1)));
assertSame(saved, result);
verify(subscriptionMapper, never()).renewByCas(anyLong(), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any());
}
// ============================== 续期叠加未过期==============================
@Test
void testGrant_renewUnexpiredStacksOnOldExpire() {
// 账本未命中 已有未过期订阅到期=now+10天 续期 30 新到期 = expire + 30不丢未用 10 = 共约 40 天后
when(subscriptionGrantMapper.selectByBizNo("SUB-2")).thenReturn(null);
LocalDateTime oldExpire = LocalDateTime.now().plusDays(10);
SubscriptionDO existing = sub(500L, 99L, PLAN_MONTHLY, oldExpire, "OTHER-BIZ");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(existing);
// CAS 命中新到期 = oldExpire + 30
when(subscriptionMapper.renewByCas(eq(500L), eq(oldExpire), any(), eq(PLAN_MONTHLY),
eq(TradeSubscriptionStatusEnum.ACTIVE.getStatus()), any(), eq("SUB-2"), eq(8L), any()))
.thenReturn(1);
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-2", 8L, "续费");
// 续期基点 = expire未过期不丢时长新到期 oldExpire + 30
ArgumentCaptor<LocalDateTime> newExpireCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(subscriptionMapper).renewByCas(eq(500L), eq(oldExpire), newExpireCaptor.capture(),
anyInt(), anyInt(), any(), eq("SUB-2"), anyLong(), any());
LocalDateTime newExpire = newExpireCaptor.getValue();
assertEquals(oldExpire.plusDays(30), newExpire); // 精确旧到期 + 30
assertEquals(newExpire, result.getExpireTime()); // 回填内存对象
verify(subscriptionGrantMapper).insert(any(SubscriptionGrantDO.class)); // 账本先写
verify(subscriptionMapper, never()).insert(any(SubscriptionDO.class));
}
// ============================== 续期已过期 now 起算 ==============================
@Test
void testGrant_renewExpiredStartsFromNow() {
// 账本未命中 已有过期订阅到期=now-5天 续期 30 now 起算不把过去 5 天算进去新到期 now + 30
when(subscriptionGrantMapper.selectByBizNo("SUB-3")).thenReturn(null);
LocalDateTime oldExpire = LocalDateTime.now().minusDays(5);
SubscriptionDO existing = sub(501L, 99L, PLAN_MONTHLY, oldExpire, "OTHER-BIZ");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(existing);
when(subscriptionMapper.renewByCas(eq(501L), eq(oldExpire), any(), anyInt(), anyInt(), any(), any(), anyLong(), any()))
.thenReturn(1);
LocalDateTime before = LocalDateTime.now();
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-3", 8L, null);
ArgumentCaptor<LocalDateTime> newExpireCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(subscriptionMapper).renewByCas(eq(501L), eq(oldExpire), newExpireCaptor.capture(),
anyInt(), anyInt(), any(), eq("SUB-3"), anyLong(), any());
LocalDateTime newExpire = newExpireCaptor.getValue();
// now 起算新到期 now + 30 不含过去时长
assertTrue(newExpire.isAfter(before.plusDays(30).minusMinutes(1)));
assertTrue(newExpire.isBefore(LocalDateTime.now().plusDays(30).plusMinutes(1)));
assertEquals(newExpire, result.getExpireTime());
}
// ============================== bizNo 幂等账本命中不重复延长==============================
@Test
void testGrant_idempotentSameBizNoHitLedgerNoExtend() {
// 账本命中 bizNo 已赋过 幂等不写账本不延长 CAS/insert返回当前订阅态订阅行权威
SubscriptionGrantDO hitGrant = grantLedger(700L, 99L, "SUB-DUP", LocalDateTime.now().plusDays(40));
when(subscriptionGrantMapper.selectByBizNo("SUB-DUP")).thenReturn(hitGrant);
LocalDateTime curExpire = LocalDateTime.now().plusDays(40);
SubscriptionDO current = sub(502L, 99L, PLAN_MONTHLY, curExpire, "SUB-DUP");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(current);
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-DUP", 8L, null);
assertSame(current, result);
assertEquals(curExpire, result.getExpireTime()); // 到期不变不重复延长
verify(subscriptionGrantMapper, never()).insert(any(SubscriptionGrantDO.class)); // 幂等命中不再写账本
verify(subscriptionMapper, never()).renewByCas(anyLong(), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any());
verify(subscriptionMapper, never()).insert(any(SubscriptionDO.class));
}
// ============================== 历史 bizNo 重放P0 核心回归==============================
@Test
void testGrant_replayOldBizNoBlockedByLedger() {
// P0 漏洞回归行内 last_grant_biz_no 已是最近一次=B重放历史 bizNo=A
// 旧实现行内键会误判 AB 再次续期新实现账本表 selectByBizNo(A) 命中 幂等挡住绝不重复续期
SubscriptionGrantDO oldGrantA = grantLedger(701L, 99L, "BIZ-A", LocalDateTime.now().plusDays(30));
when(subscriptionGrantMapper.selectByBizNo("BIZ-A")).thenReturn(oldGrantA);
// 当前订阅行 last_grant_biz_no=B最近一次是 B到期已叠加
SubscriptionDO current = sub(503L, 99L, PLAN_MONTHLY, LocalDateTime.now().plusDays(60), "BIZ-B");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(current);
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "BIZ-A", 8L, "重放A");
assertSame(current, result); // 返回当前态未延长
verify(subscriptionGrantMapper, never()).insert(any(SubscriptionGrantDO.class));
verify(subscriptionMapper, never()).renewByCas(anyLong(), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any());
verify(subscriptionMapper, never()).insert(any(SubscriptionDO.class));
}
// ============================== 并发同 bizNo账本 insert uk_biz_no 回查幂等返回 ==============================
@Test
void testGrant_concurrentLedgerDuplicateKeyFallback() {
// 账本首查未命中 insert uk_biz_no并发同 bizNo 回查账本命中 幂等返回当前订阅不续期
when(subscriptionGrantMapper.selectByBizNo("SUB-CC"))
.thenReturn(null) // 首查未命中
.thenReturn(grantLedger(702L, 99L, "SUB-CC", LocalDateTime.now().plusDays(30))); // 撞键后回查命中
when(subscriptionGrantMapper.insert(any(SubscriptionGrantDO.class)))
.thenThrow(new DuplicateKeyException("uk_biz_no"));
SubscriptionDO current = sub(504L, 99L, PLAN_MONTHLY, LocalDateTime.now().plusDays(30), "SUB-CC");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(current);
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-CC", 8L, null);
assertSame(current, result);
verify(subscriptionMapper, never()).renewByCas(anyLong(), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any());
verify(subscriptionMapper, never()).insert(any(SubscriptionDO.class));
}
@Test
void testGrant_concurrentLedgerDuplicateKeyButMissThrowsConflict() {
// 账本 insert uk_biz_no 却回查不到非幂等的并发异常 抛冲突让事务回滚
when(subscriptionGrantMapper.selectByBizNo("SUB-CCX")).thenReturn(null).thenReturn(null);
when(subscriptionGrantMapper.insert(any(SubscriptionGrantDO.class)))
.thenThrow(new DuplicateKeyException("uk_biz_no"));
ServiceException ex = assertThrows(ServiceException.class,
() -> subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-CCX", 8L, null));
assertEquals(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT.getCode(), ex.getCode());
}
// ============================== 并发续期 CAS 命中 0 回查重试 ==============================
@Test
void testGrant_renewCasMissRetriesThenSucceeds() {
// 账本未命中 第一次 CAS 命中 0 并发改了 expire 回查最新 第二轮 CAS 成功
when(subscriptionGrantMapper.selectByBizNo("SUB-4")).thenReturn(null);
LocalDateTime expire1 = LocalDateTime.now().plusDays(10);
LocalDateTime expire2 = LocalDateTime.now().plusDays(20); // 并发续期后的新到期
SubscriptionDO v1 = sub(505L, 99L, PLAN_MONTHLY, expire1, "OLD");
SubscriptionDO v2 = sub(505L, 99L, PLAN_MONTHLY, expire2, "OLD");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(v1) // 首查
.thenReturn(v2); // CAS miss 后回查
when(subscriptionMapper.renewByCas(eq(505L), eq(expire1), any(), anyInt(), anyInt(), any(), any(), anyLong(), any()))
.thenReturn(0); // 第一轮 miss
when(subscriptionMapper.renewByCas(eq(505L), eq(expire2), any(), anyInt(), anyInt(), any(), any(), anyLong(), any()))
.thenReturn(1); // 第二轮命中
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-4", 8L, null);
// 第二轮以 expire2 为基点叠加 30
assertEquals(expire2.plusDays(30), result.getExpireTime());
verify(subscriptionMapper, times(2)).renewByCas(eq(505L), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any());
}
@Test
void testGrant_renewCasMissTwiceThrowsConflict() {
// 账本未命中 两轮 CAS miss高并发持续撞 抛冲突不死循环
when(subscriptionGrantMapper.selectByBizNo("SUB-5")).thenReturn(null);
LocalDateTime expire1 = LocalDateTime.now().plusDays(10);
SubscriptionDO v1 = sub(506L, 99L, PLAN_MONTHLY, expire1, "OLD");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(v1);
when(subscriptionMapper.renewByCas(anyLong(), any(), any(), anyInt(), anyInt(), any(), any(), anyLong(), any()))
.thenReturn(0); // 始终 miss
ServiceException ex = assertThrows(ServiceException.class,
() -> subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-5", 8L, null));
assertEquals(TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT.getCode(), ex.getCode());
}
// ============================== 首建并发命中 uk_user 转续期 ==============================
@Test
void testGrant_insertDuplicateKeyFallbackToRenew() {
// 账本未命中 首查无 订阅 insert 命中 uk_user并发首建 回查转续期分支 CAS 成功
when(subscriptionGrantMapper.selectByBizNo("SUB-6")).thenReturn(null);
LocalDateTime concurrentExpire = LocalDateTime.now().plusDays(10);
SubscriptionDO concurrent = sub(507L, 99L, PLAN_MONTHLY, concurrentExpire, "OTHER");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(null) // 首查无
.thenReturn(concurrent); // 兜底回查命中
when(subscriptionMapper.insert(any(SubscriptionDO.class))).thenThrow(new DuplicateKeyException("uk_user"));
when(subscriptionMapper.renewByCas(eq(507L), eq(concurrentExpire), any(), anyInt(), anyInt(), any(), any(), anyLong(), any()))
.thenReturn(1);
SubscriptionDO result = subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 30, "SUB-6", 8L, null);
assertEquals(concurrentExpire.plusDays(30), result.getExpireTime()); // 转续期叠加
}
// ============================== 校验套餐 / 时长非法不查库不写账本==============================
@Test
void testGrant_invalidPlanRejected() {
// plan=9 非法套餐 拒绝不查账本/不查库
ServiceException ex = assertThrows(ServiceException.class,
() -> subscriptionService.grantSubscription(99L, 9, 30, "SUB-7", 8L, null));
assertEquals(TRADE_SUBSCRIPTION_PLAN_INVALID.getCode(), ex.getCode());
verify(subscriptionGrantMapper, never()).selectByBizNo(anyString());
verify(subscriptionMapper, never()).selectByUserId(anyLong());
}
@Test
void testGrant_invalidDurationRejected() {
// durationDays 0 拒绝0 与负数均拒不查账本/不查库
ServiceException ex0 = assertThrows(ServiceException.class,
() -> subscriptionService.grantSubscription(99L, PLAN_MONTHLY, 0, "SUB-8", 8L, null));
assertEquals(TRADE_SUBSCRIPTION_DURATION_INVALID.getCode(), ex0.getCode());
ServiceException exNeg = assertThrows(ServiceException.class,
() -> subscriptionService.grantSubscription(99L, PLAN_MONTHLY, -1, "SUB-9", 8L, null));
assertEquals(TRADE_SUBSCRIPTION_DURATION_INVALID.getCode(), exNeg.getCode());
verify(subscriptionGrantMapper, never()).selectByBizNo(anyString());
verify(subscriptionMapper, never()).selectByUserId(anyLong());
}
// ============================== C 端查订阅态 ==============================
@Test
void testGetMySubscription_delegatesToMapper() {
// 查订阅态委托 Mapper userIduk_user从未订阅返回 null
SubscriptionDO existing = sub(508L, 99L, PLAN_MONTHLY, LocalDateTime.now().plusDays(5), "X");
when(subscriptionMapper.selectByUserId(99L)).thenReturn(existing);
assertSame(existing, subscriptionService.getMySubscription(99L));
when(subscriptionMapper.selectByUserId(77L)).thenReturn(null);
assertNull(subscriptionService.getMySubscription(77L)); // 从未订阅
}
// ============================== 测试夹具 ==============================
private static SubscriptionDO sub(Long id, Long userId, Integer plan, LocalDateTime expire, String lastBizNo) {
SubscriptionDO s = new SubscriptionDO();
s.setId(id);
s.setUserId(userId);
s.setPlan(plan);
s.setStatus(TradeSubscriptionStatusEnum.ACTIVE.getStatus());
s.setStartTime(LocalDateTime.now().minusDays(1));
s.setExpireTime(expire);
s.setSource("admin_grant");
s.setLastGrantBizNo(lastBizNo);
s.setOperatorUserId(1L);
s.setRemark("");
return s;
}
/** 构造一条幂等账本(命中用) */
private static SubscriptionGrantDO grantLedger(Long id, Long userId, String bizNo, LocalDateTime expireAfter) {
SubscriptionGrantDO g = new SubscriptionGrantDO();
g.setId(id);
g.setUserId(userId);
g.setBizNo(bizNo);
g.setPlan(PLAN_MONTHLY);
g.setDurationDays(30);
g.setExpireTimeAfter(expireAfter);
g.setSource("admin_grant");
g.setOperatorUserId(1L);
g.setRemark("");
return g;
}
}

View File

@ -0,0 +1,37 @@
-- =============================================================================
-- trade 模块 admin 权限点登记 SQLU2 经济admin 赋余额 / 赋订阅 两端点鉴权所需 system_menu 种子)
-- 文件game-cloud/game-module-trade/sql/trade_menu.sql
-- 设计依据U2 R-ECONdocs/plans/2026-06-18-002+ TradeAdminController @PreAuthorize('trade:account:grant' / 'trade:subscription:grant')。
--
-- ⚠️ 为什么不进 Flyway V21与 biz_menu.sql 同理):
-- 1) V21.0.0__create_game_trade_subscription.sql 两副本contracts/db-schemas/ + huijing-server/.../db/migration/必须字节一致md5 全等),
-- 把权限 seed 塞进 V21 会破坏副本字节一致、触发 flyway validate 崩;
-- 2) huijing 菜单 seed 一贯走独立 SQLsql/mysql/ruoyi-vue-pro.sql / game-module-biz/sql/biz_menu.sql 同范式),
-- 权限点不属 Flyway 业务表迁移范畴。故 trade 权限点照「单独菜单初始化 SQL」方式登记与 V21 解耦、不破副本一致性。
--
-- 执行方式mini-desktop staging幂等可重复执行INSERT IGNORE 撞 id 即跳过):
-- mysql --default-character-set=utf8mb4 -h<host> -u<user> -p<pwd> <db> < game-cloud/game-module-trade/sql/trade_menu.sql
-- (含中文,务必 --default-character-set=utf8mb4 防乱码。)
--
-- 放行说明:
-- huijing 超级管理员角色system_role.code='super_admin'type=1对 @ss.hasPermission 全量放行(*:*:*
-- 登记菜单行后超管即可调用赋余额/赋订阅端点;分配给普通运营角色由运营在 game-admin「角色管理」按需勾选不在本 SQL
-- id 取 7110~7112biz_menu.sql 已占 7100~7107huijing base seed system_menu 7110+ 区间未占用,避免碰撞)。
-- 范围:本 SQL 仅登记 U2 新增的赋余额/赋订阅两权限点NO-MERGE 范围trade 其余 withdraw/income/report 权限点不在本次范围。
-- =============================================================================
-- 父菜单经营管理type=2 菜单parent_id=0 顶级;前端路由/组件由前端按需补,本 SQL 仅登记后端鉴权所需节点)
INSERT IGNORE INTO `system_menu`
(`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
`status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
(7110, '经营管理', '', 2, 110, 0, 'trade-ops', 'ep:wallet', 'trade/ops/index', 'TradeOps',
0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- 2 个按钮权限点type=3 按钮parent_id=7110对齐 TradeAdminController U2 端点 @PreAuthorize
INSERT IGNORE INTO `system_menu`
(`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
`status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
(7111, 'admin 赋余额', 'trade:account:grant', 3, 1, 7110, '', '', NULL, NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(7112, 'admin 赋订阅', 'trade:subscription:grant', 3, 2, 7110, '', '', NULL, NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');

View File

@ -0,0 +1,114 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块tradegame-module-tradeU2 经济admin 赋余额 + 会员订阅)| owner6c6g 后端线
-- 文件V21.0.0__create_game_trade_subscription.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移
-- 内容U2 R-ECON 三张新表 ——
-- ① game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细,余额增的来源凭证,可对账)
-- ② game_trade_subscription —— 会员订阅(三档套餐 plan + 到期时间 expire_time + 状态 statusadmin 赋订阅 + C 端查订阅态)
-- ③ game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行 + uk_biz_no挡任意历史 bizNo 重放重复续期)
-- 背景AGENTS §1 三条变现线:广告分成 / 订阅会员 / B 端定制):
-- - 钱包/收益/提现已 realV7/V14本轮补「admin 赋余额」运营给余额real 入账 + 流水)与「订阅域」(实体表 + 赋订阅 + 查态)。
-- - admin 赋余额「不」走 game_trade_incomeincome 是「广告/打赏分账」流水,进平台营收报表 gross/net 聚合;
-- admin 手动赋余额是运营动作,混入 income 会污染营收报表与对账锚点source_ref 对齐 ad 台账),故单独建 game_trade_grant 流水表。
-- - 账户 game_trade_account 余额仍由原子增balance += amount、total_income += amount维持不变式 balance+frozen+total_withdraw=total_income
-- 范围边界:真实支付收单/自助购买订阅(接 pay 下单回调开通)= 日历闸门,本轮不做;订阅由 admin 手动赋(运营/B 端开通)。
-- 约定InnoDB + utf8mb4显式列含 Huijing 审计列 creator/create_time/updater/update_time/deleted + tenant_id
-- 金额一律「分」(BIGINT) 禁浮点;资金写入有幂等键(biz_no + uk);状态机用 tinyint中文列注释关键查询建索引、禁裸 select*。
-- 错误码段trade = 1-106-***-***(赋余额 005 / 订阅 006
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细凭证;余额增的来源,可对账)
-- 写入点admin 赋余额AccountService.grant→ 写一条 grant 流水 + 账户 balance/total_income 原子增(同事务)。
-- 幂等核心uk_biz_no(biz_no, tenant_id) 防重复赋余额——同一 biz_no 重复提交返回已有记录,不重复入账(不重复发钱)。
-- biz_no 由调用端admin 前端/运营)生成唯一;幂等范式与 game_trade_withdraw.uk_biz_no 一致。
-- 归属user_id = 被赋余额的创作者operator_user_id = 操作的运营/admin审计可溯区别于 Huijing 审计列 creator
-- 金额单位amount 一律「分」(BIGINT) 禁浮点赋余额只增不减amount > 0由 Service 校验)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_grant` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '赋余额流水 ID',
`user_id` BIGINT NOT NULL COMMENT '被赋余额的创作者用户 ID账户归属',
`amount` BIGINT NOT NULL COMMENT '赋余额金额单位BIGINT 禁浮点;只增不减,>0',
`biz_no` VARCHAR(64) NOT NULL COMMENT '赋余额业务单号 = 幂等键(调用端生成唯一;重复提交同一 biz_no 不重复入账)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '赋余额来源标识(固定 admin_grant区别于收益流水 income 的 ad/tip 分账来源)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '赋余额备注(运营填写赋值原因,如「活动奖励」「客诉补偿」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`, `tenant_id`) COMMENT '赋余额幂等键:同一 biz_no 只入账一次(防重复赋余额/重复发钱)',
KEY `idx_user` (`user_id`) COMMENT '按创作者查赋余额记录(运营对账)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'admin 赋余额流水表运营手动充值明细余额增来源uk 防重复入账)';
-- -----------------------------------------------------------------------------
-- 表game_trade_subscription —— 会员订阅(三档套餐 + 到期时间 + 状态admin 赋订阅 + C 端查订阅态)
-- 一个用户一条订阅记录uk_user(user_id, tenant_id) —— 赋订阅/续期 upsert 同一行(叠加 expire_time不为每次赋值建新行。
-- 赋订阅幂等P0 修复):由独立账本表 game_trade_subscription_grant.uk_biz_no 保证——挡「任意历史 bizNo」重放重复续期。
-- 注:本行 last_grant_biz_no 仅记「最近一次」赋订阅 bizNo 供审计追溯,**不再作幂等判据**(行内键只能挡最近一次重放,
-- 挡不住历史 bizNo 重放A 续期→键=AB 续期→键=B重放 A 时键(B)≠A 会误判新请求重复续期)。订阅是「一用户一行」状态聚合,
-- 续期叠加在同一行,但幂等必须靠独立账本(与赋余额 grant、提现 withdraw 同范式:每笔一行 + uk_biz_no
-- 套餐 plan枚举 TradeSubscriptionPlanEnum1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由赋订阅时长决定,不写死天数)。
-- 状态 status枚举 TradeSubscriptionStatusEnum0 生效中 / 1 已过期。
-- MVP 无 cron 物化过期:「是否有效」以 expire_time 与当前时间比较为权威实时判定Service 查询时按 expire_time>now 回算 effectiveStatus
-- status 列落「赋订阅初始态」与后续定时 job 物化预留C 端查态不依赖定时 job 也能正确反映过期。
-- 续期叠加:赋订阅时若已有未过期订阅,新到期 = max(now, 旧 expire_time) + 时长(不丢未用时长);已过期则从 now 起算。
-- 范围边界:真实支付收单/自助购买 = 日历闸门,本轮 admin 手动赋source 标识赋值来源admin_grant
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_subscription` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订阅记录 ID',
`user_id` BIGINT NOT NULL COMMENT '订阅用户 IDDataPermission用户只见自己订阅',
`plan` TINYINT NOT NULL COMMENT '订阅套餐1 月度 / 2 季度 / 3 年度(枚举 TradeSubscriptionPlanEnum',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态0 生效中 / 1 已过期(枚举 TradeSubscriptionStatusEnum权威有效性以 expire_time>now 实时判定)',
`start_time` DATETIME NOT NULL COMMENT '本次订阅生效起始时间(首次赋订阅或续期起算点)',
`expire_time` DATETIME NOT NULL COMMENT '到期时间权威字段expire_time>now 即有效;续期时叠加,不丢未用时长)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '订阅来源标识(固定 admin_grant真实自助购买 later 接 pay 回调)',
`last_grant_biz_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次赋订阅业务单号(仅审计追溯;幂等由 game_trade_subscription_grant.uk_biz_no 保证,本列非幂等键)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/admin 赋订阅RBAC审计可溯',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '订阅备注运营填写开通原因如「B 端定制开通」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user` (`user_id`, `tenant_id`) COMMENT '一个用户一条订阅记录(赋订阅/续期 upsert 同一行,防并发重复建)',
KEY `idx_expire` (`expire_time`) COMMENT '按到期时间扫描(后续过期物化 job 用)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员订阅表(三档套餐+到期时间+状态admin 赋订阅expire_time 权威判有效)';
-- -----------------------------------------------------------------------------
-- 表game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行;挡任意历史 bizNo 重放重复续期)
-- 写入点admin 赋订阅SubscriptionService.grantSubscription→ 续期前先 insert 一行账本 + 订阅 upsert 续期(同事务)。
-- 【为什么需要本表P0 NO-MERGE 修复)】
-- 订阅是「一用户一行」状态聚合game_trade_subscription.uk_user续期叠加 expire_time其行内 last_grant_biz_no
-- 只记「最近一次」赋订阅 bizNo——只能挡最近一次重放挡不住历史 bizNo 重放:
-- bizNo=A 续期 → 行内键=AbizNo=B 续期 → 行内键=B此时重放 bizNo=A行内键(B)≠A → 误判新请求再次续期(重复发权益)。
-- 故照搬赋余额 game_trade_grant 的「独立流水表 + uk_biz_no」范式每笔赋订阅先 insert 本表,撞键即「已处理」幂等返回。
-- 幂等核心uk_biz_no(biz_no, tenant_id) 防重复赋订阅——同一 biz_no 重复提交返回当前订阅态,不重复延长(重试安全)。
-- biz_no 由调用端admin 前端/运营)生成唯一;幂等范式与 game_trade_grant.uk_biz_no / game_trade_withdraw.uk_biz_no 一致。
-- 归属user_id = 被赋订阅的用户operator_user_id = 操作的运营/admin审计可溯区别于 Huijing 审计列 creator
-- 落账快照plan/duration_days 记本次赋订阅入参expire_time_after 记赋值后到期时间(续期成功后回填,供对账与幂等回显)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_subscription_grant` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '赋订阅流水 ID',
`user_id` BIGINT NOT NULL COMMENT '被赋订阅的用户 ID订阅归属',
`biz_no` VARCHAR(64) NOT NULL COMMENT '赋订阅业务单号 = 幂等键(调用端生成唯一;重复提交同一 biz_no 不重复延长权益)',
`plan` TINYINT NOT NULL COMMENT '本次赋订阅套餐1 月度 / 2 季度 / 3 年度(枚举 TradeSubscriptionPlanEnum落账快照',
`duration_days` INT NOT NULL COMMENT '本次赋订阅时长(天,落账快照;续期叠加的时长来源凭证,>0',
`expire_time_after` DATETIME NULL COMMENT '本次赋值后到期时间(续期成功后回填的快照,供对账与幂等命中回显)',
`source` VARCHAR(32) NOT NULL DEFAULT 'admin_grant' COMMENT '赋订阅来源标识(固定 admin_grant真实自助购买 later 接 pay 回调)',
`operator_user_id` BIGINT NULL COMMENT '操作人用户 ID运营/adminRBAC审计可溯区别于审计列 creator',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '赋订阅备注运营填写开通原因如「B 端定制开通」)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Huijing 审计列)',
`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 '租户 IDHuijing 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`, `tenant_id`) COMMENT '赋订阅幂等键:同一 biz_no 只赋一次(防重复延长订阅权益/重复发会员)',
KEY `idx_user` (`user_id`) COMMENT '按用户查赋订阅记录(运营对账)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员订阅赋值幂等账本每笔赋订阅一行uk 挡任意历史 bizNo 重放重复续期)';