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:
zizi 2026-06-18 03:56:58 +00:00
parent ca53a0db0c
commit ec23ff61bc
30 changed files with 1961 additions and 3 deletions

View File

@ -8,9 +8,10 @@ openapi: 3.0.3
# 本波 ProjectApi 暂无 reviewProject seam下架联动标 TODO 留 Phase C 主 agent 收口(只写台账、不裸调不存在的方法)。
# 降权R5降权出流屏蔽 P1 本波不生效(不建 FeedDownweightApi 孤儿 seam、不调 feed
# IPD5ip 不独立建模块,仅在 compliance 内留 IpComplianceSeam 扩展点(默认 pass 桩Wave4 接 ip 只换实现。
# 端:/rpc-apiproject.publish 同进程 Feign 注入)/ /admin-api管理端 game-adminRBAC 封禁)/ /app-api产品端只读分级前缀由 huijing 框架按 controller 包名/RpcConstants 自动添加
# U4 R-ACC2026-06-18 扩):政策正文页(富文本 + 版本app 只读最新 / admin 发布多版本)+ 申诉回路app 本人提交/查询 + admin 处理状态机,可选关联封禁台账)。归属 compliance已有 rating/ban/gate 合规语义),薄 CRUD。
# 端:/rpc-apiproject.publish 同进程 Feign 注入)/ /admin-api管理端 game-adminRBAC 封禁+政策发布+申诉处理)/ /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 不在本模块登记R1block→admitted=false 不抛异常)。
# 子段001 锁风门 / 002 封禁 / 003 分级 / 004 政策正文U4/ 005 申诉U4publish-blocked 不在本模块登记R1block→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: 新增一版政策正文(富文本 + versionversion 须比同类型现有最大版本大,否则报 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 取自 tokengetLoginUserId()Mapper 强制 WHERE 谓词(可信边界,非前端入参)。
# 状态机 status0 待处理 → 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:
# ===== 锁风门 GateRPC seamR1 唯一形态)=====
@ -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 }

View File

