diff --git a/contracts/api-schemas/compliance.yaml b/contracts/api-schemas/compliance.yaml index 1298eedc..ee7c0024 100644 --- a/contracts/api-schemas/compliance.yaml +++ b/contracts/api-schemas/compliance.yaml @@ -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 } diff --git a/contracts/db-schemas/V22.0.0__create_compliance_policy_appeal.sql b/contracts/db-schemas/V22.0.0__create_compliance_policy_appeal.sql new file mode 100644 index 00000000..03a854f4 --- /dev/null +++ b/contracts/db-schemas/V22.0.0__create_compliance_policy_appeal.sql @@ -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 处理状态机;可选关联封禁台账)'; diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealStatusEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealStatusEnum.java new file mode 100644 index 00000000..20ff0c53 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealStatusEnum.java @@ -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; + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealTypeEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealTypeEnum.java new file mode 100644 index 00000000..a9c096b4 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/AppealTypeEnum.java @@ -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); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/ErrorCodeConstants.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/ErrorCodeConstants.java index bed88d5c..bf20af0d 100644 --- a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/ErrorCodeConstants.java @@ -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, "申诉:处理决定非法"); + } diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/PolicyTypeEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/PolicyTypeEnum.java new file mode 100644 index 00000000..ef48988e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/com/wanxiang/huijing/game/module/compliance/enums/PolicyTypeEnum.java @@ -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)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/ComplianceAppealController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/ComplianceAppealController.java new file mode 100644 index 00000000..b5e8dca7 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/ComplianceAppealController.java @@ -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 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> getAppealPage(@Valid AppealPageReqVO reqVO) { + PageResult pageResult = appealService.getAppealPage(reqVO); + return success(ComplianceConvert.toAppealRespPage(pageResult)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/CompliancePolicyController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/CompliancePolicyController.java new file mode 100644 index 00000000..ae814115 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/CompliancePolicyController.java @@ -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 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> getPolicyPage(@Valid PolicyPageReqVO reqVO) { + PageResult pageResult = policyDocumentService.getPolicyPage(reqVO); + return success(ComplianceConvert.toPolicyRespPage(pageResult)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealHandleReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealHandleReqVO.java new file mode 100644 index 00000000..1872848f --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealHandleReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealPageReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealPageReqVO.java new file mode 100644 index 00000000..77a8a40e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/AppealPageReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPageReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPageReqVO.java new file mode 100644 index 00000000..24791f10 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPageReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPublishReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPublishReqVO.java new file mode 100644 index 00000000..12c737c0 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/admin/vo/PolicyPublishReqVO.java @@ -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 = "

用户协议

……

") + @NotBlank(message = "政策正文不能为空") + private String content; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/ComplianceAppealController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/ComplianceAppealController.java new file mode 100644 index 00000000..7c4d08d6 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/ComplianceAppealController.java @@ -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 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> getMyPage(@Valid AppealMyPageReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + PageResult pageResult = appealService.getMyPage(reqVO, userId); + return success(ComplianceConvert.toAppealRespPage(pageResult)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/CompliancePolicyController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/CompliancePolicyController.java new file mode 100644 index 00000000..7e0b315d --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/CompliancePolicyController.java @@ -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 getLatest(@RequestParam("policyType") String policyType) { + PolicyDocumentDO doc = policyDocumentService.getLatest(policyType); + return success(ComplianceConvert.toPolicyRespVO(doc)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealMyPageReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealMyPageReqVO.java new file mode 100644 index 00000000..0a906680 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealMyPageReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealRespVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealRespVO.java new file mode 100644 index 00000000..9bf239f3 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealSubmitReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealSubmitReqVO.java new file mode 100644 index 00000000..66c3dd5e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/AppealSubmitReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/PolicyDocumentRespVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/PolicyDocumentRespVO.java new file mode 100644 index 00000000..f4036210 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/controller/app/vo/PolicyDocumentRespVO.java @@ -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 = "

用户协议

……

