完善区域群通知

This commit is contained in:
ZuoZuo 2026-05-11 01:40:29 +08:00
parent 47a7df3394
commit baa6d81529
139 changed files with 10827 additions and 2413 deletions

View File

@ -31,6 +31,15 @@
- Redis 只保存当前路由/lease 等热状态,不能作为唯一恢复来源。 - Redis 只保存当前路由/lease 等热状态,不能作为唯一恢复来源。
- Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。 - Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。
## Time Rules
- 全局业务时间统一使用 UTC不使用服务器所在地、容器本地时区、客户端时区或 IP 推断时区作为结算、统计、刷新和幂等周期的切日来源。
- 跨服务契约、HTTP 响应、数据库业务事实和 outbox payload 中的时间统一使用 Unix epoch milliseconds字段名保持 `*_ms`
- 统计、账务和列表时间范围统一使用 `[start_ms, end_ms)`:包含开始毫秒,不包含结束毫秒,避免相邻区间重复计算边界记录。
- 每日任务、自然日刷新和 `task_day` 统一按 UTC 日期计算;不要重新引入 `task_timezone``time.Local``loc=Local`
- MySQL DSN 必须使用 `loc=UTC`Docker/Compose 服务必须保持 `TZ=UTC`MySQL 必须保持 `--default-time-zone=+00:00`
- 如果产品需要用户本地时间展示,只能在展示层或资料/风控元数据中处理;要把它用于业务切日,必须先形成明确产品规则、契约字段和边界测试。
## Protocol Rules ## Protocol Rules
- 外部 HTTP JSON 入口在 `gateway-service` - 外部 HTTP JSON 入口在 `gateway-service`
@ -105,4 +114,5 @@ docker compose down
- 房间状态变更必须经过 Room Cell 命令链路。 - 房间状态变更必须经过 Room Cell 命令链路。
- 需要恢复的关键动作必须写 command log 或 snapshot不能只改内存。 - 需要恢复的关键动作必须写 command log 或 snapshot不能只改内存。
- 房间外副作用优先走 outbox除非是明确的同步主链路依赖。 - 房间外副作用优先走 outbox除非是明确的同步主链路依赖。
- 新增统计、账务、任务刷新或时间范围逻辑时,必须补 UTC 边界测试,至少覆盖开始边界、结束前一毫秒和结束边界。
- 文档只写当前事实和明确约束,不记录“做了什么”的流水账。 - 文档只写当前事实和明确约束,不记录“做了什么”的流水账。

View File

