Compare commits

...

21 Commits

Author SHA1 Message Date
zhx
f8ec2118b9 增加房间流水奖励修复 2026-06-29 10:41:24 +08:00
local
151cf495e4 bi报表和财务系统 2026-06-28 23:24:57 +08:00
local
1409529f10 新BI系统 2026-06-28 15:11:56 +08:00
local
4060aee8af 在线状态 2026-06-27 23:39:22 +08:00
zhx
400363ec65 幸运礼物倍数 2026-06-27 18:22:14 +08:00
zhx
c47e4e6832 增加批量送礼 2026-06-27 16:59:30 +08:00
zhx
a1e038442d 直接发im,去掉 outbox一层 2026-06-27 00:40:30 +08:00
zhx
2e061101b3 删除掉机器人outbox 2026-06-26 23:21:22 +08:00
zhx
beab5f57c8 修复钱包错误 2026-06-26 22:14:11 +08:00
zhx
2254097ea4 修复性能问题 2026-06-26 21:31:14 +08:00
zhx
902ca5dc3c 增加redis聚合 2026-06-26 17:56:30 +08:00
zhx
fe736780e0 成就,财富魅力等级 2026-06-26 16:20:58 +08:00
zhx
538668014b fix(room): optimize robot outbox claim query 2026-06-26 16:08:31 +08:00
zhx
0039c7f2ee fix(activity): skip zero heat gift growth events 2026-06-26 15:17:53 +08:00
zhx
bea9dcf1dd 增加接口判断 2026-06-26 10:51:43 +08:00
zhx
5890e3321a 新注册流程 2026-06-26 10:15:48 +08:00
zhx
210bef6955 幸运礼物方案 2026-06-25 23:35:44 +08:00
zhx
a10a851075 回滚注册 2026-06-25 22:00:49 +08:00
zhx
d145f3608d 修复用户登录 2026-06-25 21:48:44 +08:00
zhx
4c9bca8008 修复私聊送礼 2026-06-25 21:46:56 +08:00
zhx
ba0e5664dd 注册,im私聊消息 2026-06-25 21:30:26 +08:00
249 changed files with 31395 additions and 13523 deletions

View File

@ -7,7 +7,7 @@ ADMIN_CONFIG ?= configs/config.yaml
DEV_RUN_SERVICES ?= all
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init databi-real-flow-check rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
proto:
@ -48,6 +48,7 @@ build:
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
run:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
./scripts/prepare-local-rocketmq-config.sh
ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker
./scripts/apply-local-mysql-initdb.sh
@ -69,6 +70,7 @@ admin-up: admin-deps admin
# `admin-deps` 拉起后台本地运行所需的 MySQL、Redis 和 user-service。
admin-deps:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
@ -92,6 +94,7 @@ admin-build:
# 完整 up 结束后自动启动 admin 前台进程。
up:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
@ -114,9 +117,14 @@ db-init:
./scripts/resolve-compose-container-conflicts.sh mysql
./scripts/apply-local-mysql-initdb.sh
# `databi-real-flow-check` 用真实 owner outbox 重放并审计 Databi 口径;充值允许走本地模拟回调,其余必须先通过真实业务流程产生事实。
databi-real-flow-check:
./scripts/databi-real-flow-check.sh
# `rebuild` 重新构建并启动单个本地服务。
rebuild: check-service
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
@ -178,6 +186,7 @@ resolve-compose-conflicts:
# `up-service` 是 `up SERVICE=...` 的内部实现。
up-service: check-service
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \

View File

@ -193,7 +193,6 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent``RoomRocketIgnited` 等房间事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_robot_room_outbox` | `hyapp-room-robot-outbox-producer` | `hyapp-test-room-robot-im-bridge` / `hyapp-room-robot-im-bridge` | room-service IM bridge 只消费机器人房间礼物、热度和麦位热度展示事实;本地/testbox 用 test group线上用正式 group |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-statistics-room-outbox` | statistics-service 消费 `RoomGiftSent``RoomUserJoined` 并更新聚合表 |
| `hyapp_user_outbox` | `hyapp-user-outbox-producer` | `hyapp-statistics-user-outbox` | statistics-service 消费 `UserRegistered` 并更新注册 cohort |

File diff suppressed because it is too large Load Diff

View File

@ -1711,6 +1711,16 @@ message UpsertAchievementDefinitionResponse {
bool created = 2;
}
message DeleteAchievementDefinitionRequest {
RequestMeta meta = 1;
string achievement_id = 2;
int64 operator_admin_id = 3;
}
message DeleteAchievementDefinitionResponse {
AchievementDefinition achievement = 1;
}
message LuckyGiftMeta {
RequestMeta meta = 1;
string command_id = 2;
@ -1876,6 +1886,14 @@ message ExecuteLuckyGiftDrawResponse {
LuckyGiftDrawResult result = 1;
}
message BatchExecuteLuckyGiftDrawRequest {
repeated LuckyGiftMeta lucky_gifts = 1;
}
message BatchExecuteLuckyGiftDrawResponse {
repeated LuckyGiftDrawResult results = 1;
}
message GetLuckyGiftConfigRequest {
RequestMeta meta = 1;
string gift_id = 2;
@ -2575,6 +2593,7 @@ service AchievementService {
service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
rpc BatchExecuteLuckyGiftDraw(BatchExecuteLuckyGiftDrawRequest) returns (BatchExecuteLuckyGiftDrawResponse);
}
// WheelService owns wheel draw decisions after wallet debit succeeds.
@ -2743,6 +2762,7 @@ service AdminGrowthLevelService {
service AdminAchievementService {
rpc ListAchievementDefinitions(ListAchievementsRequest) returns (ListAchievementsResponse);
rpc UpsertAchievementDefinition(UpsertAchievementDefinitionRequest) returns (UpsertAchievementDefinitionResponse);
rpc DeleteAchievementDefinition(DeleteAchievementDefinitionRequest) returns (DeleteAchievementDefinitionResponse);
}
// AdminLuckyGiftService is the admin entry for lucky gift rule versions and draw audit.

View File

@ -1788,8 +1788,9 @@ var AchievementService_ServiceDesc = grpc.ServiceDesc{
}
const (
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/BatchExecuteLuckyGiftDraw"
)
// LuckyGiftServiceClient is the client API for LuckyGiftService service.
@ -1800,6 +1801,7 @@ const (
type LuckyGiftServiceClient interface {
CheckLuckyGift(ctx context.Context, in *CheckLuckyGiftRequest, opts ...grpc.CallOption) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(ctx context.Context, in *ExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*ExecuteLuckyGiftDrawResponse, error)
BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error)
}
type luckyGiftServiceClient struct {
@ -1830,6 +1832,16 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
return out, nil
}
func (c *luckyGiftServiceClient) BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchExecuteLuckyGiftDrawResponse)
err := c.cc.Invoke(ctx, LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// LuckyGiftServiceServer is the server API for LuckyGiftService service.
// All implementations must embed UnimplementedLuckyGiftServiceServer
// for forward compatibility.
@ -1838,6 +1850,7 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
type LuckyGiftServiceServer interface {
CheckLuckyGift(context.Context, *CheckLuckyGiftRequest) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error)
BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error)
mustEmbedUnimplementedLuckyGiftServiceServer()
}
@ -1854,6 +1867,9 @@ func (UnimplementedLuckyGiftServiceServer) CheckLuckyGift(context.Context, *Chec
func (UnimplementedLuckyGiftServiceServer) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecuteLuckyGiftDraw not implemented")
}
func (UnimplementedLuckyGiftServiceServer) BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchExecuteLuckyGiftDraw not implemented")
}
func (UnimplementedLuckyGiftServiceServer) mustEmbedUnimplementedLuckyGiftServiceServer() {}
func (UnimplementedLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -1911,6 +1927,24 @@ func _LuckyGiftService_ExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchExecuteLuckyGiftDrawRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, req.(*BatchExecuteLuckyGiftDrawRequest))
}
return interceptor(ctx, in, info, handler)
}
// LuckyGiftService_ServiceDesc is the grpc.ServiceDesc for LuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1926,6 +1960,10 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_ExecuteLuckyGiftDraw_Handler,
},
{
MethodName: "BatchExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
@ -6175,6 +6213,7 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
const (
AdminAchievementService_ListAchievementDefinitions_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/ListAchievementDefinitions"
AdminAchievementService_UpsertAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/UpsertAchievementDefinition"
AdminAchievementService_DeleteAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/DeleteAchievementDefinition"
)
// AdminAchievementServiceClient is the client API for AdminAchievementService service.
@ -6185,6 +6224,7 @@ const (
type AdminAchievementServiceClient interface {
ListAchievementDefinitions(ctx context.Context, in *ListAchievementsRequest, opts ...grpc.CallOption) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, in *UpsertAchievementDefinitionRequest, opts ...grpc.CallOption) (*UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(ctx context.Context, in *DeleteAchievementDefinitionRequest, opts ...grpc.CallOption) (*DeleteAchievementDefinitionResponse, error)
}
type adminAchievementServiceClient struct {
@ -6215,6 +6255,16 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
return out, nil
}
func (c *adminAchievementServiceClient) DeleteAchievementDefinition(ctx context.Context, in *DeleteAchievementDefinitionRequest, opts ...grpc.CallOption) (*DeleteAchievementDefinitionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteAchievementDefinitionResponse)
err := c.cc.Invoke(ctx, AdminAchievementService_DeleteAchievementDefinition_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminAchievementServiceServer is the server API for AdminAchievementService service.
// All implementations must embed UnimplementedAdminAchievementServiceServer
// for forward compatibility.
@ -6223,6 +6273,7 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
type AdminAchievementServiceServer interface {
ListAchievementDefinitions(context.Context, *ListAchievementsRequest) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(context.Context, *DeleteAchievementDefinitionRequest) (*DeleteAchievementDefinitionResponse, error)
mustEmbedUnimplementedAdminAchievementServiceServer()
}
@ -6239,6 +6290,9 @@ func (UnimplementedAdminAchievementServiceServer) ListAchievementDefinitions(con
func (UnimplementedAdminAchievementServiceServer) UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertAchievementDefinition not implemented")
}
func (UnimplementedAdminAchievementServiceServer) DeleteAchievementDefinition(context.Context, *DeleteAchievementDefinitionRequest) (*DeleteAchievementDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteAchievementDefinition not implemented")
}
func (UnimplementedAdminAchievementServiceServer) mustEmbedUnimplementedAdminAchievementServiceServer() {
}
func (UnimplementedAdminAchievementServiceServer) testEmbeddedByValue() {}
@ -6297,6 +6351,24 @@ func _AdminAchievementService_UpsertAchievementDefinition_Handler(srv interface{
return interceptor(ctx, in, info, handler)
}
func _AdminAchievementService_DeleteAchievementDefinition_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteAchievementDefinitionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAchievementServiceServer).DeleteAchievementDefinition(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAchievementService_DeleteAchievementDefinition_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAchievementServiceServer).DeleteAchievementDefinition(ctx, req.(*DeleteAchievementDefinitionRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminAchievementService_ServiceDesc is the grpc.ServiceDesc for AdminAchievementService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -6312,6 +6384,10 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertAchievementDefinition",
Handler: _AdminAchievementService_UpsertAchievementDefinition_Handler,
},
{
MethodName: "DeleteAchievementDefinition",
Handler: _AdminAchievementService_DeleteAchievementDefinition_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,11 @@ message RoomUserJoined {
int64 region_id = 6;
// is_robot
bool is_robot = 7;
// nickname/avatar/display_user_id IM user-service
string nickname = 8;
string avatar = 9;
string display_user_id = 10;
string pretty_display_user_id = 11;
}
// RoomUserLeft
@ -98,6 +103,11 @@ message RoomMicChanged {
bool mic_muted = 11;
string seat_status = 12;
int64 target_gift_value = 13;
// target_* IM/UI
string target_nickname = 14;
string target_avatar = 15;
string target_display_user_id = 16;
string target_pretty_display_user_id = 17;
}
// RoomMicSeatLocked
@ -184,6 +194,74 @@ message RoomGiftSent {
string receiver_avatar = 28;
string receiver_display_user_id = 29;
string receiver_pretty_display_user_id = 30;
// display_mode=batch activityCP RoomGiftBatchSent
string display_mode = 31;
}
// RoomGiftBatchLuckyResult
// IM activity
message RoomGiftBatchLuckyResult {
bool enabled = 1;
string draw_id = 2;
string command_id = 3;
string pool_id = 4;
string gift_id = 5;
int64 rule_version = 6;
string experience_pool = 7;
string selected_tier_id = 8;
int64 multiplier_ppm = 9;
int64 base_reward_coins = 10;
int64 room_atmosphere_reward_coins = 11;
int64 activity_subsidy_coins = 12;
int64 effective_reward_coins = 13;
string reward_status = 14;
bool stage_feedback = 15;
bool high_multiplier = 16;
int64 created_at_ms = 17;
int64 target_user_id = 18;
}
// RoomGiftBatchTarget
// gateway user-service IM bridge
message RoomGiftBatchTarget {
int64 target_user_id = 1;
string receiver_nickname = 2;
string receiver_avatar = 3;
string receiver_display_user_id = 4;
string receiver_pretty_display_user_id = 5;
int64 gift_value = 6;
int64 target_gift_value = 7;
int64 coin_spent = 8;
string billing_receipt_id = 9;
string command_id = 10;
RoomGiftBatchLuckyResult lucky_gift = 11;
}
// RoomGiftBatchSent Flutter
// RoomGiftSent durable outbox CP
message RoomGiftBatchSent {
int64 sender_user_id = 1;
string sender_name = 2;
string sender_avatar = 3;
string sender_display_user_id = 4;
string sender_pretty_display_user_id = 5;
string gift_id = 6;
int32 gift_count = 7;
int64 total_gift_value = 8;
int64 total_coin_spent = 9;
string billing_receipt_id = 10;
int64 room_heat = 11;
string pool_id = 12;
string gift_type_code = 13;
string cp_relation_type = 14;
string gift_name = 15;
string gift_icon_url = 16;
string gift_animation_url = 17;
repeated string gift_effect_types = 18;
repeated int64 target_user_ids = 19;
repeated RoomGiftBatchTarget targets = 20;
string command_id = 21;
int64 visible_region_id = 22;
}
// RoomRobotLuckyGiftDrawn

File diff suppressed because it is too large Load Diff

View File

@ -635,6 +635,15 @@ message RoomEntryVehicleSnapshot {
int64 expires_at_ms = 9;
}
// RoomUserDisplayProfile gateway IM/UI
message RoomUserDisplayProfile {
int64 user_id = 1;
string nickname = 2;
string avatar = 3;
string display_user_id = 4;
string pretty_display_user_id = 5;
}
message JoinRoomRequest {
RequestMeta meta = 1;
string role = 2;
@ -645,6 +654,8 @@ message JoinRoomRequest {
int64 actor_region_id = 6;
// actor_is_robot room-service gateway false
bool actor_is_robot = 7;
// actor_display_profile room-service IM/outbox RoomState
RoomUserDisplayProfile actor_display_profile = 8;
}
// JoinRoomResponse
@ -776,6 +787,8 @@ message AdminDeleteRoomResponse {
message MicUpRequest {
RequestMeta meta = 1;
int32 seat_no = 2;
// target_display_profile room-service IM/outbox
RoomUserDisplayProfile target_display_profile = 3;
}
// MicUpResponse
@ -793,6 +806,8 @@ message MicDownRequest {
int64 target_user_id = 2;
// reason publish_timeout
string reason = 3;
// target_display_profile IM
RoomUserDisplayProfile target_display_profile = 4;
}
// MicDownResponse
@ -807,6 +822,8 @@ message ChangeMicSeatRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
int32 seat_no = 3;
// target_display_profile 线
RoomUserDisplayProfile target_display_profile = 4;
}
// ChangeMicSeatResponse
@ -825,6 +842,8 @@ message ConfirmMicPublishingRequest {
int64 room_version = 4;
int64 event_time_ms = 5;
string source = 6;
// target_display_profile
RoomUserDisplayProfile target_display_profile = 7;
}
// ConfirmMicPublishingResponse
@ -857,6 +876,8 @@ message SetMicMuteRequest {
// target_user_id actor_user_id
int64 target_user_id = 2;
bool muted = 3;
// target_display_profile IM/UI
RoomUserDisplayProfile target_display_profile = 4;
}
// SetMicMuteResponse
@ -1022,6 +1043,49 @@ message SendGiftDisplayProfile {
string pretty_display_user_id = 5;
}
// SendGiftBatchTarget
// RoomGiftSent
message SendGiftBatchTarget {
int64 target_user_id = 1;
string receiver_nickname = 2;
string receiver_avatar = 3;
string receiver_display_user_id = 4;
string receiver_pretty_display_user_id = 5;
int64 gift_value = 6;
int64 target_gift_value = 7;
int64 coin_spent = 8;
string billing_receipt_id = 9;
string command_id = 10;
LuckyGiftDrawResult lucky_gift = 11;
}
// SendGiftBatchDisplay
// 广使 RoomGiftBatchSent IM coin_balance_after
message SendGiftBatchDisplay {
string event_id = 1;
int64 sender_user_id = 2;
string sender_name = 3;
string sender_avatar = 4;
string sender_display_user_id = 5;
string sender_pretty_display_user_id = 6;
string gift_id = 7;
int32 gift_count = 8;
int64 total_gift_value = 9;
int64 total_coin_spent = 10;
string billing_receipt_id = 11;
int64 room_heat = 12;
string pool_id = 13;
string gift_type_code = 14;
string cp_relation_type = 15;
string gift_name = 16;
string gift_icon_url = 17;
string gift_animation_url = 18;
repeated string gift_effect_types = 19;
repeated int64 target_user_ids = 20;
repeated SendGiftBatchTarget targets = 21;
string command_id = 22;
}
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
@ -1053,6 +1117,8 @@ message SendGiftRequest {
SendGiftDisplayProfile sender_display_profile = 16;
// target_display_profiles IM target_user_id
repeated SendGiftDisplayProfile target_display_profiles = 17;
// display_mode=batch batch-send HTTP IM send
string display_mode = 18;
}
// SendGiftResponse
@ -1067,6 +1133,12 @@ message SendGiftResponse {
LuckyGiftDrawResult lucky_gift = 7;
// lucky_gifts target_user_id
repeated LuckyGiftDrawResult lucky_gifts = 8;
// coin_balance_after COIN
int64 coin_balance_after = 9;
// gift_income_balance_after COIN 0
int64 gift_income_balance_after = 10;
// batch_display batch-send send
SendGiftBatchDisplay batch_display = 11;
}
// CheckSpeakPermissionRequest IM gateway

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,42 @@ message LoginThirdPartyRequest {
string campaign = 20;
}
// RegisterThirdPartyRequest 使
// profile_required credential session
message RegisterThirdPartyRequest {
RequestMeta meta = 1;
string provider = 2;
string credential = 3;
string device_id = 4;
string username = 5;
string avatar = 6;
string gender = 7;
string country = 8;
string invite_code = 9;
string device = 10;
string os_version = 11;
string birth = 12;
string app_version = 13;
string build_number = 14;
string source = 15;
string install_channel = 16;
string campaign = 17;
string platform = 18;
string language = 19;
string timezone = 20;
}
// CheckThirdPartyRegisteredRequest
message CheckThirdPartyRegisteredRequest {
RequestMeta meta = 1;
string provider = 2;
string credential = 3;
}
message CheckThirdPartyRegisteredResponse {
bool registered = 1;
}
// AuthResponse
message AuthResponse {
AuthToken token = 1;
@ -182,6 +218,8 @@ message AppHeartbeatResponse {
service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
rpc RegisterThirdParty(RegisterThirdPartyRequest) returns (AuthResponse);
rpc CheckThirdPartyRegistered(CheckThirdPartyRegisteredRequest) returns (CheckThirdPartyRegisteredResponse);
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);

View File

@ -21,6 +21,8 @@ const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty"
AuthService_CheckThirdPartyRegistered_FullMethodName = "/hyapp.user.v1.AuthService/CheckThirdPartyRegistered"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
@ -40,6 +42,8 @@ const (
type AuthServiceClient interface {
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
CheckThirdPartyRegistered(ctx context.Context, in *CheckThirdPartyRegisteredRequest, opts ...grpc.CallOption) (*CheckThirdPartyRegisteredResponse, error)
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
@ -79,6 +83,26 @@ func (c *authServiceClient) LoginThirdParty(ctx context.Context, in *LoginThirdP
return out, nil
}
func (c *authServiceClient) RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuthResponse)
err := c.cc.Invoke(ctx, AuthService_RegisterThirdParty_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) CheckThirdPartyRegistered(ctx context.Context, in *CheckThirdPartyRegisteredRequest, opts ...grpc.CallOption) (*CheckThirdPartyRegisteredResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckThirdPartyRegisteredResponse)
err := c.cc.Invoke(ctx, AuthService_CheckThirdPartyRegistered_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetPasswordResponse)
@ -177,6 +201,8 @@ func (c *authServiceClient) AppHeartbeat(ctx context.Context, in *AppHeartbeatRe
type AuthServiceServer interface {
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error)
CheckThirdPartyRegistered(context.Context, *CheckThirdPartyRegisteredRequest) (*CheckThirdPartyRegisteredResponse, error)
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
@ -202,6 +228,12 @@ func (UnimplementedAuthServiceServer) LoginPassword(context.Context, *LoginPassw
func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LoginThirdParty not implemented")
}
func (UnimplementedAuthServiceServer) RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RegisterThirdParty not implemented")
}
func (UnimplementedAuthServiceServer) CheckThirdPartyRegistered(context.Context, *CheckThirdPartyRegisteredRequest) (*CheckThirdPartyRegisteredResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckThirdPartyRegistered not implemented")
}
func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPassword not implemented")
}
@ -286,6 +318,42 @@ func _AuthService_LoginThirdParty_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _AuthService_RegisterThirdParty_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterThirdPartyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RegisterThirdParty(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RegisterThirdParty_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RegisterThirdParty(ctx, req.(*RegisterThirdPartyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_CheckThirdPartyRegistered_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckThirdPartyRegisteredRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).CheckThirdPartyRegistered(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_CheckThirdPartyRegistered_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).CheckThirdPartyRegistered(ctx, req.(*CheckThirdPartyRegisteredRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPasswordRequest)
if err := dec(in); err != nil {
@ -463,6 +531,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "LoginThirdParty",
Handler: _AuthService_LoginThirdParty_Handler,
},
{
MethodName: "RegisterThirdParty",
Handler: _AuthService_RegisterThirdParty_Handler,
},
{
MethodName: "CheckThirdPartyRegistered",
Handler: _AuthService_CheckThirdPartyRegistered_Handler,
},
{
MethodName: "SetPassword",
Handler: _AuthService_SetPassword_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -313,6 +313,15 @@ message GetHostProfileResponse {
HostProfile host_profile = 1;
}
message BatchGetHostProfilesRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message BatchGetHostProfilesResponse {
map<int64, HostProfile> host_profiles = 1;
}
message GetBDProfileRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -526,6 +535,7 @@ service UserHostService {
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc BatchGetHostProfiles(BatchGetHostProfilesRequest) returns (BatchGetHostProfilesResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);

View File

@ -33,6 +33,7 @@ const (
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_BatchGetHostProfiles_FullMethodName = "/hyapp.user.v1.UserHostService/BatchGetHostProfiles"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
@ -63,6 +64,7 @@ type UserHostServiceClient interface {
GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
@ -221,6 +223,16 @@ func (c *userHostServiceClient) GetHostProfile(ctx context.Context, in *GetHostP
return out, nil
}
func (c *userHostServiceClient) BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetHostProfilesResponse)
err := c.cc.Invoke(ctx, UserHostService_BatchGetHostProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBDProfileResponse)
@ -321,6 +333,7 @@ type UserHostServiceServer interface {
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
@ -381,6 +394,9 @@ func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context,
func (UnimplementedUserHostServiceServer) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetHostProfile not implemented")
}
func (UnimplementedUserHostServiceServer) BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetHostProfiles not implemented")
}
func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBDProfile not implemented")
}
@ -678,6 +694,24 @@ func _UserHostService_GetHostProfile_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _UserHostService_BatchGetHostProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetHostProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_BatchGetHostProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, req.(*BatchGetHostProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetBDProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBDProfileRequest)
if err := dec(in); err != nil {
@ -885,6 +919,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetHostProfile",
Handler: _UserHostService_GetHostProfile_Handler,
},
{
MethodName: "BatchGetHostProfiles",
Handler: _UserHostService_BatchGetHostProfiles_Handler,
},
{
MethodName: "GetBDProfile",
Handler: _UserHostService_GetBDProfile_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,25 @@ message DebitGiftRequest {
string charge_source = 15;
}
// DebitDirectGiftRequest gateway user-service
message DebitDirectGiftRequest {
string command_id = 1;
int64 sender_user_id = 2;
int64 target_user_id = 3;
string gift_id = 4;
int32 gift_count = 5;
string price_version = 6;
string app_code = 7;
// region_id 使 sender region_id
int64 region_id = 8;
int64 sender_region_id = 9;
bool target_is_host = 10;
int64 target_host_region_id = 11;
int64 target_agency_owner_user_id = 12;
string entitlement_id = 13;
string charge_source = 14;
}
// DebitGiftResponse sender COIN
message DebitGiftResponse {
string billing_receipt_id = 1;
@ -57,6 +76,10 @@ message DebitGiftResponse {
repeated string gift_effect_types = 17;
string entitlement_id = 18;
string charge_source = 19;
// gift_income_coin_amount COIN0 /
int64 gift_income_coin_amount = 20;
// gift_income_balance_after COIN 0
int64 gift_income_balance_after = 21;
}
// DebitGiftTarget
@ -1111,6 +1134,8 @@ message ConfirmGooglePaymentRequest {
string purchase_token = 9;
string order_id = 10;
int64 purchase_time_ms = 11;
// country_id gateway user-service BI
int64 country_id = 12;
}
message ConfirmGooglePaymentResponse {
@ -1249,6 +1274,30 @@ message UpdateThirdPartyPaymentRateRequest {
int64 operator_user_id = 5;
}
message ThirdPartyPaymentMethodSyncIssue {
string country_code = 1;
string currency_code = 2;
string code = 3;
string message = 4;
}
message SyncThirdPartyPaymentMethodsRequest {
string request_id = 1;
string app_code = 2;
string provider_code = 3;
int64 operator_user_id = 4;
}
message SyncThirdPartyPaymentMethodsResponse {
string provider_code = 1;
int32 scanned_country_count = 2;
int32 fetched_country_count = 3;
int32 created_count = 4;
int32 updated_count = 5;
int32 skipped_count = 6;
repeated ThirdPartyPaymentMethodSyncIssue issues = 7;
}
message H5RechargeOptionsRequest {
string request_id = 1;
string app_code = 2;
@ -1293,6 +1342,8 @@ message ExternalRechargeOrder {
int64 created_at_ms = 25;
int64 updated_at_ms = 26;
bool idempotent_replay = 27;
// target_country_id
int64 target_country_id = 28;
}
message CreateH5RechargeOrderRequest {
@ -1314,6 +1365,8 @@ message CreateH5RechargeOrderRequest {
string payer_name = 15;
string payer_account = 16;
string payer_email = 17;
// target_country_id gateway user-service H5
int64 target_country_id = 18;
}
message CreateTemporaryRechargeOrderRequest {
@ -2008,6 +2061,7 @@ service WalletCronService {
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
rpc DebitDirectGift(DebitDirectGiftRequest) returns (DebitGiftResponse);
rpc BatchDebitGift(BatchDebitGiftRequest) returns (BatchDebitGiftResponse);
rpc DebitRobotGift(DebitRobotGiftRequest) returns (DebitGiftResponse);
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
@ -2062,6 +2116,7 @@ service WalletService {
rpc ListThirdPartyPaymentChannels(ListThirdPartyPaymentChannelsRequest) returns (ListThirdPartyPaymentChannelsResponse);
rpc SetThirdPartyPaymentMethodStatus(SetThirdPartyPaymentMethodStatusRequest) returns (ThirdPartyPaymentMethodResponse);
rpc UpdateThirdPartyPaymentRate(UpdateThirdPartyPaymentRateRequest) returns (ThirdPartyPaymentMethodResponse);
rpc SyncThirdPartyPaymentMethods(SyncThirdPartyPaymentMethodsRequest) returns (SyncThirdPartyPaymentMethodsResponse);
rpc ListH5RechargeOptions(H5RechargeOptionsRequest) returns (H5RechargeOptionsResponse);
rpc CreateH5RechargeOrder(CreateH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc CreateTemporaryRechargeOrder(CreateTemporaryRechargeOrderRequest) returns (H5RechargeOrderResponse);

View File

@ -202,6 +202,7 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_DebitDirectGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitDirectGift"
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
WalletService_DebitRobotGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitRobotGift"
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
@ -256,6 +257,7 @@ const (
WalletService_ListThirdPartyPaymentChannels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListThirdPartyPaymentChannels"
WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetThirdPartyPaymentMethodStatus"
WalletService_UpdateThirdPartyPaymentRate_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateThirdPartyPaymentRate"
WalletService_SyncThirdPartyPaymentMethods_FullMethodName = "/hyapp.wallet.v1.WalletService/SyncThirdPartyPaymentMethods"
WalletService_ListH5RechargeOptions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListH5RechargeOptions"
WalletService_CreateH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateH5RechargeOrder"
WalletService_CreateTemporaryRechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateTemporaryRechargeOrder"
@ -299,6 +301,7 @@ const (
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
type WalletServiceClient interface {
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
DebitDirectGift(ctx context.Context, in *DebitDirectGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error)
DebitRobotGift(ctx context.Context, in *DebitRobotGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
@ -353,6 +356,7 @@ type WalletServiceClient interface {
ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(ctx context.Context, in *SyncThirdPartyPaymentMethodsRequest, opts ...grpc.CallOption) (*SyncThirdPartyPaymentMethodsResponse, error)
ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, in *CreateTemporaryRechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
@ -407,6 +411,16 @@ func (c *walletServiceClient) DebitGift(ctx context.Context, in *DebitGiftReques
return out, nil
}
func (c *walletServiceClient) DebitDirectGift(ctx context.Context, in *DebitDirectGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DebitGiftResponse)
err := c.cc.Invoke(ctx, WalletService_DebitDirectGift_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchDebitGiftResponse)
@ -947,6 +961,16 @@ func (c *walletServiceClient) UpdateThirdPartyPaymentRate(ctx context.Context, i
return out, nil
}
func (c *walletServiceClient) SyncThirdPartyPaymentMethods(ctx context.Context, in *SyncThirdPartyPaymentMethodsRequest, opts ...grpc.CallOption) (*SyncThirdPartyPaymentMethodsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SyncThirdPartyPaymentMethodsResponse)
err := c.cc.Invoke(ctx, WalletService_SyncThirdPartyPaymentMethods_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOptionsResponse)
@ -1294,6 +1318,7 @@ func (c *walletServiceClient) RetryRedPacketRefund(ctx context.Context, in *Retr
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
type WalletServiceServer interface {
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
DebitDirectGift(context.Context, *DebitDirectGiftRequest) (*DebitGiftResponse, error)
BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error)
DebitRobotGift(context.Context, *DebitRobotGiftRequest) (*DebitGiftResponse, error)
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
@ -1348,6 +1373,7 @@ type WalletServiceServer interface {
ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(context.Context, *SyncThirdPartyPaymentMethodsRequest) (*SyncThirdPartyPaymentMethodsResponse, error)
ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
CreateTemporaryRechargeOrder(context.Context, *CreateTemporaryRechargeOrderRequest) (*H5RechargeOrderResponse, error)
@ -1395,6 +1421,9 @@ type UnimplementedWalletServiceServer struct{}
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitGift not implemented")
}
func (UnimplementedWalletServiceServer) DebitDirectGift(context.Context, *DebitDirectGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitDirectGift not implemented")
}
func (UnimplementedWalletServiceServer) BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchDebitGift not implemented")
}
@ -1557,6 +1586,9 @@ func (UnimplementedWalletServiceServer) SetThirdPartyPaymentMethodStatus(context
func (UnimplementedWalletServiceServer) UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateThirdPartyPaymentRate not implemented")
}
func (UnimplementedWalletServiceServer) SyncThirdPartyPaymentMethods(context.Context, *SyncThirdPartyPaymentMethodsRequest) (*SyncThirdPartyPaymentMethodsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncThirdPartyPaymentMethods not implemented")
}
func (UnimplementedWalletServiceServer) ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListH5RechargeOptions not implemented")
}
@ -1698,6 +1730,24 @@ func _WalletService_DebitGift_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _WalletService_DebitDirectGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DebitDirectGiftRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DebitDirectGift(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DebitDirectGift_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DebitDirectGift(ctx, req.(*DebitDirectGiftRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_BatchDebitGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchDebitGiftRequest)
if err := dec(in); err != nil {
@ -2670,6 +2720,24 @@ func _WalletService_UpdateThirdPartyPaymentRate_Handler(srv interface{}, ctx con
return interceptor(ctx, in, info, handler)
}
func _WalletService_SyncThirdPartyPaymentMethods_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncThirdPartyPaymentMethodsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SyncThirdPartyPaymentMethods(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SyncThirdPartyPaymentMethods_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SyncThirdPartyPaymentMethods(ctx, req.(*SyncThirdPartyPaymentMethodsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListH5RechargeOptions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(H5RechargeOptionsRequest)
if err := dec(in); err != nil {
@ -3293,6 +3361,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DebitGift",
Handler: _WalletService_DebitGift_Handler,
},
{
MethodName: "DebitDirectGift",
Handler: _WalletService_DebitDirectGift_Handler,
},
{
MethodName: "BatchDebitGift",
Handler: _WalletService_BatchDebitGift_Handler,
@ -3509,6 +3581,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateThirdPartyPaymentRate",
Handler: _WalletService_UpdateThirdPartyPaymentRate_Handler,
},
{
MethodName: "SyncThirdPartyPaymentMethods",
Handler: _WalletService_SyncThirdPartyPaymentMethods_Handler,
},
{
MethodName: "ListH5RechargeOptions",
Handler: _WalletService_ListH5RechargeOptions_Handler,

View File

@ -151,6 +151,8 @@ services:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rocketmq-broker:
condition: service_started
user-service:
@ -308,16 +310,16 @@ services:
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ./tmp/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ./tmp/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ./tmp/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/game-service/deploy/mysql/initdata/001_self_games.sql:/docker-entrypoint-initdb.d/010_game_service_data.sql:ro
- ./services/robot-service/deploy/mysql/initdb/001_robot_service.sql:/docker-entrypoint-initdb.d/011_robot_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
- ./tmp/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s

View File

@ -0,0 +1,303 @@
# Google 三方注册 Flutter 对接
## 边界
Google 登录后Flutter 只暂存 Firebase ID token 和 Google 基本信息,不写入 `AppAuthSession`
资料页允许 pending third-party auth 进入,不要求 `_authService.isSignedIn`。用户点击完成后Flutter 调注册接口;后端校验 Firebase ID token、创建或补齐用户资料、返回 access/refresh token。Flutter 收到 token 后再写入现有 `AppAuthSession`
旧接口 `POST /api/v1/auth/third-party/login` 仍保留兼容。新 Google 流程只能在检查接口返回 `registered=true` 后调用它拿 session不要在 `registered=false` 时调用它,避免旧接口创建 pending 用户。
## 调用流程
1. Flutter 调 Google Sign-In / Firebase Auth拿到 Firebase ID token。
2. Flutter 暂存:
- `provider=firebase`
- `credential=<firebase_id_token>`
- Google 返回的昵称、头像等基础信息
- `device_id``platform``language``timezone`
3. 调注册检查接口。
4. 如果 `registered=true`,调用旧三方登录接口拿 session写入 `AppAuthSession` 后进首页。
5. 如果 `registered=false`,进入资料页,用户补齐 `username``avatar``gender``country``invite_code`
6. 如果用户选择本地头像,先调用注册头像上传接口拿 URL。
7. 调 `POST /api/v1/auth/third-party/register`
8. 成功后把返回的 token 写入 `AppAuthSession`
## 注册检查
地址:
```http
POST /api/v1/auth/third-party/registered/check
```
Headers
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Content-Type` | 是 | `application/json` |
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
| `X-App-Package` | 否 | 包名;后端可按包名解析 app_code。 |
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `provider` | string | 是 | 固定传 `firebase`。 |
| `credential` | string | 是 | Firebase ID token。 |
请求示例:
```json
{
"provider": "firebase",
"credential": "firebase_id_token"
}
```
返回值:
```json
{
"code": 0,
"message": "ok",
"request_id": "req_xxx",
"data": {
"registered": true
}
}
```
Flutter 处理:
| `registered` | 处理 |
| --- | --- |
| `true` | 用户已完成注册;再调用 `POST /api/v1/auth/third-party/login` 获取 session。 |
| `false` | 用户未完成注册,或者只有旧流程留下的 pending 用户;进入资料页,最后调用注册接口。 |
## 注册头像上传
地址:
```http
POST /api/v1/files/registration/avatar/upload
```
Headers
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Content-Type` | 是 | `multipart/form-data` |
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `file` | file | 是 | 头像文件;也支持字段名 `avatar`。 |
限制:
| 项 | 说明 |
| --- | --- |
| 登录态 | 不需要 `Authorization`。 |
| 大小 | 最大 5MB。 |
| 格式 | `jpg``jpeg``png``webp`。 |
| 校验 | 后端按文件头识别图片类型,不信任客户端传入的 Content-Type。 |
返回值:
```json
{
"code": 0,
"message": "ok",
"request_id": "req_xxx",
"data": {
"url": "https://media.example.com/app/avatars/registration/20260626/avatar_xxx.png",
"object_key": "app/avatars/registration/20260626/avatar_xxx.png",
"content_type": "image/png",
"size_bytes": 204800
}
}
```
Flutter 使用 `data.url` 作为注册接口的 `avatar`
## 三方注册
地址:
```http
POST /api/v1/auth/third-party/register
```
Headers
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Content-Type` | 是 | `application/json` |
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
| `X-App-Package` | 否 | 包名;后端可按包名解析 app_code。 |
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `provider` | string | 是 | 固定传 `firebase`。 |
| `credential` | string | 是 | Firebase ID token。 |
| `device_id` | string | 是 | 设备 ID用于 refresh session 绑定和注册风控。 |
| `username` | string | 是 | 用户昵称。 |
| `avatar` | string | 是 | HTTP(S) 头像 URL可以是上传接口返回的 `url`。 |
| `gender` | string | 是 | 性别值,后端只保存原值。 |
| `country` | string | 是 | 国家码,例如 `SG``CN`。 |
| `invite_code` | string | 否 | 邀请码。 |
| `device` | string | 否 | 设备型号。 |
| `os_version` | string | 否 | 系统版本。 |
| `birth` | string | 否 | 生日,格式 `yyyy-mm-dd`。 |
| `app_version` | string | 否 | App 版本。 |
| `build_number` | string | 否 | 构建号。 |
| `source` | string | 否 | 注册来源。 |
| `install_channel` | string | 否 | 安装渠道。 |
| `campaign` | string | 否 | 投放活动。 |
| `platform` | string | 是 | `android``ios`。 |
| `language` | string | 否 | 客户端语言,例如 `en-US`。 |
| `timezone` | string | 否 | IANA 时区,例如 `Asia/Shanghai`。 |
请求示例:
```json
{
"provider": "firebase",
"credential": "firebase_id_token",
"device_id": "ios_device_001",
"username": "Alice",
"avatar": "https://media.example.com/app/avatars/registration/20260626/avatar_xxx.png",
"gender": "female",
"country": "SG",
"invite_code": "",
"device": "iPhone 15",
"os_version": "iOS 18.1",
"birth": "2000-01-02",
"app_version": "1.2.3",
"build_number": "123",
"source": "google",
"install_channel": "app_store",
"campaign": "",
"platform": "ios",
"language": "en-US",
"timezone": "Asia/Shanghai"
}
```
返回值:
```json
{
"code": 0,
"message": "ok",
"request_id": "req_xxx",
"data": {
"app_code": "lalu",
"user_id": "10001",
"display_user_id": "163001",
"default_display_user_id": "163001",
"display_user_id_kind": "default",
"display_user_id_expires_at_ms": 0,
"session_id": "sess_xxx",
"access_token": "access_token",
"refresh_token": "refresh_token",
"expires_in_sec": 1800,
"token_type": "Bearer",
"is_new_user": true,
"profile_completed": true,
"onboarding_status": "completed"
}
}
```
Flutter 成功处理:
| 字段 | 处理 |
| --- | --- |
| `access_token` | 写入现有 `AppAuthSession.accessToken`。 |
| `refresh_token` | 写入现有 `AppAuthSession.refreshToken`。 |
| `session_id` | 写入现有 session 标识。 |
| `user_id` | 字符串保存,不要转 JS number。 |
| `display_user_id` | 展示号。 |
| `profile_completed` | 必须为 `true`;否则不要进入首页。 |
| `onboarding_status` | 成功应为 `completed`。 |
## 重试和兼容
同一个 Firebase subject 如果之前已经被旧流程创建成 pending 用户,新注册接口会补齐这个用户并返回 token不会再创建第二个用户。
同一个 Firebase subject 如果已经完成注册,再调该接口会按已有用户登录返回 token不会覆盖用户后续改过的资料。
## 错误处理
| HTTP | code | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | 缺必填字段、国家格式错误、头像 URL 非 HTTP(S)、生日格式错误。 | 停留资料页,提示用户修正。 |
| `400` | `INVALID_INVITE_CODE` | 邀请码格式错误或不可用。 | 提示邀请码无效,可允许清空后重试。 |
| `401` | `AUTH_FAILED` | Firebase ID token 无效、过期或 provider 不允许。 | 回到 Google 登录重新拿 token。 |
| `429` | `RATE_LIMITED` | 登录注册入口限流。 | 稍后重试。 |
| `409` | `DEVICE_ALREADY_REGISTERED` | 当前设备已达到注册账号上限。 | 提示当前设备无法继续注册。 |
| `502` | `UPSTREAM_ERROR` | user-service 或对象存储不可用。 | 稍后重试。 |
## Dart 调用示例
```dart
Future<AppAuthSession> registerFirebaseUser({
required AppNetworkService network,
required String firebaseIdToken,
required String deviceId,
required String username,
required String avatar,
required String gender,
required String country,
String inviteCode = '',
}) async {
final envelope = await network.postEnvelope(
'/api/v1/auth/third-party/register',
data: {
'provider': 'firebase',
'credential': firebaseIdToken,
'device_id': deviceId,
'username': username,
'avatar': avatar,
'gender': gender,
'country': country,
'invite_code': inviteCode,
'platform': Platform.isIOS ? 'ios' : 'android',
'language': PlatformDispatcher.instance.locale.toLanguageTag(),
'timezone': await currentIanaTimezone(),
},
requiresAuthorization: false,
);
if (envelope.code != 0) {
throw ApiException(envelope.code, envelope.message);
}
final data = envelope.data as Map<String, dynamic>;
if (data['profile_completed'] != true ||
data['onboarding_status'] != 'completed') {
throw StateError('third-party register did not complete profile');
}
return AppAuthSession(
appCode: data['app_code'] as String? ?? '',
userId: data['user_id'] as String? ?? '',
displayUserId: data['display_user_id'] as String? ?? '',
sessionId: data['session_id'] as String? ?? '',
accessToken: data['access_token'] as String? ?? '',
refreshToken: data['refresh_token'] as String? ?? '',
expiresInSec: (data['expires_in_sec'] as num? ?? 0).toInt(),
tokenType: data['token_type'] as String? ?? 'Bearer',
profileCompleted: data['profile_completed'] == true,
onboardingStatus: data['onboarding_status'] as String? ?? '',
);
}
```
相关 IM注册接口成功后后端会导入腾讯 IM 用户资料Flutter 不需要额外调用 IM 导入接口。

