feat(community): U3 R-SOC 评论/关注/排行ZSET/弹幕WS

社区社交后端四子能力(game-module-community),克隆通知底座/feed互动幂等范式:

- 评论 Comments:CRUD + 举报转 compliance。删除按 userId 归属强校验(防越权删他人评论),
  举报落本端举报态幂等 + 事务提交后 afterCommit 接缝(compliance 无受理 -api,留桩 OPEN ITEM)。
- 关注 Follow:active 软开关幂等切换(不删行,范本=feed FeedInteractLog),不能关注自己,
  并发双插由 DB 唯一键 DuplicateKeyException CAS 收敛;粉丝数/关注数 countActive。
- 排行 Ranking:Redis ZSET + ZSET↔DB 一致性窗口。DB=真相、ZSET=缓存索引:
  写穿透双写(DB 原子累加 + ZSET incrementScore)、ZSET 缺失/启动从 DB 回填重建、最终一致;
  维度白名单 RankDimensionEnum(game_hot/creator_hot)。
- 弹幕 Danmaku:WebSocket 单机最小实(LocalWebSocketMessageSender),自维护房间 gameId→sessionId
  注册表做房间维度广播(框架仅按用户维度推送),断连懒清理;多实例需切 sender-type=redis(MQ future-state 未部署)。

契约/库表:
- Flyway V23.0.0(U3 独占 V23.x):game_community_comment/follow/rank/danmaku 四表 +
  幂等唯一键(follow uk(user_id,target_user_id,deleted,tenant_id)、rank uk(dimension,target_id,...)),
  同步 huijing-server/db/migration + contracts/db-schemas。
- community.yaml additive 加 8 个 app 端点 + 5 个 schema(既有冻结)。
- 错误码段 1-107-004 评论 / 005 关注 / 006 排行 / 007 弹幕。

验证:mvn -pl game-module-community/game-module-community-server -am test -o 全绿
(57 tests:新增 U3 35 + 既有 22;CommentService 9 / FollowService 8 / RankService 8 / Danmaku 10)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 11:01:24 -07:00
parent e378f3c662
commit 3f35acb4d3
44 changed files with 3178 additions and 0 deletions

View File

