完善区域群通知
This commit is contained in:
parent
47a7df3394
commit
baa6d81529
10
AGENTS.md
10
AGENTS.md
@ -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 边界测试,至少覆盖开始边界、结束前一毫秒和结束边界。
|
||||
- 文档只写当前事实和明确约束,不记录“做了什么”的流水账。
|
||||
|
||||
24
README.md
24
README.md
@ -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
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
@ -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
@ -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 是注册页唯一资料提交入口。
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。
|
||||
|
||||
@ -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)
|
||||
|
||||
462
docs/im-global-region-broadcast-architecture.md
Normal file
462
docs/im-global-region-broadcast-architecture.md
Normal 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 guard;callback 必须先识别 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 调用该 RPC;IM 外部副作用失败不回滚已提交的用户主数据。
|
||||
- 失败必须写结构化日志,后续可由后台补偿或用户下次刷新 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 不影响领取接口的后端金额校验 |
|
||||
@ -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` 等待下一轮抢占。
|
||||
|
||||
@ -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` 过期后可被重新抢占 |
|
||||
|
||||
为什么不加最大重试次数:
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 命令幂等结果,不写用户国家修改日志 |
|
||||
|
||||
|
||||
@ -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 等级 |
|
||||
|
||||
|
||||
@ -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
126
pkg/imgroup/imgroup.go
Normal 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
|
||||
}
|
||||
44
pkg/imgroup/imgroup_test.go
Normal file
44
pkg/imgroup/imgroup_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
151
pkg/tencentim/rest_client_real_test.go
Normal file
151
pkg/tencentim/rest_client_real_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 文件被修改后修复 checksum;staging/prod 必须关闭。
|
||||
allow_checksum_repair: true
|
||||
jwt_secret: "dev-shared-secret"
|
||||
access_token_ttl: "30m"
|
||||
refresh_token_ttl: "168h"
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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")))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
// 后台国家更新已经写入 users;IM 成员移除是外部副作用,失败不回滚资料,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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(¬ifications).Error; err != nil {
|
||||
if err := s.db.Where("read_at_ms IS NULL").Order("created_at_ms DESC").Limit(3).Find(¬ifications).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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
41
services/activity-service/internal/client/region_source.go
Normal file
41
services/activity-service/internal/client/region_source.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
432
services/activity-service/internal/service/broadcast/service.go
Normal file
432
services/activity-service/internal/service/broadcast/service.go
Normal 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 只接受已序列化 JSON;event_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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_configs,gateway 只读下发给 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: ""
|
||||
|
||||
@ -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_configs,gateway 只读下发给 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 控制台配置的回调地址,用于入群前、发言前等业务守卫。
|
||||
|
||||
@ -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_configs,gateway 只读下发给 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 UserSig;SDKAppID 和密钥必须与 room-service 保持一致。
|
||||
sdk_app_id: 20036101
|
||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||
admin_identifier: "900100100"
|
||||
# 客户端 UserSig 有效期;线上建议 12h-7d,按登录刷新策略决定。
|
||||
user_sig_ttl: "24h"
|
||||
# 腾讯云 IM 控制台回调 URL。需要公网可访问;本地为空表示暂不启用回调。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
27
services/gateway-service/internal/client/broadcast_client.go
Normal file
27
services/gateway-service/internal/client/broadcast_client.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user