@ -12,7 +12,8 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `room-service` 是房间业务状态 owner负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、snapshot、command log、outbox、Redis lease以及腾讯云 IM 群组/系统消息桥接。 - `room-service` 是房间业务状态 owner负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、snapshot、command log、outbox、Redis lease以及腾讯云 IM 群组/系统消息桥接。
- `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。 - `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 - `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
- `activity-service` 当前提供活动 gRPC 查询骨架和房间事件 consumer 边界,尚未接入 `room_outbox` 消费主循环。 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service` - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/multi-app-tenant-architecture.md` - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/multi-app-tenant-architecture.md`
@ -23,6 +24,16 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。 - 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。
- 房间外事件protobuf outbox当前先落 MySQL。 - 房间外事件protobuf outbox当前先落 MySQL。
## Time Model
项目全局时间模型固定为 UTC + epoch milliseconds
- 跨服务、数据库和 HTTP/gRPC 响应里的业务时间统一使用 Unix epoch milliseconds字段名保持 `*_ms`,例如 `created_at_ms``expire_at_ms``start_at_ms``end_at_ms``next_refresh_at_ms`
- 后端业务自然日统一按 UTC 切日。每日任务的 `task_day` 是 UTC `YYYY-MM-DD``next_refresh_at_ms` 是下一次 UTC 00:00:00 的毫秒时间戳。
- 统计和账务时间范围统一使用 `[start_ms, end_ms)`,也就是包含开始、不包含结束。查询“昨天充值多少”时,调用方必须先把“昨天”转换成明确的 UTC 毫秒范围,再交给后端查询。
- MySQL DSN 必须使用 `loc=UTC`。Docker 环境设置 `TZ=UTC`MySQL 使用 `--default-time-zone=+00:00`,避免依赖服务器所在地或容器本地时区。
- 用户所在时区、设备时区或 IP 推断时区只能用于展示、风控或资料字段;不能作为结算、统计、任务刷新和幂等周期的业务切日来源。
## Ports ## Ports
本地和 Docker 编排统一使用 `13xxx` 端口: 本地和 Docker 编排统一使用 `13xxx` 端口:
@ -34,6 +45,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
| `wallet-service` | `13004` | gRPC | | `wallet-service` | `13004` | gRPC |
| `user-service` | `13005` | gRPC | | `user-service` | `13005` | gRPC |
| `activity-service` | `13006` | gRPC | | `activity-service` | `13006` | gRPC |
| `cron-service` | `13007` | gRPC |
| MySQL | `23306 -> 3306` | local Docker only | | MySQL | `23306 -> 3306` | local Docker only |
| Redis | `13379 -> 6379` | local Docker only | | Redis | `13379 -> 6379` | local Docker only |
@ -45,7 +57,7 @@ Use Docker for local and test environments:
make up make up
``` ```
Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `mysql`, `redis`. Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `mysql`, `redis`, plus full service names.
Common commands: Common commands:
@ -62,7 +74,7 @@ make down
`make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin` `make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin`
For direct host execution, each service reads its own `services/<service>/configs/config.yaml`; `room-service``user-service``wallet-service``activity-service` 都要求 MySQL 可用。 For direct host execution, each service reads its own `services/<service>/configs/config.yaml`; `room-service``user-service``wallet-service``activity-service``cron-service` 都要求 MySQL 可用。
## Tencent IM ## Tencent IM
@ -117,7 +129,8 @@ MySQL 是本地和线上运行的持久化底座:
- `room-service`: `hyapp_room`,保存 room meta、snapshot、command log 和 outbox业务行按 `app_code` 隔离。 - `room-service`: `hyapp_room`,保存 room meta、snapshot、command log 和 outbox业务行按 `app_code` 隔离。
- `user-service`: `hyapp_user`,保存 `apps` 注册表、用户主数据、注册资料快照、登录身份、session、默认短号、靓号租约和 host 关系事实,业务行按 `app_code` 隔离。 - `user-service`: `hyapp_user`,保存 `apps` 注册表、用户主数据、注册资料快照、登录身份、session、默认短号、靓号租约和 host 关系事实,业务行按 `app_code` 隔离。
- `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。 - `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。
- `activity-service`: `hyapp_activity`,保存活动状态、事件消费幂等和 activity outbox业务行按 `app_code` 隔离。 - `activity-service`: `hyapp_activity`,保存任务定义、任务进度、奖励领取、活动状态、事件消费幂等和 activity outbox业务行按 `app_code` 隔离。
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。 - `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
`room-service` uses MySQL as the room recovery durable source: `room-service` uses MySQL as the room recovery durable source:
@ -161,6 +174,7 @@ services/room-service/ Room Cell and durable room state
services/wallet-service/ wallet DebitGift and ledger foundation services/wallet-service/ wallet DebitGift and ledger foundation
services/user-service/ user service services/user-service/ user service
services/activity-service/ activity foundation and room event consumer boundary services/activity-service/ activity foundation and room event consumer boundary
services/cron-service/ background scheduling and owner-service batch trigger
``` ```
## Current Limits ## Current Limits
@ -168,6 +182,6 @@ services/activity-service/ activity foundation and room event consumer boundary
- 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。 - 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
- 房间命令未传 `command_id`gateway 会自动生成新值;客户端 HTTP 重试同一业务动作时必须复用原 `command_id` 才能命中 room-service command log 幂等。 - 房间命令未传 `command_id`gateway 会自动生成新值;客户端 HTTP 重试同一业务动作时必须复用原 `command_id` 才能命中 room-service command log 幂等。
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet` - `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`
- `activity-service` 当前只有同步查询和 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路未完整实现 - `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐
- `room-service` App 已启动 `room_outbox` 补偿 worker该状态只表示腾讯云 IM 系统消息补偿结果activity/audit 后续必须使用独立消费位点。 - `room-service` App 已启动 `room_outbox` 补偿 worker该状态只表示腾讯云 IM 系统消息补偿结果activity/audit 后续必须使用独立消费位点。
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,8 @@ syntax = "proto3";
package hyapp.activity.v1; package hyapp.activity.v1;
import "proto/events/room/v1/events.proto";
option go_package = "hyapp.local/api/proto/activity/v1;activityv1"; option go_package = "hyapp.local/api/proto/activity/v1;activityv1";
// RequestMeta activity-service RPC // RequestMeta activity-service RPC
@ -278,6 +280,75 @@ message ConsumeTaskEventResponse {
int32 matched_task_count = 3; int32 matched_task_count = 3;
} }
// BroadcastJoinGroup gateway IM
message BroadcastJoinGroup {
string group_id = 1;
string type = 2;
int64 region_id = 3;
}
message EnsureBroadcastGroupsRequest {
RequestMeta meta = 1;
}
message EnsureBroadcastGroupsResponse {
int32 ensured_count = 1;
}
message PublishRegionBroadcastRequest {
RequestMeta meta = 1;
string event_id = 2;
int64 region_id = 3;
string broadcast_type = 4;
string payload_json = 5;
}
message PublishGlobalBroadcastRequest {
RequestMeta meta = 1;
string event_id = 2;
string broadcast_type = 3;
string payload_json = 4;
}
message PublishBroadcastResponse {
string event_id = 1;
string group_id = 2;
string status = 3;
bool created = 4;
}
// RemoveRegionBroadcastMemberRequest
message RemoveRegionBroadcastMemberRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int64 region_id = 3;
string reason = 4;
}
message RemoveRegionBroadcastMemberResponse {
string group_id = 1;
bool removed = 2;
}
message ProcessBroadcastOutboxBatchResponse {
int32 claimed_count = 1;
int32 success_count = 2;
int32 failure_count = 3;
bool has_more = 4;
}
message ConsumeRoomEventRequest {
RequestMeta meta = 1;
hyapp.events.room.v1.EventEnvelope envelope = 2;
}
message ConsumeRoomEventResponse {
string event_id = 1;
string status = 2;
string broadcast_event_id = 3;
bool broadcast_created = 4;
}
// TaskDefinition // TaskDefinition
message TaskDefinition { message TaskDefinition {
string task_id = 1; string task_id = 1;
@ -381,6 +452,20 @@ service TaskService {
rpc ConsumeTaskEvent(ConsumeTaskEventRequest) returns (ConsumeTaskEventResponse); rpc ConsumeTaskEvent(ConsumeTaskEventRequest) returns (ConsumeTaskEventResponse);
} }
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
service BroadcastService {
rpc EnsureBroadcastGroups(EnsureBroadcastGroupsRequest) returns (EnsureBroadcastGroupsResponse);
rpc PublishRegionBroadcast(PublishRegionBroadcastRequest) returns (PublishBroadcastResponse);
rpc PublishGlobalBroadcast(PublishGlobalBroadcastRequest) returns (PublishBroadcastResponse);
rpc RemoveRegionBroadcastMember(RemoveRegionBroadcastMemberRequest) returns (RemoveRegionBroadcastMemberResponse);
rpc ProcessBroadcastOutboxBatch(CronBatchRequest) returns (ProcessBroadcastOutboxBatchResponse);
}
// RoomEventConsumerService consumes room-service outbox facts without owning Room Cell state.
service RoomEventConsumerService {
rpc ConsumeRoomEvent(ConsumeRoomEventRequest) returns (ConsumeRoomEventResponse);
}
// AdminTaskService 访 activity-service // AdminTaskService 访 activity-service
service AdminTaskService { service AdminTaskService {
rpc ListTaskDefinitions(ListTaskDefinitionsRequest) returns (ListTaskDefinitionsResponse); rpc ListTaskDefinitions(ListTaskDefinitionsRequest) returns (ListTaskDefinitionsResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1
@ -784,6 +784,371 @@ var TaskService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto", Metadata: "proto/activity/v1/activity.proto",
} }
const (
BroadcastService_EnsureBroadcastGroups_FullMethodName = "/hyapp.activity.v1.BroadcastService/EnsureBroadcastGroups"
BroadcastService_PublishRegionBroadcast_FullMethodName = "/hyapp.activity.v1.BroadcastService/PublishRegionBroadcast"
BroadcastService_PublishGlobalBroadcast_FullMethodName = "/hyapp.activity.v1.BroadcastService/PublishGlobalBroadcast"
BroadcastService_RemoveRegionBroadcastMember_FullMethodName = "/hyapp.activity.v1.BroadcastService/RemoveRegionBroadcastMember"
BroadcastService_ProcessBroadcastOutboxBatch_FullMethodName = "/hyapp.activity.v1.BroadcastService/ProcessBroadcastOutboxBatch"
)
// BroadcastServiceClient is the client API for BroadcastService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
type BroadcastServiceClient interface {
EnsureBroadcastGroups(ctx context.Context, in *EnsureBroadcastGroupsRequest, opts ...grpc.CallOption) (*EnsureBroadcastGroupsResponse, error)
PublishRegionBroadcast(ctx context.Context, in *PublishRegionBroadcastRequest, opts ...grpc.CallOption) (*PublishBroadcastResponse, error)
PublishGlobalBroadcast(ctx context.Context, in *PublishGlobalBroadcastRequest, opts ...grpc.CallOption) (*PublishBroadcastResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, in *RemoveRegionBroadcastMemberRequest, opts ...grpc.CallOption) (*RemoveRegionBroadcastMemberResponse, error)
ProcessBroadcastOutboxBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*ProcessBroadcastOutboxBatchResponse, error)
}
type broadcastServiceClient struct {
cc grpc.ClientConnInterface
}
func NewBroadcastServiceClient(cc grpc.ClientConnInterface) BroadcastServiceClient {
return &broadcastServiceClient{cc}
}
func (c *broadcastServiceClient) EnsureBroadcastGroups(ctx context.Context, in *EnsureBroadcastGroupsRequest, opts ...grpc.CallOption) (*EnsureBroadcastGroupsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EnsureBroadcastGroupsResponse)
err := c.cc.Invoke(ctx, BroadcastService_EnsureBroadcastGroups_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *broadcastServiceClient) PublishRegionBroadcast(ctx context.Context, in *PublishRegionBroadcastRequest, opts ...grpc.CallOption) (*PublishBroadcastResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishBroadcastResponse)
err := c.cc.Invoke(ctx, BroadcastService_PublishRegionBroadcast_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *broadcastServiceClient) PublishGlobalBroadcast(ctx context.Context, in *PublishGlobalBroadcastRequest, opts ...grpc.CallOption) (*PublishBroadcastResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishBroadcastResponse)
err := c.cc.Invoke(ctx, BroadcastService_PublishGlobalBroadcast_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *broadcastServiceClient) RemoveRegionBroadcastMember(ctx context.Context, in *RemoveRegionBroadcastMemberRequest, opts ...grpc.CallOption) (*RemoveRegionBroadcastMemberResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveRegionBroadcastMemberResponse)
err := c.cc.Invoke(ctx, BroadcastService_RemoveRegionBroadcastMember_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *broadcastServiceClient) ProcessBroadcastOutboxBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*ProcessBroadcastOutboxBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessBroadcastOutboxBatchResponse)
err := c.cc.Invoke(ctx, BroadcastService_ProcessBroadcastOutboxBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BroadcastServiceServer is the server API for BroadcastService service.
// All implementations must embed UnimplementedBroadcastServiceServer
// for forward compatibility.
//
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
type BroadcastServiceServer interface {
EnsureBroadcastGroups(context.Context, *EnsureBroadcastGroupsRequest) (*EnsureBroadcastGroupsResponse, error)
PublishRegionBroadcast(context.Context, *PublishRegionBroadcastRequest) (*PublishBroadcastResponse, error)
PublishGlobalBroadcast(context.Context, *PublishGlobalBroadcastRequest) (*PublishBroadcastResponse, error)
RemoveRegionBroadcastMember(context.Context, *RemoveRegionBroadcastMemberRequest) (*RemoveRegionBroadcastMemberResponse, error)
ProcessBroadcastOutboxBatch(context.Context, *CronBatchRequest) (*ProcessBroadcastOutboxBatchResponse, error)
mustEmbedUnimplementedBroadcastServiceServer()
}
// UnimplementedBroadcastServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedBroadcastServiceServer struct{}
func (UnimplementedBroadcastServiceServer) EnsureBroadcastGroups(context.Context, *EnsureBroadcastGroupsRequest) (*EnsureBroadcastGroupsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EnsureBroadcastGroups not implemented")
}
func (UnimplementedBroadcastServiceServer) PublishRegionBroadcast(context.Context, *PublishRegionBroadcastRequest) (*PublishBroadcastResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PublishRegionBroadcast not implemented")
}
func (UnimplementedBroadcastServiceServer) PublishGlobalBroadcast(context.Context, *PublishGlobalBroadcastRequest) (*PublishBroadcastResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PublishGlobalBroadcast not implemented")
}
func (UnimplementedBroadcastServiceServer) RemoveRegionBroadcastMember(context.Context, *RemoveRegionBroadcastMemberRequest) (*RemoveRegionBroadcastMemberResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveRegionBroadcastMember not implemented")
}
func (UnimplementedBroadcastServiceServer) ProcessBroadcastOutboxBatch(context.Context, *CronBatchRequest) (*ProcessBroadcastOutboxBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessBroadcastOutboxBatch not implemented")
}
func (UnimplementedBroadcastServiceServer) mustEmbedUnimplementedBroadcastServiceServer() {}
func (UnimplementedBroadcastServiceServer) testEmbeddedByValue() {}
// UnsafeBroadcastServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to BroadcastServiceServer will
// result in compilation errors.
type UnsafeBroadcastServiceServer interface {
mustEmbedUnimplementedBroadcastServiceServer()
}
func RegisterBroadcastServiceServer(s grpc.ServiceRegistrar, srv BroadcastServiceServer) {
// If the following call pancis, it indicates UnimplementedBroadcastServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&BroadcastService_ServiceDesc, srv)
}
func _BroadcastService_EnsureBroadcastGroups_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EnsureBroadcastGroupsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BroadcastServiceServer).EnsureBroadcastGroups(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BroadcastService_EnsureBroadcastGroups_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BroadcastServiceServer).EnsureBroadcastGroups(ctx, req.(*EnsureBroadcastGroupsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BroadcastService_PublishRegionBroadcast_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishRegionBroadcastRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BroadcastServiceServer).PublishRegionBroadcast(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BroadcastService_PublishRegionBroadcast_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BroadcastServiceServer).PublishRegionBroadcast(ctx, req.(*PublishRegionBroadcastRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BroadcastService_PublishGlobalBroadcast_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishGlobalBroadcastRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BroadcastServiceServer).PublishGlobalBroadcast(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BroadcastService_PublishGlobalBroadcast_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BroadcastServiceServer).PublishGlobalBroadcast(ctx, req.(*PublishGlobalBroadcastRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BroadcastService_RemoveRegionBroadcastMember_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveRegionBroadcastMemberRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BroadcastServiceServer).RemoveRegionBroadcastMember(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BroadcastService_RemoveRegionBroadcastMember_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BroadcastServiceServer).RemoveRegionBroadcastMember(ctx, req.(*RemoveRegionBroadcastMemberRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BroadcastService_ProcessBroadcastOutboxBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BroadcastServiceServer).ProcessBroadcastOutboxBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BroadcastService_ProcessBroadcastOutboxBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BroadcastServiceServer).ProcessBroadcastOutboxBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// BroadcastService_ServiceDesc is the grpc.ServiceDesc for BroadcastService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var BroadcastService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.BroadcastService",
HandlerType: (*BroadcastServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "EnsureBroadcastGroups",
Handler: _BroadcastService_EnsureBroadcastGroups_Handler,
},
{
MethodName: "PublishRegionBroadcast",
Handler: _BroadcastService_PublishRegionBroadcast_Handler,
},
{
MethodName: "PublishGlobalBroadcast",
Handler: _BroadcastService_PublishGlobalBroadcast_Handler,
},
{
MethodName: "RemoveRegionBroadcastMember",
Handler: _BroadcastService_RemoveRegionBroadcastMember_Handler,
},
{
MethodName: "ProcessBroadcastOutboxBatch",
Handler: _BroadcastService_ProcessBroadcastOutboxBatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
RoomEventConsumerService_ConsumeRoomEvent_FullMethodName = "/hyapp.activity.v1.RoomEventConsumerService/ConsumeRoomEvent"
)
// RoomEventConsumerServiceClient is the client API for RoomEventConsumerService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// RoomEventConsumerService consumes room-service outbox facts without owning Room Cell state.
type RoomEventConsumerServiceClient interface {
ConsumeRoomEvent(ctx context.Context, in *ConsumeRoomEventRequest, opts ...grpc.CallOption) (*ConsumeRoomEventResponse, error)
}
type roomEventConsumerServiceClient struct {
cc grpc.ClientConnInterface
}
func NewRoomEventConsumerServiceClient(cc grpc.ClientConnInterface) RoomEventConsumerServiceClient {
return &roomEventConsumerServiceClient{cc}
}
func (c *roomEventConsumerServiceClient) ConsumeRoomEvent(ctx context.Context, in *ConsumeRoomEventRequest, opts ...grpc.CallOption) (*ConsumeRoomEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConsumeRoomEventResponse)
err := c.cc.Invoke(ctx, RoomEventConsumerService_ConsumeRoomEvent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomEventConsumerServiceServer is the server API for RoomEventConsumerService service.
// All implementations must embed UnimplementedRoomEventConsumerServiceServer
// for forward compatibility.
//
// RoomEventConsumerService consumes room-service outbox facts without owning Room Cell state.
type RoomEventConsumerServiceServer interface {
ConsumeRoomEvent(context.Context, *ConsumeRoomEventRequest) (*ConsumeRoomEventResponse, error)
mustEmbedUnimplementedRoomEventConsumerServiceServer()
}
// UnimplementedRoomEventConsumerServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedRoomEventConsumerServiceServer struct{}
func (UnimplementedRoomEventConsumerServiceServer) ConsumeRoomEvent(context.Context, *ConsumeRoomEventRequest) (*ConsumeRoomEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConsumeRoomEvent not implemented")
}
func (UnimplementedRoomEventConsumerServiceServer) mustEmbedUnimplementedRoomEventConsumerServiceServer() {
}
func (UnimplementedRoomEventConsumerServiceServer) testEmbeddedByValue() {}
// UnsafeRoomEventConsumerServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to RoomEventConsumerServiceServer will
// result in compilation errors.
type UnsafeRoomEventConsumerServiceServer interface {
mustEmbedUnimplementedRoomEventConsumerServiceServer()
}
func RegisterRoomEventConsumerServiceServer(s grpc.ServiceRegistrar, srv RoomEventConsumerServiceServer) {
// If the following call pancis, it indicates UnimplementedRoomEventConsumerServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&RoomEventConsumerService_ServiceDesc, srv)
}
func _RoomEventConsumerService_ConsumeRoomEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConsumeRoomEventRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomEventConsumerServiceServer).ConsumeRoomEvent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomEventConsumerService_ConsumeRoomEvent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomEventConsumerServiceServer).ConsumeRoomEvent(ctx, req.(*ConsumeRoomEventRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomEventConsumerService_ServiceDesc is the grpc.ServiceDesc for RoomEventConsumerService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var RoomEventConsumerService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.RoomEventConsumerService",
HandlerType: (*RoomEventConsumerServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ConsumeRoomEvent",
Handler: _RoomEventConsumerService_ConsumeRoomEvent_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const ( const (
AdminTaskService_ListTaskDefinitions_FullMethodName = "/hyapp.activity.v1.AdminTaskService/ListTaskDefinitions" AdminTaskService_ListTaskDefinitions_FullMethodName = "/hyapp.activity.v1.AdminTaskService/ListTaskDefinitions"
AdminTaskService_UpsertTaskDefinition_FullMethodName = "/hyapp.activity.v1.AdminTaskService/UpsertTaskDefinition" AdminTaskService_UpsertTaskDefinition_FullMethodName = "/hyapp.activity.v1.AdminTaskService/UpsertTaskDefinition"

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.35.1 // protoc-gen-go v1.34.2
// protoc v4.25.3 // protoc v5.29.2
// source: proto/events/room/v1/events.proto // source: proto/events/room/v1/events.proto
package roomeventsv1 package roomeventsv1
@ -38,9 +38,11 @@ type EventEnvelope struct {
func (x *EventEnvelope) Reset() { func (x *EventEnvelope) Reset() {
*x = EventEnvelope{} *x = EventEnvelope{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[0] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *EventEnvelope) String() string { func (x *EventEnvelope) String() string {
@ -51,7 +53,7 @@ func (*EventEnvelope) ProtoMessage() {}
func (x *EventEnvelope) ProtoReflect() protoreflect.Message { func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0] mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -133,9 +135,11 @@ type RoomCreated struct {
func (x *RoomCreated) Reset() { func (x *RoomCreated) Reset() {
*x = RoomCreated{} *x = RoomCreated{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[1] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomCreated) String() string { func (x *RoomCreated) String() string {
@ -146,7 +150,7 @@ func (*RoomCreated) ProtoMessage() {}
func (x *RoomCreated) ProtoReflect() protoreflect.Message { func (x *RoomCreated) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1] mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -222,9 +226,11 @@ type RoomUserJoined struct {
func (x *RoomUserJoined) Reset() { func (x *RoomUserJoined) Reset() {
*x = RoomUserJoined{} *x = RoomUserJoined{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[2] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomUserJoined) String() string { func (x *RoomUserJoined) String() string {
@ -235,7 +241,7 @@ func (*RoomUserJoined) ProtoMessage() {}
func (x *RoomUserJoined) ProtoReflect() protoreflect.Message { func (x *RoomUserJoined) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2] mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -275,9 +281,11 @@ type RoomUserLeft struct {
func (x *RoomUserLeft) Reset() { func (x *RoomUserLeft) Reset() {
*x = RoomUserLeft{} *x = RoomUserLeft{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[3] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomUserLeft) String() string { func (x *RoomUserLeft) String() string {
@ -288,7 +296,7 @@ func (*RoomUserLeft) ProtoMessage() {}
func (x *RoomUserLeft) ProtoReflect() protoreflect.Message { func (x *RoomUserLeft) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3] mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -322,9 +330,11 @@ type RoomClosed struct {
func (x *RoomClosed) Reset() { func (x *RoomClosed) Reset() {
*x = RoomClosed{} *x = RoomClosed{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[4] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomClosed) String() string { func (x *RoomClosed) String() string {
@ -335,7 +345,7 @@ func (*RoomClosed) ProtoMessage() {}
func (x *RoomClosed) ProtoReflect() protoreflect.Message { func (x *RoomClosed) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4] mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -387,9 +397,11 @@ type RoomMicChanged struct {
func (x *RoomMicChanged) Reset() { func (x *RoomMicChanged) Reset() {
*x = RoomMicChanged{} *x = RoomMicChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[5] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomMicChanged) String() string { func (x *RoomMicChanged) String() string {
@ -400,7 +412,7 @@ func (*RoomMicChanged) ProtoMessage() {}
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message { func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5] mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -498,9 +510,11 @@ type RoomMicSeatLocked struct {
func (x *RoomMicSeatLocked) Reset() { func (x *RoomMicSeatLocked) Reset() {
*x = RoomMicSeatLocked{} *x = RoomMicSeatLocked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[6] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomMicSeatLocked) String() string { func (x *RoomMicSeatLocked) String() string {
@ -511,7 +525,7 @@ func (*RoomMicSeatLocked) ProtoMessage() {}
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message { func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6] mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -559,9 +573,11 @@ type RoomChatEnabledChanged struct {
func (x *RoomChatEnabledChanged) Reset() { func (x *RoomChatEnabledChanged) Reset() {
*x = RoomChatEnabledChanged{} *x = RoomChatEnabledChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[7] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomChatEnabledChanged) String() string { func (x *RoomChatEnabledChanged) String() string {
@ -572,7 +588,7 @@ func (*RoomChatEnabledChanged) ProtoMessage() {}
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message { func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7] mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -614,9 +630,11 @@ type RoomAdminChanged struct {
func (x *RoomAdminChanged) Reset() { func (x *RoomAdminChanged) Reset() {
*x = RoomAdminChanged{} *x = RoomAdminChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[8] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomAdminChanged) String() string { func (x *RoomAdminChanged) String() string {
@ -627,7 +645,7 @@ func (*RoomAdminChanged) ProtoMessage() {}
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message { func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8] mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -676,9 +694,11 @@ type RoomHostTransferred struct {
func (x *RoomHostTransferred) Reset() { func (x *RoomHostTransferred) Reset() {
*x = RoomHostTransferred{} *x = RoomHostTransferred{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[9] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomHostTransferred) String() string { func (x *RoomHostTransferred) String() string {
@ -689,7 +709,7 @@ func (*RoomHostTransferred) ProtoMessage() {}
func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message { func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9] mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -738,9 +758,11 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() { func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{} *x = RoomUserMuted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[10] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomUserMuted) String() string { func (x *RoomUserMuted) String() string {
@ -751,7 +773,7 @@ func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message { func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10] mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -799,9 +821,11 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() { func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{} *x = RoomUserKicked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomUserKicked) String() string { func (x *RoomUserKicked) String() string {
@ -812,7 +836,7 @@ func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message { func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11] mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -853,9 +877,11 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() { func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{} *x = RoomUserUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomUserUnbanned) String() string { func (x *RoomUserUnbanned) String() string {
@ -866,7 +892,7 @@ func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message { func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12] mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -907,13 +933,17 @@ type RoomGiftSent struct {
GiftCount int32 `protobuf:"varint,4,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"` GiftCount int32 `protobuf:"varint,4,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
GiftValue int64 `protobuf:"varint,5,opt,name=gift_value,json=giftValue,proto3" json:"gift_value,omitempty"` GiftValue int64 `protobuf:"varint,5,opt,name=gift_value,json=giftValue,proto3" json:"gift_value,omitempty"`
BillingReceiptId string `protobuf:"bytes,6,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"` BillingReceiptId string `protobuf:"bytes,6,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
VisibleRegionId int64 `protobuf:"varint,7,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
CommandId string `protobuf:"bytes,8,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
} }
func (x *RoomGiftSent) Reset() { func (x *RoomGiftSent) Reset() {
*x = RoomGiftSent{} *x = RoomGiftSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomGiftSent) String() string { func (x *RoomGiftSent) String() string {
@ -924,7 +954,7 @@ func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message { func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13] mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -981,6 +1011,20 @@ func (x *RoomGiftSent) GetBillingReceiptId() string {
return "" return ""
} }
func (x *RoomGiftSent) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
func (x *RoomGiftSent) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
// RoomHeatChanged 表达热度变化结果。 // RoomHeatChanged 表达热度变化结果。
type RoomHeatChanged struct { type RoomHeatChanged struct {
state protoimpl.MessageState state protoimpl.MessageState
@ -993,9 +1037,11 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() { func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{} *x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomHeatChanged) String() string { func (x *RoomHeatChanged) String() string {
@ -1006,7 +1052,7 @@ func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message { func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14] mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -1048,9 +1094,11 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() { func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{} *x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RoomRankChanged) String() string { func (x *RoomRankChanged) String() string {
@ -1061,7 +1109,7 @@ func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message { func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15] mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -1213,8 +1261,8 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xdf, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xaa,
0x01, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x02, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12,
0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
@ -1228,21 +1276,26 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65,
0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10,
0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64,
0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69,
0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73,
0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x0f, 0x52,
0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14,
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64,
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72,
0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x31, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01,
0x6f, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67,
0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31,
0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
} }
var ( var (
@ -1289,6 +1342,200 @@ func file_proto_events_room_v1_events_proto_init() {
if File_proto_events_room_v1_events_proto != nil { if File_proto_events_room_v1_events_proto != nil {
return return
} }
if !protoimpl.UnsafeEnabled {
file_proto_events_room_v1_events_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*EventEnvelope); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*RoomCreated); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserJoined); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserLeft); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*RoomClosed); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicSeatLocked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*RoomChatEnabledChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*RoomAdminChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RoomHostTransferred); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserMuted); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserKicked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[12].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserUnbanned); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[13].Exporter = func(v any, i int) any {
switch v := v.(*RoomGiftSent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[14].Exporter = func(v any, i int) any {
switch v := v.(*RoomHeatChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[15].Exporter = func(v any, i int) any {
switch v := v.(*RoomRankChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{

View File

@ -116,6 +116,8 @@ message RoomGiftSent {
int32 gift_count = 4; int32 gift_count = 4;
int64 gift_value = 5; int64 gift_value = 5;
string billing_receipt_id = 6; string billing_receipt_id = 6;
int64 visible_region_id = 7;
string command_id = 8;
} }
// RoomHeatChanged // RoomHeatChanged

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/room/v1/room.proto // source: proto/room/v1/room.proto
package roomv1 package roomv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.35.1 // protoc-gen-go v1.34.2
// protoc v4.25.3 // protoc v5.29.2
// source: proto/user/v1/auth.proto // source: proto/user/v1/auth.proto
package userv1 package userv1
@ -33,9 +33,11 @@ type LoginPasswordRequest struct {
func (x *LoginPasswordRequest) Reset() { func (x *LoginPasswordRequest) Reset() {
*x = LoginPasswordRequest{} *x = LoginPasswordRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[0] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[0]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *LoginPasswordRequest) String() string { func (x *LoginPasswordRequest) String() string {
@ -46,7 +48,7 @@ func (*LoginPasswordRequest) ProtoMessage() {}
func (x *LoginPasswordRequest) ProtoReflect() protoreflect.Message { func (x *LoginPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[0] mi := &file_proto_user_v1_auth_proto_msgTypes[0]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -112,9 +114,11 @@ type LoginThirdPartyRequest struct {
func (x *LoginThirdPartyRequest) Reset() { func (x *LoginThirdPartyRequest) Reset() {
*x = LoginThirdPartyRequest{} *x = LoginThirdPartyRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[1] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[1]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *LoginThirdPartyRequest) String() string { func (x *LoginThirdPartyRequest) String() string {
@ -125,7 +129,7 @@ func (*LoginThirdPartyRequest) ProtoMessage() {}
func (x *LoginThirdPartyRequest) ProtoReflect() protoreflect.Message { func (x *LoginThirdPartyRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[1] mi := &file_proto_user_v1_auth_proto_msgTypes[1]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -294,9 +298,11 @@ type AuthResponse struct {
func (x *AuthResponse) Reset() { func (x *AuthResponse) Reset() {
*x = AuthResponse{} *x = AuthResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[2] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[2]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *AuthResponse) String() string { func (x *AuthResponse) String() string {
@ -307,7 +313,7 @@ func (*AuthResponse) ProtoMessage() {}
func (x *AuthResponse) ProtoReflect() protoreflect.Message { func (x *AuthResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[2] mi := &file_proto_user_v1_auth_proto_msgTypes[2]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -363,9 +369,11 @@ type SetPasswordRequest struct {
func (x *SetPasswordRequest) Reset() { func (x *SetPasswordRequest) Reset() {
*x = SetPasswordRequest{} *x = SetPasswordRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[3] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[3]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *SetPasswordRequest) String() string { func (x *SetPasswordRequest) String() string {
@ -376,7 +384,7 @@ func (*SetPasswordRequest) ProtoMessage() {}
func (x *SetPasswordRequest) ProtoReflect() protoreflect.Message { func (x *SetPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[3] mi := &file_proto_user_v1_auth_proto_msgTypes[3]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -423,9 +431,11 @@ type SetPasswordResponse struct {
func (x *SetPasswordResponse) Reset() { func (x *SetPasswordResponse) Reset() {
*x = SetPasswordResponse{} *x = SetPasswordResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[4] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[4]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *SetPasswordResponse) String() string { func (x *SetPasswordResponse) String() string {
@ -436,7 +446,7 @@ func (*SetPasswordResponse) ProtoMessage() {}
func (x *SetPasswordResponse) ProtoReflect() protoreflect.Message { func (x *SetPasswordResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[4] mi := &file_proto_user_v1_auth_proto_msgTypes[4]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -470,9 +480,11 @@ type RefreshTokenRequest struct {
func (x *RefreshTokenRequest) Reset() { func (x *RefreshTokenRequest) Reset() {
*x = RefreshTokenRequest{} *x = RefreshTokenRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[5] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RefreshTokenRequest) String() string { func (x *RefreshTokenRequest) String() string {
@ -483,7 +495,7 @@ func (*RefreshTokenRequest) ProtoMessage() {}
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message { func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[5] mi := &file_proto_user_v1_auth_proto_msgTypes[5]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -523,9 +535,11 @@ type RefreshTokenResponse struct {
func (x *RefreshTokenResponse) Reset() { func (x *RefreshTokenResponse) Reset() {
*x = RefreshTokenResponse{} *x = RefreshTokenResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[6] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RefreshTokenResponse) String() string { func (x *RefreshTokenResponse) String() string {
@ -536,7 +550,7 @@ func (*RefreshTokenResponse) ProtoMessage() {}
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message { func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[6] mi := &file_proto_user_v1_auth_proto_msgTypes[6]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -571,9 +585,11 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() { func (x *LogoutRequest) Reset() {
*x = LogoutRequest{} *x = LogoutRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[7] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *LogoutRequest) String() string { func (x *LogoutRequest) String() string {
@ -584,7 +600,7 @@ func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message { func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[7] mi := &file_proto_user_v1_auth_proto_msgTypes[7]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -631,9 +647,11 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() { func (x *LogoutResponse) Reset() {
*x = LogoutResponse{} *x = LogoutResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[8] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *LogoutResponse) String() string { func (x *LogoutResponse) String() string {
@ -644,7 +662,7 @@ func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message { func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[8] mi := &file_proto_user_v1_auth_proto_msgTypes[8]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -682,9 +700,11 @@ type RecordLoginBlockedRequest struct {
func (x *RecordLoginBlockedRequest) Reset() { func (x *RecordLoginBlockedRequest) Reset() {
*x = RecordLoginBlockedRequest{} *x = RecordLoginBlockedRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[9] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RecordLoginBlockedRequest) String() string { func (x *RecordLoginBlockedRequest) String() string {
@ -695,7 +715,7 @@ func (*RecordLoginBlockedRequest) ProtoMessage() {}
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message { func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[9] mi := &file_proto_user_v1_auth_proto_msgTypes[9]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -763,9 +783,11 @@ type RecordLoginBlockedResponse struct {
func (x *RecordLoginBlockedResponse) Reset() { func (x *RecordLoginBlockedResponse) Reset() {
*x = RecordLoginBlockedResponse{} *x = RecordLoginBlockedResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[10] if protoimpl.UnsafeEnabled {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms.StoreMessageInfo(mi) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
} }
func (x *RecordLoginBlockedResponse) String() string { func (x *RecordLoginBlockedResponse) String() string {
@ -776,7 +798,7 @@ func (*RecordLoginBlockedResponse) ProtoMessage() {}
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message { func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[10] mi := &file_proto_user_v1_auth_proto_msgTypes[10]
if x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
@ -1017,6 +1039,140 @@ func file_proto_user_v1_auth_proto_init() {
return return
} }
file_proto_user_v1_user_proto_init() file_proto_user_v1_user_proto_init()
if !protoimpl.UnsafeEnabled {
file_proto_user_v1_auth_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*LoginPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*LoginThirdPartyRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*AuthResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*LogoutRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*LogoutResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/user/v1/auth.proto // source: proto/user/v1/auth.proto
package userv1 package userv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/user/v1/host.proto // source: proto/user/v1/host.proto
package userv1 package userv1

File diff suppressed because it is too large Load Diff

View File

@ -225,6 +225,9 @@ message ChangeUserCountryRequest {
message ChangeUserCountryResponse { message ChangeUserCountryResponse {
User user = 1; User user = 1;
int64 next_change_allowed_at_ms = 2; int64 next_change_allowed_at_ms = 2;
int64 old_region_id = 3;
int64 new_region_id = 4;
bool region_changed = 5;
} }
// CompleteOnboardingRequest // CompleteOnboardingRequest

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/user/v1/user.proto // source: proto/user/v1/user.proto
package userv1 package userv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v5.29.2
// source: proto/wallet/v1/wallet.proto // source: proto/wallet/v1/wallet.proto
package walletv1 package walletv1

View File

@ -4,6 +4,8 @@ services:
context: . context: .
dockerfile: services/gateway-service/Dockerfile dockerfile: services/gateway-service/Dockerfile
container_name: gateway-service container_name: gateway-service
environment:
TZ: UTC
ports: ports:
- "13000:13000" - "13000:13000"
depends_on: depends_on:
@ -25,6 +27,8 @@ services:
context: . context: .
dockerfile: services/room-service/Dockerfile dockerfile: services/room-service/Dockerfile
container_name: room-service container_name: room-service
environment:
TZ: UTC
ports: ports:
- "13001:13001" - "13001:13001"
depends_on: depends_on:
@ -46,6 +50,8 @@ services:
context: . context: .
dockerfile: services/wallet-service/Dockerfile dockerfile: services/wallet-service/Dockerfile
container_name: wallet-service container_name: wallet-service
environment:
TZ: UTC
ports: ports:
- "13004:13004" - "13004:13004"
depends_on: depends_on:
@ -67,6 +73,8 @@ services:
context: . context: .
dockerfile: services/user-service/Dockerfile dockerfile: services/user-service/Dockerfile
container_name: user-service container_name: user-service
environment:
TZ: UTC
ports: ports:
- "13005:13005" - "13005:13005"
depends_on: depends_on:
@ -84,6 +92,8 @@ services:
context: . context: .
dockerfile: services/activity-service/Dockerfile dockerfile: services/activity-service/Dockerfile
container_name: activity-service container_name: activity-service
environment:
TZ: UTC
ports: ports:
- "13006:13006" - "13006:13006"
depends_on: depends_on:
@ -103,6 +113,8 @@ services:
context: . context: .
dockerfile: services/cron-service/Dockerfile dockerfile: services/cron-service/Dockerfile
container_name: cron-service container_name: cron-service
environment:
TZ: UTC
ports: ports:
- "13007:13007" - "13007:13007"
depends_on: depends_on:
@ -125,7 +137,9 @@ services:
command: command:
- "--character-set-server=utf8mb4" - "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci" - "--collation-server=utf8mb4_unicode_ci"
- "--default-time-zone=+00:00"
environment: environment:
TZ: UTC
MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hyapp_room MYSQL_DATABASE: hyapp_room
MYSQL_USER: hyapp MYSQL_USER: hyapp
@ -152,6 +166,8 @@ services:
redis: redis:
image: redis:7.4-alpine image: redis:7.4-alpine
container_name: hyapp-redis container_name: hyapp-redis
environment:
TZ: UTC
ports: ports:
- "13379:6379" - "13379:6379"
healthcheck: healthcheck:

View File

@ -137,7 +137,7 @@ service_name: cron-service
node_id: cron-local node_id: cron-local
environment: local environment: local
grpc_addr: ":13007" grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "127.0.0.1:13005" user_service_addr: "127.0.0.1:13005"
activity_service_addr: "127.0.0.1:13006" activity_service_addr: "127.0.0.1:13006"

View File

@ -6,7 +6,7 @@
- 后台增加 `活动管理` 一级菜单,`每日任务` 作为活动管理下的二级菜单。 - 后台增加 `活动管理` 一级菜单,`每日任务` 作为活动管理下的二级菜单。
- `每日任务` 页面包含两个 Tab`每日任务``专属任务` - `每日任务` 页面包含两个 Tab`每日任务``专属任务`
- 每日任务按服务器统一时区每天刷新,当天只能领取一次,超额完成不结转到明天。 - 每日任务按 UTC 自然日刷新,当天只能领取一次,超额完成不结转到明天。
- 专属任务是用户维度终身只能完成并领取一次的任务。 - 专属任务是用户维度终身只能完成并领取一次的任务。
- 任务奖励统一发金币,金币发放必须走 `wallet-service` 账本。 - 任务奖励统一发金币,金币发放必须走 `wallet-service` 账本。
- 任务进度由服务端事实事件驱动,客户端不能上报任务进度。 - 任务进度由服务端事实事件驱动,客户端不能上报任务进度。
@ -72,7 +72,7 @@ flowchart LR
上麦任务默认读取 `user_mic_daily_stats.mic_online_ms` 或消费 `UserMicSessionClosed` 派生事件。`mic_online_ms` 只从确认发流开始累计未发声、只占麦、pending_publish 不计入有效麦上时间。 上麦任务默认读取 `user_mic_daily_stats.mic_online_ms` 或消费 `UserMicSessionClosed` 派生事件。`mic_online_ms` 只从确认发流开始累计未发声、只占麦、pending_publish 不计入有效麦上时间。
跨天上麦必须按服务器任务时区拆分到不同 `task_day`,不能把整段时长归到下麦当天。 跨天上麦必须按 UTC 自然日拆分到不同 `task_day`,不能把整段时长归到下麦当天。
### 幸运礼物任务口径 ### 幸运礼物任务口径
@ -89,9 +89,9 @@ flowchart LR
### 每日任务 ### 每日任务
- 周期 key 使用服务器统一时区计算,例如 `Asia/Shanghai` 下的 `2026-05-09` - 周期 key 使用 UTC 自然日计算,例如 UTC 下的 `2026-05-09`
- 服务器时区是系统配置,不使用用户手机时区。 - 任务周期不读取机器本地时区、用户手机时区或请求 IP 所在时区。
- 事件归属日按 `occurred_at_ms`服务器时区计算,不按消费时间。 - 事件归属日按 `occurred_at_ms` UTC 计算,不按消费时间。
- 用户当天完成后只能领取一次。 - 用户当天完成后只能领取一次。
- 当天超额完成只保留当天进度,不结转到明天。 - 当天超额完成只保留当天进度,不结转到明天。
- 每日刷新不需要批量清空用户数据;查询时按当前 `task_day` 读取或懒创建进度。 - 每日刷新不需要批量清空用户数据;查询时按当前 `task_day` 读取或懒创建进度。
@ -288,7 +288,7 @@ UNIQUE(app_code, user_id, task_id, cycle_key)
| `event_type` | 来源事件类型 | | `event_type` | 来源事件类型 |
| `source_service` | room / wallet / user | | `source_service` | room / wallet / user |
| `user_id` | 事件归属用户 | | `user_id` | 事件归属用户 |
| `task_day` | 服务器时区日期 | | `task_day` | UTC 日期 |
| `status` | consumed / skipped / failed | | `status` | consumed / skipped / failed |
| `skip_reason` | 跳过原因 | | `skip_reason` | 跳过原因 |
| `created_at_ms` | 创建时间 | | `created_at_ms` | 创建时间 |
@ -390,22 +390,16 @@ sequenceDiagram
## 边界和补充规则 ## 边界和补充规则
### 1. 服务器时区 ### 1. UTC 周期
必须定义全局任务时区,例如: 所有 daily 周期都按 UTC 计算:
```yaml
task_timezone: "Asia/Shanghai"
```
所有 daily 周期都按这个时区计算:
- `task_day` - `task_day`
- `cycle_start_ms` - `cycle_start_ms`
- `cycle_end_ms` - `cycle_end_ms`
- `next_refresh_at_ms` - `next_refresh_at_ms`
不要使用用户手机时区,不要使用请求 IP 所在时区。 不要使用用户手机时区、请求 IP 所在时区或服务器部署所在地时区。
### 2. 任务定义修改 ### 2. 任务定义修改
@ -462,7 +456,7 @@ CP 任务以成功组成 CP 的事件为准。后续解除 CP 不回滚已完成
跨天麦位 session 必须拆分到不同 `task_day` 跨天麦位 session 必须拆分到不同 `task_day`
例如服务器时区 23:50 上麦,00:10 下麦: 例如 UTC 23:50 上麦,次日 UTC 00:10 下麦:
```text ```text
前一天 +10 分钟 前一天 +10 分钟
@ -571,9 +565,8 @@ task_definitions + user_task_progress
## 需要提前确认的问题 ## 需要提前确认的问题
1. 服务器任务时区使用哪个 IANA 时区,是否所有 App 共用。 1. 幸运礼物任务首版是“消耗金币”或“中奖金币”二选一,还是需要 AND 条件。
2. 幸运礼物任务首版是“消耗金币”或“中奖金币”二选一,还是需要 AND 条件。 2. 充值任务是否包含币商转金币、后台补偿、活动赠币;建议首版只包含真实充值和币商转普通金币。
3. 充值任务是否包含币商转金币、后台补偿、活动赠币;建议首版只包含真实充值和币商转普通金币。 3. daily 任务是否允许次日补领;建议首版不允许。
4. daily 任务是否允许次日补领;建议首版不允许。 4. 专属任务是否全体用户可见,还是需要后台指定用户或人群。
5. 专属任务是否全体用户可见,还是需要后台指定用户或人群。 5. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。
6. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。

View File

@ -90,7 +90,7 @@ admin_operation_logs(
resource_id VARCHAR(80) NOT NULL DEFAULT '', resource_id VARCHAR(80) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
detail TEXT, detail TEXT,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
INDEX idx_admin_operation_logs_request_id (request_id), INDEX idx_admin_operation_logs_request_id (request_id),
INDEX idx_admin_operation_logs_resource_id (resource_id), INDEX idx_admin_operation_logs_resource_id (resource_id),
INDEX idx_admin_operation_logs_action (action) INDEX idx_admin_operation_logs_action (action)

View File

@ -0,0 +1,462 @@
# IM 全局/区域播报群架构
本文定义腾讯云 IM 全局/区域播报群的架构。它服务红包、区域运营通知、贵重礼物播报等实时入口消息,不替代房间群、公屏、系统 inbox、钱包账务或 Room Cell 状态。
相关文档:
```text
docs/voice-room-basic-loop-implementation.md
docs/voice-room-client-api-flow.md
docs/app-message-tab-architecture.md
docs/room-outbox-compensation-development.md
```
## Current State
当前仓库只有房间 IM 群:
| Capability | Current Owner | Current Rule |
| --- | --- | --- |
| IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` |
| 房间群建群 | `room-service` | `CreateRoom` 调腾讯 IM REST `create_group``GroupID = room_id` |
| 房间系统消息 | `room-service` | Room Cell 命令成功后向 `GroupID = room_id``room_*` 自定义消息 |
| IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard |
| 系统/活动 inbox | `activity-service` | backend inbox/fanout非实时 IM 群 |
因此,直接新建 `global``region` 群会被现有 IM callback 误判为房间群并拒绝。必须先增加 GroupID 类型识别和 callback 分流。
## Scope
本阶段新增:
| Capability | Owner | Required Result |
| --- | --- | --- |
| 全局/区域 GroupID 规则 | shared package | 任何服务都用同一套解析和生成规则 |
| 播报群建群 reconciler | `activity-service` | 启动和定时补齐全局群、active 区域群 |
| `/im/usersig` join 列表 | `gateway-service` | 返回当前用户应加入的全局/区域播报群 |
| IM callback 分流 | `gateway-service` | 房间群走 room guard播报群走用户状态/区域校验 |
| 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 |
| 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` |
| 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 |
本阶段不做:
| Excluded | Reason |
| --- | --- |
| 把全局/区域群状态放进 `room-service` | `room-service` 只拥有房间状态和房间群桥接 |
| 用 IM 群承载红包资金事实 | 红包金额、领取、退款和并发扣减必须由 wallet/red-packet 领域持久化 |
| 为每个红包建群 | 红包是业务消息,不是会话边界 |
| 客户端自报区域入群 | 用户区域归属只来自 `user-service` |
| 普通用户向播报群发消息 | 播报群是服务端只写通道 |
## GroupID Rules
GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID 长度约束。推荐规则:
| Group Type | GroupID | Meaning |
| --- | --- | --- |
| room | `<room_id>` | 现有房间群,必须满足 `pkg/roomid` 规则 |
| global_broadcast | `hy_<app_code>_bc_g` | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
规则:
- `app_code` 必须使用 `appcode.Normalize` 后的值。
- `region_id <= 0` 不能生成区域播报群。
- GroupID 解析必须返回 `{type, app_code, region_id}`,不能靠调用方手写字符串判断。
- 全局/区域播报 GroupID 不得被 `roomid.ValidStringID` 误判后进入 room guardcallback 必须先识别 broadcast 前缀。
- 如果未来 `app_code` 可能很长,需要在 App 注册表中约束可用于 GroupID 的短码,不能截断后拼接。
建议新增共享包:
```text
pkg/imgroup
```
核心 API
```go
type Kind string
const (
KindRoom Kind = "room"
KindGlobalBroadcast Kind = "global_broadcast"
KindRegionBroadcast Kind = "region_broadcast"
)
type ParsedGroup struct {
Kind Kind
AppCode string
RegionID int64
RoomID string
}
func GlobalBroadcastGroupID(appCode string) (string, error)
func RegionBroadcastGroupID(appCode string, regionID int64) (string, error)
func Parse(groupID string) ParsedGroup
```
## Ownership Boundaries
| Area | Owner | Rule |
| --- | --- | --- |
| 用户身份、资料完成、区域归属 | `user-service` | gateway callback 和 `/im/usersig` 只信 user-service |
| 房间群、公屏、房间系统消息 | `room-service` | `GroupID = room_id`,仍由 Room Cell 和 room outbox 驱动 |
| 全局/区域播报群 | `activity-service` | 作为消息/播报能力,不写 Room Cell 核心状态 |
| 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 |
| 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket |
`room-service` 只需要继续产出 `RoomGiftSent` outbox 事实。贵重礼物是否需要区域播报,由 `activity-service` 消费 outbox 后判断,这样不会把区域播报策略塞进 Room Cell 主链路。
## Client Flow
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant U as user-service
participant IM as Tencent IM SDK
C->>G: GET /api/v1/im/usersig
G->>U: GetUser(user_id)
U-->>G: user.region_id, profile_completed
G-->>C: UserSig + join_groups
C->>IM: login(user_id, user_sig)
C->>IM: joinGroup(hy_<app>_bc_g)
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
```
`/api/v1/im/usersig` response 增加 `join_groups`,客户端必须按返回列表加入播报群:
```json
{
"sdk_app_id": 20036101,
"user_id": "42",
"user_sig": "xxx",
"expire_at_ms": 1770000000000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"type": "global_broadcast"
},
{
"group_id": "hy_lalu_bc_r_1001",
"type": "region_broadcast",
"region_id": 1001
}
]
}
```
客户端规则:
- 用户完成资料后才允许请求 IM UserSig。
- 登录 IM 后先 join `join_groups`,进入房间时再 join 房间群。
- 区域变化后,下一次 `/im/usersig` 或 profile refresh 应返回新区域群;客户端应退出旧区域群并加入新区域群。
- 全局群可配置为默认返回或按开关返回;区域群是主要实时播报通道。
## User Region Change Flow
用户国家变化可能改变 `users.region_id`。区域变化时要更新的是“这个用户在旧区域群里的成员关系”,不是区域群生命周期:
```text
不能做:
destroy_group(hy_<app>_bc_r_<old_region_id>)
或清空旧区域群成员
应该做:
delete_group_member(group_id=hy_<app>_bc_r_<old_region_id>, user_id=<user_id>)
```
职责拆分:
| Step | Owner | Rule |
| --- | --- | --- |
| App 用户改国家 | `gateway-service -> user-service` | `user-service` 更新 `country/region_id` 并返回 old/new region |
| 后台改用户国家 | `hyapp-admin-server` | 后台写 `users.country/region_id` 后对比 old/new region |
| 移除旧区域成员关系 | `activity-service` | 调腾讯云 IM `delete_group_member`,只移除该 user不删除群 |
| 加入新区群 | App | 重新拉 profile 或 `/im/usersig`,按新的 `join_groups` join 新区群 |
| 准入兜底 | `gateway-service` callback | 用户再次尝试 join 旧区域群时按当前 `user.region_id` 拒绝 |
实现规则:
- `activity-service` 暴露 `BroadcastService.RemoveRegionBroadcastMember`,入参是 `user_id + old_region_id`
- `old_region_id <= 0` 是 no-op因为 GLOBAL/未知区域没有区域播报群。
- `gateway-service``hyapp-admin-server` 在国家修改成功后 best-effort 调用该 RPCIM 外部副作用失败不回滚已提交的用户主数据。
- 失败必须写结构化日志,后续可由后台补偿或用户下次刷新 join 列表修正callback 仍然保证用户不能重新加入错误区域群。
- 区域群由 reconciler 长期维护,不因为单个用户离开而解散。
## Group Lifecycle
播报群建群不应该在 `/im/usersig` 请求路径同步调用腾讯 REST避免登录入口被外部建群耗时拖垮。
推荐由 `activity-service` 增加 broadcast group reconciler
| Trigger | Action |
| --- | --- |
| service startup | ensure `hy_<app>_bc_g` and all active region groups |
| periodic reconcile | 扫 active regions补齐缺失群 |
| region created/enabled | ensure corresponding region group |
| region disabled | 停止返回该区域群;是否解散群按运营策略单独处理 |
Tencent REST 建群必须幂等:
- 已存在视为成功。
- 建群失败写结构化日志和指标,等待下一轮 reconcile。
- 不把 SecretKey、UserSig、完整外部响应写入业务错误。
播报群建议使用腾讯 IM 普通群能力,并开启 callback
- `ApplyJoinOption` 允许用户申请/加入,但最终由 callback 判定。
- 普通用户发言由 `CallbackBeforeSendMsg` 拒绝。
- 服务端通过管理员 REST `send_group_msg` 发布自定义消息。
## Callback Routing
gateway 的腾讯 IM callback 必须先解析 `GroupId`
```text
GroupID = room_id
Join: VerifyRoomPresence(room_id, user_id)
Send: CheckSpeakPermission(room_id, user_id)
GroupID = hy_<app>_bc_g
Join: user exists + profile_completed + app_code matched
Send: reject ordinary users; allow Tencent admin/server sender only
GroupID = hy_<app>_bc_r_<region_id>
Join: user exists + profile_completed + user.region_id == region_id + app_code matched
Send: reject ordinary users; allow Tencent admin/server sender only
```
失败策略:
- callback 鉴权失败必须 fail-closed。
- SDKAppID 不匹配必须 fail-closed。
- 无法解析用户 ID 必须拒绝。
- user-service 不可用时,播报群 join 必须拒绝;不能用客户端缓存区域兜底。
- 未知 GroupID 类型可按 room 群处理,但不得绕过现有 room guard。
## Broadcast Message Contract
所有播报都使用 `TIMCustomElem``CloudCustomData` 带完整 JSON。客户端只根据 `broadcast_type``action.type` 分发 UI 行为。
公共字段:
```json
{
"event_id": "stable-id",
"broadcast_type": "super_gift",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_room_xxx",
"sender_user_id": 42,
"sent_at_ms": 1770000000000,
"action": {
"type": "enter_room",
"room_id": "lalu_room_xxx"
}
}
```
贵重礼物播报:
```json
{
"event_id": "gift_broadcast:lalu_room_xxx:cmd_send_gift_1",
"broadcast_type": "super_gift",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_room_xxx",
"sender_user_id": 42,
"target_user_id": 43,
"gift_id": "gift_rocket",
"gift_count": 1,
"gift_value": 999999,
"room_title": "Room title",
"sent_at_ms": 1770000000000,
"action": {
"type": "enter_room",
"room_id": "lalu_room_xxx"
}
}
```
红包播报:
```json
{
"event_id": "red_packet:rp_1000001",
"broadcast_type": "red_packet",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_room_xxx",
"red_packet_id": "rp_1000001",
"sender_user_id": 42,
"sent_at_ms": 1770000000000,
"action": {
"type": "open_red_packet",
"red_packet_id": "rp_1000001",
"room_id": "lalu_room_xxx"
}
}
```
客户端点击播报进入房间时,仍必须走正常房间入口:
```text
JoinRoom(room_id) -> join room IM group -> RTC token if needed
```
播报消息不能直接授予进房、抢红包或发言权限。
## Server Broadcast Sending
建议 `activity-service` 增加 `BroadcastService` 内部能力:
```text
PublishRegionBroadcast(app_code, region_id, payload)
PublishGlobalBroadcast(app_code, payload)
EnsureBroadcastGroups(app_code)
```
实现规则:
- 发送前通过 `pkg/imgroup` 生成 GroupID。
- payload 必须包含稳定 `event_id`
- REST 发送失败不能影响原业务事实提交。
- 重试必须依赖本地 outbox不能只依赖 Tencent REST 返回。
建议新增表:
```sql
CREATE TABLE im_broadcast_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
scope VARCHAR(32) NOT NULL,
group_id VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error VARCHAR(512) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms)
);
```
如果第一阶段不落表,也必须明确只支持 best-effort 实时播报;红包入口和重要运营消息不能依赖 best-effort。
## High Value Gift Broadcast
推荐链路:
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant A as activity-service
participant IM as Tencent IM
C->>G: POST /api/v1/rooms/gift/send
G->>R: SendGift
R->>W: DebitGift
W-->>R: receipt + settlement
R->>R: Room Cell heat/rank + RoomGiftSent outbox
R-->>G: SendGiftResponse
G-->>C: room result
A->>R: consume room outbox / event stream
A->>A: threshold decision
A->>IM: send_group_msg(region broadcast group)
```
阈值策略属于播报配置,不属于 Room Cell
| Config | Meaning |
| --- | --- |
| `super_gift_min_value` | 单次礼物总价值达到该值才播报 |
| `broadcast_scope` | `region` by default, `global` only for special campaign |
| `cooldown_ms` | 同一房间/同一用户短时间内去重,避免刷屏 |
`RoomGiftSent` 必须携带足够字段让 activity-service 判断:
- `room_id`
- `visible_region_id`
- `sender_user_id`
- `target_user_id`
- `gift_id`
- `gift_count`
- `gift_value` or `gift_point_added`
- `command_id`
- `room_version`
如果现有事件字段不足,应优先扩展 protobuf event而不是让 activity-service 反查多个服务拼凑事实。
## Red Packet Broadcast
红包资金事实必须由 wallet/red-packet 领域持久化:
| Fact | Owner |
| --- | --- |
| 红包创建、总金额、份数 | wallet/red-packet domain |
| 抢红包并发扣减 | wallet/red-packet domain |
| 领取记录 | wallet/red-packet domain |
| 过期退款 | wallet/red-packet domain + cron |
| IM 播报 | activity-service broadcast |
红包创建成功后,红包领域写 `RedPacketCreated` 事件activity-service 转成区域/全局播报。客户端点击播报只打开红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。
## App Code And Region Safety
- `app_code` 必须从 request context 或服务配置取得,不能从客户端 body 读取。
- 区域群 join 校验必须使用 user-service 当前 `region_id`
- 用户改国家/区域后,应在下一次 token refresh 或 profile refresh 中返回新区域群。
- 旧区域群成员可能短期残留,因此敏感消息不能只靠 IM 群成员做授权;客户端点击后的后端接口仍要校验当前用户区域/资格。
## Observability
必须打点:
| Metric / Log | Labels |
| --- | --- |
| `im_broadcast_group_ensure_total` | `app_code, group_type, result` |
| `im_broadcast_send_total` | `app_code, scope, result, broadcast_type` |
| `im_broadcast_outbox_pending` | `app_code, scope` |
| `im_callback_route_total` | `group_type, command, result` |
| `im_callback_region_denied_total` | `app_code, region_id, reason` |
日志必须包含 `request_id/event_id/group_id/app_code/region_id`,不能包含 Tencent SecretKey、UserSig 或完整私密响应。
## Implementation Sequence
1. 新增 `pkg/imgroup`,覆盖 global/region/room GroupID 生成和解析单测。
2. 扩展 `pkg/tencentim.RESTClient`,增加通用 `EnsureGroup``PublishGroupCustomMessage`,保留现有 `EnsureRoomGroup/PublishRoomEvent` wrapper。
3. `gateway-service` 扩展 `/api/v1/im/usersig`,查询 user-service 并返回 `join_groups`
4. `gateway-service` 改造 Tencent IM callback 分流,房间群和播报群走不同 guard。
5. `activity-service` 增加 broadcast group reconciler启动时确保全局和 active 区域群。
6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。
7. 扩展 `RoomGiftSent` 事件字段activity-service 消费后按阈值发 `super_gift` 区域播报。
8. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。
## Test Expectations
必须覆盖:
| Test | Expected |
| --- | --- |
| GroupID parse/generate | global、region、room 可稳定识别;非法 region 拒绝 |
| `/im/usersig` | 已完成资料用户返回 global + current region group |
| IM callback room group | 仍走 `VerifyRoomPresence` / `CheckSpeakPermission` |
| IM callback region join | region 匹配允许,不匹配拒绝 |
| IM callback broadcast send | 普通用户拒绝,服务端 sender 允许 |
| group reconciler | 已存在群视为成功,失败可重试 |
| broadcast outbox | 同一 `event_id` 幂等,失败后 retry |
| super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 |
| red packet | IM payload 不影响领取接口的后端金额校验 |

View File

@ -120,7 +120,7 @@ stateDiagram-v2
规则: 规则:
- `ClaimPendingOutbox` 必须把事件从 `pending/retryable` 原子改成 `delivering`,并写入 `worker_id``lock_until`。 - `ClaimPendingOutbox` 必须把事件从 `pending/retryable` 原子改成 `delivering`,并写入 `worker_id``lock_until_ms`。
- 其他 worker 不能抢到未过期的 `delivering` 事件。 - 其他 worker 不能抢到未过期的 `delivering` 事件。
- 投递成功后才能标记 `delivered` - 投递成功后才能标记 `delivered`
- 投递失败必须保留 envelope 和 last_error转入 `retryable` 等待下一轮抢占。 - 投递失败必须保留 envelope 和 last_error转入 `retryable` 等待下一轮抢占。

View File

@ -60,7 +60,7 @@ Room Cell command applied
```text ```text
claim room_outbox where status in (pending, retryable) claim room_outbox where status in (pending, retryable)
-> status=delivering, set worker_id and lock_until -> status=delivering, set worker_id and lock_until_ms
-> publish one EventEnvelope to configured OutboxPublisher -> publish one EventEnvelope to configured OutboxPublisher
-> success: status=delivered, last_error=NULL -> success: status=delivered, last_error=NULL
-> failure: status=retryable, retry_count=retry_count+1, last_error=<short error> -> failure: status=retryable, retry_count=retry_count+1, last_error=<short error>
@ -166,7 +166,7 @@ type App struct {
- 不能在 worker 退出前关闭 MySQL否则可能无法标记投递结果。 - 不能在 worker 退出前关闭 MySQL否则可能无法标记投递结果。
- worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。 - worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。
- 如果进程被强杀,已抢占未完成的记录保持 `delivering``lock_until` 过期后下次启动继续补偿。 - 如果进程被强杀,已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后下次启动继续补偿。
## Worker API ## Worker API
@ -202,7 +202,7 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
处理规则: 处理规则:
- 每轮调用 `repository.ClaimPendingOutbox(ctx, worker_id, batch_size, lock_until)`,把 `pending/retryable` 事件抢占为 `delivering` - 每轮调用 `repository.ClaimPendingOutbox(ctx, worker_id, batch_size, lock_until_ms)`,把 `pending/retryable` 事件抢占为 `delivering`
- 每条记录单独创建 `context.WithTimeout(ctx, publish_timeout)` - 每条记录单独创建 `context.WithTimeout(ctx, publish_timeout)`
- `PublishOutboxEvent` 成功后调用 `MarkOutboxDelivered` - `PublishOutboxEvent` 成功后调用 `MarkOutboxDelivered`
- `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed` - `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed`
@ -238,7 +238,7 @@ V1 采用固定间隔无限重试:
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 | | 腾讯云 IM 返回可重试错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
| envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 | | envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 |
| 不需要推 IM 的事件类型 | no-op success标记 `delivered` | | 不需要推 IM 的事件类型 | no-op success标记 `delivered` |
| 进程退出 | 已抢占未完成的记录保持 `delivering``lock_until` 过期后可被重新抢占 | | 进程退出 | 已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后可被重新抢占 |
为什么不加最大重试次数: 为什么不加最大重试次数:

View File

@ -100,7 +100,7 @@ jwt:
`wallet-service` `wallet-service`
```yaml ```yaml
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: false mysql_auto_migrate: false
``` ```

View File

@ -550,7 +550,7 @@ sequenceDiagram
U->>DB: check pretty display_user_id availability U->>DB: check pretty display_user_id availability
U->>DB: mark default display_user_id held U->>DB: mark default display_user_id held
U->>DB: insert pretty_display_user_id_leases(active) U->>DB: insert pretty_display_user_id_leases(active)
U->>DB: insert user_display_user_ids(pretty,active,expires_at) U->>DB: insert user_display_user_ids(pretty,active,expires_at_ms)
U->>DB: update users.current_display_user_id=pretty U->>DB: update users.current_display_user_id=pretty
U->>DB: insert display_user_id_change_logs(change_type=pretty_apply) U->>DB: insert display_user_id_change_logs(change_type=pretty_apply)
U->>DB: commit U->>DB: commit

View File

@ -154,6 +154,7 @@ source_assignment_status
- 管理端调整区域国家时,创建重算任务,按 country 分页刷新已有用户的 `region_id` - 管理端调整区域国家时,创建重算任务,按 country 分页刷新已有用户的 `region_id`
- 后台创建 BD Leader 是关系管理命令,可以显式把目标用户迁到后台选择的 active 区域;该动作由 `hyapp-admin-server` 鉴权审计,由 user-service host domain 在同一事务里写 `users.region_id``bd_profiles.region_id`,不修改 `users.country` - 后台创建 BD Leader 是关系管理命令,可以显式把目标用户迁到后台选择的 active 区域;该动作由 `hyapp-admin-server` 鉴权审计,由 user-service host domain 在同一事务里写 `users.region_id``bd_profiles.region_id`,不修改 `users.country`
- 后台修改 App 用户国家是用户资料管理命令,必须校验目标国家 `enabled=true`,并在同一次更新中按 active 国家映射写入新的 `users.region_id`;该动作不受普通用户国家修改冷却限制。 - 后台修改 App 用户国家是用户资料管理命令,必须校验目标国家 `enabled=true`,并在同一次更新中按 active 国家映射写入新的 `users.region_id`;该动作不受普通用户国家修改冷却限制。
- 当国家修改导致 `old_region_id != new_region_id` 时,后端要调用 `activity-service``RemoveRegionBroadcastMember`,用腾讯云 IM `delete_group_member` 只把该用户从旧区域播报群移除;不能删除或解散区域群。
### User Region Change Sources ### User Region Change Sources
@ -162,8 +163,8 @@ source_assignment_status
| 场景 | 入口 | 是否修改 `users.country` | `users.region_id` 写入方式 | 审计/日志 | | 场景 | 入口 | 是否修改 `users.country` | `users.region_id` 写入方式 | 审计/日志 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 首次注册或首次完成资料 | App/gateway 调 user-service 注册/资料完成链路 | 是,写用户选择的 enabled 国家 | user-service 在同一事务内按 active 国家映射解析并写入;无映射时写空 | 注册/资料完成链路日志,不写国家修改冷却日志 | | 首次注册或首次完成资料 | App/gateway 调 user-service 注册/资料完成链路 | 是,写用户选择的 enabled 国家 | user-service 在同一事务内按 active 国家映射解析并写入;无映射时写空 | 注册/资料完成链路日志,不写国家修改冷却日志 |
| 用户主动修改国家 | App/gateway 调 `ChangeUserCountry` | 是,写新 enabled 国家 | user-service 在同一事务内按新国家 active 映射重算;无映射时写空 | 写 `user_country_change_logs`,受国家修改冷却限制 | | 用户主动修改国家 | App/gateway 调 `ChangeUserCountry` | 是,写新 enabled 国家 | user-service 在同一事务内按新国家 active 映射重算;无映射时写空gateway 成功后 best-effort 移除旧区域群成员关系 | 写 `user_country_change_logs`,受国家修改冷却限制IM 移除失败写 gateway 结构化日志 |
| 后台修改 App 用户国家 | `hyapp-admin-server` 的 App 用户管理接口 | 是,写后台选择的 enabled 国家 | `hyapp-admin-server` 在同一次用户更新中按 active 国家映射重算;无映射时写空 | 写后台操作审计,不写 App 自助国家修改冷却日志 | | 后台修改 App 用户国家 | `hyapp-admin-server` 的 App 用户管理接口 | 是,写后台选择的 enabled 国家 | `hyapp-admin-server` 在同一次用户更新中按 active 国家映射重算;无映射时写空;成功后 best-effort 移除旧区域群成员关系 | 写后台操作审计,不写 App 自助国家修改冷却日志IM 移除失败写 admin 结构化日志 |
| 管理端调整区域国家映射 | 后台创建/停用区域、替换区域国家归属后触发 rebuild | 否 | rebuild worker 按受影响 `country` 分页刷新历史用户 `region_id`,最终一致 | 写区域变更审计和 rebuild task不写用户国家修改日志 | | 管理端调整区域国家映射 | 后台创建/停用区域、替换区域国家归属后触发 rebuild | 否 | rebuild worker 按受影响 `country` 分页刷新历史用户 `region_id`,最终一致 | 写区域变更审计和 rebuild task不写用户国家修改日志 |
| 后台创建 BD Leader | `hyapp-admin-server` 的 host 关系管理命令 | 否 | user-service 校验所选区域 active锁目标用户行并在创建 `bd_profiles` 的同一事务内把目标用户迁到所选区域 | 写后台操作审计和 host 命令幂等结果,不写用户国家修改日志 | | 后台创建 BD Leader | `hyapp-admin-server` 的 host 关系管理命令 | 否 | user-service 校验所选区域 active锁目标用户行并在创建 `bd_profiles` 的同一事务内把目标用户迁到所选区域 | 写后台操作审计和 host 命令幂等结果,不写用户国家修改日志 |

View File

@ -136,9 +136,9 @@ VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
| 场景 | 处理 | | 场景 | 处理 |
| ---------------------- | ------------------------------------------------------------------------ | | ---------------------- | ------------------------------------------------------------------------ |
| 无有效 VIP | 可以购买任意 active 等级,`expires_at = now + 7d` | | 无有效 VIP | 可以购买任意 active 等级,`expires_at_ms = now_ms + 7d_ms` |
| 有效 VIP购买同等级 | 续期,`expires_at = current_expires_at + 7d` | | 有效 VIP购买同等级 | 续期,`expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at = current_expires_at + 7d` | | 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` | | 有效 VIP购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 | | VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |

