资源撤回,系统消息

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
@ -43,30 +44,30 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type fanoutRequest struct {
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs []int64 `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs int64List `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
}
type fanoutDTO struct {
@ -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,18 +79,24 @@ 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++ {
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: page,
PageSize: rechargeBillExportPageSize,
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
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,
})
if err != nil {
response.ServerError(c, "导出账单失败")

View File

@ -52,15 +52,21 @@ 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")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
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,
})
if err != nil {
response.ServerError(c, "获取账单列表失败")

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

@ -152,43 +152,44 @@ type ManagerHandlers struct {
}
type RoomHandlers struct {
GetMyRoom http.HandlerFunc
ListRoomFeeds http.HandlerFunc
ListRooms http.HandlerFunc
GetCurrentRoom http.HandlerFunc
GetRoomSnapshot http.HandlerFunc
GetRoomDetail http.HandlerFunc
GetRoomShare http.HandlerFunc
GetRoomShareLanding http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc
ListRoomBannedUsers http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc
GetRoomRocket http.HandlerFunc
FollowRoom http.HandlerFunc
CreateRoom http.HandlerFunc
UpdateRoomProfile http.HandlerFunc
SaveRoomBackground http.HandlerFunc
ListRoomBackgrounds http.HandlerFunc
SetRoomBackground http.HandlerFunc
JoinRoom http.HandlerFunc
RoomHeartbeat http.HandlerFunc
LeaveRoom http.HandlerFunc
CloseRoom http.HandlerFunc
MicUp http.HandlerFunc
MicDown http.HandlerFunc
ChangeMicSeat http.HandlerFunc
ConfirmMicPublishing http.HandlerFunc
MicHeartbeat http.HandlerFunc
SetMicMute http.HandlerFunc
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomPassword http.HandlerFunc
SetRoomAdmin http.HandlerFunc
MuteUser http.HandlerFunc
KickUser http.HandlerFunc
UnbanUser http.HandlerFunc
SendGift http.HandlerFunc
SendGiftBatch http.HandlerFunc
GetMyRoom http.HandlerFunc
ListRoomFeeds http.HandlerFunc
ListRooms http.HandlerFunc
GetCurrentRoom http.HandlerFunc
GetRoomSnapshot http.HandlerFunc
GetRoomDetail http.HandlerFunc
GetRoomShare http.HandlerFunc
GetRoomShareLanding http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc
ListRoomBannedUsers http.HandlerFunc
GetRoomContributionRank http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc
GetRoomRocket http.HandlerFunc
FollowRoom http.HandlerFunc
CreateRoom http.HandlerFunc
UpdateRoomProfile http.HandlerFunc
SaveRoomBackground http.HandlerFunc
ListRoomBackgrounds http.HandlerFunc
SetRoomBackground http.HandlerFunc
JoinRoom http.HandlerFunc
RoomHeartbeat http.HandlerFunc
LeaveRoom http.HandlerFunc
CloseRoom http.HandlerFunc
MicUp http.HandlerFunc
MicDown http.HandlerFunc
ChangeMicSeat http.HandlerFunc
ConfirmMicPublishing http.HandlerFunc
MicHeartbeat http.HandlerFunc
SetMicMute http.HandlerFunc
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomPassword http.HandlerFunc
SetRoomAdmin http.HandlerFunc
MuteUser http.HandlerFunc
KickUser http.HandlerFunc
UnbanUser http.HandlerFunc
SendGift http.HandlerFunc
SendGiftBatch http.HandlerFunc
}
type MessageHandlers struct {
@ -517,6 +518,7 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/{room_id}/share", http.MethodGet, h.GetRoomShare)
r.profile("/rooms/{room_id}/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

@ -382,36 +382,39 @@ type fakeUserIdentityClient struct {
}
type fakeRoomQueryClient struct {
lastList *roomv1.ListRoomsRequest
lastListOwners *roomv1.ListRoomsByOwnersRequest
lastFeeds *roomv1.ListRoomFeedsRequest
lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
lastRocket *roomv1.GetRoomRocketRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest
lastBannedUsers *roomv1.ListRoomBannedUsersRequest
resp *roomv1.ListRoomsResponse
ownersResp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
backgroundsResp *roomv1.ListRoomBackgroundsResponse
rocketResp *roomv1.GetRoomRocketResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse
bannedUsersResp *roomv1.ListRoomBannedUsersResponse
err error
ownersErr error
feedsErr error
myRoomErr error
currentErr error
snapshotErr error
backgroundsErr error
rocketErr error
onlineErr error
bannedUsersErr error
lastList *roomv1.ListRoomsRequest
lastListOwners *roomv1.ListRoomsByOwnersRequest
lastFeeds *roomv1.ListRoomFeedsRequest
lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
lastRocket *roomv1.GetRoomRocketRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest
lastBannedUsers *roomv1.ListRoomBannedUsersRequest
lastContributionRank *roomv1.ListRoomContributionRankRequest
resp *roomv1.ListRoomsResponse
ownersResp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
backgroundsResp *roomv1.ListRoomBackgroundsResponse
rocketResp *roomv1.GetRoomRocketResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse
bannedUsersResp *roomv1.ListRoomBannedUsersResponse
contributionRankResp *roomv1.ListRoomContributionRankResponse
err error
ownersErr error
feedsErr error
myRoomErr error
currentErr error
snapshotErr error
backgroundsErr error
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

@ -66,43 +66,44 @@ func New(config Config) *Handler {
func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
return httproutes.RoomHandlers{
GetMyRoom: h.getMyRoom,
ListRoomFeeds: h.listRoomFeeds,
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
GetRoomDetail: h.getRoomDetail,
GetRoomShare: h.getRoomShare,
GetRoomShareLanding: h.getRoomShareLanding,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
ListRoomBannedUsers: h.listRoomBannedUsers,
GetRoomGiftPanel: h.getRoomGiftPanel,
GetRoomRocket: h.getRoomRocket,
FollowRoom: h.handleRoomFollow,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
SaveRoomBackground: h.saveRoomBackground,
ListRoomBackgrounds: h.listRoomBackgrounds,
SetRoomBackground: h.setRoomBackground,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
CloseRoom: h.closeRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
MicHeartbeat: h.micHeartbeat,
SetMicMute: h.setMicMute,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomPassword: h.setRoomPassword,
SetRoomAdmin: h.setRoomAdmin,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
SendGiftBatch: h.sendGiftBatch,
GetMyRoom: h.getMyRoom,
ListRoomFeeds: h.listRoomFeeds,
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
GetRoomDetail: h.getRoomDetail,
GetRoomShare: h.getRoomShare,
GetRoomShareLanding: h.getRoomShareLanding,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
ListRoomBannedUsers: h.listRoomBannedUsers,
GetRoomContributionRank: h.getRoomContributionRank,
GetRoomGiftPanel: h.getRoomGiftPanel,
GetRoomRocket: h.getRoomRocket,
FollowRoom: h.handleRoomFollow,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
SaveRoomBackground: h.saveRoomBackground,
ListRoomBackgrounds: h.listRoomBackgrounds,
SetRoomBackground: h.setRoomBackground,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
CloseRoom: h.closeRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
MicHeartbeat: h.micHeartbeat,
SetMicMute: h.setMicMute,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomPassword: h.setRoomPassword,
SetRoomAdmin: h.setRoomAdmin,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
SendGiftBatch: h.sendGiftBatch,
}
}

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

@ -100,16 +100,17 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: durableOutboxRecords,
DeleteRoom: result.deleteRoom,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
RoomMode: result.roomMode,
VisibleRegionID: result.visibleRegionID,
CloseInfo: result.closeInfo,
RoomUserGiftStats: result.roomUserGiftStats,
RoomWeeklyContribution: result.roomWeeklyContribution,
OutboxRecords: durableOutboxRecords,
DeleteRoom: result.deleteRoom,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
RoomMode: result.roomMode,
VisibleRegionID: result.visibleRegionID,
CloseInfo: result.closeInfo,
RoomUserGiftStats: result.roomUserGiftStats,
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
RoomWeeklyContribution: result.roomWeeklyContribution,
})
commitCancel()
if err != nil {

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
}
outboxEvents = append(outboxEvents, event)
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,12 +572,14 @@ 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 _, err := tx.ExecContext(ctx, `
DELETE FROM user_resource_equipment
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
command.AppCode, grant.TargetUserID, item.EntitlementID,
); err != nil {
return walletOutboxEvent{}, err
if nextStatus != resourcedomain.StatusActive {
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_resource_equipment
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
command.AppCode, grant.TargetUserID, item.EntitlementID,
); err != nil {
return walletOutboxEvent{}, err
}
}
eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
@ -558,6 +591,10 @@ func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, g
"grant_item_id": item.GrantItemID,
"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,