") + private String content; + + @Schema(description = "状态:1 已发布 / 0 草稿", example = "1") + private Integer status; + + @Schema(description = "更新时间", example = "2026-06-18 10:00:00") + private LocalDateTime updateTime; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/convert/ComplianceConvert.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/convert/ComplianceConvert.java index 0b62a8e5..1eae1736 100644 --- a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/convert/ComplianceConvert.java +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/convert/ComplianceConvert.java @@ -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 toPolicyRespPage(PageResult page) { + List 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 toAppealRespPage(PageResult page) { + List list = page.getList().stream() + .map(ComplianceConvert::toAppealRespVO) + .collect(Collectors.toList()); + return new PageResult<>(list, page.getTotal()); + } + } diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/appeal/AppealDO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/appeal/AppealDO.java new file mode 100644 index 00000000..aefb2206 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/appeal/AppealDO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/policy/PolicyDocumentDO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/policy/PolicyDocumentDO.java new file mode 100644 index 00000000..c2b03d85 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/dataobject/policy/PolicyDocumentDO.java @@ -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; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/AppealMapper.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/AppealMapper.java new file mode 100644 index 00000000..c439eff8 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/AppealMapper.java @@ -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 { + + /** + * 重复提交守卫:同 (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 list = selectList(new LambdaQueryWrapperX() + .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 selectMyPage(AppealMyPageReqVO reqVO, Long userId) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(AppealDO::getUserId, userId) // 强制按当前用户过滤(归属隔离硬边界) + .eqIfPresent(AppealDO::getStatus, reqVO.getStatus()) + .orderByDesc(AppealDO::getId)); + } + + /** + * 申诉台账分页(admin,按状态筛选,RBAC 全量不按 userId 隔离);命中 idx_status + * + * @param reqVO 分页 + 可选 status 过滤 + * @return 分页结果 + */ + default PageResult selectPage(AppealPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AppealDO::getStatus, reqVO.getStatus()) + .orderByDesc(AppealDO::getId)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/PolicyDocumentMapper.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/PolicyDocumentMapper.java new file mode 100644 index 00000000..85544f4c --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/dal/mysql/PolicyDocumentMapper.java @@ -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 { + + /** + * 取某政策类型「最新已发布」一版(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 list = selectList(new LambdaQueryWrapperX() + .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 list = selectList(new LambdaQueryWrapperX() + .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 selectPage(PolicyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(PolicyDocumentDO::getPolicyType, reqVO.getPolicyType()) + .orderByDesc(PolicyDocumentDO::getId)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealService.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealService.java new file mode 100644 index 00000000..aa0acd30 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealService.java @@ -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 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 getAppealPage(AppealPageReqVO reqVO); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImpl.java new file mode 100644 index 00000000..558baed1 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImpl.java @@ -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 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 getAppealPage(AppealPageReqVO reqVO) { + return appealMapper.selectPage(reqVO); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentService.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentService.java new file mode 100644 index 00000000..3145b5f2 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentService.java @@ -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 getPolicyPage(PolicyPageReqVO reqVO); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImpl.java new file mode 100644 index 00000000..a3c6c516 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImpl.java @@ -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 getPolicyPage(PolicyPageReqVO reqVO) { + return policyDocumentMapper.selectPage(reqVO); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImplTest.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImplTest.java new file mode 100644 index 00000000..06fbca84 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/appeal/AppealServiceImplTest.java @@ -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 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 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 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 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; + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImplTest.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImplTest.java new file mode 100644 index 00000000..8b29694c --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/com/wanxiang/huijing/game/module/compliance/service/policy/PolicyDocumentServiceImplTest.java @@ -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, "隐私政策", "

……

"), 1L); + + assertEquals(1L, id); + ArgumentCaptor 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, "用户协议", "

v3

"), 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, "用户协议", "

dup

"), 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; + } + +} diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V22.0.0__create_compliance_policy_appeal.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V22.0.0__create_compliance_policy_appeal.sql new file mode 100644 index 00000000..03a854f4 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V22.0.0__create_compliance_policy_appeal.sql @@ -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 处理状态机;可选关联封禁台账)';