完善区域群通知

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 更新必须保持原子性,不能改回非原子的 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
- 外部 HTTP JSON 入口在 `gateway-service`
@ -105,4 +114,5 @@ docker compose down
- 房间状态变更必须经过 Room Cell 命令链路。
- 需要恢复的关键动作必须写 command log 或 snapshot不能只改内存。
- 房间外副作用优先走 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 群组/系统消息桥接。
- `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。
- `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`
- 多 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/...` 引用生成包。
- 房间外事件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
本地和 Docker 编排统一使用 `13xxx` 端口:
@ -34,6 +45,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
| `wallet-service` | `13004` | gRPC |
| `user-service` | `13005` | gRPC |
| `activity-service` | `13006` | gRPC |
| `cron-service` | `13007` | gRPC |
| MySQL | `23306 -> 3306` | local Docker only |
| Redis | `13379 -> 6379` | local Docker only |
@ -45,7 +57,7 @@ Use Docker for local and test environments:
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:
@ -62,7 +74,7 @@ make down
`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
@ -117,7 +129,8 @@ MySQL 是本地和线上运行的持久化底座:
- `room-service`: `hyapp_room`,保存 room meta、snapshot、command log 和 outbox业务行按 `app_code` 隔离。
- `user-service`: `hyapp_user`,保存 `apps` 注册表、用户主数据、注册资料快照、登录身份、session、默认短号、靓号租约和 host 关系事实,业务行按 `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 业务库。
`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/user-service/ user service
services/activity-service/ activity foundation and room event consumer boundary
services/cron-service/ background scheduling and owner-service batch trigger
```
## 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。
- 房间命令未传 `command_id`gateway 会自动生成新值;客户端 HTTP 重试同一业务动作时必须复用原 `command_id` 才能命中 room-service command log 幂等。
- `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 后续必须使用独立消费位点。
- 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;
import "proto/events/room/v1/events.proto";
option go_package = "hyapp.local/api/proto/activity/v1;activityv1";
// RequestMeta activity-service RPC
@ -278,6 +280,75 @@ message ConsumeTaskEventResponse {
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
message TaskDefinition {
string task_id = 1;
@ -381,6 +452,20 @@ service TaskService {
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
service AdminTaskService {
rpc ListTaskDefinitions(ListTaskDefinitionsRequest) returns (ListTaskDefinitionsResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3
// - protoc v5.29.2
// source: proto/activity/v1/activity.proto
package activityv1
@ -784,6 +784,371 @@ var TaskService_ServiceDesc = grpc.ServiceDesc{
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 (
AdminTaskService_ListTaskDefinitions_FullMethodName = "/hyapp.activity.v1.AdminTaskService/ListTaskDefinitions"
AdminTaskService_UpsertTaskDefinition_FullMethodName = "/hyapp.activity.v1.AdminTaskService/UpsertTaskDefinition"

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v4.25.3
// protoc-gen-go v1.34.2
// protoc v5.29.2
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -38,9 +38,11 @@ type EventEnvelope struct {
func (x *EventEnvelope) Reset() {
*x = EventEnvelope{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *EventEnvelope) String() string {
@ -51,7 +53,7 @@ func (*EventEnvelope) ProtoMessage() {}
func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -133,9 +135,11 @@ type RoomCreated struct {
func (x *RoomCreated) Reset() {
*x = RoomCreated{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomCreated) String() string {
@ -146,7 +150,7 @@ func (*RoomCreated) ProtoMessage() {}
func (x *RoomCreated) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -222,9 +226,11 @@ type RoomUserJoined struct {
func (x *RoomUserJoined) Reset() {
*x = RoomUserJoined{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserJoined) String() string {
@ -235,7 +241,7 @@ func (*RoomUserJoined) ProtoMessage() {}
func (x *RoomUserJoined) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -275,9 +281,11 @@ type RoomUserLeft struct {
func (x *RoomUserLeft) Reset() {
*x = RoomUserLeft{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserLeft) String() string {
@ -288,7 +296,7 @@ func (*RoomUserLeft) ProtoMessage() {}
func (x *RoomUserLeft) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -322,9 +330,11 @@ type RoomClosed struct {
func (x *RoomClosed) Reset() {
*x = RoomClosed{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomClosed) String() string {
@ -335,7 +345,7 @@ func (*RoomClosed) ProtoMessage() {}
func (x *RoomClosed) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -387,9 +397,11 @@ type RoomMicChanged struct {
func (x *RoomMicChanged) Reset() {
*x = RoomMicChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomMicChanged) String() string {
@ -400,7 +412,7 @@ func (*RoomMicChanged) ProtoMessage() {}
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -498,9 +510,11 @@ type RoomMicSeatLocked struct {
func (x *RoomMicSeatLocked) Reset() {
*x = RoomMicSeatLocked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomMicSeatLocked) String() string {
@ -511,7 +525,7 @@ func (*RoomMicSeatLocked) ProtoMessage() {}
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -559,9 +573,11 @@ type RoomChatEnabledChanged struct {
func (x *RoomChatEnabledChanged) Reset() {
*x = RoomChatEnabledChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomChatEnabledChanged) String() string {
@ -572,7 +588,7 @@ func (*RoomChatEnabledChanged) ProtoMessage() {}
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -614,9 +630,11 @@ type RoomAdminChanged struct {
func (x *RoomAdminChanged) Reset() {
*x = RoomAdminChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomAdminChanged) String() string {
@ -627,7 +645,7 @@ func (*RoomAdminChanged) ProtoMessage() {}
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -676,9 +694,11 @@ type RoomHostTransferred struct {
func (x *RoomHostTransferred) Reset() {
*x = RoomHostTransferred{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomHostTransferred) String() string {
@ -689,7 +709,7 @@ func (*RoomHostTransferred) ProtoMessage() {}
func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -738,9 +758,11 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserMuted) String() string {
@ -751,7 +773,7 @@ func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -799,9 +821,11 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserKicked) String() string {
@ -812,7 +836,7 @@ func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -853,9 +877,11 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserUnbanned) String() string {
@ -866,7 +892,7 @@ func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
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"`
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"`
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() {
*x = RoomGiftSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomGiftSent) String() string {
@ -924,7 +954,7 @@ func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -981,6 +1011,20 @@ func (x *RoomGiftSent) GetBillingReceiptId() string {
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 表达热度变化结果。
type RoomHeatChanged struct {
state protoimpl.MessageState
@ -993,9 +1037,11 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomHeatChanged) String() string {
@ -1006,7 +1052,7 @@ func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1048,9 +1094,11 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomRankChanged) String() string {
@ -1061,7 +1109,7 @@ func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
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))
if ms.LoadMessageInfo() == nil {
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,
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,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xdf,
0x01, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xaa,
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,
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,
@ -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,
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,
0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01,
0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72,
0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f,
0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d,
0x0a, 0x0a, 0x67, 0x69, 0x66, 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,
0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73,
0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x0f, 0x52,
0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14,
0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64,
0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f,
0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
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 (
@ -1289,6 +1342,200 @@ func file_proto_events_room_v1_events_proto_init() {
if File_proto_events_room_v1_events_proto != nil {
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{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

View File

@ -116,6 +116,8 @@ message RoomGiftSent {
int32 gift_count = 4;
int64 gift_value = 5;
string billing_receipt_id = 6;
int64 visible_region_id = 7;
string command_id = 8;
}
// 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.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3
// - protoc v5.29.2
// source: proto/room/v1/room.proto
package roomv1

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -137,7 +137,7 @@ service_name: cron-service
node_id: cron-local
environment: local
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"
activity_service_addr: "127.0.0.1:13006"

View File

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

View File

@ -90,7 +90,7 @@ admin_operation_logs(
resource_id VARCHAR(80) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
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_resource_id (resource_id),
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` 事件。
- 投递成功后才能标记 `delivered`
- 投递失败必须保留 envelope 和 last_error转入 `retryable` 等待下一轮抢占。

View File

@ -60,7 +60,7 @@ Room Cell command applied
```text
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
-> success: status=delivered, last_error=NULL
-> failure: status=retryable, retry_count=retry_count+1, last_error=<short error>
@ -166,7 +166,7 @@ type App struct {
- 不能在 worker 退出前关闭 MySQL否则可能无法标记投递结果。
- worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。
- 如果进程被强杀,已抢占未完成的记录保持 `delivering``lock_until` 过期后下次启动继续补偿。
- 如果进程被强杀,已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后下次启动继续补偿。
## 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)`
- `PublishOutboxEvent` 成功后调用 `MarkOutboxDelivered`
- `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed`
@ -238,7 +238,7 @@ V1 采用固定间隔无限重试:
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
| envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 |
| 不需要推 IM 的事件类型 | no-op success标记 `delivered` |
| 进程退出 | 已抢占未完成的记录保持 `delivering``lock_until` 过期后可被重新抢占 |
| 进程退出 | 已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后可被重新抢占 |
为什么不加最大重试次数:

View File

@ -100,7 +100,7 @@ jwt:
`wallet-service`
```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
```

View File

@ -550,7 +550,7 @@ sequenceDiagram
U->>DB: check pretty display_user_id availability
U->>DB: mark default display_user_id held
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: insert display_user_id_change_logs(change_type=pretty_apply)
U->>DB: commit

View File

@ -154,6 +154,7 @@ source_assignment_status
- 管理端调整区域国家时,创建重算任务,按 country 分页刷新已有用户的 `region_id`
- 后台创建 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`;该动作不受普通用户国家修改冷却限制。
- 当国家修改导致 `old_region_id != new_region_id` 时,后端要调用 `activity-service``RemoveRegionBroadcastMember`,用腾讯云 IM `delete_group_member` 只把该用户从旧区域播报群移除;不能删除或解散区域群。
### User Region Change Sources
@ -162,8 +163,8 @@ source_assignment_status
| 场景 | 入口 | 是否修改 `users.country` | `users.region_id` 写入方式 | 审计/日志 |
| --- | --- | --- | --- | --- |
| 首次注册或首次完成资料 | App/gateway 调 user-service 注册/资料完成链路 | 是,写用户选择的 enabled 国家 | user-service 在同一事务内按 active 国家映射解析并写入;无映射时写空 | 注册/资料完成链路日志,不写国家修改冷却日志 |
| 用户主动修改国家 | App/gateway 调 `ChangeUserCountry` | 是,写新 enabled 国家 | user-service 在同一事务内按新国家 active 映射重算;无映射时写空 | 写 `user_country_change_logs`,受国家修改冷却限制 |
| 后台修改 App 用户国家 | `hyapp-admin-server` 的 App 用户管理接口 | 是,写后台选择的 enabled 国家 | `hyapp-admin-server` 在同一次用户更新中按 active 国家映射重算;无映射时写空 | 写后台操作审计,不写 App 自助国家修改冷却日志 |
| 用户主动修改国家 | 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 国家映射重算;无映射时写空;成功后 best-effort 移除旧区域群成员关系 | 写后台操作审计,不写 App 自助国家修改冷却日志IM 移除失败写 admin 结构化日志 |
| 管理端调整区域国家映射 | 后台创建/停用区域、替换区域国家归属后触发 rebuild | 否 | rebuild worker 按受影响 `country` 分页刷新历史用户 `region_id`,最终一致 | 写区域变更审计和 rebuild task不写用户国家修改日志 |
| 后台创建 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购买同等级 | 续期,`expires_at = current_expires_at + 7d` |
| 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at = current_expires_at + 7d` |
| 无有效 VIP | 可以购买任意 active 等级,`expires_at_ms = now_ms + 7d_ms` |
| 有效 VIP购买同等级 | 续期,`expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |

View File

@ -12,5 +12,5 @@ type System struct{}
// Now 返回当前系统时间。
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 (
// DefaultEndpoint is Tencent Cloud Chat's China REST API endpoint.
// DefaultEndpoint 是腾讯云 IM 国内 REST 默认入口;海外部署可在配置中覆盖为对应地域域名。
DefaultEndpoint = "console.tim.qq.com"
// DefaultGroupType keeps room messages recoverable; AVChatRoom has no history storage.
// DefaultGroupType 使用 ChatRoom保留消息历史能力AVChatRoom 不适合需要补偿和漫游的房间消息。
DefaultGroupType = "ChatRoom"
// createGroupCommand is Tencent Cloud Chat REST command for group creation.
// createGroupCommand 是建群 REST 命令;房间群、全局播报群、区域播报群都复用这一个能力。
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"
// 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"
)
// RESTConfig contains Tencent Cloud Chat server-side REST API settings.
// RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
type RESTConfig struct {
// SDKAppID is the application ID assigned by Tencent Cloud Chat.
// SDKAppID 是腾讯云 IM 应用 ID回调校验和 UserSig 签发都必须使用同一个值。
SDKAppID int64
// SecretKey signs the admin UserSig used by REST API calls.
// SecretKey 只用于服务端签 admin UserSig不能返回给客户端或写入错误日志。
SecretKey string
// AdminIdentifier is the App administrator account configured in Tencent Cloud Chat.
// AdminIdentifier 是腾讯云控制台配置的管理员账号,播报群消息用它证明“服务端发送”。
AdminIdentifier string
// AdminUserSigTTL controls the cached admin UserSig lifetime.
// AdminUserSigTTL 控制 REST 调用签名有效期;过短会增加签发频率,过长会扩大泄露影响面。
AdminUserSigTTL time.Duration
// Endpoint is the regional REST API host, such as adminapisgp.im.qcloud.com.
// Endpoint 是 REST 地域入口,例如 adminapisgp.im.qcloud.com不要把地域选择写死在业务里。
Endpoint string
// GroupType is the Tencent group type used for voice rooms.
// GroupType 是房间和播报群共用的腾讯群类型;当前保持 ChatRoom 以保留历史能力。
GroupType string
// HTTPClient allows tests to inject a fake transport.
// HTTPClient 允许测试注入 fake transport也让上层统一设置超时。
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 {
EventID string `json:"event_id"`
RoomID string `json:"room_id"`
@ -61,18 +83,18 @@ type RoomEvent struct {
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 {
// cfg is immutable after construction so calls can safely run concurrently.
// cfg 初始化后不再修改,因此同一个 client 可以被多个 worker 并发复用。
cfg RESTConfig
// httpClient is reused for keep-alive; Tencent recommends long connections for REST API.
// httpClient 复用 keep-alive避免高频播报时每条消息都重新建连接。
httpClient *http.Client
}
// NewRESTClient validates config and creates a Tencent Cloud Chat REST client.
// NewRESTClient 校验必需配置并创建 REST client。
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
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")
}
if cfg.AdminUserSigTTL <= 0 {
@ -94,7 +116,8 @@ func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
}, 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 {
roomID = strings.TrimSpace(roomID)
if roomID == "" {
@ -102,11 +125,41 @@ func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUs
}
_ = ownerUserID
request := createGroupRequest{
Type: c.cfg.GroupType,
return c.EnsureGroup(ctx, GroupSpec{
GroupID: roomID,
Name: roomID,
Type: c.cfg.GroupType,
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
@ -114,17 +167,17 @@ func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUs
return err
}
if response.ErrorCode == 10021 || response.ErrorCode == 10025 {
// Custom GroupId already exists; for idempotent CreateRoom retry this is success.
// 腾讯云不同接口版本可能返回 10021/10025 表示群已存在;建群语义要求幂等。
return nil
}
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 {
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")
}
@ -133,21 +186,44 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro
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{
GroupID: event.RoomID,
Random: randomUint32(),
GroupID: message.GroupID,
Random: randomUint32(),
CloudCustomData: string(payload),
FromAccount: message.FromAccount,
MsgBody: []messageElement{
{
MsgType: "TIMCustomElem",
MsgContent: customMsgContent{
Data: string(eventPayload),
Desc: event.EventType,
Ext: "room_system_message",
Data: string(payload),
Desc: message.Desc,
Ext: message.Ext,
Sound: "",
},
},
},
CloudCustomData: string(eventPayload),
}
var response restResponse
@ -158,15 +234,33 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro
return response.err()
}
// DeleteRoomGroupMember removes a room user from the Tencent Cloud Chat group.
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error {
roomID = strings.TrimSpace(roomID)
if roomID == "" || userID <= 0 {
return fmt.Errorf("room group member removal is incomplete")
// DestroyGroup 解散一个腾讯云 IM 群。
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
groupID = strings.TrimSpace(groupID)
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{
GroupID: roomID,
GroupID: groupID,
MemberToDelAccount: []string{FormatUserID(userID)},
Silence: 1,
}
@ -179,9 +273,14 @@ func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, u
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 {
// 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)
}
@ -208,7 +307,7 @@ func (c *RESTClient) post(ctx context.Context, command string, payload any, out
return err
}
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))
}
if err := json.Unmarshal(responseBody, out); err != nil {
@ -223,7 +322,7 @@ func (c *RESTClient) endpoint(command string) string {
SDKAppID: c.cfg.SDKAppID,
SecretKey: c.cfg.SecretKey,
TTL: c.cfg.AdminUserSigTTL,
}, c.cfg.AdminIdentifier, time.Now())
}, c.cfg.AdminIdentifier, time.Now().UTC())
values := url.Values{}
values.Set("sdkappid", strconv.FormatInt(c.cfg.SDKAppID, 10))
@ -238,7 +337,7 @@ func (c *RESTClient) endpoint(command string) string {
func randomUint32() uint32 {
var payload [4]byte
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())
}
@ -256,7 +355,7 @@ func (r restResponse) err() error {
return nil
}
if r.ErrorCode == 0 && r.ActionStatus == "" {
// Some tests only decode partial responses; keep zero-code as success.
// 部分测试只构造 ErrorCode腾讯语义里 0 即成功,因此允许空 ActionStatus。
return nil
}
@ -279,6 +378,10 @@ type sendGroupMsgRequest struct {
FromAccount string `json:"From_Account,omitempty"`
}
type destroyGroupRequest struct {
GroupID string `json:"GroupId"`
}
type deleteGroupMemberRequest struct {
GroupID string `json:"GroupId"`
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 请求。
func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) {
var capturedPath string

View File

@ -82,7 +82,12 @@ func main() {
LogLevel: logger.Warn,
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 {
fatalRuntime("connect_mysql_failed", err)
}
@ -91,7 +96,9 @@ func main() {
fatalRuntime("mysql_db_handle_failed", err)
}
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)
}
}
@ -191,7 +198,7 @@ func main() {
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
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),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),

View File

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

View File

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

View File

@ -15,10 +15,10 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.HTTPAddr != ":13100" {
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)
}
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)
}
if cfg.AccessTokenTTL != 30*time.Minute {
@ -27,6 +27,9 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.RefreshTokenTTL != 7*24*time.Hour {
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" {
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)
UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
}
type GRPCClient struct {
client activityv1.AdminTaskServiceClient
taskClient activityv1.AdminTaskServiceClient
broadcastClient activityv1.BroadcastServiceClient
}
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) {
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) {
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) {
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", "最后登录"})
for _, user := range users {
lastLogin := ""
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format("2006-01-02 15:04:05")
if user.LastLoginAtMS != nil {
lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("2006-01-02 15:04:05")
}
_ = writer.Write([]string{
fmt.Sprintf("%d", user.ID),

View File

@ -42,5 +42,5 @@ func newRequestID() string {
if _, err := rand.Read(raw[:]); err == nil {
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"
"sort"
"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 {
return ApplyWithOptions(ctx, db, dir, Options{})
}
func ApplyWithOptions(ctx context.Context, db *sql.DB, dir string, options Options) error {
if strings.TrimSpace(dir) == "" {
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 {
version := filepath.Base(file)
applied, err := isApplied(ctx, db, version, file)
applied, err := isApplied(ctx, db, version, file, options)
if err != nil {
return err
}
@ -50,14 +62,17 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(128) PRIMARY KEY,
checksum CHAR(64) NOT NULL DEFAULT '',
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 {
return err
}
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 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 {
@ -92,7 +107,7 @@ func migrationFiles(dir string) ([]string, error) {
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
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) {
@ -110,6 +125,13 @@ func isApplied(ctx context.Context, db *sql.DB, version string, file string) (bo
}
sum := checksum(body)
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)
}
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)
}
}
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 nil

View File

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

View File

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

View File

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

View File

@ -8,7 +8,9 @@ import (
"strings"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -21,9 +23,9 @@ type Handler struct {
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{
service: NewService(userClient, userDB, walletDB),
service: NewService(userClient, activityClient, userDB, walletDB),
cfg: cfg,
audit: audit,
}
@ -87,7 +89,7 @@ func (h *Handler) UpdateUser(c *gin.Context) {
response.BadRequest(c, "用户参数不正确")
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 {
writeMutationError(c, err, "更新 App 用户失败")
return

View File

@ -5,13 +5,16 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/security"
activityv1 "hyapp.local/api/proto/activity/v1"
)
var (
@ -20,9 +23,10 @@ var (
)
type Service struct {
userClient userclient.Client
userDB *sql.DB
walletDB *sql.DB
userClient userclient.Client
activityClient activityclient.Client
userDB *sql.DB
walletDB *sql.DB
}
type AppUser struct {
@ -44,8 +48,8 @@ type AppUser struct {
Username string `json:"username"`
}
func NewService(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, userDB: userDB, walletDB: walletDB}
func NewService(userClient userclient.Client, activityClient activityclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, activityClient: activityClient, userDB: userDB, walletDB: walletDB}
}
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
}
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 {
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 = ?")
args = append(args, nullableString(value))
}
oldRegionID := int64(0)
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)
if err != nil {
return AppUser{}, err
@ -289,7 +299,41 @@ func (s *Service) UpdateUser(ctx context.Context, userID int64, req updateUserRe
if affected == 0 {
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) {

View File

@ -78,7 +78,7 @@ func (s *AuditService) ExportLoginLogs(options repository.ListOptions) (CSVExpor
item.IP,
item.Status,
item.Message,
item.CreatedAt.Format(time.RFC3339),
time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
})
}
writer.Flush()
@ -109,7 +109,7 @@ func (s *AuditService) ExportOperationLogs(options repository.ListOptions) (CSVE
item.RiskLevel,
fmt.Sprintf("%d", item.LatencyMS),
item.Detail,
item.CreatedAt.Format(time.RFC3339),
time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
})
}
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()))
response.OK(c, gin.H{
"accessToken": result.AccessToken,
"expiresAt": result.ExpiresAt,
"expiresAtMs": result.ExpiresAtMS,
"user": shared.UserDTO(result.User),
"permissions": result.Permissions,
})
@ -73,7 +73,7 @@ func (h *Handler) Refresh(c *gin.Context) {
response.OK(c, gin.H{
"accessToken": result.AccessToken,
"expiresAt": result.ExpiresAt,
"expiresAtMs": result.ExpiresAtMS,
"user": shared.UserDTO(result.User),
"permissions": result.Permissions,
})

View File

@ -29,7 +29,7 @@ type LoginInput struct {
type LoginResult struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
ExpiresAtMS int64
User model.User
Permissions []string
}
@ -59,17 +59,17 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) {
return LoginResult{}, err
}
if err := s.store.CreateRefreshToken(model.RefreshToken{
UserID: user.ID,
TokenHash: refreshHash,
UserAgent: input.UserAgent,
IP: input.IP,
ExpiresAt: time.Now().Add(s.cfg.RefreshTokenTTL),
UserID: user.ID,
TokenHash: refreshHash,
UserAgent: input.UserAgent,
IP: input.IP,
ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(),
}); err != nil {
return LoginResult{}, err
}
_ = s.store.TouchUserLogin(user.ID)
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 {
@ -96,7 +96,7 @@ func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) {
if err != nil {
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) {

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ func (s *Store) ListLoginLogs(options ListOptions) ([]model.LoginLog, int64, err
}
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
}
@ -39,7 +39,7 @@ func (s *Store) ExportLoginLogs(options ListOptions) ([]model.LoginLog, error) {
}
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
}
@ -57,7 +57,7 @@ func (s *Store) ListOperationLogs(options ListOptions) ([]model.OperationLog, in
}
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
}
@ -69,6 +69,6 @@ func (s *Store) ExportOperationLogs(options ListOptions) ([]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
}

View File

@ -42,10 +42,10 @@ func PermissionsForUser(user model.User) []string {
}
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{}{
"last_login_at": &now,
"updated_at": now,
"last_login_at_ms": &nowMS,
"updated_at_ms": nowMS,
}).Error
}
@ -55,7 +55,7 @@ func (s *Store) CreateRefreshToken(token model.RefreshToken) error {
func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
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 {
return nil, err
}
@ -64,6 +64,6 @@ func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
}
func (s *Store) RevokeRefreshToken(hash string) error {
now := time.Now()
return s.db.Model(&model.RefreshToken{}).Where("token_hash = ? AND revoked_at IS NULL", hash).Update("revoked_at", &now).Error
nowMS := time.Now().UTC().UnixMilli()
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 {
return nil, err
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if err := s.db.Model(&model.OperationLog{}).Where("created_at >= ?", today).Count(&logsToday).Error; err != nil {
now := time.Now().UTC()
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_ms >= ?", todayMS).Count(&logsToday).Error; err != nil {
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
}
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
}
alerts := make([]map[string]string, 0, len(notifications))
@ -46,7 +46,7 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
"type": item.Level,
"name": item.Title,
"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)},
"notifications": {int(unreadNotifications)},
},
Alerts: alerts,
UpdatedAt: now.Format("15:04:05"),
Alerts: alerts,
UpdatedAtMS: now.UnixMilli(),
}, nil
}

View File

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

View File

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

View File

@ -38,7 +38,7 @@ type DashboardOverview struct {
UnreadNotifications int `json:"unreadNotifications"`
Alerts []map[string]string `json:"alerts"`
Series map[string][]int `json:"series"`
UpdatedAt string `json:"updatedAt"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
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) {
expiresAt := time.Now().Add(s.ttl)
now := time.Now().UTC()
expiresAt := now.Add(s.ttl)
claims := Claims{
UserID: userID,
Username: username,
Permissions: permissions,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(now),
Subject: username,
},
}

View File

@ -6,9 +6,9 @@ CREATE TABLE IF NOT EXISTS admin_users (
team VARCHAR(80),
status VARCHAR(24) NOT NULL DEFAULT 'active',
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
last_login_at_ms BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_users_status (status)
);
@ -17,8 +17,8 @@ CREATE TABLE IF NOT EXISTS admin_roles (
name VARCHAR(80) NOT NULL UNIQUE,
code VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_permissions (
@ -27,8 +27,8 @@ CREATE TABLE IF NOT EXISTS admin_permissions (
code VARCHAR(100) NOT NULL UNIQUE,
kind VARCHAR(32) NOT NULL,
description VARCHAR(255),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_permissions_kind (kind)
);
@ -54,8 +54,8 @@ CREATE TABLE IF NOT EXISTS admin_menus (
permission_code VARCHAR(100),
sort INT NOT NULL DEFAULT 0,
visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_menus_parent_id (parent_id),
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,
user_agent VARCHAR(255),
ip VARCHAR(64),
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
expires_at_ms BIGINT NOT NULL,
revoked_at_ms BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
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 (
@ -82,7 +82,7 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
user_agent VARCHAR(255),
status VARCHAR(32) NOT NULL,
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_status (status)
);
@ -98,7 +98,7 @@ CREATE TABLE IF NOT EXISTS admin_operation_logs (
ip VARCHAR(64),
status VARCHAR(32) NOT NULL,
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_action (action)
);
@ -108,9 +108,9 @@ CREATE TABLE IF NOT EXISTS admin_notifications (
title VARCHAR(120) NOT NULL,
content VARCHAR(500),
level VARCHAR(24) NOT NULL DEFAULT 'info',
read_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
read_at_ms BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_notifications_level (level)
);
@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS admin_service_assets (
memory INT NOT NULL DEFAULT 0,
trend_json TEXT,
logs_json TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
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,
scope_type VARCHAR(40) NOT NULL,
scope_value VARCHAR(120) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
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_scope (scope_type, scope_value)
@ -19,10 +19,10 @@ CREATE TABLE IF NOT EXISTS admin_jobs (
error TEXT,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
started_at_ms BIGINT NULL,
finished_at_ms BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_admin_jobs_type (type),
INDEX idx_admin_jobs_status (status),
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 max_attempts INT NOT NULL DEFAULT 3 AFTER attempt,
ADD COLUMN locked_by VARCHAR(120) NOT NULL DEFAULT '' AFTER max_attempts,
ADD COLUMN locked_until DATETIME(3) NULL AFTER locked_by,
ADD COLUMN artifact_path VARCHAR(255) NOT NULL DEFAULT '' AFTER locked_until,
ADD COLUMN locked_until_ms BIGINT NULL AFTER locked_by,
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_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,
country_code VARCHAR(8) NOT NULL DEFAULT '',
description VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at_ms BIGINT 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_scope (app_code, platform, region_id, country_code)
) 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
ENV TZ=UTC
WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser

View File

@ -8,11 +8,28 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
wallet_service_addr: "wallet-service:13004"
task_timezone: "Asia/Shanghai"
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:
room_outbox_poll_interval_ms: 1000
batch_size: 100

View File

@ -8,11 +8,28 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
wallet_service_addr: "wallet-service.internal:13004"
task_timezone: "Asia/Shanghai"
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:
room_outbox_poll_interval_ms: 1000
batch_size: 100

View File

@ -8,11 +8,28 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
wallet_service_addr: "127.0.0.1:13004"
task_timezone: "Asia/Shanghai"
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:
room_outbox_poll_interval_ms: 1000
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)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
task_id VARCHAR(96) NOT NULL,

View File

@ -14,9 +14,11 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config"
activityservice "hyapp/services/activity-service/internal/service/activity"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
messageservice "hyapp/services/activity-service/internal/service/message"
taskservice "hyapp/services/activity-service/internal/service/task"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
@ -25,13 +27,18 @@ import (
// App 装配 activity-service gRPC 入口和底座依赖。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
broadcast *broadcastservice.Service
broadcastWorkerEnabled bool
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
workerCtx context.Context
workerStop context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
}
// New 初始化 activity-service 应用。
@ -67,29 +74,76 @@ func New(cfg config.Config) (*App, error) {
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
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.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc))
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc))
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc))
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
health := grpchealth.NewServingChecker("activity-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
workerCtx, workerStop := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
userConn: userConn,
walletConn: walletConn,
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
broadcast: broadcastSvc,
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
userConn: userConn,
walletConn: walletConn,
workerCtx: workerCtx,
workerStop: workerStop,
}, nil
}
// Run 启动 gRPC 服务。
func (a *App) Run() error {
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)
if a.workerStop != nil {
a.workerStop()
}
a.workerWG.Wait()
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
@ -102,6 +156,10 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerStop != nil {
a.workerStop()
}
a.workerWG.Wait()
a.server.GracefulStop()
if a.userConn != nil {
_ = 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
import (
"net/http"
"strings"
"time"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
)
// Config 描述 activity-service 启动配置。
@ -19,8 +22,10 @@ type Config struct {
UserServiceAddr string `yaml:"user_service_addr"`
// WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。
WalletServiceAddr string `yaml:"wallet_service_addr"`
// TaskTimezone 是 daily 任务统一周期时区,不能使用客户端手机时区。
TaskTimezone string `yaml:"task_timezone"`
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
TencentIM TencentIMConfig `yaml:"tencent_im"`
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
Broadcast BroadcastConfig `yaml:"broadcast"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"`
@ -39,6 +44,46 @@ type MessageInboxConfig struct {
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 返回本地默认配置。
func Default() Config {
return Config{
@ -46,11 +91,27 @@ func Default() Config {
NodeID: "activity-local",
Environment: "local",
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",
WalletServiceAddr: "127.0.0.1:13004",
TaskTimezone: "Asia/Shanghai",
MySQLAutoMigrate: false,
TencentIM: TencentIMConfig{
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{
RoomOutboxPollIntervalMs: 1000,
BatchSize: 100,
@ -82,9 +143,6 @@ func Load(path string) (Config, error) {
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
cfg.WalletServiceAddr = Default().WalletServiceAddr
}
if strings.TrimSpace(cfg.TaskTimezone) == "" {
cfg.TaskTimezone = Default().TaskTimezone
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "activity-service"
@ -97,6 +155,39 @@ func Load(path string) (Config, error) {
if cfg.Environment == "" {
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
}

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

View File

@ -16,7 +16,7 @@ import (
func TestTaskEventQueryAndClaimFlow(t *testing.T) {
repository := mysqltest.NewRepository(t)
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))
svc.SetClock(func() time.Time { return now })

View File

@ -5,10 +5,13 @@ import (
"time"
)
func TestDailyCycleFallsBackWhenLocationMissing(t *testing.T) {
func TestDailyCycleUsesUTCDay(t *testing.T) {
svc := &Service{}
cycle := svc.dailyCycle(time.Date(2026, 5, 8, 16, 30, 0, 0, time.UTC))
if cycle.Key != "2026-05-09" {
t.Fatalf("daily cycle must use UTC+8 fallback when tzdata is missing: %+v", cycle)
cycle := svc.dailyCycle(time.Date(2026, 5, 8, 23, 30, 0, 0, time.FixedZone("riyadh", 3*60*60)))
if cycle.Key != "2026-05-08" {
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
ENV TZ=UTC
WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser
@ -27,4 +29,3 @@ EXPOSE 13007
ENTRYPOINT ["/app/server"]
CMD ["-config", "/app/config.yaml"]

View File

@ -8,7 +8,7 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
activity_service_addr: "activity-service:13006"
wallet_service_addr: "wallet-service:13004"

View File

@ -8,7 +8,7 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
activity_service_addr: "activity-service.internal:13006"
wallet_service_addr: "wallet-service.internal:13004"

View File

@ -8,7 +8,7 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
activity_service_addr: "127.0.0.1:13006"
wallet_service_addr: "127.0.0.1:13004"

View File

@ -62,7 +62,7 @@ func Default() Config {
NodeID: "cron-local",
Environment: "local",
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",
ActivityServiceAddr: "127.0.0.1:13006",
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))
return false
}
now := time.Now()
now := time.Now().UTC()
lockTTL := parseDuration(task.LockTTL, 30*time.Second)
acquired, err := s.store.TryAcquireTaskLease(ctx, taskName, appCode, s.nodeID, now.UnixMilli(), lockTTL)
if err != nil {
@ -168,7 +168,7 @@ func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string
})
cancel()
finished := time.Now()
finished := time.Now().UTC()
run.FinishedAtMs = finished.UnixMilli()
run.DurationMs = finished.Sub(now).Milliseconds()
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
ENV TZ=UTC
WORKDIR /app
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"
app_config:
# 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:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true
@ -45,6 +45,7 @@ tencent_im:
# Docker 本地联调腾讯云 IM UserSig必须与 room-service 使用同一组应用配置。
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
user_sig_ttl: "24h"
callback_url: ""
callback_auth_token: ""

View File

@ -15,7 +15,7 @@ wallet_service_addr: "wallet-service.internal:13004"
activity_service_addr: "activity-service.internal:13006"
app_config:
# 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:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true
@ -47,6 +47,8 @@ tencent_im:
sdk_app_id: 1400000000
# 腾讯云 IM SDKSecretKey只允许保存在服务端密钥系统或线上配置中。
secret_key: "TENCENT_IM_SECRET_KEY"
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
user_sig_ttl: "24h"
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。

View File

@ -15,7 +15,7 @@ wallet_service_addr: "127.0.0.1:13004"
activity_service_addr: "127.0.0.1:13006"
app_config:
# 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:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true
@ -45,6 +45,7 @@ tencent_im:
# 本地联调腾讯云 IM UserSigSDKAppID 和密钥必须与 room-service 保持一致。
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
# 客户端 UserSig 有效期;线上建议 12h-7d按登录刷新策略决定。
user_sig_ttl: "24h"
# 腾讯云 IM 控制台回调 URL。需要公网可访问本地为空表示暂不启用回调。

View File

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

View File

@ -12,9 +12,9 @@ import (
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 = `
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
WHERE app_code = ? AND status = 'active'
AND (? = '' OR platform = ?)
@ -111,8 +111,8 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
for rows.Next() {
var key string
var url string
var updatedAt sql.NullTime
if err := rows.Scan(&key, &url, &updatedAt); err != nil {
var updatedAtMS int64
if err := rows.Scan(&key, &url, &updatedAtMS); err != nil {
return nil, err
}
@ -121,9 +121,7 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
Key: key,
URL: strings.TrimSpace(url),
}
if updatedAt.Valid {
link.UpdatedAtMs = updatedAt.Time.UnixMilli()
}
link.UpdatedAtMs = updatedAtMS
configs[key] = link
}
if err := rows.Err(); err != nil {
@ -165,7 +163,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
for rows.Next() {
var item Banner
var appCode string
var updatedAt sql.NullTime
var updatedAtMS int64
if err := rows.Scan(
&item.ID,
&appCode,
@ -177,13 +175,11 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
&item.RegionID,
&item.CountryCode,
&item.Description,
&updatedAt,
&updatedAtMS,
); err != nil {
return nil, err
}
if updatedAt.Valid {
item.UpdatedAtMs = updatedAt.Time.UnixMilli()
}
item.UpdatedAtMs = updatedAtMS
items = append(items, item)
}
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"`
// UserSigTTL 是客户端 IM 登录票据有效期。
UserSigTTL time.Duration `yaml:"user_sig_ttl"`
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
AdminIdentifier string `yaml:"admin_identifier"`
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
CallbackURL string `yaml:"callback_url"`
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token只用于校验回调来源。
@ -144,7 +146,7 @@ func Default() Config {
WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006",
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{
Enabled: true,
@ -164,7 +166,8 @@ func Default() Config {
KeyPrefix: "login_risk:ip:",
},
TencentIM: TencentIMConfig{
UserSigTTL: 24 * time.Hour,
UserSigTTL: 24 * time.Hour,
AdminIdentifier: "administrator",
},
TencentRTC: TencentRTCConfig{
UserSigTTL: 2 * time.Hour,
@ -269,6 +272,12 @@ func (cfg *Config) Normalize() error {
}
cfg.TencentRTC.CallbackURL = strings.TrimSpace(cfg.TencentRTC.CallbackURL)
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
}

View File

@ -7,12 +7,15 @@ import (
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/auth"
"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。
@ -31,6 +34,7 @@ type Handler struct {
walletClient client.WalletClient
messageClient client.MessageInboxClient
taskClient client.TaskClient
broadcastClient client.BroadcastClient
appConfigReader appConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
@ -46,6 +50,7 @@ type TencentIMConfig struct {
SDKAppID int64
SecretKey string
UserSigTTL time.Duration
AdminIdentifier string
CallbackAuthToken string
}
@ -160,6 +165,11 @@ func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader
@ -220,17 +230,98 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
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{
SDKAppID: h.tencentIM.SDKAppID,
SecretKey: h.tencentIM.SecretKey,
TTL: h.tencentIM.UserSigTTL,
}, tencentim.FormatUserID(userID), time.Now())
}, tencentim.FormatUserID(userID), time.Now().UTC())
if err != nil {
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
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
}
type fakeBroadcastClient struct {
lastRemove *activityv1.RemoveRegionBroadcastMemberRequest
err error
}
func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
f.lastLoginPassword = req
f.loginPasswordCount++
@ -321,6 +326,7 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
}
return &userv1.GetUserResponse{User: &userv1.User{
UserId: req.GetUserId(),
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
DisplayUserId: "100001",
Username: "hy",
Gender: "female",
@ -411,8 +417,9 @@ func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1
UserId: req.GetUserId(),
DisplayUserId: "100001",
Country: req.GetCountry(),
RegionId: 2002,
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) {
@ -597,6 +604,14 @@ func (f *fakeTaskClient) ClaimTaskReward(_ context.Context, req *activityv1.Clai
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) {
f.lastHost = req
if f.hostErr != nil {
@ -2151,6 +2166,31 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
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) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
@ -2525,11 +2565,12 @@ func TestJoinRoomClosedWritesRoomClosedEnvelope(t *testing.T) {
}
func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
SecretKey: "secret",
UserSigTTL: time.Hour,
}).Routes(auth.NewVerifier("secret"))
}, profileClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
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"] == "" {
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{
SDKAppID: 1400000000,
UserSigTTL: time.Hour,
}).Routes(auth.NewVerifier("secret"))
}, profileClient).Routes(auth.NewVerifier("secret"))
missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
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) {
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,

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