Compare commits
21 Commits
bf2735d068
...
f8ec2118b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ec2118b9 | ||
|
|
151cf495e4 | ||
|
|
1409529f10 | ||
|
|
4060aee8af | ||
|
|
400363ec65 | ||
|
|
c47e4e6832 | ||
|
|
a1e038442d | ||
|
|
2e061101b3 | ||
|
|
beab5f57c8 | ||
|
|
2254097ea4 | ||
|
|
902ca5dc3c | ||
|
|
fe736780e0 | ||
|
|
538668014b | ||
|
|
0039c7f2ee | ||
|
|
bea9dcf1dd | ||
|
|
5890e3321a | ||
|
|
210bef6955 | ||
|
|
a10a851075 | ||
|
|
d145f3608d | ||
|
|
4c9bca8008 | ||
|
|
ba0e5664dd |
11
Makefile
11
Makefile
@ -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; \
|
||||
|
||||
@ -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
@ -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.
|
||||
|
||||
@ -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
@ -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 表示这条逐目标事实只给统计、activity、CP 和礼物墙消费;房间展示由同命令的 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
@ -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
@ -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);
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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
@ -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 是本次给收礼人返还的 COIN;0 表示当前礼物类型/区域不返币。
|
||||
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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
303
docs/flutter对接/Google三方注册Flutter对接.md
Normal file
303
docs/flutter对接/Google三方注册Flutter对接.md
Normal 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 导入接口。
|
||||
419
docs/幸运礼物产品文档.md
419
docs/幸运礼物产品文档.md
@ -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` 再候选过滤。 |
|
||||
| 房间气氛池 | 当前保留房间气氛池账本和结果字段,且气氛奖励口径独立于基础 RTP;v2 主候选默认仍来自基础 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 贡献,默认仍保持极简模式。 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
416
pkg/userleaderboard/store.go
Normal file
416
pkg/userleaderboard/store.go
Normal 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
|
||||
`)
|
||||
52
pkg/userleaderboard/store_test.go
Normal file
52
pkg/userleaderboard/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
36
pkg/xerr/device.go
Normal 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
19
pkg/xerr/device_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ const (
|
||||
RegionDisabled Code = "REGION_DISABLED"
|
||||
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||
// DeviceAlreadyRegistered 表示同一注册设备已经创建过账号,不能再次作为三方注册入口。
|
||||
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
||||
DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED"
|
||||
|
||||
// InsufficientBalance 表示钱包余额不足。
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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"
|
||||
|
||||
51
scripts/databi-real-flow-check.sh
Executable file
51
scripts/databi-real-flow-check.sh
Executable 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."
|
||||
35
scripts/prepare-local-mysql-initdb.sh
Executable file
35
scripts/prepare-local-mysql-initdb.sh
Executable 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
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
UserKeyword string
|
||||
BizType string
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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 组装支付后台 handler;wallet 负责账单和商品事实,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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
653
server/admin/internal/modules/payment/money.go
Normal file
653
server/admin/internal/modules/payment/money.go
Normal 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))
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
215
server/admin/internal/repository/money_scope_repository.go
Normal file
215
server/admin/internal/repository/money_scope_repository.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -76,6 +76,8 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
&model.DataScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.AdminJob{},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
26
server/admin/migrations/063_money_permission.sql
Normal file
26
server/admin/migrations/063_money_permission.sql
Normal 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';
|
||||
@ -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 '区域 ID,0 表示整个 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');
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
25
services/activity-service/internal/app/user_leaderboard.go
Normal file
25
services/activity-service/internal/app/user_leaderboard.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ const (
|
||||
TypeActivityLimited = "activity_limited"
|
||||
|
||||
CollectionOrdinary = "ordinary"
|
||||
CollectionEvent = "event"
|
||||
CycleLifetime = "lifetime"
|
||||
|
||||
StatusDraft = "draft"
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 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 outbox,worker 后续负责 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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), ®ionPayload); 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = ?")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 份单抽金额。
|
||||
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 暴露后台幸运礼物规则配置和抽奖审计入口。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user