View File

@ -12,5 +12,5 @@ type System struct{}
// Now 返回当前系统时间。 // Now 返回当前系统时间。
func (System) Now() time.Time { func (System) Now() time.Time {
return time.Now() return time.Now().UTC()
} }

126
pkg/imgroup/imgroup.go Normal file
View File

@ -0,0 +1,126 @@
// Package imgroup 定义腾讯云 IM GroupID 的唯一生成和解析规则。
// gateway、room-service、activity-service 必须共享这里的规则,避免某个服务手写字符串后把播报群误当房间群。
package imgroup
import (
"fmt"
"strconv"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
)
const (
// KindUnknown 表示 GroupID 既不是本项目播报群,也不是合法房间群;回调层必须 fail-closed。
KindUnknown Kind = "unknown"
// KindRoom 保持已有房间群规则:腾讯 IM GroupID 直接等于内部 room_id。
KindRoom Kind = "room"
// KindGlobalBroadcast 是 App 维度全局播报群,只允许服务端管理员账号发消息。
KindGlobalBroadcast Kind = "global_broadcast"
// KindRegionBroadcast 是区域播报群,用户只能加入自己 user-service 归属的区域群。
KindRegionBroadcast Kind = "region_broadcast"
// globalSuffix/regionToken 是可解析协议的一部分;不要在业务代码里重复拼接这些片段。
globalSuffix = "_bc_g"
regionToken = "_bc_r_"
)
// Kind 是回调分流和客户端 join 列表使用的稳定分类,不直接暴露腾讯云原始字符串判断。
type Kind string
// ParsedGroup 是 GroupID 的强类型解析结果;只有对应 Kind 的字段才有业务含义。
type ParsedGroup struct {
Kind Kind
AppCode string
RegionID int64
RoomID string
}
// GlobalBroadcastGroupID 生成 App 全局播报群 ID。
// app_code 先 Normalize 再进入 GroupID确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
func GlobalBroadcastGroupID(value string) (string, error) {
app := appcode.Normalize(value)
if err := validateAppCodeForGroupID(app); err != nil {
return "", err
}
groupID := "hy_" + app + globalSuffix
if len(groupID) > roomid.MaxStringIDLength {
return "", fmt.Errorf("global broadcast group_id is too long")
}
return groupID, nil
}
// RegionBroadcastGroupID 生成区域播报群 ID。
// region_id <= 0 被拒绝GLOBAL/未知区域不能变成一个可 join 的区域群。
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
app := appcode.Normalize(value)
if err := validateAppCodeForGroupID(app); err != nil {
return "", err
}
if regionID <= 0 {
return "", fmt.Errorf("region_id must be positive")
}
groupID := "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
if len(groupID) > roomid.MaxStringIDLength {
return "", fmt.Errorf("region broadcast group_id is too long")
}
return groupID, nil
}
// Parse 在调用任何 room guard 前完成 GroupID 分类。
// 解析顺序必须先播报群、后房间群;否则 hy_<app>_bc_* 这类合法短字符串可能被 roomid 规则吞掉。
func Parse(groupID string) ParsedGroup {
groupID = strings.TrimSpace(groupID)
if groupID == "" {
return ParsedGroup{Kind: KindUnknown}
}
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) {
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix)
if validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindGlobalBroadcast, AppCode: app}
}
return ParsedGroup{Kind: KindUnknown}
}
if strings.HasPrefix(groupID, "hy_") {
body := strings.TrimPrefix(groupID, "hy_")
tokenIndex := strings.LastIndex(body, regionToken)
if tokenIndex >= 0 {
app := body[:tokenIndex]
rawRegion := body[tokenIndex+len(regionToken):]
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}
}
return ParsedGroup{Kind: KindUnknown}
}
}
if roomid.ValidStringID(groupID) {
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
}
return ParsedGroup{Kind: KindUnknown}
}
func validateAppCodeForGroupID(app string) error {
if app == "" {
return fmt.Errorf("app_code is required")
}
// GroupID 是腾讯云侧长期存在的外部标识,只允许稳定 ASCII 子集,避免大小写、空格或 Unicode 造成不可逆映射。
for i := 0; i < len(app); i++ {
char := app[i]
if char >= 'a' && char <= 'z' {
continue
}
if char >= '0' && char <= '9' {
continue
}
if char == '_' || char == '-' {
continue
}
return fmt.Errorf("app_code contains unsupported character")
}
return nil
}

View File

@ -0,0 +1,44 @@
package imgroup
import "testing"
func TestBroadcastGroupIDGenerateAndParse(t *testing.T) {
global, err := GlobalBroadcastGroupID(" LaLu ")
if err != nil {
t.Fatalf("GlobalBroadcastGroupID failed: %v", err)
}
if global != "hy_lalu_bc_g" {
t.Fatalf("global group mismatch: %s", global)
}
parsedGlobal := Parse(global)
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" || parsedGlobal.RegionID != 0 {
t.Fatalf("global parse mismatch: %+v", parsedGlobal)
}
region, err := RegionBroadcastGroupID("lalu", 1001)
if err != nil {
t.Fatalf("RegionBroadcastGroupID failed: %v", err)
}
if region != "hy_lalu_bc_r_1001" {
t.Fatalf("region group mismatch: %s", region)
}
parsedRegion := Parse(region)
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
t.Fatalf("region parse mismatch: %+v", parsedRegion)
}
}
func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
if parsed := Parse("room-1001"); parsed.Kind != KindRoom || parsed.RoomID != "room-1001" {
t.Fatalf("room parse mismatch: %+v", parsed)
}
if parsed := Parse("hy_lalu_bc_r_0"); parsed.Kind != KindUnknown {
t.Fatalf("zero region must be rejected: %+v", parsed)
}
if _, err := RegionBroadcastGroupID("lalu", 0); err == nil {
t.Fatal("zero region must not generate a region broadcast group")
}
if _, err := GlobalBroadcastGroupID("bad app"); err == nil {
t.Fatal("app_code with spaces must be rejected")
}
}

View File