@ -150,6 +150,244 @@ paths:
data: { type: array, items: { $ref: '#/components/schemas/CommunityRewardRespVO' } }
msg: { type: string, example: '' }
# ===========================================================================
# app 端(U3 R-SOC 社区社交):评论 / 关注 / 排行 / 弹幕(additive,2026-06-18 plan002 U3)
# 错误码段:评论 1-107-004 / 关注 1-107-005 / 排行 1-107-006 / 弹幕 1-107-007。
# 写端点(发评论/删评论/举报/关注/取关/发弹幕)需用户 Token,归属/发起方一律 getLoginUserId()(非前端入参)。
# 公开读:评论列表/计数、排行 TopN、弹幕回放无需登录。举报转 compliance:落本端举报态 + 事务提交后接缝(compliance 受理 -api 待提供,OPEN ITEM)。
# 弹幕实时接收走 WebSocket(type="danmaku",本契约仅描述 HTTP;WS 路径见后端 huijing.websocket.path),HTTP 发送为兜底入口。
# ===========================================================================
/app-api/community/comments:
post:
tags: [app-community]
summary: 发表评论(R-SOC)
description: 需登录;作者 = 当前登录用户(token 解析,非前端入参)。内容非空。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [targetType, targetId, content]
properties:
targetType: { type: integer, enum: [1], default: 1, description: '评论目标类型:1游戏' }
targetId: { type: integer, format: int64, description: '评论目标 ID(gameId)' }
content: { type: string, maxLength: 1024, description: '评论正文' }
parentId: { type: integer, format: int64, default: 0, description: '父评论 ID(0=一级,MVP 不展开树)' }
responses:
'200':
description: 新评论 ID(CommonResult<Long>)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { type: integer, format: int64, example: 1 }
msg: { type: string, example: '' }
get:
tags: [app-community]
summary: 评论分页列表(R-SOC)
description: 公开读;按 target_type+target_id 维度,id 倒序。
parameters:
- { name: targetType, in: query, required: false, schema: { type: integer, enum: [1], default: 1 }, description: '目标类型:1游戏' }
- { name: targetId, in: query, required: true, schema: { type: integer, format: int64 }, description: '目标 ID(gameId)' }
- { name: pageNo, in: query, required: false, schema: { type: integer, default: 1 }, description: '页码,从 1 开始' }
- { name: pageSize, in: query, required: false, schema: { type: integer, default: 10 }, description: '每页条数(最大 200)' }
responses:
'200':
description: 评论分页结果(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data:
type: object
properties:
list: { type: array, items: { $ref: '#/components/schemas/CommunityCommentRespVO' } }
total: { type: integer, format: int64, example: 100 }
msg: { type: string, example: '' }
/app-api/community/comments/{id}:
delete:
tags: [app-community]
summary: 删除评论(R-SOC)
description: 需登录;先校验归属当前登录用户,防越权删他人评论(数据边界红线)。
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '评论 ID' }
responses:
'200':
description: CommonResult<Boolean>
content: { application/json: { schema: { $ref: '#/components/schemas/CommonResultBoolean' } } }
/app-api/community/comments/count:
get:
tags: [app-community]
summary: 评论数(R-SOC)
description: 某目标的评论总数(公开计数)。
parameters:
- { name: targetType, in: query, required: false, schema: { type: integer, enum: [1], default: 1 } }
- { name: targetId, in: query, required: true, schema: { type: integer, format: int64 }, description: '目标 ID(gameId)' }
responses:
'200':
description: CommonResult<Long>
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { type: integer, format: int64, example: 42 }
msg: { type: string, example: '' }
/app-api/community/comments/{id}/report:
post:
tags: [app-community]
summary: 举报评论(R-SOC)
description: >-
需登录;理由必填;落本端举报态(report_status=1)幂等 + 事务提交后转 compliance 受理。
OPEN ITEM:compliance 当前无举报受理 -api,本波仅留接缝桩,接通后经 -api 调用 + 幂等 + 补偿。
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '被举报评论 ID' }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason]
properties:
reason: { type: string, maxLength: 200, description: '举报理由(必填)' }
responses:
'200':
description: CommonResult<Boolean>
content: { application/json: { schema: { $ref: '#/components/schemas/CommonResultBoolean' } } }
/app-api/community/follow/{targetUserId}:
post:
tags: [app-community]
summary: 关注(R-SOC)
description: 需登录;发起方 = 当前登录用户;幂等(重复关注同态不写);不能关注自己;并发由 DB 唯一键 CAS 收敛。
parameters:
- { name: targetUserId, in: path, required: true, schema: { type: integer, format: int64 }, description: '被关注方用户 ID' }
responses:
'200':
description: 关注结果(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { $ref: '#/components/schemas/CommunityFollowResultRespVO' }
msg: { type: string, example: '' }
delete:
tags: [app-community]
summary: 取关(R-SOC)
description: 需登录;幂等(反激活 active=0,不删行)。
parameters:
- { name: targetUserId, in: path, required: true, schema: { type: integer, format: int64 }, description: '被关注方用户 ID' }
responses:
'200':
description: 取关结果(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { $ref: '#/components/schemas/CommunityFollowResultRespVO' }
msg: { type: string, example: '' }
/app-api/community/follow/stat/{userId}:
get:
tags: [app-community]
summary: 关注统计(R-SOC)
description: 某用户粉丝数/关注数 + 当前登录者是否已关注 ta(未登录则 followedByMe=false)。
parameters:
- { name: userId, in: path, required: true, schema: { type: integer, format: int64 }, description: '查询的用户 ID' }
responses:
'200':
description: 关注统计(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { $ref: '#/components/schemas/CommunityFollowStatRespVO' }
msg: { type: string, example: '' }
/app-api/community/rank:
get:
tags: [app-community]
summary: 排行榜单 TopN(R-SOC)
description: >-
公开读;维度须为已定义维度(game_hot 游戏热度 / creator_hot 创作者热度)。
优先读 Redis ZSET(快路径),ZSET 缺失自动从 DB 权威源回填重建(读路径不空窗,最终一致)。
parameters:
- { name: dimension, in: query, required: true, schema: { type: string, enum: [game_hot, creator_hot] }, description: '排行维度' }
- { name: topN, in: query, required: false, schema: { type: integer, default: 10 }, description: '取前 N 名' }
responses:
'200':
description: 榜单(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { type: array, items: { $ref: '#/components/schemas/CommunityRankItemRespVO' } }
msg: { type: string, example: '' }
/app-api/community/danmaku:
post:
tags: [app-community]
summary: 发送弹幕(R-SOC)
description: >-
需登录;落库(可回放)+ 触发 WebSocket 房间(gameId)广播给在线观众;发送者 = 当前登录用户。
实时接收走 WebSocket(type="danmaku");本 HTTP 发送为不开 WS 客户端的兜底入口。
单实例边界:多实例需切 huijing.websocket.sender-type=redis(MQ future-state 未部署)。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [gameId, content]
properties:
gameId: { type: integer, format: int64, description: '游戏 ID(弹幕房间)' }
content: { type: string, maxLength: 255, description: '弹幕正文' }
playTime: { type: integer, default: 0, description: '播放进度(秒,MVP 可为 0)' }
responses:
'200':
description: 已落库弹幕(CommonResult,同时是 WS 广播载荷)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { $ref: '#/components/schemas/CommunityDanmakuRespVO' }
msg: { type: string, example: '' }
/app-api/community/danmaku/recent:
get:
tags: [app-community]
summary: 最近弹幕回放(R-SOC)
description: 公开读;某游戏最近 N 条弹幕(进房补帧),按时间正序。
parameters:
- { name: gameId, in: query, required: true, schema: { type: integer, format: int64 }, description: '游戏 ID(房间)' }
- { name: limit, in: query, required: false, schema: { type: integer, default: 50 }, description: '取近 N 条' }
responses:
'200':
description: 弹幕列表(CommonResult)
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
data: { type: array, items: { $ref: '#/components/schemas/CommunityDanmakuRespVO' } }
msg: { type: string, example: '' }
# ===========================================================================
# RPC 端(/rpc-api/community/*):CommunityNotifyApi 四方法
# 供上游 aigc/project/trade 同进程 @Primary 调用,非对外业务路由(@FeignClient(name="community-server"))。
@ -291,3 +529,49 @@ components:
status: { type: integer, description: '发放状态:0已触发待发放(本波终态) 1已发放(M4)' }
grantedRef: { type: string, description: 'M4 发放回执(本波空)' }
createTime: { type: string, format: date-time, description: '触发时间' }
# ====================== U3 R-SOC 社区社交(additive) ======================
CommunityCommentRespVO:
type: object
description: 社区评论(列表项)
properties:
id: { type: integer, format: int64, description: '评论 ID' }
targetType: { type: integer, description: '评论目标类型:1游戏' }
targetId: { type: integer, format: int64, description: '评论目标 ID(gameId)' }
userId: { type: integer, format: int64, description: '评论作者用户 ID' }
content: { type: string, description: '评论正文' }
parentId: { type: integer, format: int64, description: '父评论 ID(0=一级评论)' }
reportStatus: { type: integer, description: '举报态:0正常 1被举报' }
createTime: { type: string, format: date-time, description: '创建时间' }
CommunityFollowResultRespVO:
type: object
description: 关注/取关结果(回显切换后关注态 + 被关注方最新粉丝数)
properties:
targetUserId: { type: integer, format: int64, description: '被关注方用户 ID' }
following: { type: boolean, description: '切换后是否已关注:true=已关注 false=已取关' }
followerCount: { type: integer, format: int64, description: '被关注方最新粉丝数' }
CommunityFollowStatRespVO:
type: object
description: 关注统计(粉丝数 + 关注数 + 当前登录者是否已关注 ta)
properties:
userId: { type: integer, format: int64, description: '查询的用户 ID' }
followerCount: { type: integer, format: int64, description: '粉丝数(被多少 active 用户关注)' }
followingCount: { type: integer, format: int64, description: '关注数(active 关注了多少人)' }
followedByMe: { type: boolean, description: '当前登录者是否已关注 ta(未登录则恒 false)' }
CommunityRankItemRespVO:
type: object
description: 排行榜单项
properties:
rank: { type: integer, description: '名次(从 1 开始)' }
targetId: { type: integer, format: int64, description: '排行对象 ID(game_hot=gameId / creator_hot=creatorUserId)' }
score: { type: integer, format: int64, description: '排行分值' }
CommunityDanmakuRespVO:
type: object
description: 弹幕(回放列表项 + WebSocket 广播载荷,type="danmaku" 的 content)
properties:
id: { type: integer, format: int64, description: '弹幕 ID' }
gameId: { type: integer, format: int64, description: '游戏 ID(房间)' }
userId: { type: integer, format: int64, description: '发送者用户 ID' }
content: { type: string, description: '弹幕正文' }
playTime: { type: integer, description: '播放进度(秒)' }
createTime: { type: string, format: date-time, description: '发送时间' }

View File

@ -0,0 +1,107 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题:community 社区社交(评论 + 关注 + 排行 + 弹幕)(U3 R-SOC,plan002)| owner:U3
-- 文件:V23.0.0__create_game_community_social.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V23.0.1)
-- 版本号:U3 独占 V23.x(全局当前最大 V22;切勿与他人版本冲突)。
-- 内容:community 四张社交新表:
-- 1. game_community_comment —— 评论(CRUD + 举报转 compliance);
-- 2. game_community_follow —— 关注(active 软开关幂等切换,不删行);
-- 3. game_community_rank —— 排行权威源(Redis ZSET 的回填源 / 一致性兜底,DB=真相、ZSET=缓存索引);
-- 4. game_community_danmaku —— 弹幕(WebSocket 广播 + 落库可回放)。
-- 守门①:本 V23 同时放仓根 contracts/db-schemas/(授权源)+ huijing-server 执行目录(唯一执行副本),
-- 不放任何单模块 -server/db/migration/(避免同版本出现在多个 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 / creator_user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce(可信边界,非 @DataPermission);禁裸 select*。
-- 错误码段:community = 1-107-***-***(落 -api 的 ErrorCodeConstants.java,评论 004/关注 005/排行 006/弹幕 007)。
-- 幂等:关注 uk(user_id,target_user_id,deleted,tenant_id) 软切换不删行;排行 uk(dimension,target_id,deleted,tenant_id) 一目标一行。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表1:game_community_comment —— 评论(CRUD + 举报转 compliance)
-- 归属隔离 = Mapper 删除/编辑强制 user_id = getLoginUserId() 谓词,用户只能改自己的评论;
-- 列表查询按 target_type+target_id 维度(公开可见,不按 user 隔离)。举报由 Service 落举报态 + 转 compliance 受理(事务提交后)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_comment` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '评论 ID',
`target_type` TINYINT NOT NULL DEFAULT 1 COMMENT '评论目标类型:1游戏(MVP 仅游戏,预留扩展)',
`target_id` BIGINT NOT NULL COMMENT '评论目标 ID(target_type=1 时 = game_project.id)',
`user_id` BIGINT NOT NULL COMMENT '评论作者用户 ID(归属隔离:删除/编辑 Mapper 强制以 getLoginUserId() 作 WHERE 谓词)',
`content` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '评论正文',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父评论 ID(0=一级评论;预留楼中楼,MVP 不展开树)',
`report_status` TINYINT NOT NULL DEFAULT 0 COMMENT '举报态:0正常 1被举报(转 compliance 受理后置 1,本端只留信号痕迹,不裁决)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;区别于业务归属列 user_id)',
`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(MVP 单租户=0)',
PRIMARY KEY (`id`),
KEY `idx_target` (`target_type`, `target_id`, `id`) COMMENT '目标维度评论列表(按 id 倒序,新评论在前)',
KEY `idx_user` (`user_id`, `id`) COMMENT '我的评论列表 / 归属校验加速'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区评论(CRUD + 举报转 compliance,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表2:game_community_follow —— 关注(active 软开关幂等切换,不删行)
-- 范本 = feed game_feed_interact_log:唯一键命中则改 active(取关=反激活 active=0),未命中则插,幂等不重复落行。
-- 计数 = countActive(active=1)。一对 (user_id,target_user_id) 一行(uk 保证幂等,反复关/取关只切 active)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_follow` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '关注关系 ID',
`user_id` BIGINT NOT NULL COMMENT '关注发起方用户 ID(粉丝;归属隔离:写动作强制以 getLoginUserId() 承载)',
`target_user_id` BIGINT NOT NULL COMMENT '被关注方用户 ID(偶像)',
`active` TINYINT NOT NULL DEFAULT 1 COMMENT '激活态:1已关注 0已取关(软开关,取关不删行)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`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 '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_target` (`user_id`, `target_user_id`, `deleted`, `tenant_id`) COMMENT '一对关注关系一行(幂等软切换,防并发双插)',
KEY `idx_target_active` (`target_user_id`, `active`) COMMENT '粉丝数统计(某人被多少 active 关注)',
KEY `idx_user_active` (`user_id`, `active`) COMMENT '关注数统计 / 我的关注列表'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区关注关系(active 软开关幂等,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表3:game_community_rank —— 排行权威源(DB=真相,Redis ZSET=缓存索引)
-- 一致性窗口(最终一致):分值变更事件在同一 Service 流程内【双写】DB 本行 + ZSET(写穿透);
-- ZSET 缺失/启动/周期对账时由本表回填重建 ZSET(rebuild)。score 落 DB 供权威回填,ZSET 仅供快速 TopN。
-- 一目标一维度一行(uk 保证幂等累加落点)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_rank` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '排行记录 ID',
`dimension` VARCHAR(32) NOT NULL COMMENT '排行维度(如 game_hot 游戏热度 / creator_hot 创作者热度),与 ZSET key community:rank:{dimension} 对应',
`target_id` BIGINT NOT NULL COMMENT '排行对象 ID(game_hot=gameId / creator_hot=creatorUserId)',
`score` BIGINT NOT NULL DEFAULT 0 COMMENT '排行分值(权威源;BIGINT 防溢出,与 ZSET double 同口径取整数域)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(系统身份写=0)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(系统身份写=0)',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dimension_target` (`dimension`, `target_id`, `deleted`, `tenant_id`) COMMENT '一维度一对象一行(幂等累加落点 + ZSET 回填唯一源)',
KEY `idx_dimension_score` (`dimension`, `score`) COMMENT '权威排序回填 TopN(ZSET 重建用)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区排行权威源(ZSET 回填源,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表4:game_community_danmaku —— 弹幕(WebSocket 广播 + 落库可回放)
-- 房间 = game_id(同一游戏的所有在线观众)。发送 = 落库 + WebSocket 广播给房间内会话。
-- 多实例边界:本波 LocalWebSocketMessageSender 仅单机;多实例需切 huijing.websocket.sender-type=redis(MQ future-state 未部署)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_danmaku` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '弹幕 ID',
`game_id` BIGINT NOT NULL COMMENT '游戏 ID(= game_project.id,弹幕房间维度)',
`user_id` BIGINT NOT NULL COMMENT '发送者用户 ID',
`content` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '弹幕正文',
`play_time` INT NOT NULL DEFAULT 0 COMMENT '播放进度(秒;回放时按此对齐弹幕出现时机,MVP 可为 0)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`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 '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
KEY `idx_game` (`game_id`, `id`) COMMENT '某游戏最近弹幕回放(按 id 倒序/正序取近段)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区弹幕(WS 广播 + 落库回放,U3 R-SOC)';

View File

@ -30,4 +30,28 @@ public interface ErrorCodeConstants {
/** 通知类型非法(mock-trigger 入参 type 不在 1系统/2公告/3审核/4收益 范围内) */
ErrorCode COMMUNITY_NOTIFY_TYPE_INVALID = new ErrorCode(1_107_002_000, "通知类型非法");
// ========== 评论 Comment 1-107-004-*** (U3 R-SOC) ==========
/** 评论不存在(删除/编辑前查不到目标评论) */
ErrorCode COMMUNITY_COMMENT_NOT_EXISTS = new ErrorCode(1_107_004_000, "评论不存在");
/** 非评论作者:app 端用户只能删除/操作自己发表的评论(数据边界,服务端强校验,防越权删他人评论) */
ErrorCode COMMUNITY_COMMENT_NOT_OWNER = new ErrorCode(1_107_004_001, "无权操作他人的评论");
/** 评论内容为空(去除首尾空白后为空串) */
ErrorCode COMMUNITY_COMMENT_CONTENT_BLANK = new ErrorCode(1_107_004_002, "评论内容不能为空");
/** 评论举报理由为空(举报转 compliance 必填理由) */
ErrorCode COMMUNITY_COMMENT_REPORT_REASON_REQUIRED = new ErrorCode(1_107_004_003, "举报理由不能为空");
// ========== 关注 Follow 1-107-005-*** (U3 R-SOC) ==========
/** 不能关注自己(target_user_id == 当前登录 id) */
ErrorCode COMMUNITY_FOLLOW_SELF_NOT_ALLOWED = new ErrorCode(1_107_005_000, "不能关注自己");
// ========== 排行 Rank 1-107-006-*** (U3 R-SOC) ==========
/** 排行维度非法(dimension 不在已定义维度集合内) */
ErrorCode COMMUNITY_RANK_DIMENSION_INVALID = new ErrorCode(1_107_006_000, "排行维度非法");
// ========== 弹幕 Danmaku 1-107-007-*** (U3 R-SOC) ==========
/** 弹幕内容为空(去除首尾空白后为空串) */
ErrorCode COMMUNITY_DANMAKU_CONTENT_BLANK = new ErrorCode(1_107_007_000, "弹幕内容不能为空");
/** 弹幕内容超长(超过最大长度限制) */
ErrorCode COMMUNITY_DANMAKU_CONTENT_TOO_LONG = new ErrorCode(1_107_007_001, "弹幕内容超长");
}

View File

@ -0,0 +1,41 @@
package com.wanxiang.huijing.game.module.community.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Objects;
/**
* 社区排行维度枚举(U3 R-SOC,对应 ZSET key community:rank:{dimension} 与表 game_community_rank.dimension)
*
* MVP 定义两维:游戏热度 / 创作者热度。维度合法性由 Service 校验,避免脏 key 污染 Redis。
* 维度新增须同步本枚举(作为白名单单一来源),供上游排行事件源(feed/project)与本模块共用。
*
* @author 造梦AI
*/
@Getter
@AllArgsConstructor
public enum RankDimensionEnum {
/** 游戏热度:targetId = gameId,分值由播放/点赞等事件累加 */
GAME_HOT("game_hot", "游戏热度"),
/** 创作者热度:targetId = creatorUserId,分值由作品表现/粉丝增长等累加 */
CREATOR_HOT("creator_hot", "创作者热度");
/** 维度码(落库 + ZSET key 后缀) */
private final String dimension;
/** 维度名(展示用) */
private final String name;
/**
* 根据维度码查枚举
*
* @param dimension 维度码
* @return 枚举;非法/未定义返回 null
*/
public static RankDimensionEnum of(String dimension) {
return Arrays.stream(values()).filter(e -> Objects.equals(e.dimension, dimension)).findFirst().orElse(null);
}
}

View File

@ -61,6 +61,12 @@
<artifactId>huijing-spring-boot-starter-redis</artifactId>
</dependency>
<!-- WebSocket:弹幕实时房间广播(U3 R-SOC,LocalWebSocketMessageSender 单机,多实例切 sender-type=redis) -->
<dependency>
<groupId>com.wanxiang</groupId>
<artifactId>huijing-spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RPC 远程调用(CommunityNotifyApi 同进程 @Primary 解析需 Feign 基建在类路径) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -0,0 +1,83 @@
package com.wanxiang.huijing.game.module.community.controller.app.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentCreateReqVO;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentPageReqVO;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentReportReqVO;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentRespVO;
import com.wanxiang.huijing.game.module.community.convert.comment.CommentConvert;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
import com.wanxiang.huijing.game.module.community.service.comment.CommentService;
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.Parameter;
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)- community 评论控制器(U3 R-SOC 评论)
*
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
* 鉴权:发表/删除/举报为写端点需用户 Token;归属隔离 = userId 取自 token 解析的 getLoginUserId()(非前端入参),
* 删除强校验作者本人(防越权删他人评论)。列表/计数为公开读,按 target 维度。
*
* @author 造梦AI
*/
@Tag(name = "用户 App - community 评论")
@RestController
@RequestMapping("/community")
@Validated
public class AppCommunityCommentController {
@Resource
private CommentService commentService;
@PostMapping("/comments")
@Operation(summary = "发表评论", description = "需登录;作者 = 当前登录用户(token 解析,非前端入参)")
public CommonResult<Long> createComment(@Valid @RequestBody CommentCreateReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(commentService.createComment(reqVO, userId));
}
@DeleteMapping("/comments/{id}")
@Operation(summary = "删除评论", description = "先校验归属当前登录用户,防越权删他人评论")
@Parameter(name = "id", description = "评论编号", required = true, example = "1024")
public CommonResult<Boolean> deleteComment(@PathVariable("id") Long id) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
commentService.deleteComment(id, userId);
return success(true);
}
@GetMapping("/comments")
@Operation(summary = "评论分页列表", description = "公开内容,按 target_type+target_id 维度,id 倒序")
public CommonResult<PageResult<CommentRespVO>> getCommentPage(@Valid CommentPageReqVO pageReqVO) {
PageResult<CommentDO> pageResult = commentService.getCommentPage(pageReqVO);
return success(CommentConvert.toRespPage(pageResult));
}
@GetMapping("/comments/count")
@Operation(summary = "评论数", description = "某目标的评论总数(公开计数)")
@Parameter(name = "targetType", description = "目标类型:1游戏", example = "1")
@Parameter(name = "targetId", description = "目标 ID(gameId)", required = true, example = "1024")
public CommonResult<Long> getCommentCount(@RequestParam(value = "targetType", defaultValue = "1") Integer targetType,
@RequestParam("targetId") Long targetId) {
return success(commentService.getCommentCount(targetType, targetId));
}
@PostMapping("/comments/{id}/report")
@Operation(summary = "举报评论", description = "需登录;理由必填;落本端举报态 + 事务提交后转 compliance 受理")
@Parameter(name = "id", description = "评论编号", required = true, example = "1024")
public CommonResult<Boolean> reportComment(@PathVariable("id") Long id,
@Valid @RequestBody CommentReportReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
commentService.reportComment(id, reqVO.getReason(), userId);
return success(true);
}
}

View File

@ -0,0 +1,36 @@
package com.wanxiang.huijing.game.module.community.controller.app.comment.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 发表评论 Request VO(app 端)
*
* 作者 userId 由 token 解析注入,不在此暴露(数据边界)。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 发表评论 Request VO")
@Data
public class CommentCreateReqVO {
@Schema(description = "评论目标类型:1游戏", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "评论目标类型不能为空")
private Integer targetType = 1;
@Schema(description = "评论目标 ID(targetType=1 时为 gameId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "评论目标 ID 不能为空")
private Long targetId;
@Schema(description = "评论正文", requiredMode = Schema.RequiredMode.REQUIRED, example = "这游戏太好玩了")
@NotEmpty(message = "评论内容不能为空")
@Size(max = 1024, message = "评论内容不能超过 1024 个字符")
private String content;
@Schema(description = "父评论 ID(0=一级评论;MVP 不展开树)", example = "0")
private Long parentId = 0L;
}

View File

@ -0,0 +1,29 @@
package com.wanxiang.huijing.game.module.community.controller.app.comment.vo;
import com.wanxiang.huijing.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 评论分页 Request VO(app 端)
*
* 评论列表为公开内容,按 target 维度查询(targetType + targetId),不按 user 隔离。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 评论分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class CommentPageReqVO extends PageParam {
@Schema(description = "评论目标类型:1游戏", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "评论目标类型不能为空")
private Integer targetType = 1;
@Schema(description = "评论目标 ID(targetType=1 时为 gameId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "评论目标 ID 不能为空")
private Long targetId;
}

View File

@ -0,0 +1,24 @@
package com.wanxiang.huijing.game.module.community.controller.app.comment.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 举报评论 Request VO(app 端)
*
* 举报理由必填(随信号转 compliance 受理,本端只落举报态、不裁决)。举报人 userId 由 token 解析注入。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 举报评论 Request VO")
@Data
public class CommentReportReqVO {
@Schema(description = "举报理由", requiredMode = Schema.RequiredMode.REQUIRED, example = "含违规内容")
@NotEmpty(message = "举报理由不能为空")
@Size(max = 200, message = "举报理由不能超过 200 个字符")
private String reason;
}

View File

@ -0,0 +1,41 @@
package com.wanxiang.huijing.game.module.community.controller.app.comment.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 评论 Response VO(app 端,评论列表项)
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 评论 Response VO")
@Data
public class CommentRespVO {
@Schema(description = "评论 ID", example = "1")
private Long id;
@Schema(description = "评论目标类型:1游戏", example = "1")
private Integer targetType;
@Schema(description = "评论目标 ID", example = "1024")
private Long targetId;
@Schema(description = "评论作者用户 ID", example = "99")
private Long userId;
@Schema(description = "评论正文", example = "这游戏太好玩了")
private String content;
@Schema(description = "父评论 ID(0=一级评论)", example = "0")
private Long parentId;
@Schema(description = "举报态:0正常 1被举报", example = "0")
private Integer reportStatus;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,55 @@
package com.wanxiang.huijing.game.module.community.controller.app.danmaku;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuSendReqVO;
import com.wanxiang.huijing.game.module.community.service.danmaku.DanmakuService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
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.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 产品端(game-studio)- community 弹幕控制器(U3 R-SOC 弹幕)
*
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
* 发送(HTTP):需用户 Token;落库 + 触发 WebSocket 房间(gameId)广播给在线观众。
* 实时接收走 WebSocket(type="danmaku",见 DanmakuWebSocketMessageListener);本 HTTP 发送为不开 WS 客户端的兜底入口。
* 回放:某游戏最近弹幕(公开读,进房补帧)。
*
* @author 造梦AI
*/
@Tag(name = "用户 App - community 弹幕")
@RestController
@RequestMapping("/community")
@Validated
public class AppCommunityDanmakuController {
@Resource
private DanmakuService danmakuService;
@PostMapping("/danmaku")
@Operation(summary = "发送弹幕", description = "需登录;落库 + WebSocket 房间广播;发送者 = 当前登录用户(token 解析)")
public CommonResult<DanmakuRespVO> sendDanmaku(@Valid @RequestBody DanmakuSendReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(danmakuService.sendDanmaku(reqVO, userId));
}
@GetMapping("/danmaku/recent")
@Operation(summary = "最近弹幕回放", description = "公开读;某游戏最近 N 条弹幕(进房补帧),按时间正序")
@Parameter(name = "gameId", description = "游戏编号(房间)", required = true, example = "1024")
@Parameter(name = "limit", description = "取近 N 条(默认 50)", example = "50")
public CommonResult<List<DanmakuRespVO>> getRecentDanmaku(@RequestParam("gameId") Long gameId,
@RequestParam(value = "limit", defaultValue = "50") Integer limit) {
return success(danmakuService.getRecentDanmaku(gameId, limit == null ? 50 : limit));
}
}

View File

@ -0,0 +1,32 @@
package com.wanxiang.huijing.game.module.community.controller.app.danmaku.message;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 弹幕 WebSocket 入站消息体(U3 R-SOC 弹幕,前端 → 后端)
*
* 前端经 WebSocket 发 {type:"danmaku", content:<本对象 JSON>};后端 listener 按 action 路由:
* - join:当前会话加入房间(gameId),开始接收该房间弹幕广播;
* - leave:当前会话离开房间;
* - send:在房间内发弹幕(落库 + 广播;与 HTTP 发送等价,便于纯 WS 客户端)。
*
* @author 造梦AI
*/
@Schema(description = "弹幕 WebSocket 入站消息体")
@Data
public class DanmakuWebSocketMessage {
@Schema(description = "动作:join进房 / leave退房 / send发弹幕", example = "join")
private String action;
@Schema(description = "游戏房间 ID", example = "1024")
private Long gameId;
@Schema(description = "弹幕正文(action=send 时必填)", example = "前方高能")
private String content;
@Schema(description = "播放进度(秒,action=send 可选)", example = "0")
private Integer playTime;
}

View File

@ -0,0 +1,37 @@
package com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 弹幕 Response VO(app 端,回放列表项 + WS 广播载荷)
*
* 同时复用为 WebSocket 广播的消息内容(JsonUtils 序列化为 content 下发给房间会话)。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 弹幕 Response VO")
@Data
public class DanmakuRespVO {
@Schema(description = "弹幕 ID", example = "1")
private Long id;
@Schema(description = "游戏 ID(房间)", example = "1024")
private Long gameId;
@Schema(description = "发送者用户 ID", example = "99")
private Long userId;
@Schema(description = "弹幕正文", example = "前方高能")
private String content;
@Schema(description = "播放进度(秒)", example = "0")
private Integer playTime;
@Schema(description = "发送时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,32 @@
package com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 发送弹幕 Request VO(app 端,HTTP 落库 + 触发 WS 广播)
*
* 发送者 userId 由 token 解析注入,不在此暴露(数据边界)。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 发送弹幕 Request VO")
@Data
public class DanmakuSendReqVO {
@Schema(description = "游戏 ID(弹幕房间)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "游戏 ID 不能为空")
private Long gameId;
@Schema(description = "弹幕正文", requiredMode = Schema.RequiredMode.REQUIRED, example = "前方高能")
@NotEmpty(message = "弹幕内容不能为空")
@Size(max = 255, message = "弹幕内容不能超过 255 个字符")
private String content;
@Schema(description = "播放进度(秒,MVP 可为 0)", example = "0")
private Integer playTime = 0;
}

View File

@ -0,0 +1,60 @@
package com.wanxiang.huijing.game.module.community.controller.app.follow;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowResultRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowStatRespVO;
import com.wanxiang.huijing.game.module.community.service.follow.FollowService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
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 org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 产品端(game-studio)- community 关注控制器(U3 R-SOC 关注)
*
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
* 鉴权:关注/取关为写端点需用户 Token;发起方 = 当前登录用户(token 解析,非前端入参,防伪冒)。
* 软开关幂等:关注/取关切 active 不删行;并发由 DB 唯一键 CAS 收敛。
*
* @author 造梦AI
*/
@Tag(name = "用户 App - community 关注")
@RestController
@RequestMapping("/community")
@Validated
public class AppCommunityFollowController {
@Resource
private FollowService followService;
@PostMapping("/follow/{targetUserId}")
@Operation(summary = "关注", description = "需登录;幂等(重复关注同态不写);不能关注自己")
@Parameter(name = "targetUserId", description = "被关注方用户编号", required = true, example = "88")
public CommonResult<FollowResultRespVO> follow(@PathVariable("targetUserId") Long targetUserId) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(followService.toggleFollow(targetUserId, true, userId));
}
@DeleteMapping("/follow/{targetUserId}")
@Operation(summary = "取关", description = "需登录;幂等(反激活 active=0,不删行)")
@Parameter(name = "targetUserId", description = "被关注方用户编号", required = true, example = "88")
public CommonResult<FollowResultRespVO> unfollow(@PathVariable("targetUserId") Long targetUserId) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(followService.toggleFollow(targetUserId, false, userId));
}
@GetMapping("/follow/stat/{userId}")
@Operation(summary = "关注统计", description = "某用户粉丝数/关注数 + 当前登录者是否已关注 ta(未登录则 followedByMe=false)")
@Parameter(name = "userId", description = "查询的用户编号", required = true, example = "88")
public CommonResult<FollowStatRespVO> getFollowStat(@PathVariable("userId") Long userId) {
// 当前登录者(可能未登录,getLoginUserId 返回 null,统计仍可读,followedByMe 恒 false)
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
return success(followService.getFollowStat(userId, currentUserId));
}
}

View File

@ -0,0 +1,26 @@
package com.wanxiang.huijing.game.module.community.controller.app.follow.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 关注/取关结果 Response VO(app 端)
*
* 回显切换后的最新关注态与被关注方粉丝数(即时反馈)。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 关注/取关结果 Response VO")
@Data
public class FollowResultRespVO {
@Schema(description = "被关注方用户 ID", example = "88")
private Long targetUserId;
@Schema(description = "切换后是否已关注:true=已关注 false=已取关", example = "true")
private Boolean following;
@Schema(description = "被关注方最新粉丝数", example = "1024")
private Long followerCount;
}

View File

@ -0,0 +1,29 @@
package com.wanxiang.huijing.game.module.community.controller.app.follow.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 关注统计 Response VO(app 端)
*
* 某用户的粉丝数 + 关注数 +(可选)当前登录者是否已关注 ta。
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 关注统计 Response VO")
@Data
public class FollowStatRespVO {
@Schema(description = "查询的用户 ID", example = "88")
private Long userId;
@Schema(description = "粉丝数(被多少 active 用户关注)", example = "1024")
private Long followerCount;
@Schema(description = "关注数(active 关注了多少人)", example = "50")
private Long followingCount;
@Schema(description = "当前登录者是否已关注 ta(未登录则恒 false)", example = "true")
private Boolean followedByMe;
}

View File

@ -0,0 +1,46 @@
package com.wanxiang.huijing.game.module.community.controller.app.rank;
import com.wanxiang.huijing.game.module.community.controller.app.rank.vo.RankItemRespVO;
import com.wanxiang.huijing.game.module.community.service.rank.RankService;
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 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 java.util.List;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 产品端(game-studio)- community 排行控制器(U3 R-SOC 排行)
*
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
* 公开读:榜单为公开内容,无需登录。优先读 Redis ZSET(快路径),ZSET 缺失自动从 DB 回填重建(兜底不空窗)。
*
* @author 造梦AI
*/
@Tag(name = "用户 App - community 排行")
@RestController
@RequestMapping("/community")
@Validated
public class AppCommunityRankController {
@Resource
private RankService rankService;
@GetMapping("/rank")
@Operation(summary = "排行榜单 TopN", description = "公开读;维度须为已定义维度(game_hot/creator_hot);ZSET 缺失自动 DB 回填")
@Parameter(name = "dimension", description = "排行维度:game_hot 游戏热度 / creator_hot 创作者热度", required = true, example = "game_hot")
@Parameter(name = "topN", description = "取前 N 名(默认 10)", example = "10")
public CommonResult<List<RankItemRespVO>> getTopN(@RequestParam("dimension") String dimension,
@RequestParam(value = "topN", defaultValue = "10") Integer topN) {
return success(rankService.getTopN(dimension, topN == null ? 10 : topN));
}
}

View File

@ -0,0 +1,24 @@
package com.wanxiang.huijing.game.module.community.controller.app.rank.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 排行榜单项 Response VO(app 端)
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 排行榜单项 Response VO")
@Data
public class RankItemRespVO {
@Schema(description = "名次(从 1 开始)", example = "1")
private Integer rank;
@Schema(description = "排行对象 ID(game_hot=gameId / creator_hot=creatorUserId)", example = "1024")
private Long targetId;
@Schema(description = "排行分值", example = "9999")
private Long score;
}

View File

@ -0,0 +1,44 @@
package com.wanxiang.huijing.game.module.community.convert.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentRespVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
import com.wanxiang.huijing.framework.common.util.object.BeanUtils;
import java.util.List;
import java.util.stream.Collectors;
/**
* 社区评论转换器(DO ↔ VO,BeanUtils + 手写 static,照黄金模块 ProjectConvert / MessageConvert 范式)
*
* @author 造梦AI
*/
public class CommentConvert {
/**
* 单个 DO → RespVO(字段同名平铺拷贝)
*
* @param comment 评论 DO
* @return 响应 VO;入参为 null 时返回 null
*/
public static CommentRespVO toRespVO(CommentDO comment) {
if (comment == null) {
return null;
}
return BeanUtils.toBean(comment, CommentRespVO.class);
}
/**
* 分页 DO → 分页 RespVO
*
* @param page 分页 DO
* @return 分页 VO
*/
public static PageResult<CommentRespVO> toRespPage(PageResult<CommentDO> page) {
List<CommentRespVO> list = page.getList().stream()
.map(CommentConvert::toRespVO)
.collect(Collectors.toList());
return new PageResult<>(list, page.getTotal());
}
}

View File

@ -0,0 +1,53 @@
package com.wanxiang.huijing.game.module.community.dal.dataobject.comment;
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_community_comment,U3 R-SOC 评论)
*
* 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。
* 归属隔离:user_id 为评论作者,删除/编辑由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce(防越权删他人评论,
* 区别于审计列 creator);列表查询按 target 维度(公开可见)。举报由 Service 落 report_status + 转 compliance 受理。
*
* @author 造梦AI
*/
@TableName("game_community_comment")
@KeySequence("game_community_comment_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class CommentDO extends TenantBaseDO {
/**
* 评论 ID
*/
private Long id;
/**
* 评论目标类型:1游戏(MVP 仅游戏,预留扩展)
*/
private Integer targetType;
/**
* 评论目标 ID(targetType=1 时 = game_project.id)
*/
private Long targetId;
/**
* 评论作者用户 ID(业务归属字段;删除/编辑 Mapper 强制以 getLoginUserId() 作谓词。区别于审计列 creator)
*/
private Long userId;
/**
* 评论正文
*/
private String content;
/**
* 父评论 ID(0=一级评论;预留楼中楼,MVP 不展开树)
*/
private Long parentId;
/**
* 举报态:0正常 1被举报(转 compliance 受理后置 1,本端只留信号痕迹,不裁决)
*/
private Integer reportStatus;
}

View File

@ -0,0 +1,45 @@
package com.wanxiang.huijing.game.module.community.dal.dataobject.danmaku;
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_community_danmaku,U3 R-SOC 弹幕)
*
* 继承 {@link TenantBaseDO}:自动携带审计列 + tenant_id。
* 房间 = game_id(同一游戏的所有在线观众);发送 = 落库 + WebSocket 广播给房间会话。
* 多实例边界:本波 LocalWebSocketMessageSender 仅单机;多实例需切 huijing.websocket.sender-type=redis(MQ future-state 未部署)。
*
* @author 造梦AI
*/
@TableName("game_community_danmaku")
@KeySequence("game_community_danmaku_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class DanmakuDO extends TenantBaseDO {
/**
* 弹幕 ID
*/
private Long id;
/**
* 游戏 ID(= game_project.id,弹幕房间维度)
*/
private Long gameId;
/**
* 发送者用户 ID
*/
private Long userId;
/**
* 弹幕正文
*/
private String content;
/**
* 播放进度(秒;回放时按此对齐弹幕出现时机,MVP 可为 0)
*/
private Integer playTime;
}

View File

@ -0,0 +1,41 @@
package com.wanxiang.huijing.game.module.community.dal.dataobject.follow;
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_community_follow,U3 R-SOC 关注)
*
* 继承 {@link TenantBaseDO}:自动携带审计列 + tenant_id。
* 设计意图(范本=feed FeedInteractLogDO):唯一键 (user_id,target_user_id) 保证幂等,
* 取关=反激活 active=0(不删行),反复关/取关只切 active。计数 = countActive(active=1)。
*
* @author 造梦AI
*/
@TableName("game_community_follow")
@KeySequence("game_community_follow_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class FollowDO extends TenantBaseDO {
/**
* 关注关系 ID
*/
private Long id;
/**
* 关注发起方用户 ID(粉丝;归属隔离:写动作强制以 getLoginUserId() 承载,非前端入参)
*/
private Long userId;
/**
* 被关注方用户 ID(偶像)
*/
private Long targetUserId;
/**
* 激活态:1已关注 0已取关(软开关,取关不删行)
*/
private Integer active;
}

View File

@ -0,0 +1,41 @@
package com.wanxiang.huijing.game.module.community.dal.dataobject.rank;
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_community_rank,U3 R-SOC 排行)
*
* 继承 {@link TenantBaseDO}:自动携带审计列 + tenant_id。
* 一致性约定:DB=真相、Redis ZSET=缓存索引;分值变更在同一 Service 流程内双写 DB 本行 + ZSET(写穿透),
* ZSET 缺失/启动/对账时由本表回填重建(rebuild)。一维度一对象一行(uk_dimension_target 幂等累加落点)。
*
* @author 造梦AI
*/
@TableName("game_community_rank")
@KeySequence("game_community_rank_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class RankDO extends TenantBaseDO {
/**
* 排行记录 ID
*/
private Long id;
/**
* 排行维度(如 game_hot 游戏热度 / creator_hot 创作者热度),对应 ZSET key community:rank:{dimension}
*/
private String dimension;
/**
* 排行对象 ID(game_hot=gameId / creator_hot=creatorUserId)
*/
private Long targetId;
/**
* 排行分值(权威源;BIGINT 防溢出,与 ZSET double 同口径取整数域)
*/
private Long score;
}

View File

@ -0,0 +1,61 @@
package com.wanxiang.huijing.game.module.community.dal.mysql.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentPageReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
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;
/**
* 社区评论 Mapper(U3 R-SOC 评论)
*
* ⚠️ 归属隔离(R-medium PII 红线,黄金模块未用 @DataPermission):删除/编辑等写动作【必须】在 Service 比对
* do.userId == 当前登录 id(防越权删他人评论,userId 取自 token 解析的 getLoginUserId()、非前端入参)。
* 评论列表为公开内容,按 target 维度查询,不按 user 隔离。
*
* @author 造梦AI
*/
@Mapper
public interface CommentMapper extends BaseMapperX<CommentDO> {
/**
* 目标维度评论分页(公开可见,按 target_type+target_id 过滤,id 倒序新评论在前)
*
* @param reqVO 分页 + target_type/target_id 过滤(targetId 必填)
* @return 分页结果
*/
default PageResult<CommentDO> selectTargetPage(CommentPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<CommentDO>()
.eq(CommentDO::getTargetType, reqVO.getTargetType())
.eq(CommentDO::getTargetId, reqVO.getTargetId())
.orderByDesc(CommentDO::getId));
}
/**
* 查单条评论(删除/编辑/举报前的归属校验用,命中主键)
*
* 注:仅按主键查,归属校验由 Service 比对 do.userId == 当前登录 id(防越权操作他人评论),不在此处过滤。
*
* @param id 评论 ID(主键)
* @return 评论 DO;不存在返回 null
*/
default CommentDO selectByIdSafe(Long id) {
return selectOne(new LambdaQueryWrapperX<CommentDO>()
.eq(CommentDO::getId, id));
}
/**
* 某目标的评论总数(公开计数,按 target 维度,不按 user 隔离)
*
* @param targetType 目标类型
* @param targetId 目标 ID
* @return 评论数
*/
default Long selectCountByTarget(Integer targetType, Long targetId) {
return selectCount(new LambdaQueryWrapperX<CommentDO>()
.eq(CommentDO::getTargetType, targetType)
.eq(CommentDO::getTargetId, targetId));
}
}

View File

@ -0,0 +1,34 @@
package com.wanxiang.huijing.game.module.community.dal.mysql.danmaku;
import com.wanxiang.huijing.game.module.community.dal.dataobject.danmaku.DanmakuDO;
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(U3 R-SOC 弹幕)
*
* 弹幕落库供回放;房间维度 = game_id。
*
* @author 造梦AI
*/
@Mapper
public interface DanmakuMapper extends BaseMapperX<DanmakuDO> {
/**
* 某游戏最近 N 条弹幕(回放/进房补帧用,按 id 倒序取近段后由调用方翻正序)
*
* @param gameId 游戏 ID(房间)
* @param limit 取近 N 条
* @return 弹幕 DO 列表(id 倒序)
*/
default List<DanmakuDO> selectRecentByGame(Long gameId, int limit) {
return selectList(new LambdaQueryWrapperX<DanmakuDO>()
.eq(DanmakuDO::getGameId, gameId)
.orderByDesc(DanmakuDO::getId)
.last("LIMIT " + limit));
}
}

View File

@ -0,0 +1,55 @@
package com.wanxiang.huijing.game.module.community.dal.mysql.follow;
import com.wanxiang.huijing.game.module.community.dal.dataobject.follow.FollowDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
/**
* 社区关注关系 Mapper(U3 R-SOC 关注)
*
* 范本 = feed FeedInteractLogMapper:唯一键命中改 active(软切换幂等),countActive 统计粉丝/关注数。
*
* @author 造梦AI
*/
@Mapper
public interface FollowMapper extends BaseMapperX<FollowDO> {
/**
* 按唯一键查关注关系(软切换前判存在;命中首条防并发双插)
*
* @param userId 关注发起方用户 ID(粉丝)
* @param targetUserId 被关注方用户 ID(偶像)
* @return 关注关系 DO;不存在返回 null
*/
default FollowDO selectByUserTarget(Long userId, Long targetUserId) {
return selectOne(new LambdaQueryWrapperX<FollowDO>()
.eq(FollowDO::getUserId, userId)
.eq(FollowDO::getTargetUserId, targetUserId));
}
/**
* 某人的粉丝数(被多少 active=1 用户关注)
*
* @param targetUserId 被关注方用户 ID
* @return 粉丝数
*/
default Long selectFollowerCount(Long targetUserId) {
return selectCount(new LambdaQueryWrapperX<FollowDO>()
.eq(FollowDO::getTargetUserId, targetUserId)
.eq(FollowDO::getActive, 1));
}
/**
* 某人的关注数(active=1 关注了多少人)
*
* @param userId 关注发起方用户 ID
* @return 关注数
*/
default Long selectFollowingCount(Long userId) {
return selectCount(new LambdaQueryWrapperX<FollowDO>()
.eq(FollowDO::getUserId, userId)
.eq(FollowDO::getActive, 1));
}
}

View File

@ -0,0 +1,71 @@
package com.wanxiang.huijing.game.module.community.dal.mysql.rank;
import com.wanxiang.huijing.game.module.community.dal.dataobject.rank.RankDO;
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 org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 社区排行权威源 Mapper(U3 R-SOC 排行,DB=真相)
*
* 一致性约定:分值变更在 Service 内双写 DB(本 Mapper)+ ZSET(RedisTemplate.opsForZSet);
* ZSET 缺失/启动/对账时由 selectTopByDimension 回填重建 ZSET。原子累加 @Update 防并发丢更新。
*
* @author 造梦AI
*/
@Mapper
public interface RankMapper extends BaseMapperX<RankDO> {
/**
* 按唯一键查排行行(累加前判存在;命中首条防并发双插)
*
* @param dimension 排行维度
* @param targetId 排行对象 ID
* @return 排行 DO;不存在返回 null
*/
default RankDO selectByDimensionTarget(String dimension, Long targetId) {
return selectOne(new LambdaQueryWrapperX<RankDO>()
.eq(RankDO::getDimension, dimension)
.eq(RankDO::getTargetId, targetId));
}
/**
* 原子累加 score(防并发丢更新):单语句 UPDATE…SET score=score+#{delta},行级原子累加。
*
* 注:
* 1) 原生 @Update 绕过 MyBatis-Plus 字段自动填充,故显式 SET updater='0'(系统身份写,防 updater 残留旧值;
* update_time 由 DDL ON UPDATE CURRENT_TIMESTAMP 自动刷新);
* 2) WHERE 含 deleted=0,避免命中逻辑删除行;
* 3) 调用前提:记录已存在(首次初始化由 Service 先 insert 兜底)。
*
* @param dimension 排行维度
* @param targetId 排行对象 ID
* @param delta 分值增量(可负)
* @return 受影响行数(1=累加成功;0=记录不存在,调用方据此走初始化兜底)
*/
@Update("UPDATE game_community_rank "
+ "SET score = score + #{delta}, updater = '0' "
+ "WHERE dimension = #{dimension} AND target_id = #{targetId} AND deleted = 0")
int incrScore(@Param("dimension") String dimension, @Param("targetId") Long targetId, @Param("delta") long delta);
/**
* 某维度按 score 降序取 TopN(ZSET 重建/缺失回填的权威源)
*
* 显式不裸 select*;按 (dimension, score) 复合索引降序,limit N。
*
* @param dimension 排行维度
* @param limit 取前 N 条
* @return 排行 DO 列表(score 降序)
*/
default List<RankDO> selectTopByDimension(String dimension, int limit) {
return selectList(new LambdaQueryWrapperX<RankDO>()
.eq(RankDO::getDimension, dimension)
.orderByDesc(RankDO::getScore)
.last("LIMIT " + limit));
}
}

View File

@ -0,0 +1,58 @@
package com.wanxiang.huijing.game.module.community.service.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentCreateReqVO;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentPageReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
/**
* 社区评论 Service(U3 R-SOC 评论:CRUD + 举报转 compliance)
*
* @author 造梦AI
*/
public interface CommentService {
/**
* 发表评论
*
* @param reqVO 评论入参(target + content)
* @param userId 作者用户 ID(token 解析,非前端入参)
* @return 新评论 ID
*/
Long createComment(CommentCreateReqVO reqVO, Long userId);
/**
* 删除评论(归属校验:仅作者本人可删,防越权删他人评论)
*
* @param id 评论 ID
* @param userId 当前登录用户 ID
*/
void deleteComment(Long id, Long userId);
/**
* 目标维度评论分页(公开可见,按 target 过滤)
*
* @param reqVO 分页 + target 过滤
* @return 分页结果
*/
PageResult<CommentDO> getCommentPage(CommentPageReqVO reqVO);
/**
* 某目标评论总数(公开计数)
*
* @param targetType 目标类型
* @param targetId 目标 ID
* @return 评论数
*/
Long getCommentCount(Integer targetType, Long targetId);
/**
* 举报评论:落举报态 + 转 compliance 受理(事务提交后,本端不裁决)
*
* @param id 被举报评论 ID
* @param reason 举报理由(必填)
* @param userId 举报人用户 ID(token 解析)
*/
void reportComment(Long id, String reason, Long userId);
}

View File

@ -0,0 +1,145 @@
package com.wanxiang.huijing.game.module.community.service.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentCreateReqVO;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentPageReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.comment.CommentMapper;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import java.util.Objects;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_COMMENT_CONTENT_BLANK;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_COMMENT_NOT_EXISTS;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_COMMENT_NOT_OWNER;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_COMMENT_REPORT_REASON_REQUIRED;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 社区评论 Service 实现(U3 R-SOC 评论)
*
* 业务规则在此承载、可单测、可追溯:
* ① 发表评论(内容非空校验);
* ② 删除评论(归属校验,防越权删他人评论 — PII/数据边界红线);
* ③ 目标维度公开列表/计数;
* ④ 举报评论:落 report_status + 转 compliance 受理(事务提交后,不裁决)。
*
* 举报转 compliance(重要约定):compliance 的 -api 当前仅暴露发布锁风门 evaluate,
* 【无举报受理入口】。故本波先落本端举报态 + 事务提交后 hook 留痕(明确接缝),待 compliance 提供受理 -api 后接通。
* 事务内禁止远程调用(§7)——故用 TransactionSynchronization.afterCommit 在提交后再发起(实际接入改为调 Feign / 发 MQ,须幂等 + 补偿)。
*
* @author 造梦AI
*/
@Slf4j
@Service
public class CommentServiceImpl implements CommentService {
@Resource
private CommentMapper commentMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createComment(CommentCreateReqVO reqVO, Long userId) {
// 内容非空校验(去首尾空白后判空,防纯空白评论)
String content = reqVO.getContent() == null ? "" : reqVO.getContent().trim();
if (content.isEmpty()) {
throw exception(COMMUNITY_COMMENT_CONTENT_BLANK);
}
CommentDO comment = new CommentDO();
comment.setTargetType(reqVO.getTargetType() == null ? 1 : reqVO.getTargetType());
comment.setTargetId(reqVO.getTargetId());
comment.setUserId(userId); // 业务归属=当前登录用户(token 解析,非前端入参)
comment.setContent(content);
comment.setParentId(reqVO.getParentId() == null ? 0L : reqVO.getParentId());
comment.setReportStatus(0); // 0=正常
commentMapper.insert(comment);
log.info("[createComment] 发表评论成功 commentId={}, userId={}, targetType={}, targetId={}",
comment.getId(), userId, comment.getTargetType(), comment.getTargetId());
return comment.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteComment(Long id, Long userId) {
CommentDO comment = commentMapper.selectByIdSafe(id);
if (comment == null) {
throw exception(COMMUNITY_COMMENT_NOT_EXISTS);
}
// 越权防护(数据边界红线):仅评论作者本人可删,防越权删他人评论
if (!Objects.equals(comment.getUserId(), userId)) {
log.error("[deleteComment] 越权删除被拦截 commentId={}, ownerUserId={}, operatorUserId={}",
id, comment.getUserId(), userId);
throw exception(COMMUNITY_COMMENT_NOT_OWNER);
}
commentMapper.deleteById(id); // 逻辑删除(deleted=1)
log.info("[deleteComment] 删除评论成功 commentId={}, userId={}", id, userId);
}
@Override
public PageResult<CommentDO> getCommentPage(CommentPageReqVO reqVO) {
// 公开列表:按 target 维度查询,不按 user 隔离
return commentMapper.selectTargetPage(reqVO);
}
@Override
public Long getCommentCount(Integer targetType, Long targetId) {
Long count = commentMapper.selectCountByTarget(targetType, targetId);
return count == null ? 0L : count;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reportComment(Long id, String reason, Long userId) {
// 举报理由必填(随信号转 compliance)
if (!StringUtils.hasText(reason)) {
throw exception(COMMUNITY_COMMENT_REPORT_REASON_REQUIRED);
}
CommentDO comment = commentMapper.selectByIdSafe(id);
if (comment == null) {
throw exception(COMMUNITY_COMMENT_NOT_EXISTS);
}
// 落本端举报态:0→1(幂等,已被举报则不重复写)
if (!Objects.equals(comment.getReportStatus(), 1)) {
CommentDO update = new CommentDO();
update.setId(id);
update.setReportStatus(1); // 1=被举报
commentMapper.updateById(update);
}
log.info("[reportComment] 评论举报已落本端举报态 commentId={}, reporterUserId={}, reason={}", id, userId, reason);
// 事务提交后转 compliance 受理(事务内禁止远程调用 §7)
registerComplianceIntakeAfterCommit(id, comment.getTargetId(), userId, reason);
}
/**
* 在事务提交后转 compliance 受理(接缝桩,OPEN ITEM)。
*
* 现状:compliance 的 -api 仅有发布锁风门 evaluate,无举报受理入口 → 此处仅留痕,未真正调用。
* 接通方案(待 compliance 交付举报受理 -api,如 ComplianceReportApi.intake(...)):
* 在 afterCommit 内经 -api 同进程 @Primary 调用提交受理,并保证幂等(同 commentId 重复举报去重)+ 失败补偿(重试/落待办表)。
* 用 afterCommit 而非事务内直调:避免远程调用占用 DB 事务、避免举报受理失败回滚已落的本端举报态。
*/
private void registerComplianceIntakeAfterCommit(Long commentId, Long targetId, Long reporterUserId, String reason) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// 无活动事务(理论不至,@Transactional 已开):直接留痕,避免静默丢举报
log.warn("[reportComment] 无活动事务,compliance 受理接缝直接留痕 commentId={}", commentId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// TODO 跨模块对接 compliance:commentId/targetId/reporterUserId/reason 转 compliance 举报受理(-api)。
// compliance 当前无举报受理 -api,本波留痕接缝;接通后改为经 -api 调用 + 幂等 + 补偿。
log.info("[reportComment][afterCommit] 待转 compliance 受理(接缝桩,OPEN ITEM):"
+ "commentId={}, targetId={}, reporterUserId={}, reason={}",
commentId, targetId, reporterUserId, reason);
}
});
}
}

View File

@ -0,0 +1,91 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 弹幕房间注册表(U3 R-SOC 弹幕:room=gameId → sessionId 集合)
*
* 为何需要:框架 WebSocketMessageSender.send 是「按用户(userType,userId)」维度推送,
* 而弹幕是「按房间(gameId)广播给该游戏所有在线观众」——这是框架没有的房间维度。
* 故在此自维护 gameId→sessionId 注册表,广播时据此取会话 doSend(见 DanmakuServiceImpl)。
*
* 单实例边界:本注册表为进程内 ConcurrentHashMap,仅单机生效。
* 多实例需切 huijing.websocket.sender-type=redis 由框架跨实例转发(MQ future-state 未部署),
* 届时房间维度广播需改造为跨实例(如 Redis 维护房间成员 + pub/sub),本波不实现。
*
* @author 造梦AI
*/
@Slf4j
@Component
public class DanmakuRoomRegistry {
/** 房间映射:key=gameId,value=该房间内的 WebSocket sessionId 集合 */
private final ConcurrentHashMap<Long, Set<String>> roomSessions = new ConcurrentHashMap<>();
/**
* 会话加入房间(进房/开播时调用)
*
* @param gameId 游戏房间 ID
* @param sessionId WebSocket 会话 ID
*/
public void join(Long gameId, String sessionId) {
if (gameId == null || sessionId == null) {
return;
}
roomSessions.computeIfAbsent(gameId, k -> Collections.newSetFromMap(new ConcurrentHashMap<>())).add(sessionId);
log.info("[DanmakuRoom] 会话加入房间 gameId={}, sessionId={}", gameId, sessionId);
}
/**
* 会话离开指定房间(退房时调用)
*
* @param gameId 游戏房间 ID
* @param sessionId WebSocket 会话 ID
*/
public void leave(Long gameId, String sessionId) {
if (gameId == null || sessionId == null) {
return;
}
Set<String> sessions = roomSessions.get(gameId);
if (sessions != null) {
sessions.remove(sessionId);
if (sessions.isEmpty()) {
roomSessions.remove(gameId, sessions);
}
}
}
/**
* 会话从所有房间移除(连接断开时调用,防泄漏残留会话)
*
* @param sessionId WebSocket 会话 ID
*/
public void removeSession(String sessionId) {
if (sessionId == null) {
return;
}
roomSessions.forEach((gameId, sessions) -> {
sessions.remove(sessionId);
if (sessions.isEmpty()) {
roomSessions.remove(gameId, sessions);
}
});
}
/**
* 取某房间的会话 ID 集合(广播用;返回快照副本,避免并发修改)
*
* @param gameId 游戏房间 ID
* @return sessionId 集合快照(无成员返回空集)
*/
public Set<String> getRoomSessionIds(Long gameId) {
Set<String> sessions = roomSessions.get(gameId);
return sessions == null ? Collections.emptySet() : Set.copyOf(sessions);
}
}

View File

@ -0,0 +1,33 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuSendReqVO;
import java.util.List;
/**
* 社区弹幕 Service(U3 R-SOC 弹幕:落库 + WebSocket 房间广播)
*
* @author 造梦AI
*/
public interface DanmakuService {
/**
* 发送弹幕:落库(可回放)+ 广播给房间(gameId)内所有在线会话。
*
* @param reqVO 弹幕入参(gameId/content/playTime)
* @param userId 发送者用户 ID(token 解析,非前端入参)
* @return 已落库的弹幕(同时是广播载荷)
*/
DanmakuRespVO sendDanmaku(DanmakuSendReqVO reqVO, Long userId);
/**
* 某游戏最近 N 条弹幕(进房补帧 / 回放,按时间正序返回)
*
* @param gameId 游戏 ID(房间)
* @param limit 取近 N 条
* @return 弹幕列表(时间正序)
*/
List<DanmakuRespVO> getRecentDanmaku(Long gameId, int limit);
}

View File

@ -0,0 +1,141 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuSendReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.danmaku.DanmakuDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.danmaku.DanmakuMapper;
import com.wanxiang.huijing.framework.common.util.json.JsonUtils;
import com.wanxiang.huijing.framework.websocket.core.sender.AbstractWebSocketMessageSender;
import com.wanxiang.huijing.framework.websocket.core.session.WebSocketSessionManager;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.WebSocketSession;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_DANMAKU_CONTENT_BLANK;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 社区弹幕 Service 实现(U3 R-SOC 弹幕)
*
* 发送 = 落库(可回放)+ 房间广播。
* 广播机制:弹幕是「房间(gameId)维度广播」,框架 sender 只按用户维度推送,故借房间注册表
* {@link DanmakuRoomRegistry} 取房间会话,复用 {@link AbstractWebSocketMessageSender#doSend} 批量下发。
* 消息类型固定 "danmaku",content = DanmakuRespVO 的 JSON(JsonUtils 序列化,绝不用 Map.toString)。
*
* 单实例边界:LocalWebSocketMessageSender + 进程内房间注册表仅单机生效;
* 多实例需切 huijing.websocket.sender-type=redis(MQ future-state 未部署),届时房间广播需跨实例改造,本波不实现。
*
* @author 造梦AI
*/
@Slf4j
@Service
public class DanmakuServiceImpl implements DanmakuService {
/** WebSocket 弹幕消息类型(前后端约定,前端按此 type 渲染弹幕轨道) */
public static final String WS_TYPE_DANMAKU = "danmaku";
@Resource
private DanmakuMapper danmakuMapper;
@Resource
private DanmakuRoomRegistry roomRegistry;
/** 会话管理器(按 sessionId 取会话以广播;框架默认单机实现 WebSocketSessionManagerImpl) */
@Resource
private WebSocketSessionManager sessionManager;
/**
* WebSocket 消息发送器(默认 LocalWebSocketMessageSender,单机)。
* 用其 public doSend(sessions,type,content) 向房间会话集合批量下发;房间维度由本类自取会话。
*/
@Resource
private AbstractWebSocketMessageSender webSocketMessageSender;
@Override
@Transactional(rollbackFor = Exception.class)
public DanmakuRespVO sendDanmaku(DanmakuSendReqVO reqVO, Long userId) {
// 内容非空校验(去首尾空白)
String content = reqVO.getContent() == null ? "" : reqVO.getContent().trim();
if (content.isEmpty()) {
throw exception(COMMUNITY_DANMAKU_CONTENT_BLANK);
}
// 落库(可回放)
DanmakuDO danmaku = new DanmakuDO();
danmaku.setGameId(reqVO.getGameId());
danmaku.setUserId(userId); // 发送者=当前登录用户(token 解析,非前端入参)
danmaku.setContent(content);
danmaku.setPlayTime(reqVO.getPlayTime() == null ? 0 : reqVO.getPlayTime());
danmakuMapper.insert(danmaku);
log.info("[sendDanmaku] 弹幕落库成功 danmakuId={}, gameId={}, userId={}", danmaku.getId(), reqVO.getGameId(), userId);
// 组装广播载荷并下发房间
DanmakuRespVO vo = toRespVO(danmaku);
broadcastToRoom(reqVO.getGameId(), vo);
return vo;
}
@Override
public List<DanmakuRespVO> getRecentDanmaku(Long gameId, int limit) {
int safeLimit = limit <= 0 ? 50 : limit;
// 拷入可变 list 再翻转(不假设 Mapper 返回 list 可变):Mapper 取 id 倒序近段,翻为时间正序返回(前端按序补帧)
List<DanmakuDO> rows = new ArrayList<>(danmakuMapper.selectRecentByGame(gameId, safeLimit));
Collections.reverse(rows);
List<DanmakuRespVO> result = new ArrayList<>(rows.size());
for (DanmakuDO row : rows) {
result.add(toRespVO(row));
}
return result;
}
/**
* 向房间(gameId)内所有在线会话广播一条弹幕。
*
* 复用框架 doSend:从房间注册表取 sessionId,经 sessionManager 解析为活动会话,批量下发。
* 广播失败不影响弹幕已落库(弹幕主链路 = 落库成功;推送为尽力而为,断连会话由框架/注册表清理)。
*
* @param gameId 房间
* @param vo 广播载荷
*/
private void broadcastToRoom(Long gameId, DanmakuRespVO vo) {
Set<String> sessionIds = roomRegistry.getRoomSessionIds(gameId);
if (sessionIds.isEmpty()) {
log.info("[sendDanmaku] 房间无在线会话,仅落库不广播 gameId={}", gameId);
return;
}
List<WebSocketSession> sessions = new ArrayList<>(sessionIds.size());
for (String sessionId : sessionIds) {
WebSocketSession session = sessionManager.getSession(sessionId);
if (session != null && session.isOpen()) {
sessions.add(session);
} else {
// 懒清理:会话已断开/不存在(框架无回调通知房间注册表),广播时顺手剔除残留 sessionId 防泄漏
roomRegistry.leave(gameId, sessionId);
}
}
if (sessions.isEmpty()) {
return;
}
// content 用 JsonUtils 序列化(禁 Map.toString);doSend 内再包一层 JsonWebSocketMessage{type,content}
webSocketMessageSender.doSend(sessions, WS_TYPE_DANMAKU, JsonUtils.toJsonString(vo));
log.info("[sendDanmaku] 弹幕已广播 gameId={}, danmakuId={}, sessions={}", gameId, vo.getId(), sessions.size());
}
/** DO → RespVO(同时复用为 WS 广播载荷) */
private DanmakuRespVO toRespVO(DanmakuDO danmaku) {
DanmakuRespVO vo = new DanmakuRespVO();
vo.setId(danmaku.getId());
vo.setGameId(danmaku.getGameId());
vo.setUserId(danmaku.getUserId());
vo.setContent(danmaku.getContent());
vo.setPlayTime(danmaku.getPlayTime());
vo.setCreateTime(danmaku.getCreateTime());
return vo;
}
}

View File

@ -0,0 +1,79 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.message.DanmakuWebSocketMessage;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuSendReqVO;
import com.wanxiang.huijing.framework.websocket.core.listener.WebSocketMessageListener;
import com.wanxiang.huijing.framework.websocket.core.util.WebSocketFrameworkUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
/**
* 弹幕 WebSocket 入站监听器(U3 R-SOC 弹幕,type="danmaku")
*
* 前端经 WS 发 {type:"danmaku", content:DanmakuWebSocketMessage(JSON)},框架按 type 调度到此。
* 路由:join进房(注册房间)/ leave退房 / send发弹幕(落库 + 广播,与 HTTP 发送等价,便于纯 WS 客户端)。
* 身份:发送者从握手注入的会话身份取(WebSocketFrameworkUtils.getLoginUserId),非消息体入参(数据边界)。
*
* @author 造梦AI
*/
@Slf4j
@Component
public class DanmakuWebSocketMessageListener implements WebSocketMessageListener<DanmakuWebSocketMessage> {
/** 动作:进房 */
private static final String ACTION_JOIN = "join";
/** 动作:退房 */
private static final String ACTION_LEAVE = "leave";
/** 动作:发弹幕 */
private static final String ACTION_SEND = "send";
@Resource
private DanmakuRoomRegistry roomRegistry;
@Resource
private DanmakuService danmakuService;
@Override
public void onMessage(WebSocketSession session, DanmakuWebSocketMessage message) {
if (message == null || message.getGameId() == null) {
log.warn("[danmaku-ws] 入站消息缺 gameId,忽略 sessionId={}", session.getId());
return;
}
String action = message.getAction() == null ? "" : message.getAction();
switch (action) {
case ACTION_JOIN:
// 进房:当前会话加入房间,开始接收该房间弹幕广播
roomRegistry.join(message.getGameId(), session.getId());
break;
case ACTION_LEAVE:
roomRegistry.leave(message.getGameId(), session.getId());
break;
case ACTION_SEND:
// 发弹幕:身份取自会话(握手注入),非消息体入参(防伪冒他人)
Long userId = WebSocketFrameworkUtils.getLoginUserId(session);
if (userId == null) {
log.warn("[danmaku-ws] 未登录会话发弹幕被拒 sessionId={}, gameId={}", session.getId(), message.getGameId());
return;
}
// 发送方应已进房;兜底确保在房间内(避免发了却收不到自己弹幕)
roomRegistry.join(message.getGameId(), session.getId());
DanmakuSendReqVO reqVO = new DanmakuSendReqVO();
reqVO.setGameId(message.getGameId());
reqVO.setContent(message.getContent());
reqVO.setPlayTime(message.getPlayTime());
// 委托 Service:落库 + 房间广播(含发送者自己)
danmakuService.sendDanmaku(reqVO, userId);
break;
default:
log.warn("[danmaku-ws] 未知 action={},忽略 sessionId={}", action, session.getId());
}
}
@Override
public String getType() {
return DanmakuServiceImpl.WS_TYPE_DANMAKU; // "danmaku"
}
}

View File

@ -0,0 +1,32 @@
package com.wanxiang.huijing.game.module.community.service.follow;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowResultRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowStatRespVO;
/**
* 社区关注 Service(U3 R-SOC 关注:软开关幂等切换,不删行)
*
* @author 造梦AI
*/
public interface FollowService {
/**
* 关注/取关软切换(幂等):唯一键命中改 active,未命中插;反复关/取关只切 active。
*
* @param targetUserId 被关注方用户 ID
* @param follow true=关注 false=取关
* @param userId 发起方用户 ID(token 解析,非前端入参)
* @return 切换后关注态 + 被关注方最新粉丝数
*/
FollowResultRespVO toggleFollow(Long targetUserId, boolean follow, Long userId);
/**
* 关注统计(粉丝数 + 关注数 + 当前登录者是否已关注 ta)
*
* @param userId 查询的用户 ID
* @param currentUserId 当前登录用户 ID(null=未登录,followedByMe 恒 false)
* @return 统计结果
*/
FollowStatRespVO getFollowStat(Long userId, Long currentUserId);
}

View File

@ -0,0 +1,95 @@
package com.wanxiang.huijing.game.module.community.service.follow;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowResultRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowStatRespVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.follow.FollowDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.follow.FollowMapper;
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 java.util.Objects;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_FOLLOW_SELF_NOT_ALLOWED;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 社区关注 Service 实现(U3 R-SOC 关注)
*
* 软开关幂等切换(范本 = feed FeedServiceImpl.interact):唯一键 (user_id,target_user_id) 命中则改 active,
* 未命中则插;取关=反激活 active=0(不删行)。计数 = countActive。
* 并发安全(follow CAS):先查后插,并发双插由 DB 唯一键 uk_user_target 裁决(一方 INSERT,一方抛
* DuplicateKeyException),catch 后回查改 active 即可(与黄金路径同语义,幂等)。
*
* @author 造梦AI
*/
@Slf4j
@Service
public class FollowServiceImpl implements FollowService {
@Resource
private FollowMapper followMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public FollowResultRespVO toggleFollow(Long targetUserId, boolean follow, Long userId) {
// 不能关注自己(业务边界)
if (Objects.equals(targetUserId, userId)) {
throw exception(COMMUNITY_FOLLOW_SELF_NOT_ALLOWED);
}
int active = follow ? 1 : 0;
// 幂等软切换:命中唯一键改 active,未命中插(并发双插由 uk_user_target 裁决)
FollowDO existing = followMapper.selectByUserTarget(userId, targetUserId);
if (existing == null) {
FollowDO row = new FollowDO();
row.setUserId(userId); // 业务归属=当前登录用户(token 解析,非前端入参)
row.setTargetUserId(targetUserId);
row.setActive(active);
try {
followMapper.insert(row);
} catch (DuplicateKeyException e) {
// 并发双插:另一线程已建行,回查改 active(CAS 幂等收敛)
log.info("[toggleFollow] 并发双插命中唯一键,回查改 active userId={}, targetUserId={}", userId, targetUserId);
FollowDO concurrent = followMapper.selectByUserTarget(userId, targetUserId);
if (concurrent != null && !Objects.equals(concurrent.getActive(), active)) {
FollowDO update = new FollowDO();
update.setId(concurrent.getId());
update.setActive(active);
followMapper.updateById(update);
}
}
} else if (!Objects.equals(existing.getActive(), active)) {
// 态变更才写(幂等:重复关/重复取关同态不写)
FollowDO update = new FollowDO();
update.setId(existing.getId());
update.setActive(active);
followMapper.updateById(update);
}
log.info("[toggleFollow] 关注软切换完成 userId={}, targetUserId={}, follow={}", userId, targetUserId, follow);
// 回显最新粉丝数(active 计数)
FollowResultRespVO result = new FollowResultRespVO();
result.setTargetUserId(targetUserId);
result.setFollowing(follow);
result.setFollowerCount(followMapper.selectFollowerCount(targetUserId));
return result;
}
@Override
public FollowStatRespVO getFollowStat(Long userId, Long currentUserId) {
FollowStatRespVO stat = new FollowStatRespVO();
stat.setUserId(userId);
stat.setFollowerCount(followMapper.selectFollowerCount(userId));
stat.setFollowingCount(followMapper.selectFollowingCount(userId));
// 当前登录者是否已 active 关注 ta(未登录则恒 false)
boolean followedByMe = false;
if (currentUserId != null && !Objects.equals(currentUserId, userId)) {
FollowDO mine = followMapper.selectByUserTarget(currentUserId, userId);
followedByMe = mine != null && Objects.equals(mine.getActive(), 1);
}
stat.setFollowedByMe(followedByMe);
return stat;
}
}

View File

@ -0,0 +1,49 @@
package com.wanxiang.huijing.game.module.community.service.rank;
import com.wanxiang.huijing.game.module.community.controller.app.rank.vo.RankItemRespVO;
import java.util.List;
/**
* 社区排行 Service(U3 R-SOC 排行:Redis ZSET + ZSET↔DB 一致性窗口)
*
* 一致性约定(DB=真相、ZSET=缓存索引):
* - 写穿透:分值变更在同一流程内【双写】DB 本行(权威)+ ZSET(索引),两者最终一致;
* - 回填重建:ZSET 缺失/启动/周期对账时由 DB selectTop 回填 ZSET(rebuild),消除缓存丢失;
* - 一致性窗口:DB 与 ZSET 在双写之间存在极短不一致窗口(最终一致);幂等 ≠ 一致性,两者分别处理。
*
* @author 造梦AI
*/
public interface RankService {
/**
* 分值变更(写穿透):DB 原子累加(权威)+ ZSET incrementScore(索引),同流程双写。
*
* 调用方 = 排行事件源(如游戏被播放/点赞→game_hot 维度累加)。系统身份写(updater=0)。
*
* @param dimension 排行维度(须为已定义维度,否则抛维度非法)
* @param targetId 排行对象 ID
* @param delta 分值增量(可负)
*/
void addScore(String dimension, Long targetId, long delta);
/**
* 查 TopN 榜单:优先读 ZSET(快路径);ZSET 缺失(缓存丢失/未预热)则从 DB 回填重建 ZSET 后再读(兜底)。
*
* @param dimension 排行维度(须为已定义维度)
* @param topN 取前 N 名
* @return 榜单(rank/targetId/score,按分值降序)
*/
List<RankItemRespVO> getTopN(String dimension, int topN);
/**
* 从 DB 权威源全量回填重建某维度 ZSET(启动预热 / 周期对账 / 缓存丢失兜底)。
*
* 重建语义:以 DB 为准重写 ZSET(先删旧 key 再按 DB TopN 写入),消除 ZSET 与 DB 的漂移。
*
* @param dimension 排行维度
* @return 回填的成员数
*/
int rebuildFromDb(String dimension);
}

View File

@ -0,0 +1,156 @@
package com.wanxiang.huijing.game.module.community.service.rank;
import com.wanxiang.huijing.game.module.community.controller.app.rank.vo.RankItemRespVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.rank.RankDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.rank.RankMapper;
import com.wanxiang.huijing.game.module.community.enums.RankDimensionEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_RANK_DIMENSION_INVALID;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 社区排行 Service 实现(U3 R-SOC 排行:Redis ZSET + ZSET↔DB 一致性窗口)
*
* ============================ ZSET↔DB 一致性设计(核心) ============================
* 角色:DB game_community_rank = 真相(source of truth);Redis ZSET community:rank:{dimension} = 缓存/索引。
* 1) 写穿透(addScore):分值变更在同一 Service 流程内【双写】——
* 先 DB 原子累加 score(@Transactional 保证 DB 侧;首次 insert 兜底,并发双插由 uk 裁决),
* 再 ZSET incrementScore 同步索引。DB 提交后 ZSET 已写,二者最终一致。
* 2) 一致性窗口(最终一致):DB 与 ZSET 在「DB 提交 → ZSET 写入」之间存在极短窗口不一致;
* 若进程在两步之间崩溃,ZSET 会落后于 DB —— 由 (3) 的回填对账修复(DB 权威,ZSET 向 DB 收敛)。
* 3) 回填重建(rebuildFromDb):ZSET 缺失(缓存丢失/未预热)/启动/周期对账时,以 DB TopN 重写 ZSET,
* 消除漂移。getTopN 读 ZSET 为空时自动触发回填兜底,保证读路径不空窗。
* 注意:幂等 ≠ 一致性。幂等(重复写不重复落)由 DB 唯一键保证;一致性(ZSET 跟 DB 对齐)由双写 + 回填保证,二者分别处理。
* ===================================================================================
*
* @author 造梦AI
*/
@Slf4j
@Service
public class RankServiceImpl implements RankService {
/** ZSET key 前缀:community:rank:{dimension} */
private static final String ZSET_KEY_PREFIX = "community:rank:";
/** 回填 / 默认查询的 TopN 上限(ZSET 只保留热榜前若干,全量权威在 DB) */
private static final int REBUILD_TOP_LIMIT = 500;
@Resource
private RankMapper rankMapper;
/** 来自 huijing-spring-boot-starter-redis 的 RedisTemplate(JSON 值序列化),ZSET 操作载体 */
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
@Transactional(rollbackFor = Exception.class)
public void addScore(String dimension, Long targetId, long delta) {
validateDimension(dimension);
// —— 第一写(权威):DB 原子累加;记录不存在则 insert 初始化兜底(并发双插由 uk 裁决再累加)——
int affected = rankMapper.incrScore(dimension, targetId, delta);
if (affected == 0) {
ensureRankRow(dimension, targetId, delta);
}
// —— 第二写(索引):ZSET 同步累加,与 DB 同向(最终一致)——
// 说明:Redis 非事务参与者;此处在 DB 写之后同流程执行,DB 提交后 ZSET 即生效。
// 若两步间崩溃致 ZSET 落后,由 rebuildFromDb 对账向 DB 收敛(DB 权威)。
redisTemplate.opsForZSet().incrementScore(buildKey(dimension), String.valueOf(targetId), (double) delta);
log.info("[addScore] 排行双写完成(DB 权威 + ZSET 索引)dimension={}, targetId={}, delta={}", dimension, targetId, delta);
}
@Override
public List<RankItemRespVO> getTopN(String dimension, int topN) {
validateDimension(dimension);
int safeTopN = topN <= 0 ? 10 : topN;
// 快路径:读 ZSET 降序(含分值)
Set<ZSetOperations.TypedTuple<Object>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(buildKey(dimension), 0, safeTopN - 1);
// 缓存缺失(未预热/丢失):从 DB 回填重建后再读(读路径兜底,不空窗)
if (tuples == null || tuples.isEmpty()) {
log.info("[getTopN] ZSET 缺失,触发 DB 回填重建 dimension={}", dimension);
rebuildFromDb(dimension);
tuples = redisTemplate.opsForZSet().reverseRangeWithScores(buildKey(dimension), 0, safeTopN - 1);
}
return toRankItems(tuples);
}
@Override
public int rebuildFromDb(String dimension) {
validateDimension(dimension);
// 以 DB 权威 TopN 重写 ZSET:先删旧 key(消除已删/漂移残留),再批量写入
String key = buildKey(dimension);
redisTemplate.delete(key);
List<RankDO> rows = rankMapper.selectTopByDimension(dimension, REBUILD_TOP_LIMIT);
for (RankDO row : rows) {
redisTemplate.opsForZSet().add(key, String.valueOf(row.getTargetId()),
(double) (row.getScore() == null ? 0L : row.getScore()));
}
log.info("[rebuildFromDb] ZSET 已由 DB 权威回填重建 dimension={}, members={}", dimension, rows.size());
return rows.size();
}
// ============================== 内部方法 ==============================
/**
* 首次初始化排行行(幂等:uk_dimension_target 兜底)。
* 并发双插由 DB 唯一键裁决(一方 INSERT,一方抛 DuplicateKeyException),catch 后改走原子累加补上增量。
*/
private void ensureRankRow(String dimension, Long targetId, long delta) {
RankDO row = new RankDO();
row.setDimension(dimension);
row.setTargetId(targetId);
row.setScore(delta); // 首条直接落初始增量
try {
rankMapper.insert(row);
log.info("[ensureRankRow] 排行行首次初始化 dimension={}, targetId={}, score={}", dimension, targetId, delta);
} catch (DuplicateKeyException e) {
// 并发初始化:另一线程已建行,补一次原子累加把本次 delta 加上(避免丢更新)
log.info("[ensureRankRow] 并发初始化命中唯一键,改走原子累加 dimension={}, targetId={}", dimension, targetId);
rankMapper.incrScore(dimension, targetId, delta);
}
}
/** 维度合法性校验(须为已定义维度,避免脏 key 污染 Redis) */
private void validateDimension(String dimension) {
if (RankDimensionEnum.of(dimension) == null) {
throw exception(COMMUNITY_RANK_DIMENSION_INVALID);
}
}
/** 拼 ZSET key:community:rank:{dimension} */
private String buildKey(String dimension) {
return ZSET_KEY_PREFIX + dimension;
}
/** ZSET 降序元组 → 榜单 VO(解析 member 为 targetId,score 取整) */
private List<RankItemRespVO> toRankItems(Set<ZSetOperations.TypedTuple<Object>> tuples) {
List<RankItemRespVO> result = new ArrayList<>();
if (tuples == null) {
return result;
}
int rank = 1;
for (ZSetOperations.TypedTuple<Object> tuple : tuples) {
Object member = tuple.getValue();
if (member == null) {
continue;
}
RankItemRespVO item = new RankItemRespVO();
item.setRank(rank++);
item.setTargetId(Long.valueOf(String.valueOf(member)));
item.setScore(tuple.getScore() == null ? 0L : (long) Math.floor(tuple.getScore()));
result.add(item);
}
return result;
}
}

