资源撤回,系统消息
This commit is contained in:
parent
ccebdcb28a
commit
5a03a3d49a
@ -32,7 +32,7 @@ App 系统/活动消息:
|
|||||||
- Flutter 消息页固定展示腾讯云 IM 用户消息、后端系统消息和后端活动消息三个入口;系统/活动页通过 `gateway-service` 读取 `activity-service` 的 inbox,只按当前客户端能力展示标题、摘要/正文和时间。
|
- Flutter 消息页固定展示腾讯云 IM 用户消息、后端系统消息和后端活动消息三个入口;系统/活动页通过 `gateway-service` 读取 `activity-service` 的 inbox,只按当前客户端能力展示标题、摘要/正文和时间。
|
||||||
- `activity-service/internal/service/message` 是系统/活动消息 owner。单人消息使用 `CreateSystemNotice` / `CreateActivityNotice`,全服或范围通知使用 `CreateNoticeFanout` 创建 `message_fanout_jobs`,由 cron 触发 fanout worker 分批写入 `user_inbox_messages`。
|
- `activity-service/internal/service/message` 是系统/活动消息 owner。单人消息使用 `CreateSystemNotice` / `CreateActivityNotice`,全服或范围通知使用 `CreateNoticeFanout` 创建 `message_fanout_jobs`,由 cron 触发 fanout worker 分批写入 `user_inbox_messages`。
|
||||||
- 活动业务发通知时传 `producer_event_id` 或 `command_id` 作为幂等键,`message_type` 只允许 `system` 或 `activity`;跳转参数使用已有 `action_type` 白名单,例如 `activity_detail`、`room_detail`、`user_profile`、`app_h5`。
|
- 活动业务发通知时传 `producer_event_id` 或 `command_id` 作为幂等键,`message_type` 只允许 `system` 或 `activity`;跳转参数使用已有 `action_type` 白名单,例如 `activity_detail`、`room_detail`、`user_profile`、`app_h5`。
|
||||||
- `server/admin` 的运营管理/全服通知只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
|
- `server/admin` 的 APP配置/系统消息推送只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
|
||||||
|
|
||||||
## Time Model
|
## Time Model
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -80,6 +80,15 @@ message RankItem {
|
|||||||
int64 updated_at_ms = 4;
|
int64 updated_at_ms = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomContributionRankItem 表达房间内某个周期的送礼贡献用户排行项。
|
||||||
|
message RoomContributionRankItem {
|
||||||
|
int64 rank = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
int64 score = 3;
|
||||||
|
int64 gift_value = 4;
|
||||||
|
int64 updated_at_ms = 5;
|
||||||
|
}
|
||||||
|
|
||||||
// LuckyGiftDrawResult 是 SendGift 同步返回给当前送礼用户的幸运礼物抽奖表现。
|
// LuckyGiftDrawResult 是 SendGift 同步返回给当前送礼用户的幸运礼物抽奖表现。
|
||||||
message LuckyGiftDrawResult {
|
message LuckyGiftDrawResult {
|
||||||
bool enabled = 1;
|
bool enabled = 1;
|
||||||
@ -1276,6 +1285,26 @@ message ListRoomGiftLeaderboardResponse {
|
|||||||
int64 server_time_ms = 6;
|
int64 server_time_ms = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRoomContributionRankRequest 查询单房间内用户送礼贡献周期榜。
|
||||||
|
message ListRoomContributionRankRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string room_id = 2;
|
||||||
|
string period = 3;
|
||||||
|
int32 limit = 4;
|
||||||
|
int64 viewer_user_id = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoomContributionRankResponse 返回单房间内用户送礼贡献周期榜。
|
||||||
|
message ListRoomContributionRankResponse {
|
||||||
|
string room_id = 1;
|
||||||
|
string period = 2;
|
||||||
|
int64 start_at_ms = 3;
|
||||||
|
int64 end_at_ms = 4;
|
||||||
|
repeated RoomContributionRankItem items = 5;
|
||||||
|
int64 total = 6;
|
||||||
|
int64 server_time_ms = 7;
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyRoomRequest 查询当前用户自己创建的房间。
|
// GetMyRoomRequest 查询当前用户自己创建的房间。
|
||||||
// 该查询必须由 gateway 写入鉴权后的 owner_user_id,不能从客户端接收用户 ID。
|
// 该查询必须由 gateway 写入鉴权后的 owner_user_id,不能从客户端接收用户 ID。
|
||||||
message GetMyRoomRequest {
|
message GetMyRoomRequest {
|
||||||
@ -1475,6 +1504,7 @@ service RoomQueryService {
|
|||||||
rpc ListRoomsByOwners(ListRoomsByOwnersRequest) returns (ListRoomsResponse);
|
rpc ListRoomsByOwners(ListRoomsByOwnersRequest) returns (ListRoomsResponse);
|
||||||
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
|
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
|
||||||
rpc ListRoomGiftLeaderboard(ListRoomGiftLeaderboardRequest) returns (ListRoomGiftLeaderboardResponse);
|
rpc ListRoomGiftLeaderboard(ListRoomGiftLeaderboardRequest) returns (ListRoomGiftLeaderboardResponse);
|
||||||
|
rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse);
|
||||||
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
|
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
|
||||||
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
|
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
|
||||||
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
|
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
|
||||||
|
|||||||
@ -1573,6 +1573,7 @@ const (
|
|||||||
RoomQueryService_ListRoomsByOwners_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomsByOwners"
|
RoomQueryService_ListRoomsByOwners_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomsByOwners"
|
||||||
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
|
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
|
||||||
RoomQueryService_ListRoomGiftLeaderboard_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomGiftLeaderboard"
|
RoomQueryService_ListRoomGiftLeaderboard_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomGiftLeaderboard"
|
||||||
|
RoomQueryService_ListRoomContributionRank_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomContributionRank"
|
||||||
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
|
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
|
||||||
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
|
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
|
||||||
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
|
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
|
||||||
@ -1600,6 +1601,7 @@ type RoomQueryServiceClient interface {
|
|||||||
ListRoomsByOwners(ctx context.Context, in *ListRoomsByOwnersRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
ListRoomsByOwners(ctx context.Context, in *ListRoomsByOwnersRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
||||||
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
||||||
ListRoomGiftLeaderboard(ctx context.Context, in *ListRoomGiftLeaderboardRequest, opts ...grpc.CallOption) (*ListRoomGiftLeaderboardResponse, error)
|
ListRoomGiftLeaderboard(ctx context.Context, in *ListRoomGiftLeaderboardRequest, opts ...grpc.CallOption) (*ListRoomGiftLeaderboardResponse, error)
|
||||||
|
ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error)
|
||||||
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
|
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
|
||||||
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
|
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
|
||||||
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
|
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
|
||||||
@ -1737,6 +1739,16 @@ func (c *roomQueryServiceClient) ListRoomGiftLeaderboard(ctx context.Context, in
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *roomQueryServiceClient) ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListRoomContributionRankResponse)
|
||||||
|
err := c.cc.Invoke(ctx, RoomQueryService_ListRoomContributionRank_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *roomQueryServiceClient) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) {
|
func (c *roomQueryServiceClient) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(GetMyRoomResponse)
|
out := new(GetMyRoomResponse)
|
||||||
@ -1825,6 +1837,7 @@ type RoomQueryServiceServer interface {
|
|||||||
ListRoomsByOwners(context.Context, *ListRoomsByOwnersRequest) (*ListRoomsResponse, error)
|
ListRoomsByOwners(context.Context, *ListRoomsByOwnersRequest) (*ListRoomsResponse, error)
|
||||||
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
|
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
|
||||||
ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error)
|
ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error)
|
||||||
|
ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error)
|
||||||
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
|
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
|
||||||
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
|
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
|
||||||
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
|
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
|
||||||
@ -1878,6 +1891,9 @@ func (UnimplementedRoomQueryServiceServer) ListRoomFeeds(context.Context, *ListR
|
|||||||
func (UnimplementedRoomQueryServiceServer) ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error) {
|
func (UnimplementedRoomQueryServiceServer) ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListRoomGiftLeaderboard not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListRoomGiftLeaderboard not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedRoomQueryServiceServer) ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListRoomContributionRank not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedRoomQueryServiceServer) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) {
|
func (UnimplementedRoomQueryServiceServer) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetMyRoom not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetMyRoom not implemented")
|
||||||
}
|
}
|
||||||
@ -2136,6 +2152,24 @@ func _RoomQueryService_ListRoomGiftLeaderboard_Handler(srv interface{}, ctx cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _RoomQueryService_ListRoomContributionRank_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListRoomContributionRankRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(RoomQueryServiceServer).ListRoomContributionRank(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: RoomQueryService_ListRoomContributionRank_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(RoomQueryServiceServer).ListRoomContributionRank(ctx, req.(*ListRoomContributionRankRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _RoomQueryService_GetMyRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _RoomQueryService_GetMyRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(GetMyRoomRequest)
|
in := new(GetMyRoomRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -2317,6 +2351,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListRoomGiftLeaderboard",
|
MethodName: "ListRoomGiftLeaderboard",
|
||||||
Handler: _RoomQueryService_ListRoomGiftLeaderboard_Handler,
|
Handler: _RoomQueryService_ListRoomGiftLeaderboard_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListRoomContributionRank",
|
||||||
|
Handler: _RoomQueryService_ListRoomContributionRank_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetMyRoom",
|
MethodName: "GetMyRoom",
|
||||||
Handler: _RoomQueryService_GetMyRoom_Handler,
|
Handler: _RoomQueryService_GetMyRoom_Handler,
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
|
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
|
||||||
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
|
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
|
||||||
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
|
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
|
||||||
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`;region/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
|
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`;region/country/all_active_users/all_registered_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
|
||||||
|
|
||||||
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
|
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
|
||||||
|
|
||||||
@ -238,6 +238,7 @@ message_fanout_jobs(
|
|||||||
| `region` | 区域活动、区域公告 | user-service `users.region_id` |
|
| `region` | 区域活动、区域公告 | user-service `users.region_id` |
|
||||||
| `country` | 国家定向通知 | user-service `users.country` |
|
| `country` | 国家定向通知 | user-service `users.country` |
|
||||||
| `all_active_users` | 全 App 活跃用户 | user-service active users cursor |
|
| `all_active_users` | 全 App 活跃用户 | user-service active users cursor |
|
||||||
|
| `all_registered_users` | 全 App 注册用户 | user-service registered users cursor |
|
||||||
|
|
||||||
### Global Broadcast State
|
### Global Broadcast State
|
||||||
|
|
||||||
@ -527,7 +528,7 @@ tasks:
|
|||||||
3. 在 `activity-service` 增加 inbox domain/repository/service,先实现单用户入箱、列表、未读、已读、删除。
|
3. 在 `activity-service` 增加 inbox domain/repository/service,先实现单用户入箱、列表、未读、已读、删除。
|
||||||
4. 在 `gateway-service` 增加 activity/message client 和 App HTTP:`/api/v1/messages/tabs`、`/api/v1/messages`、`/api/v1/messages/{id}/read`、`/api/v1/messages/read-all`、`DELETE /api/v1/messages/{id}`。
|
4. 在 `gateway-service` 增加 activity/message client 和 App HTTP:`/api/v1/messages/tabs`、`/api/v1/messages`、`/api/v1/messages/{id}/read`、`/api/v1/messages/read-all`、`DELETE /api/v1/messages/{id}`。
|
||||||
5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
|
5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
|
||||||
6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region`、`country`、`user_ids`、`all_active_users`。
|
6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region`、`country`、`user_ids`、`all_active_users`、`all_registered_users`。
|
||||||
7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。
|
7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。
|
||||||
8. 增加 Redis 未读数缓存和缓存失效逻辑。
|
8. 增加 Redis 未读数缓存和缓存失效逻辑。
|
||||||
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。
|
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。
|
||||||
|
|||||||
@ -550,6 +550,7 @@ definitions:
|
|||||||
- region
|
- region
|
||||||
- country
|
- country
|
||||||
- all_active_users
|
- all_active_users
|
||||||
|
- all_registered_users
|
||||||
target_filter_json:
|
target_filter_json:
|
||||||
type: string
|
type: string
|
||||||
template_snapshot_json:
|
template_snapshot_json:
|
||||||
|
|||||||
@ -1223,6 +1223,56 @@ paths:
|
|||||||
$ref: "#/responses/Conflict"
|
$ref: "#/responses/Conflict"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/responses/UpstreamError"
|
$ref: "#/responses/UpstreamError"
|
||||||
|
/api/v1/rooms/{room_id}/contribution-rank:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- rooms
|
||||||
|
summary: 查询房内用户贡献周期榜
|
||||||
|
operationId: getRoomContributionRank
|
||||||
|
description: 房间内 day/week/month 三个 tab 使用的用户贡献榜;gateway 使用 access token user_id 作为 viewer,room-service 要求 viewer 仍在该房间。查询只读周期统计表,不刷新 presence。
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: room_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
pattern: "^[A-Za-z0-9_-]{1,48}$"
|
||||||
|
maxLength: 48
|
||||||
|
- name: period
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: day
|
||||||
|
enum:
|
||||||
|
- day
|
||||||
|
- week
|
||||||
|
- month
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
default: 20
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 返回当前房间指定周期的用户贡献榜和榜单用户展示资料。
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/RoomContributionRankEnvelope"
|
||||||
|
"400":
|
||||||
|
$ref: "#/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/responses/Conflict"
|
||||||
|
"502":
|
||||||
|
$ref: "#/responses/UpstreamError"
|
||||||
/api/v1/rooms/{room_id}/follow:
|
/api/v1/rooms/{room_id}/follow:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -4893,6 +4943,25 @@ definitions:
|
|||||||
updated_at_ms:
|
updated_at_ms:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
RoomContributionRankItemData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rank:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
score:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
gift_value:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
updated_at_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
profile:
|
||||||
|
$ref: "#/definitions/RoomUserProfileData"
|
||||||
RoomUserProfileData:
|
RoomUserProfileData:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -4983,6 +5052,48 @@ definitions:
|
|||||||
server_time_ms:
|
server_time_ms:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
RoomContributionRankData:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- room_id
|
||||||
|
- period
|
||||||
|
- start_at_ms
|
||||||
|
- end_at_ms
|
||||||
|
- total
|
||||||
|
- contribution_rank
|
||||||
|
- profiles
|
||||||
|
- server_time_ms
|
||||||
|
properties:
|
||||||
|
room_id:
|
||||||
|
type: string
|
||||||
|
period:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- day
|
||||||
|
- week
|
||||||
|
- month
|
||||||
|
start_at_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 周期开始 UTC epoch ms,包含。
|
||||||
|
end_at_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 周期结束 UTC epoch ms,不包含。
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
contribution_rank:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/RoomContributionRankItemData"
|
||||||
|
profiles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/RoomUserProfileData"
|
||||||
|
server_time_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
RoomFollowData:
|
RoomFollowData:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@ -5491,6 +5602,13 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: "#/definitions/RoomSnapshotData"
|
$ref: "#/definitions/RoomSnapshotData"
|
||||||
|
RoomContributionRankEnvelope:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/definitions/RoomContributionRankData"
|
||||||
RoomFollowEnvelope:
|
RoomFollowEnvelope:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
|||||||
@ -1039,6 +1039,7 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- all_active_users
|
- all_active_users
|
||||||
|
- all_registered_users
|
||||||
- region
|
- region
|
||||||
- country
|
- country
|
||||||
cursor_user_id:
|
cursor_user_id:
|
||||||
|
|||||||
@ -64,9 +64,9 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
|
|||||||
- 数据权限通过角色的 `data-scopes` 保存,并在后台用户列表/导出查询中执行。
|
- 数据权限通过角色的 `data-scopes` 保存,并在后台用户列表/导出查询中执行。
|
||||||
- 任务产物默认写入 `storage/exports`,通过 `/api/v1/jobs/:id/artifact` 下载。
|
- 任务产物默认写入 `storage/exports`,通过 `/api/v1/jobs/:id/artifact` 下载。
|
||||||
|
|
||||||
## Full Server Notice
|
## System Message Push
|
||||||
|
|
||||||
菜单入口:运营管理 / 全服通知,菜单 code 为 `operation-full-server-notice`,页面路径为 `/operations/full-server-notices`。
|
菜单入口:APP配置 / 系统消息推送,菜单 code 为 `operation-full-server-notice`,页面路径为 `/app-config/system-message-push`。
|
||||||
|
|
||||||
接口:
|
接口:
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
|
|||||||
POST /api/v1/admin/operations/full-server-notices/fanout
|
POST /api/v1/admin/operations/full-server-notices/fanout
|
||||||
```
|
```
|
||||||
|
|
||||||
参数只传后台需要的命令字段:`message_type` 为 `system` 或 `activity`,`target_scope` 支持 `all_active_users`、`single_user`、`user_ids`、`region`、`country`,内容字段为 `title`、`summary`、`body`,跳转字段为 `action_type`、`action_param`,扩展字段为 `metadata_json`。`command_id` 是幂等键,不传时由 admin-server 按管理员和当前时间生成。
|
参数只传后台需要的命令字段:`message_type` 为 `system` 或 `activity`,`target_scope` 支持 `all_registered_users`、`all_active_users`、`single_user`、`user_ids`、`region`、`country`,内容字段为 `title`、`summary`、`body`,跳转字段为 `action_type`、`action_param`,扩展字段为 `metadata_json`。`command_id` 是幂等键,不传时由 admin-server 按管理员和当前时间生成。
|
||||||
|
|
||||||
返回值包含 `job_id`、`status`、`created`、`command_id`、`message_type`、`target_scope`。接口只创建 activity-service 的 `message_fanout_jobs`,实际逐用户写入由 message fanout worker 分批完成。
|
返回值包含 `job_id`、`status`、`created`、`command_id`、`message_type`、`target_scope`。接口只创建 activity-service 的 `message_fanout_jobs`,实际逐用户写入由 message fanout worker 分批完成。
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
type CoinSellerRechargeBillQuery struct {
|
type CoinSellerRechargeBillQuery struct {
|
||||||
Keyword string
|
Keyword string
|
||||||
RegionID int64
|
RegionID int64
|
||||||
|
StatTZ string
|
||||||
StartMS int64
|
StartMS int64
|
||||||
EndMS int64
|
EndMS int64
|
||||||
Page int
|
Page int
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListCoinSellerRechargeBills 把 dashboard-cdc-worker 的 dealer_recharge 聚合事实暴露给 finance 充值流水。
|
||||||
|
// Yumi 当前只有按天/国家沉淀的币商充值金额,没有单笔订单号和币商用户 ID;这里生成稳定 synthetic id,
|
||||||
|
// 让列表、导出和区域筛选至少与总览/Databi 的金额口径完全一致。
|
||||||
|
func (s *MySQLExternalDashboardSource) ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, 0, fmt.Errorf("external dashboard source is not configured")
|
||||||
|
}
|
||||||
|
countryFilter, err := s.countryFilter(ctx, StatisticsQuery{RegionID: query.RegionID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if countryFilter.Applied && len(countryFilter.Codes) == 0 {
|
||||||
|
return []CoinSellerRechargeBill{}, 0, nil
|
||||||
|
}
|
||||||
|
requestTimezone := strings.TrimSpace(query.StatTZ)
|
||||||
|
if requestTimezone == "" {
|
||||||
|
requestTimezone = s.statTimezone
|
||||||
|
}
|
||||||
|
requestLocation, err := time.LoadLocation(requestTimezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("load external dashboard bill request timezone %s: %w", requestTimezone, err)
|
||||||
|
}
|
||||||
|
storageTimezone := s.statTimezone
|
||||||
|
if _, err := time.LoadLocation(storageTimezone); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("load external dashboard bill storage timezone %s: %w", storageTimezone, err)
|
||||||
|
}
|
||||||
|
startDate, endDate, _, _ := externalDashboardDateRange(StatisticsQuery{StartMS: query.StartMS, EndMS: query.EndMS}, requestLocation)
|
||||||
|
whereSQL, args := s.periodMetricWhere(storageTimezone, startDate, endDate, countryFilter)
|
||||||
|
whereSQL, args = s.appendCoinSellerBillKeyword(whereSQL, args, query.Keyword)
|
||||||
|
|
||||||
|
page := query.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := query.PageSize
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := s.db.QueryRowContext(queryCtx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT t.period_key, t.country_code
|
||||||
|
FROM country_dashboard_period_metric t
|
||||||
|
WHERE `+whereSQL+`
|
||||||
|
GROUP BY t.period_key, t.country_code
|
||||||
|
HAVING COALESCE(SUM(t.dealer_recharge), 0) <> 0
|
||||||
|
) bills`, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count external coin seller recharge bills for %s: %w", s.appCode, err)
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []CoinSellerRechargeBill{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(queryCtx, `
|
||||||
|
SELECT t.period_key, UPPER(t.country_code), MAX(t.country_name), CAST(COALESCE(SUM(t.dealer_recharge), 0) AS CHAR)
|
||||||
|
FROM country_dashboard_period_metric t
|
||||||
|
WHERE `+whereSQL+`
|
||||||
|
GROUP BY t.period_key, t.country_code
|
||||||
|
HAVING COALESCE(SUM(t.dealer_recharge), 0) <> 0
|
||||||
|
ORDER BY t.period_key DESC, t.country_code
|
||||||
|
LIMIT ? OFFSET ?`, append(args, pageSize, (page-1)*pageSize)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("query external coin seller recharge bills for %s: %w", s.appCode, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]CoinSellerRechargeBill, 0, pageSize)
|
||||||
|
for rows.Next() {
|
||||||
|
var periodKey string
|
||||||
|
var countryCode string
|
||||||
|
var countryName sql.NullString
|
||||||
|
var amountText string
|
||||||
|
if err := rows.Scan(&periodKey, &countryCode, &countryName, &amountText); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan external coin seller recharge bill for %s: %w", s.appCode, err)
|
||||||
|
}
|
||||||
|
amount, err := decimalTextToMinor(amountText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("parse external coin seller recharge amount for %s/%s/%s: %w", s.appCode, periodKey, countryCode, err)
|
||||||
|
}
|
||||||
|
createdAt, err := time.ParseInLocation("2006-01-02", periodKey, requestLocation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("parse external coin seller recharge day for %s/%s/%s: %w", s.appCode, periodKey, countryCode, err)
|
||||||
|
}
|
||||||
|
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||||
|
items = append(items, CoinSellerRechargeBill{
|
||||||
|
TransactionID: fmt.Sprintf("%s-dealer-recharge-%s-%s", s.appCode, periodKey, countryCode),
|
||||||
|
Source: "dealer_recharge",
|
||||||
|
CountryCode: countryCode,
|
||||||
|
USDMinorAmount: amount,
|
||||||
|
CreatedAtMS: createdAt.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("iterate external coin seller recharge bills for %s: %w", s.appCode, err)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MySQLExternalDashboardSource) appendCoinSellerBillKeyword(whereSQL string, args []any, rawKeyword string) (string, []any) {
|
||||||
|
keyword := strings.TrimSpace(rawKeyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return whereSQL, args
|
||||||
|
}
|
||||||
|
upperKeyword := strings.ToUpper(keyword)
|
||||||
|
return whereSQL + `
|
||||||
|
AND (CONCAT(?, '-dealer-recharge-', t.period_key, '-', UPPER(t.country_code)) = ?
|
||||||
|
OR UPPER(t.country_code) = ?
|
||||||
|
OR t.country_name LIKE ?)`, append(args, s.appCode, keyword, upperKeyword, "%"+keyword+"%")
|
||||||
|
}
|
||||||
@ -120,6 +120,65 @@ func TestMySQLExternalDashboardSourceStatisticsOverview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMySQLExternalDashboardSourceListCoinSellerRechargeBills(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AppCode: "Yumi",
|
||||||
|
AppName: "Yumi",
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
StatTimezone: "Asia/Riyadh",
|
||||||
|
RequestTimeout: time.Second,
|
||||||
|
})
|
||||||
|
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
start := time.Date(2026, 6, 7, 0, 0, 0, 0, shanghai)
|
||||||
|
end := time.Date(2026, 7, 7, 0, 0, 0, 0, shanghai)
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*FROM country_dashboard_period_metric t[\\s\\S]*HAVING COALESCE\\(SUM\\(t\\.dealer_recharge\\), 0\\) <> 0").
|
||||||
|
WithArgs("Asia/Riyadh", "LIKEI", "2026-06-07", "2026-07-07").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
|
||||||
|
mock.ExpectQuery("SELECT t\\.period_key, UPPER\\(t\\.country_code\\), MAX\\(t\\.country_name\\), CAST\\(COALESCE\\(SUM\\(t\\.dealer_recharge\\), 0\\) AS CHAR\\)[\\s\\S]*FROM country_dashboard_period_metric t").
|
||||||
|
WithArgs("Asia/Riyadh", "LIKEI", "2026-06-07", "2026-07-07", 20, 0).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"period_key", "country_code", "country_name", "dealer_recharge"}).
|
||||||
|
AddRow("2026-07-05", "SA", "Saudi Arabia", "13686.92").
|
||||||
|
AddRow("2026-07-04", "EG", "Egypt", "10.00"))
|
||||||
|
|
||||||
|
items, total, err := source.(CoinSellerRechargeBillSource).ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{
|
||||||
|
StatTZ: "Asia/Shanghai",
|
||||||
|
StartMS: start.UnixMilli(),
|
||||||
|
EndMS: end.UnixMilli(),
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 || len(items) != 2 {
|
||||||
|
t.Fatalf("unexpected result total=%d items=%+v", total, items)
|
||||||
|
}
|
||||||
|
if items[0].TransactionID != "yumi-dealer-recharge-2026-07-05-SA" || items[0].Source != "dealer_recharge" || items[0].CountryCode != "SA" {
|
||||||
|
t.Fatalf("unexpected first item identity: %+v", items[0])
|
||||||
|
}
|
||||||
|
if items[0].USDMinorAmount != 1_368_692 {
|
||||||
|
t.Fatalf("unexpected first item amount: %+v", items[0])
|
||||||
|
}
|
||||||
|
expectedCreatedAt := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai).UnixMilli()
|
||||||
|
if items[0].CreatedAtMS != expectedCreatedAt {
|
||||||
|
t.Fatalf("unexpected first item created_at: got %d want %d", items[0].CreatedAtMS, expectedCreatedAt)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalDashboardRetentionUsesNilWhenBaseMissing(t *testing.T) {
|
func TestExternalDashboardRetentionUsesNilWhenBaseMissing(t *testing.T) {
|
||||||
metric := externalDashboardMetric{
|
metric := externalDashboardMetric{
|
||||||
D1RetentionUsers: 0,
|
D1RetentionUsers: 0,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ const (
|
|||||||
targetScopeRegion = "region"
|
targetScopeRegion = "region"
|
||||||
targetScopeCountry = "country"
|
targetScopeCountry = "country"
|
||||||
targetScopeAllActiveUsers = "all_active_users"
|
targetScopeAllActiveUsers = "all_active_users"
|
||||||
|
targetScopeAllRegistered = "all_registered_users"
|
||||||
|
|
||||||
defaultProducerEventType = "admin_full_server_notice"
|
defaultProducerEventType = "admin_full_server_notice"
|
||||||
defaultBatchSize = 500
|
defaultBatchSize = 500
|
||||||
@ -43,30 +44,30 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fanoutRequest struct {
|
type fanoutRequest struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
MessageType string `json:"message_type"`
|
MessageType string `json:"message_type"`
|
||||||
TargetScope string `json:"target_scope"`
|
TargetScope string `json:"target_scope"`
|
||||||
TargetUserID int64 `json:"target_user_id"`
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
UserIDs []int64 `json:"user_ids"`
|
UserIDs int64List `json:"user_ids"`
|
||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
ProducerEventType string `json:"producer_event_type"`
|
ProducerEventType string `json:"producer_event_type"`
|
||||||
AggregateType string `json:"aggregate_type"`
|
AggregateType string `json:"aggregate_type"`
|
||||||
AggregateID string `json:"aggregate_id"`
|
AggregateID string `json:"aggregate_id"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
TemplateVersion string `json:"template_version"`
|
TemplateVersion string `json:"template_version"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
IconURL string `json:"icon_url"`
|
IconURL string `json:"icon_url"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
ActionType string `json:"action_type"`
|
ActionType string `json:"action_type"`
|
||||||
ActionParam string `json:"action_param"`
|
ActionParam string `json:"action_param"`
|
||||||
Priority int32 `json:"priority"`
|
Priority int32 `json:"priority"`
|
||||||
SentAtMS int64 `json:"sent_at_ms"`
|
SentAtMS int64 `json:"sent_at_ms"`
|
||||||
ExpireAtMS int64 `json:"expire_at_ms"`
|
ExpireAtMS int64 `json:"expire_at_ms"`
|
||||||
MetadataJSON string `json:"metadata_json"`
|
MetadataJSON string `json:"metadata_json"`
|
||||||
BatchSize int32 `json:"batch_size"`
|
BatchSize int32 `json:"batch_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type fanoutDTO struct {
|
type fanoutDTO struct {
|
||||||
@ -201,7 +202,7 @@ func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, er
|
|||||||
func buildTargetFilterJSON(req fanoutRequest) (string, error) {
|
func buildTargetFilterJSON(req fanoutRequest) (string, error) {
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
switch req.TargetScope {
|
switch req.TargetScope {
|
||||||
case targetScopeAllActiveUsers:
|
case targetScopeAllActiveUsers, targetScopeAllRegistered:
|
||||||
payload = map[string]any{}
|
payload = map[string]any{}
|
||||||
case targetScopeSingleUser:
|
case targetScopeSingleUser:
|
||||||
if req.TargetUserID <= 0 {
|
if req.TargetUserID <= 0 {
|
||||||
@ -209,7 +210,7 @@ func buildTargetFilterJSON(req fanoutRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
payload = map[string]any{"target_user_id": req.TargetUserID}
|
payload = map[string]any{"target_user_id": req.TargetUserID}
|
||||||
case targetScopeUserIDs:
|
case targetScopeUserIDs:
|
||||||
userIDs := positiveUniqueInt64s(req.UserIDs)
|
userIDs := positiveUniqueInt64s([]int64(req.UserIDs))
|
||||||
if len(userIDs) == 0 {
|
if len(userIDs) == 0 {
|
||||||
return "", fmt.Errorf("user_ids 不能为空")
|
return "", fmt.Errorf("user_ids 不能为空")
|
||||||
}
|
}
|
||||||
@ -268,13 +269,48 @@ func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
|
|||||||
|
|
||||||
func allowedTargetScope(scope string) bool {
|
func allowedTargetScope(scope string) bool {
|
||||||
switch scope {
|
switch scope {
|
||||||
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers:
|
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers, targetScopeAllRegistered:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type int64List []int64
|
||||||
|
|
||||||
|
func (values *int64List) UnmarshalJSON(data []byte) error {
|
||||||
|
var rawItems []json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &rawItems); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed := make([]int64, 0, len(rawItems))
|
||||||
|
for _, rawItem := range rawItems {
|
||||||
|
value, err := parseJSONInt64(rawItem)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed = append(parsed, value)
|
||||||
|
}
|
||||||
|
*values = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONInt64(raw json.RawMessage) (int64, error) {
|
||||||
|
text := strings.TrimSpace(string(raw))
|
||||||
|
if strings.HasPrefix(text, `"`) {
|
||||||
|
unquoted, err := strconv.Unquote(text)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(unquoted)
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(text, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
func positiveUniqueInt64s(values []int64) []int64 {
|
func positiveUniqueInt64s(values []int64) []int64 {
|
||||||
seen := make(map[int64]struct{}, len(values))
|
seen := make(map[int64]struct{}, len(values))
|
||||||
out := make([]int64, 0, len(values))
|
out := make([]int64, 0, len(values))
|
||||||
|
|||||||
@ -9,7 +9,7 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
|
|||||||
req, err := normalizeFanoutRequest(fanoutRequest{
|
req, err := normalizeFanoutRequest(fanoutRequest{
|
||||||
MessageType: "activity",
|
MessageType: "activity",
|
||||||
TargetScope: "user_ids",
|
TargetScope: "user_ids",
|
||||||
UserIDs: []int64{42, 43, 42, 0},
|
UserIDs: int64List{42, 43, 42, 0},
|
||||||
Title: "活动上线",
|
Title: "活动上线",
|
||||||
Summary: "新活动已上线。",
|
Summary: "新活动已上线。",
|
||||||
ActionType: "activity_detail",
|
ActionType: "activity_detail",
|
||||||
@ -38,6 +38,16 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFanoutUserIDsAcceptNumericStrings(t *testing.T) {
|
||||||
|
var userIDs int64List
|
||||||
|
if err := userIDs.UnmarshalJSON([]byte(`["325379237278126080","42"]`)); err != nil {
|
||||||
|
t.Fatalf("UnmarshalJSON failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(userIDs) != 2 || userIDs[0] != 325379237278126080 || userIDs[1] != 42 {
|
||||||
|
t.Fatalf("user_ids mismatch: %+v", userIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
|
func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
|
||||||
req, err := normalizeFanoutRequest(fanoutRequest{
|
req, err := normalizeFanoutRequest(fanoutRequest{
|
||||||
MessageType: "system",
|
MessageType: "system",
|
||||||
@ -53,3 +63,23 @@ func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
|
|||||||
t.Fatalf("country target without country should fail")
|
t.Fatalf("country target without country should fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFanoutJSONForAllRegisteredUsersScope(t *testing.T) {
|
||||||
|
req, err := normalizeFanoutRequest(fanoutRequest{
|
||||||
|
MessageType: "system",
|
||||||
|
TargetScope: "all_registered_users",
|
||||||
|
Title: "系统通知",
|
||||||
|
Summary: "所有注册用户可见。",
|
||||||
|
BatchSize: 500,
|
||||||
|
}, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeFanoutRequest failed: %v", err)
|
||||||
|
}
|
||||||
|
targetFilterJSON, err := buildTargetFilterJSON(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildTargetFilterJSON failed: %v", err)
|
||||||
|
}
|
||||||
|
if targetFilterJSON != `{}` {
|
||||||
|
t.Fatalf("target filter mismatch: %s", targetFilterJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ type legacyRechargeBillQuery struct {
|
|||||||
RegionID int64
|
RegionID int64
|
||||||
StartAtMS int64
|
StartAtMS int64
|
||||||
EndAtMS int64
|
EndAtMS int64
|
||||||
// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验;0 保持财务页默认中国时区。
|
// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验和聚合列表日期解释;0 保持财务页默认中国时区。
|
||||||
TzOffsetMinutes int32
|
TzOffsetMinutes int32
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
@ -281,6 +281,7 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
|
|||||||
rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{
|
rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{
|
||||||
Keyword: query.Keyword,
|
Keyword: query.Keyword,
|
||||||
RegionID: query.RegionID,
|
RegionID: query.RegionID,
|
||||||
|
StatTZ: legacyDashboardStatTimezone(legacySummaryTZOffset(query)),
|
||||||
StartMS: query.StartAtMS,
|
StartMS: query.StartAtMS,
|
||||||
EndMS: query.EndAtMS,
|
EndMS: query.EndAtMS,
|
||||||
Page: query.Page,
|
Page: query.Page,
|
||||||
|
|||||||
@ -284,7 +284,7 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
|
|||||||
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
|
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
|
||||||
t.Fatalf("paid facts mismatch: %+v", item)
|
t.Fatalf("paid facts mismatch: %+v", item)
|
||||||
}
|
}
|
||||||
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 {
|
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" {
|
||||||
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
|
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,18 +79,24 @@ func (h *Handler) ExportRechargeBills(c *gin.Context) {
|
|||||||
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
|
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
|
||||||
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
|
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
|
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
|
||||||
|
if tzOffsetMinutes == 0 {
|
||||||
|
// legacy 币商充值导出必须和页面列表使用同一个自然日解释方式。
|
||||||
|
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
|
||||||
|
}
|
||||||
if source, ok := h.billSources[appCode]; ok {
|
if source, ok := h.billSources[appCode]; ok {
|
||||||
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
|
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
|
||||||
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
|
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
|
||||||
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
||||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||||
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||||
Page: page,
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
PageSize: rechargeBillExportPageSize,
|
Page: page,
|
||||||
|
PageSize: rechargeBillExportPageSize,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "导出账单失败")
|
response.ServerError(c, "导出账单失败")
|
||||||
|
|||||||
@ -52,15 +52,21 @@ type googleRechargePaidDTO struct {
|
|||||||
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
|
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
|
||||||
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
|
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
|
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
|
||||||
|
if tzOffsetMinutes == 0 {
|
||||||
|
// legacy 币商充值列表可能来自 dashboard 自然日聚合,默认沿用财务页中国时区。
|
||||||
|
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
|
||||||
|
}
|
||||||
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
||||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||||
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||||
Page: options.Page,
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
PageSize: options.PageSize,
|
Page: options.Page,
|
||||||
|
PageSize: options.PageSize,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取账单列表失败")
|
response.ServerError(c, "获取账单列表失败")
|
||||||
|
|||||||
@ -111,8 +111,8 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
|
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
|
||||||
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
|
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
|
||||||
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
|
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
|
||||||
{Name: "全服通知查看", Code: "full-server-notice:view", Kind: "menu"},
|
{Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"},
|
||||||
{Name: "全服通知发送", Code: "full-server-notice:send", Kind: "button"},
|
{Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"},
|
||||||
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||||
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
|
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
|
||||||
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
|
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
|
||||||
@ -301,6 +301,7 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &appConfigID, Title: "弹窗配置", Code: "app-config-popups", Path: "/app-config/popups", Icon: "image", PermissionCode: "app-config:view", Sort: 69, Visible: true},
|
{ParentID: &appConfigID, Title: "弹窗配置", Code: "app-config-popups", Path: "/app-config/popups", Icon: "image", PermissionCode: "app-config:view", Sort: 69, Visible: true},
|
||||||
{ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 70, Visible: true},
|
{ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 72, Visible: true},
|
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 72, Visible: true},
|
||||||
|
{ParentID: &appConfigID, Title: "系统消息推送", Code: "operation-full-server-notice", Path: "/app-config/system-message-push", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 73, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
|
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
|
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true},
|
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true},
|
||||||
@ -313,7 +314,6 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true},
|
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true},
|
||||||
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true},
|
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true},
|
||||||
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true},
|
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true},
|
||||||
{ParentID: &operationsID, Title: "全服通知", Code: "operation-full-server-notice", Path: "/operations/full-server-notices", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 75, Visible: true},
|
|
||||||
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
|
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
|
||||||
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
|
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
|
||||||
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
|
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 将既有全服通知入口迁移到 APP 配置下,菜单 code 和权限 code 保持不变以兼容历史角色授权。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('系统消息推送查看', 'full-server-notice:view', 'menu', '', @now_ms, @now_ms),
|
||||||
|
('系统消息推送发送', 'full-server-notice:send', 'button', '', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, '系统消息推送', 'operation-full-server-notice', '/app-config/system-message-push', 'campaign', 'full-server-notice:view', 73, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'app-config'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
@ -19,7 +19,7 @@ func NewGRPCUserTargetSource(conn *grpc.ClientConn) *GRPCUserTargetSource {
|
|||||||
return &GRPCUserTargetSource{client: userv1.NewUserServiceClient(conn)}
|
return &GRPCUserTargetSource{client: userv1.NewUserServiceClient(conn)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFanoutUserIDs returns a stable user_id cursor page for region/country/all-active fanout scopes.
|
// ListFanoutUserIDs returns a stable user_id cursor page for user-service backed fanout scopes.
|
||||||
func (s *GRPCUserTargetSource) ListFanoutUserIDs(ctx context.Context, query messageservice.TargetQuery) ([]int64, int64, bool, error) {
|
func (s *GRPCUserTargetSource) ListFanoutUserIDs(ctx context.Context, query messageservice.TargetQuery) ([]int64, int64, bool, error) {
|
||||||
resp, err := s.client.ListUserIDs(ctx, &userv1.ListUserIDsRequest{
|
resp, err := s.client.ListUserIDs(ctx, &userv1.ListUserIDsRequest{
|
||||||
Meta: &userv1.RequestMeta{
|
Meta: &userv1.RequestMeta{
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const (
|
|||||||
TargetScopeRegion = "region"
|
TargetScopeRegion = "region"
|
||||||
TargetScopeCountry = "country"
|
TargetScopeCountry = "country"
|
||||||
TargetScopeAllActiveUsers = "all_active_users"
|
TargetScopeAllActiveUsers = "all_active_users"
|
||||||
|
TargetScopeAllRegistered = "all_registered_users"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TabSection is the message tab summary returned to App clients.
|
// TabSection is the message tab summary returned to App clients.
|
||||||
|
|||||||
@ -153,7 +153,7 @@ func (s *Service) fanoutTargetPage(ctx context.Context, job messagedomain.Fanout
|
|||||||
return []int64{userID}, userID, true, nil
|
return []int64{userID}, userID, true, nil
|
||||||
case messagedomain.TargetScopeUserIDs:
|
case messagedomain.TargetScopeUserIDs:
|
||||||
return userIDsTargetPage(target.UserIDs, job.CursorUserID, pageSize)
|
return userIDsTargetPage(target.UserIDs, job.CursorUserID, pageSize)
|
||||||
case messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers:
|
case messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
|
||||||
if s.targetSource == nil {
|
if s.targetSource == nil {
|
||||||
return nil, job.CursorUserID, false, xerr.New(xerr.Unavailable, "fanout target source is not configured")
|
return nil, job.CursorUserID, false, xerr.New(xerr.Unavailable, "fanout target source is not configured")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,7 +170,7 @@ func noticeTargetFilterJSON(cmd NoticeFanoutCommand) (string, error) {
|
|||||||
return "", xerr.New(xerr.InvalidArgument, "country is required")
|
return "", xerr.New(xerr.InvalidArgument, "country is required")
|
||||||
}
|
}
|
||||||
payload = map[string]any{"country": country}
|
payload = map[string]any{"country": country}
|
||||||
case messagedomain.TargetScopeAllActiveUsers:
|
case messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
|
||||||
payload = map[string]any{}
|
payload = map[string]any{}
|
||||||
default:
|
default:
|
||||||
return "", xerr.New(xerr.InvalidArgument, "target_scope is invalid")
|
return "", xerr.New(xerr.InvalidArgument, "target_scope is invalid")
|
||||||
|
|||||||
@ -474,7 +474,7 @@ func allowedActionType(actionType string) bool {
|
|||||||
|
|
||||||
func allowedFanoutScope(scope string) bool {
|
func allowedFanoutScope(scope string) bool {
|
||||||
switch scope {
|
switch scope {
|
||||||
case messagedomain.TargetScopeSingleUser, messagedomain.TargetScopeUserIDs, messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers:
|
case messagedomain.TargetScopeSingleUser, messagedomain.TargetScopeUserIDs, messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -293,3 +293,53 @@ func TestFanoutWorkerMaterializesUserIDTargetsByCursor(t *testing.T) {
|
|||||||
t.Fatalf("last user inbox mismatch: items=%+v err=%v", items, err)
|
t.Fatalf("last user inbox mismatch: items=%+v err=%v", items, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFanoutWorkerMaterializesAllRegisteredUsersViaTargetSource(t *testing.T) {
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
targets := &fakeFanoutTargetSource{userIDs: []int64{42, 43}}
|
||||||
|
now := time.UnixMilli(1_800_000_040_000)
|
||||||
|
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository, messageservice.WithTargetSource(targets))
|
||||||
|
svc.SetClock(func() time.Time { return now })
|
||||||
|
|
||||||
|
job, created, err := svc.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{
|
||||||
|
CommandID: "cmd-fanout-registered-1",
|
||||||
|
MessageType: messagedomain.SectionSystem,
|
||||||
|
TargetScope: messagedomain.TargetScopeAllRegistered,
|
||||||
|
TargetFilterJSON: `{}`,
|
||||||
|
TemplateSnapshotJSON: `{"title":"系统通知","summary":"所有注册用户可见"}`,
|
||||||
|
BatchSize: 500,
|
||||||
|
CreatedBy: "admin:1",
|
||||||
|
})
|
||||||
|
if err != nil || !created || job.TargetScope != messagedomain.TargetScopeAllRegistered {
|
||||||
|
t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
processed, err := svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 500, MaxRetry: 3, LockTTL: time.Minute})
|
||||||
|
if err != nil || !processed {
|
||||||
|
t.Fatalf("fanout batch failed: processed=%v err=%v", processed, err)
|
||||||
|
}
|
||||||
|
if len(targets.queries) != 1 || targets.queries[0].TargetScope != messagedomain.TargetScopeAllRegistered {
|
||||||
|
t.Fatalf("target source query mismatch: %+v", targets.queries)
|
||||||
|
}
|
||||||
|
for _, userID := range []int64{42, 43} {
|
||||||
|
items, _, err := svc.ListInboxMessages(ctx, userID, messagedomain.SectionSystem, 20, "")
|
||||||
|
if err != nil || len(items) != 1 || items[0].Title != "系统通知" {
|
||||||
|
t.Fatalf("user %d inbox mismatch: items=%+v err=%v", userID, items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeFanoutTargetSource struct {
|
||||||
|
queries []messageservice.TargetQuery
|
||||||
|
userIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeFanoutTargetSource) ListFanoutUserIDs(_ context.Context, query messageservice.TargetQuery) ([]int64, int64, bool, error) {
|
||||||
|
s.queries = append(s.queries, query)
|
||||||
|
if len(s.userIDs) == 0 {
|
||||||
|
return nil, query.CursorUserID, true, nil
|
||||||
|
}
|
||||||
|
nextCursor := s.userIDs[len(s.userIDs)-1]
|
||||||
|
return append([]int64(nil), s.userIDs...), nextCursor, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ type RoomQueryClient interface {
|
|||||||
ListRoomsByOwners(ctx context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error)
|
ListRoomsByOwners(ctx context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error)
|
||||||
ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error)
|
ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error)
|
||||||
ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error)
|
ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error)
|
||||||
|
ListRoomContributionRank(ctx context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error)
|
||||||
GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error)
|
GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error)
|
||||||
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
|
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
|
||||||
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
|
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
|
||||||
@ -210,6 +211,10 @@ func (c *grpcRoomQueryClient) ListRoomGiftLeaderboard(ctx context.Context, req *
|
|||||||
return c.client.ListRoomGiftLeaderboard(ctx, req)
|
return c.client.ListRoomGiftLeaderboard(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *grpcRoomQueryClient) ListRoomContributionRank(ctx context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
return c.client.ListRoomContributionRank(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *grpcRoomQueryClient) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
func (c *grpcRoomQueryClient) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||||
return c.client.GetMyRoom(ctx, req)
|
return c.client.GetMyRoom(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -195,6 +195,10 @@ func (f *fakeUserLeaderboardRoomQueryClient) ListRoomGiftLeaderboard(context.Con
|
|||||||
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserLeaderboardRoomQueryClient) ListRoomContributionRank(context.Context, *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
return &roomv1.ListRoomContributionRankResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeUserLeaderboardRoomQueryClient) GetMyRoom(context.Context, *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
func (f *fakeUserLeaderboardRoomQueryClient) GetMyRoom(context.Context, *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||||
return &roomv1.GetMyRoomResponse{}, nil
|
return &roomv1.GetMyRoomResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -152,43 +152,44 @@ type ManagerHandlers struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RoomHandlers struct {
|
type RoomHandlers struct {
|
||||||
GetMyRoom http.HandlerFunc
|
GetMyRoom http.HandlerFunc
|
||||||
ListRoomFeeds http.HandlerFunc
|
ListRoomFeeds http.HandlerFunc
|
||||||
ListRooms http.HandlerFunc
|
ListRooms http.HandlerFunc
|
||||||
GetCurrentRoom http.HandlerFunc
|
GetCurrentRoom http.HandlerFunc
|
||||||
GetRoomSnapshot http.HandlerFunc
|
GetRoomSnapshot http.HandlerFunc
|
||||||
GetRoomDetail http.HandlerFunc
|
GetRoomDetail http.HandlerFunc
|
||||||
GetRoomShare http.HandlerFunc
|
GetRoomShare http.HandlerFunc
|
||||||
GetRoomShareLanding http.HandlerFunc
|
GetRoomShareLanding http.HandlerFunc
|
||||||
ListRoomOnlineUsers http.HandlerFunc
|
ListRoomOnlineUsers http.HandlerFunc
|
||||||
ListRoomBannedUsers http.HandlerFunc
|
ListRoomBannedUsers http.HandlerFunc
|
||||||
GetRoomGiftPanel http.HandlerFunc
|
GetRoomContributionRank http.HandlerFunc
|
||||||
GetRoomRocket http.HandlerFunc
|
GetRoomGiftPanel http.HandlerFunc
|
||||||
FollowRoom http.HandlerFunc
|
GetRoomRocket http.HandlerFunc
|
||||||
CreateRoom http.HandlerFunc
|
FollowRoom http.HandlerFunc
|
||||||
UpdateRoomProfile http.HandlerFunc
|
CreateRoom http.HandlerFunc
|
||||||
SaveRoomBackground http.HandlerFunc
|
UpdateRoomProfile http.HandlerFunc
|
||||||
ListRoomBackgrounds http.HandlerFunc
|
SaveRoomBackground http.HandlerFunc
|
||||||
SetRoomBackground http.HandlerFunc
|
ListRoomBackgrounds http.HandlerFunc
|
||||||
JoinRoom http.HandlerFunc
|
SetRoomBackground http.HandlerFunc
|
||||||
RoomHeartbeat http.HandlerFunc
|
JoinRoom http.HandlerFunc
|
||||||
LeaveRoom http.HandlerFunc
|
RoomHeartbeat http.HandlerFunc
|
||||||
CloseRoom http.HandlerFunc
|
LeaveRoom http.HandlerFunc
|
||||||
MicUp http.HandlerFunc
|
CloseRoom http.HandlerFunc
|
||||||
MicDown http.HandlerFunc
|
MicUp http.HandlerFunc
|
||||||
ChangeMicSeat http.HandlerFunc
|
MicDown http.HandlerFunc
|
||||||
ConfirmMicPublishing http.HandlerFunc
|
ChangeMicSeat http.HandlerFunc
|
||||||
MicHeartbeat http.HandlerFunc
|
ConfirmMicPublishing http.HandlerFunc
|
||||||
SetMicMute http.HandlerFunc
|
MicHeartbeat http.HandlerFunc
|
||||||
SetMicSeatLock http.HandlerFunc
|
SetMicMute http.HandlerFunc
|
||||||
SetChatEnabled http.HandlerFunc
|
SetMicSeatLock http.HandlerFunc
|
||||||
SetRoomPassword http.HandlerFunc
|
SetChatEnabled http.HandlerFunc
|
||||||
SetRoomAdmin http.HandlerFunc
|
SetRoomPassword http.HandlerFunc
|
||||||
MuteUser http.HandlerFunc
|
SetRoomAdmin http.HandlerFunc
|
||||||
KickUser http.HandlerFunc
|
MuteUser http.HandlerFunc
|
||||||
UnbanUser http.HandlerFunc
|
KickUser http.HandlerFunc
|
||||||
SendGift http.HandlerFunc
|
UnbanUser http.HandlerFunc
|
||||||
SendGiftBatch http.HandlerFunc
|
SendGift http.HandlerFunc
|
||||||
|
SendGiftBatch http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandlers struct {
|
type MessageHandlers struct {
|
||||||
@ -517,6 +518,7 @@ func (r routes) registerRoomRoutes() {
|
|||||||
r.profile("/rooms/{room_id}/share", http.MethodGet, h.GetRoomShare)
|
r.profile("/rooms/{room_id}/share", http.MethodGet, h.GetRoomShare)
|
||||||
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
|
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
|
||||||
r.profile("/rooms/{room_id}/banned-users", http.MethodGet, h.ListRoomBannedUsers)
|
r.profile("/rooms/{room_id}/banned-users", http.MethodGet, h.ListRoomBannedUsers)
|
||||||
|
r.profile("/rooms/{room_id}/contribution-rank", http.MethodGet, h.GetRoomContributionRank)
|
||||||
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
|
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
|
||||||
r.profile("/rooms/{room_id}/rocket", http.MethodGet, h.GetRoomRocket)
|
r.profile("/rooms/{room_id}/rocket", http.MethodGet, h.GetRoomRocket)
|
||||||
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
|
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
|
||||||
|
|||||||
@ -382,36 +382,39 @@ type fakeUserIdentityClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeRoomQueryClient struct {
|
type fakeRoomQueryClient struct {
|
||||||
lastList *roomv1.ListRoomsRequest
|
lastList *roomv1.ListRoomsRequest
|
||||||
lastListOwners *roomv1.ListRoomsByOwnersRequest
|
lastListOwners *roomv1.ListRoomsByOwnersRequest
|
||||||
lastFeeds *roomv1.ListRoomFeedsRequest
|
lastFeeds *roomv1.ListRoomFeedsRequest
|
||||||
lastMyRoom *roomv1.GetMyRoomRequest
|
lastMyRoom *roomv1.GetMyRoomRequest
|
||||||
lastCurrent *roomv1.GetCurrentRoomRequest
|
lastCurrent *roomv1.GetCurrentRoomRequest
|
||||||
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
||||||
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
|
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
|
||||||
lastRocket *roomv1.GetRoomRocketRequest
|
lastRocket *roomv1.GetRoomRocketRequest
|
||||||
lastOnline *roomv1.ListRoomOnlineUsersRequest
|
lastOnline *roomv1.ListRoomOnlineUsersRequest
|
||||||
lastBannedUsers *roomv1.ListRoomBannedUsersRequest
|
lastBannedUsers *roomv1.ListRoomBannedUsersRequest
|
||||||
resp *roomv1.ListRoomsResponse
|
lastContributionRank *roomv1.ListRoomContributionRankRequest
|
||||||
ownersResp *roomv1.ListRoomsResponse
|
resp *roomv1.ListRoomsResponse
|
||||||
feedsResp *roomv1.ListRoomsResponse
|
ownersResp *roomv1.ListRoomsResponse
|
||||||
myRoomResp *roomv1.GetMyRoomResponse
|
feedsResp *roomv1.ListRoomsResponse
|
||||||
currentResp *roomv1.GetCurrentRoomResponse
|
myRoomResp *roomv1.GetMyRoomResponse
|
||||||
snapshotResp *roomv1.GetRoomSnapshotResponse
|
currentResp *roomv1.GetCurrentRoomResponse
|
||||||
backgroundsResp *roomv1.ListRoomBackgroundsResponse
|
snapshotResp *roomv1.GetRoomSnapshotResponse
|
||||||
rocketResp *roomv1.GetRoomRocketResponse
|
backgroundsResp *roomv1.ListRoomBackgroundsResponse
|
||||||
onlineResp *roomv1.ListRoomOnlineUsersResponse
|
rocketResp *roomv1.GetRoomRocketResponse
|
||||||
bannedUsersResp *roomv1.ListRoomBannedUsersResponse
|
onlineResp *roomv1.ListRoomOnlineUsersResponse
|
||||||
err error
|
bannedUsersResp *roomv1.ListRoomBannedUsersResponse
|
||||||
ownersErr error
|
contributionRankResp *roomv1.ListRoomContributionRankResponse
|
||||||
feedsErr error
|
err error
|
||||||
myRoomErr error
|
ownersErr error
|
||||||
currentErr error
|
feedsErr error
|
||||||
snapshotErr error
|
myRoomErr error
|
||||||
backgroundsErr error
|
currentErr error
|
||||||
rocketErr error
|
snapshotErr error
|
||||||
onlineErr error
|
backgroundsErr error
|
||||||
bannedUsersErr error
|
rocketErr error
|
||||||
|
onlineErr error
|
||||||
|
bannedUsersErr error
|
||||||
|
contributionRankErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeUserDeviceClient struct {
|
type fakeUserDeviceClient struct {
|
||||||
@ -1285,6 +1288,17 @@ func (f *fakeRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.L
|
|||||||
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRoomQueryClient) ListRoomContributionRank(_ context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
f.lastContributionRank = req
|
||||||
|
if f.contributionRankErr != nil {
|
||||||
|
return nil, f.contributionRankErr
|
||||||
|
}
|
||||||
|
if f.contributionRankResp != nil {
|
||||||
|
return f.contributionRankResp, nil
|
||||||
|
}
|
||||||
|
return &roomv1.ListRoomContributionRankResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRoomQueryClient) GetMyRoom(_ context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
func (f *fakeRoomQueryClient) GetMyRoom(_ context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||||
f.lastMyRoom = req
|
f.lastMyRoom = req
|
||||||
if f.myRoomErr != nil {
|
if f.myRoomErr != nil {
|
||||||
@ -4372,6 +4386,127 @@ func TestListRoomBannedUsersIncludesProfileCountryAndUnbanTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetRoomContributionRankIncludesProfiles(t *testing.T) {
|
||||||
|
queryClient := &fakeRoomQueryClient{contributionRankResp: &roomv1.ListRoomContributionRankResponse{
|
||||||
|
RoomId: "room-rank",
|
||||||
|
Period: "week",
|
||||||
|
StartAtMs: 1_780_000_000_000,
|
||||||
|
EndAtMs: 1_780_604_800_000,
|
||||||
|
Total: 1,
|
||||||
|
ServerTimeMs: 1_780_000_010_000,
|
||||||
|
Items: []*roomv1.RoomContributionRankItem{
|
||||||
|
{Rank: 1, UserId: 43, Score: 900, GiftValue: 900, UpdatedAtMs: 1_780_000_001_000},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
profileClient := &fakeUserProfileClient{}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||||
|
handler.SetRoomQueryClient(queryClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank?period=week&limit=10", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-room-contribution-rank")
|
||||||
|
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 queryClient.lastContributionRank == nil || queryClient.lastContributionRank.GetRoomId() != "room-rank" || queryClient.lastContributionRank.GetPeriod() != "week" || queryClient.lastContributionRank.GetLimit() != 10 || queryClient.lastContributionRank.GetViewerUserId() != 42 {
|
||||||
|
t.Fatalf("contribution rank request mismatch: %+v", queryClient.lastContributionRank)
|
||||||
|
}
|
||||||
|
if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "43" {
|
||||||
|
t.Fatalf("contribution rank must hydrate ranked users: %+v", profileClient.lastBatch)
|
||||||
|
}
|
||||||
|
var response httpkit.ResponseEnvelope
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("decode contribution rank response failed: %v", err)
|
||||||
|
}
|
||||||
|
data, ok := response.Data.(map[string]any)
|
||||||
|
items, itemsOK := data["contribution_rank"].([]any)
|
||||||
|
profiles, profilesOK := data["profiles"].([]any)
|
||||||
|
if response.Code != httpkit.CodeOK || response.RequestID == "" || response.RequestID != recorder.Header().Get("X-Request-ID") || !ok || !itemsOK || len(items) != 1 || !profilesOK || len(profiles) != 1 {
|
||||||
|
t.Fatalf("contribution rank response mismatch: %+v", response)
|
||||||
|
}
|
||||||
|
if data["room_id"] != "room-rank" || data["period"] != "week" || data["start_at_ms"] != float64(1_780_000_000_000) || data["end_at_ms"] != float64(1_780_604_800_000) || data["total"] != float64(1) || data["server_time_ms"] != float64(1_780_000_010_000) {
|
||||||
|
t.Fatalf("contribution rank metadata mismatch: %+v", data)
|
||||||
|
}
|
||||||
|
first := items[0].(map[string]any)
|
||||||
|
if first["rank"] != float64(1) || first["user_id"] != "43" || first["score"] != float64(900) || first["gift_value"] != float64(900) || first["updated_at_ms"] != float64(1_780_000_001_000) {
|
||||||
|
t.Fatalf("rank item mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
profile, ok := first["profile"].(map[string]any)
|
||||||
|
if !ok || profile["username"] != "user-43" || profile["avatar"] != "https://cdn.example/avatar.png" {
|
||||||
|
t.Fatalf("rank item profile mismatch: %+v", first["profile"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRoomContributionRankDefaultsPeriod(t *testing.T) {
|
||||||
|
queryClient := &fakeRoomQueryClient{contributionRankResp: &roomv1.ListRoomContributionRankResponse{
|
||||||
|
RoomId: "room-rank",
|
||||||
|
Period: "day",
|
||||||
|
StartAtMs: 1_780_000_000_000,
|
||||||
|
EndAtMs: 1_780_086_400_000,
|
||||||
|
ServerTimeMs: 1_780_000_010_000,
|
||||||
|
}}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetRoomQueryClient(queryClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
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 queryClient.lastContributionRank == nil || queryClient.lastContributionRank.GetPeriod() != "" || queryClient.lastContributionRank.GetLimit() != 20 {
|
||||||
|
t.Fatalf("default contribution rank request mismatch: %+v", queryClient.lastContributionRank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRoomContributionRankRejectsInvalidPeriod(t *testing.T) {
|
||||||
|
queryClient := &fakeRoomQueryClient{}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetRoomQueryClient(queryClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank?period=year", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-room-contribution-rank-invalid")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-contribution-rank-invalid")
|
||||||
|
if queryClient.lastContributionRank != nil {
|
||||||
|
t.Fatalf("invalid period must not call room-service: %+v", queryClient.lastContributionRank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRoomContributionRankRejectsInvalidLimit(t *testing.T) {
|
||||||
|
tests := []string{"0", "-1", "abc"}
|
||||||
|
for _, limit := range tests {
|
||||||
|
t.Run(limit, func(t *testing.T) {
|
||||||
|
queryClient := &fakeRoomQueryClient{}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetRoomQueryClient(queryClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank?period=day&limit="+limit, nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-room-contribution-rank-limit")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-contribution-rank-limit")
|
||||||
|
if queryClient.lastContributionRank != nil {
|
||||||
|
t.Fatalf("invalid limit must not call room-service: %+v", queryClient.lastContributionRank)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
|
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
|
||||||
queryClient := &fakeRoomQueryClient{}
|
queryClient := &fakeRoomQueryClient{}
|
||||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
@ -10092,6 +10227,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
|
|||||||
{name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`},
|
{name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`},
|
||||||
{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
|
{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
|
||||||
{name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
|
{name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
|
||||||
|
{name: "room_contribution_rank", method: http.MethodGet, path: "/api/v1/rooms/room-1/contribution-rank?period=day"},
|
||||||
{name: "mic_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","mic_session_id":"mic-1"}`},
|
{name: "mic_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","mic_session_id":"mic-1"}`},
|
||||||
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
|
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
|
||||||
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
|
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
|
||||||
|
|||||||
@ -66,43 +66,44 @@ func New(config Config) *Handler {
|
|||||||
|
|
||||||
func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||||
return httproutes.RoomHandlers{
|
return httproutes.RoomHandlers{
|
||||||
GetMyRoom: h.getMyRoom,
|
GetMyRoom: h.getMyRoom,
|
||||||
ListRoomFeeds: h.listRoomFeeds,
|
ListRoomFeeds: h.listRoomFeeds,
|
||||||
ListRooms: h.listRooms,
|
ListRooms: h.listRooms,
|
||||||
GetCurrentRoom: h.getCurrentRoom,
|
GetCurrentRoom: h.getCurrentRoom,
|
||||||
GetRoomSnapshot: h.getRoomSnapshot,
|
GetRoomSnapshot: h.getRoomSnapshot,
|
||||||
GetRoomDetail: h.getRoomDetail,
|
GetRoomDetail: h.getRoomDetail,
|
||||||
GetRoomShare: h.getRoomShare,
|
GetRoomShare: h.getRoomShare,
|
||||||
GetRoomShareLanding: h.getRoomShareLanding,
|
GetRoomShareLanding: h.getRoomShareLanding,
|
||||||
ListRoomOnlineUsers: h.listRoomOnlineUsers,
|
ListRoomOnlineUsers: h.listRoomOnlineUsers,
|
||||||
ListRoomBannedUsers: h.listRoomBannedUsers,
|
ListRoomBannedUsers: h.listRoomBannedUsers,
|
||||||
GetRoomGiftPanel: h.getRoomGiftPanel,
|
GetRoomContributionRank: h.getRoomContributionRank,
|
||||||
GetRoomRocket: h.getRoomRocket,
|
GetRoomGiftPanel: h.getRoomGiftPanel,
|
||||||
FollowRoom: h.handleRoomFollow,
|
GetRoomRocket: h.getRoomRocket,
|
||||||
CreateRoom: h.createRoom,
|
FollowRoom: h.handleRoomFollow,
|
||||||
UpdateRoomProfile: h.updateRoomProfile,
|
CreateRoom: h.createRoom,
|
||||||
SaveRoomBackground: h.saveRoomBackground,
|
UpdateRoomProfile: h.updateRoomProfile,
|
||||||
ListRoomBackgrounds: h.listRoomBackgrounds,
|
SaveRoomBackground: h.saveRoomBackground,
|
||||||
SetRoomBackground: h.setRoomBackground,
|
ListRoomBackgrounds: h.listRoomBackgrounds,
|
||||||
JoinRoom: h.joinRoom,
|
SetRoomBackground: h.setRoomBackground,
|
||||||
RoomHeartbeat: h.roomHeartbeat,
|
JoinRoom: h.joinRoom,
|
||||||
LeaveRoom: h.leaveRoom,
|
RoomHeartbeat: h.roomHeartbeat,
|
||||||
CloseRoom: h.closeRoom,
|
LeaveRoom: h.leaveRoom,
|
||||||
MicUp: h.micUp,
|
CloseRoom: h.closeRoom,
|
||||||
MicDown: h.micDown,
|
MicUp: h.micUp,
|
||||||
ChangeMicSeat: h.changeMicSeat,
|
MicDown: h.micDown,
|
||||||
ConfirmMicPublishing: h.confirmMicPublishing,
|
ChangeMicSeat: h.changeMicSeat,
|
||||||
MicHeartbeat: h.micHeartbeat,
|
ConfirmMicPublishing: h.confirmMicPublishing,
|
||||||
SetMicMute: h.setMicMute,
|
MicHeartbeat: h.micHeartbeat,
|
||||||
SetMicSeatLock: h.setMicSeatLock,
|
SetMicMute: h.setMicMute,
|
||||||
SetChatEnabled: h.setChatEnabled,
|
SetMicSeatLock: h.setMicSeatLock,
|
||||||
SetRoomPassword: h.setRoomPassword,
|
SetChatEnabled: h.setChatEnabled,
|
||||||
SetRoomAdmin: h.setRoomAdmin,
|
SetRoomPassword: h.setRoomPassword,
|
||||||
MuteUser: h.muteUser,
|
SetRoomAdmin: h.setRoomAdmin,
|
||||||
KickUser: h.kickUser,
|
MuteUser: h.muteUser,
|
||||||
UnbanUser: h.unbanUser,
|
KickUser: h.kickUser,
|
||||||
SendGift: h.sendGift,
|
UnbanUser: h.unbanUser,
|
||||||
SendGiftBatch: h.sendGiftBatch,
|
SendGift: h.sendGift,
|
||||||
|
SendGiftBatch: h.sendGiftBatch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -678,6 +678,49 @@ func (h *Handler) listRoomBannedUsers(writer http.ResponseWriter, request *http.
|
|||||||
httpkit.WriteOK(writer, request, roomBannedUsersDataFromProto(roomID, resp, h.roomDisplayProfileMap(request, userIDs)))
|
httpkit.WriteOK(writer, request, roomBannedUsersDataFromProto(roomID, resp, h.roomDisplayProfileMap(request, userIDs)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRoomContributionRank 返回房间内用户贡献周期榜。
|
||||||
|
func (h *Handler) getRoomContributionRank(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.roomQueryClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||||
|
if !roomid.ValidStringID(roomID) {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
period := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("period")))
|
||||||
|
switch period {
|
||||||
|
case "", "day", "week", "month":
|
||||||
|
// 空 period 在 room-service 收敛为 day,方便首屏默认 tab 不重复写参数。
|
||||||
|
default:
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, ok := httpkit.PositiveInt32Query(request, "limit", 20)
|
||||||
|
if !ok {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.roomQueryClient.ListRoomContributionRank(request.Context(), &roomv1.ListRoomContributionRankRequest{
|
||||||
|
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||||||
|
RoomId: roomID,
|
||||||
|
Period: period,
|
||||||
|
Limit: limit,
|
||||||
|
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userIDs := make([]int64, 0, len(resp.GetItems()))
|
||||||
|
for _, item := range resp.GetItems() {
|
||||||
|
userIDs = append(userIDs, item.GetUserId())
|
||||||
|
}
|
||||||
|
httpkit.WriteOK(writer, request, roomContributionRankDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
|
||||||
|
}
|
||||||
|
|
||||||
// getRoomGiftPanel 返回房间送礼面板初始化数据。
|
// getRoomGiftPanel 返回房间送礼面板初始化数据。
|
||||||
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.roomQueryClient == nil || h.walletClient == nil {
|
if h.roomQueryClient == nil || h.walletClient == nil {
|
||||||
|
|||||||
@ -285,6 +285,10 @@ func (c *shareRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.
|
|||||||
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *shareRoomQueryClient) ListRoomContributionRank(context.Context, *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
return &roomv1.ListRoomContributionRankResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *shareRoomQueryClient) GetMyRoom(context.Context, *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
func (c *shareRoomQueryClient) GetMyRoom(context.Context, *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||||
return &roomv1.GetMyRoomResponse{}, nil
|
return &roomv1.GetMyRoomResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,26 @@ type roomBannedUserData struct {
|
|||||||
Permanent bool `json:"permanent"`
|
Permanent bool `json:"permanent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type roomContributionRankData struct {
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
StartAtMS int64 `json:"start_at_ms"`
|
||||||
|
EndAtMS int64 `json:"end_at_ms"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
ContributionRank []roomContributionRankItemData `json:"contribution_rank"`
|
||||||
|
Profiles []roomDisplayProfileData `json:"profiles"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomContributionRankItemData struct {
|
||||||
|
Rank int64 `json:"rank"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
GiftValue int64 `json:"gift_value"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
||||||
|
Profile *roomDisplayProfileData `json:"profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type setRoomAdminData struct {
|
type setRoomAdminData struct {
|
||||||
Result roomCommandResultData `json:"result"`
|
Result roomCommandResultData `json:"result"`
|
||||||
TargetUserID string `json:"target_user_id"`
|
TargetUserID string `json:"target_user_id"`
|
||||||
@ -1078,6 +1098,43 @@ func roomBannedUsersDataFromProto(roomID string, resp *roomv1.ListRoomBannedUser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func roomContributionRankDataFromProto(resp *roomv1.ListRoomContributionRankResponse, profiles map[int64]roomDisplayProfileData) roomContributionRankData {
|
||||||
|
if resp == nil {
|
||||||
|
return roomContributionRankData{}
|
||||||
|
}
|
||||||
|
items := make([]roomContributionRankItemData, 0, len(resp.GetItems()))
|
||||||
|
for _, rankItem := range resp.GetItems() {
|
||||||
|
item := roomContributionRankItemData{
|
||||||
|
Rank: rankItem.GetRank(),
|
||||||
|
UserID: formatOptionalUserID(rankItem.GetUserId()),
|
||||||
|
Score: rankItem.GetScore(),
|
||||||
|
GiftValue: rankItem.GetGiftValue(),
|
||||||
|
UpdatedAtMS: rankItem.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
if profile, ok := profiles[rankItem.GetUserId()]; ok {
|
||||||
|
copied := profile
|
||||||
|
item.Profile = &copied
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
profileItems := make([]roomDisplayProfileData, 0, len(profiles))
|
||||||
|
for _, rankItem := range resp.GetItems() {
|
||||||
|
if profile, ok := profiles[rankItem.GetUserId()]; ok {
|
||||||
|
profileItems = append(profileItems, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roomContributionRankData{
|
||||||
|
RoomID: resp.GetRoomId(),
|
||||||
|
Period: resp.GetPeriod(),
|
||||||
|
StartAtMS: resp.GetStartAtMs(),
|
||||||
|
EndAtMS: resp.GetEndAtMs(),
|
||||||
|
Total: resp.GetTotal(),
|
||||||
|
ContributionRank: items,
|
||||||
|
Profiles: profileItems,
|
||||||
|
ServerTimeMS: resp.GetServerTimeMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func roomOnlineUsersFromLegacyUsers(users []*roomv1.RoomUser) []*roomv1.RoomOnlineUser {
|
func roomOnlineUsersFromLegacyUsers(users []*roomv1.RoomUser) []*roomv1.RoomOnlineUser {
|
||||||
items := make([]*roomv1.RoomOnlineUser, 0, len(users))
|
items := make([]*roomv1.RoomOnlineUser, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
|
|||||||
@ -94,6 +94,21 @@ CREATE TABLE IF NOT EXISTS room_user_gift_stats (
|
|||||||
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
|
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户送礼价值统计表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户送礼价值统计表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_user_contribution_period_stats (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
|
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
||||||
|
period VARCHAR(16) NOT NULL COMMENT '周期类型:day/week/month',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT '周期开始时间,UTC epoch ms,包含',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT '周期结束时间,UTC epoch ms,不包含',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||||
|
gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '当前周期内累计送礼贡献值',
|
||||||
|
last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '当前周期最近一次送礼时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, period, period_start_ms, user_id),
|
||||||
|
KEY idx_room_contribution_period_rank (app_code, room_id, period, period_start_ms, gift_value DESC, last_gift_at_ms DESC, user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户周期贡献榜统计表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS room_background_images (
|
CREATE TABLE IF NOT EXISTS room_background_images (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',
|
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS room_user_contribution_period_stats (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
|
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
||||||
|
period VARCHAR(16) NOT NULL COMMENT '周期类型:day/week/month',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT '周期开始时间,UTC epoch ms,包含',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT '周期结束时间,UTC epoch ms,不包含',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||||
|
gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '当前周期内累计送礼贡献值',
|
||||||
|
last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '当前周期最近一次送礼时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, period, period_start_ms, user_id),
|
||||||
|
KEY idx_room_contribution_period_rank (app_code, room_id, period, period_start_ms, gift_value DESC, last_gift_at_ms DESC, user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户周期贡献榜统计表';
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/roomid"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
roomContributionRankPeriodDay = "day"
|
||||||
|
roomContributionRankPeriodWeek = "week"
|
||||||
|
roomContributionRankPeriodMonth = "month"
|
||||||
|
|
||||||
|
defaultRoomContributionRankLimit = 20
|
||||||
|
maxRoomContributionRankLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomContributionRankQuery struct {
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
Period string
|
||||||
|
PeriodStartMS int64
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomContributionRankPage struct {
|
||||||
|
Items []RoomContributionRankEntry
|
||||||
|
Total int64
|
||||||
|
Period string
|
||||||
|
StartAtMS int64
|
||||||
|
EndAtMS int64
|
||||||
|
ServerTimeMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomContributionRankEntry struct {
|
||||||
|
Rank int64
|
||||||
|
UserID int64
|
||||||
|
Score int64
|
||||||
|
GiftValue int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoomContributionRank 读取单房间用户贡献周期榜。
|
||||||
|
// 查询只访问 MySQL 周期读模型,不扫描 Room Cell、command log 或 outbox。
|
||||||
|
func (s *Service) ListRoomContributionRank(ctx context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
roomID := strings.TrimSpace(req.GetRoomId())
|
||||||
|
viewerUserID := req.GetViewerUserId()
|
||||||
|
if !roomid.ValidStringID(roomID) {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
|
||||||
|
}
|
||||||
|
if viewerUserID <= 0 {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
|
||||||
|
}
|
||||||
|
period := normalizeRoomContributionRankPeriod(req.GetPeriod())
|
||||||
|
if period == "" {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "period is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 周期榜是房间内面板数据,必须确认 viewer 仍在该房间,避免用公开接口枚举任意房间用户贡献。
|
||||||
|
presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, viewerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists || presence.RoomID != roomID || presence.Status != roomPresenceStatusActive {
|
||||||
|
return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int(req.GetLimit())
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultRoomContributionRankLimit
|
||||||
|
}
|
||||||
|
if limit > maxRoomContributionRankLimit {
|
||||||
|
limit = maxRoomContributionRankLimit
|
||||||
|
}
|
||||||
|
now := s.clock.Now().UTC()
|
||||||
|
start, end := roomContributionRankWindow(period, now)
|
||||||
|
result, err := s.repository.ListRoomContributionRank(ctx, RoomContributionRankQuery{
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
RoomID: roomID,
|
||||||
|
Period: period,
|
||||||
|
PeriodStartMS: start.UnixMilli(),
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*roomv1.RoomContributionRankItem, 0, len(result.Items))
|
||||||
|
for _, item := range result.Items {
|
||||||
|
items = append(items, &roomv1.RoomContributionRankItem{
|
||||||
|
Rank: item.Rank,
|
||||||
|
UserId: item.UserID,
|
||||||
|
Score: item.Score,
|
||||||
|
GiftValue: item.GiftValue,
|
||||||
|
UpdatedAtMs: item.UpdatedAtMS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &roomv1.ListRoomContributionRankResponse{
|
||||||
|
RoomId: roomID,
|
||||||
|
Period: period,
|
||||||
|
StartAtMs: start.UnixMilli(),
|
||||||
|
EndAtMs: end.UnixMilli(),
|
||||||
|
Items: items,
|
||||||
|
Total: result.Total,
|
||||||
|
ServerTimeMs: now.UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomContributionPeriodStatIncrements(app string, roomID string, userID int64, giftValue int64, occurredMS int64) []RoomUserContributionPeriodStatIncrement {
|
||||||
|
if userID <= 0 || giftValue <= 0 || strings.TrimSpace(roomID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
occurredAt := time.UnixMilli(occurredMS).UTC()
|
||||||
|
if occurredMS <= 0 {
|
||||||
|
occurredAt = time.Now().UTC()
|
||||||
|
occurredMS = occurredAt.UnixMilli()
|
||||||
|
}
|
||||||
|
stats := make([]RoomUserContributionPeriodStatIncrement, 0, 3)
|
||||||
|
for _, period := range []string{roomContributionRankPeriodDay, roomContributionRankPeriodWeek, roomContributionRankPeriodMonth} {
|
||||||
|
start, end := roomContributionRankWindow(period, occurredAt)
|
||||||
|
stats = append(stats, RoomUserContributionPeriodStatIncrement{
|
||||||
|
AppCode: app,
|
||||||
|
RoomID: roomID,
|
||||||
|
Period: period,
|
||||||
|
PeriodStartMS: start.UnixMilli(),
|
||||||
|
PeriodEndMS: end.UnixMilli(),
|
||||||
|
UserID: userID,
|
||||||
|
GiftValue: giftValue,
|
||||||
|
LastGiftAtMS: occurredMS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRoomContributionRankPeriod(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "", roomContributionRankPeriodDay, "today", "daily":
|
||||||
|
return roomContributionRankPeriodDay
|
||||||
|
case roomContributionRankPeriodWeek, "weekly":
|
||||||
|
return roomContributionRankPeriodWeek
|
||||||
|
case roomContributionRankPeriodMonth, "monthly":
|
||||||
|
return roomContributionRankPeriodMonth
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomContributionRankWindow(period string, now time.Time) (time.Time, time.Time) {
|
||||||
|
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
switch period {
|
||||||
|
case roomContributionRankPeriodWeek:
|
||||||
|
weekday := int(dayStart.Weekday())
|
||||||
|
if weekday == 0 {
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
start := dayStart.AddDate(0, 0, 1-weekday)
|
||||||
|
return start, start.AddDate(0, 0, 7)
|
||||||
|
case roomContributionRankPeriodMonth:
|
||||||
|
start := time.Date(dayStart.Year(), dayStart.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
return start, start.AddDate(0, 1, 0)
|
||||||
|
default:
|
||||||
|
return dayStart, dayStart.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -434,6 +434,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
|
|||||||
CoinSpent: roomGiftLeaderboardValue(f.billing),
|
CoinSpent: roomGiftLeaderboardValue(f.billing),
|
||||||
OccurredAtMS: now.UnixMilli(),
|
OccurredAtMS: now.UnixMilli(),
|
||||||
},
|
},
|
||||||
|
roomUserContributionPeriodStats: roomContributionPeriodStatIncrements(appcode.FromContext(f.ctx), current.RoomID, f.cmd.ActorUserID(), f.heatValue, now.UnixMilli()),
|
||||||
roomUserGiftStats: []RoomUserGiftStatIncrement{
|
roomUserGiftStats: []RoomUserGiftStatIncrement{
|
||||||
{
|
{
|
||||||
AppCode: appcode.FromContext(f.ctx),
|
AppCode: appcode.FromContext(f.ctx),
|
||||||
@ -492,6 +493,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
|
|||||||
if f.cmd.RobotGift {
|
if f.cmd.RobotGift {
|
||||||
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
|
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
|
||||||
result.roomGiftLeaderboard = nil
|
result.roomGiftLeaderboard = nil
|
||||||
|
result.roomUserContributionPeriodStats = nil
|
||||||
result.roomUserGiftStats = nil
|
result.roomUserGiftStats = nil
|
||||||
result.roomWeeklyContribution = nil
|
result.roomWeeklyContribution = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,23 @@ func TestSendGiftWritesRoomGiftLeaderboardWithHeatValue(t *testing.T) {
|
|||||||
if leaderboard.increments[0].CoinSpent != 10 {
|
if leaderboard.increments[0].CoinSpent != 10 {
|
||||||
t.Fatalf("leaderboard must use heat value after global ratio, got %+v", leaderboard.increments[0])
|
t.Fatalf("leaderboard must use heat value after global ratio, got %+v", leaderboard.increments[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rankResp, err := svc.ListRoomContributionRank(ctx, &roomv1.ListRoomContributionRankRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||||
|
RoomId: roomID,
|
||||||
|
Period: "day",
|
||||||
|
Limit: 10,
|
||||||
|
ViewerUserId: senderID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list room contribution rank failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rankResp.GetItems()) != 1 || rankResp.GetItems()[0].GetUserId() != senderID || rankResp.GetItems()[0].GetGiftValue() != 10 {
|
||||||
|
t.Fatalf("room contribution rank must use committed gift heat value: %+v", rankResp.GetItems())
|
||||||
|
}
|
||||||
|
if rankResp.GetPeriod() != "day" || rankResp.GetStartAtMs() <= 0 || rankResp.GetEndAtMs() <= rankResp.GetStartAtMs() {
|
||||||
|
t.Fatalf("room contribution rank period metadata mismatch: %+v", rankResp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRoomContributionUsesCurrentUTCWeek(t *testing.T) {
|
func TestAdminRoomContributionUsesCurrentUTCWeek(t *testing.T) {
|
||||||
@ -178,6 +195,52 @@ func TestAdminRoomContributionUsesCurrentUTCWeek(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoomContributionRankWeekUsesNaturalUTCWeek(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
||||||
|
BillingReceiptId: "receipt-rank-week-boundary",
|
||||||
|
CoinSpent: 100,
|
||||||
|
ChargeAmount: 100,
|
||||||
|
HeatValue: 10,
|
||||||
|
GiftTypeCode: "normal",
|
||||||
|
}}}
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 0, 30, 0, 0, time.UTC)}
|
||||||
|
svc := roomservice.New(roomservice.Config{
|
||||||
|
NodeID: "node-room-rank-week-boundary-test",
|
||||||
|
LeaseTTL: 10 * time.Second,
|
||||||
|
RankLimit: 20,
|
||||||
|
SnapshotEveryN: 1,
|
||||||
|
Clock: now,
|
||||||
|
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||||
|
|
||||||
|
roomID := "room-rank-week-boundary"
|
||||||
|
senderID := int64(18201)
|
||||||
|
targetID := int64(18202)
|
||||||
|
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||||
|
sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-rank-week-boundary")
|
||||||
|
|
||||||
|
now.now = time.Date(2026, 6, 8, 1, 0, 0, 0, time.UTC)
|
||||||
|
rankResp, err := svc.ListRoomContributionRank(ctx, &roomv1.ListRoomContributionRankRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||||
|
RoomId: roomID,
|
||||||
|
Period: "week",
|
||||||
|
Limit: 10,
|
||||||
|
ViewerUserId: senderID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list natural week contribution rank failed: %v", err)
|
||||||
|
}
|
||||||
|
wantStart := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
if rankResp.GetStartAtMs() != wantStart {
|
||||||
|
t.Fatalf("week contribution rank must start at natural UTC Monday 00:00: got %d want %d", rankResp.GetStartAtMs(), wantStart)
|
||||||
|
}
|
||||||
|
if len(rankResp.GetItems()) != 1 || rankResp.GetItems()[0].GetUserId() != senderID || rankResp.GetItems()[0].GetGiftValue() != 10 {
|
||||||
|
t.Fatalf("week contribution rank must keep 00:30 gift in the same natural week after 01:00: %+v", rankResp.GetItems())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendGiftForWeeklyContribution(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, senderID int64, targetID int64, commandID string) *roomv1.SendGiftResponse {
|
func sendGiftForWeeklyContribution(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, senderID int64, targetID int64, commandID string) *roomv1.SendGiftResponse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@ -100,16 +100,17 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
LeaseToken: lease.LeaseToken,
|
LeaseToken: lease.LeaseToken,
|
||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
},
|
},
|
||||||
OutboxRecords: durableOutboxRecords,
|
OutboxRecords: durableOutboxRecords,
|
||||||
DeleteRoom: result.deleteRoom,
|
DeleteRoom: result.deleteRoom,
|
||||||
RoomStatus: result.roomStatus,
|
RoomStatus: result.roomStatus,
|
||||||
RoomSeatCount: result.roomSeatCount,
|
RoomSeatCount: result.roomSeatCount,
|
||||||
RoomPasswordHash: result.roomPasswordHash,
|
RoomPasswordHash: result.roomPasswordHash,
|
||||||
RoomMode: result.roomMode,
|
RoomMode: result.roomMode,
|
||||||
VisibleRegionID: result.visibleRegionID,
|
VisibleRegionID: result.visibleRegionID,
|
||||||
CloseInfo: result.closeInfo,
|
CloseInfo: result.closeInfo,
|
||||||
RoomUserGiftStats: result.roomUserGiftStats,
|
RoomUserGiftStats: result.roomUserGiftStats,
|
||||||
RoomWeeklyContribution: result.roomWeeklyContribution,
|
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
|
||||||
|
RoomWeeklyContribution: result.roomWeeklyContribution,
|
||||||
})
|
})
|
||||||
commitCancel()
|
commitCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -76,6 +76,8 @@ type MutationCommit struct {
|
|||||||
CloseInfo *RoomCloseInfo
|
CloseInfo *RoomCloseInfo
|
||||||
// RoomUserGiftStats 是本次命令产生的房间用户送礼价值增量,必须和命令日志同事务提交。
|
// RoomUserGiftStats 是本次命令产生的房间用户送礼价值增量,必须和命令日志同事务提交。
|
||||||
RoomUserGiftStats []RoomUserGiftStatIncrement
|
RoomUserGiftStats []RoomUserGiftStatIncrement
|
||||||
|
// RoomUserContributionPeriodStats 是本次命令产生的房间用户周期贡献增量,必须和命令日志同事务提交。
|
||||||
|
RoomUserContributionPeriodStats []RoomUserContributionPeriodStatIncrement
|
||||||
// RoomWeeklyContribution 是后台房间贡献列的 UTC 周期读模型增量;它和命令日志同事务提交,避免送礼成功后榜单缺分。
|
// RoomWeeklyContribution 是后台房间贡献列的 UTC 周期读模型增量;它和命令日志同事务提交,避免送礼成功后榜单缺分。
|
||||||
RoomWeeklyContribution *RoomWeeklyContributionIncrement
|
RoomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||||
}
|
}
|
||||||
@ -89,6 +91,18 @@ type RoomUserGiftStatIncrement struct {
|
|||||||
LastGiftAtMS int64
|
LastGiftAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomUserContributionPeriodStatIncrement 表达当前房间内某个送礼用户在固定 UTC 周期内的贡献增量。
|
||||||
|
type RoomUserContributionPeriodStatIncrement struct {
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
Period string
|
||||||
|
PeriodStartMS int64
|
||||||
|
PeriodEndMS int64
|
||||||
|
UserID int64
|
||||||
|
GiftValue int64
|
||||||
|
LastGiftAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
// RoomWeeklyContributionIncrement 表达一个房间在当前 UTC 周内新增的展示贡献。
|
// RoomWeeklyContributionIncrement 表达一个房间在当前 UTC 周内新增的展示贡献。
|
||||||
type RoomWeeklyContributionIncrement struct {
|
type RoomWeeklyContributionIncrement struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -485,6 +499,12 @@ type RoomOnlineUserPage struct {
|
|||||||
PageSize int
|
PageSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomContributionRankStore 管理房间内用户周期贡献榜读模型。
|
||||||
|
type RoomContributionRankStore interface {
|
||||||
|
// ListRoomContributionRank 查询房间内某周期 Top 用户贡献,不扫描命令日志或 outbox。
|
||||||
|
ListRoomContributionRank(ctx context.Context, query RoomContributionRankQuery) (RoomContributionRankPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
// RoomListQuery 是 room-service 房间列表读模型查询条件。
|
// RoomListQuery 是 room-service 房间列表读模型查询条件。
|
||||||
type RoomListQuery struct {
|
type RoomListQuery struct {
|
||||||
// AppCode 是列表查询所属 App,通常来自 gateway RequestMeta。
|
// AppCode 是列表查询所属 App,通常来自 gateway RequestMeta。
|
||||||
@ -667,6 +687,7 @@ type Repository interface {
|
|||||||
OutboxStore
|
OutboxStore
|
||||||
RoomListStore
|
RoomListStore
|
||||||
PresenceStore
|
PresenceStore
|
||||||
|
RoomContributionRankStore
|
||||||
AdminRoomStore
|
AdminRoomStore
|
||||||
RobotRoomStore
|
RobotRoomStore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,6 +197,8 @@ type mutationResult struct {
|
|||||||
walletDebitMS int64
|
walletDebitMS int64
|
||||||
// roomGiftLeaderboard 是 SendGift 成功后写入跨房间贡献榜的轻量增量。
|
// roomGiftLeaderboard 是 SendGift 成功后写入跨房间贡献榜的轻量增量。
|
||||||
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
|
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
|
||||||
|
// roomUserContributionPeriodStats 是当前房间用户周期贡献统计,和命令日志同事务提交。
|
||||||
|
roomUserContributionPeriodStats []RoomUserContributionPeriodStatIncrement
|
||||||
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
|
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
|
||||||
roomUserGiftStats []RoomUserGiftStatIncrement
|
roomUserGiftStats []RoomUserGiftStatIncrement
|
||||||
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。
|
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。
|
||||||
|
|||||||
@ -248,6 +248,39 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, stat := range commit.RoomUserContributionPeriodStats {
|
||||||
|
if stat.UserID <= 0 || stat.GiftValue <= 0 || strings.TrimSpace(stat.Period) == "" || stat.PeriodStartMS <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
|
||||||
|
statRoomID := strings.TrimSpace(stat.RoomID)
|
||||||
|
if statRoomID == "" {
|
||||||
|
statRoomID = commit.Command.RoomID
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO room_user_contribution_period_stats (
|
||||||
|
app_code, room_id, period, period_start_ms, period_end_ms, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
gift_value = gift_value + VALUES(gift_value),
|
||||||
|
period_end_ms = VALUES(period_end_ms),
|
||||||
|
last_gift_at_ms = VALUES(last_gift_at_ms),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
statAppCode,
|
||||||
|
statRoomID,
|
||||||
|
strings.TrimSpace(stat.Period),
|
||||||
|
stat.PeriodStartMS,
|
||||||
|
stat.PeriodEndMS,
|
||||||
|
stat.UserID,
|
||||||
|
stat.GiftValue,
|
||||||
|
stat.LastGiftAtMS,
|
||||||
|
commit.Command.CreatedAtMS,
|
||||||
|
commit.Command.CreatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
|
if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
|
||||||
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
|
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
|
||||||
weeklyRoomID := strings.TrimSpace(weekly.RoomID)
|
weeklyRoomID := strings.TrimSpace(weekly.RoomID)
|
||||||
@ -295,6 +328,7 @@ func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode str
|
|||||||
`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
|
||||||
|
|||||||
@ -0,0 +1,72 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRoomContributionRank 读取单房间用户周期贡献 TopN。
|
||||||
|
// 该查询只命中周期统计表索引,不扫描 command log、outbox 或 Room Cell 快照。
|
||||||
|
func (r *Repository) ListRoomContributionRank(ctx context.Context, query roomservice.RoomContributionRankQuery) (roomservice.RoomContributionRankPage, error) {
|
||||||
|
app := appcode.Normalize(query.AppCode)
|
||||||
|
if app == "" {
|
||||||
|
app = appcode.FromContext(ctx)
|
||||||
|
}
|
||||||
|
roomID := strings.TrimSpace(query.RoomID)
|
||||||
|
period := strings.TrimSpace(query.Period)
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*)
|
||||||
|
FROM room_user_contribution_period_stats
|
||||||
|
WHERE app_code = ? AND room_id = ? AND period = ? AND period_start_ms = ?`,
|
||||||
|
app,
|
||||||
|
roomID,
|
||||||
|
period,
|
||||||
|
query.PeriodStartMS,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
return roomservice.RoomContributionRankPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT user_id, gift_value, updated_at_ms
|
||||||
|
FROM room_user_contribution_period_stats
|
||||||
|
WHERE app_code = ? AND room_id = ? AND period = ? AND period_start_ms = ?
|
||||||
|
ORDER BY gift_value DESC, last_gift_at_ms DESC, user_id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
app,
|
||||||
|
roomID,
|
||||||
|
period,
|
||||||
|
query.PeriodStartMS,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomContributionRankPage{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]roomservice.RoomContributionRankEntry, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var item roomservice.RoomContributionRankEntry
|
||||||
|
if err := rows.Scan(&item.UserID, &item.GiftValue, &item.UpdatedAtMS); err != nil {
|
||||||
|
return roomservice.RoomContributionRankPage{}, err
|
||||||
|
}
|
||||||
|
item.Rank = int64(len(items)) + 1
|
||||||
|
item.Score = item.GiftValue
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return roomservice.RoomContributionRankPage{}, err
|
||||||
|
}
|
||||||
|
return roomservice.RoomContributionRankPage{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -89,6 +89,20 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
PRIMARY KEY (app_code, room_id, user_id),
|
PRIMARY KEY (app_code, room_id, user_id),
|
||||||
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
|
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_user_contribution_period_stats (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
room_id VARCHAR(64) NOT NULL,
|
||||||
|
period VARCHAR(16) NOT NULL,
|
||||||
|
period_start_ms BIGINT NOT NULL,
|
||||||
|
period_end_ms BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
gift_value BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, room_id, period, period_start_ms, user_id),
|
||||||
|
KEY idx_room_contribution_period_rank (app_code, room_id, period, period_start_ms, gift_value DESC, last_gift_at_ms DESC, user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS room_background_images (
|
`CREATE TABLE IF NOT EXISTS room_background_images (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
background_id BIGINT NOT NULL AUTO_INCREMENT,
|
background_id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@ -506,6 +506,13 @@ func (s *Server) ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListRo
|
|||||||
return mapServiceResult(s.svc.ListRoomGiftLeaderboard(ctx, req))
|
return mapServiceResult(s.svc.ListRoomGiftLeaderboard(ctx, req))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRoomContributionRank 代理到 Redis zset 单房间用户贡献周期榜读模型。
|
||||||
|
func (s *Server) ListRoomContributionRank(ctx context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
|
||||||
|
// 单房间贡献榜只读 SendGift 成功后投影的周期 zset,不扫描 Room Cell、command log 或 outbox。
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
return mapServiceResult(s.svc.ListRoomContributionRank(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyRoom 代理到 Mine 顶部“我的房间”权威查询。
|
// GetMyRoom 代理到 Mine 顶部“我的房间”权威查询。
|
||||||
func (s *Server) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
func (s *Server) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||||
// 我的房间查询直接读取 rooms 元数据,并用快照补齐实时卡片字段,不依赖发现页投影。
|
// 我的房间查询直接读取 rooms 元数据,并用快照补齐实时卡片字段,不依赖发现页投影。
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package user
|
|||||||
const (
|
const (
|
||||||
// UserIDTargetAllActive supports low-frequency full-App jobs without exposing profile fields.
|
// UserIDTargetAllActive supports low-frequency full-App jobs without exposing profile fields.
|
||||||
UserIDTargetAllActiveUsers = "all_active_users"
|
UserIDTargetAllActiveUsers = "all_active_users"
|
||||||
|
// UserIDTargetAllRegistered selects every registered user in the current app, regardless of account status.
|
||||||
|
UserIDTargetAllRegistered = "all_registered_users"
|
||||||
// UserIDTargetRegion selects active users by their materialized region_id snapshot.
|
// UserIDTargetRegion selects active users by their materialized region_id snapshot.
|
||||||
UserIDTargetRegion = "region"
|
UserIDTargetRegion = "region"
|
||||||
// UserIDTargetCountry selects active users by their current country code snapshot.
|
// UserIDTargetCountry selects active users by their current country code snapshot.
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
package user_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
|
userservice "hyapp/services/user-service/internal/service/user"
|
||||||
|
"hyapp/services/user-service/internal/testutil/mysqltest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListUserIDsAllRegisteredIncludesNonActiveUsers(t *testing.T) {
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
for _, user := range []userdomain.User{
|
||||||
|
{AppCode: "lalu", UserID: 1001, DefaultDisplayUserID: "1001", CurrentDisplayUserID: "1001", Status: userdomain.StatusActive, RegionID: 11, Country: "PH"},
|
||||||
|
{AppCode: "lalu", UserID: 1002, DefaultDisplayUserID: "1002", CurrentDisplayUserID: "1002", Status: userdomain.StatusDisabled, RegionID: 11, Country: "PH"},
|
||||||
|
{AppCode: "lalu", UserID: 1003, DefaultDisplayUserID: "1003", CurrentDisplayUserID: "1003", Status: userdomain.StatusBanned, RegionID: 11, Country: "PH"},
|
||||||
|
} {
|
||||||
|
repository.PutUser(user)
|
||||||
|
}
|
||||||
|
svc := userservice.New(repository)
|
||||||
|
|
||||||
|
activeIDs, nextCursor, done, err := svc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
|
||||||
|
TargetScope: userdomain.UserIDTargetAllActiveUsers,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserIDs active failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(activeIDs, []int64{1001}) || nextCursor != 1001 || !done {
|
||||||
|
t.Fatalf("active query mismatch: ids=%v next=%d done=%v", activeIDs, nextCursor, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
regionIDs, nextCursor, done, err := svc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
|
||||||
|
TargetScope: userdomain.UserIDTargetRegion,
|
||||||
|
RegionID: 11,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserIDs region failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(regionIDs, []int64{1001}) || nextCursor != 1001 || !done {
|
||||||
|
t.Fatalf("region query mismatch: ids=%v next=%d done=%v", regionIDs, nextCursor, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
countryIDs, nextCursor, done, err := svc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
|
||||||
|
TargetScope: userdomain.UserIDTargetCountry,
|
||||||
|
Country: "PH",
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserIDs country failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(countryIDs, []int64{1001}) || nextCursor != 1001 || !done {
|
||||||
|
t.Fatalf("country query mismatch: ids=%v next=%d done=%v", countryIDs, nextCursor, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredIDs, nextCursor, done, err := svc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
|
||||||
|
TargetScope: userdomain.UserIDTargetAllRegistered,
|
||||||
|
PageSize: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserIDs registered first page failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(registeredIDs, []int64{1001, 1002}) || nextCursor != 1002 || done {
|
||||||
|
t.Fatalf("registered first page mismatch: ids=%v next=%d done=%v", registeredIDs, nextCursor, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredIDs, nextCursor, done, err = svc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
|
||||||
|
TargetScope: userdomain.UserIDTargetAllRegistered,
|
||||||
|
CursorUserID: nextCursor,
|
||||||
|
PageSize: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserIDs registered second page failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(registeredIDs, []int64{1003}) || nextCursor != 1003 || !done {
|
||||||
|
t.Fatalf("registered second page mismatch: ids=%v next=%d done=%v", registeredIDs, nextCursor, done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -113,7 +113,7 @@ func (s *Service) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (
|
|||||||
return s.userRepository.BatchGetRoomBasicUsers(ctx, userIDs)
|
return s.userRepository.BatchGetRoomBasicUsers(ctx, userIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUserIDs returns active user IDs for low-frequency backend fanout tasks.
|
// ListUserIDs returns target user IDs for low-frequency backend fanout tasks.
|
||||||
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
|
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
|
||||||
if s.userRepository == nil {
|
if s.userRepository == nil {
|
||||||
return nil, 0, false, xerr.New(xerr.Unavailable, "user repository is not configured")
|
return nil, 0, false, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||||
@ -125,7 +125,7 @@ func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageF
|
|||||||
return nil, 0, false, xerr.New(xerr.InvalidArgument, "cursor_user_id is invalid")
|
return nil, 0, false, xerr.New(xerr.InvalidArgument, "cursor_user_id is invalid")
|
||||||
}
|
}
|
||||||
switch filter.TargetScope {
|
switch filter.TargetScope {
|
||||||
case userdomain.UserIDTargetAllActiveUsers:
|
case userdomain.UserIDTargetAllActiveUsers, userdomain.UserIDTargetAllRegistered:
|
||||||
case userdomain.UserIDTargetRegion:
|
case userdomain.UserIDTargetRegion:
|
||||||
if filter.RegionID <= 0 {
|
if filter.RegionID <= 0 {
|
||||||
return nil, 0, false, xerr.New(xerr.InvalidArgument, "region_id is required")
|
return nil, 0, false, xerr.New(xerr.InvalidArgument, "region_id is required")
|
||||||
|
|||||||
@ -135,17 +135,21 @@ func (r *Repository) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUserIDs returns active user IDs in ascending order for cursor-based backend fanout.
|
// ListUserIDs returns target user IDs in ascending order for cursor-based backend fanout.
|
||||||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||||||
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
|
conditions := []string{"app_code = ?", "user_id > ?"}
|
||||||
args := []any{appcode.FromContext(ctx), string(userdomain.StatusActive), filter.CursorUserID}
|
args := []any{appcode.FromContext(ctx), filter.CursorUserID}
|
||||||
switch filter.TargetScope {
|
switch filter.TargetScope {
|
||||||
|
case userdomain.UserIDTargetAllRegistered:
|
||||||
|
case userdomain.UserIDTargetAllActiveUsers:
|
||||||
|
conditions = append(conditions, "status = ?")
|
||||||
|
args = append(args, string(userdomain.StatusActive))
|
||||||
case userdomain.UserIDTargetRegion:
|
case userdomain.UserIDTargetRegion:
|
||||||
conditions = append(conditions, "region_id = ?")
|
conditions = append(conditions, "status = ?", "region_id = ?")
|
||||||
args = append(args, filter.RegionID)
|
args = append(args, string(userdomain.StatusActive), filter.RegionID)
|
||||||
case userdomain.UserIDTargetCountry:
|
case userdomain.UserIDTargetCountry:
|
||||||
conditions = append(conditions, "country = ?")
|
conditions = append(conditions, "status = ?", "country = ?")
|
||||||
args = append(args, filter.Country)
|
args = append(args, string(userdomain.StatusActive), filter.Country)
|
||||||
}
|
}
|
||||||
args = append(args, filter.PageSize)
|
args = append(args, filter.PageSize)
|
||||||
|
|
||||||
|
|||||||
@ -4643,8 +4643,8 @@ func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient 验证余额不足时整个撤销事务回滚,权益和发放记录仍保持成功状态。
|
// TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数。
|
||||||
func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(t *testing.T) {
|
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
svc := walletservice.New(repository)
|
svc := walletservice.New(repository)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -4689,31 +4689,38 @@ func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(t *testing.T) {
|
|||||||
entitlementID := entitlementIDFromGrant(t, grant)
|
entitlementID := entitlementIDFromGrant(t, grant)
|
||||||
repository.SetBalance(42102, 100)
|
repository.SetBalance(42102, 100)
|
||||||
|
|
||||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||||
RequestID: "cmd-revoke-insufficient",
|
RequestID: "cmd-revoke-insufficient",
|
||||||
GrantID: grant.GrantID,
|
GrantID: grant.GrantID,
|
||||||
Reason: "test insufficient",
|
Reason: "test insufficient",
|
||||||
OperatorUserID: 90002,
|
OperatorUserID: 90002,
|
||||||
})
|
})
|
||||||
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
if err != nil {
|
||||||
t.Fatalf("revoke should fail with insufficient balance, got %v", err)
|
t.Fatalf("RevokeResourceGrant should clamp insufficient balance: %v", err)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusDone); got != 1 {
|
if revoked.Status != resourcedomain.GrantStatusRevoked {
|
||||||
t.Fatalf("failed revoke must keep grant succeeded, got %d", got)
|
t.Fatalf("insufficient balance revoke should still mark grant revoked: %+v", revoked)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = 'active'", entitlementID); got != 1 {
|
assertBalance(t, svc, 42102, ledger.AssetCoin, 0)
|
||||||
t.Fatalf("failed revoke must keep entitlement active, got %d", got)
|
if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusRevoked); got != 1 {
|
||||||
|
t.Fatalf("clamped revoke must mark grant revoked, got %d", got)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 1 {
|
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 {
|
||||||
t.Fatalf("failed revoke must keep equipment row, got %d", got)
|
t.Fatalf("clamped revoke must still revoke entitlement, got %d", got)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 0 {
|
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 0 {
|
||||||
t.Fatalf("failed revoke must not write reverse transaction, got %d", got)
|
t.Fatalf("clamped revoke must remove equipment row, got %d", got)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42102), ledger.AssetCoin, int64(-100)); got != 1 {
|
||||||
|
t.Fatalf("clamped revoke should write one partial reverse coin entry, got %d", got)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 {
|
||||||
|
t.Fatalf("clamped revoke should write one reverse transaction, got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRevokeResourceGrantRejectsUnsupportedRecords 固定撤销范围:只允许成功的资源组发放,单资源、异常状态和不存在记录都不能误处理。
|
// TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration 锁定单资源撤回的金币夹零和素材天数扣减语义。
|
||||||
func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
|
func TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
svc := walletservice.New(repository)
|
svc := walletservice.New(repository)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -4733,26 +4740,113 @@ func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
|
|||||||
t.Fatalf("create single coin resource failed: %v", err)
|
t.Fatalf("create single coin resource failed: %v", err)
|
||||||
}
|
}
|
||||||
singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||||||
CommandID: "cmd-single-revoke-reject",
|
CommandID: "cmd-single-coin-revoke",
|
||||||
TargetUserID: 42103,
|
TargetUserID: 42103,
|
||||||
ResourceID: coinResource.ResourceID,
|
ResourceID: coinResource.ResourceID,
|
||||||
Quantity: 1,
|
Quantity: 2,
|
||||||
Reason: "single reject",
|
Reason: "single coin revoke",
|
||||||
OperatorUserID: 90001,
|
OperatorUserID: 90001,
|
||||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("single GrantResource setup failed: %v", err)
|
t.Fatalf("single coin GrantResource setup failed: %v", err)
|
||||||
}
|
}
|
||||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
repository.SetBalance(42103, 50)
|
||||||
RequestID: "cmd-revoke-single-reject",
|
coinRevoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||||
|
RequestID: "cmd-revoke-single-coin",
|
||||||
GrantID: singleGrant.GrantID,
|
GrantID: singleGrant.GrantID,
|
||||||
Reason: "single reject",
|
Reason: "single coin revoke",
|
||||||
OperatorUserID: 90002,
|
OperatorUserID: 90002,
|
||||||
})
|
})
|
||||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
if err != nil {
|
||||||
t.Fatalf("single resource revoke should be invalid, got %v", err)
|
t.Fatalf("single coin RevokeResourceGrant failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if coinRevoked.Status != resourcedomain.GrantStatusRevoked {
|
||||||
|
t.Fatalf("single coin revoke status mismatch: %+v", coinRevoked)
|
||||||
|
}
|
||||||
|
assertBalance(t, svc, 42103, ledger.AssetCoin, 0)
|
||||||
|
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42103), ledger.AssetCoin, int64(-50)); got != 1 {
|
||||||
|
t.Fatalf("single coin revoke should clamp reverse entry to available balance, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
frameResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||||
|
ResourceCode: "frame_single_revoke_duration",
|
||||||
|
ResourceType: resourcedomain.TypeAvatarFrame,
|
||||||
|
Name: "Frame Single Revoke Duration",
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Grantable: true,
|
||||||
|
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||||||
|
UsageScopes: []string{"profile"},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create frame resource failed: %v", err)
|
||||||
|
}
|
||||||
|
durationMS := int64((7 * 24 * time.Hour) / time.Millisecond)
|
||||||
|
firstFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||||||
|
CommandID: "cmd-frame-revoke-duration-first",
|
||||||
|
TargetUserID: 42105,
|
||||||
|
ResourceID: frameResource.ResourceID,
|
||||||
|
Quantity: 1,
|
||||||
|
DurationMS: durationMS,
|
||||||
|
Reason: "frame duration first",
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first frame GrantResource setup failed: %v", err)
|
||||||
|
}
|
||||||
|
firstExpiresAt, firstQuantity, firstRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
|
||||||
|
if firstQuantity != 1 || firstRemaining != 1 {
|
||||||
|
t.Fatalf("first frame entitlement mismatch quantity=%d remaining=%d", firstQuantity, firstRemaining)
|
||||||
|
}
|
||||||
|
secondFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||||||
|
CommandID: "cmd-frame-revoke-duration-second",
|
||||||
|
TargetUserID: 42105,
|
||||||
|
ResourceID: frameResource.ResourceID,
|
||||||
|
Quantity: 1,
|
||||||
|
DurationMS: durationMS,
|
||||||
|
Reason: "frame duration second",
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second frame GrantResource setup failed: %v", err)
|
||||||
|
}
|
||||||
|
secondExpiresAt, secondQuantity, secondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
|
||||||
|
if secondQuantity != 2 || secondRemaining != 2 || secondExpiresAt != firstExpiresAt+durationMS {
|
||||||
|
t.Fatalf("second frame entitlement should extend by one duration: first=%d second=%d quantity=%d remaining=%d", firstExpiresAt, secondExpiresAt, secondQuantity, secondRemaining)
|
||||||
|
}
|
||||||
|
if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||||
|
RequestID: "cmd-revoke-frame-second",
|
||||||
|
GrantID: secondFrameGrant.GrantID,
|
||||||
|
Reason: "frame duration second revoke",
|
||||||
|
OperatorUserID: 90002,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("second frame RevokeResourceGrant failed: %v", err)
|
||||||
|
}
|
||||||
|
afterSecondRevokeExpiresAt, afterSecondQuantity, afterSecondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
|
||||||
|
if afterSecondExpiresAt := afterSecondRevokeExpiresAt; afterSecondExpiresAt != firstExpiresAt || afterSecondQuantity != 1 || afterSecondRemaining != 1 {
|
||||||
|
t.Fatalf("second revoke should deduct exactly one duration: expires=%d want=%d quantity=%d remaining=%d", afterSecondExpiresAt, firstExpiresAt, afterSecondQuantity, afterSecondRemaining)
|
||||||
|
}
|
||||||
|
if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||||
|
RequestID: "cmd-revoke-frame-first",
|
||||||
|
GrantID: firstFrameGrant.GrantID,
|
||||||
|
Reason: "frame duration first revoke",
|
||||||
|
OperatorUserID: 90002,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first frame RevokeResourceGrant failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ? AND status = ? AND remaining_quantity = 0 AND expires_at_ms <= ?", int64(42105), frameResource.ResourceID, resourcedomain.GrantStatusRevoked, time.Now().UnixMilli()); got != 1 {
|
||||||
|
t.Fatalf("first revoke should clamp material entitlement to zero remaining duration, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRevokeResourceGrantRejectsUnsupportedRecords 固定异常状态和不存在记录不能误处理。
|
||||||
|
func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||||||
GroupCode: "failed_revoke_reject_pack",
|
GroupCode: "failed_revoke_reject_pack",
|
||||||
|
|||||||
@ -444,6 +444,17 @@ func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.T
|
|||||||
return item, err
|
return item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getUserResourceEntitlementForUpdateTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
|
item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
|
||||||
|
userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ? FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx), entitlementID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
|
||||||
|
}
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) {
|
func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
var item resourcedomain.UserResourceEntitlement
|
var item resourcedomain.UserResourceEntitlement
|
||||||
var resource resourcedomain.Resource
|
var resource resourcedomain.Resource
|
||||||
|
|||||||
@ -137,7 +137,7 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
|
|||||||
return r.GetResourceGrant(ctx, grantID)
|
return r.GetResourceGrant(ctx, grantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeResourceGrant 撤销一次成功的资源组发放;钱包扣回、权益失效、装备清理和发放状态更新必须同事务提交。
|
// RevokeResourceGrant 撤销一次成功的资源发放;钱包扣回、权益扣减、装备清理和发放状态更新必须同事务提交。
|
||||||
func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error) {
|
func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -169,9 +169,6 @@ func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedo
|
|||||||
}
|
}
|
||||||
return grant, nil
|
return grant, nil
|
||||||
}
|
}
|
||||||
if grant.GrantSubjectType != resourcedomain.GrantSubjectGroup {
|
|
||||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "only resource group grant can be revoked")
|
|
||||||
}
|
|
||||||
if grant.Status != resourcedomain.GrantStatusDone {
|
if grant.Status != resourcedomain.GrantStatusDone {
|
||||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource grant is not succeeded")
|
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource grant is not succeeded")
|
||||||
}
|
}
|
||||||
@ -185,11 +182,13 @@ func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedo
|
|||||||
for _, item := range grant.Items {
|
for _, item := range grant.Items {
|
||||||
switch item.ResultType {
|
switch item.ResultType {
|
||||||
case resourcedomain.ResultWalletCredit:
|
case resourcedomain.ResultWalletCredit:
|
||||||
event, err := r.revokeGrantedWalletCredit(ctx, tx, grant, item, command, nowMs)
|
event, changed, err := r.revokeGrantedWalletCredit(ctx, tx, grant, item, command, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceGrant{}, err
|
return resourcedomain.ResourceGrant{}, err
|
||||||
}
|
}
|
||||||
outboxEvents = append(outboxEvents, event)
|
if changed {
|
||||||
|
outboxEvents = append(outboxEvents, event)
|
||||||
|
}
|
||||||
case resourcedomain.ResultEntitlement:
|
case resourcedomain.ResultEntitlement:
|
||||||
event, err := r.revokeGrantedEntitlement(ctx, tx, grant, item, command, nowMs)
|
event, err := r.revokeGrantedEntitlement(ctx, tx, grant, item, command, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -220,8 +219,8 @@ func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedo
|
|||||||
}
|
}
|
||||||
|
|
||||||
revokeCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, 0)
|
revokeCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, 0)
|
||||||
groupID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
|
subjectID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
|
||||||
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, groupID, map[string]any{
|
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, subjectID, map[string]any{
|
||||||
"action": "revoke",
|
"action": "revoke",
|
||||||
"app_code": command.AppCode,
|
"app_code": command.AppCode,
|
||||||
"grant_id": grant.GrantID,
|
"grant_id": grant.GrantID,
|
||||||
@ -464,24 +463,28 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx,
|
|||||||
return inserted, nil
|
return inserted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) revokeGrantedWalletCredit(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
|
func (r *Repository) revokeGrantedWalletCredit(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, bool, error) {
|
||||||
if strings.TrimSpace(item.WalletTransactionID) == "" {
|
if strings.TrimSpace(item.WalletTransactionID) == "" {
|
||||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant wallet transaction is missing")
|
return walletOutboxEvent{}, false, xerr.New(xerr.LedgerConflict, "resource grant wallet transaction is missing")
|
||||||
}
|
}
|
||||||
// 反扣金额以原入账分录为准,避免资源组或资源定义后续变更导致撤销扣回的内容漂移。
|
// 反扣金额以原入账分录为准,避免资源组或资源定义后续变更导致撤销扣回的内容漂移。
|
||||||
original, err := r.lockOriginalGrantCreditEntry(ctx, tx, item.WalletTransactionID, grant.TargetUserID)
|
original, err := r.lockOriginalGrantCreditEntry(ctx, tx, item.WalletTransactionID, grant.TargetUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, false, err
|
||||||
}
|
}
|
||||||
if original.AvailableDelta <= 0 || original.FrozenDelta != 0 {
|
if original.AvailableDelta <= 0 || original.FrozenDelta != 0 {
|
||||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant wallet entry is not reversible")
|
return walletOutboxEvent{}, false, xerr.New(xerr.LedgerConflict, "resource grant wallet entry is not reversible")
|
||||||
}
|
}
|
||||||
account, err := r.lockAccount(ctx, tx, grant.TargetUserID, original.AssetType, false, nowMs)
|
account, err := r.lockAccount(ctx, tx, grant.TargetUserID, original.AssetType, false, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, false, err
|
||||||
}
|
}
|
||||||
if account.AvailableAmount < original.AvailableDelta {
|
revokeAmount := original.AvailableDelta
|
||||||
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "insufficient balance to revoke resource grant")
|
if account.AvailableAmount < revokeAmount {
|
||||||
|
revokeAmount = account.AvailableAmount
|
||||||
|
}
|
||||||
|
if revokeAmount <= 0 {
|
||||||
|
return walletOutboxEvent{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
walletCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
walletCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
||||||
@ -492,44 +495,72 @@ func (r *Repository) revokeGrantedWalletCredit(ctx context.Context, tx *sql.Tx,
|
|||||||
"grant_item_id": item.GrantItemID,
|
"grant_item_id": item.GrantItemID,
|
||||||
"target_user_id": grant.TargetUserID,
|
"target_user_id": grant.TargetUserID,
|
||||||
"asset_type": original.AssetType,
|
"asset_type": original.AssetType,
|
||||||
"wallet_amount": original.AvailableDelta,
|
"wallet_amount": revokeAmount,
|
||||||
|
"original_wallet_amount": original.AvailableDelta,
|
||||||
"original_transaction_id": item.WalletTransactionID,
|
"original_transaction_id": item.WalletTransactionID,
|
||||||
"revoked_by_user_id": command.OperatorUserID,
|
"revoked_by_user_id": command.OperatorUserID,
|
||||||
"revoke_reason": command.Reason,
|
"revoke_reason": command.Reason,
|
||||||
"operator_reason": "resource grant revoke",
|
"operator_reason": "resource grant revoke",
|
||||||
}
|
}
|
||||||
if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrantRevoke, resourceGrantWalletRevokeHash(grant.GrantID, item.GrantItemID, original.AssetType, original.AvailableDelta), grant.GrantID, metadata, nowMs); err != nil {
|
if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrantRevoke, resourceGrantWalletRevokeHash(grant.GrantID, item.GrantItemID, original.AssetType, revokeAmount), grant.GrantID, metadata, nowMs); err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, false, err
|
||||||
}
|
}
|
||||||
if err := r.applyAccountDelta(ctx, tx, account, -original.AvailableDelta, 0, nowMs); err != nil {
|
if err := r.applyAccountDelta(ctx, tx, account, -revokeAmount, 0, nowMs); err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, false, err
|
||||||
}
|
}
|
||||||
availableAfter := account.AvailableAmount - original.AvailableDelta
|
availableAfter := account.AvailableAmount - revokeAmount
|
||||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
UserID: grant.TargetUserID,
|
UserID: grant.TargetUserID,
|
||||||
AssetType: original.AssetType,
|
AssetType: original.AssetType,
|
||||||
AvailableDelta: -original.AvailableDelta,
|
AvailableDelta: -revokeAmount,
|
||||||
FrozenDelta: 0,
|
FrozenDelta: 0,
|
||||||
AvailableAfter: availableAfter,
|
AvailableAfter: availableAfter,
|
||||||
FrozenAfter: account.FrozenAmount,
|
FrozenAfter: account.FrozenAmount,
|
||||||
CreatedAtMS: nowMs,
|
CreatedAtMS: nowMs,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, false, err
|
||||||
}
|
}
|
||||||
return balanceChangedEvent(transactionID, walletCommandID, grant.TargetUserID, original.AssetType, -original.AvailableDelta, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), nil
|
return balanceChangedEvent(transactionID, walletCommandID, grant.TargetUserID, original.AssetType, -revokeAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
|
func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
|
||||||
if strings.TrimSpace(item.EntitlementID) == "" {
|
if strings.TrimSpace(item.EntitlementID) == "" {
|
||||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is missing")
|
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is missing")
|
||||||
}
|
}
|
||||||
// 撤销不折算有效期,直接让原发放产生的权益不可用;装备表同步删除,避免客户端继续展示已穿戴状态。
|
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, item.EntitlementID)
|
||||||
|
if err != nil {
|
||||||
|
return walletOutboxEvent{}, err
|
||||||
|
}
|
||||||
|
if entitlement.UserID != grant.TargetUserID {
|
||||||
|
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement owner mismatch")
|
||||||
|
}
|
||||||
|
nextQuantity := entitlement.Quantity - item.Quantity
|
||||||
|
if nextQuantity < 0 {
|
||||||
|
nextQuantity = 0
|
||||||
|
}
|
||||||
|
nextRemaining := entitlement.RemainingQuantity - item.Quantity
|
||||||
|
if nextRemaining < 0 {
|
||||||
|
nextRemaining = 0
|
||||||
|
}
|
||||||
|
nextExpiresAt := entitlement.ExpiresAtMS
|
||||||
|
if item.DurationMS > 0 && nextExpiresAt > 0 {
|
||||||
|
nextExpiresAt -= item.DurationMS
|
||||||
|
if nextExpiresAt < nowMs {
|
||||||
|
nextExpiresAt = nowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextStatus := resourcedomain.StatusActive
|
||||||
|
if nextRemaining == 0 || (nextExpiresAt > 0 && nextExpiresAt <= nowMs) {
|
||||||
|
nextStatus = resourcedomain.GrantStatusRevoked
|
||||||
|
}
|
||||||
|
// 发放策略可能把多次同资源发放合并到同一权益;撤销必须扣回本次发放贡献的天数/数量,
|
||||||
|
// 不能直接删除整条权益,否则会误伤其它仍然有效的发放。
|
||||||
result, err := tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
UPDATE user_resource_entitlements
|
UPDATE user_resource_entitlements
|
||||||
SET status = ?, remaining_quantity = 0, updated_at_ms = ?
|
SET status = ?, quantity = ?, remaining_quantity = ?, expires_at_ms = ?, updated_at_ms = ?
|
||||||
WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
|
WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
|
||||||
resourcedomain.GrantStatusRevoked, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
|
nextStatus, nextQuantity, nextRemaining, nextExpiresAt, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return walletOutboxEvent{}, err
|
return walletOutboxEvent{}, err
|
||||||
@ -541,12 +572,14 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
|
|||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is not reversible")
|
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is not reversible")
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if nextStatus != resourcedomain.StatusActive {
|
||||||
DELETE FROM user_resource_equipment
|
if _, err := tx.ExecContext(ctx, `
|
||||||
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
|
DELETE FROM user_resource_equipment
|
||||||
command.AppCode, grant.TargetUserID, item.EntitlementID,
|
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
|
||||||
); err != nil {
|
command.AppCode, grant.TargetUserID, item.EntitlementID,
|
||||||
return walletOutboxEvent{}, err
|
); err != nil {
|
||||||
|
return walletOutboxEvent{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
||||||
@ -558,6 +591,10 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
|
|||||||
"grant_item_id": item.GrantItemID,
|
"grant_item_id": item.GrantItemID,
|
||||||
"resource_id": item.ResourceID,
|
"resource_id": item.ResourceID,
|
||||||
"entitlement_id": item.EntitlementID,
|
"entitlement_id": item.EntitlementID,
|
||||||
|
"status": nextStatus,
|
||||||
|
"quantity": nextQuantity,
|
||||||
|
"remaining_quantity": nextRemaining,
|
||||||
|
"expires_at_ms": nextExpiresAt,
|
||||||
"revoked_by_user_id": command.OperatorUserID,
|
"revoked_by_user_id": command.OperatorUserID,
|
||||||
"revoke_reason": command.Reason,
|
"revoke_reason": command.Reason,
|
||||||
"updated_at_ms": nowMs,
|
"updated_at_ms": nowMs,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user