@ -0,0 +1,76 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题U4 R-ACC 账号(政策正文页 / 申诉)| ownerTier-1 广度后端 compliance 子单元2026-06-18-002 计划 U4
-- 文件V22.0.0__create_compliance_policy_appeal.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 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 重复校验失败)。
-- 守门②:含中文 SQLmini-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 NULLMVP 单租户 tenant_id=0
-- 申诉归属隔离列 user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce可信边界非 @DataPermission禁裸 select*。
-- 错误码段compliance = 1-109-***-***子段004 政策正文 / 005 申诉;落 -api 的 ErrorCodeConstants.java
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表1game_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 '租户 IDYudao 多租户兼容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 发布多版本)';
-- -----------------------------------------------------------------------------
-- 表2game_compliance_appeal —— 申诉台账提交→admin 处理状态机)
-- 用途:/app-api/compliance/appeal/{submit,my-page} 本人提交/查询;/admin-api/compliance/appeal/{handle,page} admin 处理/台账。
-- 状态机 status0 待处理 → 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 取自 tokengetLoginUserId()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 '可选关联封禁台账 IDgame_user_ban.id0=无关联,封禁申诉时填)',
`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 '租户 IDYudao 多租户兼容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 处理状态机;可选关联封禁台账)';

View File

@ -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.statustinyint
*
* 合法流转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;
}
}

View File

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

View File

@ -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, "申诉:处理决定非法");
}

View File

@ -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_typevarchar
*
* 白名单仅允许下列类型发布/读取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));
}
}

View File

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

View File

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

View File

@ -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;
/**
* 申诉处理入参 VOadmin-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;
}

View File

@ -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 VOadmin对齐 compliance.yaml /admin-api/compliance/appeal/page
*
* 分页复用 Huijing PageParampageNo/pageSize可选按状态筛选命中 idx_statusadmin 端不按 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;
}

View File

@ -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 VOadmin对齐 compliance.yaml /admin-api/compliance/policy/page
*
* 分页复用 Huijing PageParampageNo/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;
}

View File

@ -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;
/**
* 政策正文发布入参 VOadmin-api对齐 compliance.yaml /admin-api/compliance/policy/publish
*
* 发布一版政策正文富文本 + versionversion 须比同类型现有最大版本大否则报 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;
}

View File

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

View File

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

View File

@ -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 VOapp 对齐 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;
}

View File

@ -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 AppealRespVOapp /my-page + admin /page 共用
*
* 字段与 DO 同名直拷BeanUtils.toBeanapp 端只返回本人申诉归属隔离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;
}

View File

@ -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;
/**
* 申诉提交入参 VOapp-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;
}

View File

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

View File

@ -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 政策正文 VOapp /latest + admin /page 单项共用
*
* @param doc 政策正文 DO
* @return 政策正文 VO入参 null 返回 null
*/
public static PolicyDocumentRespVO toPolicyRespVO(PolicyDocumentDO doc) {
return BeanUtils.toBean(doc, PolicyDocumentRespVO.class);
}
/**
* 分页 DO 分页政策正文 VOadmin 历史分页
*
* @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 申诉 VOapp /my-page + admin /page 单项共用
*
* @param appeal 申诉 DO
* @return 申诉 VO入参 null 返回 null
*/
public static AppealRespVO toAppealRespVO(AppealDO appeal) {
return BeanUtils.toBean(appeal, AppealRespVO.class);
}
/**
* 分页 DO 分页申诉 VOapp 我的申诉 / 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());
}
}

View File

@ -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 取自 tokengetLoginUserId()app Mapper 强制 WHERE 谓词 enforce可信边界非前端入参
* 状态机 status0 待处理 1 已通过 / 2 已驳回合法流转由 Service 校验DO 层不承载
* 重复提交守卫 (user_id,ban_id) 待处理(status=0)查重命中 idx_user_ban_status
* 软关联ban_id 可选关联 game_user_ban.id0=无关联不建外键存在性由 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;
/**
* 可选关联封禁台账 IDgame_user_ban.id0=无关联封禁申诉时填
*/
private Long banId;
/**
* 申诉理由
*/
private String reason;
/**
* 状态机0 待处理 / 1 已通过 / 2 已驳回 AppealStatusEnum合法流转由 Service 校验
*/
private Integer status;
/**
* 处理结果说明admin 处理时填
*/
private String handleResult;
}

View File

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

View File

@ -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.selectMyPageapp 端查询必须 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));
}
}

View File

@ -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_statusapp 取某类型最新已发布版
* - 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));
}
}

View File

@ -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;
/**
* 申诉 ServiceU4 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);
/**
* 申诉台账分页adminRBAC 全量
*
* @param reqVO 分页 + 可选状态过滤
* @return 分页结果
*/
PageResult<AppealDO> getAppealPage(AppealPageReqVO reqVO);
}

View File

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

View File

@ -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;
/**
* 政策正文 ServiceU4 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);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,76 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题U4 R-ACC 账号(政策正文页 / 申诉)| ownerTier-1 广度后端 compliance 子单元2026-06-18-002 计划 U4
-- 文件V22.0.0__create_compliance_policy_appeal.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 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 重复校验失败)。
-- 守门②:含中文 SQLmini-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 NULLMVP 单租户 tenant_id=0
-- 申诉归属隔离列 user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce可信边界非 @DataPermission禁裸 select*。
-- 错误码段compliance = 1-109-***-***子段004 政策正文 / 005 申诉;落 -api 的 ErrorCodeConstants.java
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表1game_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 '租户 IDYudao 多租户兼容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 发布多版本)';
-- -----------------------------------------------------------------------------
-- 表2game_compliance_appeal —— 申诉台账提交→admin 处理状态机)
-- 用途:/app-api/compliance/appeal/{submit,my-page} 本人提交/查询;/admin-api/compliance/appeal/{handle,page} admin 处理/台账。
-- 状态机 status0 待处理 → 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 取自 tokengetLoginUserId()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 '可选关联封禁台账 IDgame_user_ban.id0=无关联,封禁申诉时填)',
`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 '租户 IDYudao 多租户兼容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 处理状态机;可选关联封禁台账)';