View File

@ -0,0 +1,155 @@
package com.wanxiang.huijing.game.module.community.service.comment;
import com.wanxiang.huijing.game.module.community.controller.app.comment.vo.CommentCreateReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.comment.CommentDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.comment.CommentMapper;
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.community.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* {@link CommentServiceImpl} 单元测试(纯 Mockito,不依赖 DB)
*
* 覆盖评论核心规则(U3 R-SOC):发表内容非空、删除归属校验(防越权删他人评论 — PII/数据边界红线)、
* 举报理由必填 + 落举报态幂等 + 转 compliance 接缝。归属隔离 enforce 须有测试兜底。
*
* @author 造梦AI
*/
class CommentServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private CommentServiceImpl commentService;
@Mock
private CommentMapper commentMapper;
// ============================== 发表评论 ==============================
@Test
void testCreateComment_success() {
CommentCreateReqVO reqVO = new CommentCreateReqVO();
reqVO.setTargetType(1);
reqVO.setTargetId(1024L);
reqVO.setContent("好玩");
reqVO.setParentId(0L);
commentService.createComment(reqVO, 99L);
ArgumentCaptor<CommentDO> captor = ArgumentCaptor.forClass(CommentDO.class);
verify(commentMapper).insert(captor.capture());
CommentDO saved = captor.getValue();
assertEquals(99L, saved.getUserId()); // 业务归属=入参 userId(token 解析)
assertEquals(1024L, saved.getTargetId());
assertEquals("好玩", saved.getContent());
assertEquals(0, saved.getReportStatus()); // 初始正常态
}
@Test
void testCreateComment_blankContentRejected() {
CommentCreateReqVO reqVO = new CommentCreateReqVO();
reqVO.setTargetId(1024L);
reqVO.setContent(" "); // 纯空白
ServiceException ex = assertThrows(ServiceException.class,
() -> commentService.createComment(reqVO, 99L));
assertEquals(COMMUNITY_COMMENT_CONTENT_BLANK.getCode(), ex.getCode());
verify(commentMapper, never()).insert(any(CommentDO.class));
}
// ============================== 删除评论(归属校验) ==============================
@Test
void testDeleteComment_ownerSucceeds() {
CommentDO comment = ownedComment(1L, 99L, 0);
when(commentMapper.selectByIdSafe(1L)).thenReturn(comment);
commentService.deleteComment(1L, 99L); // 作者本人
verify(commentMapper).deleteById(1L);
}
@Test
void testDeleteComment_notOwnerRejected() {
// 越权防护:评论属他人(作者 11),当前用户 99 删除 → 拒
CommentDO comment = ownedComment(1L, 11L, 0);
when(commentMapper.selectByIdSafe(1L)).thenReturn(comment);
ServiceException ex = assertThrows(ServiceException.class,
() -> commentService.deleteComment(1L, 99L));
assertEquals(COMMUNITY_COMMENT_NOT_OWNER.getCode(), ex.getCode());
verify(commentMapper, never()).deleteById(any());
}
@Test
void testDeleteComment_notExistsRejected() {
when(commentMapper.selectByIdSafe(1L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> commentService.deleteComment(1L, 99L));
assertEquals(COMMUNITY_COMMENT_NOT_EXISTS.getCode(), ex.getCode());
}
// ============================== 举报评论 ==============================
@Test
void testReportComment_reasonRequired() {
ServiceException ex = assertThrows(ServiceException.class,
() -> commentService.reportComment(1L, " ", 99L));
assertEquals(COMMUNITY_COMMENT_REPORT_REASON_REQUIRED.getCode(), ex.getCode());
verify(commentMapper, never()).updateById(any(CommentDO.class));
}
@Test
void testReportComment_notExistsRejected() {
when(commentMapper.selectByIdSafe(1L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> commentService.reportComment(1L, "含违规", 99L));
assertEquals(COMMUNITY_COMMENT_NOT_EXISTS.getCode(), ex.getCode());
}
@Test
void testReportComment_marksReportStatus() {
CommentDO comment = ownedComment(1L, 11L, 0); // 正常态被举报
when(commentMapper.selectByIdSafe(1L)).thenReturn(comment);
commentService.reportComment(1L, "含违规", 99L);
ArgumentCaptor<CommentDO> captor = ArgumentCaptor.forClass(CommentDO.class);
verify(commentMapper).updateById(captor.capture());
assertEquals(1, captor.getValue().getReportStatus()); // 落举报态 1
}
@Test
void testReportComment_alreadyReportedIdempotent() {
CommentDO comment = ownedComment(1L, 11L, 1); // 已被举报
when(commentMapper.selectByIdSafe(1L)).thenReturn(comment);
commentService.reportComment(1L, "含违规", 99L);
// 幂等:已举报态不重复写
verify(commentMapper, never()).updateById(any(CommentDO.class));
}
// ============================== 测试夹具 ==============================
/** 构造归属指定作者、指定举报态的评论 */
private static CommentDO ownedComment(Long id, Long userId, Integer reportStatus) {
CommentDO comment = new CommentDO();
comment.setId(id);
comment.setUserId(userId);
comment.setTargetType(1);
comment.setTargetId(1024L);
comment.setReportStatus(reportStatus);
return comment;
}
}

View File

@ -0,0 +1,69 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link DanmakuRoomRegistry} 单元测试(纯 POJO,无依赖)
*
* 覆盖房间维度管理(U3 R-SOC 弹幕):进房/退房/断连清理/取房间会话快照。
*
* @author 造梦AI
*/
class DanmakuRoomRegistryTest {
private final DanmakuRoomRegistry registry = new DanmakuRoomRegistry();
@Test
void testJoinAndGet() {
registry.join(1024L, "s1");
registry.join(1024L, "s2");
registry.join(2048L, "s3");
assertEquals(Set.of("s1", "s2"), registry.getRoomSessionIds(1024L));
assertEquals(Set.of("s3"), registry.getRoomSessionIds(2048L));
assertTrue(registry.getRoomSessionIds(9999L).isEmpty()); // 空房间
}
@Test
void testLeaveRemovesAndCleansEmptyRoom() {
registry.join(1024L, "s1");
registry.leave(1024L, "s1");
assertTrue(registry.getRoomSessionIds(1024L).isEmpty());
}
@Test
void testRemoveSessionAcrossRooms() {
registry.join(1024L, "s1");
registry.join(2048L, "s1"); // 同会话在两个房间(理论场景)
registry.join(1024L, "s2");
registry.removeSession("s1"); // 断连:从所有房间移除
assertEquals(Set.of("s2"), registry.getRoomSessionIds(1024L));
assertTrue(registry.getRoomSessionIds(2048L).isEmpty());
}
@Test
void testGetReturnsSnapshotCopy() {
registry.join(1024L, "s1");
Set<String> snapshot = registry.getRoomSessionIds(1024L);
// 快照副本:对返回集合的修改不影响内部状态(且不抛 UnsupportedOperation 由 Set.copyOf 保证不可变)
assertThrows(UnsupportedOperationException.class, () -> snapshot.add("s2"));
assertEquals(Set.of("s1"), registry.getRoomSessionIds(1024L));
}
@Test
void testNullSafetyIgnored() {
registry.join(null, "s1");
registry.join(1024L, null);
registry.leave(null, null);
registry.removeSession(null);
// 不抛异常,且无脏数据
assertTrue(registry.getRoomSessionIds(1024L).isEmpty());
}
}

