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:
parent
9f2b9f9feb
commit
ca53a0db0c
@ -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-update,miss 回查重试有限轮次(禁死循环)。范本 `SubscriptionServiceImpl` |
|
||||
| 「有效期/过期态」依赖定时 job 物化 status 列 | 无 cron(MVP RocketMQ/job 多为远期)时过期态不准 | **有效性以 `expire_time>now` 查询时实时回算**(`effectiveStatus`/`active` 在 Convert 层算),落库 status 仅初始态 + 后续物化预留;不依赖定时 job 也能正确反映过期。范本 `TradeConvert.toSubscriptionVO` |
|
||||
|
||||
@ -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);
|
||||
- 操作人:取自 token(getLoginUserId()),落 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 手动赋)。
|
||||
- 套餐 plan:1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由 durationDays 决定;非法 1-106-006-000);
|
||||
- 续期叠加:已有未过期订阅则新到期 = max(now, 旧 expire) + durationDays 天(不丢未用时长);已过期/无记录从 now 起算;
|
||||
- 幂等:以 bizNo 内联在订阅行 last_grant_biz_no——同一 bizNo 重复提交不重复延长时长(重试安全);
|
||||
- 一用户一行(game_trade_subscription.uk_user),upsert 同一行(CAS 防并发续期 lost-update);
|
||||
- 操作人取自 token,落 operator_user_id(审计可溯);durationDays 必须 > 0(1-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_subscription,DataPermission 限本人 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
|
||||
|
||||
114
contracts/db-schemas/V21.0.0__create_game_trade_subscription.sql
Normal file
114
contracts/db-schemas/V21.0.0__create_game_trade_subscription.sql
Normal file
@ -0,0 +1,114 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:trade(game-module-trade,U2 经济:admin 赋余额 + 会员订阅)| owner:6c6g 后端线
|
||||
-- 文件:V21.0.0__create_game_trade_subscription.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移)
|
||||
-- 内容:U2 R-ECON 三张新表 ——
|
||||
-- ① game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细,余额增的来源凭证,可对账)
|
||||
-- ② game_trade_subscription —— 会员订阅(三档套餐 plan + 到期时间 expire_time + 状态 status,admin 赋订阅 + C 端查订阅态)
|
||||
-- ③ game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行 + uk_biz_no,挡任意历史 bizNo 重放重复续期)
|
||||
-- 背景(AGENTS §1 三条变现线:广告分成 / 订阅会员 / B 端定制):
|
||||
-- - 钱包/收益/提现已 real(V7/V14);本轮补「admin 赋余额」(运营给余额,real 入账 + 流水)与「订阅域」(实体表 + 赋订阅 + 查态)。
|
||||
-- - admin 赋余额「不」走 game_trade_income:income 是「广告/打赏分账」流水,进平台营收报表 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(运营/admin,RBAC;审计可溯,区别于审计列 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 '租户 ID(Huijing 多租户兼容,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 续期→键=A,B 续期→键=B,重放 A 时键(B)≠A 会误判新请求重复续期)。订阅是「一用户一行」状态聚合,
|
||||
-- 续期叠加在同一行,但幂等必须靠独立账本(与赋余额 grant、提现 withdraw 同范式:每笔一行 + uk_biz_no)。
|
||||
-- 套餐 plan(枚举 TradeSubscriptionPlanEnum):1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由赋订阅时长决定,不写死天数)。
|
||||
-- 状态 status(枚举 TradeSubscriptionStatusEnum):0 生效中 / 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 '订阅用户 ID(DataPermission:用户只见自己订阅)',
|
||||
`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 '租户 ID(Huijing 多租户兼容,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 续期 → 行内键=A;bizNo=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(运营/admin,RBAC;审计可溯,区别于审计列 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 '租户 ID(Huijing 多租户兼容,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 重放重复续期)';
|
||||
@ -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 赋余额(U2,AccountService.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 '创作者用户 ID(DataPermission:创作者只见自己账户)',
|
||||
`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.amount;U2 起含赋余额,维持 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 '创建时间(账户初始化时间)',
|
||||
|
||||
@ -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, "订阅赋值提交冲突,请重试");
|
||||
|
||||
}
|
||||
|
||||
@ -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.plan,tinyint)
|
||||
*
|
||||
* 三档变现订阅(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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.wanxiang.huijing.game.module.trade.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 会员订阅状态枚举(对应表 game_trade_subscription.status,tinyint)
|
||||
*
|
||||
* 状态机:0 生效中 → 1 已过期。
|
||||
* - 生效中(ACTIVE,0):赋订阅/续期后,且 expire_time > now;
|
||||
* - 已过期(EXPIRED,1):expire_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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/** 账户 Service(U2:admin 赋余额 credit/grant) */
|
||||
@Resource
|
||||
private AccountService accountService;
|
||||
|
||||
/** 订阅 Service(U2:admin 赋订阅) */
|
||||
@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 = "运营手动给创作者账户赋余额(活动奖励/客诉补偿);幂等键=bizNo,real 入账+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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 VO(admin 端,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;
|
||||
|
||||
}
|
||||
@ -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 VO(admin 端,U2 经济,对齐 trade.yaml GrantSubscriptionReqVO)
|
||||
*
|
||||
* 运营/B 端手动给用户开通会员订阅(MVP 不接真实支付收单,归 pay 日历闸门)。bizNo 为幂等键(同 bizNo 不重复延长,重试安全);
|
||||
* plan=1月度/2季度/3年度(枚举校验在 Service);durationDays>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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/** 订阅 Service(U2:C 端查订阅态) */
|
||||
@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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 VO(app 端,C 端查订阅态,对齐 trade.yaml SubscriptionRespVO)
|
||||
*
|
||||
* 有效性以 expire_time>now 实时判定(见 TradeConvert.toSubscriptionVO):
|
||||
* - active:是否当前有效(expire_time>now)——C 端据此判会员权益是否开启;
|
||||
* - effectiveStatus:0 生效中 / 1 已过期(按 expire_time 实时回算,非依赖落库 status 列,不依赖定时 job)。
|
||||
* 从未订阅时由 Controller 返回「subscribed=false 的空态 VO」(active=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;
|
||||
|
||||
}
|
||||
@ -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=true,effectiveStatus=生效中),
|
||||
* 不依赖落库 status 列(status 仅赋值初始态/物化预留),故无 cron 物化过期也能正确反映过期态。
|
||||
* 入参为 null(从未订阅)时返回「subscribed=false 的空态 VO」(active=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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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(运营/admin,RBAC;审计可溯,区别于审计列 creator)
|
||||
*/
|
||||
private Long operatorUserId;
|
||||
/**
|
||||
* 赋余额备注(运营填写赋值原因,如「活动奖励」「客诉补偿」)
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
/**
|
||||
* 订阅用户 ID(DataPermission:用户只见自己订阅)
|
||||
*/
|
||||
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;
|
||||
|
||||
}
|
||||
@ -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 续期 → 行内键=A;bizNo=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(运营/admin,RBAC;审计可溯,区别于审计列 creator)
|
||||
*/
|
||||
private Long operatorUserId;
|
||||
/**
|
||||
* 赋订阅备注(运营填写开通原因,如「B 端定制开通」)
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
@ -53,6 +53,29 @@ public interface AccountMapper extends BaseMapperX<AccountDO> {
|
||||
return update(null, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* admin 赋余额(原子):balance += amount、total_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、累计收益 += amount(admin 赋余额计入累计收益,维持账面不变式)
|
||||
.setSql(" balance = balance + " + amount
|
||||
+ ", total_income = total_income + " + amount)
|
||||
.eq(AccountDO::getUserId, userId);
|
||||
return update(null, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现申请冻结(原子,带余额充足校验防超扣):balance -= amount、frozen += amount,仅当 balance >= amount
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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(一个用户一条订阅)。
|
||||
* 续期用乐观 CAS:UPDATE ... 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-update:WHERE 带 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 += amount、total_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 -= amount、frozen += amount
|
||||
*
|
||||
|
||||
@ -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 赋余额流水 Mapper(U2 经济:赋余额记一笔可对账 + 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 += amount、total_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 行 = 余额不足(防超扣)
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.wanxiang.huijing.game.module.trade.service.subscription;
|
||||
|
||||
import com.wanxiang.huijing.game.module.trade.dal.dataobject.SubscriptionDO;
|
||||
|
||||
/**
|
||||
* 会员订阅 Service(U2 经济: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);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/** 赋订阅幂等账本 Mapper(P0:每笔赋订阅记一行 + 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 赋余额(U2,AccountService.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 '创作者用户 ID(DataPermission:创作者只见自己账户)',
|
||||
`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.amount;U2 起含赋余额,维持 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 '创建时间(账户初始化时间)',
|
||||
|
||||
@ -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>now):active=true、effectiveStatus=生效中(0);
|
||||
* - 已过期(expire≤now):active=false、effectiveStatus=已过期(1),即便落库 status 仍为 0;
|
||||
* - 从未订阅(null):空态 subscribed=false、active=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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
// ============================== U2:admin 赋余额(流水 + 余额增 + 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 += amount、total_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) {
|
||||
|
||||
@ -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:赋订阅幂等账本 Mapper(uk_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——
|
||||
// 旧实现(行内键)会误判 A≠B 再次续期;新实现账本表 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 按 userId(uk_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;
|
||||
}
|
||||
|
||||
}
|
||||
37
game-cloud/game-module-trade/sql/trade_menu.sql
Normal file
37
game-cloud/game-module-trade/sql/trade_menu.sql
Normal file
@ -0,0 +1,37 @@
|
||||
-- =============================================================================
|
||||
-- trade 模块 admin 权限点登记 SQL(U2 经济:admin 赋余额 / 赋订阅 两端点鉴权所需 system_menu 种子)
|
||||
-- 文件:game-cloud/game-module-trade/sql/trade_menu.sql
|
||||
-- 设计依据:U2 R-ECON(docs/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 一贯走独立 SQL(sql/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~7112(biz_menu.sql 已占 7100~7107,huijing 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');
|
||||
@ -0,0 +1,114 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:trade(game-module-trade,U2 经济:admin 赋余额 + 会员订阅)| owner:6c6g 后端线
|
||||
-- 文件:V21.0.0__create_game_trade_subscription.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移)
|
||||
-- 内容:U2 R-ECON 三张新表 ——
|
||||
-- ① game_trade_grant —— admin 赋余额流水(运营手动给创作者账户充值的明细,余额增的来源凭证,可对账)
|
||||
-- ② game_trade_subscription —— 会员订阅(三档套餐 plan + 到期时间 expire_time + 状态 status,admin 赋订阅 + C 端查订阅态)
|
||||
-- ③ game_trade_subscription_grant —— 会员订阅赋值幂等账本(每笔赋订阅记一行 + uk_biz_no,挡任意历史 bizNo 重放重复续期)
|
||||
-- 背景(AGENTS §1 三条变现线:广告分成 / 订阅会员 / B 端定制):
|
||||
-- - 钱包/收益/提现已 real(V7/V14);本轮补「admin 赋余额」(运营给余额,real 入账 + 流水)与「订阅域」(实体表 + 赋订阅 + 查态)。
|
||||
-- - admin 赋余额「不」走 game_trade_income:income 是「广告/打赏分账」流水,进平台营收报表 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(运营/admin,RBAC;审计可溯,区别于审计列 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 '租户 ID(Huijing 多租户兼容,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 续期→键=A,B 续期→键=B,重放 A 时键(B)≠A 会误判新请求重复续期)。订阅是「一用户一行」状态聚合,
|
||||
-- 续期叠加在同一行,但幂等必须靠独立账本(与赋余额 grant、提现 withdraw 同范式:每笔一行 + uk_biz_no)。
|
||||
-- 套餐 plan(枚举 TradeSubscriptionPlanEnum):1 月度 / 2 季度 / 3 年度(仅标识权益档位,到期时间由赋订阅时长决定,不写死天数)。
|
||||
-- 状态 status(枚举 TradeSubscriptionStatusEnum):0 生效中 / 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 '订阅用户 ID(DataPermission:用户只见自己订阅)',
|
||||
`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 '租户 ID(Huijing 多租户兼容,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 续期 → 行内键=A;bizNo=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(运营/admin,RBAC;审计可溯,区别于审计列 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 '租户 ID(Huijing 多租户兼容,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 重放重复续期)';
|
||||
Loading…
x
Reference in New Issue
Block a user