View File

@ -1,172 +1,307 @@
# 幸运礼物产品文档
# 幸运礼物 · 必爆隐藏奖池产品方案6.25 版)
本文档按当前代码实现描述幸运礼物产品能力。所有功能点均给出实现依据;未在代码中闭环的能力不写成已上线能力。
## 文档信息
## 1. 产品定位和范围
| 项 | 内容 |
| --- | --- |
| 版本 | v1.0 |
| 日期 | 2026-06-25 |
| 范围 | 语音房幸运礼物的新一版产品与算法方案 |
| 状态 | 产品方案,不能直接等同于当前代码已上线能力 |
幸运礼物是建立在房间送礼链路上的付费抽奖玩法。用户发送带 `pool_id` 的幸运礼物,系统先完成金币扣费,再按奖池规则抽奖;中奖金币由钱包服务异步入账,开奖结果通过房间 IM 和区域播报做表现。
目标是在语音房送礼场景里提供一个极简、高方差、长期自平衡的幸运礼物系统。用户侧只感知“送礼 -> 开奖 -> 结果表现”,后台通过隐藏奖池完成持续喂池和必然爆池。平台先抽水,剩余资金进入小奖反馈层和隐藏奖池层,所有开奖必须可审计、可重放
| 功能点 | 产品规则 | 实现依据 |
## 1. 项目背景与目标
### 1.1 背景
用户发送幸运礼物后,需要马上获得强反馈。传统固定倍数或简单随机容易出现长时间无大奖、低波动、低传播的问题,用户会认为玩法“干等”或“不刺激”。新方案用三层反馈叠加:
- 高频小奖让每次送礼都有反馈。
- 稀有大奖制造黑天鹅惊喜。
- 必爆隐藏奖池保证在可控贡献量内一定出现大额 payout。
### 1.2 核心目标
| 目标 | 说明 |
| --- | --- |
| 用户侧 | 每次送礼都有独立开奖体验,高频小奖、稀有大奖和必爆大奖叠加。 |
| 平台侧 | 每笔先抽水,剩余奖池自平衡,不允许平台倒贴。 |
| 长期循环 | 通过“持续喂池 -> 必然爆池 -> 留底滚动”形成可持续戏剧性循环。 |
| 认知成本 | 用户端不展示复杂池子、爆点和概率,只触发送礼并接收结果。 |
| 审计公平 | 每次开奖记录 before/after、随机因子、trigger 状态和结果,支持事后重放。 |
### 1.3 成功标准
- 单次 100 金礼物的玩家侧期望返还约为 75-78 金。
- 必爆机制保证在可控贡献量内一定出现大额 payout。
- 大奖释放后隐藏奖池仍保留 52%-68% 底池,继续滚雪球。
- 内部账号、机器人和主播测试流量不能真实消耗 J Fund。
## 2. 核心设计理念
### 2.1 三层爽感
| 层级 | 机制 | 产品意义 |
| --- | --- | --- |
| 付费抽奖定位 | 幸运礼物不是独立支付入口,而是送礼成功后的抽奖附加玩法。 | `services/room-service/internal/room/service/gift.go``SendGift` 先调用 `wallet.DebitGift`,成功后再调用 `ExecuteLuckyGiftDraw`。 |
| 规则隔离维度 | 规则按 `pool_id` 隔离,不按真实礼物 ID 绑定规则;一个奖池可服务不同价格礼物。 | `docs/幸运礼物实现文档.md``services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go``GetLuckyGiftRuleConfig``getLuckyGiftRuleConfig``luckyRuntimeConfig` 按真实 `coin_spent` 缩放奖档金额。 |
| 服务边界 | `activity-service` 负责规则、抽奖、奖池、风控、抽奖事实和 outbox`wallet-service` 负责扣费和返奖入账;`room-service` 负责房间状态、热度、榜单和房间 outbox。 | `services/activity-service/internal/service/luckygift/service.go``Repository``WalletClient``RoomPublisher` 接口;`services/room-service/internal/room/service/gift.go``services/wallet-service/internal/service/wallet/service.go``CreditLuckyGiftReward`。 |
| 不兼容旧玩法假设 | 当前只以 v2 规则版本为准;旧文档和旧 gift_id 规则模型不作为产品口径。 | `docs/幸运礼物实现文档.md` 开头说明;后台路由固定为 `/admin/activity/lucky-gifts/v2/*`,见 `server/admin/internal/modules/luckygift/routes.go`。 |
| 高频层 S Fund | 超过 58% 概率获得正返还。 | 维持连续发送行为,避免每次都空。 |
| 稀有大奖层 J Fund | 1% 概率按隐藏池百分比释放。 | 形成不可预测的大额惊喜。 |
| 必然爆发层 Must-Hit | 隐藏爆点强制释放 32%-48% 奖池。 | 消除长期干等,完成喂池到爆池的闭环。 |
## 2. 角色和使用场景
### 2.2 隐藏与独立
| 角色 | 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- | --- |
| 送礼用户 | 发送幸运礼物并获得即时抽奖结果 | 用户在房间内给一个在线用户送礼,送礼成功后当前用户同步拿到 `lucky_gift` 结果。 | `SendGift` 校验送礼者和收礼者都在房间;响应包含 `roomv1.SendGiftResponse.LuckyGift`;转换逻辑在 `luckyGiftResultFromProto`。 |
| 收礼用户/主播 | 作为礼物目标和主播风控主体 | 目标用户必须在房间内;主播风控优先使用房主 ID缺失时退回房间 ID。 | `SendGift` 校验 `TargetUserID``luckyGiftAnchorID`。 |
| 房间内用户 | 看到送礼事实和开奖结果表现 | 普通送礼表现继续广播;中奖结果由 activity outbox worker 发布 `lucky_gift_drawn` 房间群消息。 | `RoomGiftSent` outbox 构建在 `SendGift`;开奖结果发布在 `publishLuckyGiftDrawn`。 |
| 运营管理员 | 配置奖池、阶段奖档、RTP、风控上限并查看数据 | 后台提供奖池配置、发布、列表、抽奖汇总和抽奖明细接口;前端提供配置页、模拟工具和数据概览。 | 后端 `server/admin/internal/modules/luckygift/handler.go`;前端 `hyapp-admin-platform/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx`。 |
- 公共奖池状态、爆点和当前周期不对客户端展示。
- S Fund 与 J Fund 独立 roll小奖层 Miss 不影响大奖层触发。
- 每次送礼都先形成完整开奖事实,再由表现层展示结果。
## 3. 用户端功能
### 2.3 平台零风险
| 功能点 | 产品规则 | 实现依据 |
- 每笔送礼先扣平台抽水。
- J Fund payout 只能按比例释放,并受留底、单次上限和风控上限约束。
- 奖池余额不足时不能透支,必须降级为可支付结果或 Miss。
## 3. 资金流与数学模型
每送 100 金单位礼物,资金拆分如下:
| 去向 | 金额 | 占比 |
| --- | ---: | ---: |
| 平台抽水 | 22 金 | 22% |
| S Fund 小奖反馈 | 28 金 | 28% |
| J Fund 隐藏奖池 | 50 金 | 50% |
长期口径:
- 平台固定收益22 金。
- S Fund 期望返还:约 22.84 金,利用率约 81.6%。
- J Fund payout 长期受控在总贡献 50% 以内,依赖分段释放比例、必爆上限和留底。
- 玩家侧综合期望返还约为 75.84 金,符合社交礼物玩法的健康区间。
## 4. 开奖算法
### 4.1 S Fund 小奖反馈层
S Fund 使用 Alias Method 做 O(1) 采样。金额使用连续均匀分布,避免固定值被用户感知为机械规律。
| Tier | 概率 | 返还金额 | 期望贡献 |
| --- | ---: | ---: | ---: |
| Miss | 42% | 0 | 0 |
| 小回血 | 32% | Uniform[8, 18] | 4.16 |
| 中回血 | 16% | Uniform[25, 45] | 5.60 |
| 好奖 | 7% | Uniform[60, 120] | 6.30 |
| 大回血 | 3% | Uniform[150, 280] | 6.45 |
期望值:
```text
E[S] = 0.32 * 13 + 0.16 * 35 + 0.07 * 90 + 0.03 * 215
= 22.51
```
说明:原始方案中的 `22.84` 与表格均值直接计算不完全一致。按当前表格区间中点计算是 `22.51`。如果产品口径必须锁定 `22.84`,需要微调某一档金额区间或概率后再进入实现。
### 4.2 J Fund 基础隐藏奖池层
每次送礼原子增加 50 金到 J Fund。J Fund 与 S Fund 独立开奖。
基础触发:
- 触发概率1.0%。
- 获奖金额:`win_J = J * r`
- 释放后:`J_new = J - win_J`,不清空奖池,保留自然底池。
释放比例按当前 J Fund 规模分段:
| J Fund 当前规模 | r 范围 |
| --- | --- |
| J < 200000 | Uniform[0.05, 0.10] |
| 200000 <= J < 500000 | Uniform[0.08, 0.15] |
| J >= 500000 | Uniform[0.12, 0.22] |
### 4.3 Must-Hit 必爆机制
周期初始化:
```text
T = J_current + Uniform[280000, 720000]
```
每次送礼按顺序执行:
1. `J = J + 50`
2. 如果 `J >= T` 且当前周期未触发,强制触发超级爆池。
3. 超级爆池金额为 `win_J = J * r`,其中 `r = Uniform[0.32, 0.48]`
4. 释放后 `J_new = J - win_J`,通常保留 52%-68% 底池。
5. 新周期爆点为 `T_new = J_new + Uniform[300000, 800000]`
参数依据:
- 28 万到 72 万爆点贡献区间用于覆盖热门房间约 40-120 分钟的心理窗口。
- 32%-48% 强制释放比例保证单次大奖冲击力,同时保留底池继续滚动。
## 5. 完整开奖流程
```text
on_lucky_gift(user_id, amount = 100):
platform_rake += 22
s_fund += 28
j_before = j_fund
j_fund += 50
s_win = roll_s_fund()
credit_user(user_id, s_win)
j_win = 0
forced = false
if j_fund >= current_trigger and current_cycle_not_triggered:
r = uniform(0.32, 0.48)
j_win = j_fund * r
j_win = apply_jackpot_caps(j_win, j_fund)
j_fund -= j_win
current_trigger = j_fund + uniform(300000, 800000)
forced = true
else if random() < 0.01:
r = get_r_by_pool_size(j_fund)
j_win = j_fund * r
j_win = apply_jackpot_caps(j_win, j_fund)
j_fund -= j_win
if j_win > 0:
credit_user(user_id, j_win)
write_audit_log(...)
```
必须注意:真实实现不能在抽奖事务里直接调用钱包入账。应先写开奖事实和 outbox再由 worker 幂等发奖,避免数据库事务和外部 RPC 混在一起。
## 6. 安全、公平与风控规则
| 规则 | 要求 |
| --- | --- |
| 单次上限 | `win_J = min(win_J, j_fund * 0.48, 500000)`。 |
| 个人累计上限 | 可选日/周 jackpot 累计封顶,防止洗钱和异常套利。 |
| 内部账号隔离 | internal、bot、host 测试账号只能走 S Fund 和模拟 J Fund不真实消耗 J Fund。 |
| 随机性 | 使用密码学安全 RNG每次记录 seed 或可验证随机材料,支持重放。 |
| 并发安全 | J Fund 更新必须在数据库事务内完成,使用行锁、版本号或分布式锁防止并发透支。 |
| 初始种子 | 新周期可注入 8 万到 15 万启动 J Fund计入营销成本或后期回收。 |
| 审计完整性 | 每笔记录 gift_id、user_id、room_id、j_before、j_after、s_win、j_win、forced_hit、random_seed、trigger、ts。 |
## 7. 参数配置与动态调优
### 7.1 推荐 v1.0 参数
| 参数 | 推荐值 | 调优方向 |
| --- | --- | --- |
| 送礼前资格检查 | App 可在送礼前调用检查接口确认奖池是否可用;该接口只检查,不扣费、不抽奖。 | `POST /api/v1/activities/lucky-gifts/check` 注册在 `services/gateway-service/internal/transport/http/httproutes/router.go`;处理函数 `activityapi.Handler.checkLuckyGift`。 |
| 检查参数 | 检查接口要求 `room_id``gift_id``pool_id` 必填,用户 ID 来自登录态。 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go``luckyGiftCheckRequest` 和必填校验。 |
| 检查返回 | 返回是否启用、原因、奖池 ID、礼物 ID、参考价格、规则版本、目标 RTP、用户当前体验池。 | `luckyGiftCheckData``activityv1.CheckLuckyGiftResponse``Repository.CheckLuckyGift` 读取用户状态并返回 `ExperiencePool`。 |
| 显式奖池触发 | 新客户端传 `pool_id` 时,系统必须先检查规则启用,再扣费;规则未启用则整笔送礼失败。 | `SendGift``cmd.PoolID != ""` 分支调用 `CheckLuckyGift`,未启用返回 `RuleNotActive`,位置在扣费调用前。 |
| 旧客户端兼容触发 | 未显式传 `pool_id` 时,若钱包返回礼物类型为 `lucky``super_lucky`,仍可触发幸运礼物抽奖。 | `Service.shouldDrawLuckyGift` 判断 `giftTypeCode``lucky/super_lucky`。 |
| 扣费优先 | 抽奖只使用钱包扣费成功后的 `coin_spent`,不信任客户端价格。 | `SendGift` 调用 `wallet.DebitGift` 后,将 `billing.GetCoinSpent()` 写入 `LuckyGiftMeta.CoinSpent`。 |
| 设备风控标识 | 设备维度优先使用房间命令的 `session_id`,没有时退回 `command_id`,保证风控 scope 不为空。 | `luckyGiftDeviceID`。 |
| 同步抽奖结果 | 送礼响应中返回抽奖 ID、奖池、规则版本、体验池、命中奖档、倍率、奖励金币、发放状态、阶段反馈和高倍标识。 | `api/proto/room/v1/room.proto``LuckyGiftDrawResult``luckyGiftResultFromProto`。 |
| 普通送礼事实保留 | 幸运礼物不会替代普通送礼房间热度、本地礼物榜、RoomGiftSent、RoomHeatChanged、RoomRankChanged 仍照常落地。 | `SendGift` 在抽奖后继续更新 `current.Heat``GiftRank`,并构建 `RoomGiftSent``RoomHeatChanged``RoomRankChanged` outbox。 |
| 批量送礼 | `gift_count=N` 表示抽 N 次;响应展示整次送礼的聚合倍数和总奖励。 | `executeLuckyGiftDrawBatch``luckyDrawUnitSpend``stageLuckyBatchDraw``luckyAggregateDrawResults`。 |
| 批量金额拆分 | 总扣费按 `gift_count` 拆为 N 个单抽金额,余数分给前几抽,保证子抽金额总和等于扣费事实。 | `luckyDrawUnitSpend`;单测 `TestLuckyDrawUnitSpendSplitsBatchSpendByGiftCount`。 |
| 平台抽水 | 22% | 不可低于 18%。 |
| S Fund 贡献 | 28% | 维持 E[S] 约 22.5-23.5。 |
| J Fund 贡献 | 50% | 核心滚雪球资金来源。 |
| 基础池奖概率 | 1.0% | 可在 0.6%-1.5% 调整。 |
| 必爆释放比例 | Uniform[0.32, 0.48] | 越高越激进,越低越耐久。 |
| 初始爆点范围 | Uniform[280000, 720000] | 礼物量越大,范围可越大。 |
| 新周期爆点范围 | Uniform[300000, 800000] | 大奖后重建周期,避免连爆过密。 |
## 4. 抽奖规则
### 7.2 动态微调
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 奖池配置开关 | 每个 `pool_id` 有独立启用状态;未配置或未启用时不能抽奖。 | `Service.GetConfig` 未配置返回 disabled 草稿;`Repository.CheckLuckyGift` 未配置返回 `enabled=false``executeSingleLuckyGiftDraw``executeOptimizedLuckyGiftDrawBatch` 未启用返回 `RuleNotActive`。 |
| 不可变规则版本 | 每次后台发布都新增 `rule_version`,不覆盖历史版本。 | `Repository.PublishLuckyGiftRuleConfig` 事务内锁最新版本并写入 `lucky_gift_rule_versions``lucky_gift_stage_tiers`。 |
| 最新规则读取 | App 检查、抽奖运行和后台列表都读取当前奖池最新版本。 | `getLuckyGiftRuleConfig``rule_version DESC LIMIT 1``ListLuckyGiftRuleConfigs` 使用每个 pool 的 `MAX(rule_version)`。 |
| RTP 目标 | `target_rtp_ppm` 表示基础返奖目标,后台业务单位是百分比,服务端持久化为 ppm。 | `server/admin/internal/modules/luckygift/handler.go``percentToPPM``ppmToPercent``validateRuleConfig` 限制 10%-99%。 |
| 入池比例 | 每笔扣费按 `pool_rate_ppm` 进入基础奖池,且入池比例必须不低于目标 RTP。 | `validateRuleConfig` 校验 `PoolRatePPM >= TargetRTPPPM``luckyBasePoolIn``creditLuckyBasePools`。 |
| 结算窗口 | RTP 结算窗口按金币流水配置,运行时按参考价格折算窗口抽数;窗口只用于观察和结算,不改变随机概率。 | `luckyRuntimeConfigFromRuleConfig``SettlementWindowWager / GiftPriceReference` 折算窗口;`getOpenLuckyRTPWindow``selectLuckyCandidate` 注释和实现不读取 RTP 缺口。 |
| 波动带 | 后台发布时校验每个阶段期望 RTP 是否落在 `目标 RTP ± 波动带`。 | `validateRuleStageExpectedRTP`;前端 `luckyGiftConfigFormSchema` 同步校验。 |
| 参考价格 | `gift_price_reference` 用于配置奖档金额、结算窗口和等价抽数;真实抽奖按本次单抽 `coin_spent` 缩放奖档金额。 | `luckyRuntimeConfigFromRuleConfig``luckyRuntimeConfig``luckyScaleReward`。 |
| 用户阶段 | 用户分为新手、正常、高阶三个阶段;阶段由累计金币流水折算等价抽数决定。 | `luckyEquivalentDraws``luckyExperiencePool`;用户状态表字段见 `lucky_user_states`。 |
| 阶段奖档 | 每个阶段都配置一组奖档,每个奖档包含倍率、概率、预算来源、高水位、播报级别、启用状态;当前 v2 抽奖主链路只把 `base_rtp` 奖档转换为随机候选。 | `LuckyGiftRuleStage``LuckyGiftRuleTier` proto`domain.RuleStage``domain.RuleTier`;后台 DTO `stageDTO``tierDTO`;运行转换见 `luckyRuntimeTiersFromRuleStages`。 |
| 概率闭合 | 每个阶段启用奖档概率合计必须为 100%,且必须存在 0x 档和至少一个基础 RTP 奖档。 | `validateRuleStage`;前端 `luckyGiftConfigFormSchema`。 |
| 高倍限制 | 高倍率奖档必须标记高水位,运行时仍受奖池和风控过滤。 | 后端 `validateRuleStage` 对高倍率校验;前端 schema 对 5x 及以上要求 `highWaterOnly``selectLuckyCandidate` 对高倍和 `LargeTierEnabled` 做过滤。 |
| 随机候选 | 抽奖只使用后台配置概率;奖池和风控只删除不可支付候选,不动态注入 RTP 修正档。 | `selectLuckyCandidate``luckyConfiguredWeightCandidates`;单测 `TestLuckyConfiguredWeightCandidatesUsesConfiguredWeightsWithoutForcedSettlement`。 |
| 无可支付奖档 | 所有正奖档都因奖池或风控不可支付时,本抽返回 0 倍 `no_payable_tier`,不透支。 | `selectLuckyCandidate` 返回 `no_payable_tier`;单测 `TestSelectLuckyCandidateFallsBackToZeroWhenAllPositiveTiersAreUnpayable`。 |
当 J Fund 过大时,可以轻微抬高基础大奖概率:
## 5. 奖池、预算和风控
```text
p_base = 0.01 + min(0.004, (J - 500000) / 75000000)
```
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 三层基础奖池 | 基础返奖由平台池、房间池、礼物池共同承担;任一层余额不足都会过滤奖档。 | `getOrCreateLuckyPool``luckySplitWeighted``luckyWeightedPoolCapacity``creditLuckyBasePools``debitLuckyPool`。 |
| 先入池后开奖 | 每笔扣费先按入池比例补充奖池,再过滤候选并开奖。 | 单抽路径先调用 `creditLuckyBasePools``selectLuckyCandidate`;批量路径在 `stageLuckyBatchDraw` 中先 `creditLuckyPoolInMemory` 再候选过滤。 |
| 房间气氛池 | 当前保留房间气氛池账本和结果字段,且气氛奖励口径独立于基础 RTPv2 主候选默认仍来自基础 RTP。 | `lucky_room_atmosphere_pools` 表;`getOrCreateLuckyAtmosphere``creditLuckyAtmosphere``updateLuckyAtmosphereNet``applyLuckyDraw` 注释说明气氛奖励不进入基础 RTP。 |
| 活动补贴预算 | 当前保留活动补贴总预算和 UTC 日预算账本v2 后台第一阶段未暴露活动预算,运行主候选默认不从活动补贴生成。 | `lucky_activity_budgets` 表;`getLuckyActivityRemaining``getOrCreateLuckyActivityTotalRemaining``luckyRuntimeConfigFromRuleConfig``ActivityBudget``ActivityDailyLimit` 置为 0。 |
| 单次风控上限 | 单抽奖励不能超过规则配置的单次返奖上限。 | `validateRuleConfig` 要求 `MaxSinglePayout > 0``luckyRiskCapacity` 首先使用 `MaxSinglePayout`。 |
| 用户小时/日上限 | 同一用户在小时和 UTC 自然日内有返奖上限。 | `getLuckyRiskCounters` 创建 `user:hour``user:day` 计数;`luckyRiskCapacity``updateLuckyRiskCounters`。 |
| 设备日上限 | 同一设备在 UTC 自然日内有返奖上限。 | `getLuckyRiskCounters``device/day``luckyGiftDeviceID` 保证设备 scope。 |
| 房间小时上限 | 同一房间每小时有返奖上限。 | `getLuckyRiskCounters``room/hour``luckyRiskCapacity`。 |
| 主播日上限 | 同一主播 UTC 自然日有返奖上限。 | `getLuckyRiskCounters``anchor/day``luckyGiftAnchorID`。 |
| 并发一致性 | 奖池、用户状态、RTP 窗口和风控计数在事务内锁定,避免并发透支。 | `Repository.ExecuteLuckyGiftDraw` 使用 MySQL 事务;`getLuckyUserStateForUpdate``getLatestLuckyRTPWindow(... FOR UPDATE)``getOrCreateLuckyPool(... FOR UPDATE)``getOrCreateLuckyRiskCounter(... FOR UPDATE)`。 |
动态调参必须写入规则版本或配置变更记录,不能只改运行内存,否则无法审计和复盘。
## 6. 返奖和表现
## 8. 预期效果与 KPI
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 异步发奖 | 抽奖事务只写事实和 outbox不直接调用钱包worker 后续处理返奖和表现。 | `applyLuckyDraw``insertLuckyAggregateDrawOutbox``activity_outbox`;外部 RPC 在 `processDrawOutbox`。 |
| 钱包入账 | 有有效奖励时worker 调用钱包 `CreditLuckyGiftReward` 给中奖用户加 COIN。 | `Service.processDrawOutbox` 调用 `walletv1.CreditLuckyGiftReward`;钱包实现见 `services/wallet-service/internal/service/wallet/service.go``internal/storage/mysql/repository.go`。 |
| 返奖幂等 | 钱包命令 ID 由聚合 `draw_id` 派生,同一 draw 重试只加币一次。 | `processDrawOutbox` 使用 `CommandId: "lucky_reward:" + payload.DrawID`;钱包 `CreditLuckyGiftReward``lookupTransaction` 和 request hash 幂等;单测 `TestCreditLuckyGiftRewardIsIdempotent`。 |
| 房间开奖结果 | worker 发 `lucky_gift_drawn` 房间群自定义消息,用于房间内开奖结果表现。 | `publishLuckyGiftDrawn`IM 消息 `Desc``lucky_gift_drawn`。 |
| 区域播报 | 当存在可见区域、有效奖励且聚合倍率不低于 1x 时,写区域播报 outbox。 | `shouldPublishLuckyGiftRegionBroadcast``publishLuckyGiftRegionBroadcast``luckyGiftRegionBroadcastPayload`。 |
| 成功收敛 | 钱包、区域播报和房间 IM 都成功后,整批 draw records 标记 `granted`outbox 标记 `delivered`。 | `processDrawOutbox` 末尾调用 `MarkLuckyGiftDrawsGranted``MarkLuckyGiftOutboxDelivered`。 |
| 失败退避 | outbox 处理失败按指数退避重试;达到最大重试后标记失败。 | `markOutboxFailed``luckyGiftBackoff``MarkLuckyGiftOutboxRetryable``MarkLuckyGiftOutboxFailed`。 |
| 已返奖但表现失败 | 钱包已加币后,如果 IM 或播报失败,不把 draw 改成 failed避免覆盖账务事实。 | `markOutboxFailed(... alreadyCredited=true)` 分支;`MarkLuckyGiftDrawsFailed` 注释约束 only pending。 |
| 维度 | 预期 |
| --- | --- |
| 用户行为 | 高频小奖维持发送频率,稀有大奖和必爆大奖制造传播峰值。 |
| 经济指标 | 平台抽水稳定J Fund 不透支,大额释放后仍有底池恢复能力。 |
| 风险指标 | 单用户单次/累计上限、内部账号隔离、完整审计降低套利和质疑。 |
| 长期循环 | 数学上保证喂池后一定爆发,形成持续正反馈循环。 |
## 7. 批量抽奖产品规则
## 9. 技术实施建议
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| N 次独立随机 | `gift_count=N` 时内部执行 N 次抽奖,每次保留独立 draw record。 | `executeOptimizedLuckyGiftDrawBatch` 循环调用 `stageLuckyBatchDraw``insertLuckyDrawRecords` 批量写每条明细。 |
| 一次聚合发放 | 批量抽奖只产生一条聚合 `LuckyGiftDrawn` outbox钱包只入账一次。 | `insertLuckyAggregateDrawOutbox` payload 包含 `draw_id` 和全部 `draw_ids``processDrawOutbox` 对聚合金额调用一次钱包。 |
| 聚合结果展示 | 批量响应展示总基础奖励、总气氛奖励、总活动补贴、总有效奖励、总倍率;总倍率是子抽倍率相加,不是平均倍率。 | `luckyAggregateDrawResults`。 |
| 批量性能 | 规则、用户状态、RTP 窗口、奖池、预算和风控行只锁一次,循环内推进内存状态,事务尾部批量写回。 | `executeOptimizedLuckyGiftDrawBatch``stageLuckyBatchDraw``persistLuckyBatchDraw`。 |
| 批量幂等 | 批量子抽命令使用 `command_id#序号`;重试读回已提交子抽,部分存在时报冲突。 | `luckyDrawSubCommandID``collectLuckyBatchDrawsByCommand`。 |
### 9.1 后端落地顺序
## 8. 后台配置功能
1. 先实现资金原子更新、J Fund 周期状态、开奖事实和完整审计日志。
2. 再接入 outbox worker由钱包服务幂等入账。
3. 最后接入房间 IM、区域播报、后台配置、后台查询和监控告警。
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 后台菜单 | 运营后台有“幸运礼物”菜单,权限码为 `lucky-gift:view``lucky-gift:update`。 | `server/admin/migrations/017_lucky_gift_navigation.sql``server/admin/internal/repository/seed.go`;前端 `routes.js`。 |
| 查看配置 | 管理员可按 `pool_id` 查看奖池配置;未配置时返回 disabled 默认草稿。 | `GET /admin/activity/lucky-gifts/v2/config``Handler.GetLuckyGiftConfig``Service.GetConfig`;前端 `getLuckyGiftConfig`。 |
| 新增奖池 | 前端允许输入新奖池 ID并以默认草稿打开配置抽屉。 | `LuckyGiftConfigPage.jsx``AddPoolDialog``useLuckyGiftPage.submitAddPool`。 |
| 发布配置 | 管理员保存后发布为新的不可变规则版本。 | `PUT /admin/activity/lucky-gifts/v2/config``Handler.UpsertLuckyGiftConfig``Service.UpsertConfig``Repository.PublishLuckyGiftRuleConfig`。 |
| 奖池列表 | 管理员可查看已有奖池列表,每个奖池展示最新版本。 | `GET /admin/activity/lucky-gifts/v2/configs``Handler.ListLuckyGiftConfigs``Repository.ListLuckyGiftRuleConfigs`;前端 `listLuckyGiftConfigs`。 |
| 基础控制字段 | 后台可配置启用状态、RTP、入池比例、结算窗口流水、波动带、参考价格、新手/正常等价抽数。 | 前端 `LuckyGiftConfigDrawer` 的“基础控制”;后端 `configRequest``LuckyGiftRuleConfig` 字段。 |
| 风控上限字段 | 后台可配置单次、用户小时、用户每日、设备每日、房间小时、主播每日上限。 | 前端 `LuckyGiftConfigDrawer` 的“风控上限”;后端 `validateRuleConfig`。 |
| 阶段奖档编辑 | 后台可编辑新手、正常、高阶三个阶段的奖档 ID、倍率、概率、预算来源、广播级别、高水位和启用状态其中 `reward_source` 当前用于配置和校验,运行随机只消费 `base_rtp`。 | `StageTierEditor``TierRow`;后端 `configToProto``configFromProto`;运行转换见 `luckyRuntimeTiersFromRuleStages`。 |
| 概率纠正工具 | 前端提供“纠正概率”工具,按目标 RTP 调整阶段概率,帮助运营形成可发布配置。 | `LuckyGiftConfigDrawer` 调用 `correctStageTierProbabilities`;实现见 `configModel.js`。 |
| 表单校验 | 前端提前校验概率闭合、RTP 波动带、0x 档、基础 RTP 档和高倍高水位;后端仍做最终校验。 | 前端 `luckyGiftConfigFormSchema`;后端 `validateRuleConfig``validateRuleStage``validateRuleStageExpectedRTP`。 |
| 随机模拟测试 | 后台配置页提供随机模拟,可输入用户数、房间数、单抽花费范围和抽奖次数范围,输出概览并下载 CSV。 | `LuckyGiftSimulationPanel.jsx``runLuckyGiftSimulation`。 |
### 9.2 服务边界
## 9. 后台审计和数据
| 服务 | 职责 |
| --- | --- |
| room-service | 只负责送礼主链路、房间状态、Room Cell 和房间 outbox不拥有奖池。 |
| activity-service | 拥有幸运礼物规则、S Fund、J Fund、开奖事实、风控和发奖 outbox。 |
| wallet-service | 负责送礼扣费和幸运礼物返奖入账,保证幂等账务。 |
| statistics-service | 只消费事件写聚合表,不回查 owner service 明细表。 |
| server/admin | 提供参数配置、规则版本、开奖明细和统计查询入口。 |
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 抽奖明细查询 | 后台接口支持按 `pool_id/gift_id/user_id/room_id/status` 分页查询抽奖明细。 | `GET /admin/activity/lucky-gifts/v2/draws``Handler.ListLuckyGiftDraws``Repository.ListLuckyGiftDraws``luckyDrawWhereClause`。 |
| 抽奖汇总 | 后台接口和页面展示总抽数、参与用户、参与房间、消耗金币、返奖金币、实际 RTP、pending/granted/failed 数。 | `GET /admin/activity/lucky-gifts/v2/draw-summary``Repository.GetLuckyGiftDrawSummary`;前端 `LuckyGiftDrawSummaryPanel`。 |
| 审计快照 | 每条抽奖明细记录命中奖档、过滤原因、奖池快照、RTP 快照、发放状态和钱包交易 ID。 | `lucky_draw_records` 表;`luckyDrawRecordSnapshots``ListLuckyGiftDraws`。 |
| 统计大盘口径 | 统计服务从 `RoomGiftSent` 事件识别带 `pool_id` 的礼物,累计幸运礼物流水和去重付费人数。 | `RoomGiftSent.pool_id``statistics-service/internal/storage/mysql/repository.go``ConsumeRoomGift``stat_lucky_gift_day_payers`。 |
| 发放状态 | 抽奖记录状态分为 `pending``granted``failed`;无奖励且无表现副作用的记录可直接 `granted`。 | `domain.StatusPending/Granted/Failed``luckyInitialRewardStatus``MarkLuckyGiftDrawsGranted``MarkLuckyGiftDrawsFailed`。 |
### 9.3 必须支持热更新
## 10. 接口清单
以下参数需要后台配置并落规则版本:
| 端 | 接口 | 功能点 | 实现依据 |
| --- | --- | --- | --- |
| App HTTP | `POST /api/v1/activities/lucky-gifts/check` | 送礼前资格检查。 | `activityapi.Handler.checkLuckyGift``httproutes.registerTaskRoutes`。 |
| App HTTP | `POST /api/v1/rooms/gift/send` | 发礼物并在成功扣费后触发幸运礼物抽奖。 | `httproutes.registerRoomRoutes``room-service``SendGift`。 |
| 内部 gRPC | `LuckyGiftService.CheckLuckyGift` | 房间链路或 gateway 查询奖池可用性。 | `api/proto/activity/v1/activity.proto``LuckyGiftServer.CheckLuckyGift`。 |
| 内部 gRPC | `LuckyGiftService.ExecuteLuckyGiftDraw` | 扣费成功后的抽奖命令。 | `LuckyGiftMeta``LuckyGiftDrawResult``LuckyGiftServer.ExecuteLuckyGiftDraw`。 |
| 内部 gRPC | `WalletService.CreditLuckyGiftReward` | 抽奖 outbox worker 调钱包入账。 | `api/proto/wallet/v1/wallet.proto`;钱包 `CreditLuckyGiftReward`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/config` | 获取单个奖池配置。 | `Handler.GetLuckyGiftConfig`。 |
| 后台 HTTP | `PUT /admin/activity/lucky-gifts/v2/config` | 发布奖池配置。 | `Handler.UpsertLuckyGiftConfig`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/configs` | 获取奖池配置列表。 | `Handler.ListLuckyGiftConfigs`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/draws` | 查询抽奖明细。 | `Handler.ListLuckyGiftDraws`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/draw-summary` | 查询抽奖汇总。 | `Handler.GetLuckyGiftDrawSummary`。 |
- 平台抽水比例。
- S Fund 分布、概率和金额区间。
- J Fund 贡献比例。
- 基础大奖概率和分段释放比例。
- Must-Hit 爆点范围和释放比例。
- 单次、单用户、设备、房间、主播风控上限。
- 内部账号和机器人隔离规则。
## 11. 异常和边界
### 9.4 监控告警
| 场景 | 产品处理 | 实现依据 |
| --- | --- | --- |
| 奖池未配置 | 检查接口返回不可用;后台返回可编辑 disabled 草稿。 | `Repository.CheckLuckyGift` 返回 `not_configured``Service.GetConfig` 返回 `DefaultRuleConfig`。 |
| 奖池关闭 | 显式传 `pool_id` 的送礼在扣费前失败;抽奖入口也会拒绝。 | `SendGift``CheckLuckyGift` 分支;`executeSingleLuckyGiftDraw``executeOptimizedLuckyGiftDrawBatch`。 |
| 抽奖失败 | 整笔送礼失败,不落普通礼物事实,依赖钱包扣费幂等处理重试。 | `SendGift` 在抽奖失败时直接返回错误,未进入 Room Cell 事件落地。 |
| outbox payload 损坏 | 无法解析时走失败收敛,不无限重试。 | `decodeDrawOutboxPayload``markOutboxFailed`。 |
| worker 依赖缺失 | 钱包、房间发布或区域播报依赖缺失时outbox 进入重试或失败,不直接丢弃。 | `processDrawOutbox``wallet == nil``publisher == nil``broadcaster == nil` 分支。 |
| 小额礼物 | 阶段按累计流水折算,奖档金额按实际单抽金额缩放,风控上限保持绝对金币。 | `luckyRuntimeConfig` 注释和实现;单测 `TestLuckyRuntimeConfigScalesTierRewardsByActualSpendButKeepsRiskCapsAbsolute`。 |
| UTC 窗口 | 付费时间、风控小时/日、活动预算日均使用 UTC epoch ms。 | `Service.Draw``PaidAtMS` 使用 `UTC().UnixMilli()``getLuckyRiskCounters``getLuckyActivityRemaining` 使用 `time.UnixMilli(...).UTC()`。 |
- J Fund 余额异常波动。
- 实际 win rate 与配置概率长期偏离。
- 单用户、单设备、单房间大额中奖集中。
- outbox 发奖积压或失败。
- 钱包返奖幂等冲突。
- 必爆周期长时间未重置或重复触发。
## 12. 当前不作为已实现产品能力
### 9.5 合规
| 能力 | 当前状态 | 实现依据 |
| --- | --- | --- |
| 运行时按 RTP 缺口动态调概率 | 不支持。RTP 只用于发布校验和窗口观察。 | `selectLuckyCandidate` 只使用配置权重并过滤不可支付候选。 |
| 多目标送礼抽奖拆分 | 不支持。当前房间送礼仅支持 `target_type=user` 且一个目标用户。 | `SendGift` 对非 `user` 和多目标返回 `InvalidArgument`。 |
| 后台配置三层奖池权重 | v2 后台当前不暴露,运行侧使用保守默认权重。 | `luckyRuntimeConfigFromRuleConfig` 固定 `PlatformPoolWeightPPM=200000``RoomPoolWeightPPM=300000``GiftPoolWeightPPM=500000`。 |
| 按奖档广播级别精细化播报 | 配置字段存在,但当前区域播报阈值由代码统一判断为有区域、有效奖励、倍率不低于 1x。 | `LuckyGiftRuleTier.broadcast_level``shouldPublishLuckyGiftRegionBroadcast`。 |
| 按 `reward_source` 生成活动补贴或表现候选 | 不支持。当前 v2 运行候选只读取 `base_rtp` 奖档,活动补贴和表现来源不会被转换成随机候选。 | `luckyRuntimeTiersFromRuleStages` 对非 `SourceBaseRTP` 的奖档 `continue`。 |
上线前需要法务确认虚拟礼物概率玩法边界。客户端可以公开必要规则说明,但不能暴露隐藏奖池余额、当前爆点和触发进度。
## 13. 验证依据
## 10. 审计日志字段
| 验证项 | 覆盖功能 | 实现依据 |
| --- | --- | --- |
| Activity 配置校验单测 | 默认配置、ppm 单位、防错、概率闭合、0x 档、风控上限、阶段阈值。 | `services/activity-service/internal/service/luckygift/config_test.go`。 |
| Activity worker 单测 | 钱包入账、房间 IM、批量一次发奖并批量 granted。 | `services/activity-service/internal/service/luckygift/service_test.go`。 |
| Activity 抽奖算法单测 | 真实金额缩放、等价抽数、批量拆分、聚合倍数、候选过滤、不可支付回退 0x。 | `services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go`。 |
| Room 送礼链路单测 | 送礼后返回 `lucky_gift`,并把扣费后的金额传给抽奖。 | `services/room-service/internal/room/service/lucky_gift_test.go`。 |
| Gateway 检查接口单测 | App 检查接口正确转发 room/gift/pool/user 参数。 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler_test.go`。 |
| Wallet 返奖幂等单测 | 同一幸运礼物返奖命令只产生一条钱包交易和 outbox。 | `services/wallet-service/internal/service/wallet/service_test.go``TestCreditLuckyGiftRewardIsIdempotent`。 |
| 后台单位转换单测 | 百分比和倍率在后台 HTTP DTO 与 proto ppm 之间正确转换。 | `server/admin/internal/modules/luckygift/handler_test.go`。 |
| 前端表单与模拟单测 | 配置 payload、阶段 RTP、概率纠正和模拟确定性。 | `hyapp-admin-platform/src/features/lucky-gift/*test*`。 |
每笔开奖至少记录:
| 字段 | 说明 |
| --- | --- |
| gift_id | 本次送礼或抽奖唯一 ID。 |
| command_id | 幂等命令 ID重试必须复用。 |
| user_id | 送礼用户。 |
| room_id | 房间 ID。 |
| target_user_id | 收礼用户。 |
| amount | 本次礼物真实扣费金额。 |
| s_before / s_after | S Fund 变更快照。 |
| j_before / j_after | J Fund 变更快照。 |
| s_win | S Fund 中奖金额。 |
| j_win | J Fund 中奖金额。 |
| forced_hit | 是否 Must-Hit 强制触发。 |
| trigger_before / trigger_after | 周期爆点变更。 |
| random_seed | 可验证随机材料。 |
| random_rolls | 本次所有随机 roll 明细。 |
| status | pending、granted、failed。 |
| created_at_ms | UTC epoch milliseconds。 |
## 11. 当前实现差距
本方案与当前仓库中的幸运礼物 v2 配置化 RTP 奖档模型不是同一个口径。进入技术实现前,需要明确是否替换现有 v2 主链路,或作为新 `pool_id` / 新规则版本并行上线。
必须确认的差距:
- 当前实现按 `pool_id`、规则版本、阶段奖档和 RTP 窗口抽奖;新方案按 S Fund、J Fund 和 Must-Hit 周期抽奖。
- 当前实现已有三层基础奖池和风控计数;新方案需要新增隐藏 J Fund 周期状态和强制爆点。
- 当前实现 worker 已支持异步发奖;新方案必须复用这个账务边界,不能把伪代码中的 `credit_user` 做成事务内 RPC。
- 当前统计只能消费事实事件写聚合表;新方案的 S/J payout 需要补充事件字段或独立统计读模型。
## 12. 版本迭代计划
| 版本 | 内容 |
| --- | --- |
| v1.1 | 增加多档幸运礼物,高价礼物贡献更高 J Fund 比例。 |
| v1.2 | 按房间活跃度自适应 Must-Hit 爆点范围。 |
| v1.3 | 可选展示个人累计 jackpot 贡献,默认仍保持极简模式。 |

View File

@ -54,6 +54,8 @@ type ConsumerConfig struct {
ConsumePullBatch int32
ConsumePullTimeout time.Duration
ConsumeGoroutines int
// ConsumeFromFirst 仅给可重建读模型使用;普通业务 consumer 保持客户端默认的 last offset避免新组误重放历史副作用。
ConsumeFromFirst bool
}
// Message is the stable publish shape used by services.
@ -209,6 +211,9 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
if cfg.ConsumeGoroutines > 0 {
options = append(options, consumer.WithConsumeGoroutineNums(cfg.ConsumeGoroutines))
}
if cfg.ConsumeFromFirst {
options = append(options, consumer.WithConsumeFromWhere(consumer.ConsumeFromFirstOffset))
}
client, err := rocketmq.NewPushConsumer(options...)
if err != nil {
return nil, err

View File

@ -78,12 +78,15 @@ type CustomGroupMessage struct {
// CustomUserMessage 表示一条服务端发给单个用户的 TIMCustomElem。
// PayloadJSON 同时写入 Data 和 CloudCustomData客户端必须按 event_id 去重。
type CustomUserMessage struct {
ToAccount string
EventID string
Desc string
Ext string
ToAccount string
EventID string
Desc string
Ext string
// FromAccount 为空时使用管理员账号;用户主动行为要显式传用户 identifier避免消息归属变成系统通知。
FromAccount string
PayloadJSON json.RawMessage
// SyncOtherMachine 透传腾讯 C2C 同步策略0 保持历史默认 2不同步发送方其他终端。
SyncOtherMachine int
PayloadJSON json.RawMessage
}
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
@ -315,13 +318,18 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
// C2C 服务端消息默认从管理员账号发出,避免调用方每个业务模块复制同一配置。
message.FromAccount = c.cfg.AdminIdentifier
}
syncOtherMachine := message.SyncOtherMachine
if syncOtherMachine == 0 {
// 历史 notice 消息只投递给目标用户;不显式传值时继续保持这个行为,避免改变钱包/CP 通知的漫游面。
syncOtherMachine = 2
}
payload := bytes.TrimSpace(message.PayloadJSON)
if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 {
return fmt.Errorf("user custom message is incomplete")
}
request := sendC2CMsgRequest{
SyncOtherMachine: 2,
SyncOtherMachine: syncOtherMachine,
ToAccount: message.ToAccount,
MsgRandom: randomUint32(),
CloudCustomData: string(payload),

View File

@ -0,0 +1,416 @@
// Package userleaderboard owns the Redis read model for App user gift rankings.
package userleaderboard
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"hyapp/pkg/appcode"
)
const (
BoardSent = "sent"
BoardReceived = "received"
PeriodToday = "today"
PeriodWeek = "week"
PeriodMonth = "month"
defaultKeyPrefix = "activity:user_leaderboard"
defaultPageSize = 20
maxPageSize = 100
defaultEventDedupeTTL = 90 * 24 * time.Hour
dailyLeaderboardKeep = 7 * 24 * time.Hour
weeklyLeaderboardKeep = 21 * 24 * time.Hour
monthlyLeaderboardKeep = 35 * 24 * time.Hour
)
var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only.
type Store struct {
client *redis.Client
keyPrefix string
}
// GiftEvent is the minimal WalletGiftDebited fact needed to update the user leaderboard.
type GiftEvent struct {
AppCode string
EventID string
SenderUserID int64
TargetUserID int64
GiftValue int64
GiftCount int64
OccurredAtMS int64
DirectGift bool
}
// Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
type Query struct {
AppCode string
BoardType string
Period string
Page int
PageSize int
Now time.Time
}
// Page is the Redis aggregate result returned to gateway before profile enrichment.
type Page struct {
Items []Entry
Total int64
BoardType string
Period string
StartAtMS int64
EndAtMS int64
ServerTimeMS int64
}
// Entry contains only aggregate facts. gateway enriches user profile data separately.
type Entry struct {
Rank int64
UserID string
GiftValue int64
GiftCount int64
TransactionCount int64
LastGiftAtMS int64
}
// NewRedisClient creates and verifies the Redis dependency used by the leaderboard read model.
func NewRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
// 榜单 Redis 是 gateway 查询的唯一读模型;启动时探测失败比运行中慢查钱包库更容易定位。
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
// NewStore binds a verified Redis client to a stable key namespace.
func NewStore(client *redis.Client, keyPrefix string) *Store {
keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":")
if keyPrefix == "" {
keyPrefix = defaultKeyPrefix
}
return &Store{client: client, keyPrefix: keyPrefix}
}
// ApplyGiftEvent increments sent and received buckets for day/week/month.
// It returns false when the fact is outside the old SQL leaderboard scope, such as direct gifts.
func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, error) {
if s == nil || s.client == nil {
return false, ErrNotConfigured
}
event = normalizeGiftEvent(event)
if event.AppCode == "" || event.EventID == "" || event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.GiftValue <= 0 || event.DirectGift {
return false, nil
}
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
keys := []string{s.dedupeKey(event.AppCode, event.EventID)}
args := []any{
strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10),
strconv.FormatInt(event.GiftValue, 10),
strconv.FormatInt(event.GiftCount, 10),
strconv.FormatInt(event.OccurredAtMS, 10),
}
updates := make([]leaderboardUpdate, 0, 6)
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates,
s.updateFor(event.AppCode, BoardSent, period, event.SenderUserID, occurredAt),
s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
)
}
args = append(args, strconv.Itoa(len(updates)))
for _, update := range updates {
keys = append(keys, update.scoreKey, update.itemKey)
args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10))
}
result, err := applyGiftEventScript.Run(ctx, s.client, keys, args...).Int64()
if err != nil {
return false, err
}
return result == 1, nil
}
// List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings.
func (s *Store) List(ctx context.Context, query Query) (Page, error) {
if s == nil || s.client == nil {
return Page{}, ErrNotConfigured
}
query = normalizeQuery(query)
start, end := QueryWindow(query.Period, query.Now)
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
offset := int64((query.Page - 1) * query.PageSize)
stop := offset + int64(query.PageSize) - 1
total, err := s.client.ZCard(ctx, key).Result()
if err != nil {
return Page{}, err
}
rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
if err != nil {
return Page{}, err
}
items := make([]Entry, 0, len(rows))
for index, row := range rows {
userID, ok := row.Member.(string)
if !ok || strings.TrimSpace(userID) == "" {
continue
}
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, userID, int64(math.Round(row.Score)))
if err != nil {
return Page{}, err
}
entry.Rank = offset + int64(index) + 1
items = append(items, entry)
}
return Page{
Items: items,
Total: total,
BoardType: query.BoardType,
Period: query.Period,
StartAtMS: start.UnixMilli(),
EndAtMS: end.UnixMilli(),
ServerTimeMS: query.Now.UTC().UnixMilli(),
}, nil
}
// RankForUser returns the caller's current aggregate rank for the same bucket used by List.
func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Entry, bool, error) {
if s == nil || s.client == nil {
return Entry{}, false, ErrNotConfigured
}
if userID <= 0 {
return Entry{}, false, nil
}
query = normalizeQuery(query)
start, _ := QueryWindow(query.Period, query.Now)
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
member := strconv.FormatInt(userID, 10)
rank, err := s.client.ZRevRank(ctx, key, member).Result()
if errors.Is(err, redis.Nil) {
return Entry{}, false, nil
}
if err != nil {
return Entry{}, false, err
}
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, member, 0)
if err != nil {
return Entry{}, false, err
}
entry.Rank = rank + 1
return entry, true, nil
}
// NormalizeBoardType keeps gateway query aliases out of the Redis key layer.
func NormalizeBoardType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", BoardSent, "send", "sender", "gift_sent", "user_sent":
return BoardSent
case BoardReceived, "receive", "receiver", "gift_received", "user_received":
return BoardReceived
default:
return ""
}
}
// NormalizePeriod maps public aliases to the fixed UTC buckets used by activity and gateway.
func NormalizePeriod(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", PeriodToday, "day", "daily":
return PeriodToday
case PeriodWeek, "weekly":
return PeriodWeek
case PeriodMonth, "monthly":
return PeriodMonth
default:
return ""
}
}
// QueryWindow preserves the existing gateway response contract: start is the UTC bucket boundary, end is request time.
func QueryWindow(period string, now time.Time) (time.Time, time.Time) {
now = normalizedNow(now)
start, _ := periodBounds(period, now)
return start, now
}
type leaderboardUpdate struct {
scoreKey string
itemKey string
member string
ttl time.Duration
}
func (s *Store) updateFor(app string, board string, period string, userID int64, occurredAt time.Time) leaderboardUpdate {
start, periodEnd := periodBounds(period, occurredAt)
member := strconv.FormatInt(userID, 10)
return leaderboardUpdate{
scoreKey: s.scoreKey(app, board, period, start),
itemKey: s.itemKey(app, board, period, start, member),
member: member,
ttl: retentionTTL(period, start, periodEnd),
}
}
func (s *Store) entryFromHash(ctx context.Context, app string, board string, period string, start time.Time, userID string, scoreFallback int64) (Entry, error) {
values, err := s.client.HGetAll(ctx, s.itemKey(app, board, period, start, userID)).Result()
if err != nil {
return Entry{}, err
}
entry := Entry{
UserID: userID,
GiftValue: scoreFallback,
}
if value := int64FromHash(values, "gift_value"); value > 0 {
entry.GiftValue = value
}
entry.GiftCount = int64FromHash(values, "gift_count")
entry.TransactionCount = int64FromHash(values, "transaction_count")
entry.LastGiftAtMS = int64FromHash(values, "last_gift_at_ms")
return entry, nil
}
func (s *Store) scoreKey(app string, board string, period string, start time.Time) string {
return fmt.Sprintf("%s:%s:%s:%s:%s:scores", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start))
}
func (s *Store) itemKey(app string, board string, period string, start time.Time, userID string) string {
return fmt.Sprintf("%s:%s:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start), userID)
}
func (s *Store) dedupeKey(app string, eventID string) string {
return fmt.Sprintf("%s:%s:dedupe:%s", s.keyPrefix, appcode.Normalize(app), strings.TrimSpace(eventID))
}
func normalizeGiftEvent(event GiftEvent) GiftEvent {
event.AppCode = appcode.Normalize(event.AppCode)
event.EventID = strings.TrimSpace(event.EventID)
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = time.Now().UTC().UnixMilli()
}
return event
}
func normalizeQuery(query Query) Query {
query.AppCode = appcode.Normalize(query.AppCode)
query.BoardType = NormalizeBoardType(query.BoardType)
if query.BoardType == "" {
query.BoardType = BoardSent
}
query.Period = NormalizePeriod(query.Period)
if query.Period == "" {
query.Period = PeriodToday
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = defaultPageSize
}
if query.PageSize > maxPageSize {
query.PageSize = maxPageSize
}
query.Now = normalizedNow(query.Now)
return query
}
func normalizedNow(now time.Time) time.Time {
if now.IsZero() {
return time.Now().UTC()
}
return now.UTC()
}
func periodBounds(period string, at time.Time) (time.Time, time.Time) {
at = normalizedNow(at)
dayStart := time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC)
switch NormalizePeriod(period) {
case PeriodWeek:
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
start := dayStart.AddDate(0, 0, 1-weekday)
return start, start.AddDate(0, 0, 7)
case PeriodMonth:
start := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, time.UTC)
return start, start.AddDate(0, 1, 0)
default:
return dayStart, dayStart.AddDate(0, 0, 1)
}
}
func retentionTTL(period string, start time.Time, periodEnd time.Time) time.Duration {
switch NormalizePeriod(period) {
case PeriodMonth:
return periodEnd.Sub(start) + monthlyLeaderboardKeep
case PeriodWeek:
return periodEnd.Sub(start) + weeklyLeaderboardKeep
default:
return periodEnd.Sub(start) + dailyLeaderboardKeep
}
}
func bucketID(period string, start time.Time) string {
switch NormalizePeriod(period) {
case PeriodWeek:
year, week := start.ISOWeek()
return fmt.Sprintf("%04d%02d", year, week)
case PeriodMonth:
return fmt.Sprintf("%04d%02d", start.Year(), int(start.Month()))
default:
return start.Format("20060102")
}
}
func int64FromHash(values map[string]string, key string) int64 {
value, _ := strconv.ParseInt(values[key], 10, 64)
return value
}
var applyGiftEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then
return 0
end
redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do
local score_key = KEYS[2 + i * 2]
local item_key = KEYS[3 + i * 2]
local member = ARGV[6 + i * 2]
local ttl_seconds = tonumber(ARGV[7 + i * 2])
local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value)
redis.call("HINCRBY", item_key, "gift_count", gift_count)
redis.call("HINCRBY", item_key, "transaction_count", 1)
local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
local last_gift_at_ms = previous_last
if occurred_at_ms > previous_last then
redis.call("HSET", item_key, "last_gift_at_ms", occurred_at_ms)
last_gift_at_ms = occurred_at_ms
end
redis.call("ZADD", score_key, tonumber(current_value) + last_gift_at_ms / 10000000000000000, member)
redis.call("EXPIRE", score_key, ttl_seconds)
redis.call("EXPIRE", item_key, ttl_seconds)
end
return 1
`)

View File

@ -0,0 +1,52 @@
package userleaderboard
import (
"testing"
"time"
)
func TestQueryWindowUsesExistingUTCBoundaries(t *testing.T) {
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
todayStart, todayEnd := QueryWindow(PeriodToday, now)
if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) {
t.Fatalf("today start = %s", todayStart)
}
if !todayEnd.Equal(now.UTC()) {
t.Fatalf("today end = %s", todayEnd)
}
weekStart, _ := QueryWindow(PeriodWeek, now)
if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) {
t.Fatalf("week start = %s", weekStart)
}
monthStart, _ := QueryWindow(PeriodMonth, now)
if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) {
t.Fatalf("month start = %s", monthStart)
}
}
func TestNormalizeAliases(t *testing.T) {
if NormalizeBoardType("receiver") != BoardReceived {
t.Fatal("receiver should normalize to received")
}
if NormalizeBoardType("gift_sent") != BoardSent {
t.Fatal("gift_sent should normalize to sent")
}
if NormalizePeriod("monthly") != PeriodMonth {
t.Fatal("monthly should normalize to month")
}
}
func TestStoreKeyPrefixAndBucketAreStable(t *testing.T) {
store := NewStore(nil, "activity:user_leaderboard:")
weekStart := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
if got := store.scoreKey(" HyApp_Prod ", BoardSent, PeriodWeek, weekStart); got != "activity:user_leaderboard:hyapp_prod:sent:week:202653:scores" {
t.Fatalf("score key mismatch: %s", got)
}
if got := store.itemKey("hyapp_prod", BoardReceived, PeriodToday, time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC), "10001"); got != "activity:user_leaderboard:hyapp_prod:received:today:20260626:users:10001" {
t.Fatalf("item key mismatch: %s", got)
}
}

View File

@ -25,7 +25,7 @@ type Spec struct {
var catalog = map[Code]Spec{
InvalidArgument: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidArgument, "invalid argument"),
NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "conflict"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"),
RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"),
Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"),
@ -42,26 +42,26 @@ var catalog = map[Code]Spec{
AuthFailed: spec(codes.Unauthenticated, httpStatusUnauthorized, AuthFailed, "authentication failed"),
AuthLoginBlocked: spec(codes.PermissionDenied, httpStatusForbidden, AuthLoginBlocked, "login blocked"),
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "conflict"),
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "password already set"),
UserDisabled: spec(codes.PermissionDenied, httpStatusForbidden, UserDisabled, "permission denied"),
SessionExpired: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionExpired, "unauthorized"),
SessionRevoked: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionRevoked, "unauthorized"),
DisplayUserIDInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, DisplayUserIDInvalid, "invalid argument"),
DisplayUserIDExists: spec(codes.AlreadyExists, httpStatusConflict, DisplayUserIDExists, "conflict"),
DisplayUserIDCooldown: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDCooldown, "conflict"),
DisplayUserIDExists: spec(codes.AlreadyExists, httpStatusConflict, DisplayUserIDExists, "display user id already exists"),
DisplayUserIDCooldown: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDCooldown, "display user id change is cooling down"),
DisplayUserIDNotFound: spec(codes.NotFound, httpStatusNotFound, DisplayUserIDNotFound, "not found"),
DisplayUserIDAllocateFailed: spec(codes.Internal, httpStatusInternalServerError, Internal, "internal error"),
DisplayUserIDPrettyNotAvailable: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyNotAvailable, "conflict"),
DisplayUserIDPrettyActive: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyActive, "conflict"),
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "conflict"),
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "conflict"),
DisplayUserIDPrettyNotAvailable: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyNotAvailable, "pretty display user id is not available"),
DisplayUserIDPrettyActive: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyActive, "pretty display user id already active"),
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "pretty display user id lease expired"),
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "pretty display user id payment required"),
CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "country change is cooling down"),
CountryNotFound: spec(codes.NotFound, httpStatusNotFound, CountryNotFound, "not found"),
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
DeviceAlreadyRegistered: spec(
codes.FailedPrecondition,
@ -71,38 +71,38 @@ var catalog = map[Code]Spec{
),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "conflict"),
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "conflict"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "wallet state changed, please retry"),
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "coin seller is not active"),
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "conflict"),
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "conflict"),
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "payment reference already used"),
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "request payload does not match previous command"),
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "conflict"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "conflict"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "conflict"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "conflict"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "red packet is disabled"),
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),
RedPacketInvalidCountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidCountTier, "invalid argument"),
RedPacketAmountTooSmall: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketAmountTooSmall, "invalid argument"),
RedPacketDailyLimitReached: spec(codes.ResourceExhausted, 429, RedPacketDailyLimitReached, "rate limited"),
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "conflict"),
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "conflict"),
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "conflict"),
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "conflict"),
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "red packet is not open"),
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "red packet expired"),
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "red packet already claimed"),
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "red packet sold out"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),
RewardPending: spec(codes.FailedPrecondition, httpStatusConflict, RewardPending, "conflict"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "reward rule is not active"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "event already consumed"),
RewardPending: spec(codes.FailedPrecondition, httpStatusConflict, RewardPending, "reward is pending"),
InvalidSection: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidSection, "invalid argument"),
MessageNotFound: spec(codes.NotFound, httpStatusNotFound, MessageNotFound, "not found"),
MessageRecalled: spec(codes.NotFound, httpStatusNotFound, MessageRecalled, "not found"),
PageTokenInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, PageTokenInvalid, "invalid argument"),
RequestConflict: spec(codes.FailedPrecondition, httpStatusConflict, RequestConflict, "conflict"),
ProducerEventConflict: spec(codes.FailedPrecondition, httpStatusConflict, ProducerEventConflict, "conflict"),
RequestConflict: spec(codes.FailedPrecondition, httpStatusConflict, RequestConflict, "request payload does not match existing record"),
ProducerEventConflict: spec(codes.FailedPrecondition, httpStatusConflict, ProducerEventConflict, "producer event payload does not match existing record"),
}
func spec(grpcCode codes.Code, httpStatus int, publicCode Code, publicMessage string) Spec {

36
pkg/xerr/device.go Normal file
View File

@ -0,0 +1,36 @@
package xerr
import (
"fmt"
"strings"
)
// DeviceAlreadyRegisteredMessage 生成同设备注册达到上限时直接展示给 App 的错误文案。
// count 代表当前设备已注册账号数displayUserIDs 按原注册顺序列出短号,便于用户识别原账号。
func DeviceAlreadyRegisteredMessage(count int, displayUserIDs []string) string {
cleaned := make([]string, 0, len(displayUserIDs))
seen := make(map[string]struct{}, len(displayUserIDs))
for _, displayUserID := range displayUserIDs {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" {
continue
}
if _, ok := seen[displayUserID]; ok {
continue
}
seen[displayUserID] = struct{}{}
cleaned = append(cleaned, displayUserID)
}
if count < len(cleaned) {
// 调用方如果只传了短号列表而没单独计数,展示数量必须至少覆盖实际列出的账号。
count = len(cleaned)
}
if count < 0 {
count = 0
}
if len(cleaned) == 0 {
return fmt.Sprintf("you device already register %d account, login failed", count)
}
return fmt.Sprintf("you device already register %d account %s, login failed", count, strings.Join(cleaned, ","))
}

19
pkg/xerr/device_test.go Normal file
View File

@ -0,0 +1,19 @@
package xerr
import "testing"
func TestDeviceAlreadyRegisteredMessageIncludesCountAndDisplayIDs(t *testing.T) {
got := DeviceAlreadyRegisteredMessage(2, []string{"6400", "168430"})
want := "you device already register 2 account 6400,168430, login failed"
if got != want {
t.Fatalf("DeviceAlreadyRegisteredMessage() = %q, want %q", got, want)
}
}
func TestDeviceAlreadyRegisteredMessageCleansDisplayIDs(t *testing.T) {
got := DeviceAlreadyRegisteredMessage(1, []string{" 6400 ", "", "6400"})
want := "you device already register 1 account 6400, login failed"
if got != want {
t.Fatalf("DeviceAlreadyRegisteredMessage() = %q, want %q", got, want)
}
}

View File

@ -71,7 +71,7 @@ const (
RegionDisabled Code = "REGION_DISABLED"
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
InvalidInviteCode Code = "INVALID_INVITE_CODE"
// DeviceAlreadyRegistered 表示同一注册设备已经创建过账号,不能再次作为三方注册入口
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号
DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED"
// InsufficientBalance 表示钱包余额不足。

View File

@ -71,12 +71,12 @@ func TestCatalogMappings(t *testing.T) {
publicMessage: "upstream service error",
},
{
name: "ledger conflict uses aborted grpc but http conflict",
name: "ledger state change uses aborted grpc but actionable http message",
code: LedgerConflict,
grpcCode: codes.Aborted,
httpStatus: httpStatusConflict,
publicCode: string(LedgerConflict),
publicMessage: "conflict",
publicMessage: "wallet state changed, please retry",
},
{
name: "country cooldown keeps actionable app message",
@ -109,6 +109,15 @@ func TestCatalogMappings(t *testing.T) {
}
}
func TestCatalogDoesNotExposeBareConflictMessage(t *testing.T) {
for _, code := range RegisteredCodes() {
spec := SpecOf(code)
if spec.PublicMessage == "conflict" {
t.Fatalf("code %s exposes bare conflict message", code)
}
}
}
func declaredCodesFromSource(t *testing.T) map[Code]string {
t.Helper()

View File

@ -29,6 +29,7 @@ SQL_FILES=(
# Keep MySQL as the only required dependency for this script. Business services
# are started after schema/grants are known to match the current repo.
"${SCRIPT_DIR}/prepare-local-mysql-initdb.sh"
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" mysql
docker compose up -d mysql >/dev/null
@ -49,8 +50,11 @@ fi
for sql_file in "${SQL_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then
printf 'missing mysql init file: %s\n' "${sql_file}" >&2
exit 1
# Platform/admin seed files may live in a sibling deploy repository that
# is absent on lightweight app-backend checkouts. Treat those inputs as
# optional so local service startup can still exercise the owned schemas.
printf 'skipping mysql init file because it is absent or not a regular file: %s\n' "${sql_file}"
continue
fi
printf 'applying mysql init file: %s\n' "${sql_file}"

View File

@ -22,7 +22,6 @@ TOPICS=(
"hyapp_wallet_outbox"
"hyapp_wallet_realtime_outbox"
"hyapp_room_outbox"
"hyapp_robot_room_outbox"
"hyapp_room_rocket_launch"
"hyapp_user_outbox"
"hyapp_message_action_outbox"

View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
APP_CODE="${APP_CODE:-lalu}"
STAT_TZ="${STAT_TZ:-Asia/Shanghai}"
START_DAY="${START_DAY:-$(TZ=Asia/Shanghai date +%F)}"
END_DAY="${END_DAY:-$START_DAY}"
STATISTICS_MYSQL_DSN="${STATISTICS_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_statistics?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
USER_MYSQL_DSN="${USER_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
WALLET_MYSQL_DSN="${WALLET_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
ROOM_MYSQL_DSN="${ROOM_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
GAME_MYSQL_DSN="${GAME_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
export APP_CODE STAT_TZ START_DAY END_DAY
export STATISTICS_MYSQL_DSN USER_MYSQL_DSN WALLET_MYSQL_DSN ROOM_MYSQL_DSN GAME_MYSQL_DSN
echo "Databi real-flow check"
echo " app=${APP_CODE}"
echo " stat_tz=${STAT_TZ}"
echo " days=${START_DAY}..${END_DAY}"
echo
echo "Before running this check, exercise the real local business flows for the selected days."
echo "Recharge is the only metric that may use a local simulated recharge/payment callback."
echo
go run ./services/statistics-service/cmd/replay-stat-tz \
-app "${APP_CODE}" \
-timezone "${STAT_TZ}" \
-start-day "${START_DAY}" \
-end-day "${END_DAY}" \
-apply \
-statistics-dsn "${STATISTICS_MYSQL_DSN}" \
-user-dsn "${USER_MYSQL_DSN}" \
-wallet-dsn "${WALLET_MYSQL_DSN}" \
-room-dsn "${ROOM_MYSQL_DSN}" \
-game-dsn "${GAME_MYSQL_DSN}"
go run ./services/statistics-service/cmd/databi-real-flow-audit \
-app "${APP_CODE}" \
-timezone "${STAT_TZ}" \
-start-day "${START_DAY}" \
-end-day "${END_DAY}" \
-statistics-dsn "${STATISTICS_MYSQL_DSN}" \
-user-dsn "${USER_MYSQL_DSN}" \
-wallet-dsn "${WALLET_MYSQL_DSN}" \
-room-dsn "${ROOM_MYSQL_DSN}" \
-game-dsn "${GAME_MYSQL_DSN}"
echo
echo "Databi real-flow check passed."

View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
# Docker creates a missing bind-mounted file source as a directory. MySQL's
# entrypoint then treats that directory as a SQL input and exits before the
# local database is usable. Keep optional platform SQL behind generated files:
# present sources are copied and executed, absent sources become harmless no-op
# SQL files so lightweight backend checkouts can still start.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DEPLOY_PLATFORM_ROOT="${DEPLOY_PLATFORM_ROOT:-${PROJECT_ROOT}/../deploy-platform}"
LOCAL_INITDB_DIR="${PROJECT_ROOT}/tmp/mysql/initdb"
OPTIONAL_SQL_FILES=(
"005_utf8mb4_chinese_support.sql"
"006_admin_database.sql"
"007_resource_group_wallet_asset_items.sql"
"999_local_grants.sql"
)
mkdir -p "${LOCAL_INITDB_DIR}"
for sql_name in "${OPTIONAL_SQL_FILES[@]}"; do
source_file="${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/${sql_name}"
target_file="${LOCAL_INITDB_DIR}/${sql_name}"
if [[ -f "${source_file}" ]]; then
cp "${source_file}" "${target_file}"
printf 'prepared optional mysql init file: %s\n' "${source_file}"
continue
fi
printf -- '-- optional mysql init file is absent locally: %s\n' "${source_file}" > "${target_file}"
printf 'prepared empty optional mysql init file: %s\n' "${source_file}"
done

View File

@ -288,7 +288,7 @@ func main() {
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),

View File

@ -15,6 +15,7 @@ type Client interface {
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, req *activityv1.UpsertAchievementDefinitionRequest) (*activityv1.UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error)
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
@ -133,6 +134,10 @@ func (c *GRPCClient) UpsertAchievementDefinition(ctx context.Context, req *activ
return c.achievementClient.UpsertAchievementDefinition(ctx, req)
}
func (c *GRPCClient) DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error) {
return c.achievementClient.DeleteAchievementDefinition(ctx, req)
}
func (c *GRPCClient) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) {
return c.registrationRewardClient.GetRegistrationRewardConfig(ctx, req)
}

View File

@ -40,10 +40,12 @@ type Client interface {
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
ListTemporaryRechargeOrders(ctx context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error)
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
@ -186,6 +188,10 @@ func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *wal
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}
func (c *GRPCClient) CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.CreateTemporaryRechargeOrder(ctx, req)
}
func (c *GRPCClient) GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.GetTemporaryRechargeOrder(ctx, req)
}
@ -202,6 +208,10 @@ func (c *GRPCClient) UpdateThirdPartyPaymentRate(ctx context.Context, req *walle
return c.client.UpdateThirdPartyPaymentRate(ctx, req)
}
func (c *GRPCClient) SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) {
return c.client.SyncThirdPartyPaymentMethods(ctx, req)
}
func (c *GRPCClient) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
return c.client.ListAdminRechargeProducts(ctx, req)
}

View File

@ -361,6 +361,32 @@ func (DataScope) TableName() string {
return "admin_data_scopes"
}
type UserMoneyScope struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_admin_user_money_scopes_user;index:uk_admin_user_money_scope,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:idx_admin_user_money_scopes_app_region;index:uk_admin_user_money_scope,unique;not null" json:"appCode"`
RegionID int64 `gorm:"index:idx_admin_user_money_scopes_app_region;index:uk_admin_user_money_scope,unique;not null;default:0" json:"regionId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes"
}
type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
AdminUserID uint `gorm:"index:idx_admin_temporary_payment_link_owners_user;not null" json:"adminUserId"`
RegionID int64 `gorm:"index:idx_admin_temporary_payment_link_owners_region;not null" json:"regionId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (TemporaryPaymentLinkOwner) TableName() string {
return "admin_temporary_payment_link_owners"
}
type AdminJob struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:80;index;not null" json:"type"`

View File

@ -142,6 +142,27 @@ func (h *Handler) UpdateAchievementDefinition(c *gin.Context) {
response.OK(c, item)
}
// DeleteAchievementDefinition 采用归档删除,避免后台删除动作破坏用户已获得徽章和历史成就事实。
func (h *Handler) DeleteAchievementDefinition(c *gin.Context) {
achievementID := strings.TrimSpace(c.Param("achievement_id"))
if achievementID == "" {
response.BadRequest(c, "ID 参数不正确")
return
}
resp, err := h.activity.DeleteAchievementDefinition(c.Request.Context(), &activityv1.DeleteAchievementDefinitionRequest{
Meta: h.meta(c),
AchievementId: achievementID,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := achievementFromProto(resp.GetAchievement())
h.auditLog(c, "delete-achievement", item.AchievementID, item.Title)
response.OK(c, item)
}
func (r achievementRequest) toProto(c *gin.Context, achievementID string, meta *activityv1.RequestMeta) *activityv1.UpsertAchievementDefinitionRequest {
conditions := make([]*activityv1.AchievementCondition, 0, len(r.Conditions))
for _, condition := range r.Conditions {

View File

@ -14,4 +14,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/activity/achievements", middleware.RequirePermission("achievement:view"), h.ListAchievementDefinitions)
protected.POST("/admin/activity/achievements", middleware.RequirePermission("achievement:create"), h.CreateAchievementDefinition)
protected.PUT("/admin/activity/achievements/:achievement_id", middleware.RequirePermission("achievement:update"), h.UpdateAchievementDefinition)
protected.DELETE("/admin/activity/achievements/:achievement_id", middleware.RequirePermission("achievement:update"), h.DeleteAchievementDefinition)
}

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
@ -71,6 +72,42 @@ func (h *Handler) GetUser(c *gin.Context) {
response.OK(c, shared.UserDTO(*user))
}
func (h *Handler) ListUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
scopes, err := h.service.ListUserMoneyScopes(id)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return
}
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
var req moneyScopeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "财务范围参数不正确")
return
}
input := make([]MoneyScopeInput, 0, len(req.Scopes))
for _, item := range req.Scopes {
input = append(input, MoneyScopeInput{AppCode: item.AppCode, RegionID: item.RegionID})
}
scopes, err := h.service.ReplaceUserMoneyScopes(id, input)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "replace-user-money-scopes", "admin_user_money_scopes", "success", fmt.Sprintf("user_id=%d scopes=%d", id, len(scopes)))
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
@ -159,3 +196,15 @@ func (h *Handler) ExportUsers(c *gin.Context) {
c.Writer.WriteHeader(200)
_, _ = c.Writer.Write(export.Content)
}
func moneyScopeDTOs(scopes []model.UserMoneyScope) []gin.H {
out := make([]gin.H, 0, len(scopes))
for _, scope := range scopes {
out = append(out, gin.H{
"appCode": scope.AppCode,
"regionId": scope.RegionID,
"userId": scope.UserID,
})
}
return out
}

View File

@ -30,3 +30,12 @@ type batchStatusRequest struct {
IDs []uint `json:"ids" binding:"required"`
Status string `json:"status" binding:"required"`
}
type moneyScopeRequest struct {
Scopes []moneyScopeItemRequest `json:"scopes"`
}
type moneyScopeItemRequest struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
}

View File

@ -12,7 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus)
protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser)
protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser)
protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)
protected.POST("/users/:id/reset-password", middleware.RequirePermission("user:reset-password"), h.ResetUserPassword)
}

View File

@ -58,6 +58,11 @@ type UserExport struct {
Count int
}
type MoneyScopeInput struct {
AppCode string
RegionID int64
}
func NewService(store *repository.Store, cfg config.Config) *AdminUserService {
return &AdminUserService{store: store, cfg: cfg}
}
@ -102,6 +107,24 @@ func (s *AdminUserService) GetUser(id uint) (*model.User, error) {
return s.store.FindUserByID(id)
}
func (s *AdminUserService) ListUserMoneyScopes(id uint) ([]model.UserMoneyScope, error) {
return s.store.ListUserMoneyScopes(id)
}
func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInput) ([]model.UserMoneyScope, error) {
scopes := make([]model.UserMoneyScope, 0, len(input))
for _, item := range input {
scopes = append(scopes, model.UserMoneyScope{
AppCode: strings.TrimSpace(item.AppCode),
RegionID: item.RegionID,
})
}
if err := s.store.ReplaceUserMoneyScopes(id, scopes); err != nil {
return nil, err
}
return s.store.ListUserMoneyScopes(id)
}
func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) {
updates := map[string]any{}
if input.Name != nil {

View File

@ -149,6 +149,7 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
Page: options.Page,
PageSize: options.PageSize,
UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")),
BizType: firstQuery(c, "biz_type", "bizType"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), true

View File

@ -4,6 +4,7 @@ type listQuery struct {
Page int
PageSize int
UserKeyword string
BizType string
StartAtMS int64
EndAtMS int64
}

View File

@ -671,6 +671,12 @@ func userDTOFromProfile(profile userProfile) coinLedgerUserDTO {
func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ?"
args := []any{appCode, coinAssetType}
if query.BizType != "" {
// 普通金币流水的类型就是 wallet_transactions.biz_type这里做精确参数匹配
// 不把用户输入拼进 SQL保证新增业务类型也能通过同一个查询入口按真实账务类型定位。
where += " AND wt.biz_type = ?"
args = append(args, query.BizType)
}
if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS)
@ -759,6 +765,7 @@ func normalizeListQuery(query listQuery) listQuery {
query.PageSize = 100
}
query.UserKeyword = strings.TrimSpace(query.UserKeyword)
query.BizType = strings.TrimSpace(query.BizType)
return query
}

View File

@ -20,6 +20,17 @@ func TestCoinLedgerWhereUsesTimeWindowAndUserFilter(t *testing.T) {
}
}
func TestCoinLedgerWhereUsesBizTypeFilter(t *testing.T) {
query := listQuery{BizType: "game_debit", StartAtMS: 100, EndAtMS: 200}
where, args := coinLedgerWhere("lalu", query, []int64{11})
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.user_id IN (?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 6 || args[0] != "lalu" || args[1] != coinAssetType || args[2] != "game_debit" || args[3] != int64(100) || args[4] != int64(200) || args[5] != int64(11) {
t.Fatalf("args mismatch: %#v", args)
}
}
func TestCoinAdjustmentWhereLimitsManualCreditCoinEntries(t *testing.T) {
query := listQuery{StartAtMS: 100, EndAtMS: 200}
where, args := coinAdjustmentWhere("lalu", query, []int64{11})

View File

@ -24,7 +24,9 @@ const (
statusActive = "active"
statusDisabled = "disabled"
longBadgeResourceIDKey = "long_badge_resource_id"
longBadgeResourceIDKey = "long_badge_resource_id"
avatarFrameResourceIDKey = "avatar_frame_resource_id"
shortBadgeResourceIDKey = "short_badge_resource_id"
)
type Handler struct {
@ -63,6 +65,8 @@ type ruleDTO struct {
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
SortOrder int32 `json:"sort_order"`
LongBadgeResourceID int64 `json:"long_badge_resource_id"`
AvatarFrameResourceID int64 `json:"avatar_frame_resource_id"`
ShortBadgeResourceID int64 `json:"short_badge_resource_id"`
DisplayConfigJSON string `json:"display_config_json"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
@ -101,6 +105,8 @@ type ruleRequest struct {
RewardResourceGroupID *int64 `json:"reward_resource_group_id"`
SortOrder *int32 `json:"sort_order"`
LongBadgeResourceID *int64 `json:"long_badge_resource_id"`
AvatarFrameResourceID *int64 `json:"avatar_frame_resource_id"`
ShortBadgeResourceID *int64 `json:"short_badge_resource_id"`
DisplayConfigJSON *string `json:"display_config_json"`
}
@ -277,7 +283,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err = mergeLongBadgeResourceID(displayConfig, req.LongBadgeResourceID)
displayConfig, err = mergeRuleDisplayResources(displayConfig, req.LongBadgeResourceID, req.AvatarFrameResourceID, req.ShortBadgeResourceID)
if err != nil {
response.BadRequest(c, "展示配置 JSON 不正确")
return
@ -288,6 +294,18 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return
}
}
if avatarFrameID := avatarFrameResourceID(displayConfig); avatarFrameID > 0 {
if err := h.validateLevelAvatarFrameResource(c, track, avatarFrameID); err != nil {
response.BadRequest(c, err.Error())
return
}
}
if shortBadgeID := shortBadgeResourceID(displayConfig); shortBadgeID > 0 {
if err := h.validateLevelShortBadgeResource(c, track, shortBadgeID); err != nil {
response.BadRequest(c, err.Error())
return
}
}
resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{
Meta: h.meta(c),
Track: track,
@ -305,7 +323,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return
}
item := ruleFromProto(resp.GetRule())
h.auditLog(c, "upsert-level-rule", "growth_level_rules", fmt.Sprintf("%s:%d", track, level), fmt.Sprintf("required_value=%d long_badge_resource_id=%d", item.RequiredValue, item.LongBadgeResourceID))
h.auditLog(c, "upsert-level-rule", "growth_level_rules", fmt.Sprintf("%s:%d", track, level), fmt.Sprintf("required_value=%d long_badge_resource_id=%d avatar_frame_resource_id=%d short_badge_resource_id=%d", item.RequiredValue, item.LongBadgeResourceID, item.AvatarFrameResourceID, item.ShortBadgeResourceID))
response.OK(c, item)
}
@ -494,6 +512,67 @@ func (h *Handler) validateLevelLongBadgeResource(c *gin.Context, track string, r
return nil
}
func (h *Handler) validateLevelAvatarFrameResource(c *gin.Context, track string, resourceID int64) error {
if resourceID <= 0 {
return nil
}
if h.wallet == nil {
return fmt.Errorf("资源服务不可用")
}
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return fmt.Errorf("等级头像框资源不存在")
}
resource := resp.GetResource()
if resource.GetResourceType() != "avatar_frame" {
return fmt.Errorf("等级头像框必须选择头像框资源")
}
metadata := levelAvatarFrameMetadataFromJSON(resource.GetMetadataJson())
if metadata.AvatarFrameKind != "level" {
return fmt.Errorf("等级头像框必须选择等级头像框素材")
}
if metadata.LevelTrack != track {
return fmt.Errorf("等级头像框所属等级不匹配")
}
return nil
}
func (h *Handler) validateLevelShortBadgeResource(c *gin.Context, track string, resourceID int64) error {
if resourceID <= 0 {
return nil
}
if h.wallet == nil {
return fmt.Errorf("资源服务不可用")
}
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return fmt.Errorf("等级短徽章资源不存在")
}
resource := resp.GetResource()
if resource.GetResourceType() != "badge" {
return fmt.Errorf("等级短徽章必须选择徽章资源")
}
metadata := levelBadgeMetadataFromJSON(resource.GetMetadataJson())
if metadata.BadgeForm != "tile" {
return fmt.Errorf("等级短徽章必须选择短徽章素材")
}
if metadata.BadgeKind != "level" {
return fmt.Errorf("等级短徽章必须选择等级徽章素材")
}
if metadata.LevelTrack != track {
return fmt.Errorf("等级短徽章所属等级不匹配")
}
return nil
}
func (h *Handler) currentTrack(c *gin.Context, track string) (trackDTO, bool, error) {
config, err := h.loadConfig(c, track, "")
if err != nil {
@ -577,6 +656,8 @@ func ruleFromProto(item *activityv1.LevelRule) ruleDTO {
RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(),
LongBadgeResourceID: longBadgeResourceID(item.GetDisplayConfigJson()),
AvatarFrameResourceID: avatarFrameResourceID(item.GetDisplayConfigJson()),
ShortBadgeResourceID: shortBadgeResourceID(item.GetDisplayConfigJson()),
DisplayConfigJSON: item.GetDisplayConfigJson(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
@ -634,17 +715,31 @@ func findTier(items []*activityv1.LevelTier, tierID int64) (*activityv1.LevelTie
return nil, false
}
func mergeLongBadgeResourceID(raw string, value *int64) (string, error) {
func mergeRuleDisplayResources(raw string, longBadgeResourceID *int64, avatarFrameResourceID *int64, shortBadgeResourceID *int64) (string, error) {
payload, err := objectFromJSON(raw)
if err != nil {
return "", err
}
if value != nil {
if *value < 0 {
if longBadgeResourceID != nil {
if *longBadgeResourceID < 0 {
return "", fmt.Errorf("long badge resource id is invalid")
}
// 长徽章是每一级展示素材;沿用 display_config_json 可避免为展示扩展反复改表。
payload[longBadgeResourceIDKey] = *value
payload[longBadgeResourceIDKey] = *longBadgeResourceID
}
if avatarFrameResourceID != nil {
if *avatarFrameResourceID < 0 {
return "", fmt.Errorf("avatar frame resource id is invalid")
}
// 等级头像框也是每一级独立物料;和长徽章共用 display_config_json避免为等级展示资源反复扩表。
payload[avatarFrameResourceIDKey] = *avatarFrameResourceID
}
if shortBadgeResourceID != nil {
if *shortBadgeResourceID < 0 {
return "", fmt.Errorf("short badge resource id is invalid")
}
// 短徽章是每一级发放给用户的等级奖励;放在 display_config_json 内保持规则表结构稳定。
payload[shortBadgeResourceIDKey] = *shortBadgeResourceID
}
data, err := json.Marshal(payload)
if err != nil {
@ -683,11 +778,23 @@ func objectFromJSON(raw string) (map[string]any, error) {
}
func longBadgeResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, longBadgeResourceIDKey)
}
func avatarFrameResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, avatarFrameResourceIDKey)
}
func shortBadgeResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, shortBadgeResourceIDKey)
}
func positiveInt64FromDisplayConfig(raw string, key string) int64 {
payload, err := objectFromJSON(raw)
if err != nil {
return 0
}
value, ok := payload[longBadgeResourceIDKey]
value, ok := payload[key]
if !ok {
return 0
}
@ -716,6 +823,11 @@ type levelBadgeMetadata struct {
LevelTrack string `json:"level_track"`
}
type levelAvatarFrameMetadata struct {
AvatarFrameKind string `json:"avatar_frame_kind"`
LevelTrack string `json:"level_track"`
}
func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
payload := levelBadgeMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
@ -727,6 +839,16 @@ func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
return payload
}
func levelAvatarFrameMetadataFromJSON(raw string) levelAvatarFrameMetadata {
payload := levelAvatarFrameMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return levelAvatarFrameMetadata{}
}
payload.AvatarFrameKind = strings.ToLower(strings.TrimSpace(payload.AvatarFrameKind))
payload.LevelTrack = normalizeTrack(payload.LevelTrack)
return payload
}
func parseNonNegativeInt32(raw string) (int32, bool) {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
if err != nil || value < 0 {

View File

@ -6,15 +6,23 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestMergeLongBadgeResourceIDPreservesDisplayConfig(t *testing.T) {
func TestMergeRuleDisplayResourcesPreservesDisplayConfig(t *testing.T) {
badgeID := int64(9001)
raw, err := mergeLongBadgeResourceID(`{"theme":"gold"}`, &badgeID)
avatarFrameID := int64(9101)
shortBadgeID := int64(9201)
raw, err := mergeRuleDisplayResources(`{"theme":"gold"}`, &badgeID, &avatarFrameID, &shortBadgeID)
if err != nil {
t.Fatalf("mergeLongBadgeResourceID failed: %v", err)
t.Fatalf("mergeRuleDisplayResources failed: %v", err)
}
if got := longBadgeResourceID(raw); got != badgeID {
t.Fatalf("long badge id mismatch: got=%d raw=%s", got, raw)
}
if got := avatarFrameResourceID(raw); got != avatarFrameID {
t.Fatalf("avatar frame id mismatch: got=%d raw=%s", got, raw)
}
if got := shortBadgeResourceID(raw); got != shortBadgeID {
t.Fatalf("short badge id mismatch: got=%d raw=%s", got, raw)
}
config, err := objectFromJSON(raw)
if err != nil {
t.Fatalf("objectFromJSON failed: %v", err)
@ -31,7 +39,7 @@ func TestConfigFromProtoGroupsRulesAndTiers(t *testing.T) {
Track: "wealth",
Level: 3,
RequiredValue: 300,
DisplayConfigJson: `{"long_badge_resource_id":7003}`,
DisplayConfigJson: `{"long_badge_resource_id":7003,"avatar_frame_resource_id":8103,"short_badge_resource_id":7103}`,
}},
Tiers: []*activityv1.LevelTier{{
TierId: 11,
@ -49,6 +57,12 @@ func TestConfigFromProtoGroupsRulesAndTiers(t *testing.T) {
if len(dto.Tracks[0].Rules) != 1 || dto.Tracks[0].Rules[0].LongBadgeResourceID != 7003 {
t.Fatalf("rule long badge mismatch: %+v", dto.Tracks[0].Rules)
}
if dto.Tracks[0].Rules[0].AvatarFrameResourceID != 8103 {
t.Fatalf("rule avatar frame mismatch: %+v", dto.Tracks[0].Rules)
}
if dto.Tracks[0].Rules[0].ShortBadgeResourceID != 7103 {
t.Fatalf("rule short badge mismatch: %+v", dto.Tracks[0].Rules)
}
if len(dto.Tracks[0].Tiers) != 1 || dto.Tracks[0].Tiers[0].DisplayBadgeResourceID != 9001 {
t.Fatalf("tier grouping mismatch: %+v", dto.Tracks[0].Tiers)
}

View File

@ -96,9 +96,31 @@ type thirdPartyPaymentMethodDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type thirdPartyPaymentMethodSyncDTO struct {
ProviderCode string `json:"providerCode"`
ScannedCountryCount int32 `json:"scannedCountryCount"`
FetchedCountryCount int32 `json:"fetchedCountryCount"`
CreatedCount int32 `json:"createdCount"`
UpdatedCount int32 `json:"updatedCount"`
SkippedCount int32 `json:"skippedCount"`
FailedCountryCount int `json:"failedCountryCount"`
Issues []thirdPartyPaymentMethodIssueDTO `json:"issues"`
}
type thirdPartyPaymentMethodIssueDTO struct {
CountryCode string `json:"countryCode"`
CurrencyCode string `json:"currencyCode"`
Code string `json:"code"`
Message string `json:"message"`
}
type temporaryPaymentLinkDTO struct {
OrderID string `json:"orderId"`
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
OwnerUserID uint `json:"ownerUserId,omitempty"`
OwnerUsername string `json:"ownerUsername,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
CommandID string `json:"commandId,omitempty"`
AudienceType string `json:"audienceType"`
ProductName string `json:"productName"`
@ -272,6 +294,31 @@ func thirdPartyPaymentMethodFromProto(item *walletv1.ThirdPartyPaymentMethod) th
}
}
func thirdPartyPaymentMethodSyncFromProto(item *walletv1.SyncThirdPartyPaymentMethodsResponse) thirdPartyPaymentMethodSyncDTO {
if item == nil {
return thirdPartyPaymentMethodSyncDTO{}
}
dto := thirdPartyPaymentMethodSyncDTO{
ProviderCode: item.GetProviderCode(),
ScannedCountryCount: item.GetScannedCountryCount(),
FetchedCountryCount: item.GetFetchedCountryCount(),
CreatedCount: item.GetCreatedCount(),
UpdatedCount: item.GetUpdatedCount(),
SkippedCount: item.GetSkippedCount(),
FailedCountryCount: len(item.GetIssues()),
Issues: make([]thirdPartyPaymentMethodIssueDTO, 0, len(item.GetIssues())),
}
for _, issue := range item.GetIssues() {
dto.Issues = append(dto.Issues, thirdPartyPaymentMethodIssueDTO{
CountryCode: issue.GetCountryCode(),
CurrencyCode: issue.GetCurrencyCode(),
Code: issue.GetCode(),
Message: issue.GetMessage(),
})
}
return dto
}
func temporaryPaymentLinkFromProto(item *walletv1.ExternalRechargeOrder) temporaryPaymentLinkDTO {
if item == nil {
return temporaryPaymentLinkDTO{}

View File

@ -13,6 +13,7 @@ import (
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -26,13 +27,15 @@ const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
userDB *sql.DB
walletDB *sql.DB
store *repository.Store
audit shared.OperationLogger
exchangeRates exchangeRateClient
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
@ -290,9 +293,18 @@ func (h *Handler) ListThirdPartyPaymentChannels(c *gin.Context) {
func (h *Handler) ListTemporaryPaymentLinks(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
resp, err := h.wallet.ListTemporaryRechargeOrders(c.Request.Context(), &walletv1.ListTemporaryRechargeOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AppCode: appCode,
Status: options.Status,
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Keyword: options.Keyword,
@ -307,6 +319,15 @@ func (h *Handler) ListTemporaryPaymentLinks(c *gin.Context) {
for _, item := range resp.GetOrders() {
items = append(items, temporaryPaymentLinkFromProto(item))
}
if h.store != nil {
items, err = h.enrichAndFilterTemporaryLinks(appCode, items, access, queryInt64(c, "region_id", "regionId"), queryUint(c, "operator_user_id", "operatorUserId"))
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: int64(len(items))})
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
@ -316,17 +337,39 @@ func (h *Handler) GetTemporaryPaymentLink(c *gin.Context) {
response.BadRequest(c, "支付链接订单号不能为空")
return
}
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
// 查询单条订单会进入 wallet-service 的支付状态刷新逻辑admin 页面只发起核验,不在浏览器里拼三方查询参数。
resp, err := h.wallet.GetTemporaryRechargeOrder(c.Request.Context(), &walletv1.GetTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AppCode: appCode,
OrderId: orderID,
})
if err != nil {
writeWalletError(c, err, "校验三方临时支付链接失败")
return
}
response.OK(c, temporaryPaymentLinkFromProto(resp.GetOrder()))
item := temporaryPaymentLinkFromProto(resp.GetOrder())
if h.store != nil {
items, err := h.enrichAndFilterTemporaryLinks(appCode, []temporaryPaymentLinkDTO{item}, access, 0, 0)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
if len(items) == 0 {
response.Forbidden(c, "没有该支付链接的财务范围")
return
}
item = items[0]
}
response.OK(c, item)
}
func (h *Handler) SetThirdPartyPaymentMethodStatus(c *gin.Context) {
@ -383,6 +426,30 @@ func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) SyncThirdPartyPaymentMethods(c *gin.Context) {
var request thirdPartyPaymentMethodSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式同步参数不正确")
return
}
}
providerCode := strings.TrimSpace(firstNonEmptyString(request.ProviderCode, firstQuery(c, "provider_code", "providerCode"), "v5pay"))
resp, err := h.wallet.SyncThirdPartyPaymentMethods(c.Request.Context(), &walletv1.SyncThirdPartyPaymentMethodsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: providerCode,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "同步支付方式失败")
return
}
dto := thirdPartyPaymentMethodSyncFromProto(resp)
shared.OperationLog(c, h.audit, "sync-third-party-payment-methods", "third_party_payment_methods", "success", dto.ProviderCode)
response.OK(c, dto)
}
func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
var request thirdPartyPaymentRateSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
@ -473,6 +540,10 @@ type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
type thirdPartyPaymentMethodSyncRequest struct {
ProviderCode string `json:"providerCode"`
}
type thirdPartyPaymentRateSyncRequest struct {
MarkupPercent float64 `json:"markupPercent"`
}

View File

@ -11,10 +11,13 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/repository"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
@ -25,7 +28,7 @@ func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
defer db.Close()
wallet := &mockPaymentWallet{rechargeBillsResp: &walletv1.ListRechargeBillsResponse{}}
router := newPaymentHandlerTestRouter(New(wallet, db, nil))
router := newPaymentHandlerTestRouter(New(wallet, db, nil, nil, nil))
userID := int64(327702592329093120)
sellerUserID := int64(328453424662192128)
expectUserIdentityLookup(sqlMock, "111", userID)
@ -93,7 +96,7 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?status=active", nil)
recorder := httptest.NewRecorder()
@ -147,7 +150,7 @@ func TestListTemporaryPaymentLinksForwardsFilters(t *testing.T) {
UpdatedAtMs: 1700000000001,
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links?status=redirected&provider_code=mifapay&keyword=tmp&page=2&page_size=30", nil)
recorder := httptest.NewRecorder()
@ -185,7 +188,7 @@ func TestGetTemporaryPaymentLinkRefreshesSingleOrder(t *testing.T) {
PayUrl: "https://pay.example/tmp_1001",
Status: "paid",
}}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links/tmp_1001", nil)
recorder := httptest.NewRecorder()
@ -208,9 +211,162 @@ func TestGetTemporaryPaymentLinkRefreshesSingleOrder(t *testing.T) {
}
}
func TestCreateTemporaryPaymentLinkRecordsOwner(t *testing.T) {
userDB, userSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
expectRegionCountry(userSQL, "lalu", 2, "SA", 1)
expectStoreRecordOwnerAndEnrich(storeSQL, "lalu", "tmp_created", 7, 2)
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810,"returnUrl":"https://h5.example/pay"}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateTemporary == nil ||
wallet.lastCreateTemporary.GetAppCode() != "lalu" ||
wallet.lastCreateTemporary.GetUsdMinorAmount() != 1299 ||
wallet.lastCreateTemporary.GetProviderCode() != "mifapay" ||
wallet.lastCreateTemporary.GetPaymentMethodId() != 810 ||
wallet.lastCreateTemporary.GetReturnUrl() != "https://h5.example/pay" ||
wallet.lastCreateTemporary.GetPayerAccount() != "tester" {
t.Fatalf("create temporary request mismatch: %+v", wallet.lastCreateTemporary)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 || response.Data["orderId"] != "tmp_created" || response.Data["regionId"].(float64) != 2 || response.Data["ownerUserId"].(float64) != 7 {
t.Fatalf("create payment link response mismatch: %+v", response)
}
if err := userSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestCreateTemporaryPaymentLinkRejectsOutOfScopeRegion(t *testing.T) {
userDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 3}})
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels != nil || wallet.lastCreateTemporary != nil {
t.Fatalf("out-of-scope request must fail before wallet calls: channels=%+v create=%+v", wallet.lastThirdPartyChannels, wallet.lastCreateTemporary)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestCreateTemporaryPaymentLinkRejectsCountryRegionMismatch(t *testing.T) {
userDB, userSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
expectRegionCountry(userSQL, "lalu", 2, "SA", 0)
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateTemporary != nil {
t.Fatalf("country mismatch must fail before create order: %+v", wallet.lastCreateTemporary)
}
if err := userSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestGetMoneyPerformanceAggregatesPaidTemporaryOwners(t *testing.T) {
walletDB, walletSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create wallet sql mock failed: %v", err)
}
defer walletDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
walletSQL.ExpectQuery(`(?s)SELECT app_code, order_id, usd_minor_amount.*FROM external_recharge_orders`).
WithArgs("lalu", "temporary").
WillReturnRows(sqlmock.NewRows([]string{"app_code", "order_id", "usd_minor_amount", "status", "country_code", "provider_code", "payment_method_id", "pay_url", "created_at_ms", "updated_at_ms"}).
AddRow("lalu", "tmp_paid", int64(1299), "paid", "SA", "mifapay", int64(810), "https://pay.example/tmp_paid", int64(1700000000000), int64(1700000000100)).
AddRow("lalu", "tmp_credited", int64(2000), "credited", "SA", "mifapay", int64(810), "https://pay.example/tmp_credited", int64(1700000000000), int64(1700000000200)))
expectStoreOwners(storeSQL, "lalu", []ownerFixture{{orderID: "tmp_paid", adminUserID: 7, regionID: 2}, {orderID: "tmp_credited", adminUserID: 7, regionID: 2}})
expectStoreUsers(storeSQL, 7, "tester", "Tester")
router := newPaymentHandlerTestRouter(New(&mockPaymentWallet{}, nil, walletDB, store, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/money/performance", nil)
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())
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
item := items[0].(map[string]any)
summary := response.Data["summary"].(map[string]any)
if item["paidLinkCount"].(float64) != 2 || item["paidUsdMinor"].(float64) != 3299 || item["operatorName"] != "Tester" || summary["paidUsdMinor"].(float64) != 3299 {
t.Fatalf("performance response mismatch: %+v", response)
}
if err := walletSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("wallet sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -230,7 +386,7 @@ func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -248,6 +404,49 @@ func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
}
}
func TestSyncThirdPartyPaymentMethodsDefaultsToV5Pay(t *testing.T) {
wallet := &mockPaymentWallet{syncMethodsResp: &walletv1.SyncThirdPartyPaymentMethodsResponse{
ProviderCode: "v5pay",
ScannedCountryCount: 11,
FetchedCountryCount: 8,
CreatedCount: 6,
UpdatedCount: 20,
SkippedCount: 1,
Issues: []*walletv1.ThirdPartyPaymentMethodSyncIssue{{
CountryCode: "SA",
CurrencyCode: "SAR",
Code: "1013",
Message: "app is invalid",
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-methods/sync", bytes.NewBufferString(`{}`))
request.Header.Set("Content-Type", "application/json")
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 wallet.lastSyncMethods == nil ||
wallet.lastSyncMethods.GetAppCode() != "lalu" ||
wallet.lastSyncMethods.GetProviderCode() != "v5pay" ||
wallet.lastSyncMethods.GetOperatorUserId() != 7 {
t.Fatalf("sync methods request mismatch: %+v", wallet.lastSyncMethods)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 ||
response.Data["providerCode"] != "v5pay" ||
response.Data["createdCount"].(float64) != 6 ||
response.Data["failedCountryCount"].(float64) != 1 {
t.Fatalf("sync methods response mismatch: %+v", response)
}
}
func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
@ -269,7 +468,7 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
},
}},
}}
handler := New(wallet, nil, nil)
handler := New(wallet, nil, nil, nil, nil)
handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{
Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000"},
SourceNames: []string{"open.er-api.com"},
@ -311,7 +510,7 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":-1}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -328,7 +527,7 @@ func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
@ -366,8 +565,11 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
router.GET("/admin/payment/recharge-bills", handler.ListRechargeBills)
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks)
router.POST("/admin/payment/temporary-links", handler.CreateTemporaryPaymentLink)
router.GET("/admin/payment/temporary-links/:order_id", handler.GetTemporaryPaymentLink)
router.GET("/admin/money/performance", handler.GetMoneyPerformance)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.POST("/admin/payment/third-party-methods/sync", handler.SyncThirdPartyPaymentMethods)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
router.POST("/admin/payment/third-party-rates/sync", handler.SyncThirdPartyPaymentRates)
router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct)
@ -398,6 +600,98 @@ func expectUserIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID in
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
type moneyScopeFixture struct {
appCode string
regionID int64
}
type ownerFixture struct {
orderID string
adminUserID uint
regionID int64
}
func newPaymentStoreSQLMock(t *testing.T) (*repository.Store, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create store sql mock failed: %v", err)
}
mock.MatchExpectationsInOrder(false)
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("create gorm db failed: %v", err)
}
return repository.New(gormDB), mock, func() {
_ = sqlDB.Close()
}
}
func expectStoreMoneyAccess(mock sqlmock.Sqlmock, userID uint, scopes []moneyScopeFixture) {
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "tester", "Tester", "active"))
mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?").
WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"}))
rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"})
for index, scope := range scopes {
rows.AddRow(index+1, userID, scope.appCode, scope.regionID, int64(1700000000000), int64(1700000000000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(rows)
}
func expectRegionCountry(mock sqlmock.Sqlmock, appCode string, regionID int64, countryCode string, count int) {
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*FROM region_countries.*WHERE app_code = \? AND region_id = \? AND country_code = \? AND status = 'active'`).
WithArgs(appCode, regionID, countryCode).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
}
func expectStoreRecordOwnerAndEnrich(mock sqlmock.Sqlmock, appCode string, orderID string, adminUserID uint, regionID int64) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `admin_temporary_payment_link_owners`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
expectStoreOwners(mock, appCode, []ownerFixture{{orderID: orderID, adminUserID: adminUserID, regionID: regionID}})
expectStoreUsers(mock, adminUserID, "tester", "Tester")
}
func expectStoreOwners(mock sqlmock.Sqlmock, appCode string, owners []ownerFixture) {
rows := sqlmock.NewRows([]string{"app_code", "order_id", "admin_user_id", "region_id", "created_at_ms", "updated_at_ms"})
for _, owner := range owners {
rows.AddRow(appCode, owner.orderID, owner.adminUserID, owner.regionID, int64(1700000000000), int64(1700000000000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_temporary_payment_link_owners` WHERE app_code = \\? AND order_id IN").
WillReturnRows(rows)
}
func expectStoreUsers(mock sqlmock.Sqlmock, userID uint, username string, name string) {
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE id IN").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, username, name, "active"))
}
func temporaryMethodChannels(appCode string, providerCode string, methodID int64, countryCode string) *walletv1.ListThirdPartyPaymentChannelsResponse {
return &walletv1.ListThirdPartyPaymentChannelsResponse{Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: appCode,
ProviderCode: providerCode,
ProviderName: providerCode,
Status: "active",
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: methodID,
AppCode: appCode,
ProviderCode: providerCode,
ProviderName: providerCode,
CountryCode: countryCode,
CountryName: countryCode,
CurrencyCode: countryCode,
PayWay: "Card",
PayType: "CARD",
MethodName: "Card",
Status: "active",
}},
}}}
}
type mockPaymentWallet struct {
walletclient.Client
@ -407,10 +701,14 @@ type mockPaymentWallet struct {
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastTemporaryOrders *walletv1.ListTemporaryRechargeOrdersRequest
temporaryOrdersResp *walletv1.ListTemporaryRechargeOrdersResponse
lastCreateTemporary *walletv1.CreateTemporaryRechargeOrderRequest
createTemporaryResp *walletv1.H5RechargeOrderResponse
lastTemporaryOrder *walletv1.GetTemporaryRechargeOrderRequest
temporaryOrderResp *walletv1.H5RechargeOrderResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest
lastSyncMethods *walletv1.SyncThirdPartyPaymentMethodsRequest
syncMethodsResp *walletv1.SyncThirdPartyPaymentMethodsResponse
updateRateRequests []*walletv1.UpdateThirdPartyPaymentRateRequest
lastCreateProduct *walletv1.CreateRechargeProductRequest
}
@ -439,6 +737,23 @@ func (m *mockPaymentWallet) ListTemporaryRechargeOrders(_ context.Context, req *
return &walletv1.ListTemporaryRechargeOrdersResponse{}, nil
}
func (m *mockPaymentWallet) CreateTemporaryRechargeOrder(_ context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
m.lastCreateTemporary = req
if m.createTemporaryResp != nil {
return m.createTemporaryResp, nil
}
return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{
OrderId: "tmp_created",
AppCode: req.GetAppCode(),
AudienceType: "temporary",
UsdMinorAmount: req.GetUsdMinorAmount(),
ProviderCode: req.GetProviderCode(),
PaymentMethodId: req.GetPaymentMethodId(),
PayUrl: "https://pay.example/tmp_created",
Status: "redirected",
}}, nil
}
func (m *mockPaymentWallet) GetTemporaryRechargeOrder(_ context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
m.lastTemporaryOrder = req
if m.temporaryOrderResp != nil {
@ -481,6 +796,14 @@ func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *
}}, nil
}
func (m *mockPaymentWallet) SyncThirdPartyPaymentMethods(_ context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) {
m.lastSyncMethods = req
if m.syncMethodsResp != nil {
return m.syncMethodsResp, nil
}
return &walletv1.SyncThirdPartyPaymentMethodsResponse{ProviderCode: req.GetProviderCode()}, nil
}
type fakeExchangeRateClient struct {
result exchangeRateResult
err error

View File

@ -0,0 +1,653 @@
package payment
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
const (
moneyPaymentLinkCreatePermission = "money:payment-link:create"
moneyViewPermission = "money:view"
temporaryRechargeAudience = "temporary"
)
type temporaryPaymentLinkCreateRequest struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
USDMinorAmount int64 `json:"usdMinorAmount"`
ProviderCode string `json:"providerCode"`
PaymentMethodID int64 `json:"paymentMethodId"`
ReturnURL string `json:"returnUrl"`
Language string `json:"language"`
}
type moneyScopeItemDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
}
type moneyAppDTO struct {
AppID int64 `json:"appId"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
PackageName string `json:"packageName"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type moneyRegionDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
RegionCode string `json:"regionCode"`
Name string `json:"name"`
Status string `json:"status"`
SortOrder int `json:"sortOrder"`
Countries []string `json:"countries"`
}
type moneyCountryDTO struct {
AppCode string `json:"appCode"`
CountryID int64 `json:"countryId"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
CountryDisplayName string `json:"countryDisplayName"`
Flag string `json:"flag"`
Enabled bool `json:"enabled"`
RegionID int64 `json:"regionId"`
SortOrder int `json:"sortOrder"`
}
type moneyPerformanceItemDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
OperatorUserID uint `json:"operatorUserId"`
OperatorName string `json:"operatorName"`
OperatorAccount string `json:"operatorAccount"`
PaidLinkCount int64 `json:"paidLinkCount"`
PaidUSDMinor int64 `json:"paidUsdMinor"`
LastPaidAtMS int64 `json:"lastPaidAtMs"`
}
type paidTemporaryOrder struct {
AppCode string
OrderID string
USDMinorAmount int64
Status string
CountryCode string
ProviderCode string
PaymentMethodID int64
PayURL string
CreatedAtMS int64
UpdatedAtMS int64
}
func (h *Handler) GetMoneyScope(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.userDB == nil {
response.ServerError(c, "user mysql is not configured")
return
}
apps, regions, countries, err := h.loadMoneyMasterData(c.Request.Context(), access)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return
}
response.OK(c, gin.H{
"all": access.All,
"scopes": moneyScopeDTOsFromModel(access.Scopes),
"apps": apps,
"regions": regions,
"countries": countries,
})
}
func (h *Handler) GetMoneyPerformance(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.walletDB == nil || h.store == nil {
response.OK(c, gin.H{"items": []moneyPerformanceItemDTO{}, "summary": moneyPerformanceSummary(nil)})
return
}
appCodes, err := h.performanceAppCodes(c.Request.Context(), access, firstQuery(c, "app_code", "appCode"))
if err != nil {
response.ServerError(c, "获取绩效 App 失败")
return
}
regionID := queryInt64(c, "region_id", "regionId")
operatorUserID := queryUint(c, "operator_user_id", "operatorUserId")
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
aggregates := map[string]*moneyPerformanceItemDTO{}
userIDs := []uint{}
for _, appCode := range appCodes {
orders, err := h.listPaidTemporaryOrders(c.Request.Context(), appCode, startAtMS, endAtMS)
if err != nil {
response.ServerError(c, "获取支付绩效失败")
return
}
orderIDs := make([]string, 0, len(orders))
for _, order := range orders {
orderIDs = append(orderIDs, order.OrderID)
}
owners, err := h.store.TemporaryPaymentLinkOwners(appCode, orderIDs)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
for _, order := range orders {
owner, ok := owners[order.OrderID]
if !ok || !access.All && !access.Allows(owner.AppCode, owner.RegionID) {
continue
}
if regionID > 0 && owner.RegionID != regionID {
continue
}
if operatorUserID > 0 && owner.AdminUserID != operatorUserID {
continue
}
key := fmt.Sprintf("%s:%d:%d", owner.AppCode, owner.RegionID, owner.AdminUserID)
item := aggregates[key]
if item == nil {
item = &moneyPerformanceItemDTO{
AppCode: owner.AppCode,
RegionID: owner.RegionID,
OperatorUserID: owner.AdminUserID,
}
aggregates[key] = item
userIDs = append(userIDs, owner.AdminUserID)
}
item.PaidLinkCount++
item.PaidUSDMinor += order.USDMinorAmount
if order.UpdatedAtMS > item.LastPaidAtMS {
item.LastPaidAtMS = order.UpdatedAtMS
}
}
}
users, err := h.store.FindUsersByIDs(userIDs)
if err != nil {
response.ServerError(c, "获取运营用户失败")
return
}
items := make([]moneyPerformanceItemDTO, 0, len(aggregates))
for _, item := range aggregates {
if user, ok := users[item.OperatorUserID]; ok {
item.OperatorName = user.Name
item.OperatorAccount = user.Username
}
items = append(items, *item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].PaidUSDMinor == items[j].PaidUSDMinor {
return items[i].LastPaidAtMS > items[j].LastPaidAtMS
}
return items[i].PaidUSDMinor > items[j].PaidUSDMinor
})
response.OK(c, gin.H{"items": items, "summary": moneyPerformanceSummary(items)})
}
func (h *Handler) CreateTemporaryPaymentLink(c *gin.Context) {
if h.store == nil {
response.ServerError(c, "admin store is not configured")
return
}
var request temporaryPaymentLinkCreateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付链接参数不正确")
return
}
appCode := appctx.Normalize(request.AppCode)
if appCode == "" || request.RegionID <= 0 || request.USDMinorAmount <= 0 || request.PaymentMethodID <= 0 {
response.BadRequest(c, "支付链接参数不完整")
return
}
access, ok := h.moneyAccess(c)
if !ok {
return
}
if !access.Allows(appCode, request.RegionID) {
response.Forbidden(c, "没有该 App 区域的财务范围")
return
}
method, err := h.findTemporaryPaymentMethod(c.Request.Context(), appCode, request.ProviderCode, request.PaymentMethodID)
if err != nil {
if errors.Is(err, errTemporaryPaymentMethodInvalid) {
response.BadRequest(c, "支付方式不正确")
return
}
writeWalletError(c, err, "获取支付方式失败")
return
}
if ok, err := h.countryBelongsToRegion(c.Request.Context(), appCode, request.RegionID, method.GetCountryCode()); err != nil {
response.ServerError(c, "校验区域国家失败")
return
} else if !ok {
response.BadRequest(c, "支付方式国家不属于所选区域")
return
}
language := strings.TrimSpace(request.Language)
if language == "" {
language = "en"
}
resp, err := h.wallet.CreateTemporaryRechargeOrder(c.Request.Context(), &walletv1.CreateTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
CommandId: temporaryPaymentCommandID(shared.ActorFromContext(c).UserID, middleware.CurrentRequestID(c)),
CoinAmount: 0,
UsdMinorAmount: request.USDMinorAmount,
ProviderCode: method.GetProviderCode(),
PaymentMethodId: method.GetMethodId(),
ReturnUrl: strings.TrimSpace(request.ReturnURL),
ClientIp: c.ClientIP(),
Language: language,
PayerName: shared.ActorFromContext(c).Username,
PayerAccount: shared.ActorFromContext(c).Username,
})
if err != nil {
writeWalletError(c, err, "创建支付链接失败")
return
}
order := resp.GetOrder()
if order == nil || strings.TrimSpace(order.GetOrderId()) == "" {
response.ServerError(c, "创建支付链接失败")
return
}
if err := h.store.RecordTemporaryPaymentLinkOwner(model.TemporaryPaymentLinkOwner{
AppCode: appCode,
OrderID: order.GetOrderId(),
AdminUserID: shared.ActorFromContext(c).UserID,
RegionID: request.RegionID,
}); err != nil {
response.ServerError(c, "记录支付链接归属失败")
return
}
item := temporaryPaymentLinkFromProto(order)
items, err := h.enrichAndFilterTemporaryLinks(appCode, []temporaryPaymentLinkDTO{item}, access, 0, 0)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
if len(items) > 0 {
item = items[0]
}
shared.OperationLogWithResourceID(c, h.audit, "create-temporary-payment-link", "external_recharge_orders", order.GetOrderId(), "success", fmt.Sprintf("%s:%d", appCode, request.RegionID))
response.Created(c, item)
}
func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) {
if h.store == nil {
return repository.MoneyAccess{All: true}, true
}
access, err := h.store.MoneyAccessForUser(shared.ActorFromContext(c).UserID)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return repository.MoneyAccess{}, false
}
return access, true
}
func moneyAccessAllowsApp(access repository.MoneyAccess, appCode string) bool {
if access.All {
return true
}
appCode = appctx.Normalize(appCode)
for _, scope := range access.Scopes {
if appctx.Normalize(scope.AppCode) == appCode {
return true
}
}
return false
}
func (h *Handler) enrichAndFilterTemporaryLinks(appCode string, items []temporaryPaymentLinkDTO, access repository.MoneyAccess, regionID int64, operatorUserID uint) ([]temporaryPaymentLinkDTO, error) {
orderIDs := make([]string, 0, len(items))
for _, item := range items {
orderIDs = append(orderIDs, item.OrderID)
}
owners, err := h.store.TemporaryPaymentLinkOwners(appCode, orderIDs)
if err != nil {
return nil, err
}
userIDs := []uint{}
for _, owner := range owners {
userIDs = append(userIDs, owner.AdminUserID)
}
users, err := h.store.FindUsersByIDs(userIDs)
if err != nil {
return nil, err
}
out := make([]temporaryPaymentLinkDTO, 0, len(items))
for _, item := range items {
owner, ok := owners[item.OrderID]
if !ok {
if access.All && regionID == 0 && operatorUserID == 0 {
out = append(out, item)
}
continue
}
if !access.All && !access.Allows(owner.AppCode, owner.RegionID) {
continue
}
if regionID > 0 && owner.RegionID != regionID {
continue
}
if operatorUserID > 0 && owner.AdminUserID != operatorUserID {
continue
}
item.RegionID = owner.RegionID
item.OwnerUserID = owner.AdminUserID
if user, ok := users[owner.AdminUserID]; ok {
item.OwnerUsername = user.Username
item.OwnerName = user.Name
}
out = append(out, item)
}
return out, nil
}
var errTemporaryPaymentMethodInvalid = errors.New("temporary payment method invalid")
func (h *Handler) findTemporaryPaymentMethod(ctx context.Context, appCode string, providerCode string, paymentMethodID int64) (*walletv1.ThirdPartyPaymentMethod, error) {
resp, err := h.wallet.ListThirdPartyPaymentChannels(ctx, &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: strconv.FormatInt(time.Now().UnixNano(), 10),
AppCode: appCode,
ProviderCode: strings.TrimSpace(providerCode),
Status: "active",
IncludeDisabledMethods: true,
})
if err != nil {
return nil, err
}
for _, channel := range resp.GetChannels() {
for _, method := range channel.GetMethods() {
if method.GetMethodId() != paymentMethodID {
continue
}
if strings.TrimSpace(providerCode) != "" && method.GetProviderCode() != strings.TrimSpace(providerCode) {
return nil, errTemporaryPaymentMethodInvalid
}
if method.GetStatus() != "active" || strings.TrimSpace(method.GetCountryCode()) == "" {
return nil, errTemporaryPaymentMethodInvalid
}
return method, nil
}
}
return nil, errTemporaryPaymentMethodInvalid
}
func (h *Handler) countryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, error) {
if h.userDB == nil {
return false, errors.New("user mysql is not configured")
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" || countryCode == "GLOBAL" {
return false, nil
}
var count int
err := h.userDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM region_countries
WHERE app_code = ? AND region_id = ? AND country_code = ? AND status = 'active'
`, appCode, regionID, countryCode).Scan(&count)
return count > 0, err
}
func (h *Handler) loadMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
apps, err := h.listMoneyApps(ctx, access)
if err != nil {
return nil, nil, nil, err
}
appCodes := make([]string, 0, len(apps))
for _, app := range apps {
appCodes = append(appCodes, app.AppCode)
}
regions, err := h.listMoneyRegions(ctx, access, appCodes)
if err != nil {
return nil, nil, nil, err
}
countries, err := h.listMoneyCountries(ctx, access, appCodes)
if err != nil {
return nil, nil, nil, err
}
return apps, regions, countries, nil
}
func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) {
where, args := scopedAppWhere(access)
rows, err := h.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status
FROM apps
WHERE status = 'active'`+where+`
ORDER BY app_name ASC, app_code ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyAppDTO{}
for rows.Next() {
var item moneyAppDTO
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) listMoneyRegions(ctx context.Context, access repository.MoneyAccess, appCodes []string) ([]moneyRegionDTO, error) {
if len(appCodes) == 0 {
return []moneyRegionDTO{}, nil
}
where, args := inClause("r.app_code", appCodes)
rows, err := h.userDB.QueryContext(ctx, `
SELECT r.app_code, r.region_id, r.region_code, r.name, r.status, r.sort_order,
COALESCE(GROUP_CONCAT(rc.country_code ORDER BY rc.country_code SEPARATOR ','), '')
FROM regions r
LEFT JOIN region_countries rc
ON rc.app_code = r.app_code AND rc.region_id = r.region_id AND rc.status = 'active'
WHERE r.status = 'active' AND `+where+`
GROUP BY r.app_code, r.region_id, r.region_code, r.name, r.status, r.sort_order
ORDER BY r.app_code ASC, r.sort_order ASC, r.name ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyRegionDTO{}
for rows.Next() {
var item moneyRegionDTO
var countries string
if err := rows.Scan(&item.AppCode, &item.RegionID, &item.RegionCode, &item.Name, &item.Status, &item.SortOrder, &countries); err != nil {
return nil, err
}
if !access.All && !access.Allows(item.AppCode, item.RegionID) {
continue
}
item.Countries = splitCodes(countries)
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) listMoneyCountries(ctx context.Context, access repository.MoneyAccess, appCodes []string) ([]moneyCountryDTO, error) {
if len(appCodes) == 0 {
return []moneyCountryDTO{}, nil
}
where, args := inClause("c.app_code", appCodes)
rows, err := h.userDB.QueryContext(ctx, `
SELECT c.app_code, c.country_id, c.country_code, c.country_name, c.country_display_name, c.flag,
c.enabled, COALESCE(rc.region_id, 0), c.sort_order
FROM countries c
LEFT JOIN region_countries rc
ON rc.app_code = c.app_code AND rc.country_code = c.country_code AND rc.status = 'active'
WHERE c.enabled = TRUE AND `+where+`
ORDER BY c.app_code ASC, c.sort_order ASC, c.country_name ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyCountryDTO{}
for rows.Next() {
var item moneyCountryDTO
if err := rows.Scan(&item.AppCode, &item.CountryID, &item.CountryCode, &item.CountryName, &item.CountryDisplayName, &item.Flag, &item.Enabled, &item.RegionID, &item.SortOrder); err != nil {
return nil, err
}
if !access.All && !access.Allows(item.AppCode, item.RegionID) {
continue
}
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) performanceAppCodes(ctx context.Context, access repository.MoneyAccess, queryAppCode string) ([]string, error) {
if appCode := appctx.Normalize(queryAppCode); strings.TrimSpace(queryAppCode) != "" {
if !moneyAccessAllowsApp(access, appCode) {
return nil, nil
}
return []string{appCode}, nil
}
if !access.All {
return access.AppCodes(), nil
}
rows, err := h.walletDB.QueryContext(ctx, `
SELECT DISTINCT app_code
FROM external_recharge_orders
WHERE audience_type = ?
ORDER BY app_code ASC`, temporaryRechargeAudience)
if err != nil {
return nil, err
}
defer rows.Close()
appCodes := []string{}
for rows.Next() {
var appCode string
if err := rows.Scan(&appCode); err != nil {
return nil, err
}
appCodes = append(appCodes, appctx.Normalize(appCode))
}
return appCodes, rows.Err()
}
func (h *Handler) listPaidTemporaryOrders(ctx context.Context, appCode string, startAtMS int64, endAtMS int64) ([]paidTemporaryOrder, error) {
where := `WHERE app_code = ? AND audience_type = ? AND status IN ('paid', 'credited')`
args := []any{appCode, temporaryRechargeAudience}
if startAtMS > 0 {
where += ` AND updated_at_ms >= ?`
args = append(args, startAtMS)
}
if endAtMS > 0 {
where += ` AND updated_at_ms <= ?`
args = append(args, endAtMS)
}
rows, err := h.walletDB.QueryContext(ctx, `
SELECT app_code, order_id, usd_minor_amount, status, country_code, provider_code, payment_method_id, pay_url, created_at_ms, updated_at_ms
FROM external_recharge_orders
`+where+`
ORDER BY updated_at_ms DESC, order_id DESC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
orders := []paidTemporaryOrder{}
for rows.Next() {
var order paidTemporaryOrder
if err := rows.Scan(&order.AppCode, &order.OrderID, &order.USDMinorAmount, &order.Status, &order.CountryCode, &order.ProviderCode, &order.PaymentMethodID, &order.PayURL, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
return nil, err
}
orders = append(orders, order)
}
return orders, rows.Err()
}
func moneyPerformanceSummary(items []moneyPerformanceItemDTO) gin.H {
var paidLinks int64
var paidUSDMinor int64
for _, item := range items {
paidLinks += item.PaidLinkCount
paidUSDMinor += item.PaidUSDMinor
}
return gin.H{
"paidLinkCount": paidLinks,
"paidUsdMinor": paidUSDMinor,
"operatorCount": len(items),
}
}
func moneyScopeDTOsFromModel(scopes []model.UserMoneyScope) []moneyScopeItemDTO {
out := make([]moneyScopeItemDTO, 0, len(scopes))
for _, scope := range scopes {
out = append(out, moneyScopeItemDTO{AppCode: scope.AppCode, RegionID: scope.RegionID})
}
return out
}
func scopedAppWhere(access repository.MoneyAccess) (string, []any) {
if access.All {
return "", nil
}
where, args := inClause("app_code", access.AppCodes())
return " AND " + where, args
}
func inClause(column string, values []string) (string, []any) {
if len(values) == 0 {
return "1 = 0", nil
}
placeholders := make([]string, 0, len(values))
args := make([]any, 0, len(values))
for _, value := range values {
placeholders = append(placeholders, "?")
args = append(args, appctx.Normalize(value))
}
return column + " IN (" + strings.Join(placeholders, ",") + ")", args
}
func splitCodes(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func queryUint(c *gin.Context, keys ...string) uint {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0
}
return uint(parsed)
}
func temporaryPaymentCommandID(userID uint, requestID string) string {
return fmt.Sprintf("admin-temporary:%d:%s", userID, strings.TrimSpace(requestID))
}

View File

@ -12,10 +12,14 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.GET("/admin/payment/third-party-channels", middleware.RequireAnyPermission("payment-third-party:view", moneyViewPermission), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/money/scope", middleware.RequirePermission(moneyViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/performance", middleware.RequirePermission(moneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(moneyPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)

View File

@ -16,6 +16,7 @@ type resourceDTO struct {
WalletAssetAmount int64 `json:"walletAssetAmount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind,omitempty"`
BadgeForm string `json:"badgeForm,omitempty"`
BadgeKind string `json:"badgeKind,omitempty"`
LevelTrack string `json:"levelTrack,omitempty"`
@ -221,9 +222,10 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
WalletAssetAmount: item.GetWalletAssetAmount(),
PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(),
AvatarFrameKind: avatarFrameKindForResource(item.GetResourceType(), item.GetMetadataJson()),
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
BadgeKind: badgeKindForResource(item.GetResourceType(), item.GetMetadataJson()),
LevelTrack: badgeLevelTrackForResource(item.GetResourceType(), item.GetMetadataJson()),
LevelTrack: levelTrackForResource(item.GetResourceType(), item.GetMetadataJson()),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),

View File

@ -96,6 +96,10 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceAvatarFrameKind(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return
@ -208,6 +212,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceAvatarFrameKind(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return

View File

@ -94,7 +94,7 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
}
func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "")
metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "", "")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
@ -102,13 +102,13 @@ func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
if payload.BadgeForm != badgeFormStrip || payload.BadgeKind != badgeKindNormal || payload.DefaultSlot != "profile_strip" {
t.Fatalf("long badge metadata mismatch: %+v", payload)
}
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short", "", "", "")); got != badgeFormTile {
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short", "", "", "", "")); got != badgeFormTile {
t.Fatalf("short badge form mismatch: %s", got)
}
}
func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth")
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth", "")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
@ -118,6 +118,20 @@ func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
}
}
func TestAvatarFrameResourceMetadataMarksLevelFrame(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeAvatarFrame, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "game", avatarFrameKindLevel)
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("avatar frame metadata json mismatch: %v", err)
}
if payload.AvatarFrameKind != avatarFrameKindLevel || payload.LevelTrack != "game" {
t.Fatalf("level avatar frame metadata mismatch: %+v", payload)
}
if payload.AnimationFormat != resourceAnimationFormatMP4 || payload.MP4AlphaLayout == nil {
t.Fatalf("level avatar frame should keep animation metadata: %+v", payload)
}
}
func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
@ -132,7 +146,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
"detect_version": 99
},
"debug": true
}`, "", "")
}`, "", "", "")
payload := profileCardMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card metadata json mismatch: %v", err)
@ -153,7 +167,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) {
for _, resourceType := range []string{"gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", "mic_seat_animation"} {
metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "")
metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err)
@ -179,7 +193,7 @@ func TestResourceMetadataKeepsNormalMP4Layout(t *testing.T) {
"confirmed": true,
"detect_version": 1
}
}`, "", "")
}`, "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("normal mp4 metadata json mismatch: %v", err)
@ -214,7 +228,7 @@ func TestProfileCardResourceMetadataKeepsLayoutAndMP4AlphaLayout(t *testing.T) {
"confirmed": true,
"detect_version": 1
}
}`, "", "")
}`, "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card mp4 metadata json mismatch: %v", err)
@ -285,7 +299,7 @@ func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) {
"content_bottom": 1666,
"content_height": 1
}
}`, "", "")
}`, "", "", "")
if metadata != "{}" {
t.Fatalf("invalid profile card layout should be dropped: %s", metadata)
}
@ -309,6 +323,21 @@ func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
}
}
func TestValidateAvatarFrameResourceRequiresLevelTrack(t *testing.T) {
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame}); err != nil {
t.Fatalf("normal avatar frame should be default: %v", err)
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame, AvatarFrameKind: avatarFrameKindLevel}); err == nil {
t.Fatalf("level avatar frame should require level track")
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame, AvatarFrameKind: avatarFrameKindLevel, LevelTrack: "charm"}); err != nil {
t.Fatalf("valid level avatar frame rejected: %v", err)
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: "gift", AvatarFrameKind: "bad"}); err != nil {
t.Fatalf("non-avatar-frame resource should not require avatar frame kind: %v", err)
}
}
func mp4AlphaLayoutMetadataJSON(alphaLayout string, confirmed bool) string {
return `{
"animation_format": "mp4",

View File

@ -19,6 +19,7 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
const (
resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card"
@ -53,6 +54,11 @@ const (
badgeKindLevel = "level"
)
const (
avatarFrameKindNormal = "normal"
avatarFrameKindLevel = "level"
)
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
@ -67,6 +73,7 @@ type resourceRequest struct {
Amount int64 `json:"amount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind"`
BadgeForm string `json:"badgeForm"`
BadgeKind string `json:"badgeKind"`
LevelTrack string `json:"levelTrack"`
@ -113,6 +120,8 @@ type badgeMetadataPayload struct {
type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"`
AvatarFrameKind string `json:"avatar_frame_kind,omitempty"`
LevelTrack string `json:"level_track,omitempty"`
MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"`
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
}
@ -235,7 +244,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -304,7 +313,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -599,6 +608,20 @@ func validateResourceBadgeForm(req resourceRequest) error {
return nil
}
func validateResourceAvatarFrameKind(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeAvatarFrame {
return nil
}
avatarFrameKind := normalizeAvatarFrameKind(req.AvatarFrameKind)
if avatarFrameKind == "" {
return fmt.Errorf("请选择头像框类型")
}
if avatarFrameKind == avatarFrameKindLevel && !validLevelTrack(req.LevelTrack) {
return fmt.Errorf("请选择等级头像框所属等级")
}
return nil
}
func validateResourceMetadata(req resourceRequest) error {
resourceType := normalizeResourceType(req.ResourceType)
payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON)
@ -636,7 +659,7 @@ func resourceGrantStrategy(resourceType string) string {
switch resourceType {
case resourceTypeCoin:
return "wallet_credit"
case "avatar_frame", "profile_card", "vehicle", "chat_bubble":
case resourceTypeAvatarFrame, "profile_card", "vehicle", "chat_bubble":
return "extend_expiry"
case "badge":
return "set_active_flag"
@ -645,19 +668,30 @@ func resourceGrantStrategy(resourceType string) string {
}
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string) string {
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string {
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
}
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil || rawPayload == nil {
if err != nil {
return "{}"
}
if rawPayload == nil {
rawPayload = &resourceMetadataPayload{}
}
payload := resourceMetadataPayload{}
if resourceType == resourceTypeProfileCard {
payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout)
}
if resourceType == resourceTypeAvatarFrame {
kind := normalizeAvatarFrameKind(avatarFrameKind)
payload.AvatarFrameKind = kind
if kind == avatarFrameKindLevel {
// 等级头像框会被等级奖励任务按轨道过滤并发放;普通头像框不写 level_track避免商店、经理赠送等入口误判。
payload.LevelTrack = normalizeLevelTrack(levelTrack)
}
}
if resourceAllowsAnimationMetadata(resourceType) {
if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4
@ -728,7 +762,7 @@ func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload
}
func marshalResourceMetadataPayload(payload resourceMetadataPayload) string {
if payload.AnimationFormat == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil {
if payload.AnimationFormat == "" && payload.AvatarFrameKind == "" && payload.LevelTrack == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil {
return "{}"
}
body, err := json.Marshal(payload)
@ -894,6 +928,17 @@ func normalizeBadgeKind(value string) string {
}
}
func normalizeAvatarFrameKind(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", avatarFrameKindNormal:
return avatarFrameKindNormal
case avatarFrameKindLevel:
return avatarFrameKindLevel
default:
return ""
}
}
func normalizeLevelTrack(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if validLevelTrack(value) {
@ -935,6 +980,26 @@ func badgeLevelTrackFromMetadata(metadataJSON string) string {
return normalizeLevelTrack(payload.LevelTrack)
}
func avatarFrameKindFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return avatarFrameKindNormal
}
kind := normalizeAvatarFrameKind(payload.AvatarFrameKind)
if kind == "" {
return avatarFrameKindNormal
}
return kind
}
func avatarFrameLevelTrackFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeLevelTrack(payload.LevelTrack)
}
func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
@ -960,6 +1025,31 @@ func badgeLevelTrackForResource(resourceType string, metadataJSON string) string
return badgeLevelTrackFromMetadata(metadataJSON)
}
func avatarFrameKindForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameKindFromMetadata(metadataJSON)
}
func avatarFrameLevelTrackForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameLevelTrackFromMetadata(metadataJSON)
}
func levelTrackForResource(resourceType string, metadataJSON string) string {
switch normalizeResourceType(resourceType) {
case resourceTypeBadge:
return badgeLevelTrackFromMetadata(metadataJSON)
case resourceTypeAvatarFrame:
return avatarFrameLevelTrackFromMetadata(metadataJSON)
default:
return ""
}
}
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{

View File

@ -248,13 +248,19 @@ func (h *Handler) setRobotRoomStatus(c *gin.Context, status string, action strin
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
query := listQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
Status: options.Status,
SortBy: firstQueryValue(c, "sortBy", "sort_by"),
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"),
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
OwnerUserKeyword: firstQueryValue(c, "ownerUserKeyword", "owner_user_keyword"),
Status: options.Status,
SortBy: firstQueryValue(c, "sortBy", "sort_by"),
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"),
}
ownerUserID, ok := parseOptionalInt64Query(c, "ownerUserId", "owner_user_id")
if !ok {
return listQuery{}, false
}
query.OwnerUserID = ownerUserID
regionID, ok := parseOptionalInt64Query(c, "regionId", "region_id")
if !ok {
return listQuery{}, false

View File

@ -8,13 +8,15 @@ import (
)
type listQuery struct {
Page int
PageSize int
Keyword string
Status string
RegionID int64
SortBy string
SortDirection string
Page int
PageSize int
Keyword string
OwnerUserID int64
OwnerUserKeyword string
Status string
RegionID int64
SortBy string
SortDirection string
}
type roomPinListQuery struct {

View File

@ -73,42 +73,37 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return nil, 0, fmt.Errorf("room service client is not configured")
}
query = normalizeListQuery(query)
ownerUserID, ownerMatched, err := s.resolveListOwnerUserID(ctx, query)
if err != nil {
return nil, 0, err
}
if strings.TrimSpace(query.OwnerUserKeyword) != "" && !ownerMatched {
return []Room{}, 0, nil
}
roomKeyword := query.Keyword
if ownerUserID == 0 {
keywordOwnerUserID, keywordOwnerMatched, err := s.resolveOwnerNumberUserID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if keywordOwnerMatched {
ownerUserID = keywordOwnerUserID
roomKeyword = ""
}
}
result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Keyword: query.Keyword,
Keyword: roomKeyword,
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
if result.Total == 0 && strings.TrimSpace(query.Keyword) != "" && s.userDB != nil {
ownerUserID, ok, err := s.queryOwnerUserIDByDisplayID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if ok {
// 新增置顶弹窗按用户短 ID 找房间;精确解析到 user_id 后交给 room-service owner 过滤,避免字符串 LIKE 误命中。
fallback, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
if fallback.Total > 0 {
result = fallback
}
}
}
items := roomsFromClient(result.Rooms)
if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err
@ -116,24 +111,50 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return items, result.Total, nil
}
func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID string) (int64, bool, error) {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" || s.userDB == nil {
func (s *Service) resolveListOwnerUserID(ctx context.Context, query listQuery) (int64, bool, error) {
if query.OwnerUserID > 0 {
return query.OwnerUserID, true, nil
}
return s.resolveOwnerUserID(ctx, query.OwnerUserKeyword)
}
func (s *Service) resolveOwnerUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return 0, false, nil
}
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli())
args := append([]any{appctx.FromContext(ctx)}, matchArgs...)
var userID int64
err := s.userDB.QueryRowContext(ctx, `
if s.userDB == nil {
return shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
// 房主筛选必须先把短号、当前展示号、active 靓号等运营可见号码解析成稳定 user_id再交给 room-service 按 owner_user_id 精确过滤。
return shared.ResolveExactUserID(ctx, s.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
func (s *Service) resolveOwnerNumberUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" || s.userDB == nil {
return 0, false, nil
}
nowMS := time.Now().UnixMilli()
displaySQL, displayArgs := shared.UserDisplayIDSQL("u", keyword, nowMS)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMS)
args := append([]any{appctx.FromContext(ctx), keyword}, displayArgs...)
args = append(args, orderArgs...)
row := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
LIMIT 1
`, args...).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
WHERE u.app_code = ? AND (CAST(u.user_id AS CHAR) = ? OR `+displaySQL+`)
ORDER BY
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
var userID int64
if err := row.Scan(&userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
return 0, false, err
}
return userID, true, nil

View File

@ -2,6 +2,7 @@ package roomadmin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
@ -9,9 +10,12 @@ import (
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
)
func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
@ -52,6 +56,107 @@ func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
}
}
func TestListRoomsResolvesOwnerUserKeywordBeforeRoomClient(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP2026"
const ownerUserID = int64(327702592329093120)
expectRoomOwnerIdentityLookup(sqlMock, keyword, ownerUserID)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
_, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
OwnerUserKeyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(roomClient.requests) != 1 {
t.Fatalf("expected one room-service request, got %+v", roomClient.requests)
}
if got := roomClient.requests[0].OwnerUserID; got != ownerUserID {
t.Fatalf("owner user id should be resolved before room-service call: got %d want %d", got, ownerUserID)
}
if roomClient.requests[0].Keyword != "" {
t.Fatalf("owner keyword must not be forwarded as room keyword: %+v", roomClient.requests[0])
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestListRoomsResolvesRoomKeywordOwnerPrettyIDBeforeRoomClient(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP2026"
const ownerUserID = int64(327702592329093120)
expectRoomOwnerNumberLookup(sqlMock, keyword, ownerUserID)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
_, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
Keyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(roomClient.requests) != 1 {
t.Fatalf("expected one room-service request, got %+v", roomClient.requests)
}
if got := roomClient.requests[0].OwnerUserID; got != ownerUserID {
t.Fatalf("room keyword should resolve owner user id before room-service call: got %d want %d", got, ownerUserID)
}
if roomClient.requests[0].Keyword != "" {
t.Fatalf("owner pretty id must not be forwarded as room keyword: %+v", roomClient.requests[0])
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestListRoomsReturnsEmptyWhenOwnerUserKeywordHasNoMatch(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP404"
expectRoomOwnerIdentityMiss(sqlMock, keyword)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
items, total, err := service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
OwnerUserKeyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(items) != 0 || total != 0 {
t.Fatalf("unmatched owner keyword should return empty page, got items=%+v total=%d", items, total)
}
if len(roomClient.requests) != 0 {
t.Fatalf("room-service must not be called when owner keyword is known unmatched: %+v", roomClient.requests)
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) {
name, avatar, err := robotRoomProfileFromOwner(RoomOwner{
Username: " Robot Host ",
@ -65,6 +170,70 @@ func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) {
}
}
func expectRoomOwnerIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) {
expectRoomOwnerIdentityQuery(sqlMock, keyword).
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
func expectRoomOwnerIdentityMiss(sqlMock sqlmock.Sqlmock, keyword string) {
expectRoomOwnerIdentityQuery(sqlMock, keyword).
WillReturnError(sql.ErrNoRows)
}
func expectRoomOwnerNumberLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) {
expectRoomOwnerNumberQuery(sqlMock, keyword).
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
func expectRoomOwnerIdentityQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery {
return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`).
WithArgs(
"lalu",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
"%"+keyword+"%",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
)
}
func expectRoomOwnerNumberQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery {
return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*CAST\(u\.user_id AS CHAR\) = \?.*LIMIT 1`).
WithArgs(
"lalu",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
)
}
type recordingRoomClient struct {
roomclient.Client
requests []roomclient.ListRoomsRequest
result *roomclient.ListRoomsResult
}
func (c *recordingRoomClient) ListRooms(_ context.Context, req roomclient.ListRoomsRequest) (*roomclient.ListRoomsResult, error) {
c.requests = append(c.requests, req)
if c.result != nil {
return c.result, nil
}
return &roomclient.ListRoomsResult{}, nil
}
func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) {
tests := []struct {
name string

View File

@ -0,0 +1,215 @@
package repository
import (
"errors"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MoneyAccess struct {
UserID uint
All bool
Scopes []model.UserMoneyScope
}
func (access MoneyAccess) Allows(appCode string, regionID int64) bool {
if access.All {
return true
}
appCode = appctx.Normalize(appCode)
for _, scope := range access.Scopes {
if appctx.Normalize(scope.AppCode) != appCode {
continue
}
if scope.RegionID == 0 || scope.RegionID == regionID {
return true
}
}
return false
}
func (access MoneyAccess) AppCodes() []string {
if access.All {
return nil
}
set := map[string]struct{}{}
for _, scope := range access.Scopes {
appCode := appctx.Normalize(scope.AppCode)
if appCode != "" {
set[appCode] = struct{}{}
}
}
out := make([]string, 0, len(set))
for appCode := range set {
out = append(out, appCode)
}
return out
}
func (s *Store) MoneyAccessForUser(userID uint) (MoneyAccess, error) {
if userID == 0 {
return MoneyAccess{All: true}, nil
}
var user model.User
if err := s.db.Preload("Roles").First(&user, userID).Error; err != nil {
return MoneyAccess{}, err
}
scopes, err := s.ListUserMoneyScopes(userID)
if err != nil {
return MoneyAccess{}, err
}
if len(scopes) == 0 && userHasRole(user, "platform-admin") {
return MoneyAccess{UserID: userID, All: true}, nil
}
return MoneyAccess{UserID: userID, Scopes: scopes}, nil
}
func (s *Store) ListUserMoneyScopes(userID uint) ([]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
err := s.db.Where("user_id = ?", userID).Order("app_code ASC, region_id ASC").Find(&scopes).Error
return scopes, err
}
func (s *Store) ReplaceUserMoneyScopes(userID uint, scopes []model.UserMoneyScope) error {
if userID == 0 {
return errors.New("user id is required")
}
normalized, err := normalizeMoneyScopes(userID, scopes)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, userID).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserMoneyScope{}).Error; err != nil {
return err
}
if len(normalized) == 0 {
return nil
}
return tx.Create(&normalized).Error
})
}
func (s *Store) RecordTemporaryPaymentLinkOwner(owner model.TemporaryPaymentLinkOwner) error {
owner.AppCode = appctx.Normalize(owner.AppCode)
owner.OrderID = strings.TrimSpace(owner.OrderID)
if owner.AppCode == "" || owner.OrderID == "" || owner.AdminUserID == 0 || owner.RegionID <= 0 {
return errors.New("temporary payment link owner is incomplete")
}
nowMS := time.Now().UTC().UnixMilli()
if owner.CreatedAtMS == 0 {
owner.CreatedAtMS = nowMS
}
owner.UpdatedAtMS = nowMS
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_code"}, {Name: "order_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"admin_user_id",
"region_id",
"updated_at_ms",
}),
}).Create(&owner).Error
}
func (s *Store) TemporaryPaymentLinkOwners(appCode string, orderIDs []string) (map[string]model.TemporaryPaymentLinkOwner, error) {
appCode = appctx.Normalize(appCode)
ids := uniqueStrings(orderIDs)
out := make(map[string]model.TemporaryPaymentLinkOwner, len(ids))
if appCode == "" || len(ids) == 0 {
return out, nil
}
var owners []model.TemporaryPaymentLinkOwner
if err := s.db.Where("app_code = ? AND order_id IN ?", appCode, ids).Find(&owners).Error; err != nil {
return nil, err
}
for _, owner := range owners {
out[owner.OrderID] = owner
}
return out, nil
}
func (s *Store) FindUsersByIDs(ids []uint) (map[uint]model.User, error) {
uniqueIDs := uniqueUints(ids)
out := make(map[uint]model.User, len(uniqueIDs))
if len(uniqueIDs) == 0 {
return out, nil
}
var users []model.User
if err := s.db.Where("id IN ?", uniqueIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
out[user.ID] = user
}
return out, nil
}
func normalizeMoneyScopes(userID uint, scopes []model.UserMoneyScope) ([]model.UserMoneyScope, error) {
seen := map[string]struct{}{}
out := make([]model.UserMoneyScope, 0, len(scopes))
for _, scope := range scopes {
appCode := appctx.Normalize(scope.AppCode)
if appCode == "" || scope.RegionID < 0 {
return nil, errors.New("money scope app code and region id are required")
}
key := appCode + ":" + strconv.FormatInt(scope.RegionID, 10)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, model.UserMoneyScope{UserID: userID, AppCode: appCode, RegionID: scope.RegionID})
}
return out, nil
}
func userHasRole(user model.User, code string) bool {
for _, role := range user.Roles {
if role.Code == code {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
out = append(out, value)
}
return out
}
func uniqueUints(values []uint) []uint {
set := map[uint]struct{}{}
out := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
out = append(out, value)
}
return out
}

View File

@ -0,0 +1,77 @@
package repository
import (
"testing"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestReplaceAndListUserMoneyScopes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(7, "ops", "Ops", "active"))
mock.ExpectExec("DELETE FROM `admin_user_money_scopes` WHERE user_id = \\?").
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec("INSERT INTO `admin_user_money_scopes`").
WillReturnResult(sqlmock.NewResult(1, 2))
mock.ExpectCommit()
if err := store.ReplaceUserMoneyScopes(7, []model.UserMoneyScope{
{AppCode: " LALU ", RegionID: 2},
{AppCode: "lalu", RegionID: 2},
{AppCode: "yumi", RegionID: 0},
}); err != nil {
t.Fatalf("replace money scopes failed: %v", err)
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "lalu", 2, int64(1700000000000), int64(1700000000000)).
AddRow(2, 7, "yumi", 0, int64(1700000000000), int64(1700000000000)))
scopes, err := store.ListUserMoneyScopes(7)
if err != nil {
t.Fatalf("list money scopes failed: %v", err)
}
if len(scopes) != 2 || scopes[0].AppCode != "lalu" || scopes[0].RegionID != 2 || scopes[1].AppCode != "yumi" || scopes[1].RegionID != 0 {
t.Fatalf("money scopes mismatch: %+v", scopes)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestReplaceUserMoneyScopesRejectsInvalidScope(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
if err := store.ReplaceUserMoneyScopes(7, []model.UserMoneyScope{{RegionID: 2}}); err == nil {
t.Fatal("expected invalid scope error")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("create gorm db failed: %v", err)
}
return New(gormDB), mock, func() {
_ = sqlDB.Close()
}
}

View File

@ -76,6 +76,8 @@ func (s *Store) AutoMigrate() error {
&model.LoginLog{},
&model.OperationLog{},
&model.DataScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.AdminJob{},
)
}

View File

@ -112,6 +112,8 @@ var defaultPermissions = []model.Permission{
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "财务系统查看", Code: "money:view", Kind: "menu"},
{Name: "财务支付链接创建", Code: "money:payment-link:create", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
@ -553,7 +555,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "money:view", "money:payment-link:create", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -659,7 +661,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "money:view", "money:payment-link:create", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",

View File

@ -0,0 +1,26 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 财务系统是独立子系统入口,不挂后台左侧菜单;这里只登记访问权限,供 /money 独立入口校验当前账号是否可进入。
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
('财务系统查看', 'money:view', 'menu', '允许访问独立财务子系统 /money', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code = 'money:view';
INSERT IGNORE INTO admin_user_roles (user_id, role_id)
SELECT admin_user.id, admin_role.id
FROM admin_users admin_user
JOIN admin_roles admin_role
WHERE admin_user.username IN ('admin', 'hyappadmin')
AND admin_role.code = 'platform-admin';

View File

@ -0,0 +1,44 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_user_money_scopes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID',
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID0 表示整个 app',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_user_money_scope (user_id, app_code, region_id),
KEY idx_admin_user_money_scopes_user (user_id),
KEY idx_admin_user_money_scopes_app_region (app_code, region_id),
CONSTRAINT fk_admin_user_money_scopes_user FOREIGN KEY (user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户财务系统 app/区域范围';
CREATE TABLE IF NOT EXISTS admin_temporary_payment_link_owners (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
order_id VARCHAR(96) NOT NULL COMMENT 'wallet external_recharge_orders.order_id',
admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '创建支付链接的后台用户 ID',
region_id BIGINT NOT NULL COMMENT '支付链接归属区域 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, order_id),
KEY idx_admin_temporary_payment_link_owners_user (admin_user_id, created_at_ms),
KEY idx_admin_temporary_payment_link_owners_region (app_code, region_id, created_at_ms),
CONSTRAINT fk_admin_temporary_payment_link_owners_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='临时支付链接后台归属';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('财务支付链接创建', 'money:payment-link:create', 'button', '允许在独立财务子系统创建临时支付链接', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('money:view', 'money:payment-link:create');

View File

@ -24,6 +24,12 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
user_leaderboard_worker:
enabled: true
redis_addr: "redis:6379"
redis_password: ""
redis_db: 0
key_prefix: "activity:user_leaderboard"
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
@ -82,6 +88,7 @@ rocketmq:
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16
user_outbox:
enabled: true

View File

@ -24,6 +24,12 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
user_leaderboard_worker:
enabled: true
redis_addr: "TENCENT_REDIS_HOST:6379"
redis_password: "TENCENT_REDIS_PASSWORD"
redis_db: 0
key_prefix: "activity:user_leaderboard"
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
@ -82,6 +88,7 @@ rocketmq:
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16
user_outbox:
enabled: true

View File

@ -24,6 +24,12 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
user_leaderboard_worker:
enabled: true
redis_addr: "127.0.0.1:13379"
redis_password: ""
redis_db: 0
key_prefix: "activity:user_leaderboard"
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
@ -82,6 +88,7 @@ rocketmq:
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16
user_outbox:
enabled: true

View File

@ -46,6 +46,7 @@ type App struct {
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
messageActionWorkerOptions config.MessageActionConfirmWorkerConfig
messageActionOutboxTopic string
userLeaderboardRedisClose func() error
mqConsumers []*rocketmqx.Consumer
messageActionProducer *rocketmqx.Producer
userConn *grpc.ClientConn
@ -68,9 +69,13 @@ func New(cfg config.Config) (*App, error) {
var clients externalClients
var mqConsumers []*rocketmqx.Consumer
var messageActionProducer *rocketmqx.Producer
var userLeaderboardRedisClose func() error
cleanup := func() {
shutdownConsumers(mqConsumers)
servicemq.ShutdownProducers([]*rocketmqx.Producer{messageActionProducer})
if userLeaderboardRedisClose != nil {
_ = userLeaderboardRedisClose()
}
clients.Close()
if listener != nil {
_ = listener.Close()
@ -90,7 +95,13 @@ func New(cfg config.Config) (*App, error) {
}
server := grpcserver.New("activity-service")
services, err := buildServiceBundle(cfg, repository, clients)
userLeaderboardStore, closeLeaderboardRedis, err := openUserLeaderboardStore(startupCtx, cfg.UserLeaderboardWorker)
if err != nil {
cleanup()
return nil, err
}
userLeaderboardRedisClose = closeLeaderboardRedis
services, err := buildServiceBundle(cfg, repository, clients, userLeaderboardStore)
if err != nil {
cleanup()
return nil, err
@ -134,6 +145,7 @@ func New(cfg config.Config) (*App, error) {
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
messageActionWorkerOptions: cfg.MessageActionConfirmWorker,
messageActionOutboxTopic: cfg.RocketMQ.MessageActionOutbox.Topic,
userLeaderboardRedisClose: userLeaderboardRedisClose,
mqConsumers: mqConsumers,
messageActionProducer: messageActionProducer,
userConn: clients.userConn,
@ -202,6 +214,9 @@ func (a *App) Close() {
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
_ = a.mysqlRepo.Close()
}
if a.userLeaderboardRedisClose != nil {
_ = a.userLeaderboardRedisClose()
}
})
}

View File

@ -96,6 +96,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.UserLeaderboardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := newUserLeaderboardWalletConsumer(cfg, services)
if err != nil {
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.TaskEventWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := newTaskWalletConsumer(cfg, services)
if err != nil {
@ -262,6 +270,30 @@ func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*ro
return consumer, nil
}
func newUserLeaderboardWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
if services == nil || services.userLeaderboard == nil {
return nil, errors.New("user leaderboard worker requires redis store")
}
// 用户送礼榜使用独立 consumer group重放榜单读模型不能推进首充、任务、红包等业务位点。
consumer, err := rocketmqx.NewConsumer(walletOutboxUserLeaderboardConsumerConfig(cfg.RocketMQ))
if err != nil {
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := userLeaderboardGiftEventFromWalletMessage(message.Body)
if err != nil || !ok {
return err
}
// Redis 聚合自身用 wallet event_id 做幂等MQ 重投只会重新确认同一事实,不会重复加分。
_, err = services.userLeaderboard.ApplyGiftEvent(appcode.WithContext(ctx, event.AppCode), event)
return err
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.TaskConsumerGroup))
if err != nil {
@ -333,6 +365,13 @@ func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketm
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
}
func walletOutboxUserLeaderboardConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
consumerConfig := walletOutboxConsumerConfig(cfg, cfg.WalletOutbox.UserLeaderboardConsumerGroup)
// 用户榜是 Redis 可重建读模型;新 consumer group 首次上线需要尽量从 broker 保留的最早消息补齐当前周期。
consumerConfig.ConsumeFromFirst = true
return consumerConfig
}
func messageActionProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{

View File

@ -6,6 +6,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/tencentim"
"hyapp/pkg/userleaderboard"
activityclient "hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config"
achievementservice "hyapp/services/activity-service/internal/service/achievement"
@ -47,10 +48,11 @@ type serviceBundle struct {
firstRechargeReward *firstrechargeservice.Service
cumulativeRecharge *cumulativerechargeservice.Service
inviteActivityReward *inviteactivityservice.Service
userLeaderboard *userleaderboard.Store
broadcastPublisherAvailable bool
}
func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository, clients externalClients) (*serviceBundle, error) {
func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository, clients externalClients, userLeaderboardStore *userleaderboard.Store) (*serviceBundle, error) {
walletClient := walletv1.NewWalletServiceClient(clients.walletConn)
roomClient := roomv1.NewRoomQueryServiceClient(clients.roomConn)
@ -65,7 +67,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
weeklyStarSvc := weeklystarservice.New(repository, walletClient)
cpWeeklyRankSvc := cpweeklyrankservice.New(repository, activityclient.NewGRPCCPWeeklyRankSource(clients.userConn), walletClient)
roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient)
roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient, roomturnoverrewardservice.WithNoticeService(messageSvc))
// Tencent IM 是区域广播、房间礼物播报和红包播报的外部发布器。
// 如果配置要求启动广播 worker就必须先确认 publisher 可用,避免 worker 启动后只写 outbox 不发消息。
@ -93,13 +95,21 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
broadcastSvc.SetSenderProfileSource(activityclient.NewGRPCUserProfileSource(clients.userConn))
userProfileSource := activityclient.NewGRPCUserProfileSource(clients.userConn)
broadcastSvc.SetSenderProfileSource(userProfileSource)
luckyGiftSvc := luckygiftservice.New(repository,
luckyGiftOptions := []luckygiftservice.Option{
luckygiftservice.WithWallet(walletClient),
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
)
luckygiftservice.WithSenderProfileSource(userProfileSource),
}
if tencentClient != nil {
luckyGiftOptions = append(luckyGiftOptions,
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithUserPublisher(tencentClient),
)
}
luckyGiftSvc := luckygiftservice.New(repository, luckyGiftOptions...)
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
@ -124,6 +134,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
firstRechargeReward: firstRechargeRewardSvc,
cumulativeRecharge: cumulativeRechargeSvc,
inviteActivityReward: inviteActivityRewardSvc,
userLeaderboard: userLeaderboardStore,
broadcastPublisherAvailable: broadcastPublisher != nil,
}, nil
}

View File

@ -0,0 +1,25 @@
package app
import (
"context"
"strings"
"hyapp/pkg/userleaderboard"
"hyapp/services/activity-service/internal/config"
)
func openUserLeaderboardStore(ctx context.Context, cfg config.UserLeaderboardWorkerConfig) (*userleaderboard.Store, func() error, error) {
if !cfg.Enabled {
return nil, nil, nil
}
redisAddr := strings.TrimSpace(cfg.RedisAddr)
if redisAddr == "" {
return nil, nil, userleaderboard.ErrNotConfigured
}
client, err := userleaderboard.NewRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil {
return nil, nil, err
}
// activity-service 只负责把 wallet_outbox 已提交事实投影成 Redis 读模型key 命名由共享包和配置共同约束。
return userleaderboard.NewStore(client, cfg.KeyPrefix), client.Close, nil
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/userleaderboard"
"hyapp/pkg/walletmq"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
cumulativerechargedomain "hyapp/services/activity-service/internal/domain/cumulativerecharge"
@ -21,6 +22,11 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
return firstrechargedomain.RechargeEvent{}, false, nil
}
fields := firstRechargeFactFromWalletPayload(message.PayloadJSON)
// 首充奖励按 Google 商品 ID 精确匹配运营档位币商转账、H5 或其他充值事实没有 Google 商品维度,
// 这里必须在进入业务校验前确认并跳过,避免合法钱包事实被首充 consumer 当成 poison message 反复重投。
if !isCumulativeGoogleRecharge(fields.rechargeType) {
return firstrechargedomain.RechargeEvent{}, false, nil
}
return firstrechargedomain.RechargeEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
@ -127,6 +133,39 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
}, true, nil
}
func userLeaderboardGiftEventFromWalletMessage(body []byte) (userleaderboard.GiftEvent, bool, error) {
message, err := walletmq.DecodeWalletOutboxMessage(body)
if err != nil {
return userleaderboard.GiftEvent{}, false, err
}
if message.EventType != "WalletGiftDebited" {
return userleaderboard.GiftEvent{}, false, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(message.PayloadJSON), &decoded); err != nil {
return userleaderboard.GiftEvent{}, false, err
}
if boolFromDecoded(decoded, "direct_gift") {
// 旧 gateway SQL 只统计 biz_type='gift_debit'direct_gift_debit 不进榜Redis 投影必须保持这个口径。
return userleaderboard.GiftEvent{}, false, nil
}
senderUserID := firstNonZeroInt64(int64FromDecoded(decoded, "sender_user_id"), message.UserID)
targetUserID := int64FromDecoded(decoded, "target_user_id")
giftValue, _ := firstPresentInt64(decoded, "heat_value", "coin_spent", "charge_amount")
if senderUserID <= 0 || targetUserID <= 0 || giftValue <= 0 {
return userleaderboard.GiftEvent{}, false, nil
}
return userleaderboard.GiftEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
SenderUserID: senderUserID,
TargetUserID: targetUserID,
GiftValue: giftValue,
GiftCount: int64FromDecoded(decoded, "gift_count"),
OccurredAtMS: message.OccurredAtMS,
}, true, nil
}
type firstRechargePayloadFields struct {
sequence int64
rechargeType string
@ -329,6 +368,9 @@ func int64FromDecoded(decoded map[string]any, key string) int64 {
case json.Number:
parsed, _ := value.Int64()
return parsed
case string:
parsed, _ := json.Number(strings.TrimSpace(value)).Int64()
return parsed
default:
return 0
}
@ -337,3 +379,54 @@ func int64FromDecoded(decoded map[string]any, key string) int64 {
func int32FromDecoded(decoded map[string]any, key string) int32 {
return int32(int64FromDecoded(decoded, key))
}
func firstPresentInt64(decoded map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
value, ok := int64FromDecodedIfPresent(decoded, key)
if ok {
return value, true
}
}
return 0, false
}
func int64FromDecodedIfPresent(decoded map[string]any, key string) (int64, bool) {
raw, exists := decoded[key]
if !exists {
return 0, false
}
switch value := raw.(type) {
case float64:
return int64(value), true
case int64:
return value, true
case json.Number:
parsed, err := value.Int64()
return parsed, err == nil
case string:
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
parsed, err := json.Number(value).Int64()
return parsed, err == nil
default:
return 0, false
}
}
func boolFromDecoded(decoded map[string]any, key string) bool {
switch value := decoded[key].(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1"
case float64:
return value != 0
case json.Number:
parsed, _ := value.Int64()
return parsed != 0
default:
return false
}
}

View File

@ -0,0 +1,62 @@
package app
import (
"testing"
"hyapp/pkg/walletmq"
firstrechargedomain "hyapp/services/activity-service/internal/domain/firstrecharge"
)
func TestFirstRechargeEventFromWalletMessageSkipsNonGoogleRecharge(t *testing.T) {
// 币商充值是合法钱包事实,但它没有 Google 商品 ID首充 consumer 必须确认后跳过,避免 MQ 持续重投。
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "wev-coin-seller",
EventType: firstrechargedomain.RechargeEventWalletRecorded,
TransactionID: "wtx-coin-seller",
CommandID: "coin-seller-transfer-1",
UserID: 42001,
AssetType: "COIN",
AvailableDelta: 10000,
PayloadJSON: `{"recharge_type":"coin_seller_transfer","amount":10000,"recharge_sequence":1}`,
OccurredAtMS: 1710000000000,
})
if err != nil {
t.Fatalf("EncodeWalletOutboxMessage failed: %v", err)
}
event, ok, err := firstRechargeEventFromWalletMessage(body)
if err != nil {
t.Fatalf("firstRechargeEventFromWalletMessage returned error for non-google recharge: %v", err)
}
if ok || event.EventID != "" {
t.Fatalf("non-google recharge should be acknowledged without first-recharge handling, ok=%v event=%+v", ok, event)
}
}
func TestFirstRechargeEventFromWalletMessageKeepsGoogleRecharge(t *testing.T) {
// Google 充值仍按商品 ID 进入首充奖励链路,缺商品字段应由 wallet outbox 事实修正,而不是泛化吞掉。
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: "wev-google",
EventType: firstrechargedomain.RechargeEventWalletRecorded,
TransactionID: "wtx-google",
CommandID: "google-pay-1",
UserID: 42001,
AssetType: "COIN",
AvailableDelta: 160000,
PayloadJSON: `{"recharge_type":"google","google_product_id":"coins_160000","recharge_usd_minor":199,"recharge_sequence":1}`,
OccurredAtMS: 1710000000000,
})
if err != nil {
t.Fatalf("EncodeWalletOutboxMessage failed: %v", err)
}
event, ok, err := firstRechargeEventFromWalletMessage(body)
if err != nil {
t.Fatalf("firstRechargeEventFromWalletMessage returned error: %v", err)
}
if !ok || event.RechargeType != "google" || event.GoogleProductID != "coins_160000" {
t.Fatalf("google recharge should be routed to first-recharge handling, ok=%v event=%+v", ok, event)
}
}

View File

@ -0,0 +1,105 @@
package app
import (
"encoding/json"
"testing"
"hyapp/pkg/walletmq"
)
func TestUserLeaderboardGiftEventFromWalletMessage(t *testing.T) {
body := mustWalletOutboxMessage(t, "WalletGiftDebited", map[string]any{
"sender_user_id": 10001,
"target_user_id": 20002,
"heat_value": 900,
"coin_spent": 1200,
"charge_amount": 1200,
"gift_count": 3,
})
event, ok, err := userLeaderboardGiftEventFromWalletMessage(body)
if err != nil || !ok {
t.Fatalf("gift event should parse: ok=%v err=%v", ok, err)
}
if event.AppCode != "hyapp_room" || event.EventID != "evt-1" || event.SenderUserID != 10001 || event.TargetUserID != 20002 {
t.Fatalf("event identity mismatch: %+v", event)
}
if event.GiftValue != 900 || event.GiftCount != 3 || event.OccurredAtMS != 1782461920000 {
t.Fatalf("event aggregate fields mismatch: %+v", event)
}
}
func TestUserLeaderboardGiftEventSkipsDirectGift(t *testing.T) {
body := mustWalletOutboxMessage(t, "WalletGiftDebited", map[string]any{
"sender_user_id": 10001,
"target_user_id": 20002,
"heat_value": 900,
"gift_count": 1,
"direct_gift": true,
})
_, ok, err := userLeaderboardGiftEventFromWalletMessage(body)
if err != nil {
t.Fatalf("direct gift parse failed: %v", err)
}
if ok {
t.Fatal("direct gift must keep the old gift_debit SQL scope and skip user leaderboard")
}
}
func TestUserLeaderboardGiftEventKeepsSQLCoalesceSemantics(t *testing.T) {
fallbackBody := mustWalletOutboxMessage(t, "WalletGiftDebited", map[string]any{
"sender_user_id": 10001,
"target_user_id": 20002,
"coin_spent": 1200,
"charge_amount": 1500,
"gift_count": 1,
})
fallback, ok, err := userLeaderboardGiftEventFromWalletMessage(fallbackBody)
if err != nil || !ok {
t.Fatalf("fallback gift event should parse: ok=%v err=%v", ok, err)
}
if fallback.GiftValue != 1200 {
t.Fatalf("missing heat_value should fall back to coin_spent, got %+v", fallback)
}
zeroHeatBody := mustWalletOutboxMessage(t, "WalletGiftDebited", map[string]any{
"sender_user_id": 10001,
"target_user_id": 20002,
"heat_value": 0,
"coin_spent": 1200,
"charge_amount": 1500,
"gift_count": 1,
})
_, ok, err = userLeaderboardGiftEventFromWalletMessage(zeroHeatBody)
if err != nil {
t.Fatalf("zero heat gift event parse failed: %v", err)
}
if ok {
t.Fatal("heat_value=0 should not fall back to coin_spent because old SQL COALESCE treats zero as present")
}
}
func mustWalletOutboxMessage(t *testing.T, eventType string, payload map[string]any) []byte {
t.Helper()
payloadBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload failed: %v", err)
}
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: "hyapp_room",
EventID: "evt-1",
EventType: eventType,
TransactionID: "tx-1",
CommandID: "cmd-1",
UserID: 10001,
AssetType: "COIN",
AvailableDelta: -1200,
PayloadJSON: string(payloadBytes),
OccurredAtMS: 1782461920000,
})
if err != nil {
t.Fatalf("encode wallet outbox failed: %v", err)
}
return body
}

View File

@ -9,7 +9,7 @@ import (
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
// GRPCUserProfileSource 从 user-service 读取红包播报所需的发送者展示资料。
// GRPCUserProfileSource 从 user-service 读取活动播报所需的发送者展示资料。
// activity-service 只消费当前快照组装 IM不复制或拥有用户资料。
type GRPCUserProfileSource struct {
client userv1.UserServiceClient
@ -22,7 +22,7 @@ func NewGRPCUserProfileSource(conn *grpc.ClientConn) *GRPCUserProfileSource {
func (s *GRPCUserProfileSource) GetSenderProfile(ctx context.Context, userID int64) (broadcastservice.SenderProfile, error) {
resp, err := s.client.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: "red-packet-broadcast-profile",
RequestId: "activity-broadcast-profile",
Caller: "activity-service",
AppCode: appcode.FromContext(ctx),
},

View File

@ -37,6 +37,8 @@ type Config struct {
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
// TaskEventWorker 控制任务系统对 wallet/user outbox 事实的本地消费。
TaskEventWorker TaskEventWorkerConfig `yaml:"task_event_worker"`
// UserLeaderboardWorker 控制用户送礼榜单对 wallet_outbox 送礼事实的 Redis 投影。
UserLeaderboardWorker UserLeaderboardWorkerConfig `yaml:"user_leaderboard_worker"`
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"`
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
@ -105,6 +107,20 @@ type TaskEventWorkerConfig struct {
Enabled bool `yaml:"enabled"`
}
// UserLeaderboardWorkerConfig 保存用户送礼榜单 Redis 投影策略。
type UserLeaderboardWorkerConfig struct {
// Enabled 控制是否启动 wallet_outbox 用户榜单消费者;关闭时 gateway 只能读到已有 Redis 聚合。
Enabled bool `yaml:"enabled"`
// RedisAddr 是 activity 写入用户榜单聚合的 Redis 地址,必须与 gateway 读取地址一致。
RedisAddr string `yaml:"redis_addr"`
// RedisPassword 是 Redis 鉴权密码,本地默认留空。
RedisPassword string `yaml:"redis_password"`
// RedisDB 是 Redis logical DB必须与 gateway 读取配置一致。
RedisDB int `yaml:"redis_db"`
// KeyPrefix 隔离用户榜单 key必须与 gateway 读取配置一致。
KeyPrefix string `yaml:"key_prefix"`
}
// LuckyGiftWorkerConfig 保存幸运礼物抽奖副作用补偿策略。
type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"`
@ -193,6 +209,7 @@ type WalletOutboxMQConfig struct {
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
TaskConsumerGroup string `yaml:"task_consumer_group"`
UserLeaderboardConsumerGroup string `yaml:"user_leaderboard_consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
@ -243,6 +260,9 @@ func Default() Config {
TaskEventWorker: TaskEventWorkerConfig{
Enabled: true,
},
UserLeaderboardWorker: UserLeaderboardWorkerConfig{
Enabled: false,
},
MessageActionConfirmWorker: MessageActionConfirmWorkerConfig{
Enabled: true,
OutboxPollInterval: time.Second,
@ -314,6 +334,7 @@ func defaultRocketMQConfig() RocketMQConfig {
InviteActivityConsumerGroup: "hyapp-activity-invite-wallet-outbox",
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
TaskConsumerGroup: "hyapp-activity-task-wallet-outbox",
UserLeaderboardConsumerGroup: "hyapp-activity-user-leaderboard-wallet-outbox",
ConsumerMaxReconsumeTimes: 16,
},
UserOutbox: UserOutboxMQConfig{
@ -440,14 +461,19 @@ func Load(path string) (Config, error) {
if cfg.MessageActionConfirmWorker.PublishTimeout <= 0 {
cfg.MessageActionConfirmWorker.PublishTimeout = 5 * time.Second
}
cfg.UserLeaderboardWorker.RedisAddr = strings.TrimSpace(cfg.UserLeaderboardWorker.RedisAddr)
cfg.UserLeaderboardWorker.KeyPrefix = strings.TrimSpace(cfg.UserLeaderboardWorker.KeyPrefix)
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.InviteActivityRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.InviteActivityRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled || cfg.UserLeaderboardWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
}
if cfg.UserLeaderboardWorker.Enabled && cfg.UserLeaderboardWorker.RedisAddr == "" {
return Config{}, errors.New("user leaderboard worker requires redis_addr")
}
if cfg.InviteActivityRewardWorker.Enabled && !cfg.RocketMQ.UserOutbox.Enabled {
return Config{}, errors.New("invite activity reward worker requires rocketmq.user_outbox.enabled")
}
@ -497,6 +523,9 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.WalletOutbox.TaskConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.TaskConsumerGroup); cfg.WalletOutbox.TaskConsumerGroup == "" {
cfg.WalletOutbox.TaskConsumerGroup = defaults.WalletOutbox.TaskConsumerGroup
}
if cfg.WalletOutbox.UserLeaderboardConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.UserLeaderboardConsumerGroup); cfg.WalletOutbox.UserLeaderboardConsumerGroup == "" {
cfg.WalletOutbox.UserLeaderboardConsumerGroup = defaults.WalletOutbox.UserLeaderboardConsumerGroup
}
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
}

View File

@ -11,10 +11,13 @@ func TestLoadLocalEnablesOutboxMQConsumers(t *testing.T) {
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable all activity outbox MQ consumers: %+v", cfg.RocketMQ)
}
if !cfg.FirstRechargeRewardWorker.Enabled || !cfg.CumulativeRechargeRewardWorker.Enabled || !cfg.InviteActivityRewardWorker.Enabled || !cfg.RedPacketBroadcastWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: first=%+v cumulative=%+v invite=%+v red_packet=%+v", cfg.FirstRechargeRewardWorker, cfg.CumulativeRechargeRewardWorker, cfg.InviteActivityRewardWorker, cfg.RedPacketBroadcastWorker)
if !cfg.FirstRechargeRewardWorker.Enabled || !cfg.CumulativeRechargeRewardWorker.Enabled || !cfg.InviteActivityRewardWorker.Enabled || !cfg.RedPacketBroadcastWorker.Enabled || !cfg.UserLeaderboardWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: first=%+v cumulative=%+v invite=%+v red_packet=%+v user_leaderboard=%+v", cfg.FirstRechargeRewardWorker, cfg.CumulativeRechargeRewardWorker, cfg.InviteActivityRewardWorker, cfg.RedPacketBroadcastWorker, cfg.UserLeaderboardWorker)
}
if cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup == "" || cfg.RocketMQ.UserOutbox.InviteActivityConsumerGroup == "" {
if cfg.UserLeaderboardWorker.RedisAddr != "127.0.0.1:13379" || cfg.UserLeaderboardWorker.KeyPrefix != "activity:user_leaderboard" {
t.Fatalf("local user leaderboard worker redis config mismatch: %+v", cfg.UserLeaderboardWorker)
}
if cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup == "" || cfg.RocketMQ.WalletOutbox.UserLeaderboardConsumerGroup == "" || cfg.RocketMQ.UserOutbox.InviteActivityConsumerGroup == "" {
t.Fatalf("local config must configure invite activity consumer groups: wallet=%+v user=%+v", cfg.RocketMQ.WalletOutbox, cfg.RocketMQ.UserOutbox)
}
}
@ -36,4 +39,7 @@ func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
if !cfg.InviteActivityRewardWorker.Enabled || cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup == "" || cfg.RocketMQ.UserOutbox.InviteActivityConsumerGroup == "" {
t.Fatalf("tencent example must enable invite activity consumer groups: invite=%+v wallet=%+v user=%+v", cfg.InviteActivityRewardWorker, cfg.RocketMQ.WalletOutbox, cfg.RocketMQ.UserOutbox)
}
if !cfg.UserLeaderboardWorker.Enabled || cfg.UserLeaderboardWorker.RedisAddr == "" || cfg.UserLeaderboardWorker.RedisPassword == "" || cfg.RocketMQ.WalletOutbox.UserLeaderboardConsumerGroup == "" {
t.Fatalf("tencent example must configure user leaderboard worker: worker=%+v wallet=%+v", cfg.UserLeaderboardWorker, cfg.RocketMQ.WalletOutbox)
}
}

View File

@ -3,7 +3,7 @@ package room
import "context"
// EventHandler 是 room outbox 事件进入 activity-service 后的处理边界。
// 消费来源可以是 MySQL outbox、Redis Stream 或 MQ但处理侧必须保持幂等。
// 消费来源可以是 MySQL outbox 或 MQ但处理侧必须保持幂等。
type EventHandler interface {
HandleRoomEvent(ctx context.Context, eventID string, eventType string, payload []byte) error
}

View File

@ -5,6 +5,7 @@ const (
TypeActivityLimited = "activity_limited"
CollectionOrdinary = "ordinary"
CollectionEvent = "event"
CycleLifetime = "lifetime"
StatusDraft = "draft"

View File

@ -20,7 +20,7 @@ const (
TypeRoomRocketReward = "room_rocket_reward_granted"
// TypeRoomPasswordChanged 是锁房状态变化的区域通知,客户端用它刷新房间锁状态入口。
TypeRoomPasswordChanged = "room_password_changed"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前默认 1 倍及以上即进入播报 outbox。
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 5 倍及以上大奖进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// StatusPending 表示消息已持久化,尚未被 worker claim。

View File

@ -24,6 +24,7 @@ type Repository interface {
ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error)
SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error)
UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand, nowMS int64) (domain.Definition, bool, error)
DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error)
ClaimPendingAchievementRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error)
MarkAchievementRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
@ -80,7 +81,7 @@ func (s *Service) ListAchievementDefinitions(ctx context.Context, query domain.L
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
// Admin reads must include draft/paused/archived definitions so operators can recover and edit rules that App reads hide.
// 后台删除成就只归档定义;默认列表隐藏 archived避免运营误编辑已经从页面移除的成就。
query.CollectionType = normalizeCollectionType(query.CollectionType)
query.CollectionID = strings.TrimSpace(query.CollectionID)
query.Status = normalizeDefinitionStatusFilter(query.Status)
@ -171,6 +172,18 @@ func (s *Service) UpsertAchievementDefinition(ctx context.Context, command domai
return s.repository.UpsertAchievementDefinition(ctx, command, s.now().UnixMilli())
}
func (s *Service) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64) (domain.Definition, error) {
if err := s.requireRepository(); err != nil {
return domain.Definition{}, err
}
achievementID = strings.TrimSpace(achievementID)
if achievementID == "" || operatorAdminID <= 0 {
return domain.Definition{}, xerr.New(xerr.InvalidArgument, "achievement delete command is incomplete")
}
// 删除使用归档状态承载历史用户解锁、徽章展示和审计链路都保留App/后台默认查询会排除归档定义。
return s.repository.DeleteAchievementDefinition(ctx, achievementID, operatorAdminID, s.now().UnixMilli())
}
func (s *Service) ProcessAchievementRewardBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
@ -278,15 +291,16 @@ func validateDefinitionCommand(command domain.DefinitionCommand) error {
if !validDefinitionStatus(command.Status) || !validAchievementType(command.AchievementType) {
return xerr.New(xerr.InvalidArgument, "achievement definition status or type is invalid")
}
if command.RewardResourceGroupID < 0 || command.PrimaryBadgeResourceID < 0 {
if command.PrimaryBadgeResourceID <= 0 {
return xerr.New(xerr.InvalidArgument, "achievement badge resource is required")
}
if command.RewardResourceGroupID < 0 {
return xerr.New(xerr.InvalidArgument, "achievement reward resource is invalid")
}
if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
return xerr.New(xerr.InvalidArgument, "achievement effective time is invalid")
}
if len(command.Conditions) == 0 {
return xerr.New(xerr.InvalidArgument, "achievement condition is required")
}
// 新版成就配置不再要求事件条件;历史条件保留兼容,存在时仍可被旧事件流推进。
for _, condition := range command.Conditions {
if condition.MetricType == "" || condition.TargetValue <= 0 {
return xerr.New(xerr.InvalidArgument, "achievement condition is invalid")

View File

@ -171,6 +171,54 @@ func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) {
}
}
func TestDirectBadgeAchievementWithoutConditions(t *testing.T) {
svc, _ := newAchievementService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, _, err := svc.UpsertAchievementDefinition(ctx, domain.DefinitionCommand{
CollectionType: domain.CollectionEvent,
AchievementType: domain.TypeActivityLimited,
Title: "活动徽章",
Description: "活动达成后展示",
Status: domain.StatusActive,
PrimaryBadgeResourceID: 8101,
OperatorAdminID: 90001,
}); err != nil {
t.Fatalf("UpsertAchievementDefinition without conditions failed: %v", err)
}
if _, err := svc.ConsumeAchievementEvent(ctx, domain.Event{
EventID: "wallet-badge-event-direct",
EventType: "ResourceGranted",
SourceService: "wallet-service",
UserID: 10002,
MetricType: "wallet_badge_grant",
Value: 1,
OccurredAtMS: fixedAchievementNow().UnixMilli(),
DimensionsJSON: `{"grant_id":"grant-badge-direct","grant_source":"admin","items":[{"resource_id":8101,"resource_type":"badge","entitlement_id":"ent-badge-direct","metadata_json":"{\"badge_form\":\"tile\"}"}]}`,
}); err != nil {
t.Fatalf("project badge resource failed: %v", err)
}
items, total, err := svc.ListAchievements(ctx, domain.ListQuery{UserID: 10002, CollectionType: domain.CollectionEvent})
if err != nil {
t.Fatalf("ListAchievements direct badge failed: %v", err)
}
if total != 1 || len(items) != 1 || items[0].UserStatus != domain.UnlockStatusUnlocked || items[0].ProgressValue != 1 || items[0].TargetValue != 1 {
t.Fatalf("direct badge achievement read mismatch: total=%d items=%+v", total, items)
}
deleted, err := svc.DeleteAchievementDefinition(ctx, items[0].AchievementID, 90001)
if err != nil || deleted.Status != domain.StatusArchived {
t.Fatalf("DeleteAchievementDefinition failed: item=%+v err=%v", deleted, err)
}
adminItems, adminTotal, err := svc.ListAchievementDefinitions(ctx, domain.ListQuery{CollectionType: domain.CollectionEvent})
if err != nil {
t.Fatalf("ListAchievementDefinitions after delete failed: %v", err)
}
if adminTotal != 0 || len(adminItems) != 0 {
t.Fatalf("archived achievement should be hidden by default: total=%d items=%+v", adminTotal, adminItems)
}
}
func newAchievementService(t *testing.T) (*achievementservice.Service, *fakeAchievementWallet) {
t.Helper()
repository := mysqltest.NewRepository(t)

View File

@ -19,6 +19,11 @@ import (
const levelRewardReason = "growth_level_reward"
const (
levelAvatarFrameRewardDays = int64(9999)
levelAvatarFrameRewardDurationMS = levelAvatarFrameRewardDays * 24 * 60 * 60 * 1000
)
const (
registrationLevelBadgeReason = "growth_level_registration_badge"
registrationLevelBadgeSourceLevel = int32(1)
@ -260,9 +265,13 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if gift.GetIsRobotGift() {
return 0, nil
}
if gift.GetSenderUserId() <= 0 || gift.GetTargetUserId() <= 0 || gift.GetGiftValue() <= 0 {
if gift.GetSenderUserId() <= 0 || gift.GetTargetUserId() <= 0 || gift.GetGiftValue() < 0 {
return 0, xerr.New(xerr.InvalidArgument, "room gift event is incomplete")
}
if gift.GetGiftValue() == 0 {
// 低价幸运礼物按房间贡献比例折算后可能为 0这是合法结算结果增长服务只确认事件并跳过等级增量。
return 0, nil
}
dimensions, err := roomGiftDimensions(envelope, &gift)
if err != nil {
return 0, err
@ -377,25 +386,13 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
}
for _, job := range jobs {
processed++
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: job.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
GroupId: job.ResourceGroupID,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
walletGrantID, grantErr := s.grantLevelRewardJob(ctx, job)
if grantErr != nil {
failure++
nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
continue
}
walletGrantID := ""
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil {
failure++
continue
@ -405,6 +402,101 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
}
func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob) (string, error) {
walletGrantID := ""
if job.ResourceGroupID > 0 {
// 资源组奖励沿用原有配置快照command_id 后缀把组奖励和单资源等级物料奖励拆成多个钱包幂等动作。
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: job.WalletCommandID + ":group",
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
GroupId: job.ResourceGroupID,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
}
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
levelResources, err := s.levelResourceIDsForRewardJob(ctx, job)
if err != nil {
return "", err
}
if levelResources.avatarFrameResourceID > 0 {
// 等级头像框是每级固定物料,不依赖运营额外创建资源组;按产品要求直接发 9999 天权益。
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: job.WalletCommandID + ":avatar_frame",
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
ResourceId: levelResources.avatarFrameResourceID,
Quantity: 1,
DurationMs: levelAvatarFrameRewardDurationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
if levelResources.shortBadgeResourceID > 0 {
// 短徽章和等级头像框一样来自每级规则;发放为 9999 天资源,展示覆盖仍由长徽章投影负责。
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: job.WalletCommandID + ":short_badge",
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
ResourceId: levelResources.shortBadgeResourceID,
Quantity: 1,
DurationMs: levelAvatarFrameRewardDurationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
return walletGrantID, nil
}
type levelRewardResourceIDs struct {
avatarFrameResourceID int64
shortBadgeResourceID int64
}
func (s *Service) levelResourceIDsForRewardJob(ctx context.Context, job domain.RewardJob) (levelRewardResourceIDs, error) {
if job.RewardSourceType != domain.RewardSourceLevel {
return levelRewardResourceIDs{}, nil
}
level, ok := levelFromRewardSourceID(job.RewardSourceID)
if !ok {
return levelRewardResourceIDs{}, nil
}
config, err := s.repository.ListLevelConfig(ctx, domain.ConfigQuery{Track: job.Track, Status: domain.StatusActive}, s.now().UnixMilli())
if err != nil {
return levelRewardResourceIDs{}, err
}
for _, rule := range config.Rules {
if rule.Track == job.Track && rule.Level == level && rule.Status == domain.StatusActive {
return levelRewardResourceIDs{
avatarFrameResourceID: avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON),
shortBadgeResourceID: shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON),
}, nil
}
}
return levelRewardResourceIDs{}, nil
}
func roomGiftDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
payload := map[string]any{
"room_id": envelope.GetRoomId(),
@ -474,11 +566,23 @@ func normalizeJSON(value string) string {
}
func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "long_badge_resource_id")
}
func avatarFrameResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "avatar_frame_resource_id")
}
func shortBadgeResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "short_badge_resource_id")
}
func positiveInt64FromDisplayConfig(raw string, key string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
value, ok := payload[key]
if !ok {
return 0
}
@ -496,6 +600,18 @@ func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
return 0
}
func levelFromRewardSourceID(value string) (int32, bool) {
levelText := strings.TrimPrefix(strings.TrimSpace(value), "level:")
if levelText == strings.TrimSpace(value) {
return 0, false
}
parsed, err := strconv.ParseInt(levelText, 10, 32)
if err != nil || parsed < 0 {
return 0, false
}
return int32(parsed), true
}
func validTrack(value string) bool {
switch value {
case domain.TrackWealth, domain.TrackGame, domain.TrackCharm:

View File

@ -88,6 +88,78 @@ func TestGrowthLevelEventRewardFlow(t *testing.T) {
}
}
func TestGrowthLevelRewardGrantsLevelResources(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth,
Level: 1,
RequiredValue: 100,
Name: "wealth 1",
Status: domain.StatusActive,
SortOrder: 1,
DisplayConfigJSON: `{"avatar_frame_resource_id":801,"short_badge_resource_id":901}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level resource rule failed: created=%v err=%v", created, err)
}
result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "level-avatar-frame-event-1",
SourceEventID: "room-event-avatar-frame-1",
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: 10002,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: 100,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-1"}`,
})
if err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
if result.RewardJobCount != 1 {
t.Fatalf("level resources should create one reward job: %+v", result)
}
overview, err := svc.GetMyLevelOverview(ctx, 10002)
if err != nil {
t.Fatalf("GetMyLevelOverview failed: %v", err)
}
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if wealth.DisplayAvatarFrameResourceID != 801 {
t.Fatalf("level avatar frame should override display profile: %+v", wealth)
}
profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10002})
if err != nil {
t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err)
}
if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.AvatarFrameResourceID != 801 {
t.Fatalf("room display profile should use level avatar frame: %+v", profiles.Profiles)
}
claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-avatar-frame", "worker-1", 10, time.Second)
if err != nil {
t.Fatalf("ProcessLevelRewardBatch failed: %v", err)
}
if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore {
t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v", claimed, processed, success, failure, hasMore)
}
if len(wallet.grants) != 0 || len(wallet.resourceGrants) != 2 {
t.Fatalf("level resource reward should grant two resources only: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants))
}
grantedResources := make(map[int64]*walletv1.GrantResourceRequest, len(wallet.resourceGrants))
for _, req := range wallet.resourceGrants {
grantedResources[req.GetResourceId()] = req
}
for _, resourceID := range []int64{801, 901} {
req := grantedResources[resourceID]
if req == nil || req.GetDurationMs() != 9999*24*60*60*1000 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel {
t.Fatalf("level resource grant mismatch: id=%d grants=%+v", resourceID, wallet.resourceGrants)
}
}
}
func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
@ -138,6 +210,40 @@ func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) {
}
}
func TestGrowthRoomGiftEventSkipsZeroGiftValue(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
body, err := proto.Marshal(&activityevents.RoomGiftSent{
SenderUserId: 20001,
TargetUserId: 20002,
GiftId: "gift-zero-heat",
GiftCount: 1,
GiftValue: 0,
BillingReceiptId: "bill-zero-heat",
VisibleRegionId: 86,
CommandId: "cmd-gift-zero-heat",
})
if err != nil {
t.Fatalf("marshal zero heat room gift failed: %v", err)
}
consumed, err := svc.HandleRoomEvent(ctx, &activityevents.EventEnvelope{
EventId: "room-gift-event-zero-heat",
RoomId: "room-1",
EventType: "RoomGiftSent",
RoomVersion: 8,
OccurredAtMs: fixedGrowthNow().UnixMilli(),
Body: body,
AppCode: "lalu",
})
if err != nil {
t.Fatalf("zero heat RoomGiftSent should be accepted and skipped: %v", err)
}
if consumed != 0 {
t.Fatalf("zero heat RoomGiftSent should not create growth events, consumed=%d", consumed)
}
}
func TestListLevelConfigReturnsAdminRulesAndTiers(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")

View File

@ -2,6 +2,8 @@ package luckygift
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
@ -28,6 +30,7 @@ type Repository interface {
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
@ -44,7 +47,7 @@ type Repository interface {
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递
// WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM
type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
}
@ -54,18 +57,27 @@ type RoomPublisher interface {
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
}
// UserPublisher 负责把钱包余额变化私有消息发给中奖用户;失败不能回滚已经成功的钱包入账。
type UserPublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox真正发腾讯 IM 仍由 broadcast worker 补偿。
type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
}
const luckyGiftRegionBroadcastMinMultiplierPPM = 5_000_000
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
repository Repository
wallet WalletClient
publisher RoomPublisher
broadcaster RegionBroadcaster
now func() time.Time
repository Repository
wallet WalletClient
publisher RoomPublisher
userPub UserPublisher
broadcaster RegionBroadcaster
senderProfileSource broadcastservice.SenderProfileSource
now func() time.Time
}
type Option func(*Service)
@ -82,12 +94,24 @@ func WithRoomPublisher(publisher RoomPublisher) Option {
}
}
func WithUserPublisher(publisher UserPublisher) Option {
return func(s *Service) {
s.userPub = publisher
}
}
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
return func(s *Service) {
s.broadcaster = broadcaster
}
}
func WithSenderProfileSource(source broadcastservice.SenderProfileSource) Option {
return func(s *Service) {
s.senderProfileSource = source
}
}
func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
@ -122,6 +146,37 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
normalized, err := s.normalizeDrawCommand(cmd)
if err != nil {
return domain.DrawResult{}, err
}
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
}
func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if len(cmds) == 0 {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
normalized := make([]domain.DrawCommand, 0, len(cmds))
for _, cmd := range cmds {
item, err := s.normalizeDrawCommand(cmd)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
}
// 批量送礼的返奖不能阻塞 room-service 主链路repository 只落 draw fact 和 settlement outboxworker 后续负责 wallet/IM。
return s.repository.ExecuteLuckyGiftDrawBatch(ctx, normalized, s.now().UTC().UnixMilli())
}
func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawCommand, error) {
// Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.PoolID = normalizePoolID(cmd.PoolID)
@ -130,20 +185,16 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
return cmd, nil
}
type WorkerOptions struct {
@ -262,29 +313,64 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
}
func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
if _, _, err := s.settleLuckyGiftReward(ctx, event, payload, options); err != nil {
credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
if err != nil {
return err
}
if credited && !alreadyGranted {
// 钱包入账和 activity draw 状态都已经落库;这里的 C2C 只是降低客户端余额刷新延迟,失败时仍由 wallet outbox -> notice-service 补偿。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
}
if credited && payload.EffectiveRewardCoins > 0 {
displayPayload := s.enrichLuckyGiftSenderProfile(ctx, payload)
// 房间中奖 IM 必须在钱包返奖落库之后发;发送方 HTTP 本地表现不能替代房间内其他用户的实时展示。
// 这里即使 draw 已经 granted 也会补发一次fast path 可能先完成账务,随后 outbox worker 才负责可靠补房间展示。
if err := s.publishLuckyGiftDrawn(ctx, displayPayload, options); err != nil {
logx.Error(ctx, "lucky_gift_room_drawn_notice_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.String("event_id", payload.EventID),
)
}
if shouldPublishLuckyGiftRegionBroadcast(displayPayload) {
// 区域播报和房间 IM 都是表现层副作用,失败不回滚已经成功的返奖账务。
if err := s.publishLuckyGiftRegionBroadcast(ctx, displayPayload); err != nil {
logx.Error(ctx, "lucky_gift_region_broadcast_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.Int64("visible_region_id", payload.VisibleRegionID),
)
}
}
}
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
}
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, error) {
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, luckyGiftRewardReceipt, error) {
walletTransactionID := ""
credited := false
alreadyGranted := false
receipt := luckyGiftRewardReceipt{}
if payload.EffectiveRewardCoins > 0 {
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
if err != nil {
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
alreadyGranted = state.AllGranted
if alreadyGranted {
walletTransactionID = state.WalletTransactionID
credited = true
} else {
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload)
var err error
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
if err != nil {
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
walletTransactionID = receipt.WalletTransactionID
credited = true
@ -292,21 +378,23 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
}
if !credited {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return false, false, err
return false, false, receipt, err
}
return false, false, nil
return false, false, receipt, nil
}
if !alreadyGranted {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return credited, alreadyGranted, err
return credited, alreadyGranted, receipt, err
}
}
return credited, alreadyGranted, nil
return credited, alreadyGranted, receipt, nil
}
type luckyGiftRewardReceipt struct {
WalletTransactionID string
CoinBalanceAfter int64
CoinFrozenAfter int64
BalanceVersion int64
}
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
@ -351,13 +439,21 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID
result.CoinBalanceAfter = receipt.CoinBalanceAfter
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil {
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
return result
}
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送Flutter 用 balance_after 覆盖本地余额。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
return result
}
@ -404,16 +500,90 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
return luckyGiftRewardReceipt{
WalletTransactionID: resp.GetTransactionId(),
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
BalanceVersion: resp.GetBalance().GetVersion(),
}, nil
}
func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
if receipt.WalletTransactionID == "" {
return fmt.Errorf("lucky gift wallet transaction id is empty")
}
nowMs := s.now().UTC().UnixMilli()
commandID := "lucky_reward:" + payload.DrawID
noticePayload, err := json.Marshal(map[string]any{
"event_type": "WalletBalanceChanged",
"event_id": walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
"app_code": payload.AppCode,
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": fmt.Sprintf("%d", payload.UserID),
"asset_type": "COIN",
"available_delta": payload.EffectiveRewardCoins,
"frozen_delta": 0,
"available_after": receipt.CoinBalanceAfter,
"frozen_after": receipt.CoinFrozenAfter,
"balance_version": receipt.BalanceVersion,
"created_at_ms": nowMs,
"source_created_at_ms": nowMs,
"metadata": map[string]any{
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": payload.UserID,
"draw_id": payload.DrawID,
"room_id": payload.RoomID,
"visible_region_id": payload.VisibleRegionID,
"region_id": payload.VisibleRegionID,
"country_id": payload.CountryID,
"gift_id": payload.GiftID,
"pool_id": payload.PoolID,
"amount": payload.EffectiveRewardCoins,
"reason": "lucky_gift_reward",
"created_at_ms": nowMs,
},
})
if err != nil {
return err
}
publishTimeout := timeout
if publishTimeout <= 0 {
publishTimeout = 5 * time.Second
}
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
defer cancel()
return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(payload.UserID),
EventID: walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
Desc: "WalletBalanceChanged",
Ext: "wallet_notice",
PayloadJSON: noticePayload,
})
}
func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
sum := sha256.Sum256([]byte(hashInput))
return "wev_" + hex.EncodeToString(sum[:])
}
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
if err != nil {
return err
}
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
publishTimeout := options.PublishTimeout
if publishTimeout <= 0 {
publishTimeout = 5 * time.Second
}
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
GroupID: payload.RoomID,
@ -425,7 +595,7 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
}
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
if payload.VisibleRegionID <= 0 {
if s.broadcaster == nil || payload.VisibleRegionID <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))
@ -546,6 +716,9 @@ type luckyGiftDrawnPayload struct {
UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
SenderName string `json:"sender_name,omitempty"`
SenderAvatar string `json:"sender_avatar,omitempty"`
SenderDisplayUserID string `json:"sender_display_user_id,omitempty"`
VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"`
@ -563,8 +736,9 @@ type luckyGiftDrawnPayload struct {
}
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
// 当前产品策略先把 1 倍作为默认区域飘屏阈值;后续接后台配置时只替换这里的阈值来源。
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000
// 区域飘屏只承担大奖氛围,不再把 1x/2x/3x 的普通返奖广播到整区,避免高频低价值 IM 干扰用户。
// 跨房展示没有当前房间成员资料可兜底,缺 sender_name 时宁可跳过,也不能再让 Flutter 退回 `User {id}`。
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftRegionBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
}
func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any {
@ -581,6 +755,9 @@ func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any {
"user_id": payload.UserID,
"sender_user_id": payload.SenderUserID,
"target_user_id": payload.TargetUserID,
"sender_name": payload.SenderName,
"sender_avatar": payload.SenderAvatar,
"sender_display_user_id": payload.SenderDisplayUserID,
"visible_region_id": payload.VisibleRegionID,
"coin_spent": payload.CoinSpent,
"rule_version": payload.RuleVersion,
@ -615,6 +792,9 @@ func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int
"user_id": payload.UserID,
"sender_user_id": payload.SenderUserID,
"target_user_id": payload.TargetUserID,
"sender_name": payload.SenderName,
"sender_avatar": payload.SenderAvatar,
"sender_display_user_id": payload.SenderDisplayUserID,
"coin_spent": payload.CoinSpent,
"rule_version": payload.RuleVersion,
"experience_pool": payload.ExperiencePool,
@ -632,6 +812,58 @@ func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int
"type": "enter_room",
"room_id": payload.RoomID,
},
"sender": luckyGiftBroadcastProfilePayload(payload),
}
}
func (s *Service) enrichLuckyGiftSenderProfile(ctx context.Context, payload luckyGiftDrawnPayload) luckyGiftDrawnPayload {
if strings.TrimSpace(payload.SenderName) != "" && strings.TrimSpace(payload.SenderDisplayUserID) != "" {
return payload
}
if payload.SenderUserID <= 0 || s.senderProfileSource == nil {
return payload
}
profile, err := s.senderProfileSource.GetSenderProfile(ctx, payload.SenderUserID)
if err != nil {
// 用户资料只是 IM 展示快照;读取失败不能阻断已经落库的返奖和房间展示,后续仍可用 user_id 兜底排障。
logx.Error(ctx, "lucky_gift_sender_profile_resolve_failed", err,
slog.Int64("sender_user_id", payload.SenderUserID),
slog.String("draw_id", payload.DrawID),
)
return payload
}
if profile.UserID <= 0 {
profile.UserID = payload.SenderUserID
}
if strings.TrimSpace(payload.SenderName) == "" {
payload.SenderName = luckyGiftBroadcastDisplayName(profile)
}
if strings.TrimSpace(payload.SenderAvatar) == "" {
payload.SenderAvatar = strings.TrimSpace(profile.Avatar)
}
if strings.TrimSpace(payload.SenderDisplayUserID) == "" {
payload.SenderDisplayUserID = strings.TrimSpace(profile.Account)
}
return payload
}
func luckyGiftBroadcastDisplayName(profile broadcastservice.SenderProfile) string {
// Flutter 优先读 sender_name这里按昵称、账号兜底避免区域飘屏退化成 `User {id}`。
for _, value := range []string{profile.Nickname, profile.Account} {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func luckyGiftBroadcastProfilePayload(payload luckyGiftDrawnPayload) map[string]any {
// 嵌套 sender 对象兼容 Flutter 现有解析,也让后续客户端不用再从扁平字段反推展示资料。
return map[string]any{
"user_id": payload.SenderUserID,
"display_user_id": payload.SenderDisplayUserID,
"nickname": payload.SenderName,
"avatar": payload.SenderAvatar,
}
}

View File

@ -64,7 +64,8 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
CreatedAtMS: 1760000000000,
}}
wallet := &fakeLuckyGiftWallet{balanceAfter: 1300}
service := New(repository, WithWallet(wallet))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithUserPublisher(userPublisher))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
@ -87,12 +88,51 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
}
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 0 {
t.Fatalf("fast path result mismatch: %+v", result)
}
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" {
t.Fatalf("fast path should mark all draw ids granted, got %s", got)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 1300)
}
func TestDrawBatchLeavesRewardSettlementToWorker(t *testing.T) {
repository := &fakeLuckyGiftRepository{executeBatchResults: []domain.DrawResult{{
DrawID: "lucky_draw_batch_async_1",
CommandID: "cmd-gift-batch:target:42",
PoolID: "super_lucky",
GiftID: "rose",
EffectiveRewardCoins: 300,
RewardStatus: domain.StatusPending,
}}}
wallet := &fakeLuckyGiftWallet{}
service := New(repository, WithWallet(wallet))
results, err := service.DrawBatch(appcode.WithContext(context.Background(), "lalu"), []domain.DrawCommand{{
CommandID: "cmd-gift-batch:target:42",
PoolID: "super_lucky",
UserID: 42,
TargetUserID: 99,
DeviceID: "device-1",
RoomID: "room-1",
AnchorID: "42",
GiftID: "rose",
GiftCount: 1,
CoinSpent: 100,
}})
if err != nil {
t.Fatalf("DrawBatch failed: %v", err)
}
if len(results) != 1 || results[0].DrawID != "lucky_draw_batch_async_1" {
t.Fatalf("DrawBatch result mismatch: %+v", results)
}
if wallet.last != nil || repository.grantedDrawID != "" || len(repository.grantedDrawIDs) != 0 {
t.Fatalf("batch draw must not run synchronous wallet fast path: wallet=%+v granted=%s granted_ids=%v", wallet.last, repository.grantedDrawID, repository.grantedDrawIDs)
}
if len(repository.executeBatchCmds) != 1 || repository.executeBatchCmds[0].CommandID != "cmd-gift-batch:target:42" {
t.Fatalf("batch draw must pass normalized commands to repository: %+v", repository.executeBatchCmds)
}
}
func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
@ -128,7 +168,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test",
@ -160,8 +200,9 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -174,14 +215,94 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
if broadcaster.last.EventID != "" {
t.Fatalf("3x lucky gift must not enter region broadcast: %+v", broadcaster.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
}
}
func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProfile(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_5x",
"app_code": "lalu",
"draw_id": "lucky_draw_5x",
"command_id": "cmd-gift-5x",
"pool_id": "super_lucky",
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"country_id": 15,
"visible_region_id": 210,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 500,
"multiplier_ppm": luckyGiftRegionBroadcastMinMultiplierPPM,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
outbox: []domain.DrawOutbox{{
AppCode: "lalu",
OutboxID: "lucky_lucky_draw_5x",
EventType: "LuckyGiftDrawn",
PayloadJSON: string(payload),
}},
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository,
WithWallet(wallet),
WithRoomPublisher(publisher),
WithUserPublisher(userPublisher),
WithRegionBroadcaster(broadcaster),
WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
42: {UserID: 42, Account: "160042", Nickname: "Real Sender", Avatar: "https://cdn.example/sender.png"},
}}),
)
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_5x", "lucky_draw_5x", 500)
var roomPayload map[string]any
if err := json.Unmarshal(publisher.last.PayloadJSON, &roomPayload); err != nil {
t.Fatalf("room payload is invalid: %v", err)
}
if roomPayload["sender_name"] != "Real Sender" || roomPayload["sender_display_user_id"] != "160042" {
t.Fatalf("room payload must include sender display snapshot: %+v", roomPayload)
}
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_5x" || broadcaster.last.RegionID != 210 {
t.Fatalf("5x lucky gift region broadcast mismatch: %+v", broadcaster.last)
}
var regionPayload map[string]any
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), &regionPayload); err != nil {
t.Fatalf("region payload is invalid: %v", err)
}
if regionPayload["sender_name"] != "Real Sender" || regionPayload["sender_display_user_id"] != "160042" || regionPayload["sender_avatar"] != "https://cdn.example/sender.png" {
t.Fatalf("region payload must include sender display snapshot: %+v", regionPayload)
}
sender, _ := regionPayload["sender"].(map[string]any)
if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" {
t.Fatalf("nested sender profile mismatch: %+v", sender)
}
if _, exists := regionPayload["gift_name"]; exists {
t.Fatalf("lucky gift region broadcast should not send a gift-name template field: %+v", regionPayload)
}
}
func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
@ -214,8 +335,17 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service := New(repository,
WithWallet(wallet),
WithRoomPublisher(publisher),
WithUserPublisher(userPublisher),
WithRegionBroadcaster(broadcaster),
WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
42: {UserID: 42, Account: "160042", Nickname: "Batch Sender"},
}}),
)
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -231,9 +361,14 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
t.Fatalf("all sub draws should be granted together, got %s", got)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
}
}
func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_settle",
@ -261,7 +396,9 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
@ -273,9 +410,11 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
}
if publisher.last.EventID != "" {
t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
if broadcaster.last.EventID != "" {
t.Fatalf("3x settlement lucky gift must not enter region broadcast: %+v", broadcaster.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
@ -330,7 +469,7 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_im_fail",
@ -357,7 +496,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -373,6 +513,10 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" {
t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID)
}
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
@ -403,7 +547,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
@ -418,18 +563,21 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
if repository.grantedDrawID != "" {
t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
}
if publisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should only deliver settlement outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
}
type fakeLuckyGiftRepository struct {
getConfigCalls int
upserted domain.RuleConfig
executeResult domain.DrawResult
executeCmd domain.DrawCommand
rewardState domain.DrawRewardState
outbox []domain.DrawOutbox
getConfigCalls int
upserted domain.RuleConfig
executeResult domain.DrawResult
executeCmd domain.DrawCommand
executeBatchResults []domain.DrawResult
executeBatchCmds []domain.DrawCommand
rewardState domain.DrawRewardState
outbox []domain.DrawOutbox
grantedDrawID string
grantedDrawIDs []string
@ -460,6 +608,20 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd do
return r.executeResult, nil
}
func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
r.executeBatchCmds = append([]domain.DrawCommand(nil), cmds...)
if len(r.executeBatchResults) > 0 {
return append([]domain.DrawResult(nil), r.executeBatchResults...), nil
}
results := make([]domain.DrawResult, 0, len(cmds))
for _, cmd := range cmds {
result := r.executeResult
result.CommandID = cmd.CommandID
results = append(results, result)
}
return results, nil
}
func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil
}
@ -547,6 +709,16 @@ func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, me
return p.err
}
type fakeLuckyGiftUserPublisher struct {
last tencentim.CustomUserMessage
err error
}
func (p *fakeLuckyGiftUserPublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
p.last = message
return p.err
}
type fakeLuckyGiftRegionBroadcaster struct {
last broadcastservice.PublishInput
}
@ -555,3 +727,55 @@ func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Contex
b.last = input
return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil
}
type fakeLuckyGiftSenderProfileSource struct {
profiles map[int64]broadcastservice.SenderProfile
err error
}
func (s fakeLuckyGiftSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (broadcastservice.SenderProfile, error) {
if s.err != nil {
return broadcastservice.SenderProfile{}, s.err
}
if profile, ok := s.profiles[userID]; ok {
return profile, nil
}
return broadcastservice.SenderProfile{UserID: userID}, nil
}
func assertLuckyGiftWalletNotice(t *testing.T, message tencentim.CustomUserMessage, toAccount string, transactionID string, availableDelta int64, availableAfter int64) {
t.Helper()
if message.ToAccount != toAccount || message.Desc != "WalletBalanceChanged" || message.Ext != "wallet_notice" {
t.Fatalf("wallet notice envelope mismatch: %+v", message)
}
if message.EventID != walletBalanceChangedEventID(transactionID, 42, "COIN") {
t.Fatalf("wallet notice event id mismatch: %s", message.EventID)
}
var payload map[string]any
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
t.Fatalf("wallet notice payload is invalid: %v", err)
}
if payload["event_type"] != "WalletBalanceChanged" || payload["transaction_id"] != transactionID || payload["user_id"] != "42" || payload["asset_type"] != "COIN" {
t.Fatalf("wallet notice identity fields mismatch: %+v", payload)
}
if int64(payload["available_delta"].(float64)) != availableDelta || int64(payload["available_after"].(float64)) != availableAfter {
t.Fatalf("wallet notice balance fields mismatch: %+v", payload)
}
}
func assertLuckyGiftRoomMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string, drawID string, rewardCoins int64) {
t.Helper()
if message.GroupID != groupID || message.EventID != eventID || message.Desc != "lucky_gift_drawn" || message.Ext != "room_system_message" {
t.Fatalf("room lucky gift envelope mismatch: %+v", message)
}
var payload map[string]any
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
t.Fatalf("room lucky gift payload is invalid: %v", err)
}
if payload["event_type"] != "lucky_gift_drawn" || payload["event_id"] != eventID || payload["draw_id"] != drawID {
t.Fatalf("room lucky gift identity fields mismatch: %+v", payload)
}
if int64(payload["effective_reward_coins"].(float64)) != rewardCoins {
t.Fatalf("room lucky gift reward mismatch: %+v", payload)
}
}

View File

@ -3,6 +3,7 @@ package roomturnoverreward
import (
"context"
"sort"
"strconv"
"strings"
"time"
@ -14,9 +15,15 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
messageservice "hyapp/services/activity-service/internal/service/message"
)
const rewardReason = "room_turnover_reward"
const (
rewardReason = "room_turnover_reward"
rewardNoticeProducer = "room-turnover-reward"
rewardNoticeEventType = "room_turnover_reward_granted"
rewardNoticeAggregateType = "room_turnover_reward_settlement"
)
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
type Repository interface {
@ -40,21 +47,41 @@ type WalletClient interface {
CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
}
// NoticeService 是房间流水奖励发放后的用户站内信副作用;具体写入仍由 message service owner 完成。
type NoticeService interface {
CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
}
// RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。
type RoomQueryClient interface {
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
}
// Option 只注入可替换的外部副作用,避免发奖主流程在单测里依赖真实消息和房间客户端。
type Option func(*Service)
// WithNoticeService 连接系统消息公共函数;正金额发奖必须能写站内信,防止出现已发币但用户无通知的静默状态。
func WithNoticeService(notices NoticeService) Option {
return func(s *Service) {
s.notices = notices
}
}
// Service 承载房间贡献奖励 App 查询、后台配置、事件聚合和每周结算。
type Service struct {
repository Repository
wallet WalletClient
room RoomQueryClient
notices NoticeService
now func() time.Time
}
func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service {
return &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
func New(repository Repository, wallet WalletClient, room RoomQueryClient, options ...Option) *Service {
service := &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
for _, option := range options {
option(service)
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
@ -347,6 +374,11 @@ func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlem
nowMS := s.now().UTC().UnixMilli()
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
}
if s.notices == nil {
err := xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured")
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
if s.wallet == nil {
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
@ -371,9 +403,52 @@ func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlem
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
// 系统消息用 settlement_id 做生产者幂等键钱包成功但消息写入失败时settlement 仍保持可 retry下一次会用同一钱包 command_id 和同一消息键补齐。
if err := s.createRewardGrantedNotice(ctx, settlement, resp.GetTransactionId(), resp.GetGrantedAtMs()); err != nil {
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}
func (s *Service) createRewardGrantedNotice(ctx context.Context, settlement domain.Settlement, walletTransactionID string, grantedAtMS int64) error {
if s.notices == nil {
return xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured")
}
if grantedAtMS <= 0 {
grantedAtMS = s.now().UTC().UnixMilli()
}
summary := roomTurnoverRewardNoticeSummary(settlement.RewardCoinAmount)
_, err := s.notices.CreateSystemNotice(ctx, messageservice.NoticeCommand{
TargetUserID: settlement.OwnerUserID,
Producer: rewardNoticeProducer,
ProducerEventID: settlement.SettlementID,
ProducerEventType: rewardNoticeEventType,
AggregateType: rewardNoticeAggregateType,
AggregateID: settlement.SettlementID,
Title: "Room turnover reward",
Summary: summary,
Body: summary,
SentAtMS: grantedAtMS,
Metadata: map[string]any{
"settlement_id": settlement.SettlementID,
"room_id": settlement.RoomID,
"period_start_ms": settlement.PeriodStartMS,
"period_end_ms": settlement.PeriodEndMS,
"coin_spent": settlement.CoinSpent,
"reward_coin_amount": settlement.RewardCoinAmount,
"tier_id": settlement.TierID,
"tier_code": settlement.TierCode,
"wallet_transaction_id": walletTransactionID,
},
})
return err
}
func roomTurnoverRewardNoticeSummary(amount int64) string {
return "Congratulations! You earned " + strconv.FormatInt(amount, 10) + " coins from this week's room turnover reward."
}
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
if s.room == nil {
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")

View File

@ -2,6 +2,7 @@ package roomturnoverreward
import (
"context"
"errors"
"testing"
"time"
@ -12,6 +13,7 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
messageservice "hyapp/services/activity-service/internal/service/message"
)
func TestWeekWindowUsesUTCOneMondayBoundary(t *testing.T) {
@ -160,6 +162,96 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) {
}
}
func TestGrantSettlementCreatesSystemNoticeBeforeMarkGranted(t *testing.T) {
repo := &retryBoundaryRepository{
settlement: roomTurnoverRewardGrantableSettlement(),
}
wallet := &retryBoundaryWallet{}
notices := &fakeRewardNoticeService{}
svc := New(repo, wallet, nil, WithNoticeService(notices))
svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-09T01:00:00Z") })
granted, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement)
if err != nil {
t.Fatalf("grantSettlement returned error: %v", err)
}
if wallet.calls != 1 || repo.grantCalls != 1 || repo.failCalls != 0 {
t.Fatalf("grant path mismatch: wallet=%d grant=%d fail=%d", wallet.calls, repo.grantCalls, repo.failCalls)
}
if notices.calls != 1 {
t.Fatalf("expected one system notice, got %d", notices.calls)
}
if granted.Status != domain.SettlementStatusGranted || granted.WalletTransactionID != "wallet-tx-1" {
t.Fatalf("settlement should be granted with wallet transaction, got %+v", granted)
}
cmd := notices.command
if cmd.TargetUserID != repo.settlement.OwnerUserID || cmd.Producer != rewardNoticeProducer || cmd.ProducerEventID != repo.settlement.SettlementID {
t.Fatalf("notice idempotency or target mismatch: %+v", cmd)
}
if cmd.ProducerEventType != rewardNoticeEventType || cmd.AggregateType != rewardNoticeAggregateType || cmd.AggregateID != repo.settlement.SettlementID {
t.Fatalf("notice aggregate mismatch: %+v", cmd)
}
wantSummary := "Congratulations! You earned 123 coins from this week's room turnover reward."
if cmd.Title != "Room turnover reward" || cmd.Summary != wantSummary || cmd.Body != wantSummary {
t.Fatalf("notice content mismatch: title=%q summary=%q body=%q", cmd.Title, cmd.Summary, cmd.Body)
}
if cmd.SentAtMS != 1234 {
t.Fatalf("notice sent_at_ms should use wallet grant time, got %d", cmd.SentAtMS)
}
if cmd.Metadata["settlement_id"] != repo.settlement.SettlementID ||
cmd.Metadata["room_id"] != repo.settlement.RoomID ||
cmd.Metadata["reward_coin_amount"] != repo.settlement.RewardCoinAmount ||
cmd.Metadata["wallet_transaction_id"] != "wallet-tx-1" {
t.Fatalf("notice metadata mismatch: %+v", cmd.Metadata)
}
}
func TestGrantSettlementFailsWhenSystemNoticeCannotBeCreated(t *testing.T) {
repo := &retryBoundaryRepository{
settlement: roomTurnoverRewardGrantableSettlement(),
}
wallet := &retryBoundaryWallet{}
notices := &fakeRewardNoticeService{err: errors.New("message unavailable")}
svc := New(repo, wallet, nil, WithNoticeService(notices))
if _, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement); err == nil {
t.Fatal("expected grantSettlement to fail when notice creation fails")
}
if wallet.calls != 1 || notices.calls != 1 {
t.Fatalf("wallet should be credited once and notice attempted once, wallet=%d notices=%d", wallet.calls, notices.calls)
}
if repo.grantCalls != 0 || repo.failCalls != 1 || repo.settlement.Status != domain.SettlementStatusFailed {
t.Fatalf("notice failure must not mark granted: grant=%d fail=%d settlement=%+v", repo.grantCalls, repo.failCalls, repo.settlement)
}
}
func roomTurnoverRewardGrantableSettlement() domain.Settlement {
return domain.Settlement{
SettlementID: "settlement-room-turnover-1",
AppCode: "lalu",
RoomID: "room-1",
OwnerUserID: 42,
PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"),
PeriodEndMS: mustUnixMilli("2026-06-08T01:00:00Z"),
CoinSpent: 9000,
TierID: 2,
TierCode: "silver",
ThresholdCoinSpent: 5000,
RewardCoinAmount: 123,
Status: domain.SettlementStatusPending,
WalletCommandID: "room_turnover_reward:lalu:20260601:room-1",
}
}
func mustUnixMilli(value string) int64 {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed.UnixMilli()
}
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
@ -314,9 +406,26 @@ func (r *retryBoundaryRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRe
type retryBoundaryWallet struct {
calls int
req *walletv1.CreditRoomTurnoverRewardRequest
}
func (w *retryBoundaryWallet) CreditRoomTurnoverReward(context.Context, *walletv1.CreditRoomTurnoverRewardRequest, ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
func (w *retryBoundaryWallet) CreditRoomTurnoverReward(_ context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
w.calls++
w.req = req
return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil
}
type fakeRewardNoticeService struct {
calls int
command messageservice.NoticeCommand
err error
}
func (s *fakeRewardNoticeService) CreateSystemNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) {
s.calls++
s.command = cmd
if s.err != nil {
return messageservice.NoticeResult{}, s.err
}
return messageservice.NoticeResult{Created: true}, nil
}

View File

@ -430,6 +430,47 @@ func (r *Repository) UpsertAchievementDefinition(ctx context.Context, command do
return definition, created, err
}
func (r *Repository) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error) {
if r == nil || r.db == nil {
return domain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Definition{}, err
}
defer func() { _ = tx.Rollback() }()
current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID)
if err != nil {
return domain.Definition{}, err
}
version := current.Version
if current.Status != domain.StatusArchived {
version = current.Version + 1
// 归档保留定义主键和历史用户事实,只让 App 与后台默认列表跳过;这样不会破坏用户已有徽章展示引用。
if _, err := tx.ExecContext(ctx, `
UPDATE achievement_definitions
SET status = 'archived', version = ?, updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND achievement_id = ?`,
version, operatorAdminID, nowMS, appcode.FromContext(ctx), achievementID,
); err != nil {
return domain.Definition{}, err
}
// 条件只服务旧事件推进;归档时清掉当前条件,避免恢复脚本或历史事件误匹配已删除配置。
if _, err := tx.ExecContext(ctx, `
DELETE FROM achievement_conditions
WHERE app_code = ? AND achievement_id = ?`,
appcode.FromContext(ctx), achievementID,
); err != nil {
return domain.Definition{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.Definition{}, err
}
return r.getAchievementDefinition(ctx, r.db, achievementID)
}
func (r *Repository) ClaimPendingAchievementRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -509,6 +550,9 @@ func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID i
for _, condition := range definition.Conditions {
targetTotal += condition.TargetValue
}
if targetTotal == 0 && definition.PrimaryBadgeResourceID > 0 {
targetTotal = 1
}
progressTotal := int64(0)
completed := len(definition.Conditions) > 0
for _, condition := range definition.Conditions {
@ -522,6 +566,17 @@ func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID i
if err != nil {
return domain.UserAchievement{}, err
}
if !unlocked && definition.PrimaryBadgeResourceID > 0 {
// 新版成就以徽章资源配置为核心不再强依赖事件条件用户已经拥有该徽章展示资源时H5 直接视为已激活。
hasBadge, err := r.userHasBadgeDisplayResource(ctx, r.db, userID, definition.PrimaryBadgeResourceID)
if err != nil {
return domain.UserAchievement{}, err
}
if hasBadge {
unlocked = true
progressTotal = targetTotal
}
}
userStatus := domain.ProgressStatusInProgress
if completed {
userStatus = domain.ProgressStatusCompleted
@ -1018,6 +1073,8 @@ func achievementDefinitionAdminWhere(ctx context.Context, query domain.ListQuery
if query.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, query.Status)
} else {
conditions = append(conditions, "status <> 'archived'")
}
if query.CollectionType != "" {
conditions = append(conditions, "collection_type = ?")

View File

@ -249,7 +249,8 @@ func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEv
rewardJobCount = count
}
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
return domain.EventResult{}, err
}
if err := tx.Commit(); err != nil {
@ -353,7 +354,8 @@ func (r *Repository) SetUserLevel(ctx context.Context, command domain.SetUserLev
return domain.SetUserLevelResult{}, err
}
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
return domain.SetUserLevelResult{}, err
}
if err := tx.Commit(); err != nil {
@ -575,6 +577,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
}
tier := tierByID(tiers, account.CurrentTierID)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, account.CurrentLevel, tier.DisplayAvatarFrameResourceID)
currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel)
pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
if err != nil {
@ -589,7 +592,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
CurrentLevelRequiredValue: currentRequired,
NextLevel: nextLevel,
NextLevelRequiredValue: nextRequired,
DisplayAvatarFrameResourceID: tier.DisplayAvatarFrameResourceID,
DisplayAvatarFrameResourceID: avatarFrameResourceID,
DisplayBadgeResourceID: badgeResourceID,
DisplayBadgeSourceLevel: badgeSourceLevel,
RewardPendingCount: pendingCount,
@ -663,7 +666,9 @@ func (r *Repository) insertLevelHistory(ctx context.Context, tx *sql.Tx, event d
func (r *Repository) createLevelRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, previousLevel int32, nextLevel int32, previousTierID int64, nextTier domain.Tier, rules []domain.Rule, nowMS int64) (int64, error) {
created := int64(0)
for _, rule := range rules {
if rule.Level <= previousLevel || rule.Level > nextLevel || rule.RewardResourceGroupID <= 0 {
avatarFrameResourceID := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
shortBadgeResourceID := shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
if rule.Level <= previousLevel || rule.Level > nextLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceID <= 0 && shortBadgeResourceID <= 0) {
continue
}
changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.RewardResourceGroupID, nowMS)
@ -703,7 +708,7 @@ func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userI
return affected > 0, err
}
func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, avatarFrameResourceID int64, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
columnPrefix := ""
switch track {
case domain.TrackWealth:
@ -729,7 +734,7 @@ func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
)
_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, tier.DisplayAvatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
return err
}
@ -1029,12 +1034,48 @@ func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int
return badgeResourceID, badgeSourceLevel
}
func levelRuleAvatarFrameForLevel(rules []domain.Rule, currentLevel int32, fallbackResourceID int64) int64 {
if currentLevel < 0 {
return fallbackResourceID
}
avatarFrameResourceID := int64(0)
sourceLevel := int32(0)
for _, rule := range rules {
if rule.Level > currentLevel || rule.Level < sourceLevel {
continue
}
candidate := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
if candidate <= 0 {
continue
}
// 等级头像框按当前等级以内最高配置覆盖;没有每级配置时保留等级段头像框兼容旧展示。
avatarFrameResourceID = candidate
sourceLevel = rule.Level
}
if avatarFrameResourceID > 0 {
return avatarFrameResourceID
}
return fallbackResourceID
}
func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "long_badge_resource_id")
}
func avatarFrameResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "avatar_frame_resource_id")
}
func shortBadgeResourceIDFromDisplayConfig(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, "short_badge_resource_id")
}
func positiveInt64FromDisplayConfig(raw string, key string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
value, ok := payload[key]
if !ok {
return 0
}

View File

@ -213,6 +213,34 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
return luckyAggregateDrawResults(cmd, results), nil
}
func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if len(cmds) == 0 {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
results := make([]domain.DrawResult, 0, len(cmds))
for _, cmd := range cmds {
// 多目标送礼必须在 activity 内按请求顺序推进同一用户、奖池、RTP 和风控锁;这里不做并发,避免 room-service 自己制造锁竞争。
draws, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS)
if err != nil {
return nil, err
}
results = append(results, luckyAggregateDrawResults(cmd, draws))
}
if err := tx.Commit(); err != nil {
return nil, err
}
return results, nil
}
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
// gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。

View File

@ -157,6 +157,15 @@ func (s *AdminAchievementServer) UpsertAchievementDefinition(ctx context.Context
return &activityv1.UpsertAchievementDefinitionResponse{Achievement: achievementDefinitionToProto(item), Created: created}, nil
}
func (s *AdminAchievementServer) DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, err := s.svc.DeleteAchievementDefinition(ctx, req.GetAchievementId(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.DeleteAchievementDefinitionResponse{Achievement: achievementDefinitionToProto(item)}, nil
}
func userAchievementToProto(item domain.UserAchievement) *activityv1.UserAchievement {
return &activityv1.UserAchievement{
Definition: achievementDefinitionToProto(item.Definition),

View File

@ -47,7 +47,35 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.Ch
func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
meta := req.GetLuckyGift()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
result, err := s.svc.Draw(ctx, domain.DrawCommand{
result, err := s.svc.Draw(ctx, luckyDrawCommandFromProto(meta))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
}
func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
if len(req.GetLuckyGifts()) == 0 {
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required"))
}
ctx = appcode.WithContext(ctx, req.GetLuckyGifts()[0].GetMeta().GetAppCode())
cmds := make([]domain.DrawCommand, 0, len(req.GetLuckyGifts()))
for _, meta := range req.GetLuckyGifts() {
cmds = append(cmds, luckyDrawCommandFromProto(meta))
}
results, err := s.svc.DrawBatch(ctx, cmds)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(results))}
for _, result := range results {
resp.Results = append(resp.Results, luckyDrawResultToProto(result))
}
return resp, nil
}
func luckyDrawCommandFromProto(meta *activityv1.LuckyGiftMeta) domain.DrawCommand {
return domain.DrawCommand{
CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(),
@ -61,11 +89,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
}
// AdminLuckyGiftServer 暴露后台幸运礼物规则配置和抽奖审计入口。

View File

@ -58,8 +58,11 @@ app_config:
key_prefix: "gateway:app_config:"
cache_ttl: "5m"
leaderboard:
# 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。
wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
# 活动用户榜单只读 activity-service 写入的 Redis 聚合结果;时间窗口统一按 UTC 计算。
redis_addr: "redis:6379"
redis_password: ""
redis_db: 0
key_prefix: "activity:user_leaderboard"
auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true

View File

@ -62,8 +62,11 @@ app_config:
key_prefix: "gateway:app_config:"
cache_ttl: "5m"
leaderboard:
# 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。
wallet_mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
# 活动用户榜单只读 activity-service 写入的 Redis 聚合结果;必须和 activity-service 使用同一 key_prefix。
redis_addr: "TENCENT_REDIS_HOST:6379"
redis_password: "TENCENT_REDIS_PASSWORD"
redis_db: 0
key_prefix: "activity:user_leaderboard"
auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true

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