View File

@ -0,0 +1,142 @@
package com.wanxiang.huijing.game.module.community.service.danmaku;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.danmaku.vo.DanmakuSendReqVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.danmaku.DanmakuDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.danmaku.DanmakuMapper;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import com.wanxiang.huijing.framework.websocket.core.sender.AbstractWebSocketMessageSender;
import com.wanxiang.huijing.framework.websocket.core.session.WebSocketSessionManager;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.web.socket.WebSocketSession;
import java.util.List;
import java.util.Set;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_DANMAKU_CONTENT_BLANK;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link DanmakuServiceImpl} 单元测试(纯 Mockito)
*
* 覆盖弹幕核心规则(U3 R-SOC):内容非空、落库 + 房间广播、空房间仅落库不广播、回放时间正序。
* 广播复用框架 doSend,验证从房间注册表取会话后下发。
*
* @author 造梦AI
*/
class DanmakuServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private DanmakuServiceImpl danmakuService;
@Mock
private DanmakuMapper danmakuMapper;
@Mock
private DanmakuRoomRegistry roomRegistry;
@Mock
private WebSocketSessionManager sessionManager;
@Mock
private AbstractWebSocketMessageSender webSocketMessageSender;
// ============================== 内容非空 ==============================
@Test
void testSendDanmaku_blankContentRejected() {
DanmakuSendReqVO reqVO = new DanmakuSendReqVO();
reqVO.setGameId(1024L);
reqVO.setContent(" ");
ServiceException ex = assertThrows(ServiceException.class,
() -> danmakuService.sendDanmaku(reqVO, 99L));
assertEquals(COMMUNITY_DANMAKU_CONTENT_BLANK.getCode(), ex.getCode());
verify(danmakuMapper, never()).insert(any(DanmakuDO.class));
}
// ============================== 落库 + 房间广播 ==============================
@Test
void testSendDanmaku_persistsAndBroadcasts() {
DanmakuSendReqVO reqVO = new DanmakuSendReqVO();
reqVO.setGameId(1024L);
reqVO.setContent("前方高能");
reqVO.setPlayTime(0);
// 房间有一个打开的会话
WebSocketSession session = mock(WebSocketSession.class);
when(session.isOpen()).thenReturn(true);
when(roomRegistry.getRoomSessionIds(1024L)).thenReturn(Set.of("s1"));
when(sessionManager.getSession("s1")).thenReturn(session);
DanmakuRespVO vo = danmakuService.sendDanmaku(reqVO, 99L);
verify(danmakuMapper).insert(any(DanmakuDO.class)); // 落库
// 广播:复用框架 doSend,type=danmaku,会话非空
verify(webSocketMessageSender).doSend(argThat(s -> s.size() == 1),
eq(DanmakuServiceImpl.WS_TYPE_DANMAKU), anyString());
assertEquals("前方高能", vo.getContent());
assertEquals(99L, vo.getUserId());
}
@Test
void testSendDanmaku_emptyRoomNoBroadcast() {
DanmakuSendReqVO reqVO = new DanmakuSendReqVO();
reqVO.setGameId(1024L);
reqVO.setContent("有人吗");
when(roomRegistry.getRoomSessionIds(1024L)).thenReturn(Set.of());
danmakuService.sendDanmaku(reqVO, 99L);
verify(danmakuMapper).insert(any(DanmakuDO.class)); // 仍落库
verify(webSocketMessageSender, never()).doSend(any(), anyString(), anyString()); // 空房间不广播
}
@Test
void testSendDanmaku_staleSessionEvicted() {
// 房间登记的会话已断开(getSession 返回 null)→ 懒清理剔除 + 无可广播会话
DanmakuSendReqVO reqVO = new DanmakuSendReqVO();
reqVO.setGameId(1024L);
reqVO.setContent("test");
when(roomRegistry.getRoomSessionIds(1024L)).thenReturn(Set.of("stale"));
when(sessionManager.getSession("stale")).thenReturn(null);
danmakuService.sendDanmaku(reqVO, 99L);
verify(roomRegistry).leave(1024L, "stale"); // 懒清理残留 sessionId
verify(webSocketMessageSender, never()).doSend(any(), anyString(), anyString());
}
// ============================== 回放(时间正序) ==============================
@Test
void testGetRecentDanmaku_reversedToChronological() {
// Mapper 取 id 倒序近段([3,2,1])→ Service 翻为时间正序([1,2,3])
when(danmakuMapper.selectRecentByGame(eq(1024L), anyInt()))
.thenReturn(List.of(danmaku(3L), danmaku(2L), danmaku(1L)));
List<DanmakuRespVO> list = danmakuService.getRecentDanmaku(1024L, 50);
assertEquals(3, list.size());
assertEquals(1L, list.get(0).getId()); // 翻正序后最早在前
assertEquals(3L, list.get(2).getId());
}
// ============================== 测试夹具 ==============================
private static DanmakuDO danmaku(Long id) {
DanmakuDO d = new DanmakuDO();
d.setId(id);
d.setGameId(1024L);
d.setUserId(99L);
d.setContent("d" + id);
d.setPlayTime(0);
return d;
}
}

