28 KiB
App Message Tab Architecture
本文定义 App 底部 消息 tab 的产品边界、服务架构、数据模型、接口契约和开发顺序。消息 tab 固定分为 用户、系统、活动 三个分区。当前仓库不自研 IM 长连接,用户私聊仍由腾讯云 IM SDK 承担;后端只持久化系统和活动 inbox,并提供列表、未读、已读、删除和运营触达能力。
Goals
- App 底部消息 tab 展示三个分区:
用户、系统、活动。 用户分区来自腾讯云 IM C2C conversation list,不把私聊内容重复落进业务库。系统和活动分区由后端 inbox 持久化,支持分页、未读、已读、删除、过期、撤回和跳转。- 消息事实必须按
app_code和user_id隔离,不允许跨 App、跨用户读取或写入。 - 消息事实必须在 MySQL,可用 Redis 做未读数短 TTL 缓存,但 Redis 不能作为恢复来源。
- 后端外部入口统一在
gateway-service,响应使用/api/v1envelope。 - 生产者只发领域事件或内部消息命令,message inbox owner 负责模板快照、幂等、入箱和未读数。
Non-Goals
- 不实现自研 IM 长连接、单聊投递、离线漫游或私聊已读同步。腾讯云 IM 继续负责实时用户消息。
- 不把房间群消息、公屏消息或房间系统消息复制到个人消息 tab。
- 不把后台管理端
admin_notifications复用给 App 用户。后台通知属于hyapp_admin,App 消息属于 App 业务库。 - 不允许客户端自报消息归属、未读数、系统消息内容、活动消息内容或目标用户。
- 不在
room-service、wallet-service、user-service内直接写用户 inbox 表;它们只通过 outbox/事件或内部 RPC 触发入箱。
Current Implementation Snapshot
| Capability | Status | Current Evidence | Gap |
|---|---|---|---|
| 腾讯云 IM 登录 | DONE |
gateway-service 已有 /api/v1/im/usersig |
App 侧会话列表仍需客户端 SDK 接入 |
| 用户资料批量查询 | DONE |
user-service.BatchGetUsers + gateway /api/v1/users/profiles:batch |
仅返回 IM 会话展示字段 |
| activity-service 底座 | DONE |
ActivityService + MessageInboxService 共用 gRPC 入口 |
message inbox 事实 owner 已放在 activity-service |
| gateway 消息接口 | DONE |
/api/v1/messages/tabs、列表、已读、read-all、删除 |
gateway 只透传 JWT user_id 和 app_code |
| inbox MySQL 表 | DONE |
message_templates、user_inbox_messages、message_fanout_jobs |
MySQL 是 system/activity inbox 唯一事实来源 |
| 系统消息生产者 | TODO |
wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 CreateInboxMessage 或 MQ consumer |
| 活动消息生产者 | TODO |
activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
| 未读数缓存 | TODO |
无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
| 广播/fanout | DONE |
message_fanout_jobs + CreateFanoutJob + ActivityCronService.ProcessMessageFanoutBatch;region/country/all_active_users 通过 user-service ListUserIDs 游标取目标 |
还需要后台运营生产者接入 |
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
Message Sections
| Section | Meaning | Source Of Truth | Backend Responsibility |
|---|---|---|---|
user |
用户之间的单聊消息和会话 | Tencent IM SDK | 签发 UserSig、按需批量补用户资料 |
system |
账号、钱包、房间、host/agency/bd、审核、提现等系统通知 | message inbox MySQL | 持久化、分页、未读、已读、删除、撤回 |
activity |
活动上线、奖励、任务、运营触达 | message inbox MySQL, activity producer | 持久化、分页、未读、已读、删除、跳转参数 |
user 分区在客户端通过腾讯云 IM SDK 拉取 conversation list。后端不能把用户私聊复制一份做列表,否则会引入隐私、合规、已读同步和消息一致性问题。
system 和 activity 分区使用同一套 inbox 表,通过 message_type 区分。活动服务或后台运营系统只生产消息事件,最终个人收件箱由 message inbox owner 落库。
Service Boundary
graph LR
Client["Client"] --> Gateway["gateway-service"]
Client --> TIM["Tencent IM SDK"]
Gateway --> User["user-service"]
Gateway --> Message["message inbox owner\nactivity-service inbox module first"]
Gateway --> Activity["activity-service"]
TIM --> TIMCloud["Tencent IM"]
User --> UserDB[("hyapp_user")]
Message --> MessageDB[("hyapp_activity message tables")]
Activity --> ActivityDB[("hyapp_activity")]
RoomOutbox[("room_outbox")] --> MQ["MQ / event bus"]
WalletOutbox[("wallet_outbox")] --> MQ
UserOutbox[("user/host outbox")] --> MQ
ActivityOutbox[("activity_outbox")] --> MQ
MQ --> Message
Admin["hyapp-admin-server"] --> Message
首版把 message inbox owner 放在 activity-service 内的 inbox 模块,原因是当前已经有 activity-service 和 hyapp_activity 库,且活动通知天然需要 message fanout。后续如果系统/活动消息量、部署策略或权限模型明显独立,再拆为独立 message-service。
无论放在哪个进程,都必须遵守以下边界:
gateway-service只做 HTTP 鉴权、协议转换、app_code 解析、内部 gRPC 编排,不保存 inbox 状态。activity-service inbox module是system/activity消息事实 owner,负责 MySQL 表、未读数、fanout job 和幂等。user-service只提供用户资料、区域、账号状态和批量查询,不承载消息内容。room-service只拥有房间状态和房间腾讯 IM 群消息,不直接写个人消息 tab。wallet-service只拥有账务事实,通过 wallet outbox 或内部事件触发系统通知。hyapp-admin-server只做后台入口、权限和审计;创建 App 消息时必须调用 message inbox owner,不能直接写业务表。
Product Behavior
Tab Summary
- App 打开消息 tab 时展示三块:
用户、系统、活动。 用户分区的标题、最近会话、未读数由腾讯云 IM SDK 返回。- 后端
/api/v1/messages/tabs返回system/activity未读数,并明确user.source=tencent_im_sdk。 - App 总红点 = Tencent IM unread + backend system unread + backend activity unread。
Message List
system/activity列表按sent_at_ms DESC, inbox_message_id DESC排序。- 只展示当前
app_code、当前user_id、status=visible、未删除、未过期的消息。 - 列表项必须包含 title、summary、icon/image、action、read 状态、sent_at_ms。
body用于详情或富文本,不要求首版做详情页;首版可以在列表响应里返回简短 body。- 客户端展示跳转动作时只能使用服务端白名单 action,不能执行任意 URL 或脚本。
Read And Delete
- 标记已读是用户侧状态变更,重复请求返回成功,不覆盖首次
read_at_ms。 - 删除是用户侧隐藏,设置
deleted_at_ms,不物理删除事实行。 - 删除未读消息后不再计入未读。
- 过期消息不展示、不计入未读,但保留审计行。
- 撤回消息不展示、不计入未读;撤回原因仅用于审计和后台排查。
Data Model
Message Templates
模板只保存服务端可控内容。App 展示标题、摘要、图标和跳转动作时,以消息实例 snapshot 为准,避免模板更新影响历史消息。
message_templates(
app_code VARCHAR(32) NOT NULL,
template_id VARCHAR(96) NOT NULL,
template_version VARCHAR(64) NOT NULL,
message_type VARCHAR(32) NOT NULL,
locale VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_by VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, template_id, template_version),
KEY idx_message_templates_type_status (app_code, message_type, status),
KEY idx_message_templates_template_status (app_code, template_id, status)
);
字段规则:
message_type只允许system或activity。template_version必须进入消息实例 snapshot,避免模板修改影响历史消息。locale首版可以为空;后续多语言按app_code + template_id + template_version + locale选择。action_type必须是服务端白名单枚举,例如wallet_withdraw_detail、host_application_detail、activity_detail、room_detail。- 模板可以被停用,但停用不影响已入箱消息展示。
User Inbox
user_inbox_messages(
app_code VARCHAR(32) NOT NULL,
inbox_message_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
message_type VARCHAR(32) NOT NULL,
producer VARCHAR(64) NOT NULL,
producer_event_id VARCHAR(128) NOT NULL,
producer_event_type VARCHAR(96) NOT NULL DEFAULT '',
aggregate_type VARCHAR(64) NOT NULL DEFAULT '',
aggregate_id VARCHAR(128) NOT NULL DEFAULT '',
template_id VARCHAR(96) NOT NULL DEFAULT '',
template_version VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
read_at_ms BIGINT NULL,
deleted_at_ms BIGINT NULL,
sent_at_ms BIGINT NOT NULL,
expire_at_ms BIGINT NULL,
metadata_json JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, inbox_message_id),
UNIQUE KEY uk_user_inbox_producer_event (app_code, user_id, producer, producer_event_id),
KEY idx_user_inbox_list (app_code, user_id, message_type, deleted_at_ms, status, sent_at_ms, inbox_message_id),
KEY idx_user_inbox_unread (app_code, user_id, message_type, read_at_ms, deleted_at_ms, status, expire_at_ms),
KEY idx_user_inbox_aggregate (app_code, user_id, aggregate_type, aggregate_id)
);
字段规则:
producer_event_id是入箱幂等键。钱包提现审核、host 申请、活动奖励等每个业务事件必须生成稳定 ID。producer_event_type保留源事件类型,方便排查和消费策略分流。aggregate_type/aggregate_id用于同一业务对象查询,例如withdraw_request/wd_01、host_application/ha_01。status=visible才能被 App 列表看到。recalled、hidden、expired不展示。deleted_at_ms是用户侧删除标记,不删除事实行。expire_at_ms只影响展示和未读,不影响审计。metadata_json只放低敏业务快照,不放支付凭证、手机号、证件号、provider receipt 或密钥。
Fanout Jobs
大范围活动消息不要在 HTTP 请求里同步 fanout。后台只创建任务,cron-service 触发 activity-service 分批写入,并记录进度和失败原因。
message_fanout_jobs(
app_code VARCHAR(32) NOT NULL,
job_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
message_type VARCHAR(32) NOT NULL,
target_scope VARCHAR(64) NOT NULL,
target_filter_json JSON NOT NULL,
template_snapshot_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
cursor_user_id BIGINT NOT NULL DEFAULT 0,
total_count BIGINT NOT NULL DEFAULT 0,
success_count BIGINT NOT NULL DEFAULT 0,
failure_count BIGINT NOT NULL DEFAULT 0,
batch_size INT NOT NULL DEFAULT 500,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
locked_by VARCHAR(96) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NOT NULL DEFAULT '',
created_by VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, job_id),
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
);
target_filter_json 必须由后台服务端生成,不能直接信任客户端或运营前端提交的任意 SQL 条件。首版支持以下 scope:
| Scope | Meaning | Target Source |
|---|---|---|
single_user |
单个用户消息 | request target_user_id |
user_ids |
指定用户列表 | admin 上传或活动产出名单 |
region |
区域活动、区域公告 | user-service users.region_id |
country |
国家定向通知 | user-service users.country |
all_active_users |
全 App 活跃用户 | user-service active users cursor |
Global Broadcast State
如果全量公告需要“未读、已读、删除”语义,不能只保存一条公告定义后让客户端自行判断。可以二选一:
- P0 方案:全量公告也通过
message_fanout_jobs分批物化到user_inbox_messages,实现简单,查询统一。 - P1 方案:新增
message_broadcasts和user_broadcast_states,列表时合并个人 inbox 和广播定义,适合超大规模全量公告。
首版建议采用 P0 物化方案,避免 read-all、delete 和未读数出现两套语义。只有当全量 fanout 成本明显不可接受时,再实现 P1 懒加载模型。
Unread Counter Cache
未读数可以缓存,但缓存不是事实。
message:unread:{app_code}:{user_id}:{message_type}
缓存规则:
- cache miss 时从
user_inbox_messages统计。 - 入箱、已读、read-all、删除、撤回后删除或更新缓存。
- 缓存 TTL 建议 30-120 秒,防止缓存失效遗漏导致长期错误。
- 未读数不得由客户端上报覆盖。
Internal RPC Surface
首版建议在 api/proto/activity/v1 追加 message inbox RPC,或者单独新增 api/proto/message/v1。如果实现仍放在 activity-service,可以先追加到 activity proto,但命名必须保持 message inbox 边界。
service MessageInboxService {
rpc ListMessageTabs(ListMessageTabsRequest) returns (ListMessageTabsResponse);
rpc ListInboxMessages(ListInboxMessagesRequest) returns (ListInboxMessagesResponse);
rpc MarkInboxMessageRead(MarkInboxMessageReadRequest) returns (MarkInboxMessageReadResponse);
rpc MarkInboxSectionRead(MarkInboxSectionReadRequest) returns (MarkInboxSectionReadResponse);
rpc DeleteInboxMessage(DeleteInboxMessageRequest) returns (DeleteInboxMessageResponse);
rpc CreateInboxMessage(CreateInboxMessageRequest) returns (CreateInboxMessageResponse);
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
}
RPC 要求:
- 所有请求必须携带
RequestMeta.app_code和request_id。 - App 用户读写请求必须携带
user_id,并由 gateway 使用 JWT 中的用户 ID 填充。 CreateInboxMessage必须携带producer、producer_event_id、message_type、target_user_id、内容 snapshot 或 template 引用。CreateFanoutJob必须携带后台command_id,同 command 重试返回原 job。- 查询接口不需要
command_id,但必须透传request_id用于追踪。 - 修改 proto 后必须运行
make proto,并提交生成后的.pb.go/_grpc.pb.go。
App API Surface
所有接口都由 gateway-service 暴露,使用 /api/v1 envelope:{code,message,request_id,data}。app_code 来自 gateway 的 App 解析或 JWT,不接受客户端 body 自报。
Tab Summary
GET /api/v1/messages/tabs
Response data:
{
"sections": [
{
"section": "user",
"title": "用户",
"unread_count": 0,
"source": "tencent_im_sdk"
},
{
"section": "system",
"title": "系统",
"unread_count": 2,
"source": "backend"
},
{
"section": "activity",
"title": "活动",
"unread_count": 1,
"source": "backend"
}
]
}
user.unread_count 推荐由客户端腾讯云 IM SDK 直接提供。后端返回 source=tencent_im_sdk,提醒客户端不要等待后端私聊列表。
System And Activity List
GET /api/v1/messages?section=system&page_size=20&page_token=
GET /api/v1/messages?section=activity&page_size=20&page_token=
Response data:
{
"section": "system",
"items": [
{
"message_id": "msg_01",
"title": "提现审核通过",
"summary": "你的提现申请已通过,请等待人工转账。",
"body": "",
"icon_url": "",
"image_url": "",
"action_type": "wallet_withdraw_detail",
"action_param": "withdrawal_id=wd_01",
"read": false,
"sent_at_ms": 1777996800000
}
],
"next_page_token": ""
}
分页使用 (sent_at_ms, inbox_message_id) 游标。不要使用 offset,大用户消息量上来后 offset 翻页会越来越慢且容易重复或漏项。
Mark Read
POST /api/v1/messages/{message_id}/read
POST /api/v1/messages/read-all
read-all body:
{
"section": "system"
}
已读接口必须幂等:重复标记已读返回成功,不改变 read_at_ms 的首次已读时间。read-all 只影响当前 app_code/user_id/section 下未删除、未过期、visible 的消息。
Delete
DELETE /api/v1/messages/{message_id}
删除是用户侧隐藏,设置 deleted_at_ms。已经删除的消息不进入列表,也不计入未读。删除不存在或不属于当前用户的消息应返回 NOT_FOUND,不能泄露其他用户消息是否存在。
User Message Section
用户消息列表由客户端腾讯云 IM SDK 获取:
Tencent IM SDK getConversationList()
后端只补充两个能力:
GET /api/v1/im/usersig:已有,给客户端登录腾讯云 IM。GET /api/v1/users/profiles:batch?user_ids=...:后续可新增,用于会话列表头像、昵称、短号批量补全。底层复用user-service.BatchGetUsers。
profiles:batch 只返回展示字段:user_id、display_user_id、username、avatar、gender、country、region_id、app_code。不能返回手机号、登录凭证、实名信息或后台身份详情。
Producer Rules
系统消息生产者:
| Producer | Event | Section | Target |
|---|---|---|---|
wallet-service |
充值到账、提现审核、提现失败、提现打款完成 | system |
affected user |
user-service |
登录安全、账号状态、资料审核、host/agency/bd 申请结果 | system |
affected user |
room-service |
被踢、被禁言、被解除 ban 等个人强相关通知 | system |
affected user |
activity-service |
活动奖励、任务完成、排行榜奖励 | activity |
affected user |
hyapp-admin-server |
定向运营通知、全量公告、区域活动通知 | activity |
selected users |
生产规则:
- 生产者只发领域事件或调用内部
CreateInboxMessage,不能直接写user_inbox_messages。 - 每个生产者事件必须提供稳定
producer_event_id,重复消费只能生成一条 inbox。 - 房间公屏、房间礼物、麦位变更等群内事件仍走腾讯云 IM 群,不进入个人消息 tab。
room-service只有个人强相关的管理结果才进入消息 tab,例如被踢出房间、被禁言;这类消息不替代房间 IM 系统消息。- 钱包消息必须以 wallet 交易或提现单状态为事实来源,不能由 gateway 根据接口返回自行拼通知。
- 后台运营通知必须经过 admin 权限和审计,最终由 message inbox owner 入箱。
Event To Inbox Flow
sequenceDiagram
participant P as Producer Service
participant O as Producer Outbox
participant M as MQ / Consumer
participant I as Message Inbox Owner
participant DB as message MySQL
participant R as Redis
P->>O: commit domain fact + outbox
M->>O: poll or consume event
M->>I: CreateInboxMessage(producer_event_id)
I->>DB: insert user_inbox_messages with unique key
I->>R: invalidate unread cache
I-->>M: idempotent result
一致性要求:
- 生产者业务事实提交不能依赖消息入箱成功。
- 入箱失败由生产者 outbox 或 MQ consumer 重试。
- message inbox owner 以唯一键保证重复消费不重复发消息。
- 如果模板不存在或已停用,消费者应进入 retry 或 dead-letter,并告警;不能生成空标题消息。
- 如果目标用户不存在、跨 app 或已注销,按业务策略记录 skipped,不应无限重试。
Broadcast And Fanout
消息分发分三种:
| Mode | Use Case | Rule |
|---|---|---|
single_user |
提现审核、申请结果、个人奖励 | 直接写一条 inbox |
user_group |
区域活动、主播群体通知、指定用户列表 | 创建 fanout job,分批写 inbox |
global |
全 App 公告 | 首版仍建议 fanout 物化;超大规模后再做 broadcast lazy state |
Fanout batch 要求:
- 每轮锁定一批待执行 job,使用
locked_by/locked_until_ms防止多实例重复处理。 - 按
cursor_user_id分页查询目标用户,不使用 offset。 - 每个目标用户的
producer_event_id必须稳定,例如fanout:{job_id}:{user_id}。 - 单批失败不能让整个 job 丢失,记录
failure_count/error_message并继续或进入 retry。 - 目标范围按区域/国家选择时,使用 user-service 当前主数据;是否需要冻结目标用户快照由具体活动决定。
- 大型 fanout 配置必须支持 cron
interval、batch_size、lock_ttl;失败重试次数由 activity-service owner 侧状态机控制。
Read Consistency
- 用户消息的已读、未读和最后一条消息以腾讯云 IM 为准。
- 系统和活动消息的已读、未读以
user_inbox_messages为准。 - App tab 总红点 = IM SDK unread + backend system unread + backend activity unread。
- 后端不接收客户端上报的 IM unread 作为事实,只能作为埋点或调试信息。
read-all和删除成功后,必须清理对应 section 的 Redis 未读缓存。- 列表接口如果读到 Redis 未读数和 MySQL 列表状态短暂不一致,以 MySQL 为最终事实。
Error Semantics
| Error | Meaning | HTTP Mapping |
|---|---|---|
INVALID_SECTION |
section 不是 system/activity |
400 |
MESSAGE_NOT_FOUND |
消息不存在、已删除或不属于当前用户 | 404 |
MESSAGE_RECALLED |
消息已撤回,不允许展示或操作 | 404 |
PAGE_TOKEN_INVALID |
游标无法解析或不属于当前查询 | 400 |
REQUEST_CONFLICT |
同 command_id/fanout job 不同 payload | 409 |
PRODUCER_EVENT_CONFLICT |
同 producer_event_id 不同 payload | 409 |
MESSAGE_INTERNAL |
DB、缓存或内部未知错误 | 500 |
request_id 只做链路追踪,不作为消息入箱幂等键。入箱幂等键必须是 producer_event_id 或后台 command_id。
Security And Privacy
app_code由 gateway 解析或 token 携带,不接受客户端 body 覆盖。user_id由 JWT 提供,读写接口不能让客户端指定目标用户。- 后台创建消息必须通过 admin 鉴权和审计,审计留在
hyapp_admin。 action_param必须是白名单动作参数,不允许任意外链、脚本、内嵌 HTML。- 消息 title/summary/body 不得包含支付凭证、provider receipt、敏感证件、完整手机号或密钥。
- 用户删除消息只影响自己,不影响其他用户和后台审计。
- 用户私聊内容不进入后端业务库,不出现在系统/活动消息搜索里。
Configuration
首版实现需要在 activity-service 增加 message inbox 读取配置;message_fanout_jobs 由 cron-service 调度,不再由 activity-service 内置 worker 轮询:
message_inbox:
unread_cache_ttl: 60s
# services/cron-service/configs/config*.yaml
tasks:
message_fanout:
enabled: true
interval: 1s
timeout: 10s
lock_ttl: 30s
batch_size: 500
app_codes: ["lalu"]
如果消息 owner 后续拆成独立 message-service,这些配置迁移到新服务;gateway 只需要更新 message gRPC 地址。
Implementation Order
- 追加 proto:新增
MessageInboxService、消息列表、未读、已读、删除、创建入箱、fanout job RPC。 - 更新
activity-serviceMySQL initdb:新增message_templates、user_inbox_messages、message_fanout_jobs。 - 在
activity-service增加 inbox domain/repository/service,先实现单用户入箱、列表、未读、已读、删除。 - 在
gateway-service增加 activity/message client 和 App HTTP:/api/v1/messages/tabs、/api/v1/messages、/api/v1/messages/{id}/read、/api/v1/messages/read-all、DELETE /api/v1/messages/{id}。 - 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
- 增加 fanout job 和
ActivityCronService.ProcessMessageFanoutBatch,由 cron-service 调度,先支持region、country、user_ids、all_active_users。 - 增加
/api/v1/users/profiles:batch,给腾讯云 IM 用户会话列表补头像、昵称、短号。 - 增加 Redis 未读数缓存和缓存失效逻辑。
- 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。
Verification Matrix
| Scenario | Expected |
|---|---|
| 打开消息 tab | 返回 user/system/activity 三个分区 |
user 分区 |
response 标记 source=tencent_im_sdk,不返回私聊内容 |
| 拉系统消息列表 | 只返回当前 app_code、当前用户、未删除、未过期、visible 的 system 消息 |
| 拉活动消息列表 | 只返回当前 app_code、当前用户、未删除、未过期、visible 的 activity 消息 |
| 重复消费同一 producer event | 只生成一条 inbox 消息 |
| 同 producer event 不同 payload | 返回或记录冲突,不覆盖原消息 |
| 标记已读重复请求 | 返回成功,read_at_ms 不被覆盖,未读数不变成负数 |
| read-all | 当前 section 未读全部归零,其他 section 不受影响 |
| 删除未读消息 | 消息不再展示,未读数减少 |
| 删除其他用户消息 | 返回 NOT_FOUND |
| 过期消息 | 不展示、不计入未读,但数据库事实保留 |
| 全量活动通知 | fanout job 分批写入,不阻塞 Admin HTTP 请求 |
| 区域活动通知 | 只给当前 app 下目标 region 用户入箱 |
| 用户私聊会话 | App 从腾讯云 IM SDK 获取,后端不返回私聊内容 |
| Redis unread cache miss | 从 MySQL 统计后回填缓存 |
Test And Release Requirements
涉及 proto 时运行:
make proto
go test ./...
涉及 DB、配置、Docker 时运行:
docker compose config
docker compose build
涉及启动链路时实际拉起:
docker compose up -d
docker compose ps
docker compose down
最小测试覆盖:
- activity-service inbox repository 单元/集成测试:幂等、分页、未读、已读、删除、过期。
- gateway transport 测试:JWT 用户 ID 生效、app_code 透传、envelope、错误映射。
- fanout batch 测试:锁 job、分页、重复 target 幂等、失败重试。
- producer consumer 测试:同 event 重复消费只入箱一次。
- profiles batch 测试:只返回安全展示字段。
Critical Rules
user分区不落业务库,不复制腾讯云 IM 私聊内容。system和activity必须持久化到 MySQL,不能只靠 Redis 或推送。- 所有 inbox 事实必须带
app_code。 - 已读和删除必须幂等。
- 大范围活动消息必须异步 fanout。
- 房间群消息仍走腾讯云 IM 群,不进入个人消息 tab。
request_id只做追踪,不做幂等。- 后台运营消息必须有 admin 鉴权和审计。
- 生产者不能直接写 inbox 表,只能发事件或调用 message inbox owner。