@ -16,37 +16,59 @@ import (
) )
const ( const (
// DefaultEndpoint is Tencent Cloud Chat's China REST API endpoint. // DefaultEndpoint 是腾讯云 IM 国内 REST 默认入口;海外部署可在配置中覆盖为对应地域域名。
DefaultEndpoint = "console.tim.qq.com" DefaultEndpoint = "console.tim.qq.com"
// DefaultGroupType keeps room messages recoverable; AVChatRoom has no history storage. // DefaultGroupType 使用 ChatRoom保留消息历史能力AVChatRoom 不适合需要补偿和漫游的房间消息。
DefaultGroupType = "ChatRoom" DefaultGroupType = "ChatRoom"
// createGroupCommand is Tencent Cloud Chat REST command for group creation. // createGroupCommand 是建群 REST 命令;房间群、全局播报群、区域播报群都复用这一个能力。
createGroupCommand = "v4/group_open_http_svc/create_group" createGroupCommand = "v4/group_open_http_svc/create_group"
// sendGroupMsgCommand is Tencent Cloud Chat REST command for group message sending. // sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg" sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
// deleteGroupMemberCommand removes users from a Tencent Cloud Chat group. // destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member" deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
) )
// RESTConfig contains Tencent Cloud Chat server-side REST API settings. // RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
type RESTConfig struct { type RESTConfig struct {
// SDKAppID is the application ID assigned by Tencent Cloud Chat. // SDKAppID 是腾讯云 IM 应用 ID回调校验和 UserSig 签发都必须使用同一个值。
SDKAppID int64 SDKAppID int64
// SecretKey signs the admin UserSig used by REST API calls. // SecretKey 只用于服务端签 admin UserSig不能返回给客户端或写入错误日志。
SecretKey string SecretKey string
// AdminIdentifier is the App administrator account configured in Tencent Cloud Chat. // AdminIdentifier 是腾讯云控制台配置的管理员账号,播报群消息用它证明“服务端发送”。
AdminIdentifier string AdminIdentifier string
// AdminUserSigTTL controls the cached admin UserSig lifetime. // AdminUserSigTTL 控制 REST 调用签名有效期;过短会增加签发频率,过长会扩大泄露影响面。
AdminUserSigTTL time.Duration AdminUserSigTTL time.Duration
// Endpoint is the regional REST API host, such as adminapisgp.im.qcloud.com. // Endpoint 是 REST 地域入口,例如 adminapisgp.im.qcloud.com不要把地域选择写死在业务里。
Endpoint string Endpoint string
// GroupType is the Tencent group type used for voice rooms. // GroupType 是房间和播报群共用的腾讯群类型;当前保持 ChatRoom 以保留历史能力。
GroupType string GroupType string
// HTTPClient allows tests to inject a fake transport. // HTTPClient 允许测试注入 fake transport也让上层统一设置超时。
HTTPClient *http.Client HTTPClient *http.Client
} }
// RoomEvent is the backend-owned room system event sent to Tencent Cloud Chat. // GroupSpec 是项目内部的建群请求,屏蔽腾讯 REST 字段命名差异。
type GroupSpec struct {
GroupID string
Name string
OwnerAccount string
Type string
ApplyJoinOption string
}
// CustomGroupMessage 表示一条服务端发出的 TIMCustomElem 群消息。
// PayloadJSON 同时写入 Data 和 CloudCustomData客户端和排障侧都能按 event_id 做幂等和追踪。
type CustomGroupMessage struct {
GroupID string
EventID string
Desc string
Ext string
FromAccount string
PayloadJSON json.RawMessage
}
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
type RoomEvent struct { type RoomEvent struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
@ -61,18 +83,18 @@ type RoomEvent struct {
Attributes map[string]string `json:"attributes,omitempty"` Attributes map[string]string `json:"attributes,omitempty"`
} }
// RESTClient wraps Tencent Cloud Chat REST API and hides UserSig/query boilerplate. // RESTClient 封装腾讯云 IM REST API把 UserSig、endpoint query 和错误码处理收敛在一个边界。
type RESTClient struct { type RESTClient struct {
// cfg is immutable after construction so calls can safely run concurrently. // cfg 初始化后不再修改,因此同一个 client 可以被多个 worker 并发复用。
cfg RESTConfig cfg RESTConfig
// httpClient is reused for keep-alive; Tencent recommends long connections for REST API. // httpClient 复用 keep-alive避免高频播报时每条消息都重新建连接。
httpClient *http.Client httpClient *http.Client
} }
// NewRESTClient validates config and creates a Tencent Cloud Chat REST client. // NewRESTClient 校验必需配置并创建 REST client。
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) { func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
if cfg.SDKAppID <= 0 || cfg.SecretKey == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" { if cfg.SDKAppID <= 0 || cfg.SecretKey == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
// REST API requires an App administrator identifier and a valid admin UserSig. // REST API 依赖管理员账号和 admin UserSig缺任何一个都不能伪造成功。
return nil, fmt.Errorf("tencent im rest config is incomplete") return nil, fmt.Errorf("tencent im rest config is incomplete")
} }
if cfg.AdminUserSigTTL <= 0 { if cfg.AdminUserSigTTL <= 0 {
@ -94,7 +116,8 @@ func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
}, nil }, nil
} }
// EnsureRoomGroup creates the Tencent Cloud Chat group for a voice room. // EnsureRoomGroup 为房间创建腾讯云 IM 群。
// ownerUserID 暂不映射为腾讯群主,房间状态 owner 仍是 Room Cell腾讯群只做投递通道。
func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error { func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error {
roomID = strings.TrimSpace(roomID) roomID = strings.TrimSpace(roomID)
if roomID == "" { if roomID == "" {
@ -102,11 +125,41 @@ func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUs
} }
_ = ownerUserID _ = ownerUserID
request := createGroupRequest{ return c.EnsureGroup(ctx, GroupSpec{
Type: c.cfg.GroupType,
GroupID: roomID, GroupID: roomID,
Name: roomID, Name: roomID,
Type: c.cfg.GroupType,
ApplyJoinOption: "FreeAccess", ApplyJoinOption: "FreeAccess",
})
}
// EnsureGroup 创建服务端拥有的腾讯云 IM 群,并把“自定义 GroupID 已存在”视为成功。
// 这样启动 reconciler、CreateRoom 重试或发布补偿重复执行时,都不会因为已建群而中断主流程。
func (c *RESTClient) EnsureGroup(ctx context.Context, spec GroupSpec) error {
spec.GroupID = strings.TrimSpace(spec.GroupID)
spec.Name = strings.TrimSpace(spec.Name)
spec.Type = strings.TrimSpace(spec.Type)
spec.ApplyJoinOption = strings.TrimSpace(spec.ApplyJoinOption)
spec.OwnerAccount = strings.TrimSpace(spec.OwnerAccount)
if spec.GroupID == "" {
return fmt.Errorf("group_id is required")
}
if spec.Name == "" {
spec.Name = spec.GroupID
}
if spec.Type == "" {
spec.Type = c.cfg.GroupType
}
if spec.ApplyJoinOption == "" {
spec.ApplyJoinOption = "FreeAccess"
}
request := createGroupRequest{
Type: spec.Type,
GroupID: spec.GroupID,
Name: spec.Name,
OwnerAccount: spec.OwnerAccount,
ApplyJoinOption: spec.ApplyJoinOption,
} }
var response restResponse var response restResponse
@ -114,17 +167,17 @@ func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUs
return err return err
} }
if response.ErrorCode == 10021 || response.ErrorCode == 10025 { if response.ErrorCode == 10021 || response.ErrorCode == 10025 {
// Custom GroupId already exists; for idempotent CreateRoom retry this is success. // 腾讯云不同接口版本可能返回 10021/10025 表示群已存在;建群语义要求幂等。
return nil return nil
} }
return response.err() return response.err()
} }
// PublishRoomEvent converts a room system event into a Tencent group custom message. // PublishRoomEvent 把房间系统事件转换为腾讯云群自定义消息。
func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) error { func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) error {
if strings.TrimSpace(event.RoomID) == "" || strings.TrimSpace(event.EventID) == "" { if strings.TrimSpace(event.RoomID) == "" || strings.TrimSpace(event.EventID) == "" {
// event_id is Tencent-side idempotency anchor in CloudCustomData. // event_id 是客户端去重、日志追踪和补偿投递判断的共同锚点。
return fmt.Errorf("room event is incomplete") return fmt.Errorf("room event is incomplete")
} }
@ -133,21 +186,44 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro
return err return err
} }
return c.PublishGroupCustomMessage(ctx, CustomGroupMessage{
GroupID: event.RoomID,
EventID: event.EventID,
Desc: event.EventType,
Ext: "room_system_message",
PayloadJSON: eventPayload,
})
}
// PublishGroupCustomMessage 向任意服务端拥有的腾讯云 IM 群发送 TIMCustomElem。
// 该方法不判断业务类型,调用方必须已经通过 room/broadcast service 完成权限、幂等和持久化决策。
func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message CustomGroupMessage) error {
message.GroupID = strings.TrimSpace(message.GroupID)
message.EventID = strings.TrimSpace(message.EventID)
message.Desc = strings.TrimSpace(message.Desc)
message.Ext = strings.TrimSpace(message.Ext)
message.FromAccount = strings.TrimSpace(message.FromAccount)
payload := bytes.TrimSpace(message.PayloadJSON)
if message.GroupID == "" || message.EventID == "" || len(payload) == 0 {
return fmt.Errorf("group custom message is incomplete")
}
request := sendGroupMsgRequest{ request := sendGroupMsgRequest{
GroupID: event.RoomID, GroupID: message.GroupID,
Random: randomUint32(), Random: randomUint32(),
CloudCustomData: string(payload),
FromAccount: message.FromAccount,
MsgBody: []messageElement{ MsgBody: []messageElement{
{ {
MsgType: "TIMCustomElem", MsgType: "TIMCustomElem",
MsgContent: customMsgContent{ MsgContent: customMsgContent{
Data: string(eventPayload), Data: string(payload),
Desc: event.EventType, Desc: message.Desc,
Ext: "room_system_message", Ext: message.Ext,
Sound: "", Sound: "",
}, },
}, },
}, },
CloudCustomData: string(eventPayload),
} }
var response restResponse var response restResponse
@ -158,15 +234,33 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro
return response.err() return response.err()
} }
// DeleteRoomGroupMember removes a room user from the Tencent Cloud Chat group. // DestroyGroup 解散一个腾讯云 IM 群。
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error { // 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
roomID = strings.TrimSpace(roomID) func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
if roomID == "" || userID <= 0 { groupID = strings.TrimSpace(groupID)
return fmt.Errorf("room group member removal is incomplete") if groupID == "" {
return fmt.Errorf("group_id is required")
}
request := destroyGroupRequest{GroupID: groupID}
var response restResponse
if err := c.post(ctx, destroyGroupCommand, request, &response); err != nil {
return err
}
return response.err()
}
// DeleteGroupMember 从指定腾讯云 IM 群移除一个用户。
// 它只改变“这个用户在这个群里的成员关系”,不会删除群,也不会影响其他成员。
func (c *RESTClient) DeleteGroupMember(ctx context.Context, groupID string, userID int64) error {
groupID = strings.TrimSpace(groupID)
if groupID == "" || userID <= 0 {
return fmt.Errorf("group member removal is incomplete")
} }
request := deleteGroupMemberRequest{ request := deleteGroupMemberRequest{
GroupID: roomID, GroupID: groupID,
MemberToDelAccount: []string{FormatUserID(userID)}, MemberToDelAccount: []string{FormatUserID(userID)},
Silence: 1, Silence: 1,
} }
@ -179,9 +273,14 @@ func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, u
return response.err() return response.err()
} }
// FormatUserID converts the internal immutable user_id to the Tencent Chat identifier. // DeleteRoomGroupMember 从房间 IM 群移除用户;这是 presence 清理的外部副作用,不改变房间权威状态。
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error {
return c.DeleteGroupMember(ctx, roomID, userID)
}
// FormatUserID 把内部不可变 user_id 映射成腾讯云账号字符串。
func FormatUserID(userID int64) string { func FormatUserID(userID int64) string {
// Decimal user_id is stable across services and avoids exposing mutable display_user_id. // 十进制 user_id 跨服务稳定,也避免把可变 display_user_id 暴露给 IM 账号体系。
return strconv.FormatInt(userID, 10) return strconv.FormatInt(userID, 10)
} }
@ -208,7 +307,7 @@ func (c *RESTClient) post(ctx context.Context, command string, payload any, out
return err return err
} }
if response.StatusCode < 200 || response.StatusCode >= 300 { if response.StatusCode < 200 || response.StatusCode >= 300 {
// Tencent REST normally returns HTTP 200 with ErrorCode; non-2xx is transport failure. // 腾讯 REST 正常业务失败通常是 HTTP 200 + ErrorCode非 2xx 视为网络/网关层失败。
return fmt.Errorf("tencent im http status %d: %s", response.StatusCode, string(responseBody)) return fmt.Errorf("tencent im http status %d: %s", response.StatusCode, string(responseBody))
} }
if err := json.Unmarshal(responseBody, out); err != nil { if err := json.Unmarshal(responseBody, out); err != nil {
@ -223,7 +322,7 @@ func (c *RESTClient) endpoint(command string) string {
SDKAppID: c.cfg.SDKAppID, SDKAppID: c.cfg.SDKAppID,
SecretKey: c.cfg.SecretKey, SecretKey: c.cfg.SecretKey,
TTL: c.cfg.AdminUserSigTTL, TTL: c.cfg.AdminUserSigTTL,
}, c.cfg.AdminIdentifier, time.Now()) }, c.cfg.AdminIdentifier, time.Now().UTC())
values := url.Values{} values := url.Values{}
values.Set("sdkappid", strconv.FormatInt(c.cfg.SDKAppID, 10)) values.Set("sdkappid", strconv.FormatInt(c.cfg.SDKAppID, 10))
@ -238,7 +337,7 @@ func (c *RESTClient) endpoint(command string) string {
func randomUint32() uint32 { func randomUint32() uint32 {
var payload [4]byte var payload [4]byte
if _, err := rand.Read(payload[:]); err != nil { if _, err := rand.Read(payload[:]); err != nil {
// Cryptographic randomness is preferred, but REST random only needs dedupe entropy. // REST random 只需要足够的去重熵;加密随机失败时用纳秒时间兜底。
return uint32(time.Now().UnixNano()) return uint32(time.Now().UnixNano())
} }
@ -256,7 +355,7 @@ func (r restResponse) err() error {
return nil return nil
} }
if r.ErrorCode == 0 && r.ActionStatus == "" { if r.ErrorCode == 0 && r.ActionStatus == "" {
// Some tests only decode partial responses; keep zero-code as success. // 部分测试只构造 ErrorCode腾讯语义里 0 即成功,因此允许空 ActionStatus。
return nil return nil
} }
@ -279,6 +378,10 @@ type sendGroupMsgRequest struct {
FromAccount string `json:"From_Account,omitempty"` FromAccount string `json:"From_Account,omitempty"`
} }
type destroyGroupRequest struct {
GroupID string `json:"GroupId"`
}
type deleteGroupMemberRequest struct { type deleteGroupMemberRequest struct {
GroupID string `json:"GroupId"` GroupID string `json:"GroupId"`
MemberToDelAccount []string `json:"MemberToDel_Account"` MemberToDelAccount []string `json:"MemberToDel_Account"`

View File

@ -0,0 +1,151 @@
package tencentim
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
if os.Getenv("TENCENT_IM_REAL_TEST") != "1" {
t.Skip("set TENCENT_IM_REAL_TEST=1 and Tencent IM env vars to run the real REST smoke test")
}
cfg := realRESTConfigFromEnv(t)
client, err := NewRESTClient(cfg)
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
nowMS := time.Now().UTC().UnixMilli()
groupID := fmt.Sprintf("hy_codex_bc_r_%d", nowMS)
eventID := fmt.Sprintf("codex_real_im_%d", nowMS)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// 真实 IM 测试必须使用临时 GroupID避免污染业务全局/区域播报群。
if err := client.EnsureGroup(ctx, GroupSpec{
GroupID: groupID,
Name: "codex real im smoke",
Type: DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}); err != nil {
t.Fatalf("real Tencent IM create group failed: %v", err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cleanupCancel()
if err := client.DestroyGroup(cleanupCtx, groupID); err != nil {
t.Errorf("real Tencent IM destroy group cleanup failed: %v", err)
}
})
memberUserID := nowMS % 9_000_000_000
if err := realImportAccount(ctx, client, memberUserID); err != nil {
t.Fatalf("real Tencent IM account import failed: %v", err)
}
if err := realAddGroupMember(ctx, client, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM add group member failed: %v", err)
}
payload, err := json.Marshal(map[string]any{
"event_id": eventID,
"broadcast_type": "codex_real_im_smoke",
"scope": "region",
"app_code": "codex",
"region_id": 210,
"sent_at_ms": nowMS,
"action": map[string]any{
"type": "enter_room",
"room_id": "codex-room-smoke",
},
})
if err != nil {
t.Fatalf("marshal smoke payload failed: %v", err)
}
// 真正向腾讯云 IM REST 发送 TIMCustomElem成功只表示腾讯服务端接受消息不代表有客户端在线展示。
if err := client.PublishGroupCustomMessage(ctx, CustomGroupMessage{
GroupID: groupID,
EventID: eventID,
Desc: "codex_real_im_smoke",
Ext: "im_broadcast",
PayloadJSON: payload,
}); err != nil {
t.Fatalf("real Tencent IM send group custom message failed: %v", err)
}
if err := client.DeleteGroupMember(ctx, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM delete group member failed: %v", err)
}
t.Logf("real Tencent IM smoke passed: sdk_app_id=%d group_id=%s event_id=%s removed_user_id=%d", cfg.SDKAppID, groupID, eventID, memberUserID)
}
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
var response restResponse
if err := client.post(ctx, "v4/im_open_login_svc/account_import", map[string]any{
"UserID": strconv.FormatInt(userID, 10),
"Nick": "codex-im-smoke",
}, &response); err != nil {
return err
}
return response.err()
}
func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error {
var response restResponse
if err := client.post(ctx, "v4/group_open_http_svc/add_group_member", map[string]any{
"GroupId": groupID,
"Silence": 1,
"MemberList": []map[string]any{
{"Member_Account": strconv.FormatInt(userID, 10)},
},
}, &response); err != nil {
return err
}
return response.err()
}
func realRESTConfigFromEnv(t *testing.T) RESTConfig {
t.Helper()
sdkAppID, err := strconv.ParseInt(requiredEnv(t, "TENCENT_IM_SDK_APP_ID"), 10, 64)
if err != nil || sdkAppID <= 0 {
t.Fatalf("TENCENT_IM_SDK_APP_ID is invalid")
}
endpoint := strings.TrimSpace(os.Getenv("TENCENT_IM_ENDPOINT"))
if endpoint == "" {
endpoint = DefaultEndpoint
}
adminUserSigTTL := time.Hour
if rawTTL := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); rawTTL != "" {
parsed, err := time.ParseDuration(rawTTL)
if err != nil || parsed <= 0 {
t.Fatalf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid")
}
adminUserSigTTL = parsed
}
return RESTConfig{
SDKAppID: sdkAppID,
SecretKey: requiredEnv(t, "TENCENT_IM_SECRET_KEY"),
AdminIdentifier: requiredEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"),
AdminUserSigTTL: adminUserSigTTL,
Endpoint: endpoint,
GroupType: DefaultGroupType,
}
}
func requiredEnv(t *testing.T, key string) string {
t.Helper()
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
t.Fatalf("%s is required for real Tencent IM smoke test", key)
}
return value
}

View File

@ -93,6 +93,60 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) {
} }
} }
func TestRESTClientPublishGroupCustomMessageBuildsBroadcastPayload(t *testing.T) {
var capturedBody map[string]any
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
return okRESTResponse(), nil
})
err := client.PublishGroupCustomMessage(context.Background(), CustomGroupMessage{
GroupID: "hy_lalu_bc_r_1001",
EventID: "gift_broadcast:room-1:cmd-1",
Desc: "super_gift",
Ext: "im_broadcast",
FromAccount: "administrator",
PayloadJSON: json.RawMessage(`{"event_id":"gift_broadcast:room-1:cmd-1","broadcast_type":"super_gift"}`),
})
if err != nil {
t.Fatalf("PublishGroupCustomMessage failed: %v", err)
}
if capturedBody["GroupId"] != "hy_lalu_bc_r_1001" || capturedBody["From_Account"] != "administrator" {
t.Fatalf("broadcast target mismatch: %+v", capturedBody)
}
if capturedBody["CloudCustomData"] != `{"event_id":"gift_broadcast:room-1:cmd-1","broadcast_type":"super_gift"}` {
t.Fatalf("cloud custom data mismatch: %+v", capturedBody)
}
}
// TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
var capturedPath string
var capturedBody map[string]any
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
capturedPath = request.URL.Path
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
return okRESTResponse(), nil
})
if err := client.DestroyGroup(context.Background(), "hy_codex_bc_r_210"); err != nil {
t.Fatalf("DestroyGroup failed: %v", err)
}
if capturedPath != "/"+destroyGroupCommand {
t.Fatalf("path mismatch: got %q", capturedPath)
}
if capturedBody["GroupId"] != "hy_codex_bc_r_210" {
t.Fatalf("unexpected destroy group body: %+v", capturedBody)
}
}
// TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest 锁定踢出 IM 群成员的 REST 请求。 // TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest 锁定踢出 IM 群成员的 REST 请求。
func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) { func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) {
var capturedPath string var capturedPath string

View File

@ -82,7 +82,12 @@ func main() {
LogLevel: logger.Warn, LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true, IgnoreRecordNotFoundError: true,
}) })
db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{Logger: dbLogger}) db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{
Logger: dbLogger,
NowFunc: func() time.Time {
return time.Now().UTC()
},
})
if err != nil { if err != nil {
fatalRuntime("connect_mysql_failed", err) fatalRuntime("connect_mysql_failed", err)
} }
@ -91,7 +96,9 @@ func main() {
fatalRuntime("mysql_db_handle_failed", err) fatalRuntime("mysql_db_handle_failed", err)
} }
if cfg.Migrations.Enabled { if cfg.Migrations.Enabled {
if err := migration.Apply(context.Background(), sqlDB, cfg.Migrations.Dir); err != nil { if err := migration.ApplyWithOptions(context.Background(), sqlDB, cfg.Migrations.Dir, migration.Options{
AllowChecksumRepair: cfg.Migrations.AllowChecksumRepair,
}); err != nil {
fatalRuntime("apply_migrations_failed", err) fatalRuntime("apply_migrations_failed", err)
} }
} }
@ -191,7 +198,7 @@ func main() {
AdminUser: adminusermodule.New(store, cfg, auditHandler), AdminUser: adminusermodule.New(store, cfg, auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler), AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB), AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), userDB, walletDB, cfg, auditHandler), AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), userDB, walletDB, cfg, auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler), DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store), Dashboard: dashboardmodule.New(store),

View File

@ -8,14 +8,16 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
http_addr: ":13100" http_addr: ":13100"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local" user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local" room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: true mysql_auto_migrate: true
migrations: migrations:
enabled: true enabled: true
dir: "migrations" dir: "migrations"
# 本地开发阶段允许历史 migration 文件被修改后修复 checksumstaging/prod 必须关闭。
allow_checksum_repair: true
jwt_secret: "dev-shared-secret" jwt_secret: "dev-shared-secret"
access_token_ttl: "30m" access_token_ttl: "30m"
refresh_token_ttl: "168h" refresh_token_ttl: "168h"

View File

@ -40,8 +40,9 @@ type Config struct {
} }
type MigrationConfig struct { type MigrationConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Dir string `yaml:"dir"` Dir string `yaml:"dir"`
AllowChecksumRepair bool `yaml:"allow_checksum_repair"`
} }
type UserServiceConfig struct { type UserServiceConfig struct {
@ -101,14 +102,15 @@ func Default() Config {
Environment: "local", Environment: "local",
Log: logging.DefaultConfig(), Log: logging.DefaultConfig(),
HTTPAddr: ":13100", HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local", UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local", WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local", RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
MySQLAutoMigrate: true, MySQLAutoMigrate: true,
Migrations: MigrationConfig{ Migrations: MigrationConfig{
Enabled: true, Enabled: true,
Dir: "migrations", Dir: "migrations",
AllowChecksumRepair: true,
}, },
JWTSecret: "dev-shared-secret", JWTSecret: "dev-shared-secret",
AccessTokenTTL: 30 * time.Minute, AccessTokenTTL: 30 * time.Minute,
@ -260,6 +262,9 @@ func (cfg Config) Validate() error {
if cfg.Migrations.Enabled && strings.TrimSpace(cfg.Migrations.Dir) == "" { if cfg.Migrations.Enabled && strings.TrimSpace(cfg.Migrations.Dir) == "" {
return errors.New("migrations.dir is required when migrations are enabled") return errors.New("migrations.dir is required when migrations are enabled")
} }
if isLockedEnvironment(cfg.Environment) && cfg.Migrations.AllowChecksumRepair {
return errors.New("migrations.allow_checksum_repair must be false outside local/dev")
}
if strings.TrimSpace(cfg.JWTSecret) == "" { if strings.TrimSpace(cfg.JWTSecret) == "" {
return errors.New("jwt_secret is required") return errors.New("jwt_secret is required")
} }

View File

@ -15,10 +15,10 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.HTTPAddr != ":13100" { if cfg.HTTPAddr != ":13100" {
t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr) t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr)
} }
if cfg.MySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" { if cfg.MySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" {
t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN) t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN)
} }
if cfg.UserMySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local" { if cfg.UserMySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" {
t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN) t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN)
} }
if cfg.AccessTokenTTL != 30*time.Minute { if cfg.AccessTokenTTL != 30*time.Minute {
@ -27,6 +27,9 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.RefreshTokenTTL != 7*24*time.Hour { if cfg.RefreshTokenTTL != 7*24*time.Hour {
t.Fatalf("RefreshTokenTTL = %s", cfg.RefreshTokenTTL) t.Fatalf("RefreshTokenTTL = %s", cfg.RefreshTokenTTL)
} }
if !cfg.Migrations.AllowChecksumRepair {
t.Fatalf("Migrations.AllowChecksumRepair = false, want true for local config")
}
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" { if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS) t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
} }

View File

@ -13,24 +13,33 @@ type Client interface {
ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error) ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error)
UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
} }
type GRPCClient struct { type GRPCClient struct {
client activityv1.AdminTaskServiceClient taskClient activityv1.AdminTaskServiceClient
broadcastClient activityv1.BroadcastServiceClient
} }
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient { func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: activityv1.NewAdminTaskServiceClient(conn)} return &GRPCClient{
taskClient: activityv1.NewAdminTaskServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
}
} }
func (c *GRPCClient) ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error) { func (c *GRPCClient) ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error) {
return c.client.ListTaskDefinitions(ctx, req) return c.taskClient.ListTaskDefinitions(ctx, req)
} }
func (c *GRPCClient) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) { func (c *GRPCClient) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) {
return c.client.UpsertTaskDefinition(ctx, req) return c.taskClient.UpsertTaskDefinition(ctx, req)
} }
func (c *GRPCClient) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error) { func (c *GRPCClient) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error) {
return c.client.SetTaskDefinitionStatus(ctx, req) return c.taskClient.SetTaskDefinitionStatus(ctx, req)
}
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
} }

View File

@ -204,8 +204,8 @@ func (r *Runner) writeUserExport(job *model.AdminJob, users []model.User) (strin
_ = writer.Write([]string{"ID", "账号", "姓名", "角色", "团队", "状态", "MFA", "最后登录"}) _ = writer.Write([]string{"ID", "账号", "姓名", "角色", "团队", "状态", "MFA", "最后登录"})
for _, user := range users { for _, user := range users {
lastLogin := "" lastLogin := ""
if user.LastLoginAt != nil { if user.LastLoginAtMS != nil {
lastLogin = user.LastLoginAt.Format("2006-01-02 15:04:05") lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("2006-01-02 15:04:05")
} }
_ = writer.Write([]string{ _ = writer.Write([]string{
fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", user.ID),

View File

@ -42,5 +42,5 @@ func newRequestID() string {
if _, err := rand.Read(raw[:]); err == nil { if _, err := rand.Read(raw[:]); err == nil {
return hex.EncodeToString(raw[:]) return hex.EncodeToString(raw[:])
} }
return hex.EncodeToString([]byte(time.Now().Format("20060102150405.000000000"))) return hex.EncodeToString([]byte(time.Now().UTC().Format("20060102150405.000000000")))
} }

View File

@ -11,9 +11,21 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
) )
// Options controls migration repair behavior.
type Options struct {
// AllowChecksumRepair lets local/dev databases accept edited historical migrations.
// Production-like environments must keep this false so checksum drift fails closed.
AllowChecksumRepair bool
}
func Apply(ctx context.Context, db *sql.DB, dir string) error { func Apply(ctx context.Context, db *sql.DB, dir string) error {
return ApplyWithOptions(ctx, db, dir, Options{})
}
func ApplyWithOptions(ctx context.Context, db *sql.DB, dir string, options Options) error {
if strings.TrimSpace(dir) == "" { if strings.TrimSpace(dir) == "" {
return errors.New("migration dir is required") return errors.New("migration dir is required")
} }
@ -30,7 +42,7 @@ func Apply(ctx context.Context, db *sql.DB, dir string) error {
} }
for _, file := range files { for _, file := range files {
version := filepath.Base(file) version := filepath.Base(file)
applied, err := isApplied(ctx, db, version, file) applied, err := isApplied(ctx, db, version, file, options)
if err != nil { if err != nil {
return err return err
} }
@ -50,14 +62,17 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(128) PRIMARY KEY, version VARCHAR(128) PRIMARY KEY,
checksum CHAR(64) NOT NULL DEFAULT '', checksum CHAR(64) NOT NULL DEFAULT '',
dirty BOOLEAN NOT NULL DEFAULT FALSE, dirty BOOLEAN NOT NULL DEFAULT FALSE,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP applied_at_ms BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil {
return err return err
} }
if err := ensureColumn(ctx, db, "checksum", "ALTER TABLE schema_migrations ADD COLUMN checksum CHAR(64) NOT NULL DEFAULT '' AFTER version"); err != nil { if err := ensureColumn(ctx, db, "checksum", "ALTER TABLE schema_migrations ADD COLUMN checksum CHAR(64) NOT NULL DEFAULT '' AFTER version"); err != nil {
return err return err
} }
return ensureColumn(ctx, db, "dirty", "ALTER TABLE schema_migrations ADD COLUMN dirty BOOLEAN NOT NULL DEFAULT FALSE AFTER checksum") if err := ensureColumn(ctx, db, "dirty", "ALTER TABLE schema_migrations ADD COLUMN dirty BOOLEAN NOT NULL DEFAULT FALSE AFTER checksum"); err != nil {
return err
}
return ensureColumn(ctx, db, "applied_at_ms", "ALTER TABLE schema_migrations ADD COLUMN applied_at_ms BIGINT NOT NULL DEFAULT 0 AFTER dirty")
} }
func ensureColumn(ctx context.Context, db *sql.DB, column string, alter string) error { func ensureColumn(ctx context.Context, db *sql.DB, column string, alter string) error {
@ -92,7 +107,7 @@ func migrationFiles(dir string) ([]string, error) {
return files, nil return files, nil
} }
func isApplied(ctx context.Context, db *sql.DB, version string, file string) (bool, error) { func isApplied(ctx context.Context, db *sql.DB, version string, file string, options Options) (bool, error) {
var row migrationRow var row migrationRow
err := db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&row.Version, &row.Checksum, &row.Dirty) err := db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&row.Version, &row.Checksum, &row.Dirty)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -110,6 +125,13 @@ func isApplied(ctx context.Context, db *sql.DB, version string, file string) (bo
} }
sum := checksum(body) sum := checksum(body)
if row.Checksum != "" && row.Checksum != sum { if row.Checksum != "" && row.Checksum != sum {
if options.AllowChecksumRepair {
// 本地开发阶段允许直接编辑历史迁移;修复只更新记录,不重放历史 SQL避免重复 ALTER 破坏现有库。
if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET checksum = ? WHERE version = ?", sum, version); err != nil {
return false, err
}
return true, nil
}
return false, fmt.Errorf("migration %s checksum changed", version) return false, fmt.Errorf("migration %s checksum changed", version)
} }
if row.Checksum == "" { if row.Checksum == "" {
@ -151,7 +173,7 @@ func applyFile(ctx context.Context, db *sql.DB, version string, file string) err
return fmt.Errorf("apply migration %s: %w", version, err) return fmt.Errorf("apply migration %s: %w", version, err)
} }
} }
if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at = CURRENT_TIMESTAMP WHERE version = ?", sum, version); err != nil { if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at_ms = ? WHERE version = ?", sum, time.Now().UTC().UnixMilli(), version); err != nil {
return err return err
} }
return nil return nil

View File