View File

@ -0,0 +1,165 @@
package com.wanxiang.huijing.game.module.community.service.follow;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowResultRespVO;
import com.wanxiang.huijing.game.module.community.controller.app.follow.vo.FollowStatRespVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.follow.FollowDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.follow.FollowMapper;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_FOLLOW_SELF_NOT_ALLOWED;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* {@link FollowServiceImpl} 单元测试(纯 Mockito,不依赖 DB)
*
* 覆盖关注核心规则(U3 R-SOC):不能关注自己、软开关幂等切换(首关 insert / 取关改 active / 同态不写)、
* 并发双插 CAS(DuplicateKeyException 回查改 active)、粉丝数/关注数统计、followedByMe。
*
* @author 造梦AI
*/
class FollowServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private FollowServiceImpl followService;
@Mock
private FollowMapper followMapper;
// ============================== 不能关注自己 ==============================
@Test
void testFollow_selfNotAllowed() {
ServiceException ex = assertThrows(ServiceException.class,
() -> followService.toggleFollow(99L, true, 99L));
assertEquals(COMMUNITY_FOLLOW_SELF_NOT_ALLOWED.getCode(), ex.getCode());
verify(followMapper, never()).insert(any(FollowDO.class));
}
// ============================== 首次关注(insert) ==============================
@Test
void testFollow_firstTimeInserts() {
when(followMapper.selectByUserTarget(99L, 88L)).thenReturn(null);
when(followMapper.selectFollowerCount(88L)).thenReturn(1L);
FollowResultRespVO result = followService.toggleFollow(88L, true, 99L);
ArgumentCaptor<FollowDO> captor = ArgumentCaptor.forClass(FollowDO.class);
verify(followMapper).insert(captor.capture());
assertEquals(99L, captor.getValue().getUserId());
assertEquals(88L, captor.getValue().getTargetUserId());
assertEquals(1, captor.getValue().getActive());
assertTrue(result.getFollowing());
assertEquals(1L, result.getFollowerCount());
}
// ============================== 取关(已关注 → 反激活 active=0) ==============================
@Test
void testUnfollow_softToggleToInactive() {
FollowDO existing = follow(1L, 99L, 88L, 1); // 已关注
when(followMapper.selectByUserTarget(99L, 88L)).thenReturn(existing);
when(followMapper.selectFollowerCount(88L)).thenReturn(0L);
FollowResultRespVO result = followService.toggleFollow(88L, false, 99L);
ArgumentCaptor<FollowDO> captor = ArgumentCaptor.forClass(FollowDO.class);
verify(followMapper).updateById(captor.capture());
assertEquals(0, captor.getValue().getActive()); // 反激活不删行
assertFalse(result.getFollowing());
}
// ============================== 幂等:重复关注同态不写 ==============================
@Test
void testFollow_sameStateIdempotentNoWrite() {
FollowDO existing = follow(1L, 99L, 88L, 1); // 已是关注态
when(followMapper.selectByUserTarget(99L, 88L)).thenReturn(existing);
when(followMapper.selectFollowerCount(88L)).thenReturn(5L);
followService.toggleFollow(88L, true, 99L); // 再次关注
verify(followMapper, never()).updateById(any(FollowDO.class));
verify(followMapper, never()).insert(any(FollowDO.class));
}
// ============================== 并发双插 CAS(DuplicateKeyException 回查改 active) ==============================
@Test
void testFollow_concurrentInsertCasRecovers() {
// 第一次查无 → 尝试 insert 抛唯一键冲突(并发已被别线程插入 active=0)→ 回查改 active=1
when(followMapper.selectByUserTarget(99L, 88L))
.thenReturn(null) // 首查无
.thenReturn(follow(1L, 99L, 88L, 0)); // 回查得并发行(active=0)
doThrow(new DuplicateKeyException("uk_user_target")).when(followMapper).insert(any(FollowDO.class));
when(followMapper.selectFollowerCount(88L)).thenReturn(1L);
FollowResultRespVO result = followService.toggleFollow(88L, true, 99L);
// CAS 收敛:回查后改 active=1
ArgumentCaptor<FollowDO> captor = ArgumentCaptor.forClass(FollowDO.class);
verify(followMapper).updateById(captor.capture());
assertEquals(1, captor.getValue().getActive());
assertTrue(result.getFollowing());
}
@Test
void testFollow_concurrentInsertCasSameStateNoUpdate() {
// 并发行已是目标态(active=1),回查后无需再写
when(followMapper.selectByUserTarget(99L, 88L))
.thenReturn(null)
.thenReturn(follow(1L, 99L, 88L, 1));
doThrow(new DuplicateKeyException("uk_user_target")).when(followMapper).insert(any(FollowDO.class));
when(followMapper.selectFollowerCount(88L)).thenReturn(1L);
followService.toggleFollow(88L, true, 99L);
verify(followMapper, never()).updateById(any(FollowDO.class)); // 同态不重复写
}
// ============================== 关注统计 ==============================
@Test
void testGetFollowStat_countsAndFollowedByMe() {
when(followMapper.selectFollowerCount(88L)).thenReturn(1000L);
when(followMapper.selectFollowingCount(88L)).thenReturn(50L);
when(followMapper.selectByUserTarget(99L, 88L)).thenReturn(follow(1L, 99L, 88L, 1));
FollowStatRespVO stat = followService.getFollowStat(88L, 99L);
assertEquals(1000L, stat.getFollowerCount());
assertEquals(50L, stat.getFollowingCount());
assertTrue(stat.getFollowedByMe());
}
@Test
void testGetFollowStat_anonymousFollowedByMeFalse() {
when(followMapper.selectFollowerCount(88L)).thenReturn(1000L);
when(followMapper.selectFollowingCount(88L)).thenReturn(50L);
FollowStatRespVO stat = followService.getFollowStat(88L, null); // 未登录
assertFalse(stat.getFollowedByMe());
verify(followMapper, never()).selectByUserTarget(any(), any());
}
// ============================== 测试夹具 ==============================
private static FollowDO follow(Long id, Long userId, Long targetUserId, Integer active) {
FollowDO f = new FollowDO();
f.setId(id);
f.setUserId(userId);
f.setTargetUserId(targetUserId);
f.setActive(active);
return f;
}
}

