diff --git a/contracts/api-schemas/community.yaml b/contracts/api-schemas/community.yaml index 8b020a81..3abd446e 100644 --- a/contracts/api-schemas/community.yaml +++ b/contracts/api-schemas/community.yaml @@ -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) + 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 + 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 + 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 + 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: '发送时间' } diff --git a/contracts/db-schemas/V23.0.0__create_game_community_social.sql b/contracts/db-schemas/V23.0.0__create_game_community_social.sql new file mode 100644 index 00000000..9ba1f2d0 --- /dev/null +++ b/contracts/db-schemas/V23.0.0__create_game_community_social.sql @@ -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)'; diff --git a/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/ErrorCodeConstants.java b/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/ErrorCodeConstants.java index d1e87488..35dbbf05 100644 --- a/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/ErrorCodeConstants.java @@ -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, "弹幕内容超长"); + } diff --git a/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/RankDimensionEnum.java b/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/RankDimensionEnum.java new file mode 100644 index 00000000..20d5a5ae --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-api/src/main/java/com/wanxiang/huijing/game/module/community/enums/RankDimensionEnum.java @@ -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); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/pom.xml b/game-cloud/game-module-community/game-module-community-server/pom.xml index 243504c8..2ff4c13d 100644 --- a/game-cloud/game-module-community/game-module-community-server/pom.xml +++ b/game-cloud/game-module-community/game-module-community-server/pom.xml @@ -61,6 +61,12 @@ huijing-spring-boot-starter-redis + + + com.wanxiang + huijing-spring-boot-starter-websocket + + com.wanxiang diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/AppCommunityCommentController.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/AppCommunityCommentController.java new file mode 100644 index 00000000..f3277d06 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/AppCommunityCommentController.java @@ -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 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 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> getCommentPage(@Valid CommentPageReqVO pageReqVO) { + PageResult 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 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 reportComment(@PathVariable("id") Long id, + @Valid @RequestBody CommentReportReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + commentService.reportComment(id, reqVO.getReason(), userId); + return success(true); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentCreateReqVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentCreateReqVO.java new file mode 100644 index 00000000..0e5a3b64 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentCreateReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentPageReqVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentPageReqVO.java new file mode 100644 index 00000000..34d190ba --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentPageReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentReportReqVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentReportReqVO.java new file mode 100644 index 00000000..901cc6e2 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentReportReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentRespVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentRespVO.java new file mode 100644 index 00000000..db44ad1a --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/comment/vo/CommentRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/AppCommunityDanmakuController.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/AppCommunityDanmakuController.java new file mode 100644 index 00000000..cec26d95 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/AppCommunityDanmakuController.java @@ -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 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> getRecentDanmaku(@RequestParam("gameId") Long gameId, + @RequestParam(value = "limit", defaultValue = "50") Integer limit) { + return success(danmakuService.getRecentDanmaku(gameId, limit == null ? 50 : limit)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/message/DanmakuWebSocketMessage.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/message/DanmakuWebSocketMessage.java new file mode 100644 index 00000000..1ae2f726 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/message/DanmakuWebSocketMessage.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuRespVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuRespVO.java new file mode 100644 index 00000000..58a8e6e2 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuSendReqVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuSendReqVO.java new file mode 100644 index 00000000..194edddb --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/danmaku/vo/DanmakuSendReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/AppCommunityFollowController.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/AppCommunityFollowController.java new file mode 100644 index 00000000..11752138 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/AppCommunityFollowController.java @@ -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 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 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 getFollowStat(@PathVariable("userId") Long userId) { + // 当前登录者(可能未登录,getLoginUserId 返回 null,统计仍可读,followedByMe 恒 false) + Long currentUserId = SecurityFrameworkUtils.getLoginUserId(); + return success(followService.getFollowStat(userId, currentUserId)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowResultRespVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowResultRespVO.java new file mode 100644 index 00000000..6133788e --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowResultRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowStatRespVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowStatRespVO.java new file mode 100644 index 00000000..f50d88ef --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/follow/vo/FollowStatRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/AppCommunityRankController.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/AppCommunityRankController.java new file mode 100644 index 00000000..1af9d3fe --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/AppCommunityRankController.java @@ -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> getTopN(@RequestParam("dimension") String dimension, + @RequestParam(value = "topN", defaultValue = "10") Integer topN) { + return success(rankService.getTopN(dimension, topN == null ? 10 : topN)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/vo/RankItemRespVO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/vo/RankItemRespVO.java new file mode 100644 index 00000000..dbaed47c --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/controller/app/rank/vo/RankItemRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/convert/comment/CommentConvert.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/convert/comment/CommentConvert.java new file mode 100644 index 00000000..f88f8c1a --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/convert/comment/CommentConvert.java @@ -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 toRespPage(PageResult page) { + List list = page.getList().stream() + .map(CommentConvert::toRespVO) + .collect(Collectors.toList()); + return new PageResult<>(list, page.getTotal()); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/comment/CommentDO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/comment/CommentDO.java new file mode 100644 index 00000000..71a8d806 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/comment/CommentDO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/danmaku/DanmakuDO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/danmaku/DanmakuDO.java new file mode 100644 index 00000000..90b554b0 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/danmaku/DanmakuDO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/follow/FollowDO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/follow/FollowDO.java new file mode 100644 index 00000000..9a6a38de --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/follow/FollowDO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/rank/RankDO.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/rank/RankDO.java new file mode 100644 index 00000000..9232f2e3 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/dataobject/rank/RankDO.java @@ -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; + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/comment/CommentMapper.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/comment/CommentMapper.java new file mode 100644 index 00000000..0fbdbba0 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/comment/CommentMapper.java @@ -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 { + + /** + * 目标维度评论分页(公开可见,按 target_type+target_id 过滤,id 倒序新评论在前) + * + * @param reqVO 分页 + target_type/target_id 过滤(targetId 必填) + * @return 分页结果 + */ + default PageResult selectTargetPage(CommentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .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() + .eq(CommentDO::getId, id)); + } + + /** + * 某目标的评论总数(公开计数,按 target 维度,不按 user 隔离) + * + * @param targetType 目标类型 + * @param targetId 目标 ID + * @return 评论数 + */ + default Long selectCountByTarget(Integer targetType, Long targetId) { + return selectCount(new LambdaQueryWrapperX() + .eq(CommentDO::getTargetType, targetType) + .eq(CommentDO::getTargetId, targetId)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/danmaku/DanmakuMapper.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/danmaku/DanmakuMapper.java new file mode 100644 index 00000000..204ad254 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/danmaku/DanmakuMapper.java @@ -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 { + + /** + * 某游戏最近 N 条弹幕(回放/进房补帧用,按 id 倒序取近段后由调用方翻正序) + * + * @param gameId 游戏 ID(房间) + * @param limit 取近 N 条 + * @return 弹幕 DO 列表(id 倒序) + */ + default List selectRecentByGame(Long gameId, int limit) { + return selectList(new LambdaQueryWrapperX() + .eq(DanmakuDO::getGameId, gameId) + .orderByDesc(DanmakuDO::getId) + .last("LIMIT " + limit)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/follow/FollowMapper.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/follow/FollowMapper.java new file mode 100644 index 00000000..4ae9315c --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/follow/FollowMapper.java @@ -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 { + + /** + * 按唯一键查关注关系(软切换前判存在;命中首条防并发双插) + * + * @param userId 关注发起方用户 ID(粉丝) + * @param targetUserId 被关注方用户 ID(偶像) + * @return 关注关系 DO;不存在返回 null + */ + default FollowDO selectByUserTarget(Long userId, Long targetUserId) { + return selectOne(new LambdaQueryWrapperX() + .eq(FollowDO::getUserId, userId) + .eq(FollowDO::getTargetUserId, targetUserId)); + } + + /** + * 某人的粉丝数(被多少 active=1 用户关注) + * + * @param targetUserId 被关注方用户 ID + * @return 粉丝数 + */ + default Long selectFollowerCount(Long targetUserId) { + return selectCount(new LambdaQueryWrapperX() + .eq(FollowDO::getTargetUserId, targetUserId) + .eq(FollowDO::getActive, 1)); + } + + /** + * 某人的关注数(active=1 关注了多少人) + * + * @param userId 关注发起方用户 ID + * @return 关注数 + */ + default Long selectFollowingCount(Long userId) { + return selectCount(new LambdaQueryWrapperX() + .eq(FollowDO::getUserId, userId) + .eq(FollowDO::getActive, 1)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/rank/RankMapper.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/rank/RankMapper.java new file mode 100644 index 00000000..419ab32a --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/dal/mysql/rank/RankMapper.java @@ -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 { + + /** + * 按唯一键查排行行(累加前判存在;命中首条防并发双插) + * + * @param dimension 排行维度 + * @param targetId 排行对象 ID + * @return 排行 DO;不存在返回 null + */ + default RankDO selectByDimensionTarget(String dimension, Long targetId) { + return selectOne(new LambdaQueryWrapperX() + .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 selectTopByDimension(String dimension, int limit) { + return selectList(new LambdaQueryWrapperX() + .eq(RankDO::getDimension, dimension) + .orderByDesc(RankDO::getScore) + .last("LIMIT " + limit)); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentService.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentService.java new file mode 100644 index 00000000..4017a940 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentService.java @@ -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 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); + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImpl.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImpl.java new file mode 100644 index 00000000..d4dc1e78 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImpl.java @@ -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 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); + } + }); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistry.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistry.java new file mode 100644 index 00000000..82887566 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistry.java @@ -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> 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 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 getRoomSessionIds(Long gameId) { + Set sessions = roomSessions.get(gameId); + return sessions == null ? Collections.emptySet() : Set.copyOf(sessions); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuService.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuService.java new file mode 100644 index 00000000..af535960 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuService.java @@ -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 getRecentDanmaku(Long gameId, int limit); + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImpl.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImpl.java new file mode 100644 index 00000000..8effe07d --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImpl.java @@ -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 getRecentDanmaku(Long gameId, int limit) { + int safeLimit = limit <= 0 ? 50 : limit; + // 拷入可变 list 再翻转(不假设 Mapper 返回 list 可变):Mapper 取 id 倒序近段,翻为时间正序返回(前端按序补帧) + List rows = new ArrayList<>(danmakuMapper.selectRecentByGame(gameId, safeLimit)); + Collections.reverse(rows); + List 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 sessionIds = roomRegistry.getRoomSessionIds(gameId); + if (sessionIds.isEmpty()) { + log.info("[sendDanmaku] 房间无在线会话,仅落库不广播 gameId={}", gameId); + return; + } + List 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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuWebSocketMessageListener.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuWebSocketMessageListener.java new file mode 100644 index 00000000..506a93df --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuWebSocketMessageListener.java @@ -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 { + + /** 动作:进房 */ + 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" + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowService.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowService.java new file mode 100644 index 00000000..65b755ad --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowService.java @@ -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); + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImpl.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImpl.java new file mode 100644 index 00000000..b3d07a62 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImpl.java @@ -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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankService.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankService.java new file mode 100644 index 00000000..f4ee9fce --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankService.java @@ -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 getTopN(String dimension, int topN); + + /** + * 从 DB 权威源全量回填重建某维度 ZSET(启动预热 / 周期对账 / 缓存丢失兜底)。 + * + * 重建语义:以 DB 为准重写 ZSET(先删旧 key 再按 DB TopN 写入),消除 ZSET 与 DB 的漂移。 + * + * @param dimension 排行维度 + * @return 回填的成员数 + */ + int rebuildFromDb(String dimension); + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImpl.java b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImpl.java new file mode 100644 index 00000000..e23c3fb2 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/main/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImpl.java @@ -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 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 getTopN(String dimension, int topN) { + validateDimension(dimension); + int safeTopN = topN <= 0 ? 10 : topN; + // 快路径:读 ZSET 降序(含分值) + Set> 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 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 toRankItems(Set> tuples) { + List result = new ArrayList<>(); + if (tuples == null) { + return result; + } + int rank = 1; + for (ZSetOperations.TypedTuple 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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImplTest.java b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImplTest.java new file mode 100644 index 00000000..a349a87c --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/comment/CommentServiceImplTest.java @@ -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 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 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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistryTest.java b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistryTest.java new file mode 100644 index 00000000..4cb2e4f9 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuRoomRegistryTest.java @@ -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 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()); + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImplTest.java b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImplTest.java new file mode 100644 index 00000000..5aefb5b2 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/danmaku/DanmakuServiceImplTest.java @@ -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 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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImplTest.java b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImplTest.java new file mode 100644 index 00000000..38464f14 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/follow/FollowServiceImplTest.java @@ -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 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 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 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; + } + +} diff --git a/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImplTest.java b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImplTest.java new file mode 100644 index 00000000..50031678 --- /dev/null +++ b/game-cloud/game-module-community/game-module-community-server/src/test/java/com/wanxiang/huijing/game/module/community/service/rank/RankServiceImplTest.java @@ -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 redisTemplate; + @Mock + private ZSetOperations 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> hit = tuples(); + when(redisTemplate.opsForZSet()).thenReturn(zSetOps); + when(zSetOps.reverseRangeWithScores(ZKEY, 0, 1)).thenReturn(hit); + + List 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> 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 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> tuples() { + Set> set = new LinkedHashSet<>(); + set.add(tuple("1024", 99.0d)); + set.add(tuple("2048", 80.0d)); + return set; + } + + private static ZSetOperations.TypedTuple tuple(String member, double score) { + @SuppressWarnings("unchecked") + ZSetOperations.TypedTuple 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; + } + +} diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V23.0.0__create_game_community_social.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V23.0.0__create_game_community_social.sql new file mode 100644 index 00000000..9ba1f2d0 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V23.0.0__create_game_community_social.sql @@ -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)';