@ -1,7 +1,5 @@
package model package model
import "time"
const ( const (
UserStatusActive = "active" UserStatusActive = "active"
UserStatusLocked = "locked" UserStatusLocked = "locked"
@ -15,17 +13,17 @@ const (
) )
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"account"` Username string `gorm:"size:64;uniqueIndex;not null" json:"account"`
Name string `gorm:"size:80;not null" json:"name"` Name string `gorm:"size:80;not null" json:"name"`
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
Team string `gorm:"size:80" json:"team"` Team string `gorm:"size:80" json:"team"`
Status string `gorm:"size:24;index;not null;default:active" json:"status"` Status string `gorm:"size:24;index;not null;default:active" json:"status"`
MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"` MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"`
LastLoginAt *time.Time `json:"lastLoginAt"` LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"` Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (User) TableName() string { func (User) TableName() string {
@ -39,8 +37,8 @@ type Role struct {
Description string `gorm:"size:255" json:"description"` Description string `gorm:"size:255" json:"description"`
Users []User `gorm:"many2many:admin_user_roles;" json:"-"` Users []User `gorm:"many2many:admin_user_roles;" json:"-"`
Permissions []Permission `gorm:"many2many:admin_role_permissions;" json:"permissions"` Permissions []Permission `gorm:"many2many:admin_role_permissions;" json:"permissions"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (Role) TableName() string { func (Role) TableName() string {
@ -48,13 +46,13 @@ func (Role) TableName() string {
} }
type Permission struct { type Permission struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"` Name string `gorm:"size:100;not null" json:"name"`
Code string `gorm:"size:100;uniqueIndex;not null" json:"code"` Code string `gorm:"size:100;uniqueIndex;not null" json:"code"`
Kind string `gorm:"size:32;index;not null" json:"kind"` Kind string `gorm:"size:32;index;not null" json:"kind"`
Description string `gorm:"size:255" json:"description"` Description string `gorm:"size:255" json:"description"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (Permission) TableName() string { func (Permission) TableName() string {
@ -62,17 +60,17 @@ func (Permission) TableName() string {
} }
type Menu struct { type Menu struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
ParentID *uint `gorm:"index" json:"parentId"` ParentID *uint `gorm:"index" json:"parentId"`
Title string `gorm:"size:80;not null" json:"label"` Title string `gorm:"size:80;not null" json:"label"`
Code string `gorm:"size:100;uniqueIndex;not null" json:"code"` Code string `gorm:"size:100;uniqueIndex;not null" json:"code"`
Path string `gorm:"size:160" json:"path"` Path string `gorm:"size:160" json:"path"`
Icon string `gorm:"size:64" json:"icon"` Icon string `gorm:"size:64" json:"icon"`
PermissionCode string `gorm:"size:100;index" json:"permissionCode"` PermissionCode string `gorm:"size:100;index" json:"permissionCode"`
Sort int `gorm:"not null;default:0" json:"sort"` Sort int `gorm:"not null;default:0" json:"sort"`
Visible bool `gorm:"not null;default:true" json:"visible"` Visible bool `gorm:"not null;default:true" json:"visible"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (Menu) TableName() string { func (Menu) TableName() string {
@ -80,13 +78,13 @@ func (Menu) TableName() string {
} }
type AppConfig struct { type AppConfig struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Group string `gorm:"size:64;index:idx_admin_app_config_group_key,unique;not null" json:"group"` Group string `gorm:"size:64;index:idx_admin_app_config_group_key,unique;not null" json:"group"`
Key string `gorm:"size:100;index:idx_admin_app_config_group_key,unique;not null" json:"key"` Key string `gorm:"size:100;index:idx_admin_app_config_group_key,unique;not null" json:"key"`
Value string `gorm:"type:text" json:"value"` Value string `gorm:"type:text" json:"value"`
Description string `gorm:"size:255" json:"description"` Description string `gorm:"size:255" json:"description"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (AppConfig) TableName() string { func (AppConfig) TableName() string {
@ -94,19 +92,19 @@ func (AppConfig) TableName() string {
} }
type AppBanner struct { type AppBanner struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort,not null;default:lalu" json:"appCode"` AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort,not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"` CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
BannerType string `gorm:"size:16;not null" json:"bannerType"` BannerType string `gorm:"size:16;not null" json:"bannerType"`
Param string `gorm:"size:2048" json:"param"` Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort,not null;default:active" json:"status"` Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort,not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"` Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`
SortOrder int `gorm:"index:idx_admin_app_banners_app_sort,not null;default:0" json:"sortOrder"` SortOrder int `gorm:"index:idx_admin_app_banners_app_sort,not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_banners_scope,not null;default:0" json:"regionId"` RegionID int64 `gorm:"index:idx_admin_app_banners_scope,not null;default:0" json:"regionId"`
CountryCode string `gorm:"size:8;index:idx_admin_app_banners_scope" json:"countryCode"` CountryCode string `gorm:"size:8;index:idx_admin_app_banners_scope" json:"countryCode"`
Description string `gorm:"size:255" json:"description"` Description string `gorm:"size:255" json:"description"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (AppBanner) TableName() string { func (AppBanner) TableName() string {
@ -114,15 +112,15 @@ func (AppBanner) TableName() string {
} }
type RefreshToken struct { type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"userId"` UserID uint `gorm:"index;not null" json:"userId"`
TokenHash string `gorm:"size:128;uniqueIndex;not null" json:"-"` TokenHash string `gorm:"size:128;uniqueIndex;not null" json:"-"`
UserAgent string `gorm:"size:255" json:"userAgent"` UserAgent string `gorm:"size:255" json:"userAgent"`
IP string `gorm:"size:64" json:"ip"` IP string `gorm:"size:64" json:"ip"`
ExpiresAt time.Time `gorm:"index;not null" json:"expiresAt"` ExpiresAtMS int64 `gorm:"column:expires_at_ms;index;not null" json:"expiresAtMs"`
RevokedAt *time.Time `json:"revokedAt"` RevokedAtMS *int64 `gorm:"column:revoked_at_ms" json:"revokedAtMs"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (RefreshToken) TableName() string { func (RefreshToken) TableName() string {
@ -130,14 +128,14 @@ func (RefreshToken) TableName() string {
} }
type LoginLog struct { type LoginLog struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;index" json:"username"` Username string `gorm:"size:64;index" json:"username"`
UserID *uint `gorm:"index" json:"userId"` UserID *uint `gorm:"index" json:"userId"`
IP string `gorm:"size:64" json:"ip"` IP string `gorm:"size:64" json:"ip"`
UserAgent string `gorm:"size:255" json:"userAgent"` UserAgent string `gorm:"size:255" json:"userAgent"`
Status string `gorm:"size:32;index;not null" json:"status"` Status string `gorm:"size:32;index;not null" json:"status"`
Message string `gorm:"size:255" json:"message"` Message string `gorm:"size:255" json:"message"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
} }
func (LoginLog) TableName() string { func (LoginLog) TableName() string {
@ -145,23 +143,23 @@ func (LoginLog) TableName() string {
} }
type OperationLog struct { type OperationLog struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
RequestID string `gorm:"size:64;index;not null;default:''" json:"requestId"` RequestID string `gorm:"size:64;index;not null;default:''" json:"requestId"`
UserID uint `gorm:"index;not null" json:"userId"` UserID uint `gorm:"index;not null" json:"userId"`
Username string `gorm:"size:64;index;not null" json:"username"` Username string `gorm:"size:64;index;not null" json:"username"`
Action string `gorm:"size:80;index;not null" json:"action"` Action string `gorm:"size:80;index;not null" json:"action"`
Resource string `gorm:"size:120;index" json:"resource"` Resource string `gorm:"size:120;index" json:"resource"`
ResourceID string `gorm:"size:80;index;not null;default:''" json:"resourceId"` ResourceID string `gorm:"size:80;index;not null;default:''" json:"resourceId"`
Method string `gorm:"size:12" json:"method"` Method string `gorm:"size:12" json:"method"`
Path string `gorm:"size:180" json:"path"` Path string `gorm:"size:180" json:"path"`
IP string `gorm:"size:64" json:"ip"` IP string `gorm:"size:64" json:"ip"`
UserAgent string `gorm:"size:255;not null;default:''" json:"userAgent"` UserAgent string `gorm:"size:255;not null;default:''" json:"userAgent"`
Status string `gorm:"size:32;index;not null" json:"status"` Status string `gorm:"size:32;index;not null" json:"status"`
HTTPStatus int `gorm:"not null;default:0" json:"httpStatus"` HTTPStatus int `gorm:"not null;default:0" json:"httpStatus"`
RiskLevel string `gorm:"size:24;index;not null;default:normal" json:"riskLevel"` RiskLevel string `gorm:"size:24;index;not null;default:normal" json:"riskLevel"`
LatencyMS int64 `gorm:"not null;default:0" json:"latencyMs"` LatencyMS int64 `gorm:"not null;default:0" json:"latencyMs"`
Detail string `gorm:"type:text" json:"detail"` Detail string `gorm:"type:text" json:"detail"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
} }
func (OperationLog) TableName() string { func (OperationLog) TableName() string {
@ -169,13 +167,13 @@ func (OperationLog) TableName() string {
} }
type Notification struct { type Notification struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:120;not null" json:"title"` Title string `gorm:"size:120;not null" json:"title"`
Content string `gorm:"size:500" json:"content"` Content string `gorm:"size:500" json:"content"`
Level string `gorm:"size:24;index;not null;default:info" json:"level"` Level string `gorm:"size:24;index;not null;default:info" json:"level"`
ReadAt *time.Time `json:"readAt"` ReadAtMS *int64 `gorm:"column:read_at_ms" json:"readAtMs"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (Notification) TableName() string { func (Notification) TableName() string {
@ -183,12 +181,12 @@ func (Notification) TableName() string {
} }
type DataScope struct { type DataScope struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
RoleID uint `gorm:"index;not null" json:"roleId"` RoleID uint `gorm:"index;not null" json:"roleId"`
ScopeType string `gorm:"size:40;index;not null" json:"scopeType"` ScopeType string `gorm:"size:40;index;not null" json:"scopeType"`
ScopeValue string `gorm:"size:120;index;not null" json:"scopeValue"` ScopeValue string `gorm:"size:120;index;not null" json:"scopeValue"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (DataScope) TableName() string { func (DataScope) TableName() string {
@ -196,23 +194,23 @@ func (DataScope) TableName() string {
} }
type AdminJob struct { type AdminJob struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:80;index;not null" json:"type"` Type string `gorm:"size:80;index;not null" json:"type"`
Status string `gorm:"size:32;index;not null" json:"status"` Status string `gorm:"size:32;index;not null" json:"status"`
PayloadJSON string `gorm:"type:text" json:"payload"` PayloadJSON string `gorm:"type:text" json:"payload"`
ResultJSON string `gorm:"type:text" json:"result"` ResultJSON string `gorm:"type:text" json:"result"`
Error string `gorm:"type:text" json:"error"` Error string `gorm:"type:text" json:"error"`
Attempt int `gorm:"not null;default:0" json:"attempt"` Attempt int `gorm:"not null;default:0" json:"attempt"`
MaxAttempts int `gorm:"not null;default:3" json:"maxAttempts"` MaxAttempts int `gorm:"not null;default:3" json:"maxAttempts"`
LockedBy string `gorm:"size:120;index;not null;default:''" json:"lockedBy"` LockedBy string `gorm:"size:120;index;not null;default:''" json:"lockedBy"`
LockedUntil *time.Time `gorm:"index" json:"lockedUntil"` LockedUntilMS *int64 `gorm:"column:locked_until_ms;index" json:"lockedUntilMs"`
ArtifactPath string `gorm:"size:255;not null;default:''" json:"artifactPath"` ArtifactPath string `gorm:"size:255;not null;default:''" json:"artifactPath"`
CreatedBy uint `gorm:"index;not null" json:"createdBy"` CreatedBy uint `gorm:"index;not null" json:"createdBy"`
CreatedByName string `gorm:"size:64;index;not null" json:"createdByName"` CreatedByName string `gorm:"size:64;index;not null" json:"createdByName"`
StartedAt *time.Time `json:"startedAt"` StartedAtMS *int64 `gorm:"column:started_at_ms" json:"startedAtMs"`
FinishedAt *time.Time `json:"finishedAt"` FinishedAtMS *int64 `gorm:"column:finished_at_ms" json:"finishedAtMs"`
CreatedAt time.Time `json:"createdAt"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }
func (AdminJob) TableName() string { func (AdminJob) TableName() string {

View File

@ -5,6 +5,7 @@ import (
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"strings" "strings"
"time"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
@ -152,8 +153,8 @@ func (s *AdminUserService) ExportUsers(actor shared.Actor, options repository.Li
_ = writer.Write([]string{"ID", "账号", "姓名", "角色", "团队", "状态", "MFA", "最后登录"}) _ = writer.Write([]string{"ID", "账号", "姓名", "角色", "团队", "状态", "MFA", "最后登录"})
for _, user := range users { for _, user := range users {
lastLogin := "" lastLogin := ""
if user.LastLoginAt != nil { if user.LastLoginAtMS != nil {
lastLogin = user.LastLoginAt.Format("2006-01-02 15:04:05") lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("2006-01-02 15:04:05")
} }
_ = writer.Write([]string{ _ = writer.Write([]string{
fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", user.ID),

View File

@ -77,7 +77,7 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
} }
if config, ok := configByKey[definition.Key]; ok { if config, ok := configByKey[definition.Key]; ok {
item.URL = config.Value item.URL = config.Value
item.UpdatedAtMs = config.UpdatedAt.UnixMilli() item.UpdatedAtMs = config.UpdatedAtMS
} }
items = append(items, item) items = append(items, item)
} }
@ -260,8 +260,8 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
RegionID: item.RegionID, RegionID: item.RegionID,
CountryCode: item.CountryCode, CountryCode: item.CountryCode,
Description: item.Description, Description: item.Description,
CreatedAtMs: item.CreatedAt.UnixMilli(), CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAt.UnixMilli(), UpdatedAtMs: item.UpdatedAtMS,
} }
} }

View File

@ -8,7 +8,9 @@ import (
"strings" "strings"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
@ -21,9 +23,9 @@ type Handler struct {
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler { func New(userClient userclient.Client, activityClient activityclient.Client, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler {
return &Handler{ return &Handler{
service: NewService(userClient, userDB, walletDB), service: NewService(userClient, activityClient, userDB, walletDB),
cfg: cfg, cfg: cfg,
audit: audit, audit: audit,
} }
@ -87,7 +89,7 @@ func (h *Handler) UpdateUser(c *gin.Context) {
response.BadRequest(c, "用户参数不正确") response.BadRequest(c, "用户参数不正确")
return return
} }
user, err := h.service.UpdateUser(c.Request.Context(), userID, req) user, err := h.service.UpdateUser(c.Request.Context(), userID, middleware.CurrentRequestID(c), req)
if err != nil { if err != nil {
writeMutationError(c, err, "更新 App 用户失败") writeMutationError(c, err, "更新 App 用户失败")
return return

View File

@ -5,13 +5,16 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/security" "hyapp-admin-server/internal/security"
activityv1 "hyapp.local/api/proto/activity/v1"
) )
var ( var (
@ -20,9 +23,10 @@ var (
) )
type Service struct { type Service struct {
userClient userclient.Client userClient userclient.Client
userDB *sql.DB activityClient activityclient.Client
walletDB *sql.DB userDB *sql.DB
walletDB *sql.DB
} }
type AppUser struct { type AppUser struct {
@ -44,8 +48,8 @@ type AppUser struct {
Username string `json:"username"` Username string `json:"username"`
} }
func NewService(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service { func NewService(userClient userclient.Client, activityClient activityclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, userDB: userDB, walletDB: walletDB} return &Service{userClient: userClient, activityClient: activityClient, userDB: userDB, walletDB: walletDB}
} }
func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) { func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
@ -232,7 +236,7 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
return items[0], nil return items[0], nil
} }
func (s *Service) UpdateUser(ctx context.Context, userID int64, req updateUserRequest) (AppUser, error) { func (s *Service) UpdateUser(ctx context.Context, userID int64, requestID string, req updateUserRequest) (AppUser, error) {
if s.userDB == nil { if s.userDB == nil {
return AppUser{}, fmt.Errorf("user mysql is not configured") return AppUser{}, fmt.Errorf("user mysql is not configured")
} }
@ -262,7 +266,13 @@ func (s *Service) UpdateUser(ctx context.Context, userID int64, req updateUserRe
sets = append(sets, "gender = ?") sets = append(sets, "gender = ?")
args = append(args, nullableString(value)) args = append(args, nullableString(value))
} }
oldRegionID := int64(0)
if req.Country != nil { if req.Country != nil {
before, err := s.GetUser(ctx, userID)
if err != nil {
return AppUser{}, err
}
oldRegionID = before.RegionID
country, regionID, err := s.resolveCountryRegion(ctx, *req.Country) country, regionID, err := s.resolveCountryRegion(ctx, *req.Country)
if err != nil { if err != nil {
return AppUser{}, err return AppUser{}, err
@ -289,7 +299,41 @@ func (s *Service) UpdateUser(ctx context.Context, userID int64, req updateUserRe
if affected == 0 { if affected == 0 {
return AppUser{}, ErrNotFound return AppUser{}, ErrNotFound
} }
return s.GetUser(ctx, userID) updated, err := s.GetUser(ctx, userID)
if err != nil {
return AppUser{}, err
}
if req.Country != nil {
s.removeOldRegionBroadcastMemberBestEffort(ctx, userID, oldRegionID, updated.RegionID, strings.TrimSpace(requestID), "admin_user_country_changed")
}
return updated, nil
}
func (s *Service) removeOldRegionBroadcastMemberBestEffort(ctx context.Context, userID int64, oldRegionID int64, newRegionID int64, requestID string, reason string) {
if s == nil || s.activityClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID {
return
}
_, err := s.activityClient.RemoveRegionBroadcastMember(ctx, &activityv1.RemoveRegionBroadcastMemberRequest{
Meta: &activityv1.RequestMeta{
RequestId: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
SentAtMs: nowMillis(),
AppCode: appctx.FromContext(ctx),
},
UserId: userID,
RegionId: oldRegionID,
Reason: reason,
})
if err != nil {
// 后台国家更新已经写入 usersIM 成员移除是外部副作用失败不回滚资料callback 会阻止用户再次加入旧区域群。
slog.WarnContext(ctx, "admin_im_region_group_member_remove_failed",
slog.Int64("user_id", userID),
slog.Int64("old_region_id", oldRegionID),
slog.Int64("new_region_id", newRegionID),
slog.String("request_id", requestID),
slog.String("error", err.Error()),
)
}
} }
func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string) (AppUser, error) { func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string) (AppUser, error) {

View File

@ -78,7 +78,7 @@ func (s *AuditService) ExportLoginLogs(options repository.ListOptions) (CSVExpor
item.IP, item.IP,
item.Status, item.Status,
item.Message, item.Message,
item.CreatedAt.Format(time.RFC3339), time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
}) })
} }
writer.Flush() writer.Flush()
@ -109,7 +109,7 @@ func (s *AuditService) ExportOperationLogs(options repository.ListOptions) (CSVE
item.RiskLevel, item.RiskLevel,
fmt.Sprintf("%d", item.LatencyMS), fmt.Sprintf("%d", item.LatencyMS),
item.Detail, item.Detail,
item.CreatedAt.Format(time.RFC3339), time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
}) })
} }
writer.Flush() writer.Flush()

View File

@ -44,7 +44,7 @@ func (h *Handler) Login(c *gin.Context) {
h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds())) h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds()))
response.OK(c, gin.H{ response.OK(c, gin.H{
"accessToken": result.AccessToken, "accessToken": result.AccessToken,
"expiresAt": result.ExpiresAt, "expiresAtMs": result.ExpiresAtMS,
"user": shared.UserDTO(result.User), "user": shared.UserDTO(result.User),
"permissions": result.Permissions, "permissions": result.Permissions,
}) })
@ -73,7 +73,7 @@ func (h *Handler) Refresh(c *gin.Context) {
response.OK(c, gin.H{ response.OK(c, gin.H{
"accessToken": result.AccessToken, "accessToken": result.AccessToken,
"expiresAt": result.ExpiresAt, "expiresAtMs": result.ExpiresAtMS,
"user": shared.UserDTO(result.User), "user": shared.UserDTO(result.User),
"permissions": result.Permissions, "permissions": result.Permissions,
}) })

View File

@ -29,7 +29,7 @@ type LoginInput struct {
type LoginResult struct { type LoginResult struct {
AccessToken string AccessToken string
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAtMS int64
User model.User User model.User
Permissions []string Permissions []string
} }
@ -59,17 +59,17 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) {
return LoginResult{}, err return LoginResult{}, err
} }
if err := s.store.CreateRefreshToken(model.RefreshToken{ if err := s.store.CreateRefreshToken(model.RefreshToken{
UserID: user.ID, UserID: user.ID,
TokenHash: refreshHash, TokenHash: refreshHash,
UserAgent: input.UserAgent, UserAgent: input.UserAgent,
IP: input.IP, IP: input.IP,
ExpiresAt: time.Now().Add(s.cfg.RefreshTokenTTL), ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(),
}); err != nil { }); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
_ = s.store.TouchUserLogin(user.ID) _ = s.store.TouchUserLogin(user.ID)
s.loginLog(input, &user.ID, "success", "登录成功") s.loginLog(input, &user.ID, "success", "登录成功")
return LoginResult{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: *user, Permissions: permissions}, nil return LoginResult{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil
} }
func (s *AuthFlowService) Logout(refreshToken string) error { func (s *AuthFlowService) Logout(refreshToken string) error {
@ -96,7 +96,7 @@ func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) {
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
return LoginResult{AccessToken: accessToken, ExpiresAt: expiresAt, User: *user, Permissions: permissions}, nil return LoginResult{AccessToken: accessToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil
} }
func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) { func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) {

View File

@ -5,7 +5,7 @@ import "hyapp-admin-server/internal/model"
func unreadCount(items []model.Notification) int { func unreadCount(items []model.Notification) int {
count := 0 count := 0
for _, item := range items { for _, item := range items {
if item.ReadAt == nil { if item.ReadAtMS == nil {
count++ count++
} }
} }

View File

@ -2,6 +2,7 @@ package shared
import ( import (
"strings" "strings"
"time"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
) )
@ -27,8 +28,8 @@ func UserDTO(user model.User) map[string]interface{} {
} }
lastLogin := "-" lastLogin := "-"
if user.LastLoginAt != nil { if user.LastLoginAtMS != nil {
lastLogin = user.LastLoginAt.Format("01-02 15:04") lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("01-02 15:04")
} }
return map[string]interface{}{ return map[string]interface{}{
@ -41,6 +42,7 @@ func UserDTO(user model.User) map[string]interface{} {
"mfa": BoolText(user.MFAEnabled), "mfa": BoolText(user.MFAEnabled),
"mfaEnabled": user.MFAEnabled, "mfaEnabled": user.MFAEnabled,
"lastLogin": lastLogin, "lastLogin": lastLogin,
"lastLoginMs": user.LastLoginAtMS,
"roles": user.Roles, "roles": user.Roles,
"role": role, "role": role,
"roleIds": roleIDs, "roleIds": roleIDs,

View File

@ -128,7 +128,7 @@ func (s *Store) ListJobs(options ListOptions) ([]model.AdminJob, int64, error) {
} }
var jobs []model.AdminJob var jobs []model.AdminJob
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&jobs).Error err := query.Order("created_at_ms DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&jobs).Error
return jobs, total, err return jobs, total, err
} }
@ -143,13 +143,13 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
maxAttempts = 3 maxAttempts = 3
} }
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
var leased model.AdminJob var leased model.AdminJob
err := s.db.Transaction(func(tx *gorm.DB) error { err := s.db.Transaction(func(tx *gorm.DB) error {
var job model.AdminJob var job model.AdminJob
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("(status = ? OR (status = ? AND locked_until IS NOT NULL AND locked_until < ?)) AND attempt < max_attempts", model.JobStatusPending, model.JobStatusRunning, now). Where("(status = ? OR (status = ? AND locked_until_ms IS NOT NULL AND locked_until_ms < ?)) AND attempt < max_attempts", model.JobStatusPending, model.JobStatusRunning, nowMS).
Order("created_at ASC, id ASC"). Order("created_at_ms ASC, id ASC").
First(&job).Error First(&job).Error
if err != nil { if err != nil {
return err return err
@ -158,13 +158,13 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
job.MaxAttempts = maxAttempts job.MaxAttempts = maxAttempts
} }
updates := map[string]interface{}{ updates := map[string]interface{}{
"status": model.JobStatusRunning, "status": model.JobStatusRunning,
"attempt": job.Attempt + 1, "attempt": job.Attempt + 1,
"max_attempts": job.MaxAttempts, "max_attempts": job.MaxAttempts,
"locked_by": workerID, "locked_by": workerID,
"locked_until": now.Add(lease), "locked_until_ms": nowMS + lease.Milliseconds(),
"started_at": firstTime(job.StartedAt, now), "started_at_ms": firstMS(job.StartedAtMS, nowMS),
"error": "", "error": "",
} }
if err := tx.Model(&model.AdminJob{}).Where("id = ?", job.ID).Updates(updates).Error; err != nil { if err := tx.Model(&model.AdminJob{}).Where("id = ?", job.ID).Updates(updates).Error; err != nil {
return err return err
@ -184,14 +184,14 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
} }
func (s *Store) CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error { func (s *Store) CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": model.JobStatusSucceeded, "status": model.JobStatusSucceeded,
"result_json": resultJSON, "result_json": resultJSON,
"artifact_path": artifactPath, "artifact_path": artifactPath,
"locked_by": "", "locked_by": "",
"locked_until": nil, "locked_until_ms": nil,
"finished_at": &now, "finished_at_ms": &nowMS,
}).Error }).Error
} }
@ -200,19 +200,19 @@ func (s *Store) FailJobAttempt(id uint, message string) error {
if err := s.db.First(&job, id).Error; err != nil { if err := s.db.First(&job, id).Error; err != nil {
return err return err
} }
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
status := model.JobStatusPending status := model.JobStatusPending
finishedAt := interface{}(nil) finishedAt := interface{}(nil)
if job.Attempt >= job.MaxAttempts { if job.Attempt >= job.MaxAttempts {
status = model.JobStatusFailed status = model.JobStatusFailed
finishedAt = &now finishedAt = &nowMS
} }
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status, "status": status,
"error": message, "error": message,
"locked_by": "", "locked_by": "",
"locked_until": nil, "locked_until_ms": nil,
"finished_at": finishedAt, "finished_at_ms": finishedAt,
}).Error }).Error
} }
@ -220,21 +220,21 @@ func (s *Store) ReleaseJobLease(id uint) error {
return s.db.Model(&model.AdminJob{}). return s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ?", id, model.JobStatusRunning). Where("id = ? AND status = ?", id, model.JobStatusRunning).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": model.JobStatusPending, "status": model.JobStatusPending,
"locked_by": "", "locked_by": "",
"locked_until": nil, "locked_until_ms": nil,
}).Error }).Error
} }
func (s *Store) CancelJob(id uint) error { func (s *Store) CancelJob(id uint) error {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
result := s.db.Model(&model.AdminJob{}). result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status IN ?", id, []string{model.JobStatusPending, model.JobStatusRunning}). Where("id = ? AND status IN ?", id, []string{model.JobStatusPending, model.JobStatusRunning}).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": model.JobStatusCanceled, "status": model.JobStatusCanceled,
"locked_by": "", "locked_by": "",
"locked_until": nil, "locked_until_ms": nil,
"finished_at": &now, "finished_at_ms": &nowMS,
}) })
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
@ -253,7 +253,7 @@ func (s *Store) FindJobByID(id uint) (*model.AdminJob, error) {
return &job, nil return &job, nil
} }
func firstTime(value *time.Time, fallback time.Time) *time.Time { func firstMS(value *int64, fallback int64) *int64 {
if value != nil { if value != nil {
return value return value
} }

View File

@ -20,13 +20,13 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
return nil return nil
} }
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
for index := range items { for index := range items {
items[index].Group = strings.TrimSpace(items[index].Group) items[index].Group = strings.TrimSpace(items[index].Group)
items[index].Key = strings.TrimSpace(items[index].Key) items[index].Key = strings.TrimSpace(items[index].Key)
items[index].UpdatedAt = now items[index].UpdatedAtMS = nowMS
if items[index].CreatedAt.IsZero() { if items[index].CreatedAtMS == 0 {
items[index].CreatedAt = now items[index].CreatedAtMS = nowMS
} }
} }
@ -35,6 +35,6 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
{Name: "group"}, {Name: "group"},
{Name: "key"}, {Name: "key"},
}, },
DoUpdates: clause.AssignmentColumns([]string{"value", "description", "updated_at"}), DoUpdates: clause.AssignmentColumns([]string{"value", "description", "updated_at_ms"}),
}).Create(&items).Error }).Create(&items).Error
} }

View File

@ -27,7 +27,7 @@ func (s *Store) ListLoginLogs(options ListOptions) ([]model.LoginLog, int64, err
} }
var logs []model.LoginLog var logs []model.LoginLog
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error err := query.Order("created_at_ms DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error
return logs, total, err return logs, total, err
} }
@ -39,7 +39,7 @@ func (s *Store) ExportLoginLogs(options ListOptions) ([]model.LoginLog, error) {
} }
var logs []model.LoginLog var logs []model.LoginLog
err := query.Order("created_at DESC").Find(&logs).Error err := query.Order("created_at_ms DESC").Find(&logs).Error
return logs, err return logs, err
} }
@ -57,7 +57,7 @@ func (s *Store) ListOperationLogs(options ListOptions) ([]model.OperationLog, in
} }
var logs []model.OperationLog var logs []model.OperationLog
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error err := query.Order("created_at_ms DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error
return logs, total, err return logs, total, err
} }
@ -69,6 +69,6 @@ func (s *Store) ExportOperationLogs(options ListOptions) ([]model.OperationLog,
} }
var logs []model.OperationLog var logs []model.OperationLog
err := query.Order("created_at DESC").Find(&logs).Error err := query.Order("created_at_ms DESC").Find(&logs).Error
return logs, err return logs, err
} }

View File

@ -42,10 +42,10 @@ func PermissionsForUser(user model.User) []string {
} }
func (s *Store) TouchUserLogin(id uint) error { func (s *Store) TouchUserLogin(id uint) error {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
"last_login_at": &now, "last_login_at_ms": &nowMS,
"updated_at": now, "updated_at_ms": nowMS,
}).Error }).Error
} }
@ -55,7 +55,7 @@ func (s *Store) CreateRefreshToken(token model.RefreshToken) error {
func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) { func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
var token model.RefreshToken var token model.RefreshToken
err := s.db.Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", hash, time.Now()).First(&token).Error err := s.db.Where("token_hash = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?", hash, time.Now().UTC().UnixMilli()).First(&token).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -64,6 +64,6 @@ func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
} }
func (s *Store) RevokeRefreshToken(hash string) error { func (s *Store) RevokeRefreshToken(hash string) error {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.RefreshToken{}).Where("token_hash = ? AND revoked_at IS NULL", hash).Update("revoked_at", &now).Error return s.db.Model(&model.RefreshToken{}).Where("token_hash = ? AND revoked_at_ms IS NULL", hash).Update("revoked_at_ms", &nowMS).Error
} }

View File

@ -27,17 +27,17 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
if err := s.db.Model(&model.Menu{}).Where("visible = ?", true).Count(&menuTotal).Error; err != nil { if err := s.db.Model(&model.Menu{}).Where("visible = ?", true).Count(&menuTotal).Error; err != nil {
return nil, err return nil, err
} }
now := time.Now() now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) todayMS := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
if err := s.db.Model(&model.OperationLog{}).Where("created_at >= ?", today).Count(&logsToday).Error; err != nil { if err := s.db.Model(&model.OperationLog{}).Where("created_at_ms >= ?", todayMS).Count(&logsToday).Error; err != nil {
return nil, err return nil, err
} }
if err := s.db.Model(&model.Notification{}).Where("read_at IS NULL").Count(&unreadNotifications).Error; err != nil { if err := s.db.Model(&model.Notification{}).Where("read_at_ms IS NULL").Count(&unreadNotifications).Error; err != nil {
return nil, err return nil, err
} }
var notifications []model.Notification var notifications []model.Notification
if err := s.db.Where("read_at IS NULL").Order("created_at DESC").Limit(3).Find(&notifications).Error; err != nil { if err := s.db.Where("read_at_ms IS NULL").Order("created_at_ms DESC").Limit(3).Find(&notifications).Error; err != nil {
return nil, err return nil, err
} }
alerts := make([]map[string]string, 0, len(notifications)) alerts := make([]map[string]string, 0, len(notifications))
@ -46,7 +46,7 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
"type": item.Level, "type": item.Level,
"name": item.Title, "name": item.Title,
"desc": item.Content, "desc": item.Content,
"time": item.CreatedAt.Format("15:04"), "time": time.UnixMilli(item.CreatedAtMS).UTC().Format("15:04"),
}) })
} }
@ -64,7 +64,7 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
"operations": {int(logsToday)}, "operations": {int(logsToday)},
"notifications": {int(unreadNotifications)}, "notifications": {int(unreadNotifications)},
}, },
Alerts: alerts, Alerts: alerts,
UpdatedAt: now.Format("15:04:05"), UpdatedAtMS: now.UnixMilli(),
}, nil }, nil
} }

View File

@ -97,7 +97,7 @@ func userSort(sort string) string {
case "username_desc": case "username_desc":
return "username DESC" return "username DESC"
case "last_login_desc": case "last_login_desc":
return "last_login_at DESC" return "last_login_at_ms DESC"
default: default:
return "id ASC" return "id ASC"
} }

View File

@ -7,13 +7,13 @@ import (
func (s *Store) ListNotifications() ([]model.Notification, error) { func (s *Store) ListNotifications() ([]model.Notification, error) {
var items []model.Notification var items []model.Notification
err := s.db.Order("created_at DESC").Find(&items).Error err := s.db.Order("created_at_ms DESC").Find(&items).Error
return items, err return items, err
} }
func (s *Store) MarkNotificationRead(id uint) (*model.Notification, error) { func (s *Store) MarkNotificationRead(id uint) (*model.Notification, error) {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
if err := s.db.Model(&model.Notification{}).Where("id = ?", id).Update("read_at", &now).Error; err != nil { if err := s.db.Model(&model.Notification{}).Where("id = ?", id).Update("read_at_ms", &nowMS).Error; err != nil {
return nil, err return nil, err
} }
var item model.Notification var item model.Notification
@ -24,8 +24,8 @@ func (s *Store) MarkNotificationRead(id uint) (*model.Notification, error) {
} }
func (s *Store) MarkAllNotificationsRead() (int64, error) { func (s *Store) MarkAllNotificationsRead() (int64, error) {
now := time.Now() nowMS := time.Now().UTC().UnixMilli()
result := s.db.Model(&model.Notification{}).Where("read_at IS NULL").Update("read_at", &now) result := s.db.Model(&model.Notification{}).Where("read_at_ms IS NULL").Update("read_at_ms", &nowMS)
return result.RowsAffected, result.Error return result.RowsAffected, result.Error
} }

View File

@ -38,7 +38,7 @@ type DashboardOverview struct {
UnreadNotifications int `json:"unreadNotifications"` UnreadNotifications int `json:"unreadNotifications"`
Alerts []map[string]string `json:"alerts"` Alerts []map[string]string `json:"alerts"`
Series map[string][]int `json:"series"` Series map[string][]int `json:"series"`
UpdatedAt string `json:"updatedAt"` UpdatedAtMS int64 `json:"updatedAtMs"`
} }
type MenuSortItem struct { type MenuSortItem struct {

View File

@ -34,14 +34,15 @@ func CheckPassword(hash string, password string) bool {
} }
func (s *AuthService) GenerateAccessToken(userID uint, username string, permissions []string) (string, time.Time, error) { func (s *AuthService) GenerateAccessToken(userID uint, username string, permissions []string) (string, time.Time, error) {
expiresAt := time.Now().Add(s.ttl) now := time.Now().UTC()
expiresAt := now.Add(s.ttl)
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
Permissions: permissions, Permissions: permissions,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt), ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(now),
Subject: username, Subject: username,
}, },
} }

View File

@ -6,9 +6,9 @@ CREATE TABLE IF NOT EXISTS admin_users (
team VARCHAR(80), team VARCHAR(80),
status VARCHAR(24) NOT NULL DEFAULT 'active', status VARCHAR(24) NOT NULL DEFAULT 'active',
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at DATETIME NULL, last_login_at_ms BIGINT NULL,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_users_status (status) INDEX idx_admin_users_status (status)
); );
@ -17,8 +17,8 @@ CREATE TABLE IF NOT EXISTS admin_roles (
name VARCHAR(80) NOT NULL UNIQUE, name VARCHAR(80) NOT NULL UNIQUE,
code VARCHAR(80) NOT NULL UNIQUE, code VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255), description VARCHAR(255),
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL updated_at_ms BIGINT NOT NULL
); );
CREATE TABLE IF NOT EXISTS admin_permissions ( CREATE TABLE IF NOT EXISTS admin_permissions (
@ -27,8 +27,8 @@ CREATE TABLE IF NOT EXISTS admin_permissions (
code VARCHAR(100) NOT NULL UNIQUE, code VARCHAR(100) NOT NULL UNIQUE,
kind VARCHAR(32) NOT NULL, kind VARCHAR(32) NOT NULL,
description VARCHAR(255), description VARCHAR(255),
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_permissions_kind (kind) INDEX idx_admin_permissions_kind (kind)
); );
@ -54,8 +54,8 @@ CREATE TABLE IF NOT EXISTS admin_menus (
permission_code VARCHAR(100), permission_code VARCHAR(100),
sort INT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0,
visible BOOLEAN NOT NULL DEFAULT TRUE, visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_menus_parent_id (parent_id), INDEX idx_admin_menus_parent_id (parent_id),
INDEX idx_admin_menus_permission_code (permission_code) INDEX idx_admin_menus_permission_code (permission_code)
); );
@ -66,12 +66,12 @@ CREATE TABLE IF NOT EXISTS admin_refresh_tokens (
token_hash VARCHAR(128) NOT NULL UNIQUE, token_hash VARCHAR(128) NOT NULL UNIQUE,
user_agent VARCHAR(255), user_agent VARCHAR(255),
ip VARCHAR(64), ip VARCHAR(64),
expires_at DATETIME NOT NULL, expires_at_ms BIGINT NOT NULL,
revoked_at DATETIME NULL, revoked_at_ms BIGINT NULL,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_refresh_tokens_user_id (user_id), INDEX idx_admin_refresh_tokens_user_id (user_id),
INDEX idx_admin_refresh_tokens_expires_at (expires_at) INDEX idx_admin_refresh_tokens_expires_at (expires_at_ms)
); );
CREATE TABLE IF NOT EXISTS admin_login_logs ( CREATE TABLE IF NOT EXISTS admin_login_logs (
@ -82,7 +82,7 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
user_agent VARCHAR(255), user_agent VARCHAR(255),
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
message VARCHAR(255), message VARCHAR(255),
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
INDEX idx_admin_login_logs_username (username), INDEX idx_admin_login_logs_username (username),
INDEX idx_admin_login_logs_status (status) INDEX idx_admin_login_logs_status (status)
); );
@ -98,7 +98,7 @@ CREATE TABLE IF NOT EXISTS admin_operation_logs (
ip VARCHAR(64), ip VARCHAR(64),
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
detail TEXT, detail TEXT,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
INDEX idx_admin_operation_logs_user_id (user_id), INDEX idx_admin_operation_logs_user_id (user_id),
INDEX idx_admin_operation_logs_action (action) INDEX idx_admin_operation_logs_action (action)
); );
@ -108,9 +108,9 @@ CREATE TABLE IF NOT EXISTS admin_notifications (
title VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL,
content VARCHAR(500), content VARCHAR(500),
level VARCHAR(24) NOT NULL DEFAULT 'info', level VARCHAR(24) NOT NULL DEFAULT 'info',
read_at DATETIME NULL, read_at_ms BIGINT NULL,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_notifications_level (level) INDEX idx_admin_notifications_level (level)
); );
@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS admin_service_assets (
memory INT NOT NULL DEFAULT 0, memory INT NOT NULL DEFAULT 0,
trend_json TEXT, trend_json TEXT,
logs_json TEXT, logs_json TEXT,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_service_assets_status (status) INDEX idx_admin_service_assets_status (status)
); );

View File

@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS admin_data_scopes (
role_id BIGINT UNSIGNED NOT NULL, role_id BIGINT UNSIGNED NOT NULL,
scope_type VARCHAR(40) NOT NULL, scope_type VARCHAR(40) NOT NULL,
scope_value VARCHAR(120) NOT NULL, scope_value VARCHAR(120) NOT NULL,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_admin_data_scopes_role_scope (role_id, scope_type, scope_value), UNIQUE KEY uk_admin_data_scopes_role_scope (role_id, scope_type, scope_value),
INDEX idx_admin_data_scopes_role_id (role_id), INDEX idx_admin_data_scopes_role_id (role_id),
INDEX idx_admin_data_scopes_scope (scope_type, scope_value) INDEX idx_admin_data_scopes_scope (scope_type, scope_value)
@ -19,10 +19,10 @@ CREATE TABLE IF NOT EXISTS admin_jobs (
error TEXT, error TEXT,
created_by BIGINT UNSIGNED NOT NULL, created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NOT NULL, created_by_name VARCHAR(64) NOT NULL,
started_at DATETIME NULL, started_at_ms BIGINT NULL,
finished_at DATETIME NULL, finished_at_ms BIGINT NULL,
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_jobs_type (type), INDEX idx_admin_jobs_type (type),
INDEX idx_admin_jobs_status (status), INDEX idx_admin_jobs_status (status),
INDEX idx_admin_jobs_created_by (created_by), INDEX idx_admin_jobs_created_by (created_by),

View File

@ -13,7 +13,7 @@ ALTER TABLE admin_jobs
ADD COLUMN attempt INT NOT NULL DEFAULT 0 AFTER error, ADD COLUMN attempt INT NOT NULL DEFAULT 0 AFTER error,
ADD COLUMN max_attempts INT NOT NULL DEFAULT 3 AFTER attempt, ADD COLUMN max_attempts INT NOT NULL DEFAULT 3 AFTER attempt,
ADD COLUMN locked_by VARCHAR(120) NOT NULL DEFAULT '' AFTER max_attempts, ADD COLUMN locked_by VARCHAR(120) NOT NULL DEFAULT '' AFTER max_attempts,
ADD COLUMN locked_until DATETIME(3) NULL AFTER locked_by, ADD COLUMN locked_until_ms BIGINT NULL AFTER locked_by,
ADD COLUMN artifact_path VARCHAR(255) NOT NULL DEFAULT '' AFTER locked_until, ADD COLUMN artifact_path VARCHAR(255) NOT NULL DEFAULT '' AFTER locked_until_ms,
ADD INDEX idx_admin_jobs_locked_by (locked_by), ADD INDEX idx_admin_jobs_locked_by (locked_by),
ADD INDEX idx_admin_jobs_locked_until (locked_until); ADD INDEX idx_admin_jobs_locked_until (locked_until_ms);

View File

@ -10,8 +10,8 @@ CREATE TABLE IF NOT EXISTS admin_app_banners (
region_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
country_code VARCHAR(8) NOT NULL DEFAULT '', country_code VARCHAR(8) NOT NULL DEFAULT '',
description VARCHAR(255) NOT NULL DEFAULT '', description VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at DATETIME NOT NULL, updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_app_banners_app_sort (app_code, status, sort_order, id), INDEX idx_admin_app_banners_app_sort (app_code, status, sort_order, id),
INDEX idx_admin_app_banners_scope (app_code, platform, region_id, country_code) INDEX idx_admin_app_banners_scope (app_code, platform, region_id, country_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -13,6 +13,8 @@ RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-healt
FROM alpine:3.20 FROM alpine:3.20
ENV TZ=UTC
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser

View File

@ -8,11 +8,28 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13006" grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "user-service:13005" user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004" wallet_service_addr: "wallet-service:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false mysql_auto_migrate: false
tencent_im:
# Docker 本地默认关闭播报 REST避免未配置密钥时启动后访问外部腾讯云。
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
admin_user_sig_ttl: "24h"
endpoint: "console.tim.qq.com"
group_type: "ChatRoom"
request_timeout: "5s"
broadcast:
enabled: false
super_gift_min_value: 100000
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
consumer: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -8,11 +8,28 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13006" grpc_addr: ":13006"
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
user_service_addr: "user-service.internal:13005" user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004" wallet_service_addr: "wallet-service.internal:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false mysql_auto_migrate: false
tencent_im:
# 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。
enabled: true
sdk_app_id: 1400000000
secret_key: "TENCENT_IM_SECRET_KEY"
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
admin_user_sig_ttl: "24h"
endpoint: "adminapisgp.im.qcloud.com"
group_type: "ChatRoom"
request_timeout: "5s"
broadcast:
enabled: true
super_gift_min_value: 100000
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
consumer: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -8,11 +8,28 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13006" grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "127.0.0.1:13005" user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004" wallet_service_addr: "127.0.0.1:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false mysql_auto_migrate: false
tencent_im:
# activity-service 只负责全局/区域播报群;本地默认关闭,配置完整后才发送腾讯云 IM REST。
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
admin_user_sig_ttl: "24h"
endpoint: "console.tim.qq.com"
group_type: "ChatRoom"
request_timeout: "5s"
broadcast:
enabled: false
super_gift_min_value: 100000
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
consumer: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -42,6 +42,26 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms) KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
event_id VARCHAR(128) NOT NULL,
scope VARCHAR(32) NOT NULL,
group_id VARCHAR(64) NOT NULL,
broadcast_type VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error VARCHAR(512) NOT NULL DEFAULT '',
locked_by VARCHAR(128) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS task_definitions ( CREATE TABLE IF NOT EXISTS task_definitions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
task_id VARCHAR(96) NOT NULL, task_id VARCHAR(96) NOT NULL,

View File

@ -14,9 +14,11 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth" "hyapp/pkg/grpchealth"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/services/activity-service/internal/client" "hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config" "hyapp/services/activity-service/internal/config"
activityservice "hyapp/services/activity-service/internal/service/activity" activityservice "hyapp/services/activity-service/internal/service/activity"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
messageservice "hyapp/services/activity-service/internal/service/message" messageservice "hyapp/services/activity-service/internal/service/message"
taskservice "hyapp/services/activity-service/internal/service/task" taskservice "hyapp/services/activity-service/internal/service/task"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql" mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
@ -25,13 +27,18 @@ import (
// App 装配 activity-service gRPC 入口和底座依赖。 // App 装配 activity-service gRPC 入口和底座依赖。
type App struct { type App struct {
server *grpc.Server server *grpc.Server
listener net.Listener listener net.Listener
health *grpchealth.ServingChecker health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository mysqlRepo *mysqlstorage.Repository
userConn *grpc.ClientConn broadcast *broadcastservice.Service
walletConn *grpc.ClientConn broadcastWorkerEnabled bool
closeOnce sync.Once userConn *grpc.ClientConn
walletConn *grpc.ClientConn
workerCtx context.Context
workerStop context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
} }
// New 初始化 activity-service 应用。 // New 初始化 activity-service 应用。
@ -67,29 +74,76 @@ func New(cfg config.Config) (*App, error) {
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service"))) server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository) svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn))) messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn)))
taskSvc := taskservice.New(taskservice.Config{TaskTimezone: cfg.TaskTimezone}, repository, walletv1.NewWalletServiceClient(walletConn)) taskSvc := taskservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
var broadcastPublisher broadcastservice.Publisher
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
_ = walletConn.Close()
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
broadcastPublisher = tencentClient
}
if cfg.Broadcast.Enabled && broadcastPublisher == nil {
_ = walletConn.Close()
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, errors.New("broadcast worker requires tencent_im.enabled")
}
broadcastSvc := broadcastservice.New(broadcastservice.Config{
NodeID: cfg.NodeID,
SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue,
WorkerBatchSize: cfg.Broadcast.WorkerBatchSize,
WorkerLockTTL: cfg.Broadcast.WorkerLockTTL,
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc)) activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc)) activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc)) activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc))
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc)) activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc))
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc)) activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc))
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
health := grpchealth.NewServingChecker("activity-service") health := grpchealth.NewServingChecker("activity-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
workerCtx, workerStop := context.WithCancel(context.Background())
return &App{ return &App{
server: server, server: server,
listener: listener, listener: listener,
health: health, health: health,
mysqlRepo: repository, mysqlRepo: repository,
userConn: userConn, broadcast: broadcastSvc,
walletConn: walletConn, broadcastWorkerEnabled: cfg.Broadcast.Enabled,
userConn: userConn,
walletConn: walletConn,
workerCtx: workerCtx,
workerStop: workerStop,
}, nil }, nil
} }
// Run 启动 gRPC 服务。 // Run 启动 gRPC 服务。
func (a *App) Run() error { func (a *App) Run() error {
a.health.MarkServing() a.health.MarkServing()
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
}()
}
err := a.server.Serve(a.listener) err := a.server.Serve(a.listener)
if a.workerStop != nil {
a.workerStop()
}
a.workerWG.Wait()
a.health.MarkStopped() a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) { if errors.Is(err, grpc.ErrServerStopped) {
return nil return nil
@ -102,6 +156,10 @@ func (a *App) Run() error {
func (a *App) Close() { func (a *App) Close() {
a.closeOnce.Do(func() { a.closeOnce.Do(func() {
a.health.MarkDraining() a.health.MarkDraining()
if a.workerStop != nil {
a.workerStop()
}
a.workerWG.Wait()
a.server.GracefulStop() a.server.GracefulStop()
if a.userConn != nil { if a.userConn != nil {
_ = a.userConn.Close() _ = a.userConn.Close()

View File

@ -0,0 +1,41 @@
package client
import (
"context"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
)
// GRPCRegionSource reads active region IDs from user-service region master data.
type GRPCRegionSource struct {
client userv1.RegionAdminServiceClient
}
// NewGRPCRegionSource creates a region source backed by user-service gRPC.
func NewGRPCRegionSource(conn *grpc.ClientConn) *GRPCRegionSource {
return &GRPCRegionSource{client: userv1.NewRegionAdminServiceClient(conn)}
}
// ListActiveRegionIDs returns positive active region IDs; GLOBAL region 0 is not an IM region group.
func (s *GRPCRegionSource) ListActiveRegionIDs(ctx context.Context) ([]int64, error) {
resp, err := s.client.ListRegions(ctx, &userv1.ListRegionsRequest{
Meta: &userv1.RequestMeta{
RequestId: "broadcast-region-reconcile",
Caller: "activity-service",
AppCode: appcode.FromContext(ctx),
},
Status: "active",
})
if err != nil {
return nil, err
}
regionIDs := make([]int64, 0, len(resp.GetRegions()))
for _, region := range resp.GetRegions() {
if region.GetRegionId() > 0 {
regionIDs = append(regionIDs, region.GetRegionId())
}
}
return regionIDs, nil
}

View File

@ -1,10 +1,13 @@
package config package config
import ( import (
"net/http"
"strings" "strings"
"time"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/tencentim"
) )
// Config 描述 activity-service 启动配置。 // Config 描述 activity-service 启动配置。
@ -19,8 +22,10 @@ type Config struct {
UserServiceAddr string `yaml:"user_service_addr"` UserServiceAddr string `yaml:"user_service_addr"`
// WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。 // WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。
WalletServiceAddr string `yaml:"wallet_service_addr"` WalletServiceAddr string `yaml:"wallet_service_addr"`
// TaskTimezone 是 daily 任务统一周期时区,不能使用客户端手机时区。 // TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
TaskTimezone string `yaml:"task_timezone"` TencentIM TencentIMConfig `yaml:"tencent_im"`
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
Broadcast BroadcastConfig `yaml:"broadcast"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"` Consumer ConsumerConfig `yaml:"consumer"`
@ -39,6 +44,46 @@ type MessageInboxConfig struct {
UnreadCacheTTL string `yaml:"unread_cache_ttl"` UnreadCacheTTL string `yaml:"unread_cache_ttl"`
} }
// TencentIMConfig 描述 activity-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct {
Enabled bool `yaml:"enabled"`
SDKAppID int64 `yaml:"sdk_app_id"`
SecretKey string `yaml:"secret_key"`
AdminIdentifier string `yaml:"admin_identifier"`
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
Endpoint string `yaml:"endpoint"`
GroupType string `yaml:"group_type"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// RESTConfig converts YAML into the shared Tencent IM REST client config.
func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
timeout := c.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return tencentim.RESTConfig{
SDKAppID: c.SDKAppID,
SecretKey: c.SecretKey,
AdminIdentifier: c.AdminIdentifier,
AdminUserSigTTL: c.AdminUserSigTTL,
Endpoint: c.Endpoint,
GroupType: c.GroupType,
HTTPClient: &http.Client{Timeout: timeout},
}
}
// BroadcastConfig stores activity-service broadcast worker policy.
type BroadcastConfig struct {
Enabled bool `yaml:"enabled"`
SuperGiftMinValue int64 `yaml:"super_gift_min_value"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"`
}
// Default 返回本地默认配置。 // Default 返回本地默认配置。
func Default() Config { func Default() Config {
return Config{ return Config{
@ -46,11 +91,27 @@ func Default() Config {
NodeID: "activity-local", NodeID: "activity-local",
Environment: "local", Environment: "local",
GRPCAddr: ":13006", GRPCAddr: ":13006",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC",
UserServiceAddr: "127.0.0.1:13005", UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004", WalletServiceAddr: "127.0.0.1:13004",
TaskTimezone: "Asia/Shanghai", TencentIM: TencentIMConfig{
MySQLAutoMigrate: false, Enabled: false,
AdminIdentifier: "administrator",
AdminUserSigTTL: 24 * time.Hour,
Endpoint: tencentim.DefaultEndpoint,
GroupType: tencentim.DefaultGroupType,
RequestTimeout: 5 * time.Second,
},
Broadcast: BroadcastConfig{
Enabled: false,
SuperGiftMinValue: 100000,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
EnsureGroupsOnStartup: true,
},
MySQLAutoMigrate: false,
Consumer: ConsumerConfig{ Consumer: ConsumerConfig{
RoomOutboxPollIntervalMs: 1000, RoomOutboxPollIntervalMs: 1000,
BatchSize: 100, BatchSize: 100,
@ -82,9 +143,6 @@ func Load(path string) (Config, error) {
if strings.TrimSpace(cfg.WalletServiceAddr) == "" { if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
cfg.WalletServiceAddr = Default().WalletServiceAddr cfg.WalletServiceAddr = Default().WalletServiceAddr
} }
if strings.TrimSpace(cfg.TaskTimezone) == "" {
cfg.TaskTimezone = Default().TaskTimezone
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" { if cfg.ServiceName == "" {
cfg.ServiceName = "activity-service" cfg.ServiceName = "activity-service"
@ -97,6 +155,39 @@ func Load(path string) (Config, error) {
if cfg.Environment == "" { if cfg.Environment == "" {
cfg.Environment = "local" cfg.Environment = "local"
} }
cfg.TencentIM.AdminIdentifier = strings.TrimSpace(cfg.TencentIM.AdminIdentifier)
if cfg.TencentIM.AdminIdentifier == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}
cfg.TencentIM.Endpoint = strings.TrimSpace(cfg.TencentIM.Endpoint)
if cfg.TencentIM.Endpoint == "" {
cfg.TencentIM.Endpoint = tencentim.DefaultEndpoint
}
cfg.TencentIM.GroupType = strings.TrimSpace(cfg.TencentIM.GroupType)
if cfg.TencentIM.GroupType == "" {
cfg.TencentIM.GroupType = tencentim.DefaultGroupType
}
if cfg.TencentIM.AdminUserSigTTL <= 0 {
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
}
if cfg.TencentIM.RequestTimeout <= 0 {
cfg.TencentIM.RequestTimeout = 5 * time.Second
}
if cfg.Broadcast.SuperGiftMinValue <= 0 {
cfg.Broadcast.SuperGiftMinValue = 100000
}
if cfg.Broadcast.WorkerPollInterval <= 0 {
cfg.Broadcast.WorkerPollInterval = time.Second
}
if cfg.Broadcast.WorkerBatchSize <= 0 {
cfg.Broadcast.WorkerBatchSize = 100
}
if cfg.Broadcast.WorkerLockTTL <= 0 {
cfg.Broadcast.WorkerLockTTL = 30 * time.Second
}
if cfg.Broadcast.WorkerMaxRetry <= 0 {
cfg.Broadcast.WorkerMaxRetry = 8
}
return cfg, nil return cfg, nil
} }

View File

@ -0,0 +1,71 @@
// Package broadcast 定义 activity-service 拥有的 IM 播报领域事实。
// 这里的对象只描述“需要向哪个腾讯群投递什么消息”,不承载红包金额扣减或房间状态事实。
package broadcast
const (
// ScopeGlobal 表示 App 内全局群;使用频率低,适合全服运营公告或极少数全服事件。
ScopeGlobal = "global"
// ScopeRegion 表示用户所在区域群;贵重礼物、区域红包、区域运营通知默认走这个范围。
ScopeRegion = "region"
// TypeSuperGift 是贵重礼物播报,来源于 room-service 已提交的 RoomGiftSent 事实。
TypeSuperGift = "super_gift"
// TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。
TypeRedPacket = "red_packet"
// StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending"
// StatusDelivering 表示某个 worker 已抢占该记录,锁过期后允许其他 worker 接管。
StatusDelivering = "delivering"
// StatusRetryable 表示上次腾讯 REST 失败,但未超过最大重试次数。
StatusRetryable = "retryable"
// StatusDelivered 表示腾讯 REST 已接受消息,不代表每个客户端都已展示。
StatusDelivered = "delivered"
// StatusFailed 表示达到最大重试次数,需要人工或后台补偿介入。
StatusFailed = "failed"
// StatusSkipped 表示消费了上游事件但不满足播报策略,例如礼物价值未达阈值。
StatusSkipped = "skipped"
)
// OutboxRecord 是一条持久化播报投递记录。
// event_id 是幂等键group_id 是最终腾讯群目标payload_json 是客户端渲染和点击跳转所需的完整业务负载。
type OutboxRecord struct {
AppCode string
EventID string
Scope string
GroupID string
BroadcastType string
PayloadJSON string
Status string
AttemptCount int
NextRetryAtMS int64
LastError string
LockedBy string
LockedUntilMS int64
CreatedAtMS int64
UpdatedAtMS int64
}
// PublishResult 描述一次幂等入队结果Created=false 表示重复 event_id 命中已有记录。
type PublishResult struct {
EventID string
GroupID string
Status string
Created bool
}
// ProcessResult 汇总一次 worker 批处理结果,用于 cron/日志/监控判断是否需要继续拉下一批。
type ProcessResult struct {
ClaimedCount int
SuccessCount int
FailureCount int
HasMore bool
}
// ConsumeRoomEventResult 描述 room outbox 事实是否生成播报;跳过也是一种可观测消费结果。
type ConsumeRoomEventResult struct {
EventID string
Status string
BroadcastEventID string
BroadcastCreated bool
}

View File

@ -0,0 +1,432 @@
// Package broadcast 实现 activity-service 拥有的腾讯云 IM 播报能力。
// 该包只处理群生命周期、播报入队、outbox 投递和 room 事件消费策略,不拥有房间状态和资金事实。
package broadcast
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
const (
defaultBroadcastBatchSize = 100
defaultBroadcastLockTTL = 30 * time.Second
defaultBroadcastMaxRetry = 8
)
// Repository 是播报消息的持久化 outbox 边界。
// service 只通过这个接口写入/抢占/标记记录,确保腾讯 REST 失败后仍可重试和人工补偿。
type Repository interface {
SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error)
ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error)
MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error
MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error
}
// Publisher 隐藏腾讯云 IM REST 细节,只暴露 activity-service 真正拥有的建群和发群自定义消息能力。
type Publisher interface {
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
DeleteGroupMember(ctx context.Context, groupID string, userID int64) error
}
// RegionSource 从 user-service 读取 active 区域。
// 区域归属是用户主数据activity-service 不复制区域配置,只在建群 reconciler 中读取。
type RegionSource interface {
ListActiveRegionIDs(ctx context.Context) ([]int64, error)
}
// Config 保存播报策略和 worker 参数。
// SuperGiftMinValue 是产品策略阈值Worker* 是 outbox 投递吞吐和故障恢复参数。
type Config struct {
NodeID string
SuperGiftMinValue int64
WorkerBatchSize int
WorkerLockTTL time.Duration
WorkerMaxRetry int
WorkerPollInterval time.Duration
EnsureGroupsOnStartup bool
}
// PublishInput 是服务端入队播报请求。
// 调用方必须提供稳定 EventID重复请求会命中 outbox 幂等,而不是重复发 IM。
type PublishInput struct {
EventID string
BroadcastType string
RegionID int64
PayloadJSON string
}
// Service 拥有播报群建群补偿、播报入队和 outbox 投递决策。
// 它不直接向客户端返回 UI 文案,只生产带 action 的结构化 JSON让客户端自行决定展示形态。
type Service struct {
cfg Config
repository Repository
publisher Publisher
regionSource RegionSource
now func() time.Time
}
// New 创建播报服务。
// publisher 可以为 nil此时仍允许消费事件并写 outbox但真正投递会 fail-closed本地环境默认不误发腾讯云消息。
func New(cfg Config, repository Repository, publisher Publisher, regionSource RegionSource) *Service {
cfg = normalizeConfig(cfg)
return &Service{cfg: cfg, repository: repository, publisher: publisher, regionSource: regionSource, now: time.Now}
}
// SetClock 注入确定性时间,测试中用它验证 payload 和 outbox 的 Unix ms 字段。
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
// 这个动作放在启动/定时 reconciler不放在 /im/usersig 路径,避免用户登录被外部腾讯 REST 拖慢。
func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
if s == nil || s.publisher == nil {
return 0, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
}
app := appcode.FromContext(ctx)
count := 0
globalID, err := imgroup.GlobalBroadcastGroupID(app)
if err != nil {
return 0, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.EnsureGroup(ctx, broadcastGroupSpec(globalID)); err != nil {
return count, err
}
count++
if s.regionSource == nil {
// 没有区域源时至少保证全局群存在;区域群等待下次有 user-service 依赖的 reconciler 补齐。
return count, nil
}
regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx)
if err != nil {
return count, err
}
for _, regionID := range regionIDs {
if regionID <= 0 {
// GLOBAL/未知区域不对应 IM 区域群避免客户端加入一个语义不清的“0 区域群”。
continue
}
groupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
if err != nil {
return count, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.EnsureGroup(ctx, broadcastGroupSpec(groupID)); err != nil {
return count, err
}
count++
}
return count, nil
}
// PublishRegionBroadcast 把区域播报写入持久化 outbox。
// 这里不直接调用腾讯 REST保证调用方提交成功后即使进程崩溃也能由 worker 继续投递。
func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
if input.RegionID <= 0 {
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), input.RegionID)
if err != nil {
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
}
return s.enqueue(ctx, broadcastdomain.ScopeRegion, groupID, input)
}
// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
// 全局播报和区域播报共用同一张表scope/group_id 决定最终投递目标。
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
groupID, err := imgroup.GlobalBroadcastGroupID(appcode.FromContext(ctx))
if err != nil {
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
}
return s.enqueue(ctx, broadcastdomain.ScopeGlobal, groupID, input)
}
// RemoveRegionBroadcastMember 把一个用户从旧区域播报群移除。
// 这是成员关系更新,不是区域群生命周期操作;区域群仍由 reconciler 长期维护,其他用户不受影响。
func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64, regionID int64) (string, bool, error) {
if userID <= 0 {
return "", false, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if regionID <= 0 {
// GLOBAL/无区域没有对应区域播报群,调用方跨区域比较时可以安全把 0 传进来。
return "", false, nil
}
if s == nil || s.publisher == nil {
return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
}
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), regionID)
if err != nil {
return "", false, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.DeleteGroupMember(ctx, groupID, userID); err != nil {
return groupID, false, err
}
return groupID, true, nil
}
func (s *Service) enqueue(ctx context.Context, scope string, groupID string, input PublishInput) (broadcastdomain.PublishResult, error) {
if s == nil || s.repository == nil {
return broadcastdomain.PublishResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured")
}
input.EventID = strings.TrimSpace(input.EventID)
input.BroadcastType = strings.TrimSpace(input.BroadcastType)
if input.EventID == "" || input.BroadcastType == "" {
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "event_id and broadcast_type are required")
}
nowMS := s.now().UTC().UnixMilli()
payloadJSON, err := normalizedPayloadJSON(input.PayloadJSON, map[string]any{
"event_id": input.EventID,
"broadcast_type": input.BroadcastType,
"scope": scope,
"app_code": appcode.FromContext(ctx),
"region_id": input.RegionID,
"sent_at_ms": nowMS,
})
if err != nil {
return broadcastdomain.PublishResult{}, err
}
// outbox 记录保存的是完整 JSON而不是只保存引用 ID客户端收到 IM 后无需再同步请求才能完成入口跳转。
record := broadcastdomain.OutboxRecord{
AppCode: appcode.FromContext(ctx),
EventID: input.EventID,
Scope: scope,
GroupID: groupID,
BroadcastType: input.BroadcastType,
PayloadJSON: payloadJSON,
Status: broadcastdomain.StatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
created, saved, err := s.repository.SaveBroadcastOutbox(ctx, record)
if err != nil {
return broadcastdomain.PublishResult{}, err
}
return broadcastdomain.PublishResult{EventID: saved.EventID, GroupID: saved.GroupID, Status: saved.Status, Created: created}, nil
}
// ProcessPendingBroadcasts 抢占一批待投递 outbox发送到腾讯 IM并更新成功/重试状态。
// claim 和 mark 分开做:抢占阶段保护多 worker 并发,发送阶段允许单条失败不影响同批其他消息。
func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOptions) (broadcastdomain.ProcessResult, error) {
if s == nil || s.repository == nil {
return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured")
}
if s.publisher == nil {
return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
}
options = normalizeWorkerOptions(options, s.cfg)
nowMS := s.now().UTC().UnixMilli()
records, err := s.repository.ClaimPendingBroadcastOutbox(ctx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.BatchSize, options.MaxRetry)
if err != nil {
return broadcastdomain.ProcessResult{}, err
}
result := broadcastdomain.ProcessResult{ClaimedCount: len(records), HasMore: len(records) >= options.BatchSize}
for _, record := range records {
// 腾讯 REST 只接受已序列化 JSONevent_id/desc/ext 让客户端和日志都能快速识别消息类型。
err := s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
GroupID: record.GroupID,
EventID: record.EventID,
Desc: record.BroadcastType,
Ext: "im_broadcast",
PayloadJSON: json.RawMessage(record.PayloadJSON),
})
if err != nil {
result.FailureCount++
// Mark 失败只记录为 best-effort当前记录锁会过期下一轮 worker 仍可重新接管。
_ = s.repository.MarkBroadcastOutboxFailed(ctx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error()))
continue
}
if err := s.repository.MarkBroadcastOutboxDelivered(ctx, record.EventID, s.now().UTC().UnixMilli()); err != nil {
return result, err
}
result.SuccessCount++
}
return result, nil
}
// HandleRoomEvent 把已提交的 room outbox 事实转换为服务端播报。
// 只有 RoomGiftSent 且满足区域和礼物价值阈值时才生成区域播报,避免把展示策略侵入 Room Cell 主链路。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
if envelope == nil {
return broadcastdomain.ConsumeRoomEventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
if envelope.GetEventType() != "RoomGiftSent" {
// 当前只消费礼物事实;未来红包或运营事件应显式增加事件类型分支和测试。
return result, nil
}
var gift roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
if gift.GetVisibleRegionId() <= 0 || gift.GetGiftValue() < s.cfg.SuperGiftMinValue {
// visible_region_id 来自房间/主播可见区域,不能用客户端当前 IP 或本地时区推断。
return result, nil
}
broadcastEventID := superGiftBroadcastEventID(envelope, &gift)
payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli())
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeSuperGift,
RegionID: gift.GetVisibleRegionId(),
PayloadJSON: payloadJSON,
})
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
// 返回上游 event_id 和下游 broadcast_event_id方便排查“房间事件已消费但播报是否生成”。
return broadcastdomain.ConsumeRoomEventResult{
EventID: envelope.GetEventId(),
Status: published.Status,
BroadcastEventID: published.EventID,
BroadcastCreated: published.Created,
}, nil
}
// WorkerOptions 描述一次 worker 批处理请求cron 和常驻 worker 都走同一套参数归一化。
type WorkerOptions struct {
WorkerID string
BatchSize int
LockTTL time.Duration
MaxRetry int
PollInterval time.Duration
}
func normalizeConfig(cfg Config) Config {
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
cfg.NodeID = "activity-broadcast"
}
if cfg.SuperGiftMinValue <= 0 {
cfg.SuperGiftMinValue = 100000
}
if cfg.WorkerBatchSize <= 0 {
cfg.WorkerBatchSize = defaultBroadcastBatchSize
}
if cfg.WorkerLockTTL <= 0 {
cfg.WorkerLockTTL = defaultBroadcastLockTTL
}
if cfg.WorkerMaxRetry <= 0 {
cfg.WorkerMaxRetry = defaultBroadcastMaxRetry
}
if cfg.WorkerPollInterval <= 0 {
cfg.WorkerPollInterval = time.Second
}
return cfg
}
func normalizeWorkerOptions(options WorkerOptions, cfg Config) WorkerOptions {
if strings.TrimSpace(options.WorkerID) == "" {
options.WorkerID = cfg.NodeID + "-broadcast"
}
if options.BatchSize <= 0 {
options.BatchSize = cfg.WorkerBatchSize
}
if options.LockTTL <= 0 {
options.LockTTL = cfg.WorkerLockTTL
}
if options.MaxRetry <= 0 {
options.MaxRetry = cfg.WorkerMaxRetry
}
if options.PollInterval <= 0 {
options.PollInterval = cfg.WorkerPollInterval
}
return options
}
func broadcastGroupSpec(groupID string) tencentim.GroupSpec {
return tencentim.GroupSpec{
GroupID: groupID,
Name: groupID,
Type: tencentim.DefaultGroupType,
// 入群表面允许 FreeAccess真正准入由腾讯回调回查 user-service这样客户端 SDK 体验简单,权限仍在服务端。
ApplyJoinOption: "FreeAccess",
}
}
func normalizedPayloadJSON(input string, forced map[string]any) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
input = "{}"
}
var payload map[string]any
if err := json.Unmarshal([]byte(input), &payload); err != nil {
return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid")
}
if payload == nil {
payload = map[string]any{}
}
for key, value := range forced {
if key == "region_id" && value.(int64) <= 0 {
// 全局播报不写 region_id避免客户端误把 0 当作一个真实区域。
continue
}
// forced 字段覆盖调用方 payload保证 event_id/scope/app_code/sent_at_ms 不被外部请求伪造。
payload[key] = value
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) string {
if strings.TrimSpace(gift.GetCommandId()) != "" {
// command_id 是 SendGift 幂等键,用它生成播报 event_id 可避免同一房间事件重放时重复播报。
return fmt.Sprintf("gift_broadcast:%s:%s", envelope.GetRoomId(), gift.GetCommandId())
}
// 兼容没有 command_id 的旧/测试事件;当前开发阶段仍以 envelope event_id 保持幂等。
return "gift_broadcast:" + envelope.GetEventId()
}
func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64) (string, error) {
payload := map[string]any{
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeSuperGift,
"scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(),
"region_id": gift.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(),
"sender_user_id": gift.GetSenderUserId(),
"target_user_id": gift.GetTargetUserId(),
"gift_id": gift.GetGiftId(),
"gift_count": gift.GetGiftCount(),
"gift_value": gift.GetGiftValue(),
"sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"action": map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
},
}
encoded, err := json.Marshal(payload)
return string(encoded), err
}
func trimError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 512 {
return value[:512]
}
return value
}