View File

@ -0,0 +1,200 @@
package com.wanxiang.huijing.game.module.community.service.rank;
import com.wanxiang.huijing.game.module.community.controller.app.rank.vo.RankItemRespVO;
import com.wanxiang.huijing.game.module.community.dal.dataobject.rank.RankDO;
import com.wanxiang.huijing.game.module.community.dal.mysql.rank.RankMapper;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static com.wanxiang.huijing.game.module.community.enums.ErrorCodeConstants.COMMUNITY_RANK_DIMENSION_INVALID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link RankServiceImpl} 单元测试(纯 Mockito,mock RankMapper + RedisTemplate/ZSetOperations)
*
* 覆盖 ZSET↔DB 一致性核心逻辑(U3 R-SOC):
* ① 维度合法性校验(脏维度拒);
* ② 写穿透双写(DB 原子累加 + ZSET incrementScore 同流程都被调用);
* ③ 首次累加 0 行 → insert 初始化兜底(含并发 DuplicateKey CAS);
* ④ getTopN 命中 ZSET 快路径;
* ⑤ getTopN ZSET 缺失 → 触发 DB 回填重建后再读(读路径不空窗);
* ⑥ rebuildFromDb 先删 key 再按 DB 权威回填。
* ZSET 用 Mockito 桩,不依赖 jedismock ZSET 实现,专测一致性编排逻辑。
*
* @author 造梦AI
*/
class RankServiceImplTest extends BaseMockitoUnitTest {
private static final String DIM = "game_hot"; // 合法维度(RankDimensionEnum.GAME_HOT)
private static final String ZKEY = "community:rank:game_hot";
@InjectMocks
private RankServiceImpl rankService;
@Mock
private RankMapper rankMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ZSetOperations<String, Object> zSetOps;
// ============================== 维度合法性 ==============================
@Test
void testAddScore_invalidDimensionRejected() {
ServiceException ex = assertThrows(ServiceException.class,
() -> rankService.addScore("not_a_dim", 1024L, 5L));
assertEquals(COMMUNITY_RANK_DIMENSION_INVALID.getCode(), ex.getCode());
verify(rankMapper, never()).incrScore(any(), any(), anyLong());
}
// ============================== 写穿透双写 ==============================
@Test
void testAddScore_writeThroughBothDbAndZset() {
// DB 原子累加命中已有行(affected=1,不走 insert)
when(rankMapper.incrScore(DIM, 1024L, 5L)).thenReturn(1);
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
rankService.addScore(DIM, 1024L, 5L);
// 双写:DB 累加 + ZSET 同向累加
verify(rankMapper).incrScore(DIM, 1024L, 5L);
verify(zSetOps).incrementScore(ZKEY, "1024", 5.0d);
verify(rankMapper, never()).insert(any(RankDO.class)); // 命中已有行不初始化
}
@Test
void testAddScore_firstTimeInsertFallback() {
// 原子累加命中 0 行(记录不存在)→ insert 初始化兜底 → 仍写 ZSET
when(rankMapper.incrScore(DIM, 1024L, 5L)).thenReturn(0);
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
rankService.addScore(DIM, 1024L, 5L);
verify(rankMapper).insert(any(RankDO.class)); // 初始化兜底
verify(zSetOps).incrementScore(ZKEY, "1024", 5.0d); // ZSET 仍同步
}
@Test
void testAddScore_concurrentInsertCasRetriesIncr() {
// 累加 0 行 → insert 抛唯一键冲突(并发已建行)→ 补一次原子累加
when(rankMapper.incrScore(DIM, 1024L, 5L)).thenReturn(0).thenReturn(1);
doThrow(new DuplicateKeyException("uk_dimension_target")).when(rankMapper).insert(any(RankDO.class));
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
rankService.addScore(DIM, 1024L, 5L);
// 第一次 incr(0行) + catch 后补 incr:共 2 次
verify(rankMapper, times(2)).incrScore(DIM, 1024L, 5L);
verify(zSetOps).incrementScore(ZKEY, "1024", 5.0d);
}
// ============================== getTopN ==============================
@Test
void testGetTopN_cacheHit() {
// 先建桩元组(含内部 mock)再 stub,避免在 when().thenReturn() 链中创建 mock 触发 UnfinishedStubbing
Set<ZSetOperations.TypedTuple<Object>> hit = tuples();
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
when(zSetOps.reverseRangeWithScores(ZKEY, 0, 1)).thenReturn(hit);
List<RankItemRespVO> top = rankService.getTopN(DIM, 2);
assertEquals(2, top.size());
assertEquals(1, top.get(0).getRank());
assertEquals(1024L, top.get(0).getTargetId());
assertEquals(99L, top.get(0).getScore());
assertEquals(2, top.get(1).getRank());
assertEquals(2048L, top.get(1).getTargetId());
// 命中快路径,不回填、不查 DB
verify(rankMapper, never()).selectTopByDimension(any(), anyInt());
}
@Test
void testGetTopN_cacheMissTriggersRebuild() {
// 先建桩元组再 stub(避免链中创建 mock 触发 UnfinishedStubbing)
Set<ZSetOperations.TypedTuple<Object>> afterRebuild = tuples();
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
// 首读空(缓存缺失)→ rebuild → 再读得数据
when(zSetOps.reverseRangeWithScores(ZKEY, 0, 1))
.thenReturn(new LinkedHashSet<>()) // 缺失
.thenReturn(afterRebuild); // 回填后再读
when(rankMapper.selectTopByDimension(eq(DIM), anyInt())).thenReturn(List.of(rankRow(1024L, 99L)));
List<RankItemRespVO> top = rankService.getTopN(DIM, 2);
// 触发回填:删 key + 从 DB 写入
verify(redisTemplate).delete(ZKEY);
verify(rankMapper).selectTopByDimension(eq(DIM), anyInt());
verify(zSetOps).add(ZKEY, "1024", 99.0d);
// 回填后再读拿到数据
assertEquals(2, top.size());
}
// ============================== rebuildFromDb ==============================
@Test
void testRebuildFromDb_deleteThenRepopulate() {
when(redisTemplate.opsForZSet()).thenReturn(zSetOps);
when(rankMapper.selectTopByDimension(eq(DIM), anyInt()))
.thenReturn(List.of(rankRow(1024L, 99L), rankRow(2048L, 50L)));
int members = rankService.rebuildFromDb(DIM);
assertEquals(2, members);
verify(redisTemplate).delete(ZKEY); // 先删旧 key
verify(zSetOps).add(ZKEY, "1024", 99.0d); // 按 DB 权威回填
verify(zSetOps).add(ZKEY, "2048", 50.0d);
}
@Test
void testRebuildFromDb_invalidDimensionRejected() {
ServiceException ex = assertThrows(ServiceException.class,
() -> rankService.rebuildFromDb("bad_dim"));
assertEquals(COMMUNITY_RANK_DIMENSION_INVALID.getCode(), ex.getCode());
}
// ============================== 测试夹具 ==============================
/** 构造一个 ZSET 降序元组集合(1024→99 在前,2048→80 在后) */
private static Set<ZSetOperations.TypedTuple<Object>> tuples() {
Set<ZSetOperations.TypedTuple<Object>> set = new LinkedHashSet<>();
set.add(tuple("1024", 99.0d));
set.add(tuple("2048", 80.0d));
return set;
}
private static ZSetOperations.TypedTuple<Object> tuple(String member, double score) {
@SuppressWarnings("unchecked")
ZSetOperations.TypedTuple<Object> t = Mockito.mock(ZSetOperations.TypedTuple.class);
lenient().when(t.getValue()).thenReturn(member);
lenient().when(t.getScore()).thenReturn(score);
return t;
}
private static RankDO rankRow(Long targetId, Long score) {
RankDO row = new RankDO();
row.setDimension(DIM);
row.setTargetId(targetId);
row.setScore(score);
return row;
}
}

