feat(compliance): U4 R-ACC 政策正文页+申诉回路
codex 评审 MERGE-WITH-FIXES→修 P0(申诉重复提交守卫:STORED生成列pending_flag+uk_user_ban_pending局部唯一+@Transactional+DuplicateKey友好返回)+P1(@Transactional/@Size/@Min)。V22。plan 2026-06-18-002 U4。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ca53a0db0c
commit
ec23ff61bc
@ -8,9 +8,10 @@ openapi: 3.0.3
|
||||
# 本波 ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口(只写台账、不裸调不存在的方法)。
|
||||
# 降权(R5):降权出流屏蔽 P1 本波不生效(不建 FeedDownweightApi 孤儿 seam、不调 feed)。
|
||||
# IP(D5):ip 不独立建模块,仅在 compliance 内留 IpComplianceSeam 扩展点(默认 pass 桩),Wave4 接 ip 只换实现。
|
||||
# 端:/rpc-api(project.publish 同进程 Feign 注入)/ /admin-api(管理端 game-admin,RBAC 封禁)/ /app-api(产品端只读分级);前缀由 huijing 框架按 controller 包名/RpcConstants 自动添加
|
||||
# U4 R-ACC(2026-06-18 扩):政策正文页(富文本 + 版本,app 只读最新 / admin 发布多版本)+ 申诉回路(app 本人提交/查询 + admin 处理状态机,可选关联封禁台账)。归属 compliance(已有 rating/ban/gate 合规语义),薄 CRUD。
|
||||
# 端:/rpc-api(project.publish 同进程 Feign 注入)/ /admin-api(管理端 game-admin,RBAC 封禁+政策发布+申诉处理)/ /app-api(产品端只读分级+政策最新+本人申诉);前缀由 huijing 框架按 controller 包名/RpcConstants 自动添加
|
||||
# 错误码段:compliance = 1-109-***-***(本模块独占,禁止与他模块重叠:100 project/101 aigc/102 runtime/103 feed/104 telemetry/106 trade/111 ad/112 studio)
|
||||
# publish-blocked 不在本模块登记(R1:block→admitted=false 不抛异常)。
|
||||
# 子段:001 锁风门 / 002 封禁 / 003 分级 / 004 政策正文(U4)/ 005 申诉(U4)。publish-blocked 不在本模块登记(R1:block→admitted=false 不抛异常)。
|
||||
# 响应统一 Huijing CommonResult 信封:{ code, data, msg };code=0 成功
|
||||
info:
|
||||
title: 造梦AI compliance 模块 API
|
||||
@ -121,6 +122,121 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultContentRating' }
|
||||
|
||||
# ===========================================================================
|
||||
# U4 R-ACC 政策正文(app 只读取最新 / admin 发布富文本多版本)
|
||||
# 设计:政策正文 = 薄 CRUD(富文本 + 版本);app 端取某政策类型「最新已发布」一版(无登录依赖、匿名可读,仿 rating)。
|
||||
# ===========================================================================
|
||||
/app-api/compliance/policy/latest:
|
||||
get:
|
||||
tags: [app-compliance]
|
||||
summary: 取某政策正文最新已发布版(只读,匿名可读)
|
||||
description: 按政策类型取「最新已发布(status=1)+最大 version」一版;无记录报 COMPLIANCE_POLICY_NOT_FOUND。
|
||||
parameters:
|
||||
- { name: policyType, in: query, required: true, schema: { type: string }, description: '政策类型:user-agreement/privacy/community-guideline/minor-protection' }
|
||||
responses:
|
||||
'200':
|
||||
description: 政策正文
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPolicyDocument' }
|
||||
/admin-api/compliance/policy/publish:
|
||||
post:
|
||||
tags: [admin-compliance]
|
||||
summary: 发布政策正文新版(admin RBAC)
|
||||
description: 新增一版政策正文(富文本 + version);version 须比同类型现有最大版本大,否则报 COMPLIANCE_POLICY_VERSION_CONFLICT。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PolicyPublishReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 新政策正文记录 ID
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultLong' }
|
||||
/admin-api/compliance/policy/page:
|
||||
get:
|
||||
tags: [admin-compliance]
|
||||
summary: 政策正文历史分页(admin RBAC)
|
||||
parameters:
|
||||
- { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 } }
|
||||
- { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 } }
|
||||
- { name: policyType, in: query, required: false, schema: { type: string }, description: '政策类型过滤' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分页结果
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPagePolicyDocument' }
|
||||
|
||||
# ===========================================================================
|
||||
# U4 R-ACC 申诉(app 本人提交/查询 / admin 处理状态机)
|
||||
# 设计:申诉 = 薄 CRUD + 状态机(提交→admin 处理→结果);可选关联 game_user_ban 台账(banId)。
|
||||
# 归属隔离:本人 userId 取自 token(getLoginUserId()),Mapper 强制 WHERE 谓词(可信边界,非前端入参)。
|
||||
# 状态机 status:0 待处理 → 1 已通过 / 2 已驳回(admin 处理;重复提交守卫=同用户+同 banId 存在待处理则拒)。
|
||||
# ===========================================================================
|
||||
/app-api/compliance/appeal/submit:
|
||||
post:
|
||||
tags: [app-compliance]
|
||||
summary: 提交申诉(本人 Token)
|
||||
description: 提交申诉;可选关联 banId。重复提交守卫=同用户同 banId 已有待处理申诉则报 COMPLIANCE_APPEAL_DUPLICATE。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppealSubmitReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 新申诉记录 ID
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultLong' }
|
||||
/app-api/compliance/appeal/my-page:
|
||||
get:
|
||||
tags: [app-compliance]
|
||||
summary: 我的申诉分页(本人 Token,归属隔离)
|
||||
parameters:
|
||||
- { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 } }
|
||||
- { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 } }
|
||||
- { name: status, in: query, required: false, schema: { type: integer }, description: '状态过滤:0 待处理/1 已通过/2 已驳回' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分页结果
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPageAppeal' }
|
||||
/admin-api/compliance/appeal/handle:
|
||||
post:
|
||||
tags: [admin-compliance]
|
||||
summary: 处理申诉(admin RBAC,状态机流转)
|
||||
description: 待处理(0)→已通过(1)/已驳回(2);记录不存在或非待处理报 COMPLIANCE_APPEAL_CANNOT_HANDLE。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppealHandleReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 处理成功
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultBoolean' }
|
||||
/admin-api/compliance/appeal/page:
|
||||
get:
|
||||
tags: [admin-compliance]
|
||||
summary: 申诉台账分页(admin RBAC)
|
||||
parameters:
|
||||
- { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 } }
|
||||
- { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 } }
|
||||
- { name: status, in: query, required: false, schema: { type: integer }, description: '状态过滤:0 待处理/1 已通过/2 已驳回' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分页结果
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPageAppeal' }
|
||||
|
||||
components:
|
||||
schemas:
|
||||
# ===== 锁风门 Gate(RPC seam,R1 唯一形态)=====
|
||||
@ -181,6 +297,53 @@ components:
|
||||
ratedBy: { type: string, description: 'gate 锁风门自动 / admin 人工复核' }
|
||||
updateTime: { type: string, format: date-time }
|
||||
|
||||
# ===== U4 政策正文(app 只读最新 / admin 发布多版本)=====
|
||||
PolicyPublishReqVO:
|
||||
type: object
|
||||
required: [policyType, version, title, content]
|
||||
properties:
|
||||
policyType: { type: string, description: '政策类型:user-agreement/privacy/community-guideline/minor-protection' }
|
||||
version: { type: integer, description: '版本号(须比同类型现有最大版本大)' }
|
||||
title: { type: string, description: '政策标题' }
|
||||
content: { type: string, description: '政策正文(富文本 HTML,可大文本)' }
|
||||
PolicyDocumentRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
policyType: { type: string, description: '政策类型' }
|
||||
version: { type: integer, description: '版本号' }
|
||||
title: { type: string }
|
||||
content: { type: string, description: '政策正文(富文本)' }
|
||||
status: { type: integer, description: '状态:1 已发布/0 草稿(本波只发布态)' }
|
||||
updateTime: { type: string, format: date-time }
|
||||
|
||||
# ===== U4 申诉(app 本人提交/查询 / admin 处理状态机)=====
|
||||
AppealSubmitReqVO:
|
||||
type: object
|
||||
required: [appealType, reason]
|
||||
properties:
|
||||
appealType: { type: integer, description: '申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它' }
|
||||
banId: { type: integer, format: int64, description: '可选关联封禁台账 ID(封禁申诉时填)' }
|
||||
reason: { type: string, description: '申诉理由' }
|
||||
AppealHandleReqVO:
|
||||
type: object
|
||||
required: [appealId, decision]
|
||||
properties:
|
||||
appealId: { type: integer, format: int64, description: '申诉记录 ID' }
|
||||
decision: { type: integer, description: '处理决定:1 通过 / 2 驳回' }
|
||||
handleResult: { type: string, description: '处理结果说明(可空)' }
|
||||
AppealRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
appealType: { type: integer, description: '1 封禁申诉/2 审核申诉/3 其它' }
|
||||
banId: { type: integer, format: int64, description: '关联封禁台账 ID(可空)' }
|
||||
reason: { type: string }
|
||||
status: { type: integer, description: '0 待处理/1 已通过/2 已驳回' }
|
||||
handleResult: { type: string, description: '处理结果说明' }
|
||||
createTime: { type: string, format: date-time }
|
||||
updateTime: { type: string, format: date-time }
|
||||
|
||||
# ===== CommonResult 信封 =====
|
||||
CommonResultGateVerdict:
|
||||
type: object
|
||||
@ -216,3 +379,29 @@ components:
|
||||
code: { type: integer }
|
||||
data: { type: boolean }
|
||||
msg: { type: string }
|
||||
CommonResultPolicyDocument:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { $ref: '#/components/schemas/PolicyDocumentRespVO' }
|
||||
msg: { type: string }
|
||||
CommonResultPagePolicyDocument:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/PolicyDocumentRespVO' } }
|
||||
total: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
CommonResultPageAppeal:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/AppealRespVO' } }
|
||||
total: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 主题:U4 R-ACC 账号(政策正文页 / 申诉)| owner:Tier-1 广度后端 compliance 子单元(2026-06-18-002 计划 U4)
|
||||
-- 文件:V22.0.0__create_compliance_policy_appeal.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V22.0.1 DROP TABLE)
|
||||
-- 归属:game-module-compliance(已有 rating/ban/gate 合规语义,本波补「政策正文 + 申诉」两域,薄 CRUD)。
|
||||
-- 内容:compliance 模块两张新表 ——
|
||||
-- 1. game_policy_document —— 政策正文(富文本 + 版本;app 端只读取「最新已发布」一版,admin 发布多版本);
|
||||
-- 2. game_compliance_appeal —— 申诉台账(本人提交/查询 + admin 处理状态机;可选关联 game_user_ban 台账 ban_id)。
|
||||
-- 守门①(必守,沿用 V10-V20 守门):本 V22 只放仓根 contracts/db-schemas/(授权源)
|
||||
-- + huijing-server 执行目录(唯一执行副本),不放任何单模块 -server/db/migration/(避免同版本 V22 出现在多个 classpath jar 触发 Flyway 重复校验失败)。
|
||||
-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4(防乱码)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列 + 列级中文 COMMENT;状态机用 TINYINT,非法流转由 Service 校验、DO 层不承载;
|
||||
-- 每表强制 Yudao 6 审计列(creator / create_time / updater / update_time / deleted BIT(1) / tenant_id BIGINT,全 NOT NULL,MVP 单租户 tenant_id=0);
|
||||
-- 申诉归属隔离列 user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce(可信边界,非 @DataPermission);禁裸 select*。
|
||||
-- 错误码段:compliance = 1-109-***-***(子段:004 政策正文 / 005 申诉;落 -api 的 ErrorCodeConstants.java)。
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表1:game_policy_document —— 政策正文(富文本 + 版本)
|
||||
-- 用途:/app-api/compliance/policy/latest 读某政策类型「最新已发布(status=1)+ 最大 version」一版;admin /publish 发布新版、/page 查历史。
|
||||
-- 写口径:每发布一版各落一条(不去重,多版本并存);app 取最新 = WHERE policy_type=? AND status=1 ORDER BY version DESC LIMIT 1。
|
||||
-- 唯一键:(policy_type,version) 业务唯一(含 deleted/tenant_id 适配逻辑删 + 多租户),同类型同版本不可重复发布。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_policy_document` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '政策正文记录 ID',
|
||||
`policy_type` VARCHAR(32) NOT NULL COMMENT '政策类型:user-agreement 用户协议/privacy 隐私政策/community-guideline 社区规范/minor-protection 未成年人保护',
|
||||
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同类型递增,app 取最大已发布版)',
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '政策标题',
|
||||
`content` MEDIUMTEXT NULL COMMENT '政策正文(富文本 HTML,可大文本)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1 已发布 / 0 草稿(本波只发布态,预留草稿位)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;发布操作的 admin userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(发布时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_type_version` (`policy_type`, `version`, `deleted`, `tenant_id`) COMMENT '政策类型+版本业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)',
|
||||
KEY `idx_type_status` (`policy_type`, `status`) COMMENT 'app 按类型取最新已发布版(status=1 ORDER BY version DESC)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '政策正文表(富文本+版本;app 只读最新,admin 发布多版本)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表2:game_compliance_appeal —— 申诉台账(提交→admin 处理状态机)
|
||||
-- 用途:/app-api/compliance/appeal/{submit,my-page} 本人提交/查询;/admin-api/compliance/appeal/{handle,page} admin 处理/台账。
|
||||
-- 状态机 status:0 待处理 → 1 已通过 / 2 已驳回(admin 处理;合法流转由 Service 校验,DO 层不承载)。
|
||||
-- 重复提交守卫(双层,防并发双提):
|
||||
-- ① DB 层硬约束 = 唯一键 uk_user_ban_pending(user_id, ban_id, pending_flag),其中 pending_flag 为「仅待处理时非空」的
|
||||
-- STORED 生成列(status=0 → 0;否则 NULL)。MySQL/InnoDB 不支持 PostgreSQL 式「WHERE status=0 局部唯一索引」,
|
||||
-- 故借「NULL 在唯一索引中互不冲突」语义:待处理行 flag=0 → 同 (user_id,ban_id) 至多一行;已处理行 flag=NULL → 不参与去重,
|
||||
-- 允许同一封禁在前次申诉处理完后再次申诉(不误锁合法复诉)。此为原子 enforce,并发双提必有一方撞唯一键。
|
||||
-- ② 应用层 = Service.submit() 先查守卫(友好早返 COMPLIANCE_APPEAL_DUPLICATE)+ 撞唯一键兜底捕获 DuplicateKeyException 转同一业务码
|
||||
-- (并发下「先查后插」窗口由 ① 兜底,捕获后回友好提示,非 500)。
|
||||
-- ③ 生成列 pending_flag 仅 DB 侧维护,AppealDO 不映射该列(MyBatis-Plus 按显式字段 INSERT/UPDATE,不会写入生成列)。
|
||||
-- 关联:ban_id 可选关联 game_user_ban 台账(封禁申诉时填;0=无关联),不建外键(跨表软关联,由 Service 校验存在性)。
|
||||
-- 归属隔离:user_id 取自 token(getLoginUserId()),Mapper 强制 WHERE 谓词;admin 端不按 user_id 隔离(RBAC 全量)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_compliance_appeal` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '申诉记录 ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '申诉人用户 ID(归属隔离锚点,取自 token 非前端入参)',
|
||||
`appeal_type` TINYINT NOT NULL DEFAULT 3 COMMENT '申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它',
|
||||
`ban_id` BIGINT NOT NULL DEFAULT 0 COMMENT '可选关联封禁台账 ID(game_user_ban.id;0=无关联,封禁申诉时填)',
|
||||
`reason` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '申诉理由',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态机:0 待处理 / 1 已通过 / 2 已驳回(合法流转由 Service 校验)',
|
||||
`pending_flag` TINYINT GENERATED ALWAYS AS (CASE WHEN `status` = 0 THEN 0 ELSE NULL END) STORED
|
||||
COMMENT '待处理哨兵列(status=0→0;否则 NULL):供唯一键借 NULL 互不冲突语义实现「每(user,ban)至多一条待处理」的原子去重,DO 不映射',
|
||||
`handle_result` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '处理结果说明(admin 处理时填)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;申诉提交的用户 userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(提交时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(处理操作的 admin userId)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_ban_pending` (`user_id`, `ban_id`, `pending_flag`) COMMENT '重复提交硬约束:借 pending_flag(待处理=0/已处理=NULL)在唯一索引中 NULL 互不冲突,强制同(user,ban)至多一条待处理申诉(原子防并发双提;已处理不参与去重)',
|
||||
KEY `idx_user_ban_status` (`user_id`, `ban_id`, `status`) COMMENT '我的申诉按用户分页 + 应用层重复提交前置查(命中此索引)',
|
||||
KEY `idx_status` (`status`) COMMENT 'admin 按状态分页查申诉台账'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '申诉台账表(提交→admin 处理状态机;可选关联封禁台账)';
|
||||
@ -0,0 +1,58 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 申诉状态机枚举(对应表 game_compliance_appeal.status,tinyint)
|
||||
*
|
||||
* 合法流转:0 待处理 → 1 已通过 / 2 已驳回(admin 处理;终态不可再流转)。
|
||||
* 合法性由 Service 校验,DO 层不承载。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AppealStatusEnum {
|
||||
|
||||
/** 待处理(提交后初始态) */
|
||||
PENDING(0, "待处理"),
|
||||
/** 已通过(admin 处理:通过) */
|
||||
APPROVED(1, "已通过"),
|
||||
/** 已驳回(admin 处理:驳回) */
|
||||
REJECTED(2, "已驳回");
|
||||
|
||||
/** 状态值(落库 tinyint) */
|
||||
private final Integer status;
|
||||
/** 状态名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 是否待处理(仅待处理申诉可被 admin 处理)
|
||||
*
|
||||
* @param status 待判断状态值
|
||||
* @return true=待处理
|
||||
*/
|
||||
public static boolean isPending(Integer status) {
|
||||
return Objects.equals(PENDING.status, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 admin 处理决定(1 通过 / 2 驳回)映射到终态状态值
|
||||
*
|
||||
* @param decision 处理决定:1 通过 / 2 驳回
|
||||
* @return 对应终态状态值;非法决定返回 null(由 Service 拦截报错)
|
||||
*/
|
||||
public static Integer fromDecision(Integer decision) {
|
||||
if (Objects.equals(APPROVED.status, decision)) {
|
||||
return APPROVED.status;
|
||||
}
|
||||
if (Objects.equals(REJECTED.status, decision)) {
|
||||
return REJECTED.status;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 申诉类型枚举(对应表 game_compliance_appeal.appeal_type,tinyint)
|
||||
*
|
||||
* 1 封禁申诉(可关联 game_user_ban 台账 banId)/ 2 审核申诉 / 3 其它。
|
||||
* 合法性由 Service 校验,DO 层不承载。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AppealTypeEnum {
|
||||
|
||||
/** 封禁申诉(可关联封禁台账 banId) */
|
||||
BAN(1, "封禁申诉"),
|
||||
/** 审核申诉 */
|
||||
REVIEW(2, "审核申诉"),
|
||||
/** 其它 */
|
||||
OTHER(3, "其它");
|
||||
|
||||
/** 类型值(落库 tinyint) */
|
||||
private final Integer type;
|
||||
/** 类型名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 是否为封禁申诉(封禁申诉才允许关联 banId)
|
||||
*
|
||||
* @param type 待判断类型值
|
||||
* @return true=封禁申诉
|
||||
*/
|
||||
public static boolean isBan(Integer type) {
|
||||
return Objects.equals(BAN.type, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验申诉类型是否合法(仅允许 1/2/3)
|
||||
*
|
||||
* @param type 待判断类型值
|
||||
* @return true=合法
|
||||
*/
|
||||
public static boolean isValid(Integer type) {
|
||||
return Objects.equals(BAN.type, type)
|
||||
|| Objects.equals(REVIEW.type, type)
|
||||
|| Objects.equals(OTHER.type, type);
|
||||
}
|
||||
|
||||
}
|
||||
@ -31,4 +31,24 @@ public interface ErrorCodeConstants {
|
||||
/** 分级:无分级记录(按 game_id+version_id 查不到) */
|
||||
ErrorCode COMPLIANCE_RATING_NOT_FOUND = new ErrorCode(1_109_003_000, "分级:无分级记录");
|
||||
|
||||
// ========== 政策正文 1-109-004-***(U4 R-ACC)==========
|
||||
/** 政策:政策类型非法(不在 PolicyTypeEnum 白名单) */
|
||||
ErrorCode COMPLIANCE_POLICY_TYPE_INVALID = new ErrorCode(1_109_004_000, "政策:政策类型非法");
|
||||
/** 政策:无政策记录(按类型查不到「最新已发布」一版) */
|
||||
ErrorCode COMPLIANCE_POLICY_NOT_FOUND = new ErrorCode(1_109_004_001, "政策:政策正文不存在");
|
||||
/** 政策:版本冲突(新版本号未大于同类型现有最大版本) */
|
||||
ErrorCode COMPLIANCE_POLICY_VERSION_CONFLICT = new ErrorCode(1_109_004_002, "政策:版本号须大于现有最大版本");
|
||||
|
||||
// ========== 申诉 1-109-005-***(U4 R-ACC)==========
|
||||
/** 申诉:申诉类型非法(不在 AppealTypeEnum 白名单) */
|
||||
ErrorCode COMPLIANCE_APPEAL_TYPE_INVALID = new ErrorCode(1_109_005_000, "申诉:申诉类型非法");
|
||||
/** 申诉:重复提交(同用户同 banId 已有待处理申诉) */
|
||||
ErrorCode COMPLIANCE_APPEAL_DUPLICATE = new ErrorCode(1_109_005_001, "申诉:已有待处理申诉,请勿重复提交");
|
||||
/** 申诉:记录不存在(按 appealId 查不到) */
|
||||
ErrorCode COMPLIANCE_APPEAL_NOT_EXISTS = new ErrorCode(1_109_005_002, "申诉:申诉记录不存在");
|
||||
/** 申诉:不可处理(非待处理态,重复处理) */
|
||||
ErrorCode COMPLIANCE_APPEAL_CANNOT_HANDLE = new ErrorCode(1_109_005_003, "申诉:仅待处理申诉可处理");
|
||||
/** 申诉:处理决定非法(仅 1 通过 / 2 驳回) */
|
||||
ErrorCode COMPLIANCE_APPEAL_DECISION_INVALID = new ErrorCode(1_109_005_004, "申诉:处理决定非法");
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 政策类型枚举(对应表 game_policy_document.policy_type,varchar)
|
||||
*
|
||||
* 白名单:仅允许下列类型发布/读取(U4 R-ACC 政策正文页)。
|
||||
* 用字符串编码(非 tinyint)便于前端按类型路由 + 契约可读;合法性由 Service 据本白名单校验。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum PolicyTypeEnum {
|
||||
|
||||
/** 用户协议 */
|
||||
USER_AGREEMENT("user-agreement", "用户协议"),
|
||||
/** 隐私政策 */
|
||||
PRIVACY("privacy", "隐私政策"),
|
||||
/** 社区规范 */
|
||||
COMMUNITY_GUIDELINE("community-guideline", "社区规范"),
|
||||
/** 未成年人保护政策 */
|
||||
MINOR_PROTECTION("minor-protection", "未成年人保护政策");
|
||||
|
||||
/** 类型编码(落库 varchar) */
|
||||
private final String type;
|
||||
/** 类型名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 校验政策类型是否在白名单内
|
||||
*
|
||||
* @param type 待判断类型编码
|
||||
* @return true=合法
|
||||
*/
|
||||
public static boolean isValid(String type) {
|
||||
if (type == null) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(values()).anyMatch(e -> e.type.equals(type));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealHandleReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.convert.ComplianceConvert;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.service.appeal.AppealService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 管理后台(game-admin)- 申诉控制器(U4 R-ACC)
|
||||
*
|
||||
* 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。
|
||||
* 鉴权:RBAC,@PreAuthorize 注解 + 网关双重校验。
|
||||
* 申诉处理 = 状态机流转(待处理→通过/驳回,仅待处理可处理)+ 台账分页(RBAC 全量,不按 userId 隔离)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Tag(name = "管理后台 - 申诉")
|
||||
@RestController
|
||||
@RequestMapping("/compliance/appeal")
|
||||
@Validated
|
||||
public class ComplianceAppealController {
|
||||
|
||||
@Resource
|
||||
private AppealService appealService;
|
||||
|
||||
@PostMapping("/handle")
|
||||
@Operation(summary = "处理申诉", description = "状态机:待处理(0)→通过(1)/驳回(2);非待处理报不可处理")
|
||||
@PreAuthorize("@ss.hasPermission('compliance:appeal:handle')")
|
||||
public CommonResult<Boolean> handle(@Valid @RequestBody AppealHandleReqVO reqVO) {
|
||||
// operatorUserId = 当前登录的 admin 用户(落 updater 审计 + 操作溯源)
|
||||
Long operatorUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
appealService.handle(reqVO, operatorUserId);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "申诉台账分页", description = "按状态筛选,RBAC 全量")
|
||||
@PreAuthorize("@ss.hasPermission('compliance:appeal:query')")
|
||||
public CommonResult<PageResult<AppealRespVO>> getAppealPage(@Valid AppealPageReqVO reqVO) {
|
||||
PageResult<AppealDO> pageResult = appealService.getAppealPage(reqVO);
|
||||
return success(ComplianceConvert.toAppealRespPage(pageResult));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.PolicyDocumentRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.convert.ComplianceConvert;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.service.policy.PolicyDocumentService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 管理后台(game-admin)- 政策正文控制器(U4 R-ACC)
|
||||
*
|
||||
* 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。
|
||||
* 鉴权:RBAC,@PreAuthorize 注解 + 网关双重校验。
|
||||
* 政策正文 = 薄 CRUD(富文本 + 版本):发布新版(版本号须递增)+ 查历史各版。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Tag(name = "管理后台 - 政策正文")
|
||||
@RestController
|
||||
@RequestMapping("/compliance/policy")
|
||||
@Validated
|
||||
public class CompliancePolicyController {
|
||||
|
||||
@Resource
|
||||
private PolicyDocumentService policyDocumentService;
|
||||
|
||||
@PostMapping("/publish")
|
||||
@Operation(summary = "发布政策正文新版", description = "新增一版富文本政策;version 须比同类型现有最大版本大")
|
||||
@PreAuthorize("@ss.hasPermission('compliance:policy:publish')")
|
||||
public CommonResult<Long> publish(@Valid @RequestBody PolicyPublishReqVO reqVO) {
|
||||
// operatorUserId = 当前登录的 admin 用户(落 creator 审计 + 操作溯源)
|
||||
Long operatorUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return success(policyDocumentService.publish(reqVO, operatorUserId));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "政策正文历史分页", description = "按政策类型筛选,查各历史版本")
|
||||
@PreAuthorize("@ss.hasPermission('compliance:policy:query')")
|
||||
public CommonResult<PageResult<PolicyDocumentRespVO>> getPolicyPage(@Valid PolicyPageReqVO reqVO) {
|
||||
PageResult<PolicyDocumentDO> pageResult = policyDocumentService.getPolicyPage(reqVO);
|
||||
return success(ComplianceConvert.toPolicyRespPage(pageResult));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 申诉处理入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/appeal/handle)
|
||||
*
|
||||
* 状态机流转:待处理(0)→已通过(1)/已驳回(2);记录不存在或非待处理报 COMPLIANCE_APPEAL_CANNOT_HANDLE。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 申诉处理 Request VO")
|
||||
@Data
|
||||
public class AppealHandleReqVO {
|
||||
|
||||
@Schema(description = "申诉记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "申诉记录 ID 不能为空")
|
||||
private Long appealId;
|
||||
|
||||
@Schema(description = "处理决定:1 通过 / 2 驳回", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "处理决定不能为空")
|
||||
private Integer decision;
|
||||
|
||||
@Schema(description = "处理结果说明(可空)", example = "经复核,封禁解除")
|
||||
private String handleResult;
|
||||
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 申诉台账分页 Request VO(admin,对齐 compliance.yaml /admin-api/compliance/appeal/page)
|
||||
*
|
||||
* 分页复用 Huijing PageParam(pageNo/pageSize);可选按状态筛选(命中 idx_status)。admin 端不按 userId 隔离(RBAC 全量)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 申诉台账分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppealPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "状态过滤:0 待处理 / 1 已通过 / 2 已驳回", example = "0")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 政策正文历史分页 Request VO(admin,对齐 compliance.yaml /admin-api/compliance/policy/page)
|
||||
*
|
||||
* 分页复用 Huijing PageParam(pageNo/pageSize);可选按政策类型筛选(命中 idx_type_status)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 政策正文历史分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PolicyPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "政策类型过滤", example = "user-agreement")
|
||||
private String policyType;
|
||||
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 政策正文发布入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/policy/publish)
|
||||
*
|
||||
* 发布一版政策正文(富文本 + version);version 须比同类型现有最大版本大,否则报 COMPLIANCE_POLICY_VERSION_CONFLICT。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 政策正文发布 Request VO")
|
||||
@Data
|
||||
public class PolicyPublishReqVO {
|
||||
|
||||
@Schema(description = "政策类型:user-agreement/privacy/community-guideline/minor-protection",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "user-agreement")
|
||||
@NotBlank(message = "政策类型不能为空")
|
||||
private String policyType;
|
||||
|
||||
@Schema(description = "版本号(最小 1,须比同类型现有最大版本大)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
|
||||
@NotNull(message = "版本号不能为空")
|
||||
@Min(value = 1, message = "版本号必须大于等于 1") // 防 0/负数绕过「须大于现有最大版本」校验(DB version DEFAULT 1,版本从 1 起)
|
||||
private Integer version;
|
||||
|
||||
@Schema(description = "政策标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "造梦AI 用户协议")
|
||||
@NotBlank(message = "政策标题不能为空")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "政策正文(富文本 HTML,可大文本)", requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "<h1>用户协议</h1><p>……</p>")
|
||||
@NotBlank(message = "政策正文不能为空")
|
||||
private String content;
|
||||
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealMyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealSubmitReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.convert.ComplianceConvert;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.service.appeal.AppealService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 产品端(game-studio)- 申诉控制器(本人提交/查询,U4 R-ACC)
|
||||
*
|
||||
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
|
||||
* 鉴权:所有端点需用户 Token;归属隔离 = userId 取自 token 解析的 getLoginUserId()(非前端入参),
|
||||
* Mapper 强制以 userId 作 WHERE 谓词 enforce(可信边界,照 community AppCommunityMessageController)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Tag(name = "用户 App - 申诉")
|
||||
@RestController
|
||||
@RequestMapping("/compliance/appeal")
|
||||
@Validated
|
||||
public class ComplianceAppealController {
|
||||
|
||||
@Resource
|
||||
private AppealService appealService;
|
||||
|
||||
@PostMapping("/submit")
|
||||
@Operation(summary = "提交申诉", description = "可选关联 banId;重复提交守卫=同用户同 banId 待处理则拒")
|
||||
public CommonResult<Long> submit(@Valid @RequestBody AppealSubmitReqVO reqVO) {
|
||||
// userId 取自 token(归属隔离锚点,非前端入参)
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return success(appealService.submit(reqVO, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/my-page")
|
||||
@Operation(summary = "我的申诉分页", description = "申诉人只见自己申诉;可按 status 过滤")
|
||||
public CommonResult<PageResult<AppealRespVO>> getMyPage(@Valid AppealMyPageReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
PageResult<AppealDO> pageResult = appealService.getMyPage(reqVO, userId);
|
||||
return success(ComplianceConvert.toAppealRespPage(pageResult));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.PolicyDocumentRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.convert.ComplianceConvert;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.service.policy.PolicyDocumentService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 产品端(game-studio)- 政策正文控制器(只读,U4 R-ACC)
|
||||
*
|
||||
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
|
||||
* 鉴权:政策正文(用户协议/隐私/社区规范等)面向所有访客(含注册前),加 @PermitAll 供匿名读取(仿 ComplianceRatingController)。
|
||||
* 政策写入仅由 admin 发布(本控制器不写)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Tag(name = "用户 App - 政策正文")
|
||||
@RestController
|
||||
@RequestMapping("/compliance/policy")
|
||||
@Validated
|
||||
public class CompliancePolicyController {
|
||||
|
||||
@Resource
|
||||
private PolicyDocumentService policyDocumentService;
|
||||
|
||||
@GetMapping("/latest")
|
||||
@PermitAll // 政策正文面向所有访客(含注册前),匿名合法:无 userId 依赖
|
||||
@Operation(summary = "取政策正文最新已发布版", description = "按政策类型取最新已发布版;无记录报 COMPLIANCE_POLICY_NOT_FOUND")
|
||||
@Parameter(name = "policyType", description = "政策类型:user-agreement/privacy/community-guideline/minor-protection",
|
||||
required = true, example = "user-agreement")
|
||||
public CommonResult<PolicyDocumentRespVO> getLatest(@RequestParam("policyType") String policyType) {
|
||||
PolicyDocumentDO doc = policyDocumentService.getLatest(policyType);
|
||||
return success(ComplianceConvert.toPolicyRespVO(doc));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app.vo;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 我的申诉分页 Request VO(app 端,对齐 compliance.yaml /app-api/compliance/appeal/my-page)
|
||||
*
|
||||
* 仅含分页与可选状态过滤;申诉人 userId 由 token 解析注入,不在此暴露(归属隔离硬边界)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 我的申诉分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppealMyPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "状态过滤:0 待处理 / 1 已通过 / 2 已驳回", example = "0")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 申诉 Response VO(对齐 compliance.yaml AppealRespVO;app /my-page + admin /page 共用)
|
||||
*
|
||||
* 字段与 DO 同名直拷(BeanUtils.toBean)。app 端只返回本人申诉(归属隔离),admin 端返回全量。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "申诉 Response VO")
|
||||
@Data
|
||||
public class AppealRespVO {
|
||||
|
||||
@Schema(description = "申诉记录 ID", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它", example = "1")
|
||||
private Integer appealType;
|
||||
|
||||
@Schema(description = "关联封禁台账 ID(可空,0=无关联)", example = "1024")
|
||||
private Long banId;
|
||||
|
||||
@Schema(description = "申诉理由", example = "误判封禁,内容合规")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "状态:0 待处理 / 1 已通过 / 2 已驳回", example = "0")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "处理结果说明", example = "经复核,封禁解除")
|
||||
private String handleResult;
|
||||
|
||||
@Schema(description = "提交时间", example = "2026-06-18 10:00:00")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "更新时间", example = "2026-06-18 12:00:00")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 申诉提交入参 VO(app-api,对齐 compliance.yaml /app-api/compliance/appeal/submit)
|
||||
*
|
||||
* 本人提交申诉;userId 由 token 解析注入(不在此暴露,数据边界)。
|
||||
* 重复提交守卫=同用户同 banId 已有待处理申诉则拒(Service 校验)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 申诉提交 Request VO")
|
||||
@Data
|
||||
public class AppealSubmitReqVO {
|
||||
|
||||
@Schema(description = "申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "申诉类型不能为空")
|
||||
private Integer appealType;
|
||||
|
||||
@Schema(description = "可选关联封禁台账 ID(封禁申诉时填,无则不传或传 0)", example = "1024")
|
||||
private Long banId;
|
||||
|
||||
@Schema(description = "申诉理由(最长 1024 字符,对齐 DB VARCHAR(1024))", requiredMode = Schema.RequiredMode.REQUIRED, example = "误判封禁,内容合规")
|
||||
@NotBlank(message = "申诉理由不能为空")
|
||||
@Size(max = 1024, message = "申诉理由长度不能超过 1024 字符") // 对齐 game_compliance_appeal.reason VARCHAR(1024),防超长入库截断
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.controller.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 政策正文 Response VO(对齐 compliance.yaml PolicyDocumentRespVO;app /latest + admin /page 共用)
|
||||
*
|
||||
* app 端读「最新已发布」一版;admin 端分页查历史各版。字段与 DO 同名直拷(BeanUtils.toBean)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "政策正文 Response VO")
|
||||
@Data
|
||||
public class PolicyDocumentRespVO {
|
||||
|
||||
@Schema(description = "政策正文记录 ID", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "政策类型:user-agreement/privacy/community-guideline/minor-protection", example = "user-agreement")
|
||||
private String policyType;
|
||||
|
||||
@Schema(description = "版本号", example = "3")
|
||||
private Integer version;
|
||||
|
||||
@Schema(description = "政策标题", example = "造梦AI 用户协议")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "政策正文(富文本 HTML)", example = "<h1>用户协议</h1><p>……</p>")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "状态:1 已发布 / 0 草稿", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "更新时间", example = "2026-06-18 10:00:00")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.convert;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.UserBanRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.ContentRatingRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.PolicyDocumentRespVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.rating.ContentRatingDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.common.util.object.BeanUtils;
|
||||
@ -13,7 +17,7 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* compliance 模块转换器(DO ↔ VO)
|
||||
*
|
||||
* 集中承载封禁台账 / 内容分级的 DO → VO 转换(字段同名直拷,BeanUtils.toBean)。
|
||||
* 集中承载封禁台账 / 内容分级 / 政策正文(U4)/ 申诉(U4)的 DO → VO 转换(字段同名直拷,BeanUtils.toBean)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@ -52,4 +56,54 @@ public class ComplianceConvert {
|
||||
return BeanUtils.toBean(rating, ContentRatingRespVO.class);
|
||||
}
|
||||
|
||||
// ============================== U4 政策正文 ==============================
|
||||
|
||||
/**
|
||||
* DO → 政策正文 VO(app /latest + admin /page 单项共用)
|
||||
*
|
||||
* @param doc 政策正文 DO
|
||||
* @return 政策正文 VO;入参 null 返回 null
|
||||
*/
|
||||
public static PolicyDocumentRespVO toPolicyRespVO(PolicyDocumentDO doc) {
|
||||
return BeanUtils.toBean(doc, PolicyDocumentRespVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页 DO → 分页政策正文 VO(admin 历史分页)
|
||||
*
|
||||
* @param page 分页 DO
|
||||
* @return 分页 VO
|
||||
*/
|
||||
public static PageResult<PolicyDocumentRespVO> toPolicyRespPage(PageResult<PolicyDocumentDO> page) {
|
||||
List<PolicyDocumentRespVO> list = page.getList().stream()
|
||||
.map(ComplianceConvert::toPolicyRespVO)
|
||||
.collect(Collectors.toList());
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
// ============================== U4 申诉 ==============================
|
||||
|
||||
/**
|
||||
* DO → 申诉 VO(app /my-page + admin /page 单项共用)
|
||||
*
|
||||
* @param appeal 申诉 DO
|
||||
* @return 申诉 VO;入参 null 返回 null
|
||||
*/
|
||||
public static AppealRespVO toAppealRespVO(AppealDO appeal) {
|
||||
return BeanUtils.toBean(appeal, AppealRespVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页 DO → 分页申诉 VO(app 我的申诉 / admin 台账分页共用)
|
||||
*
|
||||
* @param page 分页 DO
|
||||
* @return 分页 VO
|
||||
*/
|
||||
public static PageResult<AppealRespVO> toAppealRespPage(PageResult<AppealDO> page) {
|
||||
List<AppealRespVO> list = page.getList().stream()
|
||||
.map(ComplianceConvert::toAppealRespVO)
|
||||
.collect(Collectors.toList());
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 申诉台账 DO(对应表 game_compliance_appeal)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列(creator=申诉提交的用户 userId / updater=处理的 admin userId)。
|
||||
* 归属隔离:user_id 取自 token(getLoginUserId()),app 端 Mapper 强制 WHERE 谓词 enforce(可信边界,非前端入参)。
|
||||
* 状态机 status:0 待处理 → 1 已通过 / 2 已驳回(合法流转由 Service 校验,DO 层不承载)。
|
||||
* 重复提交守卫:同 (user_id,ban_id) 待处理(status=0)查重(命中 idx_user_ban_status)。
|
||||
* 软关联:ban_id 可选关联 game_user_ban.id(0=无关联),不建外键,存在性由 Service 校验。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_compliance_appeal")
|
||||
@KeySequence("game_compliance_appeal_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppealDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 申诉记录 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 申诉人用户 ID(归属隔离锚点,取自 token 非前端入参)
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它(见 AppealTypeEnum)
|
||||
*/
|
||||
private Integer appealType;
|
||||
/**
|
||||
* 可选关联封禁台账 ID(game_user_ban.id;0=无关联,封禁申诉时填)
|
||||
*/
|
||||
private Long banId;
|
||||
/**
|
||||
* 申诉理由
|
||||
*/
|
||||
private String reason;
|
||||
/**
|
||||
* 状态机:0 待处理 / 1 已通过 / 2 已驳回(见 AppealStatusEnum,合法流转由 Service 校验)
|
||||
*/
|
||||
private Integer status;
|
||||
/**
|
||||
* 处理结果说明(admin 处理时填)
|
||||
*/
|
||||
private String handleResult;
|
||||
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 政策正文 DO(对应表 game_policy_document)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列(creator=发布操作的 admin userId)。
|
||||
* 唯一键 uk_type_version=(policy_type,version,deleted,tenant_id):同类型同版本不可重复发布。
|
||||
* 多版本:每发布一版各落一条;app 端取「最新已发布」= WHERE policy_type=? AND status=1 ORDER BY version DESC LIMIT 1。
|
||||
* 富文本:content 落 MEDIUMTEXT,承载政策正文 HTML。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_policy_document")
|
||||
@KeySequence("game_policy_document_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PolicyDocumentDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 政策正文记录 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 政策类型:user-agreement/privacy/community-guideline/minor-protection(见 PolicyTypeEnum)
|
||||
*/
|
||||
private String policyType;
|
||||
/**
|
||||
* 版本号(同类型递增,app 取最大已发布版)
|
||||
*/
|
||||
private Integer version;
|
||||
/**
|
||||
* 政策标题
|
||||
*/
|
||||
private String title;
|
||||
/**
|
||||
* 政策正文(富文本 HTML,可大文本)
|
||||
*/
|
||||
private String content;
|
||||
/**
|
||||
* 状态:1 已发布 / 0 草稿(本波只发布态,预留草稿位)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.dal.mysql;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealMyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.AppealStatusEnum;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 申诉台账 Mapper
|
||||
*
|
||||
* ⚠️ 归属隔离(黄金模块未用 @DataPermission,照 ProjectMapper.selectMyPage):app 端查询【必须】在 Mapper 强制以
|
||||
* 申诉人 userId 作 WHERE 谓词 enforce 隔离(userId 取自 token 解析的 getLoginUserId()、非前端入参)。
|
||||
* admin 端按 RBAC 全量查(不按 userId 隔离)。
|
||||
* 显式列查询、命中索引(禁裸 select*):
|
||||
* - selectPendingByUserBan 命中 idx_user_ban_status(重复提交守卫);
|
||||
* - selectMyPage / admin selectPage 命中 idx_user_ban_status / idx_status。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Mapper
|
||||
public interface AppealMapper extends BaseMapperX<AppealDO> {
|
||||
|
||||
/**
|
||||
* 重复提交守卫:同 (userId,banId) 是否已有待处理(status=0)申诉(命中 idx_user_ban_status)
|
||||
*
|
||||
* @param userId 申诉人用户 ID
|
||||
* @param banId 关联封禁台账 ID(无关联=0)
|
||||
* @return 已存在的待处理申诉 DO(仅含 id);不存在返回 null
|
||||
*/
|
||||
default AppealDO selectPendingByUserBan(Long userId, Long banId) {
|
||||
// 该组合无 DB 唯一约束(守卫由本查询前置,非 DB enforce),故用 selectList + LIMIT 1 + get(0)
|
||||
// 取首条判存在(proven 写法,避免 selectOne 在多行场景的歧义);显式只查 id,仅判存在。
|
||||
List<AppealDO> list = selectList(new LambdaQueryWrapperX<AppealDO>()
|
||||
.select(AppealDO::getId) // 显式只查 id,仅判存在
|
||||
.eq(AppealDO::getUserId, userId)
|
||||
.eq(AppealDO::getBanId, banId)
|
||||
.eq(AppealDO::getStatus, AppealStatusEnum.PENDING.getStatus())
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我的申诉」分页(app 端,申诉人只见自己申诉)
|
||||
*
|
||||
* 强制以当前登录 userId 作 WHERE 谓词(归属隔离硬边界);可选按 status 过滤;按 id 倒序(新申诉在前)。
|
||||
*
|
||||
* @param reqVO 分页 + 可选 status 过滤
|
||||
* @param userId 当前登录用户 ID(由 token 解析,非前端入参,保证数据边界)
|
||||
* @return 分页结果
|
||||
*/
|
||||
default PageResult<AppealDO> selectMyPage(AppealMyPageReqVO reqVO, Long userId) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<AppealDO>()
|
||||
.eq(AppealDO::getUserId, userId) // 强制按当前用户过滤(归属隔离硬边界)
|
||||
.eqIfPresent(AppealDO::getStatus, reqVO.getStatus())
|
||||
.orderByDesc(AppealDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 申诉台账分页(admin,按状态筛选,RBAC 全量不按 userId 隔离);命中 idx_status
|
||||
*
|
||||
* @param reqVO 分页 + 可选 status 过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
default PageResult<AppealDO> selectPage(AppealPageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<AppealDO>()
|
||||
.eqIfPresent(AppealDO::getStatus, reqVO.getStatus())
|
||||
.orderByDesc(AppealDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.dal.mysql;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 政策正文 Mapper
|
||||
*
|
||||
* 显式列查询、命中索引(禁裸 select*):
|
||||
* - selectLatestPublished 命中 idx_type_status(app 取某类型最新已发布版);
|
||||
* - selectMaxVersion 取同类型现有最大版本(发布版本冲突校验);
|
||||
* - 分页命中 idx_type_status。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Mapper
|
||||
public interface PolicyDocumentMapper extends BaseMapperX<PolicyDocumentDO> {
|
||||
|
||||
/**
|
||||
* 取某政策类型「最新已发布」一版(status=1 且 version 最大;命中 idx_type_status)
|
||||
*
|
||||
* tenant_id/deleted 由 Huijing 拦截器自动追加到 WHERE,不在此显式拼。
|
||||
*
|
||||
* @param policyType 政策类型
|
||||
* @return 最新已发布政策 DO;无则返回 null
|
||||
*/
|
||||
default PolicyDocumentDO selectLatestPublished(String policyType) {
|
||||
// 同类型可有多版本,故用 selectList + LIMIT 1 + get(0) 取最新(仓内既有「取顶 N 条」proven 写法,
|
||||
// 避免 selectOne 在多行场景的歧义);version DESC 保证取最大版本。
|
||||
List<PolicyDocumentDO> list = selectList(new LambdaQueryWrapperX<PolicyDocumentDO>()
|
||||
.eq(PolicyDocumentDO::getPolicyType, policyType)
|
||||
.eq(PolicyDocumentDO::getStatus, 1) // 1=已发布
|
||||
.orderByDesc(PolicyDocumentDO::getVersion)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某政策类型现有最大版本号(发布前版本冲突校验用;不限状态,含草稿,防版本号回退)
|
||||
*
|
||||
* @param policyType 政策类型
|
||||
* @return 现有最大版本号;无任何记录返回 null
|
||||
*/
|
||||
default Integer selectMaxVersion(String policyType) {
|
||||
// selectList + LIMIT 1 + get(0) 取最大版本(proven 写法,避免 selectOne 多行歧义);显式只查 version 单列
|
||||
List<PolicyDocumentDO> list = selectList(new LambdaQueryWrapperX<PolicyDocumentDO>()
|
||||
.select(PolicyDocumentDO::getVersion) // 显式只查 version 单列
|
||||
.eq(PolicyDocumentDO::getPolicyType, policyType)
|
||||
.orderByDesc(PolicyDocumentDO::getVersion)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0).getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* 政策正文历史分页(admin,可选按类型筛选;按 id 倒序,新版在前);命中 idx_type_status
|
||||
*
|
||||
* @param reqVO 分页 + 可选 policyType 过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
default PageResult<PolicyDocumentDO> selectPage(PolicyPageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<PolicyDocumentDO>()
|
||||
.eqIfPresent(PolicyDocumentDO::getPolicyType, reqVO.getPolicyType())
|
||||
.orderByDesc(PolicyDocumentDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.appeal;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealHandleReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealMyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealSubmitReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
|
||||
/**
|
||||
* 申诉 Service(U4 R-ACC)
|
||||
*
|
||||
* 薄 CRUD + 状态机(提交→admin 处理→结果):本人提交(重复提交守卫 + 可选关联封禁台账)+ 本人分页查询;admin 处理状态机 + 台账分页。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface AppealService {
|
||||
|
||||
/**
|
||||
* 提交申诉(本人)
|
||||
*
|
||||
* @param reqVO 提交入参(类型/可选 banId/理由)
|
||||
* @param userId 申诉人用户 ID(由 token 解析,非前端入参,归属隔离锚点)
|
||||
* @return 新申诉记录 ID
|
||||
*/
|
||||
Long submit(AppealSubmitReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 我的申诉分页(本人,归属隔离)
|
||||
*
|
||||
* @param reqVO 分页 + 可选状态过滤
|
||||
* @param userId 申诉人用户 ID(由 token 解析,强制 WHERE 谓词隔离)
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<AppealDO> getMyPage(AppealMyPageReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 处理申诉(admin,状态机流转:待处理→通过/驳回)
|
||||
*
|
||||
* @param reqVO 处理入参(申诉 ID/决定/结果说明)
|
||||
* @param operatorUserId 处理的 admin 用户 ID(审计溯源)
|
||||
*/
|
||||
void handle(AppealHandleReqVO reqVO, Long operatorUserId);
|
||||
|
||||
/**
|
||||
* 申诉台账分页(admin,RBAC 全量)
|
||||
*
|
||||
* @param reqVO 分页 + 可选状态过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<AppealDO> getAppealPage(AppealPageReqVO reqVO);
|
||||
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.appeal;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealHandleReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealMyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealSubmitReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.AppealMapper;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.UserBanMapper;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.AppealStatusEnum;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.AppealTypeEnum;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 申诉 Service 实现(U4 R-ACC,薄 CRUD + 状态机)
|
||||
*
|
||||
* 核心:
|
||||
* - 提交(本人):类型白名单校验 + 重复提交守卫(同用户同 banId 待处理则拒)+ 封禁申诉可选校验 banId 存在;
|
||||
* - 处理(admin):状态机流转 待处理(0)→通过(1)/驳回(2),仅待处理可处理(防重复处理);
|
||||
* - 归属隔离:本人查询 Mapper 强制 userId 谓词;admin 全量。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AppealServiceImpl implements AppealService {
|
||||
|
||||
@Resource
|
||||
private AppealMapper appealMapper;
|
||||
|
||||
/**
|
||||
* 同模块封禁台账 Mapper:封禁申诉关联 banId 时校验其存在(软关联,不建外键)。
|
||||
*/
|
||||
@Resource
|
||||
private UserBanMapper userBanMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class) // 查守卫+insert 两步原子(事务边界;并发兜底另由 DB 唯一键 uk_user_ban_pending enforce)
|
||||
public Long submit(AppealSubmitReqVO reqVO, Long userId) {
|
||||
// 1) 申诉类型白名单校验(仅 1/2/3)
|
||||
if (!AppealTypeEnum.isValid(reqVO.getAppealType())) {
|
||||
throw exception(COMPLIANCE_APPEAL_TYPE_INVALID);
|
||||
}
|
||||
// 2) banId 归一:无关联统一为 0(与 DB 默认值一致,避免 null 进查重谓词)
|
||||
Long banId = reqVO.getBanId() == null ? 0L : reqVO.getBanId();
|
||||
// 3) 封禁申诉且关联了具体 banId(>0)时,校验封禁台账存在(软关联防悬挂引用)
|
||||
if (AppealTypeEnum.isBan(reqVO.getAppealType()) && banId > 0L) {
|
||||
UserBanDO ban = userBanMapper.selectById(banId);
|
||||
if (ban == null) {
|
||||
// 复用封禁不存在错误码(语义=关联的封禁台账不存在)
|
||||
throw exception(COMPLIANCE_BAN_NOT_EXISTS);
|
||||
}
|
||||
}
|
||||
// 4) 重复提交守卫:同 (userId,banId) 已有待处理申诉则拒(命中 idx_user_ban_status)
|
||||
if (appealMapper.selectPendingByUserBan(userId, banId) != null) {
|
||||
throw exception(COMPLIANCE_APPEAL_DUPLICATE);
|
||||
}
|
||||
// 5) 写申诉台账(初始态=待处理)
|
||||
AppealDO appeal = new AppealDO();
|
||||
appeal.setUserId(userId);
|
||||
appeal.setAppealType(reqVO.getAppealType());
|
||||
appeal.setBanId(banId);
|
||||
appeal.setReason(reqVO.getReason());
|
||||
appeal.setStatus(AppealStatusEnum.PENDING.getStatus());
|
||||
appeal.setHandleResult("");
|
||||
try {
|
||||
appealMapper.insert(appeal);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 并发兜底:步骤 4「先查」与此处「后插」之间被另一并发请求抢先插入,撞 DB 唯一键 uk_user_ban_pending
|
||||
// (pending_flag 保证同 user+ban 至多一条待处理)→ 转同一业务码友好返回「已提交申诉」,而非 500。
|
||||
log.info("[submit][并发命中重复提交唯一键,转友好提示 userId={} banId={}]", userId, banId);
|
||||
throw exception(COMPLIANCE_APPEAL_DUPLICATE);
|
||||
}
|
||||
log.info("[submit][申诉提交 appealId={} userId={} type={} banId={}]",
|
||||
appeal.getId(), userId, reqVO.getAppealType(), banId);
|
||||
return appeal.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<AppealDO> getMyPage(AppealMyPageReqVO reqVO, Long userId) {
|
||||
// 归属隔离:Mapper 强制以 userId 作 WHERE 谓词(数据边界,token 解析非前端入参)
|
||||
return appealMapper.selectMyPage(reqVO, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class) // 查申诉+状态机校验+updateById 多步原子(事务边界,防并发重复处理下的脏写)
|
||||
public void handle(AppealHandleReqVO reqVO, Long operatorUserId) {
|
||||
// 1) 处理决定合法校验(仅 1 通过 / 2 驳回)→ 映射终态
|
||||
Integer targetStatus = AppealStatusEnum.fromDecision(reqVO.getDecision());
|
||||
if (targetStatus == null) {
|
||||
throw exception(COMPLIANCE_APPEAL_DECISION_INVALID);
|
||||
}
|
||||
// 2) 申诉须存在
|
||||
AppealDO appeal = appealMapper.selectById(reqVO.getAppealId());
|
||||
if (appeal == null) {
|
||||
throw exception(COMPLIANCE_APPEAL_NOT_EXISTS);
|
||||
}
|
||||
// 3) 状态机守卫:仅待处理(0)可处理,已处理(通过/驳回)不可重复处理
|
||||
if (!AppealStatusEnum.isPending(appeal.getStatus())) {
|
||||
throw exception(COMPLIANCE_APPEAL_CANNOT_HANDLE);
|
||||
}
|
||||
// 4) 流转到终态(通过/驳回)+ 记处理结果说明
|
||||
AppealDO update = new AppealDO();
|
||||
update.setId(reqVO.getAppealId());
|
||||
update.setStatus(targetStatus);
|
||||
update.setHandleResult(StringUtils.hasText(reqVO.getHandleResult()) ? reqVO.getHandleResult() : "");
|
||||
appealMapper.updateById(update);
|
||||
log.info("[handle][申诉处理 appealId={} decision={} → status={} operator={}]",
|
||||
reqVO.getAppealId(), reqVO.getDecision(), targetStatus, operatorUserId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<AppealDO> getAppealPage(AppealPageReqVO reqVO) {
|
||||
return appealMapper.selectPage(reqVO);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.policy;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
|
||||
/**
|
||||
* 政策正文 Service(U4 R-ACC)
|
||||
*
|
||||
* 薄 CRUD(富文本 + 版本):app 取某政策类型「最新已发布」一版;admin 发布新版(版本须递增)+ 查历史。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface PolicyDocumentService {
|
||||
|
||||
/**
|
||||
* 取某政策类型「最新已发布」一版(app 端只读)
|
||||
*
|
||||
* @param policyType 政策类型(须在 PolicyTypeEnum 白名单,否则报类型非法)
|
||||
* @return 最新已发布政策 DO;无记录报 COMPLIANCE_POLICY_NOT_FOUND
|
||||
*/
|
||||
PolicyDocumentDO getLatest(String policyType);
|
||||
|
||||
/**
|
||||
* 发布政策正文新版(admin)
|
||||
*
|
||||
* @param reqVO 发布入参(类型/版本/标题/富文本)
|
||||
* @param operatorUserId 操作的 admin 用户 ID(审计溯源)
|
||||
* @return 新政策正文记录 ID
|
||||
*/
|
||||
Long publish(PolicyPublishReqVO reqVO, Long operatorUserId);
|
||||
|
||||
/**
|
||||
* 政策正文历史分页(admin)
|
||||
*
|
||||
* @param reqVO 分页 + 可选类型过滤
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<PolicyDocumentDO> getPolicyPage(PolicyPageReqVO reqVO);
|
||||
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.policy;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.PolicyDocumentMapper;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.PolicyTypeEnum;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 政策正文 Service 实现(U4 R-ACC,薄 CRUD)
|
||||
*
|
||||
* 核心:政策正文 = 富文本 + 版本多版本并存;app 取「最新已发布」一版,admin 发布新版(版本号须严格递增防回退/覆盖)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PolicyDocumentServiceImpl implements PolicyDocumentService {
|
||||
|
||||
@Resource
|
||||
private PolicyDocumentMapper policyDocumentMapper;
|
||||
|
||||
@Override
|
||||
public PolicyDocumentDO getLatest(String policyType) {
|
||||
// 1) 类型白名单校验(非法类型直接拦,防脏类型查询)
|
||||
if (!PolicyTypeEnum.isValid(policyType)) {
|
||||
throw exception(COMPLIANCE_POLICY_TYPE_INVALID);
|
||||
}
|
||||
// 2) 取最新已发布版(status=1 且 version 最大);无则报不存在
|
||||
PolicyDocumentDO latest = policyDocumentMapper.selectLatestPublished(policyType);
|
||||
if (latest == null) {
|
||||
throw exception(COMPLIANCE_POLICY_NOT_FOUND);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long publish(PolicyPublishReqVO reqVO, Long operatorUserId) {
|
||||
// 1) 类型白名单校验
|
||||
if (!PolicyTypeEnum.isValid(reqVO.getPolicyType())) {
|
||||
throw exception(COMPLIANCE_POLICY_TYPE_INVALID);
|
||||
}
|
||||
// 2) 版本冲突校验:新版本号须严格大于同类型现有最大版本(防回退/同版本覆盖;唯一键 uk_type_version 兜底)
|
||||
Integer maxVersion = policyDocumentMapper.selectMaxVersion(reqVO.getPolicyType());
|
||||
if (maxVersion != null && reqVO.getVersion() <= maxVersion) {
|
||||
throw exception(COMPLIANCE_POLICY_VERSION_CONFLICT);
|
||||
}
|
||||
// 3) 写新版政策正文(status=1 已发布)
|
||||
PolicyDocumentDO doc = new PolicyDocumentDO();
|
||||
doc.setPolicyType(reqVO.getPolicyType());
|
||||
doc.setVersion(reqVO.getVersion());
|
||||
doc.setTitle(reqVO.getTitle());
|
||||
doc.setContent(reqVO.getContent());
|
||||
doc.setStatus(1); // 1=已发布(本波只发布态)
|
||||
policyDocumentMapper.insert(doc);
|
||||
log.info("[publish][政策正文发布 policyId={} type={} version={} operator={}]",
|
||||
doc.getId(), reqVO.getPolicyType(), reqVO.getVersion(), operatorUserId);
|
||||
return doc.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<PolicyDocumentDO> getPolicyPage(PolicyPageReqVO reqVO) {
|
||||
return policyDocumentMapper.selectPage(reqVO);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.appeal;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.AppealHandleReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealSubmitReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.appeal.AppealDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.AppealMapper;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.UserBanMapper;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.AppealStatusEnum;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.AppealTypeEnum;
|
||||
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 static com.wanxiang.huijing.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link AppealServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 UserBanServiceImplTest 范式)
|
||||
*
|
||||
* 覆盖 U4 申诉薄 CRUD + 状态机核心:
|
||||
* - 提交:写台账(status=待处理);封禁申诉关联 banId 时校验封禁存在;类型非法 → 报错;
|
||||
* - 重复提交守卫:同用户同 banId 已有待处理 → 报 COMPLIANCE_APPEAL_DUPLICATE,不插入;
|
||||
* - 处理状态机:待处理→通过/驳回(断言 status 流转);记录不存在 → 报不存在;非待处理(已处理)→ 报不可处理;决定非法 → 报错;
|
||||
* - 归属隔离 getMyPage 透传当前 userId(边界由 Mapper enforce,此处验 Service 透传未越权改 userId)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class AppealServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AppealServiceImpl appealService;
|
||||
|
||||
@Mock
|
||||
private AppealMapper appealMapper;
|
||||
@Mock
|
||||
private UserBanMapper userBanMapper;
|
||||
|
||||
// ============================== submit ==============================
|
||||
|
||||
@Test
|
||||
void testSubmit_banAppealWithExistingBan_writesPending() {
|
||||
// 封禁申诉关联 banId=1024(封禁存在);无重复待处理 → 写台账(待处理态)
|
||||
when(userBanMapper.selectById(1024L)).thenReturn(new UserBanDO());
|
||||
when(appealMapper.selectPendingByUserBan(7L, 1024L)).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
AppealDO d = inv.getArgument(0);
|
||||
d.setId(1L);
|
||||
return 1;
|
||||
}).when(appealMapper).insert(any(AppealDO.class));
|
||||
|
||||
AppealSubmitReqVO reqVO = mockSubmit(AppealTypeEnum.BAN.getType(), 1024L, "误判封禁");
|
||||
Long id = appealService.submit(reqVO, 7L);
|
||||
|
||||
assertEquals(1L, id);
|
||||
ArgumentCaptor<AppealDO> captor = ArgumentCaptor.forClass(AppealDO.class);
|
||||
verify(appealMapper).insert(captor.capture());
|
||||
AppealDO saved = captor.getValue();
|
||||
assertEquals(7L, saved.getUserId()); // 归属=当前登录用户
|
||||
assertEquals(AppealTypeEnum.BAN.getType(), saved.getAppealType());
|
||||
assertEquals(1024L, saved.getBanId());
|
||||
assertEquals(AppealStatusEnum.PENDING.getStatus(), saved.getStatus()); // 初始=待处理
|
||||
verify(userBanMapper).selectById(1024L); // 校验了关联封禁存在
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmit_otherAppealNoBan_normalizesBanIdToZero() {
|
||||
// 其它类型申诉、不传 banId → banId 归一为 0;不校验封禁;写台账
|
||||
when(appealMapper.selectPendingByUserBan(7L, 0L)).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
AppealDO d = inv.getArgument(0);
|
||||
d.setId(2L);
|
||||
return 1;
|
||||
}).when(appealMapper).insert(any(AppealDO.class));
|
||||
|
||||
AppealSubmitReqVO reqVO = mockSubmit(AppealTypeEnum.OTHER.getType(), null, "其它申诉");
|
||||
Long id = appealService.submit(reqVO, 7L);
|
||||
|
||||
assertEquals(2L, id);
|
||||
ArgumentCaptor<AppealDO> captor = ArgumentCaptor.forClass(AppealDO.class);
|
||||
verify(appealMapper).insert(captor.capture());
|
||||
assertEquals(0L, captor.getValue().getBanId()); // banId 归一为 0
|
||||
verifyNoInteractions(userBanMapper); // 非封禁/无 banId 不校验封禁台账
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmit_banAppealWithMissingBan() {
|
||||
// 封禁申诉关联 banId 但封禁不存在 → 报封禁不存在,不查重不插入
|
||||
when(userBanMapper.selectById(999L)).thenReturn(null);
|
||||
|
||||
AppealSubmitReqVO reqVO = mockSubmit(AppealTypeEnum.BAN.getType(), 999L, "关联不存在封禁");
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.submit(reqVO, 7L));
|
||||
assertEquals(COMPLIANCE_BAN_NOT_EXISTS.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).insert(any(AppealDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmit_duplicateGuard() {
|
||||
// 重复提交守卫:同用户同 banId 已有待处理 → 报重复,不插入
|
||||
when(userBanMapper.selectById(1024L)).thenReturn(new UserBanDO());
|
||||
when(appealMapper.selectPendingByUserBan(7L, 1024L)).thenReturn(new AppealDO());
|
||||
|
||||
AppealSubmitReqVO reqVO = mockSubmit(AppealTypeEnum.BAN.getType(), 1024L, "重复申诉");
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.submit(reqVO, 7L));
|
||||
assertEquals(COMPLIANCE_APPEAL_DUPLICATE.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).insert(any(AppealDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmit_typeInvalid() {
|
||||
// 申诉类型非法(如 9)→ 报类型非法,不查不插
|
||||
AppealSubmitReqVO reqVO = mockSubmit(9, null, "非法类型");
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.submit(reqVO, 7L));
|
||||
assertEquals(COMPLIANCE_APPEAL_TYPE_INVALID.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).insert(any(AppealDO.class));
|
||||
verifyNoInteractions(userBanMapper);
|
||||
}
|
||||
|
||||
// ============================== handle(状态机)==============================
|
||||
|
||||
@Test
|
||||
void testHandle_approve_pendingToApproved() {
|
||||
// 待处理 → 通过:断言 status 流转到 APPROVED + 处理结果写入
|
||||
AppealDO pending = new AppealDO();
|
||||
pending.setId(1L);
|
||||
pending.setStatus(AppealStatusEnum.PENDING.getStatus());
|
||||
when(appealMapper.selectById(1L)).thenReturn(pending);
|
||||
|
||||
appealService.handle(mockHandle(1L, 1, "复核通过,解封"), 100L);
|
||||
|
||||
ArgumentCaptor<AppealDO> captor = ArgumentCaptor.forClass(AppealDO.class);
|
||||
verify(appealMapper).updateById(captor.capture());
|
||||
AppealDO update = captor.getValue();
|
||||
assertEquals(AppealStatusEnum.APPROVED.getStatus(), update.getStatus());
|
||||
assertEquals("复核通过,解封", update.getHandleResult());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandle_reject_pendingToRejected() {
|
||||
// 待处理 → 驳回:断言 status 流转到 REJECTED
|
||||
AppealDO pending = new AppealDO();
|
||||
pending.setId(2L);
|
||||
pending.setStatus(AppealStatusEnum.PENDING.getStatus());
|
||||
when(appealMapper.selectById(2L)).thenReturn(pending);
|
||||
|
||||
appealService.handle(mockHandle(2L, 2, "证据不足"), 100L);
|
||||
|
||||
ArgumentCaptor<AppealDO> captor = ArgumentCaptor.forClass(AppealDO.class);
|
||||
verify(appealMapper).updateById(captor.capture());
|
||||
assertEquals(AppealStatusEnum.REJECTED.getStatus(), captor.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandle_notExists() {
|
||||
// 申诉不存在 → 报不存在,不更新
|
||||
when(appealMapper.selectById(9L)).thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.handle(mockHandle(9L, 1, ""), 100L));
|
||||
assertEquals(COMPLIANCE_APPEAL_NOT_EXISTS.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).updateById(any(AppealDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandle_cannotHandle_alreadyHandled() {
|
||||
// 已处理(已通过)再处理 → 报不可处理(状态机守卫,防重复处理)
|
||||
AppealDO approved = new AppealDO();
|
||||
approved.setId(3L);
|
||||
approved.setStatus(AppealStatusEnum.APPROVED.getStatus());
|
||||
when(appealMapper.selectById(3L)).thenReturn(approved);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.handle(mockHandle(3L, 2, "再处理"), 100L));
|
||||
assertEquals(COMPLIANCE_APPEAL_CANNOT_HANDLE.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).updateById(any(AppealDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandle_decisionInvalid() {
|
||||
// 处理决定非法(如 3)→ 报决定非法,不查不更新(决定校验在查记录之前)
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> appealService.handle(mockHandle(1L, 3, "非法决定"), 100L));
|
||||
assertEquals(COMPLIANCE_APPEAL_DECISION_INVALID.getCode(), ex.getCode());
|
||||
verify(appealMapper, never()).selectById(any());
|
||||
verify(appealMapper, never()).updateById(any(AppealDO.class));
|
||||
}
|
||||
|
||||
// ============================== getMyPage(归属透传)==============================
|
||||
|
||||
@Test
|
||||
void testGetMyPage_passesCurrentUserId() {
|
||||
// 归属隔离:Service 以当前登录 userId 透传给 Mapper(数据边界由 Mapper enforce,此处验未越权改 userId)
|
||||
appealService.getMyPage(new com.wanxiang.huijing.game.module.compliance.controller.app.vo.AppealMyPageReqVO(), 7L);
|
||||
verify(appealMapper).selectMyPage(any(), eq(7L));
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** 构造申诉提交入参 */
|
||||
private static AppealSubmitReqVO mockSubmit(Integer appealType, Long banId, String reason) {
|
||||
AppealSubmitReqVO reqVO = new AppealSubmitReqVO();
|
||||
reqVO.setAppealType(appealType);
|
||||
reqVO.setBanId(banId);
|
||||
reqVO.setReason(reason);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
/** 构造申诉处理入参 */
|
||||
private static AppealHandleReqVO mockHandle(Long appealId, Integer decision, String handleResult) {
|
||||
AppealHandleReqVO reqVO = new AppealHandleReqVO();
|
||||
reqVO.setAppealId(appealId);
|
||||
reqVO.setDecision(decision);
|
||||
reqVO.setHandleResult(handleResult);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package com.wanxiang.huijing.game.module.compliance.service.policy;
|
||||
|
||||
import com.wanxiang.huijing.game.module.compliance.controller.admin.vo.PolicyPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.dataobject.policy.PolicyDocumentDO;
|
||||
import com.wanxiang.huijing.game.module.compliance.dal.mysql.PolicyDocumentMapper;
|
||||
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 static com.wanxiang.huijing.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link PolicyDocumentServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 UserBanServiceImplTest 范式)
|
||||
*
|
||||
* 覆盖 U4 政策正文薄 CRUD 核心:
|
||||
* - 取最新已发布版:Mapper selectLatestPublished 已按 version DESC LIMIT 1 命中,Service 透传(多版本取最新由 Mapper SQL 保证);
|
||||
* - 无记录 → 报 COMPLIANCE_POLICY_NOT_FOUND;类型非法 → 报 COMPLIANCE_POLICY_TYPE_INVALID;
|
||||
* - 发布:版本号须严格大于现有最大版本(首版无 max 放行 / 版本回退或相等 → 报 COMPLIANCE_POLICY_VERSION_CONFLICT)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class PolicyDocumentServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private PolicyDocumentServiceImpl policyDocumentService;
|
||||
|
||||
@Mock
|
||||
private PolicyDocumentMapper policyDocumentMapper;
|
||||
|
||||
// ============================== getLatest ==============================
|
||||
|
||||
@Test
|
||||
void testGetLatest_returnsLatestPublished() {
|
||||
// 准备:Mapper 返回最新已发布版(version=3,多版本取最新由 Mapper SQL ORDER BY version DESC LIMIT 1 保证)
|
||||
PolicyDocumentDO v3 = new PolicyDocumentDO();
|
||||
v3.setId(30L);
|
||||
v3.setPolicyType("user-agreement");
|
||||
v3.setVersion(3);
|
||||
v3.setStatus(1);
|
||||
when(policyDocumentMapper.selectLatestPublished("user-agreement")).thenReturn(v3);
|
||||
|
||||
PolicyDocumentDO got = policyDocumentService.getLatest("user-agreement");
|
||||
|
||||
// 断言:透传最新已发布版(version=3)
|
||||
assertEquals(3, got.getVersion());
|
||||
assertEquals(30L, got.getId());
|
||||
verify(policyDocumentMapper).selectLatestPublished("user-agreement");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetLatest_notFound() {
|
||||
// 无任何已发布版 → 报不存在
|
||||
when(policyDocumentMapper.selectLatestPublished("privacy")).thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> policyDocumentService.getLatest("privacy"));
|
||||
assertEquals(COMPLIANCE_POLICY_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetLatest_typeInvalid() {
|
||||
// 非白名单类型 → 报类型非法(不落 Mapper 查询)
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> policyDocumentService.getLatest("not-a-policy"));
|
||||
assertEquals(COMPLIANCE_POLICY_TYPE_INVALID.getCode(), ex.getCode());
|
||||
verify(policyDocumentMapper, never()).selectLatestPublished(any());
|
||||
}
|
||||
|
||||
// ============================== publish ==============================
|
||||
|
||||
@Test
|
||||
void testPublish_firstVersion_ok() {
|
||||
// 首版:同类型无现有版本(max=null)→ 放行任意版本号
|
||||
when(policyDocumentMapper.selectMaxVersion("privacy")).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
PolicyDocumentDO d = inv.getArgument(0);
|
||||
d.setId(1L);
|
||||
return 1;
|
||||
}).when(policyDocumentMapper).insert(any(PolicyDocumentDO.class));
|
||||
|
||||
Long id = policyDocumentService.publish(mockPublish("privacy", 1, "隐私政策", "<p>……</p>"), 1L);
|
||||
|
||||
assertEquals(1L, id);
|
||||
ArgumentCaptor<PolicyDocumentDO> captor = ArgumentCaptor.forClass(PolicyDocumentDO.class);
|
||||
verify(policyDocumentMapper).insert(captor.capture());
|
||||
PolicyDocumentDO saved = captor.getValue();
|
||||
assertEquals("privacy", saved.getPolicyType());
|
||||
assertEquals(1, saved.getVersion());
|
||||
assertEquals(1, saved.getStatus()); // 已发布态
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublish_higherVersion_ok() {
|
||||
// 递增版本:现有 max=2,发布 v3 → 放行
|
||||
when(policyDocumentMapper.selectMaxVersion("user-agreement")).thenReturn(2);
|
||||
doAnswer(inv -> {
|
||||
PolicyDocumentDO d = inv.getArgument(0);
|
||||
d.setId(3L);
|
||||
return 1;
|
||||
}).when(policyDocumentMapper).insert(any(PolicyDocumentDO.class));
|
||||
|
||||
Long id = policyDocumentService.publish(mockPublish("user-agreement", 3, "用户协议", "<p>v3</p>"), 1L);
|
||||
|
||||
assertEquals(3L, id);
|
||||
verify(policyDocumentMapper).insert(any(PolicyDocumentDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublish_versionConflict_equalOrLower() {
|
||||
// 版本回退/相等:现有 max=3,发布 v3 → 报版本冲突,不插入
|
||||
when(policyDocumentMapper.selectMaxVersion("user-agreement")).thenReturn(3);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> policyDocumentService.publish(mockPublish("user-agreement", 3, "用户协议", "<p>dup</p>"), 1L));
|
||||
assertEquals(COMPLIANCE_POLICY_VERSION_CONFLICT.getCode(), ex.getCode());
|
||||
verify(policyDocumentMapper, never()).insert(any(PolicyDocumentDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublish_typeInvalid() {
|
||||
// 非白名单类型 → 报类型非法,不查 max 不插入
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> policyDocumentService.publish(mockPublish("xxx", 1, "t", "c"), 1L));
|
||||
assertEquals(COMPLIANCE_POLICY_TYPE_INVALID.getCode(), ex.getCode());
|
||||
verify(policyDocumentMapper, never()).insert(any(PolicyDocumentDO.class));
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** 构造政策发布入参 */
|
||||
private static PolicyPublishReqVO mockPublish(String type, Integer version, String title, String content) {
|
||||
PolicyPublishReqVO reqVO = new PolicyPublishReqVO();
|
||||
reqVO.setPolicyType(type);
|
||||
reqVO.setVersion(version);
|
||||
reqVO.setTitle(title);
|
||||
reqVO.setContent(content);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 主题:U4 R-ACC 账号(政策正文页 / 申诉)| owner:Tier-1 广度后端 compliance 子单元(2026-06-18-002 计划 U4)
|
||||
-- 文件:V22.0.0__create_compliance_policy_appeal.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V22.0.1 DROP TABLE)
|
||||
-- 归属:game-module-compliance(已有 rating/ban/gate 合规语义,本波补「政策正文 + 申诉」两域,薄 CRUD)。
|
||||
-- 内容:compliance 模块两张新表 ——
|
||||
-- 1. game_policy_document —— 政策正文(富文本 + 版本;app 端只读取「最新已发布」一版,admin 发布多版本);
|
||||
-- 2. game_compliance_appeal —— 申诉台账(本人提交/查询 + admin 处理状态机;可选关联 game_user_ban 台账 ban_id)。
|
||||
-- 守门①(必守,沿用 V10-V20 守门):本 V22 只放仓根 contracts/db-schemas/(授权源)
|
||||
-- + huijing-server 执行目录(唯一执行副本),不放任何单模块 -server/db/migration/(避免同版本 V22 出现在多个 classpath jar 触发 Flyway 重复校验失败)。
|
||||
-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4(防乱码)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列 + 列级中文 COMMENT;状态机用 TINYINT,非法流转由 Service 校验、DO 层不承载;
|
||||
-- 每表强制 Yudao 6 审计列(creator / create_time / updater / update_time / deleted BIT(1) / tenant_id BIGINT,全 NOT NULL,MVP 单租户 tenant_id=0);
|
||||
-- 申诉归属隔离列 user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce(可信边界,非 @DataPermission);禁裸 select*。
|
||||
-- 错误码段:compliance = 1-109-***-***(子段:004 政策正文 / 005 申诉;落 -api 的 ErrorCodeConstants.java)。
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表1:game_policy_document —— 政策正文(富文本 + 版本)
|
||||
-- 用途:/app-api/compliance/policy/latest 读某政策类型「最新已发布(status=1)+ 最大 version」一版;admin /publish 发布新版、/page 查历史。
|
||||
-- 写口径:每发布一版各落一条(不去重,多版本并存);app 取最新 = WHERE policy_type=? AND status=1 ORDER BY version DESC LIMIT 1。
|
||||
-- 唯一键:(policy_type,version) 业务唯一(含 deleted/tenant_id 适配逻辑删 + 多租户),同类型同版本不可重复发布。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_policy_document` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '政策正文记录 ID',
|
||||
`policy_type` VARCHAR(32) NOT NULL COMMENT '政策类型:user-agreement 用户协议/privacy 隐私政策/community-guideline 社区规范/minor-protection 未成年人保护',
|
||||
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同类型递增,app 取最大已发布版)',
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '政策标题',
|
||||
`content` MEDIUMTEXT NULL COMMENT '政策正文(富文本 HTML,可大文本)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1 已发布 / 0 草稿(本波只发布态,预留草稿位)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;发布操作的 admin userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(发布时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_type_version` (`policy_type`, `version`, `deleted`, `tenant_id`) COMMENT '政策类型+版本业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)',
|
||||
KEY `idx_type_status` (`policy_type`, `status`) COMMENT 'app 按类型取最新已发布版(status=1 ORDER BY version DESC)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '政策正文表(富文本+版本;app 只读最新,admin 发布多版本)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表2:game_compliance_appeal —— 申诉台账(提交→admin 处理状态机)
|
||||
-- 用途:/app-api/compliance/appeal/{submit,my-page} 本人提交/查询;/admin-api/compliance/appeal/{handle,page} admin 处理/台账。
|
||||
-- 状态机 status:0 待处理 → 1 已通过 / 2 已驳回(admin 处理;合法流转由 Service 校验,DO 层不承载)。
|
||||
-- 重复提交守卫(双层,防并发双提):
|
||||
-- ① DB 层硬约束 = 唯一键 uk_user_ban_pending(user_id, ban_id, pending_flag),其中 pending_flag 为「仅待处理时非空」的
|
||||
-- STORED 生成列(status=0 → 0;否则 NULL)。MySQL/InnoDB 不支持 PostgreSQL 式「WHERE status=0 局部唯一索引」,
|
||||
-- 故借「NULL 在唯一索引中互不冲突」语义:待处理行 flag=0 → 同 (user_id,ban_id) 至多一行;已处理行 flag=NULL → 不参与去重,
|
||||
-- 允许同一封禁在前次申诉处理完后再次申诉(不误锁合法复诉)。此为原子 enforce,并发双提必有一方撞唯一键。
|
||||
-- ② 应用层 = Service.submit() 先查守卫(友好早返 COMPLIANCE_APPEAL_DUPLICATE)+ 撞唯一键兜底捕获 DuplicateKeyException 转同一业务码
|
||||
-- (并发下「先查后插」窗口由 ① 兜底,捕获后回友好提示,非 500)。
|
||||
-- ③ 生成列 pending_flag 仅 DB 侧维护,AppealDO 不映射该列(MyBatis-Plus 按显式字段 INSERT/UPDATE,不会写入生成列)。
|
||||
-- 关联:ban_id 可选关联 game_user_ban 台账(封禁申诉时填;0=无关联),不建外键(跨表软关联,由 Service 校验存在性)。
|
||||
-- 归属隔离:user_id 取自 token(getLoginUserId()),Mapper 强制 WHERE 谓词;admin 端不按 user_id 隔离(RBAC 全量)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_compliance_appeal` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '申诉记录 ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '申诉人用户 ID(归属隔离锚点,取自 token 非前端入参)',
|
||||
`appeal_type` TINYINT NOT NULL DEFAULT 3 COMMENT '申诉类型:1 封禁申诉 / 2 审核申诉 / 3 其它',
|
||||
`ban_id` BIGINT NOT NULL DEFAULT 0 COMMENT '可选关联封禁台账 ID(game_user_ban.id;0=无关联,封禁申诉时填)',
|
||||
`reason` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '申诉理由',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态机:0 待处理 / 1 已通过 / 2 已驳回(合法流转由 Service 校验)',
|
||||
`pending_flag` TINYINT GENERATED ALWAYS AS (CASE WHEN `status` = 0 THEN 0 ELSE NULL END) STORED
|
||||
COMMENT '待处理哨兵列(status=0→0;否则 NULL):供唯一键借 NULL 互不冲突语义实现「每(user,ban)至多一条待处理」的原子去重,DO 不映射',
|
||||
`handle_result` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '处理结果说明(admin 处理时填)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;申诉提交的用户 userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(提交时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(处理操作的 admin userId)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_ban_pending` (`user_id`, `ban_id`, `pending_flag`) COMMENT '重复提交硬约束:借 pending_flag(待处理=0/已处理=NULL)在唯一索引中 NULL 互不冲突,强制同(user,ban)至多一条待处理申诉(原子防并发双提;已处理不参与去重)',
|
||||
KEY `idx_user_ban_status` (`user_id`, `ban_id`, `status`) COMMENT '我的申诉按用户分页 + 应用层重复提交前置查(命中此索引)',
|
||||
KEY `idx_status` (`status`) COMMENT 'admin 按状态分页查申诉台账'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '申诉台账表(提交→admin 处理状态机;可选关联封禁台账)';
|
||||
Loading…
x
Reference in New Issue
Block a user