View File

@ -0,0 +1,105 @@
package broadcast
import (
"context"
"encoding/json"
"testing"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
// 这个测试使用生产 initdb 创建隔离 MySQL schema验证真实 JSON 列、主键幂等、claim 锁和 delivered 更新。
repository := mysqltest.NewRepository(t)
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-real", SuperGiftMinValue: 100}, repository.Repository, publisher, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_123_456) })
ctx := appcode.WithContext(context.Background(), "lalu")
envelope := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
GiftId: "super_rocket",
GiftCount: 3,
GiftValue: 300000,
VisibleRegionId: 210,
CommandId: "cmd-real-send-gift-1",
})
created, err := service.HandleRoomEvent(ctx, envelope)
if err != nil {
t.Fatalf("HandleRoomEvent with real mysql failed: %v", err)
}
if !created.BroadcastCreated || created.BroadcastEventID != "gift_broadcast:room-1001:cmd-real-send-gift-1" {
t.Fatalf("unexpected first consume result: %+v", created)
}
t.Logf("real mysql enqueue: room_id=%s region_id=%d gift_value=%d broadcast_event_id=%s", envelope.GetRoomId(), int64(210), int64(300000), created.BroadcastEventID)
// 同一个 room outbox 事实再次消费必须命中 MySQL 主键幂等,不能产生第二条播报。
again, err := service.HandleRoomEvent(ctx, envelope)
if err != nil {
t.Fatalf("second HandleRoomEvent with real mysql failed: %v", err)
}
if again.BroadcastCreated {
t.Fatalf("duplicate room event should not create another broadcast: %+v", again)
}
record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID)
if err != nil {
t.Fatalf("read real mysql outbox failed: %v", err)
}
if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_210" {
t.Fatalf("unexpected pending outbox record: exists=%v record=%+v", exists, record)
}
t.Logf("real mysql pending record: event_id=%s group_id=%s status=%s", record.EventID, record.GroupID, record.Status)
assertJSONField(t, record.PayloadJSON, "room_id", "room-1001")
assertJSONField(t, record.PayloadJSON, "gift_id", "super_rocket")
assertJSONField(t, record.PayloadJSON, "scope", broadcastdomain.ScopeRegion)
result, err := service.ProcessPendingBroadcasts(ctx, WorkerOptions{WorkerID: "worker-real", BatchSize: 1, PollInterval: time.Second})
if err != nil {
t.Fatalf("ProcessPendingBroadcasts with real mysql failed: %v", err)
}
if result.ClaimedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("unexpected process result: %+v", result)
}
if len(publisher.messages) != 1 {
t.Fatalf("expected one Tencent message, got %d", len(publisher.messages))
}
assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_210", created.BroadcastEventID)
delivered, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID)
if err != nil {
t.Fatalf("read delivered real mysql outbox failed: %v", err)
}
if !exists || delivered.Status != broadcastdomain.StatusDelivered || delivered.LockedBy != "" || delivered.LockedUntilMS != 0 {
t.Fatalf("unexpected delivered outbox record: exists=%v record=%+v", exists, delivered)
}
t.Logf("real mysql delivered record: event_id=%s group_id=%s status=%s", delivered.EventID, delivered.GroupID, delivered.Status)
}
func assertJSONField(t *testing.T, payloadJSON string, key string, want any) {
t.Helper()
var payload map[string]any
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
t.Fatalf("payload is not valid json: %v\npayload=%s", err, payloadJSON)
}
if got := payload[key]; got != want {
t.Fatalf("json field %s mismatch: got=%v want=%v payload=%+v", key, got, want, payload)
}
}
func assertPublishedMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string) {
t.Helper()
if message.GroupID != groupID || message.EventID != eventID || message.Desc != broadcastdomain.TypeSuperGift || message.Ext != "im_broadcast" {
t.Fatalf("unexpected Tencent custom message: %+v", message)
}
assertJSONField(t, string(message.PayloadJSON), "event_id", eventID)
}