View File

@ -0,0 +1,107 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题:community 社区社交(评论 + 关注 + 排行 + 弹幕)(U3 R-SOC,plan002)| owner:U3
-- 文件:V23.0.0__create_game_community_social.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V23.0.1)
-- 版本号:U3 独占 V23.x(全局当前最大 V22;切勿与他人版本冲突)。
-- 内容:community 四张社交新表:
-- 1. game_community_comment —— 评论(CRUD + 举报转 compliance);
-- 2. game_community_follow —— 关注(active 软开关幂等切换,不删行);
-- 3. game_community_rank —— 排行权威源(Redis ZSET 的回填源 / 一致性兜底,DB=真相、ZSET=缓存索引);
-- 4. game_community_danmaku —— 弹幕(WebSocket 广播 + 落库可回放)。
-- 守门①:本 V23 同时放仓根 contracts/db-schemas/(授权源)+ huijing-server 执行目录(唯一执行副本),
-- 不放任何单模块 -server/db/migration/(避免同版本出现在多个 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 / creator_user_id 由 Mapper 强制以 getLoginUserId() 作 WHERE 谓词 enforce(可信边界,非 @DataPermission);禁裸 select*。
-- 错误码段:community = 1-107-***-***(落 -api 的 ErrorCodeConstants.java,评论 004/关注 005/排行 006/弹幕 007)。
-- 幂等:关注 uk(user_id,target_user_id,deleted,tenant_id) 软切换不删行;排行 uk(dimension,target_id,deleted,tenant_id) 一目标一行。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表1:game_community_comment —— 评论(CRUD + 举报转 compliance)
-- 归属隔离 = Mapper 删除/编辑强制 user_id = getLoginUserId() 谓词,用户只能改自己的评论;
-- 列表查询按 target_type+target_id 维度(公开可见,不按 user 隔离)。举报由 Service 落举报态 + 转 compliance 受理(事务提交后)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_comment` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '评论 ID',
`target_type` TINYINT NOT NULL DEFAULT 1 COMMENT '评论目标类型:1游戏(MVP 仅游戏,预留扩展)',
`target_id` BIGINT NOT NULL COMMENT '评论目标 ID(target_type=1 时 = game_project.id)',
`user_id` BIGINT NOT NULL COMMENT '评论作者用户 ID(归属隔离:删除/编辑 Mapper 强制以 getLoginUserId() 作 WHERE 谓词)',
`content` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '评论正文',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父评论 ID(0=一级评论;预留楼中楼,MVP 不展开树)',
`report_status` TINYINT NOT NULL DEFAULT 0 COMMENT '举报态:0正常 1被举报(转 compliance 受理后置 1,本端只留信号痕迹,不裁决)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;区别于业务归属列 user_id)',
`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(MVP 单租户=0)',
PRIMARY KEY (`id`),
KEY `idx_target` (`target_type`, `target_id`, `id`) COMMENT '目标维度评论列表(按 id 倒序,新评论在前)',
KEY `idx_user` (`user_id`, `id`) COMMENT '我的评论列表 / 归属校验加速'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区评论(CRUD + 举报转 compliance,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表2:game_community_follow —— 关注(active 软开关幂等切换,不删行)
-- 范本 = feed game_feed_interact_log:唯一键命中则改 active(取关=反激活 active=0),未命中则插,幂等不重复落行。
-- 计数 = countActive(active=1)。一对 (user_id,target_user_id) 一行(uk 保证幂等,反复关/取关只切 active)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_follow` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '关注关系 ID',
`user_id` BIGINT NOT NULL COMMENT '关注发起方用户 ID(粉丝;归属隔离:写动作强制以 getLoginUserId() 承载)',
`target_user_id` BIGINT NOT NULL COMMENT '被关注方用户 ID(偶像)',
`active` TINYINT NOT NULL DEFAULT 1 COMMENT '激活态:1已关注 0已取关(软开关,取关不删行)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`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 '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_target` (`user_id`, `target_user_id`, `deleted`, `tenant_id`) COMMENT '一对关注关系一行(幂等软切换,防并发双插)',
KEY `idx_target_active` (`target_user_id`, `active`) COMMENT '粉丝数统计(某人被多少 active 关注)',
KEY `idx_user_active` (`user_id`, `active`) COMMENT '关注数统计 / 我的关注列表'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区关注关系(active 软开关幂等,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表3:game_community_rank —— 排行权威源(DB=真相,Redis ZSET=缓存索引)
-- 一致性窗口(最终一致):分值变更事件在同一 Service 流程内【双写】DB 本行 + ZSET(写穿透);
-- ZSET 缺失/启动/周期对账时由本表回填重建 ZSET(rebuild)。score 落 DB 供权威回填,ZSET 仅供快速 TopN。
-- 一目标一维度一行(uk 保证幂等累加落点)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_rank` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '排行记录 ID',
`dimension` VARCHAR(32) NOT NULL COMMENT '排行维度(如 game_hot 游戏热度 / creator_hot 创作者热度),与 ZSET key community:rank:{dimension} 对应',
`target_id` BIGINT NOT NULL COMMENT '排行对象 ID(game_hot=gameId / creator_hot=creatorUserId)',
`score` BIGINT NOT NULL DEFAULT 0 COMMENT '排行分值(权威源;BIGINT 防溢出,与 ZSET double 同口径取整数域)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(系统身份写=0)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(系统身份写=0)',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dimension_target` (`dimension`, `target_id`, `deleted`, `tenant_id`) COMMENT '一维度一对象一行(幂等累加落点 + ZSET 回填唯一源)',
KEY `idx_dimension_score` (`dimension`, `score`) COMMENT '权威排序回填 TopN(ZSET 重建用)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区排行权威源(ZSET 回填源,U3 R-SOC)';
-- -----------------------------------------------------------------------------
-- 表4:game_community_danmaku —— 弹幕(WebSocket 广播 + 落库可回放)
-- 房间 = game_id(同一游戏的所有在线观众)。发送 = 落库 + WebSocket 广播给房间内会话。
-- 多实例边界:本波 LocalWebSocketMessageSender 仅单机;多实例需切 huijing.websocket.sender-type=redis(MQ future-state 未部署)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_community_danmaku` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '弹幕 ID',
`game_id` BIGINT NOT NULL COMMENT '游戏 ID(= game_project.id,弹幕房间维度)',
`user_id` BIGINT NOT NULL COMMENT '发送者用户 ID',
`content` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '弹幕正文',
`play_time` INT NOT NULL DEFAULT 0 COMMENT '播放进度(秒;回放时按此对齐弹幕出现时机,MVP 可为 0)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`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 '逻辑删除',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
PRIMARY KEY (`id`),
KEY `idx_game` (`game_id`, `id`) COMMENT '某游戏最近弹幕回放(按 id 倒序/正序取近段)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '社区弹幕(WS 广播 + 落库回放,U3 R-SOC)';