资源撤回,系统消息

This commit is contained in:
zhx 2026-07-06 12:43:40 +08:00
parent ccebdcb28a
commit 5a03a3d49a
53 changed files with 5068 additions and 3336 deletions

View File

@ -32,7 +32,7 @@ App 系统/活动消息:
- 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`
- 活动业务发通知时传 `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

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,15 @@ message RankItem {
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
message LuckyGiftDrawResult {
bool enabled = 1;
@ -1276,6 +1285,26 @@ message ListRoomGiftLeaderboardResponse {
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
// gateway owner_user_id ID
message GetMyRoomRequest {
@ -1475,6 +1504,7 @@ service RoomQueryService {
rpc ListRoomsByOwners(ListRoomsByOwnersRequest) returns (ListRoomsResponse);
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
rpc ListRoomGiftLeaderboard(ListRoomGiftLeaderboardRequest) returns (ListRoomGiftLeaderboardResponse);
rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);

View File

@ -1573,6 +1573,7 @@ const (
RoomQueryService_ListRoomsByOwners_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomsByOwners"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
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_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
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)
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, 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)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyRoomResponse)
@ -1825,6 +1837,7 @@ type RoomQueryServiceServer interface {
ListRoomsByOwners(context.Context, *ListRoomsByOwnersRequest) (*ListRoomsResponse, error)
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error)
ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, 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) {
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) {
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)
}
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) {
in := new(GetMyRoomRequest)
if err := dec(in); err != nil {
@ -2317,6 +2351,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRoomGiftLeaderboard",
Handler: _RoomQueryService_ListRoomGiftLeaderboard_Handler,
},
{
MethodName: "ListRoomContributionRank",
Handler: _RoomQueryService_ListRoomContributionRank_Handler,
},
{
MethodName: "GetMyRoom",
Handler: _RoomQueryService_GetMyRoom_Handler,

View File

@ -32,7 +32,7 @@
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`region/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`region/country/all_active_users/all_registered_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
@ -238,6 +238,7 @@ message_fanout_jobs(
| `region` | 区域活动、区域公告 | user-service `users.region_id` |
| `country` | 国家定向通知 | user-service `users.country` |
| `all_active_users` | 全 App 活跃用户 | user-service active users cursor |
| `all_registered_users` | 全 App 注册用户 | user-service registered users cursor |
### Global Broadcast State
@ -527,7 +528,7 @@ tasks:
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}`
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 用户会话列表补头像、昵称、短号。
8. 增加 Redis 未读数缓存和缓存失效逻辑。
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。

View File

@ -550,6 +550,7 @@ definitions:
- region
- country
- all_active_users
- all_registered_users
target_filter_json:
type: string
template_snapshot_json:

View File

@ -1223,6 +1223,56 @@ paths:
$ref: "#/responses/Conflict"
"502":
$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 作为 viewerroom-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:
post:
tags:
@ -4893,6 +4943,25 @@ definitions:
updated_at_ms:
type: integer
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:
type: object
properties:
@ -4983,6 +5052,48 @@ definitions:
server_time_ms:
type: integer
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:
type: object
required:
@ -5491,6 +5602,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomSnapshotData"
RoomContributionRankEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomContributionRankData"
RoomFollowEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -1039,6 +1039,7 @@ definitions:
type: string
enum:
- all_active_users
- all_registered_users
- region
- country
cursor_user_id:

View File

@ -64,9 +64,9 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
- 数据权限通过角色的 `data-scopes` 保存,并在后台用户列表/导出查询中执行。
- 任务产物默认写入 `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
```
参数只传后台需要的命令字段:`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 分批完成。

View File

@ -12,6 +12,7 @@ import (
type CoinSellerRechargeBillQuery struct {
Keyword string
RegionID int64
StatTZ string
StartMS int64
EndMS int64
Page int

View File

@ -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+"%")
}

View File

@ -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) {
metric := externalDashboardMetric{
D1RetentionUsers: 0,

View File

@ -27,6 +27,7 @@ const (
targetScopeRegion = "region"
targetScopeCountry = "country"
targetScopeAllActiveUsers = "all_active_users"
targetScopeAllRegistered = "all_registered_users"
defaultProducerEventType = "admin_full_server_notice"
defaultBatchSize = 500
@ -47,7 +48,7 @@ type fanoutRequest struct {
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs []int64 `json:"user_ids"`
UserIDs int64List `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
@ -201,7 +202,7 @@ func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, er
func buildTargetFilterJSON(req fanoutRequest) (string, error) {
var payload map[string]any
switch req.TargetScope {
case targetScopeAllActiveUsers:
case targetScopeAllActiveUsers, targetScopeAllRegistered:
payload = map[string]any{}
case targetScopeSingleUser:
if req.TargetUserID <= 0 {
@ -209,7 +210,7 @@ func buildTargetFilterJSON(req fanoutRequest) (string, error) {
}
payload = map[string]any{"target_user_id": req.TargetUserID}
case targetScopeUserIDs:
userIDs := positiveUniqueInt64s(req.UserIDs)
userIDs := positiveUniqueInt64s([]int64(req.UserIDs))
if len(userIDs) == 0 {
return "", fmt.Errorf("user_ids 不能为空")
}
@ -268,13 +269,48 @@ func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
func allowedTargetScope(scope string) bool {
switch scope {
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers:
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers, targetScopeAllRegistered:
return true
default:
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 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))

View File

@ -9,7 +9,7 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "activity",
TargetScope: "user_ids",
UserIDs: []int64{42, 43, 42, 0},
UserIDs: int64List{42, 43, 42, 0},
Title: "活动上线",
Summary: "新活动已上线。",
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) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "system",
@ -53,3 +63,23 @@ func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
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)
}
}

View File

@ -33,7 +33,7 @@ type legacyRechargeBillQuery struct {
RegionID int64
StartAtMS int64
EndAtMS int64
// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验0 保持财务页默认中国时区。
// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验和聚合列表日期解释0 保持财务页默认中国时区。
TzOffsetMinutes int32
Page int
PageSize int
@ -281,6 +281,7 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{
Keyword: query.Keyword,
RegionID: query.RegionID,
StatTZ: legacyDashboardStatTimezone(legacySummaryTZOffset(query)),
StartMS: query.StartAtMS,
EndMS: query.EndAtMS,
Page: query.Page,

View File

@ -284,7 +284,7 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
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)
}
}

View File

@ -79,6 +79,11 @@ func (h *Handler) ExportRechargeBills(c *gin.Context) {
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
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 {
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
@ -89,6 +94,7 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
Page: page,
PageSize: rechargeBillExportPageSize,
})

View File

@ -52,6 +52,11 @@ type googleRechargePaidDTO struct {
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
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{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
@ -59,6 +64,7 @@ func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSou
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
Page: options.Page,
PageSize: options.PageSize,
})

View File

@ -111,8 +111,8 @@ var defaultPermissions = []model.Permission{
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
{Name: "全服通知查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "全服通知发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{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: "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: "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-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},
@ -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: "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-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-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},

View File

@ -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;

View File

@ -19,7 +19,7 @@ func NewGRPCUserTargetSource(conn *grpc.ClientConn) *GRPCUserTargetSource {
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) {
resp, err := s.client.ListUserIDs(ctx, &userv1.ListUserIDsRequest{
Meta: &userv1.RequestMeta{

View File

@ -28,6 +28,7 @@ const (
TargetScopeRegion = "region"
TargetScopeCountry = "country"
TargetScopeAllActiveUsers = "all_active_users"
TargetScopeAllRegistered = "all_registered_users"
)
// TabSection is the message tab summary returned to App clients.

View File

@ -153,7 +153,7 @@ func (s *Service) fanoutTargetPage(ctx context.Context, job messagedomain.Fanout
return []int64{userID}, userID, true, nil
case messagedomain.TargetScopeUserIDs:
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 {
return nil, job.CursorUserID, false, xerr.New(xerr.Unavailable, "fanout target source is not configured")
}

View File

@ -170,7 +170,7 @@ func noticeTargetFilterJSON(cmd NoticeFanoutCommand) (string, error) {
return "", xerr.New(xerr.InvalidArgument, "country is required")
}
payload = map[string]any{"country": country}
case messagedomain.TargetScopeAllActiveUsers:
case messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
payload = map[string]any{}
default:
return "", xerr.New(xerr.InvalidArgument, "target_scope is invalid")

View File

@ -474,7 +474,7 @@ func allowedActionType(actionType string) bool {
func allowedFanoutScope(scope string) bool {
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
default:
return false

View File

@ -293,3 +293,53 @@ func TestFanoutWorkerMaterializesUserIDTargetsByCursor(t *testing.T) {
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
}

View File

@ -48,6 +48,7 @@ type RoomQueryClient interface {
ListRoomsByOwners(ctx context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, 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)
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, 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)
}
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) {
return c.client.GetMyRoom(ctx, req)
}

View File

@ -195,6 +195,10 @@ func (f *fakeUserLeaderboardRoomQueryClient) ListRoomGiftLeaderboard(context.Con
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) {
return &roomv1.GetMyRoomResponse{}, nil
}

View File

@ -162,6 +162,7 @@ type RoomHandlers struct {
GetRoomShareLanding http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc
ListRoomBannedUsers http.HandlerFunc
GetRoomContributionRank http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc
GetRoomRocket http.HandlerFunc
FollowRoom http.HandlerFunc
@ -517,6 +518,7 @@ func (r routes) registerRoomRoutes() {
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}/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}/rocket", http.MethodGet, h.GetRoomRocket)
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)

View File

@ -392,6 +392,7 @@ type fakeRoomQueryClient struct {
lastRocket *roomv1.GetRoomRocketRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest
lastBannedUsers *roomv1.ListRoomBannedUsersRequest
lastContributionRank *roomv1.ListRoomContributionRankRequest
resp *roomv1.ListRoomsResponse
ownersResp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
@ -402,6 +403,7 @@ type fakeRoomQueryClient struct {
rocketResp *roomv1.GetRoomRocketResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse
bannedUsersResp *roomv1.ListRoomBannedUsersResponse
contributionRankResp *roomv1.ListRoomContributionRankResponse
err error
ownersErr error
feedsErr error
@ -412,6 +414,7 @@ type fakeRoomQueryClient struct {
rocketErr error
onlineErr error
bannedUsersErr error
contributionRankErr error
}
type fakeUserDeviceClient struct {
@ -1285,6 +1288,17 @@ func (f *fakeRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.L
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) {
f.lastMyRoom = req
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) {
queryClient := &fakeRoomQueryClient{}
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: "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_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: "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"}`},

View File

@ -76,6 +76,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
GetRoomShareLanding: h.getRoomShareLanding,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
ListRoomBannedUsers: h.listRoomBannedUsers,
GetRoomContributionRank: h.getRoomContributionRank,
GetRoomGiftPanel: h.getRoomGiftPanel,
GetRoomRocket: h.getRoomRocket,
FollowRoom: h.handleRoomFollow,

View File

@ -678,6 +678,49 @@ func (h *Handler) listRoomBannedUsers(writer http.ResponseWriter, request *http.
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 返回房间送礼面板初始化数据。
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil || h.walletClient == nil {

View File

@ -285,6 +285,10 @@ func (c *shareRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.
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) {
return &roomv1.GetMyRoomResponse{}, nil
}

View File

@ -142,6 +142,26 @@ type roomBannedUserData struct {
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 {
Result roomCommandResultData `json:"result"`
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 {
items := make([]*roomv1.RoomOnlineUser, 0, len(users))
for _, user := range users {

View File

@ -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)
) 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 (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',

View File

@ -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='房间用户周期贡献榜统计表';

View File

@ -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)
}
}

View File

@ -434,6 +434,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
CoinSpent: roomGiftLeaderboardValue(f.billing),
OccurredAtMS: now.UnixMilli(),
},
roomUserContributionPeriodStats: roomContributionPeriodStatIncrements(appcode.FromContext(f.ctx), current.RoomID, f.cmd.ActorUserID(), f.heatValue, now.UnixMilli()),
roomUserGiftStats: []RoomUserGiftStatIncrement{
{
AppCode: appcode.FromContext(f.ctx),
@ -492,6 +493,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
if f.cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil
result.roomUserContributionPeriodStats = nil
result.roomUserGiftStats = nil
result.roomWeeklyContribution = nil
}

View File

@ -78,6 +78,23 @@ func TestSendGiftWritesRoomGiftLeaderboardWithHeatValue(t *testing.T) {
if leaderboard.increments[0].CoinSpent != 10 {
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) {
@ -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 {
t.Helper()

View File

@ -109,6 +109,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
VisibleRegionID: result.visibleRegionID,
CloseInfo: result.closeInfo,
RoomUserGiftStats: result.roomUserGiftStats,
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
RoomWeeklyContribution: result.roomWeeklyContribution,
})
commitCancel()

View File

@ -76,6 +76,8 @@ type MutationCommit struct {
CloseInfo *RoomCloseInfo
// RoomUserGiftStats 是本次命令产生的房间用户送礼价值增量,必须和命令日志同事务提交。
RoomUserGiftStats []RoomUserGiftStatIncrement
// RoomUserContributionPeriodStats 是本次命令产生的房间用户周期贡献增量,必须和命令日志同事务提交。
RoomUserContributionPeriodStats []RoomUserContributionPeriodStatIncrement
// RoomWeeklyContribution 是后台房间贡献列的 UTC 周期读模型增量;它和命令日志同事务提交,避免送礼成功后榜单缺分。
RoomWeeklyContribution *RoomWeeklyContributionIncrement
}
@ -89,6 +91,18 @@ type RoomUserGiftStatIncrement struct {
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 周内新增的展示贡献。
type RoomWeeklyContributionIncrement struct {
AppCode string
@ -485,6 +499,12 @@ type RoomOnlineUserPage struct {
PageSize int
}
// RoomContributionRankStore 管理房间内用户周期贡献榜读模型。
type RoomContributionRankStore interface {
// ListRoomContributionRank 查询房间内某周期 Top 用户贡献,不扫描命令日志或 outbox。
ListRoomContributionRank(ctx context.Context, query RoomContributionRankQuery) (RoomContributionRankPage, error)
}
// RoomListQuery 是 room-service 房间列表读模型查询条件。
type RoomListQuery struct {
// AppCode 是列表查询所属 App通常来自 gateway RequestMeta。
@ -667,6 +687,7 @@ type Repository interface {
OutboxStore
RoomListStore
PresenceStore
RoomContributionRankStore
AdminRoomStore
RobotRoomStore
}

View File

@ -197,6 +197,8 @@ type mutationResult struct {
walletDebitMS int64
// roomGiftLeaderboard 是 SendGift 成功后写入跨房间贡献榜的轻量增量。
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
// roomUserContributionPeriodStats 是当前房间用户周期贡献统计,和命令日志同事务提交。
roomUserContributionPeriodStats []RoomUserContributionPeriodStatIncrement
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
roomUserGiftStats []RoomUserGiftStatIncrement
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。

View File

@ -248,6 +248,39 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
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 {
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
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_presence 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_snapshots WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,

View File

@ -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
}

View File

@ -89,6 +89,20 @@ func (r *Repository) Migrate(ctx context.Context) error {
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)
) 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 (
app_code VARCHAR(32) NOT NULL,
background_id BIGINT NOT NULL AUTO_INCREMENT,

View File

@ -506,6 +506,13 @@ func (s *Server) ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListRo
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 顶部“我的房间”权威查询。
func (s *Server) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
// 我的房间查询直接读取 rooms 元数据,并用快照补齐实时卡片字段,不依赖发现页投影。

View File

@ -3,6 +3,8 @@ package user
const (
// UserIDTargetAllActive supports low-frequency full-App jobs without exposing profile fields.
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 = "region"
// UserIDTargetCountry selects active users by their current country code snapshot.

View File

@ -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)
}
}

View File

@ -113,7 +113,7 @@ func (s *Service) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (
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) {
if s.userRepository == nil {
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")
}
switch filter.TargetScope {
case userdomain.UserIDTargetAllActiveUsers:
case userdomain.UserIDTargetAllActiveUsers, userdomain.UserIDTargetAllRegistered:
case userdomain.UserIDTargetRegion:
if filter.RegionID <= 0 {
return nil, 0, false, xerr.New(xerr.InvalidArgument, "region_id is required")

View File

@ -135,17 +135,21 @@ func (r *Repository) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64
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) {
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
args := []any{appcode.FromContext(ctx), string(userdomain.StatusActive), filter.CursorUserID}
conditions := []string{"app_code = ?", "user_id > ?"}
args := []any{appcode.FromContext(ctx), filter.CursorUserID}
switch filter.TargetScope {
case userdomain.UserIDTargetAllRegistered:
case userdomain.UserIDTargetAllActiveUsers:
conditions = append(conditions, "status = ?")
args = append(args, string(userdomain.StatusActive))
case userdomain.UserIDTargetRegion:
conditions = append(conditions, "region_id = ?")
args = append(args, filter.RegionID)
conditions = append(conditions, "status = ?", "region_id = ?")
args = append(args, string(userdomain.StatusActive), filter.RegionID)
case userdomain.UserIDTargetCountry:
conditions = append(conditions, "country = ?")
args = append(args, filter.Country)
conditions = append(conditions, "status = ?", "country = ?")
args = append(args, string(userdomain.StatusActive), filter.Country)
}
args = append(args, filter.PageSize)

View File

@ -4643,8 +4643,8 @@ func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T
}
}
// TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient 验证余额不足时整个撤销事务回滚,权益和发放记录仍保持成功状态
func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(t *testing.T) {
// TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
@ -4689,31 +4689,38 @@ func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(t *testing.T) {
entitlementID := entitlementIDFromGrant(t, grant)
repository.SetBalance(42102, 100)
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
RequestID: "cmd-revoke-insufficient",
GrantID: grant.GrantID,
Reason: "test insufficient",
OperatorUserID: 90002,
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("revoke should fail with insufficient balance, got %v", err)
if err != nil {
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 {
t.Fatalf("failed revoke must keep grant succeeded, got %d", got)
if revoked.Status != resourcedomain.GrantStatusRevoked {
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 {
t.Fatalf("failed revoke must keep entitlement active, got %d", got)
assertBalance(t, svc, 42102, ledger.AssetCoin, 0)
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 {
t.Fatalf("failed revoke must keep equipment row, got %d", got)
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 {
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 {
t.Fatalf("failed revoke must not write reverse transaction, got %d", got)
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 0 {
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 固定撤销范围:只允许成功的资源组发放,单资源、异常状态和不存在记录都不能误处理。
func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
// TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration 锁定单资源撤回的金币夹零和素材天数扣减语义
func TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
@ -4733,26 +4740,113 @@ func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
t.Fatalf("create single coin resource failed: %v", err)
}
singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-single-revoke-reject",
CommandID: "cmd-single-coin-revoke",
TargetUserID: 42103,
ResourceID: coinResource.ResourceID,
Quantity: 1,
Reason: "single reject",
Quantity: 2,
Reason: "single coin revoke",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
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{
RequestID: "cmd-revoke-single-reject",
repository.SetBalance(42103, 50)
coinRevoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
RequestID: "cmd-revoke-single-coin",
GrantID: singleGrant.GrantID,
Reason: "single reject",
Reason: "single coin revoke",
OperatorUserID: 90002,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("single resource revoke should be invalid, got %v", err)
if err != nil {
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{
GroupCode: "failed_revoke_reject_pack",

View File

@ -444,6 +444,17 @@ func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.T
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) {
var item resourcedomain.UserResourceEntitlement
var resource resourcedomain.Resource

View File

@ -137,7 +137,7 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
return r.GetResourceGrant(ctx, grantID)
}
// RevokeResourceGrant 撤销一次成功的资源组发放;钱包扣回、权益失效、装备清理和发放状态更新必须同事务提交。
// RevokeResourceGrant 撤销一次成功的资源发放;钱包扣回、权益扣减、装备清理和发放状态更新必须同事务提交。
func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error) {
if r == nil || r.db == nil {
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
}
if grant.GrantSubjectType != resourcedomain.GrantSubjectGroup {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "only resource group grant can be revoked")
}
if grant.Status != resourcedomain.GrantStatusDone {
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 {
switch item.ResultType {
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 {
return resourcedomain.ResourceGrant{}, err
}
if changed {
outboxEvents = append(outboxEvents, event)
}
case resourcedomain.ResultEntitlement:
event, err := r.revokeGrantedEntitlement(ctx, tx, grant, item, command, nowMs)
if err != nil {
@ -220,8 +219,8 @@ func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedo
}
revokeCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, 0)
groupID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, groupID, map[string]any{
subjectID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, subjectID, map[string]any{
"action": "revoke",
"app_code": command.AppCode,
"grant_id": grant.GrantID,
@ -464,24 +463,28 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx,
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) == "" {
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)
if err != nil {
return walletOutboxEvent{}, err
return walletOutboxEvent{}, false, err
}
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)
if err != nil {
return walletOutboxEvent{}, err
return walletOutboxEvent{}, false, err
}
if account.AvailableAmount < original.AvailableDelta {
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "insufficient balance to revoke resource grant")
revokeAmount := original.AvailableDelta
if account.AvailableAmount < revokeAmount {
revokeAmount = account.AvailableAmount
}
if revokeAmount <= 0 {
return walletOutboxEvent{}, false, nil
}
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,
"target_user_id": grant.TargetUserID,
"asset_type": original.AssetType,
"wallet_amount": original.AvailableDelta,
"wallet_amount": revokeAmount,
"original_wallet_amount": original.AvailableDelta,
"original_transaction_id": item.WalletTransactionID,
"revoked_by_user_id": command.OperatorUserID,
"revoke_reason": command.Reason,
"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 {
return walletOutboxEvent{}, err
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{}, false, err
}
if err := r.applyAccountDelta(ctx, tx, account, -original.AvailableDelta, 0, nowMs); err != nil {
return walletOutboxEvent{}, err
if err := r.applyAccountDelta(ctx, tx, account, -revokeAmount, 0, nowMs); err != nil {
return walletOutboxEvent{}, false, err
}
availableAfter := account.AvailableAmount - original.AvailableDelta
availableAfter := account.AvailableAmount - revokeAmount
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: grant.TargetUserID,
AssetType: original.AssetType,
AvailableDelta: -original.AvailableDelta,
AvailableDelta: -revokeAmount,
FrozenDelta: 0,
AvailableAfter: availableAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); 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) {
if strings.TrimSpace(item.EntitlementID) == "" {
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, `
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 = ?`,
resourcedomain.GrantStatusRevoked, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
nextStatus, nextQuantity, nextRemaining, nextExpiresAt, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
)
if err != nil {
return walletOutboxEvent{}, err
@ -541,6 +572,7 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
if affected != 1 {
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is not reversible")
}
if nextStatus != resourcedomain.StatusActive {
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_resource_equipment
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
@ -548,6 +580,7 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
); err != nil {
return walletOutboxEvent{}, err
}
}
eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
return resourceOutboxEvent("UserResourceChanged", eventCommandID, grant.TargetUserID, item.ResourceID, map[string]any{
@ -558,6 +591,10 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
"grant_item_id": item.GrantItemID,
"resource_id": item.ResourceID,
"entitlement_id": item.EntitlementID,
"status": nextStatus,
"quantity": nextQuantity,
"remaining_quantity": nextRemaining,
"expires_at_ms": nextExpiresAt,
"revoked_by_user_id": command.OperatorUserID,
"revoke_reason": command.Reason,
"updated_at_ms": nowMs,