View File

@ -0,0 +1,227 @@
package broadcast
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) })
envelope := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
GiftId: "rocket",
GiftCount: 2,
GiftValue: 999,
VisibleRegionId: 1001,
CommandId: "cmd-send-gift-1",
})
result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
if err != nil {
t.Fatalf("HandleRoomEvent failed: %v", err)
}
if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "gift_broadcast:room-1001:cmd-send-gift-1" {
t.Fatalf("unexpected result: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
t.Fatalf("unexpected broadcast record: %+v", record)
}
var payload map[string]any
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("payload is not json: %v", err)
}
if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" {
t.Fatalf("payload mismatch: %+v", payload)
}
again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
if err != nil {
t.Fatalf("second HandleRoomEvent failed: %v", err)
}
if again.BroadcastCreated {
t.Fatalf("second consume must be idempotent: %+v", again)
}
}
func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
belowThreshold := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
result, err := service.HandleRoomEvent(context.Background(), belowThreshold)
if err != nil {
t.Fatalf("below threshold failed: %v", err)
}
if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
t.Fatalf("below threshold should skip: %+v", result)
}
globalRegion := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0})
result, err = service.HandleRoomEvent(context.Background(), globalRegion)
if err != nil {
t.Fatalf("global region failed: %v", err)
}
if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
t.Fatalf("global region should skip: %+v", result)
}
}
func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) {
repository := newFakeRepository()
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
repository.records["evt-1"] = broadcastdomain.OutboxRecord{
AppCode: "lalu",
EventID: "evt-1",
GroupID: "hy_lalu_bc_g",
BroadcastType: broadcastdomain.TypeRedPacket,
PayloadJSON: `{"event_id":"evt-1","broadcast_type":"red_packet"}`,
Status: broadcastdomain.StatusPending,
}
result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
if err != nil {
t.Fatalf("ProcessPendingBroadcasts failed: %v", err)
}
if result.SuccessCount != 1 || len(publisher.messages) != 1 {
t.Fatalf("publish result mismatch: result=%+v messages=%d", result, len(publisher.messages))
}
if repository.records["evt-1"].Status != broadcastdomain.StatusDelivered {
t.Fatalf("record should be delivered: %+v", repository.records["evt-1"])
}
if publisher.messages[0].GroupID != "hy_lalu_bc_g" || publisher.messages[0].Desc != broadcastdomain.TypeRedPacket {
t.Fatalf("message mismatch: %+v", publisher.messages[0])
}
}
func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)
groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
if err != nil {
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
}
if !removed || groupID != "hy_lalu_bc_r_1001" {
t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed)
}
if publisher.deletedGroupID != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
t.Fatalf("unexpected deleted member: group_id=%s user_id=%d", publisher.deletedGroupID, publisher.deletedUserID)
}
groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0)
if err != nil {
t.Fatalf("zero region should be a no-op, got: %v", err)
}
if removed || groupID != "" {
t.Fatalf("zero region should not delete any group member: group_id=%s removed=%t", groupID, removed)
}
}
func mustGiftEnvelope(t *testing.T, gift roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
t.Helper()
body, err := proto.Marshal(&gift)
if err != nil {
t.Fatalf("marshal gift failed: %v", err)
}
return &roomeventsv1.EventEnvelope{
EventId: "evt-room-gift-1",
RoomId: "room-1001",
EventType: "RoomGiftSent",
RoomVersion: 7,
OccurredAtMs: 1_700_000_000_000,
AppCode: "lalu",
Body: body,
}
}
type fakeRepository struct {
records map[string]broadcastdomain.OutboxRecord
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}}
}
func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
if existing, ok := r.records[record.EventID]; ok {
return false, existing, nil
}
r.records[record.EventID] = record
return true, record, nil
}
func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, _ int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
records := make([]broadcastdomain.OutboxRecord, 0, limit)
for eventID, record := range r.records {
if len(records) >= limit {
break
}
if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable {
continue
}
record.Status = broadcastdomain.StatusDelivering
record.LockedBy = workerID
record.LockedUntilMS = lockUntilMS
r.records[eventID] = record
records = append(records, record)
}
return records, nil
}
func (r *fakeRepository) MarkBroadcastOutboxDelivered(_ context.Context, eventID string, nowMS int64) error {
record, ok := r.records[eventID]
if !ok {
return errors.New("record not found")
}
record.Status = broadcastdomain.StatusDelivered
record.UpdatedAtMS = nowMS
r.records[eventID] = record
return nil
}
func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID string, _ string, nowMS int64, nextRetryAtMS int64, _ int, lastError string) error {
record, ok := r.records[eventID]
if !ok {
return errors.New("record not found")
}
record.Status = broadcastdomain.StatusRetryable
record.AttemptCount++
record.NextRetryAtMS = nextRetryAtMS
record.LastError = lastError
record.UpdatedAtMS = nowMS
r.records[eventID] = record
return nil
}
type fakePublisher struct {
messages []tencentim.CustomGroupMessage
deletedGroupID string
deletedUserID int64
}
func (p *fakePublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error {
return nil
}
func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
p.messages = append(p.messages, message)
return nil
}
func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error {
p.deletedGroupID = groupID
p.deletedUserID = userID
return nil
}

View File

@ -0,0 +1,35 @@
package broadcast
import (
"context"
"log/slog"
"time"
"hyapp/pkg/logx"
)
// RunWorker 先按需补齐播报群,再持续拉取持久化 outbox 投递腾讯 IM。
// 该 worker 只在 broadcast.enabled 且 tencent_im.enabled 时启动,本地默认关闭,防止开发环境误发真实 IM。
func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
options = normalizeWorkerOptions(options, s.cfg)
if s.cfg.EnsureGroupsOnStartup {
// 建群失败不退出进程:下一轮部署/定时任务可继续补偿,已经入队的消息仍保留在 MySQL。
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil {
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID))
}
}
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
if _, err := s.ProcessPendingBroadcasts(ctx, options); err != nil && ctx.Err() == nil {
// 批处理失败只影响当前轮询outbox 记录没有被删除,下一轮会按锁和重试状态重新处理。
logx.Error(ctx, "broadcast_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@ -14,9 +14,7 @@ import (
) )
const ( const (
defaultTaskTimezone = "Asia/Shanghai" taskRewardAsset = "COIN"
defaultTaskOffset = 8 * 60 * 60
taskRewardAsset = "COIN"
) )
// Repository 是任务系统的唯一持久化边界;进度、领奖和事件幂等都必须落 activity MySQL。 // Repository 是任务系统的唯一持久化边界;进度、领奖和事件幂等都必须落 activity MySQL。
@ -37,35 +35,16 @@ type WalletClient interface {
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
} }
// Config 保存任务系统的全局口径daily 周期只能使用服务端时区。
type Config struct {
TaskTimezone string
}
// Service 承载任务查询、事件消费、领奖和后台配置用例。 // Service 承载任务查询、事件消费、领奖和后台配置用例。
type Service struct { type Service struct {
cfg Config
repository Repository repository Repository
wallet WalletClient wallet WalletClient
now func() time.Time now func() time.Time
location *time.Location
} }
// New 创建任务服务;时区加载失败会退回 Asia/Shanghai避免启动后使用用户或机器本地时区。 // New 创建任务服务daily 周期固定按 UTC 自然日切分,不读取机器或客户端时区。
func New(cfg Config, repository Repository, wallet WalletClient) *Service { func New(repository Repository, wallet WalletClient) *Service {
tz := strings.TrimSpace(cfg.TaskTimezone) return &Service{repository: repository, wallet: wallet, now: time.Now}
if tz == "" {
tz = defaultTaskTimezone
}
location, err := time.LoadLocation(tz)
if err != nil {
location, _ = time.LoadLocation(defaultTaskTimezone)
}
if location == nil {
// Alpine 等精简镜像可能没有 IANA tzdata任务日边界必须继续按产品口径 UTC+8 计算。
location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset)
}
return &Service{cfg: Config{TaskTimezone: tz}, repository: repository, wallet: wallet, now: time.Now, location: location}
} }
// SetClock 给测试注入稳定时间;生产路径始终使用 time.Now。 // SetClock 给测试注入稳定时间;生产路径始终使用 time.Now。
@ -286,15 +265,8 @@ type dailyCycle struct {
} }
func (s *Service) dailyCycle(t time.Time) dailyCycle { func (s *Service) dailyCycle(t time.Time) dailyCycle {
location := s.location utc := t.UTC()
if location == nil { start := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
location, _ = time.LoadLocation(defaultTaskTimezone)
}
if location == nil {
location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset)
}
local := t.In(location)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
next := start.AddDate(0, 0, 1) next := start.AddDate(0, 0, 1)
return dailyCycle{Key: start.Format("2006-01-02"), NextRefreshMS: next.UnixMilli()} return dailyCycle{Key: start.Format("2006-01-02"), NextRefreshMS: next.UnixMilli()}
} }

View File

@ -16,7 +16,7 @@ import (
func TestTaskEventQueryAndClaimFlow(t *testing.T) { func TestTaskEventQueryAndClaimFlow(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &fakeWalletClient{} wallet := &fakeWalletClient{}
svc := taskservice.New(taskservice.Config{TaskTimezone: "Asia/Shanghai"}, repository, wallet) svc := taskservice.New(repository, wallet)
now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60)) now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60))
svc.SetClock(func() time.Time { return now }) svc.SetClock(func() time.Time { return now })

View File

@ -5,10 +5,13 @@ import (
"time" "time"
) )
func TestDailyCycleFallsBackWhenLocationMissing(t *testing.T) { func TestDailyCycleUsesUTCDay(t *testing.T) {
svc := &Service{} svc := &Service{}
cycle := svc.dailyCycle(time.Date(2026, 5, 8, 16, 30, 0, 0, time.UTC)) cycle := svc.dailyCycle(time.Date(2026, 5, 8, 23, 30, 0, 0, time.FixedZone("riyadh", 3*60*60)))
if cycle.Key != "2026-05-09" { if cycle.Key != "2026-05-08" {
t.Fatalf("daily cycle must use UTC+8 fallback when tzdata is missing: %+v", cycle) t.Fatalf("daily cycle must use UTC day key: %+v", cycle)
}
if want := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC).UnixMilli(); cycle.NextRefreshMS != want {
t.Fatalf("next refresh must be next UTC midnight: got=%d want=%d", cycle.NextRefreshMS, want)
} }
} }

View File

@ -0,0 +1,233 @@
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
// SaveBroadcastOutbox 插入一条持久化播报 outbox。
// 主键是 (app_code,event_id),重复写入会返回已有记录,保证 room outbox 重放、gRPC 重试和 cron 重跑不会重复发播报。
func (r *Repository) SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
if r == nil || r.db == nil {
return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
record.AppCode = appcode.FromContext(ctx)
_, err := r.db.ExecContext(ctx, `
INSERT INTO im_broadcast_outbox (
app_code, event_id, scope, group_id, broadcast_type, payload_json, status,
attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, '', 0, ?, ?)`,
record.AppCode,
record.EventID,
record.Scope,
record.GroupID,
record.BroadcastType,
record.PayloadJSON,
record.Status,
record.AttemptCount,
record.NextRetryAtMS,
record.LastError,
record.CreatedAtMS,
record.UpdatedAtMS,
)
if err == nil {
return true, record, nil
}
if !isMySQLDuplicate(err) {
return false, broadcastdomain.OutboxRecord{}, err
}
// duplicate 不能直接当成功丢弃;返回已有记录让上层知道当前状态和目标 group_id。
existing, exists, findErr := r.GetBroadcastOutbox(ctx, record.EventID)
if findErr != nil {
return false, broadcastdomain.OutboxRecord{}, findErr
}
if !exists {
return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Conflict, "broadcast outbox event_id already exists")
}
return false, existing, nil
}
// GetBroadcastOutbox 按幂等键读取一条播报 outbox测试和后台排障都会用到。
func (r *Repository) GetBroadcastOutbox(ctx context.Context, eventID string) (broadcastdomain.OutboxRecord, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
created_at_ms, updated_at_ms
FROM im_broadcast_outbox
WHERE app_code = ? AND event_id = ?`,
appcode.FromContext(ctx), eventID,
)
record, err := scanBroadcastOutbox(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return broadcastdomain.OutboxRecord{}, false, nil
}
return broadcastdomain.OutboxRecord{}, false, err
}
return record, true, nil
}
// ClaimPendingBroadcastOutbox 抢占一页可投递播报消息。
// FOR UPDATE SKIP LOCKED 让多个 worker 可以并行拉取不同记录locked_until_ms 处理 worker 崩溃后的接管。
func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if strings.TrimSpace(workerID) == "" {
workerID = "activity-broadcast"
}
if limit <= 0 {
limit = 100
}
if maxRetry <= 0 {
maxRetry = 8
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, `
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
created_at_ms, updated_at_ms
FROM im_broadcast_outbox
WHERE app_code = ?
AND attempt_count < ?
AND (
(status IN (?, ?) AND next_retry_at_ms <= ?)
OR (status = ? AND locked_until_ms <= ?)
)
ORDER BY created_at_ms ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx),
maxRetry,
broadcastdomain.StatusPending,
broadcastdomain.StatusRetryable,
nowMS,
broadcastdomain.StatusDelivering,
nowMS,
limit,
)
if err != nil {
_ = tx.Rollback()
return nil, err
}
records := make([]broadcastdomain.OutboxRecord, 0, limit)
for rows.Next() {
record, err := scanBroadcastOutbox(rows)
if err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
records = append(records, record)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := rows.Err(); err != nil {
_ = tx.Rollback()
return nil, err
}
for _, record := range records {
// 先在同一事务里改成 delivering再提交给调用方发送避免两个 worker 同时发送同一 event_id。
if _, err := tx.ExecContext(ctx, `
UPDATE im_broadcast_outbox
SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
broadcastdomain.StatusDelivering,
workerID,
lockUntilMS,
nowMS,
record.AppCode,
record.EventID,
); err != nil {
_ = tx.Rollback()
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for i := range records {
records[i].Status = broadcastdomain.StatusDelivering
records[i].LockedBy = workerID
records[i].LockedUntilMS = lockUntilMS
}
return records, nil
}
// MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE im_broadcast_outbox
SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
broadcastdomain.StatusDelivered,
nowMS,
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkBroadcastOutboxFailed 释放失败记录,未达上限时进入 retryable达到上限时进入 failed。
// WHERE locked_by 限定当前 worker防止锁过期后旧 worker 把新 worker 的处理结果覆盖掉。
func (r *Repository) MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error {
if maxRetry <= 0 {
maxRetry = 8
}
_, err := r.db.ExecContext(ctx, `
UPDATE im_broadcast_outbox
SET status = CASE WHEN attempt_count + 1 >= ? THEN ? ELSE ? END,
attempt_count = attempt_count + 1,
next_retry_at_ms = CASE WHEN attempt_count + 1 >= ? THEN 0 ELSE ? END,
last_error = ?,
locked_by = '',
locked_until_ms = 0,
updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND locked_by = ?`,
maxRetry,
broadcastdomain.StatusFailed,
broadcastdomain.StatusRetryable,
maxRetry,
nextRetryAtMS,
lastError,
nowMS,
appcode.FromContext(ctx),
eventID,
workerID,
)
return err
}
func scanBroadcastOutbox(scanner interface{ Scan(dest ...any) error }) (broadcastdomain.OutboxRecord, error) {
var record broadcastdomain.OutboxRecord
err := scanner.Scan(
&record.AppCode,
&record.EventID,
&record.Scope,
&record.GroupID,
&record.BroadcastType,
&record.PayloadJSON,
&record.Status,
&record.AttemptCount,
&record.NextRetryAtMS,
&record.LastError,
&record.LockedBy,
&record.LockedUntilMS,
&record.CreatedAtMS,
&record.UpdatedAtMS,
)
return record, err
}

View File

@ -0,0 +1,108 @@
package grpc
import (
"context"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
// BroadcastServer 把 IM 播报能力暴露为内部 gRPC。
// 外部 HTTP 不直接调用这里gateway/cron/room-service 只能通过 protobuf 契约进入 activity-service。
type BroadcastServer struct {
activityv1.UnimplementedBroadcastServiceServer
activityv1.UnimplementedRoomEventConsumerServiceServer
svc *broadcastservice.Service
}
// NewBroadcastServer 创建播报和 room event 消费共用的 gRPC adapter。
func NewBroadcastServer(svc *broadcastservice.Service) *BroadcastServer {
return &BroadcastServer{svc: svc}
}
func (s *BroadcastServer) EnsureBroadcastGroups(ctx context.Context, req *activityv1.EnsureBroadcastGroupsRequest) (*activityv1.EnsureBroadcastGroupsResponse, error) {
// app_code 只从 RequestMeta 进入 context底层 repository 才能自动带上租户隔离条件。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
count, err := s.svc.EnsureBroadcastGroups(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.EnsureBroadcastGroupsResponse{EnsuredCount: int32(count)}, nil
}
func (s *BroadcastServer) PublishRegionBroadcast(ctx context.Context, req *activityv1.PublishRegionBroadcastRequest) (*activityv1.PublishBroadcastResponse, error) {
// 区域播报是红包、贵重礼物等高频入口gRPC 层只做字段搬运,策略留给 service。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.PublishRegionBroadcast(ctx, broadcastservice.PublishInput{
EventID: req.GetEventId(),
BroadcastType: req.GetBroadcastType(),
RegionID: req.GetRegionId(),
PayloadJSON: req.GetPayloadJson(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.PublishBroadcastResponse{EventId: result.EventID, GroupId: result.GroupID, Status: result.Status, Created: result.Created}, nil
}
func (s *BroadcastServer) PublishGlobalBroadcast(ctx context.Context, req *activityv1.PublishGlobalBroadcastRequest) (*activityv1.PublishBroadcastResponse, error) {
// 全局播报和区域播报共用返回结构,调用方通过 group_id/status/created 判断幂等结果。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.PublishGlobalBroadcast(ctx, broadcastservice.PublishInput{
EventID: req.GetEventId(),
BroadcastType: req.GetBroadcastType(),
PayloadJSON: req.GetPayloadJson(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.PublishBroadcastResponse{EventId: result.EventID, GroupId: result.GroupID, Status: result.Status, Created: result.Created}, nil
}
func (s *BroadcastServer) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
// 这里只删除 user_id 在旧区域群的成员关系,不删除区域群;群生命周期仍由 reconciler 管理。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
groupID, removed, err := s.svc.RemoveRegionBroadcastMember(ctx, req.GetUserId(), req.GetRegionId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: groupID, Removed: removed}, nil
}
func (s *BroadcastServer) ProcessBroadcastOutboxBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.ProcessBroadcastOutboxBatchResponse, error) {
// cron-service 可以显式触发一批投递;常驻 worker 也复用同一个 service 方法。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.ProcessPendingBroadcasts(ctx, broadcastservice.WorkerOptions{
WorkerID: req.GetWorkerId(),
BatchSize: int(req.GetBatchSize()),
LockTTL: time.Duration(req.GetLockTtlMs()) * time.Millisecond,
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ProcessBroadcastOutboxBatchResponse{
ClaimedCount: int32(result.ClaimedCount),
SuccessCount: int32(result.SuccessCount),
FailureCount: int32(result.FailureCount),
HasMore: result.HasMore,
}, nil
}
func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.ConsumeRoomEventRequest) (*activityv1.ConsumeRoomEventResponse, error) {
// room-service 只推送已提交的 outbox envelope是否播报、播给哪个群由 activity-service 判定。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.HandleRoomEvent(ctx, req.GetEnvelope())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ConsumeRoomEventResponse{
EventId: result.EventID,
Status: result.Status,
BroadcastEventId: result.BroadcastEventID,
BroadcastCreated: result.BroadcastCreated,
}, nil
}

View File

@ -13,6 +13,8 @@ RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-healt
FROM alpine:3.20 FROM alpine:3.20
ENV TZ=UTC
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser
@ -27,4 +29,3 @@ EXPOSE 13007
ENTRYPOINT ["/app/server"] ENTRYPOINT ["/app/server"]
CMD ["-config", "/app/config.yaml"] CMD ["-config", "/app/config.yaml"]

View File

@ -8,7 +8,7 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13007" grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "user-service:13005" user_service_addr: "user-service:13005"
activity_service_addr: "activity-service:13006" activity_service_addr: "activity-service:13006"
wallet_service_addr: "wallet-service:13004" wallet_service_addr: "wallet-service:13004"

View File

@ -8,7 +8,7 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13007" grpc_addr: ":13007"
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local&tls=false" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
user_service_addr: "user-service.internal:13005" user_service_addr: "user-service.internal:13005"
activity_service_addr: "activity-service.internal:13006" activity_service_addr: "activity-service.internal:13006"
wallet_service_addr: "wallet-service.internal:13004" wallet_service_addr: "wallet-service.internal:13004"

View File

@ -8,7 +8,7 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
grpc_addr: ":13007" grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "127.0.0.1:13005" user_service_addr: "127.0.0.1:13005"
activity_service_addr: "127.0.0.1:13006" activity_service_addr: "127.0.0.1:13006"
wallet_service_addr: "127.0.0.1:13004" wallet_service_addr: "127.0.0.1:13004"

View File

@ -62,7 +62,7 @@ func Default() Config {
NodeID: "cron-local", NodeID: "cron-local",
Environment: "local", Environment: "local",
GRPCAddr: ":13007", GRPCAddr: ":13007",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC",
UserServiceAddr: "127.0.0.1:13005", UserServiceAddr: "127.0.0.1:13005",
ActivityServiceAddr: "127.0.0.1:13006", ActivityServiceAddr: "127.0.0.1:13006",
WalletServiceAddr: "127.0.0.1:13004", WalletServiceAddr: "127.0.0.1:13004",

View File

@ -129,7 +129,7 @@ func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode)) logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))
return false return false
} }
now := time.Now() now := time.Now().UTC()
lockTTL := parseDuration(task.LockTTL, 30*time.Second) lockTTL := parseDuration(task.LockTTL, 30*time.Second)
acquired, err := s.store.TryAcquireTaskLease(ctx, taskName, appCode, s.nodeID, now.UnixMilli(), lockTTL) acquired, err := s.store.TryAcquireTaskLease(ctx, taskName, appCode, s.nodeID, now.UnixMilli(), lockTTL)
if err != nil { if err != nil {
@ -168,7 +168,7 @@ func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string
}) })
cancel() cancel()
finished := time.Now() finished := time.Now().UTC()
run.FinishedAtMs = finished.UnixMilli() run.FinishedAtMs = finished.UnixMilli()
run.DurationMs = finished.Sub(now).Milliseconds() run.DurationMs = finished.Sub(now).Milliseconds()
run.ClaimedCount = result.ClaimedCount run.ClaimedCount = result.ClaimedCount

View File

@ -12,6 +12,8 @@ RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./s
FROM alpine:3.20 FROM alpine:3.20
ENV TZ=UTC
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser

View File

@ -15,7 +15,7 @@ wallet_service_addr: "wallet-service:13004"
activity_service_addr: "activity-service:13006" activity_service_addr: "activity-service:13006"
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit: auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。 # public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true enabled: true
@ -45,6 +45,7 @@ tencent_im:
# Docker 本地联调腾讯云 IM UserSig必须与 room-service 使用同一组应用配置。 # Docker 本地联调腾讯云 IM UserSig必须与 room-service 使用同一组应用配置。
sdk_app_id: 20036101 sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c" secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
user_sig_ttl: "24h" user_sig_ttl: "24h"
callback_url: "" callback_url: ""
callback_auth_token: "" callback_auth_token: ""

View File

@ -15,7 +15,7 @@ wallet_service_addr: "wallet-service.internal:13004"
activity_service_addr: "activity-service.internal:13006" activity_service_addr: "activity-service.internal:13006"
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit: auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。 # public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true enabled: true
@ -47,6 +47,8 @@ tencent_im:
sdk_app_id: 1400000000 sdk_app_id: 1400000000
# 腾讯云 IM SDKSecretKey只允许保存在服务端密钥系统或线上配置中。 # 腾讯云 IM SDKSecretKey只允许保存在服务端密钥系统或线上配置中。
secret_key: "TENCENT_IM_SECRET_KEY" secret_key: "TENCENT_IM_SECRET_KEY"
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。 # 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
user_sig_ttl: "24h" user_sig_ttl: "24h"
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。 # 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。

View File

@ -15,7 +15,7 @@ wallet_service_addr: "127.0.0.1:13004"
activity_service_addr: "127.0.0.1:13006" activity_service_addr: "127.0.0.1:13006"
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit: auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。 # public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true enabled: true
@ -45,6 +45,7 @@ tencent_im:
# 本地联调腾讯云 IM UserSigSDKAppID 和密钥必须与 room-service 保持一致。 # 本地联调腾讯云 IM UserSigSDKAppID 和密钥必须与 room-service 保持一致。
sdk_app_id: 20036101 sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c" secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
# 客户端 UserSig 有效期;线上建议 12h-7d按登录刷新策略决定。 # 客户端 UserSig 有效期;线上建议 12h-7d按登录刷新策略决定。
user_sig_ttl: "24h" user_sig_ttl: "24h"
# 腾讯云 IM 控制台回调 URL。需要公网可访问本地为空表示暂不启用回调。 # 腾讯云 IM 控制台回调 URL。需要公网可访问本地为空表示暂不启用回调。

View File

@ -80,6 +80,7 @@ func New(cfg config.Config) (*App, error) {
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn) var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn) var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn) var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig) appConfigReader, err := openAppConfigReader(cfg.AppConfig)
if err != nil { if err != nil {
_ = roomConn.Close() _ = roomConn.Close()
@ -92,6 +93,7 @@ func New(cfg config.Config) (*App, error) {
SDKAppID: cfg.TencentIM.SDKAppID, SDKAppID: cfg.TencentIM.SDKAppID,
SecretKey: cfg.TencentIM.SecretKey, SecretKey: cfg.TencentIM.SecretKey,
UserSigTTL: cfg.TencentIM.UserSigTTL, UserSigTTL: cfg.TencentIM.UserSigTTL,
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken, CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
}, userProfileClient) }, userProfileClient)
handler.SetRoomQueryClient(roomQueryClient) handler.SetRoomQueryClient(roomQueryClient)
@ -102,6 +104,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetWalletClient(walletClient) handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient) handler.SetMessageInboxClient(messageClient)
handler.SetTaskClient(taskClient) handler.SetTaskClient(taskClient)
handler.SetBroadcastClient(broadcastClient)
if appConfigReader != nil { if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader) handler.SetAppConfigReader(appConfigReader)
} }

View File

@ -12,9 +12,9 @@ import (
const h5LinkGroup = "h5-links" const h5LinkGroup = "h5-links"
const listH5LinksSQL = "SELECT `key`, COALESCE(value, ''), updated_at FROM admin_app_configs WHERE `group` = ?" const listH5LinksSQL = "SELECT `key`, COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ?"
const listAppBannersSQL = ` const listAppBannersSQL = `
SELECT id, app_code, cover_url, banner_type, param, platform, sort_order, region_id, country_code, description, updated_at SELECT id, app_code, cover_url, banner_type, param, platform, sort_order, region_id, country_code, description, updated_at_ms
FROM admin_app_banners FROM admin_app_banners
WHERE app_code = ? AND status = 'active' WHERE app_code = ? AND status = 'active'
AND (? = '' OR platform = ?) AND (? = '' OR platform = ?)
@ -111,8 +111,8 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
for rows.Next() { for rows.Next() {
var key string var key string
var url string var url string
var updatedAt sql.NullTime var updatedAtMS int64
if err := rows.Scan(&key, &url, &updatedAt); err != nil { if err := rows.Scan(&key, &url, &updatedAtMS); err != nil {
return nil, err return nil, err
} }
@ -121,9 +121,7 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
Key: key, Key: key,
URL: strings.TrimSpace(url), URL: strings.TrimSpace(url),
} }
if updatedAt.Valid { link.UpdatedAtMs = updatedAtMS
link.UpdatedAtMs = updatedAt.Time.UnixMilli()
}
configs[key] = link configs[key] = link
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -165,7 +163,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
for rows.Next() { for rows.Next() {
var item Banner var item Banner
var appCode string var appCode string
var updatedAt sql.NullTime var updatedAtMS int64
if err := rows.Scan( if err := rows.Scan(
&item.ID, &item.ID,
&appCode, &appCode,
@ -177,13 +175,11 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
&item.RegionID, &item.RegionID,
&item.CountryCode, &item.CountryCode,
&item.Description, &item.Description,
&updatedAt, &updatedAtMS,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if updatedAt.Valid { item.UpdatedAtMs = updatedAtMS
item.UpdatedAtMs = updatedAt.Time.UnixMilli()
}
items = append(items, item) items = append(items, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {

View File

@ -0,0 +1,27 @@
package client
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"google.golang.org/grpc"
)
// BroadcastClient abstracts gateway access to activity-service IM broadcast membership operations.
type BroadcastClient interface {
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
}
type grpcBroadcastClient struct {
client activityv1.BroadcastServiceClient
}
// NewGRPCBroadcastClient builds a broadcast client backed by activity-service BroadcastService.
func NewGRPCBroadcastClient(conn *grpc.ClientConn) BroadcastClient {
return &grpcBroadcastClient{client: activityv1.NewBroadcastServiceClient(conn)}
}
func (c *grpcBroadcastClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.client.RemoveRegionBroadcastMember(ctx, req)
}

View File

@ -90,6 +90,8 @@ type TencentIMConfig struct {
SecretKey string `yaml:"secret_key"` SecretKey string `yaml:"secret_key"`
// UserSigTTL 是客户端 IM 登录票据有效期。 // UserSigTTL 是客户端 IM 登录票据有效期。
UserSigTTL time.Duration `yaml:"user_sig_ttl"` UserSigTTL time.Duration `yaml:"user_sig_ttl"`
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
AdminIdentifier string `yaml:"admin_identifier"`
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。 // CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
CallbackURL string `yaml:"callback_url"` CallbackURL string `yaml:"callback_url"`
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token只用于校验回调来源。 // CallbackAuthToken 是腾讯云 IM 回调鉴权 token只用于校验回调来源。
@ -144,7 +146,7 @@ func Default() Config {
WalletServiceAddr: "127.0.0.1:13004", WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006", ActivityServiceAddr: "127.0.0.1:13006",
AppConfig: AppConfigConfig{ AppConfig: AppConfigConfig{
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
}, },
AuthRateLimit: AuthRateLimitConfig{ AuthRateLimit: AuthRateLimitConfig{
Enabled: true, Enabled: true,
@ -164,7 +166,8 @@ func Default() Config {
KeyPrefix: "login_risk:ip:", KeyPrefix: "login_risk:ip:",
}, },
TencentIM: TencentIMConfig{ TencentIM: TencentIMConfig{
UserSigTTL: 24 * time.Hour, UserSigTTL: 24 * time.Hour,
AdminIdentifier: "administrator",
}, },
TencentRTC: TencentRTCConfig{ TencentRTC: TencentRTCConfig{
UserSigTTL: 2 * time.Hour, UserSigTTL: 2 * time.Hour,
@ -269,6 +272,12 @@ func (cfg *Config) Normalize() error {
} }
cfg.TencentRTC.CallbackURL = strings.TrimSpace(cfg.TencentRTC.CallbackURL) cfg.TencentRTC.CallbackURL = strings.TrimSpace(cfg.TencentRTC.CallbackURL)
cfg.TencentRTC.CallbackSignKey = strings.TrimSpace(cfg.TencentRTC.CallbackSignKey) cfg.TencentRTC.CallbackSignKey = strings.TrimSpace(cfg.TencentRTC.CallbackSignKey)
cfg.TencentIM.AdminIdentifier = strings.TrimSpace(cfg.TencentIM.AdminIdentifier)
if cfg.TencentIM.AdminIdentifier == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
return nil return nil
} }

View File

@ -7,12 +7,15 @@ import (
"strings" "strings"
"time" "time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/idgen" "hyapp/pkg/idgen"
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/client"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
) )
// Handler 是 gateway 的 HTTP transport adapter。 // Handler 是 gateway 的 HTTP transport adapter。
@ -31,6 +34,7 @@ type Handler struct {
walletClient client.WalletClient walletClient client.WalletClient
messageClient client.MessageInboxClient messageClient client.MessageInboxClient
taskClient client.TaskClient taskClient client.TaskClient
broadcastClient client.BroadcastClient
appConfigReader appConfigReader appConfigReader appConfigReader
tencentIM TencentIMConfig tencentIM TencentIMConfig
tencentRTC TencentRTCConfig tencentRTC TencentRTCConfig
@ -46,6 +50,7 @@ type TencentIMConfig struct {
SDKAppID int64 SDKAppID int64
SecretKey string SecretKey string
UserSigTTL time.Duration UserSigTTL time.Duration
AdminIdentifier string
CallbackAuthToken string CallbackAuthToken string
} }
@ -160,6 +165,11 @@ func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient h.taskClient = taskClient
} }
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。 // SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) { func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader h.appConfigReader = reader
@ -220,17 +230,98 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized") writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
return return
} }
if h.userProfileClient == nil {
// 播报群 join 列表必须来自 user-service 当前资料,不能靠 token 快照猜区域。
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return
}
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: userID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
user := userResp.GetUser()
if user == nil {
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return
}
if !user.GetProfileCompleted() {
writeError(writer, request, http.StatusForbidden, codeProfileRequired, "profile required")
return
}
app := appcode.FromContext(request.Context())
if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != app {
// UserSig 是 IM 登录凭证,不能给跨 app_code 用户签发,否则会绕过播报群租户隔离。
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
return
}
result, err := tencentim.GenerateUserSig(tencentim.UserSigConfig{ result, err := tencentim.GenerateUserSig(tencentim.UserSigConfig{
SDKAppID: h.tencentIM.SDKAppID, SDKAppID: h.tencentIM.SDKAppID,
SecretKey: h.tencentIM.SecretKey, SecretKey: h.tencentIM.SecretKey,
TTL: h.tencentIM.UserSigTTL, TTL: h.tencentIM.UserSigTTL,
}, tencentim.FormatUserID(userID), time.Now()) }, tencentim.FormatUserID(userID), time.Now().UTC())
if err != nil { if err != nil {
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。 // 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error") writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return return
} }
writeOK(writer, request, result) joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
if !ok {
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
writeOK(writer, request, tencentIMUserSigResponse{
SDKAppID: result.SDKAppID,
UserID: result.UserID,
UserSig: result.UserSig,
ExpireAtMS: result.ExpireAtMS,
JoinGroups: joinGroups,
})
}
type tencentIMUserSigResponse struct {
SDKAppID int64 `json:"sdk_app_id"`
UserID string `json:"user_id"`
UserSig string `json:"user_sig"`
ExpireAtMS int64 `json:"expire_at_ms"`
JoinGroups []tencentIMJoinGroup `json:"join_groups"`
}
type tencentIMJoinGroup struct {
GroupID string `json:"group_id"`
Type string `json:"type"`
RegionID int64 `json:"region_id,omitempty"`
}
func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool) {
globalGroupID, err := imgroup.GlobalBroadcastGroupID(app)
if err != nil {
return nil, false
}
// 全局群始终返回;实际消息量应很低,主要用于全服级运营通知或极少数全局事件。
groups := []tencentIMJoinGroup{{
GroupID: globalGroupID,
Type: string(imgroup.KindGlobalBroadcast),
}}
if regionID > 0 {
// 区域群来自 user-service 当前 region_id客户端不能自选区域群区域变更后下一次 UserSig 返回新群。
regionGroupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
if err != nil {
return nil, false
}
groups = append(groups, tencentIMJoinGroup{
GroupID: regionGroupID,
Type: string(imgroup.KindRegionBroadcast),
RegionID: regionID,
})
}
return groups, true
} }

View File

@ -249,6 +249,11 @@ type fakeTaskClient struct {
err error err error
} }
type fakeBroadcastClient struct {
lastRemove *activityv1.RemoveRegionBroadcastMemberRequest
err error
}
func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) { func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
f.lastLoginPassword = req f.lastLoginPassword = req
f.loginPasswordCount++ f.loginPasswordCount++
@ -321,6 +326,7 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
} }
return &userv1.GetUserResponse{User: &userv1.User{ return &userv1.GetUserResponse{User: &userv1.User{
UserId: req.GetUserId(), UserId: req.GetUserId(),
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
DisplayUserId: "100001", DisplayUserId: "100001",
Username: "hy", Username: "hy",
Gender: "female", Gender: "female",
@ -411,8 +417,9 @@ func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1
UserId: req.GetUserId(), UserId: req.GetUserId(),
DisplayUserId: "100001", DisplayUserId: "100001",
Country: req.GetCountry(), Country: req.GetCountry(),
RegionId: 2002,
UpdatedAtMs: 3000, UpdatedAtMs: 3000,
}, NextChangeAllowedAtMs: 3600000}, nil }, NextChangeAllowedAtMs: 3600000, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil
} }
func (f *fakeUserIdentityClient) GetUserIdentity(_ context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) { func (f *fakeUserIdentityClient) GetUserIdentity(_ context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) {
@ -597,6 +604,14 @@ func (f *fakeTaskClient) ClaimTaskReward(_ context.Context, req *activityv1.Clai
return &activityv1.ClaimTaskRewardResponse{}, nil return &activityv1.ClaimTaskRewardResponse{}, nil
} }
func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
f.lastRemove = req
if f.err != nil {
return nil, f.err
}
return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) { func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req f.lastHost = req
if f.hostErr != nil { if f.hostErr != nil {
@ -2151,6 +2166,31 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown") assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown")
} }
func TestChangeCountryRemovesOldRegionBroadcastMember(t *testing.T) {
profileClient := &fakeUserProfileClient{}
broadcastClient := &fakeBroadcastClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetBroadcastClient(broadcastClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"country":"US"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-country-region")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if broadcastClient.lastRemove == nil {
t.Fatalf("expected old region broadcast member removal")
}
if broadcastClient.lastRemove.GetUserId() != 42 || broadcastClient.lastRemove.GetRegionId() != 1001 || broadcastClient.lastRemove.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("remove request mismatch: %+v", broadcastClient.lastRemove)
}
}
func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) { func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{} profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
@ -2525,11 +2565,12 @@ func TestJoinRoomClosedWritesRoomClosedEnvelope(t *testing.T) {
} }
func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) { func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
SDKAppID: 1400000000, SDKAppID: 1400000000,
SecretKey: "secret", SecretKey: "secret",
UserSigTTL: time.Hour, UserSigTTL: time.Hour,
}).Routes(auth.NewVerifier("secret")) }, profileClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -2548,11 +2589,19 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
if response.Code != codeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" { if response.Code != codeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" {
t.Fatalf("unexpected usersig response: %+v", response) t.Fatalf("unexpected usersig response: %+v", response)
} }
groups, ok := data["join_groups"].([]any)
if !ok || len(groups) != 2 {
t.Fatalf("join_groups mismatch: %+v", data["join_groups"])
}
regionGroup, ok := groups[1].(map[string]any)
if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_1001" || regionGroup["type"] != "region_broadcast" {
t.Fatalf("region join group mismatch: %+v", groups[1])
}
missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
SDKAppID: 1400000000, SDKAppID: 1400000000,
UserSigTTL: time.Hour, UserSigTTL: time.Hour,
}).Routes(auth.NewVerifier("secret")) }, profileClient).Routes(auth.NewVerifier("secret"))
missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
missingConfigRequest.Header.Set("X-Request-ID", "req-usersig-missing") missingConfigRequest.Header.Set("X-Request-ID", "req-usersig-missing")
@ -2829,6 +2878,43 @@ func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) {
} }
} }
func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
guard := &fakeRoomGuardClient{}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
AdminIdentifier: "administrator",
}, profileClient).Routes(auth.NewVerifier("secret"))
joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody))
joinRecorder := httptest.NewRecorder()
router.ServeHTTP(joinRecorder, joinRequest)
assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
if guard.lastPresence != nil {
t.Fatalf("broadcast join must not call room guard: %+v", guard.lastPresence)
}
mismatchBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1002","Requestor_Account":"42"}`)
mismatchRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(mismatchBody))
mismatchRecorder := httptest.NewRecorder()
router.ServeHTTP(mismatchRecorder, mismatchRequest)
assertTencentCallback(t, mismatchRecorder, http.StatusOK, 1)
userSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"42"}`)
userSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(userSendBody))
userSendRecorder := httptest.NewRecorder()
router.ServeHTTP(userSendRecorder, userSendRequest)
assertTencentCallback(t, userSendRecorder, http.StatusOK, 1)
adminSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"administrator"}`)
adminSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(adminSendBody))
adminSendRecorder := httptest.NewRecorder()
router.ServeHTTP(adminSendRecorder, adminSendRequest)
assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0)
}
func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) { func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
SDKAppID: 1400000000, SDKAppID: 1400000000,

Some files were not shown because too many files have changed in this diff Show More