merge main into test

This commit is contained in:
zhx 2026-07-16 19:26:31 +08:00
commit a11d5aca22
222 changed files with 28774 additions and 19637 deletions

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/events/luckygift/v1/events.proto // source: proto/events/luckygift/v1/events.proto
package luckygifteventsv1 package luckygifteventsv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/events/room/v1/events.proto // source: proto/events/room/v1/events.proto
package roomeventsv1 package roomeventsv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/game/v1/game.proto // source: proto/game/v1/game.proto
package gamev1 package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/game/v1/game.proto // source: proto/game/v1/game.proto
package gamev1 package gamev1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/robot/v1/robot.proto // source: proto/robot/v1/robot.proto
package robotv1 package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/robot/v1/robot.proto // source: proto/robot/v1/robot.proto
package robotv1 package robotv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/room/v1/room.proto // source: proto/room/v1/room.proto
package roomv1 package roomv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/user/v1/auth.proto // source: proto/user/v1/auth.proto
package userv1 package userv1
@ -1004,6 +1004,8 @@ type RefreshTokenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
// refresh_request_id 由客户端按一次 refresh 尝试生成,网络重试必须复用;旧客户端可为空。
RefreshRequestId string `protobuf:"bytes,3,opt,name=refresh_request_id,json=refreshRequestId,proto3" json:"refresh_request_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1052,6 +1054,13 @@ func (x *RefreshTokenRequest) GetRefreshToken() string {
return "" return ""
} }
func (x *RefreshTokenRequest) GetRefreshRequestId() string {
if x != nil {
return x.RefreshRequestId
}
return ""
}
// RefreshTokenResponse 返回轮换后的令牌。 // RefreshTokenResponse 返回轮换后的令牌。
type RefreshTokenResponse struct { type RefreshTokenResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -2048,10 +2057,11 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
"\btimezone\x18\x13 \x01(\tR\btimezone\"o\n" + "\btimezone\x18\x13 \x01(\tR\btimezone\"o\n" +
"\x1aQuickCreateAccountResponse\x12.\n" + "\x1aQuickCreateAccountResponse\x12.\n" +
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12!\n" + "\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12!\n" +
"\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"j\n" + "\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"\x98\x01\n" +
"\x13RefreshTokenRequest\x12.\n" + "\x13RefreshTokenRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" +
"\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\"F\n" + "\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\x12,\n" +
"\x12refresh_request_id\x18\x03 \x01(\tR\x10refreshRequestId\"F\n" +
"\x14RefreshTokenResponse\x12.\n" + "\x14RefreshTokenResponse\x12.\n" +
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\"\x83\x01\n" + "\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\"\x83\x01\n" +
"\rLogoutRequest\x12.\n" + "\rLogoutRequest\x12.\n" +

View File

@ -126,6 +126,8 @@ message QuickCreateAccountResponse {
message RefreshTokenRequest { message RefreshTokenRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string refresh_token = 2; string refresh_token = 2;
// refresh_request_id refresh
string refresh_request_id = 3;
} }
// RefreshTokenResponse // RefreshTokenResponse

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/user/v1/auth.proto // source: proto/user/v1/auth.proto
package userv1 package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.29.2 // protoc v7.35.0
// source: proto/user/v1/host.proto // source: proto/user/v1/host.proto
package userv1 package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/user/v1/host.proto // source: proto/user/v1/host.proto
package userv1 package userv1

File diff suppressed because it is too large Load Diff

View File

@ -416,6 +416,8 @@ message CPGiftSnapshot {
int32 gift_count = 5; int32 gift_count = 5;
int64 gift_value = 6; int64 gift_value = 6;
string billing_receipt_id = 7; string billing_receipt_id = 7;
// coin_spent wallet gift_value/
int64 coin_spent = 8;
} }
// CPApplication A B CP// // CPApplication A B CP//
@ -649,6 +651,8 @@ message RoomGiftCPEvent {
string gift_name = 16; string gift_name = 16;
string gift_icon_url = 17; string gift_icon_url = 17;
string gift_animation_url = 18; string gift_animation_url = 18;
// coin_spent RoomGiftSent wallet gift_value
int64 coin_spent = 19;
} }
message ConsumeRoomGiftCPEventRequest { message ConsumeRoomGiftCPEventRequest {
@ -1457,6 +1461,24 @@ message AdminGrantPrettyDisplayIDResponse {
string lease_id = 3; string lease_id = 3;
} }
// CPFormationGiftFeedItem CP
message CPFormationGiftFeedItem {
string formation_id = 1;
int64 gift_coin_value = 2;
CPUserProfile requester = 3;
CPUserProfile target = 4;
int64 formed_at_ms = 5;
}
message ListCPFormationGiftFeedRequest {
RequestMeta meta = 1;
int32 limit = 2;
}
message ListCPFormationGiftFeedResponse {
repeated CPFormationGiftFeedItem items = 1;
}
// UserService // UserService
service UserService { service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse); rpc GetUser(GetUserRequest) returns (GetUserResponse);
@ -1511,6 +1533,7 @@ service UserCPService {
rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse); rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse);
rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse); rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse);
rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse); rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse);
rpc ListCPFormationGiftFeed(ListCPFormationGiftFeedRequest) returns (ListCPFormationGiftFeedResponse);
} }
// UserCPInternalService owner outbox worker App // UserCPInternalService owner outbox worker App

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/user/v1/user.proto // source: proto/user/v1/user.proto
package userv1 package userv1
@ -1531,6 +1531,7 @@ const (
UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship" UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship"
UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship" UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship"
UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship" UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship"
UserCPService_ListCPFormationGiftFeed_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPFormationGiftFeed"
) )
// UserCPServiceClient is the client API for UserCPService service. // UserCPServiceClient is the client API for UserCPService service.
@ -1547,6 +1548,7 @@ type UserCPServiceClient interface {
PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error) PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error)
ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error) ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error)
CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error) CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error)
ListCPFormationGiftFeed(ctx context.Context, in *ListCPFormationGiftFeedRequest, opts ...grpc.CallOption) (*ListCPFormationGiftFeedResponse, error)
} }
type userCPServiceClient struct { type userCPServiceClient struct {
@ -1637,6 +1639,16 @@ func (c *userCPServiceClient) CancelBreakCPRelationship(ctx context.Context, in
return out, nil return out, nil
} }
func (c *userCPServiceClient) ListCPFormationGiftFeed(ctx context.Context, in *ListCPFormationGiftFeedRequest, opts ...grpc.CallOption) (*ListCPFormationGiftFeedResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCPFormationGiftFeedResponse)
err := c.cc.Invoke(ctx, UserCPService_ListCPFormationGiftFeed_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserCPServiceServer is the server API for UserCPService service. // UserCPServiceServer is the server API for UserCPService service.
// All implementations must embed UnimplementedUserCPServiceServer // All implementations must embed UnimplementedUserCPServiceServer
// for forward compatibility. // for forward compatibility.
@ -1651,6 +1663,7 @@ type UserCPServiceServer interface {
PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error) PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error)
ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error) ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error)
CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error)
ListCPFormationGiftFeed(context.Context, *ListCPFormationGiftFeedRequest) (*ListCPFormationGiftFeedResponse, error)
mustEmbedUnimplementedUserCPServiceServer() mustEmbedUnimplementedUserCPServiceServer()
} }
@ -1685,6 +1698,9 @@ func (UnimplementedUserCPServiceServer) ConfirmBreakCPRelationship(context.Conte
func (UnimplementedUserCPServiceServer) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) { func (UnimplementedUserCPServiceServer) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CancelBreakCPRelationship not implemented") return nil, status.Error(codes.Unimplemented, "method CancelBreakCPRelationship not implemented")
} }
func (UnimplementedUserCPServiceServer) ListCPFormationGiftFeed(context.Context, *ListCPFormationGiftFeedRequest) (*ListCPFormationGiftFeedResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCPFormationGiftFeed not implemented")
}
func (UnimplementedUserCPServiceServer) mustEmbedUnimplementedUserCPServiceServer() {} func (UnimplementedUserCPServiceServer) mustEmbedUnimplementedUserCPServiceServer() {}
func (UnimplementedUserCPServiceServer) testEmbeddedByValue() {} func (UnimplementedUserCPServiceServer) testEmbeddedByValue() {}
@ -1850,6 +1866,24 @@ func _UserCPService_CancelBreakCPRelationship_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _UserCPService_ListCPFormationGiftFeed_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCPFormationGiftFeedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCPServiceServer).ListCPFormationGiftFeed(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCPService_ListCPFormationGiftFeed_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCPServiceServer).ListCPFormationGiftFeed(ctx, req.(*ListCPFormationGiftFeedRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserCPService_ServiceDesc is the grpc.ServiceDesc for UserCPService service. // UserCPService_ServiceDesc is the grpc.ServiceDesc for UserCPService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -1889,6 +1923,10 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CancelBreakCPRelationship", MethodName: "CancelBreakCPRelationship",
Handler: _UserCPService_CancelBreakCPRelationship_Handler, Handler: _UserCPService_CancelBreakCPRelationship_Handler,
}, },
{
MethodName: "ListCPFormationGiftFeed",
Handler: _UserCPService_ListCPFormationGiftFeed_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto", Metadata: "proto/user/v1/user.proto",
@ -2581,7 +2619,7 @@ func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *Resolv
return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented") return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented")
} }
func (UnimplementedAppRegistryServiceServer) ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error) { func (UnimplementedAppRegistryServiceServer) ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListApps not implemented") return nil, status.Error(codes.Unimplemented, "method ListApps not implemented")
} }
func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {} func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {}
func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {} func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {}

File diff suppressed because it is too large Load Diff

View File

@ -1196,6 +1196,8 @@ message ResourceShopItemInput {
int64 effective_from_ms = 5; int64 effective_from_ms = 5;
int64 effective_to_ms = 6; int64 effective_to_ms = 6;
int32 sort_order = 7; int32 sort_order = 7;
// coin_price 0 使 0
int64 coin_price = 8;
} }
message ListResourceShopItemsRequest { message ListResourceShopItemsRequest {
@ -2025,6 +2027,7 @@ message VipBenefit {
string metadata_json = 12; string metadata_json = 12;
int64 created_at_ms = 13; int64 created_at_ms = 13;
int64 updated_at_ms = 14; int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15;
} }
message VipLevel { message VipLevel {
@ -2082,13 +2085,16 @@ message VipTrialCard {
} }
// VipUserSettings App VIP wallet // VipUserSettings App VIP wallet
// updated_at_ms=0 VIP // updated_at_ms=0 VIP
message VipUserSettings { message VipUserSettings {
string app_code = 1; string app_code = 1;
int64 user_id = 2; int64 user_id = 2;
bool room_entry_notice_enabled = 3; bool room_entry_notice_enabled = 3;
bool online_global_notice_enabled = 4; bool online_global_notice_enabled = 4;
int64 updated_at_ms = 5; int64 updated_at_ms = 5;
bool hide_profile_data_enabled = 6;
bool anonymous_profile_visit_enabled = 7;
bool leaderboard_invisible_enabled = 8;
} }
// VipState // VipState
@ -2145,6 +2151,8 @@ message PurchaseVipResponse {
int64 coin_balance_after = 5; int64 coin_balance_after = 5;
repeated VipRewardItem reward_items = 6; repeated VipRewardItem reward_items = 6;
VipState state = 7; VipState state = 7;
// coin_balance coin_balance_after
AssetBalance coin_balance = 8;
} }
message DebitCPBreakupFeeRequest { message DebitCPBreakupFeeRequest {
@ -2229,6 +2237,9 @@ message UpdateMyVipSettingsRequest {
int64 user_id = 3; int64 user_id = 3;
optional bool room_entry_notice_enabled = 4; optional bool room_entry_notice_enabled = 4;
optional bool online_global_notice_enabled = 5; optional bool online_global_notice_enabled = 5;
optional bool hide_profile_data_enabled = 6;
optional bool anonymous_profile_visit_enabled = 7;
optional bool leaderboard_invisible_enabled = 8;
} }
message UpdateMyVipSettingsResponse { message UpdateMyVipSettingsResponse {
@ -2949,6 +2960,52 @@ message GetPointWithdrawalConfigResponse {
string policy_instance_code = 5; string policy_instance_code = 5;
} }
// VipBenefitPreviewItem Flutter
//
message VipBenefitPreviewItem {
string preview_id = 1;
string title = 2;
// media_type: image/animationanimation 使 animation_url使 preview_url/asset_url
string media_type = 3;
string asset_url = 4;
string preview_url = 5;
string animation_url = 6;
// composition_type: none/user_avatar_center/user_avatar_waveform
string composition_type = 7;
int32 sort_order = 8;
}
message VipBenefitNumericReward {
string label = 1;
int64 value = 2;
string unit = 3;
// period: once/daily/monthly
string period = 4;
}
// VipBenefitPresentation Flutter metadata_json
// benefit_code metadata_json
message VipBenefitPresentation {
string description = 1;
repeated VipBenefitPreviewItem preview_items = 2;
VipBenefitNumericReward numeric_reward = 3;
}
// RevokeUserResourceRequest targets one entitlement instead of its source grant. This keeps
// resource-group siblings intact when an operator removes a single badge/frame from a user.
message RevokeUserResourceRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
string entitlement_id = 4;
string reason = 5;
int64 operator_user_id = 6;
}
message RevokeUserResourceResponse {
UserResourceEntitlement resource = 1;
}
// WalletCronService cron-service wallet-service owner // WalletCronService cron-service wallet-service owner
service WalletCronService { service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse); rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -3013,6 +3070,7 @@ service WalletService {
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse); rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse); rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse);
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse); rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
rpc RevokeUserResource(RevokeUserResourceRequest) returns (RevokeUserResourceResponse);
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse); rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse); rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse); rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/wallet/v1/wallet.proto // source: proto/wallet/v1/wallet.proto
package walletv1 package walletv1
@ -292,6 +292,7 @@ const (
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup" WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup" WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup"
WalletService_RevokeResourceGrant_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeResourceGrant" WalletService_RevokeResourceGrant_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeResourceGrant"
WalletService_RevokeUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeUserResource"
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources" WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource" WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource" WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
@ -427,6 +428,7 @@ type WalletServiceClient interface {
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error) GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error) GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error) RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
RevokeUserResource(ctx context.Context, in *RevokeUserResourceRequest, opts ...grpc.CallOption) (*RevokeUserResourceResponse, error)
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error) EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, error) UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, error)
@ -1040,6 +1042,16 @@ func (c *walletServiceClient) RevokeResourceGrant(ctx context.Context, in *Revok
return out, nil return out, nil
} }
func (c *walletServiceClient) RevokeUserResource(ctx context.Context, in *RevokeUserResourceRequest, opts ...grpc.CallOption) (*RevokeUserResourceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RevokeUserResourceResponse)
err := c.cc.Invoke(ctx, WalletService_RevokeUserResource_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) { func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserResourcesResponse) out := new(ListUserResourcesResponse)
@ -1830,6 +1842,7 @@ type WalletServiceServer interface {
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error) GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error)
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error)
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error) UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
@ -2072,6 +2085,9 @@ func (UnimplementedWalletServiceServer) GrantPinnedResourceGroup(context.Context
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) { func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RevokeResourceGrant not implemented") return nil, status.Error(codes.Unimplemented, "method RevokeResourceGrant not implemented")
} }
func (UnimplementedWalletServiceServer) RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RevokeUserResource not implemented")
}
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) { func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented") return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented")
} }
@ -3266,6 +3282,24 @@ func _WalletService_RevokeResourceGrant_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _WalletService_RevokeUserResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RevokeUserResourceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).RevokeUserResource(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_RevokeUserResource_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).RevokeUserResource(ctx, req.(*RevokeUserResourceRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUserResourcesRequest) in := new(ListUserResourcesRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -4799,6 +4833,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RevokeResourceGrant", MethodName: "RevokeResourceGrant",
Handler: _WalletService_RevokeResourceGrant_Handler, Handler: _WalletService_RevokeResourceGrant_Handler,
}, },
{
MethodName: "RevokeUserResource",
Handler: _WalletService_RevokeUserResource_Handler,
},
{ {
MethodName: "ListUserResources", MethodName: "ListUserResources",
Handler: _WalletService_ListUserResources_Handler, Handler: _WalletService_ListUserResources_Handler,

View File

@ -132,6 +132,8 @@ services:
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
rocketmq-broker: rocketmq-broker:
condition: service_started condition: service_started
healthcheck: healthcheck:
@ -381,10 +383,14 @@ services:
redis: redis:
image: redis:7.4-alpine image: redis:7.4-alpine
container_name: hyapp-redis container_name: hyapp-redis
# Session denylist 不能被内存淘汰AOF + volume 保证正常重启时先恢复撤销事实再接受连接。
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--maxmemory-policy", "noeviction"]
environment: environment:
TZ: UTC TZ: UTC
ports: ports:
- "13379:6379" - "13379:6379"
volumes:
- redis-data:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
@ -393,4 +399,5 @@ services:
volumes: volumes:
mysql-data: mysql-data:
redis-data:
rocketmq-store: rocketmq-store:

View File

@ -2,6 +2,14 @@
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”不再使用“SVIP”。 本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”不再使用“SVIP”。
## 0. 当前稳定契约
- `GET /vip/me``GET /vip/packages`、购买、佩戴体验卡、卸下体验卡的 `state` 完全同构,固定包含 `paid_vip``equipped_trial_card``effective_vip``effective_source``effective_benefits``evaluated_at_ms`、完整 `program_config`、完整 `user_settings`。写成功后整体替换 store禁止局部合并。
- `user_settings` 固定包含 5 项开关:进房通知、上线全服通知、隐藏个人数据、匿名访问主页、榜单隐身;均按 App 隔离、无记录默认开启。
- 权益富展示读取结构化 `benefits[].presentation`。它覆盖说明文案、多项静态/动态预览、用户头像合成方式和数值奖励Flutter 不解析 `metadata_json` 生成 UI。
- VIP 购买后的余额读取带 `version``coin_balance``coin_balance_after` 仅兼容旧客户端。历史幂等回放若 `version=0`,必须重新请求钱包余额。
- 业务分支只判断稳定 `code`,不得解析 `message`。精确 JSON、`presentation` Schema 和错误码矩阵以 [新版 VIP 权限策略 Flutter 客户端对接文档](./新版VIP权限策略_Flutter客户端对接文档.md) 为唯一协议定义;本文后续较短 JSON 只解释场景,不定义裁剪响应。
## 1. 通用约定 ## 1. 通用约定
- 地址前缀:`/api/v1`,实际 host 按环境配置。 - 地址前缀:`/api/v1`,实际 host 按环境配置。
@ -82,6 +90,20 @@ class VipEntitlementSnapshot {
return has('online_global_notice') && return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled; settings.onlineGlobalNoticeEnabled;
} }
bool allowsHideProfileData() {
return has('hide_profile_data') && settings.hideProfileDataEnabled;
}
bool allowsAnonymousProfileVisit() {
return has('anonymous_profile_visit') &&
settings.anonymousProfileVisitEnabled;
}
bool allowsLeaderboardInvisible() {
return has('leaderboard_invisible') &&
settings.leaderboardInvisibleEnabled;
}
} }
``` ```
@ -106,7 +128,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 | | 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 | | 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 | | 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` | | 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,分别处理 `VIP_ANTI_KICK``VIP_ANTI_MUTE` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 | | 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 | | 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
@ -176,7 +198,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 | | 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` | | 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` | | 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
| VIP 用户开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 | | VIP 功能开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 | | 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 | | 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
@ -250,6 +272,22 @@ final benefitHandlers = <String, VipBenefitHandler>{
"auto_equip": false, "auto_equip": false,
"sort_order": 110, "sort_order": 110,
"metadata_json": "", "metadata_json": "",
"presentation": {
"description": "进入房间时展示动态背景和用户头像",
"preview_items": [
{
"preview_id": "room_background_gold",
"title": "鎏金房间背景",
"media_type": "animation",
"asset_url": "https://cdn.example/vip/room-bg.png",
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
"animation_url": "https://cdn.example/vip/room-bg.svga",
"composition_type": "user_avatar_center",
"sort_order": 10
}
],
"numeric_reward": null
},
"created_at_ms": 0, "created_at_ms": 0,
"updated_at_ms": 0 "updated_at_ms": 0
} }
@ -302,6 +340,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": true, "online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0 "updated_at_ms": 0
} }
} }
@ -412,7 +453,8 @@ final benefitHandlers = <String, VipBenefitHandler>{
"execution_scope": "room", "execution_scope": "room",
"auto_equip": false, "auto_equip": false,
"sort_order": 290, "sort_order": 290,
"metadata_json": "" "metadata_json": "",
"presentation": null
} }
], ],
"evaluated_at_ms": 1780101000000, "evaluated_at_ms": 1780101000000,
@ -426,6 +468,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": true, "online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0 "updated_at_ms": 0
} }
} }
@ -447,7 +492,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip` - 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。 - 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
- 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]` - 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]`
- `user_settings.updated_at_ms=0` 表示用户尚未修改,个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。 - `user_settings.updated_at_ms=0` 表示用户尚未修改,5 个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
## 8. 购买、续期和升级 ## 8. 购买、续期和升级
@ -490,6 +535,12 @@ final benefitHandlers = <String, VipBenefitHandler>{
}, },
"coin_spent": 4000, "coin_spent": 4000,
"coin_balance_after": 6000, "coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [], "reward_items": [],
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
@ -523,6 +574,16 @@ final benefitHandlers = <String, VipBenefitHandler>{
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"config_version": 7 "config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
} }
} }
} }
@ -549,8 +610,9 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- Fami 同级续期:新截止时间 = 旧 `expires_at_ms` + `duration_ms`,保留剩余时间。 - Fami 同级续期:新截止时间 = 旧 `expires_at_ms` + `duration_ms`,保留剩余时间。
- Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。 - Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
- 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED` - 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED`
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`。 - 余额不足返回 `VIP_INSUFFICIENT_COIN`;套餐不存在、停用或不可购买返回 `VIP_PACKAGE_NOT_PURCHASABLE`;体系停用返回 `VIP_PROGRAM_INACTIVE`。
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。 - 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
- 购买成功后用 `coin_balance.version` 防止旧响应覆盖新余额。`version=0` 只可能来自升级前的历史幂等订单,必须刷新钱包余额。
## 9. 体验卡背包列表 ## 9. 体验卡背包列表
@ -660,17 +722,73 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"trial_card_enabled": true "level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
}, },
"state": { "state": {
"effective_source": "trial", "paid_vip": null,
"equipped_trial_card": {
"trial_card_id": "vip_trial_card_b",
"entitlement_id": "ent_card_b",
"resource_id": 102,
"user_id": 10001,
"level": 4,
"name": "VIP4",
"status": "active",
"equipped": true,
"duration_ms": 1728000000,
"effective_at_ms": 1780200000000,
"expires_at_ms": 1781928000000,
"remaining_duration_ms": 1700000000,
"grant_source": "admin_grant",
"source_grant_id": "grant_b",
"created_at_ms": 1780200000000,
"updated_at_ms": 1780200000000
},
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 4, "level": 4,
"name": "VIP4", "name": "VIP4",
"active": true, "active": true,
"expires_at_ms": 1781928000000 "started_at_ms": 1780200000000,
"expires_at_ms": 1781928000000,
"updated_at_ms": 1780200000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_benefits": [] "effective_source": "trial",
"effective_benefits": [],
"evaluated_at_ms": 1780228000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
}
}, },
"server_time_ms": 1780228000000 "server_time_ms": 1780228000000
} }
@ -710,22 +828,67 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"unequipped": true, "unequipped": true,
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1" "program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
}, },
"state": { "state": {
"effective_source": "paid",
"paid_vip": { "paid_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true "active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"equipped_trial_card": null, "equipped_trial_card": null,
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true "active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_benefits": [] "effective_source": "paid",
"effective_benefits": [],
"evaluated_at_ms": 1780229000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
}
}, },
"server_time_ms": 1780229000000 "server_time_ms": 1780229000000
} }
@ -898,7 +1061,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。 - 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。 - 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 14. VIP 通知开关 ## 14. VIP 功能开关
### 接口地址 ### 接口地址
@ -909,17 +1072,15 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
### 参数 ### 参数
GET 无参数。PATCH 是局部更新,个字段至少传一个;显式 `false` 不能被当成未传: GET 无参数。PATCH 是局部更新,5 个字段至少传一个;显式 `false` 不能被当成未传:
```json ```json
{ {
"room_entry_notice_enabled": false "room_entry_notice_enabled": false,
} "online_global_notice_enabled": true,
``` "hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
```json "leaderboard_invisible_enabled": true
{
"online_global_notice_enabled": true
} }
``` ```
@ -940,6 +1101,9 @@ GET
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": false, "online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000 "updated_at_ms": 1780300000000
}, },
"evaluated_at_ms": 1780300001000 "evaluated_at_ms": 1780300001000
@ -960,6 +1124,9 @@ PATCH
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": false, "room_entry_notice_enabled": false,
"online_global_notice_enabled": false, "online_global_notice_enabled": false,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300010000 "updated_at_ms": 1780300010000
}, },
"server_time_ms": 1780300010000 "server_time_ms": 1780300010000
@ -967,7 +1134,7 @@ PATCH
} }
``` ```
用户从未修改过时,两项默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖另一项。 用户从未修改过时,5 项均默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。
### 相关 IM ### 相关 IM
@ -978,6 +1145,7 @@ PATCH
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。 - 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
- 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true` - 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true`
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。 - 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 隐藏个人数据、匿名访问主页、榜单隐身分别要求对应权益和同名开关同时成立;客户端不得只看设置值授权。
- 设置按 App 隔离Fami 的修改不影响 Lalu。 - 设置按 App 隔离Fami 的修改不影响 Lalu。
## 15. 每日 VIP 金币返现 ## 15. 每日 VIP 金币返现
@ -1246,7 +1414,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。 - 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。 - 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则。 - 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则。
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me` - 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `VIP_BENEFIT_REQUIRED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。 - 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 17. 权益场景和完成边界 ## 17. 权益场景和完成边界
@ -1258,10 +1426,10 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 | | 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 | | 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 | | 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 |
| VIP 通知开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认项开启,开关不单独授权 | | VIP 功能开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认 5 项开启,开关不单独授权 |
| 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM不发全服 | | 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM不发全服 |
| 防踢 | 房管操作返回 `PERMISSION_DENIED` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser | | 防踢 | 房管操作返回 `VIP_ANTI_KICK` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `PERMISSION_DENIED` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 | | 防禁言 | 房管操作返回 `VIP_ANTI_MUTE` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 | | 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` | | 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
| 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 | | 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
@ -1277,4 +1445,4 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。 5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。 6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。 7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
8. 修改通知开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits` 8. 修改 VIP 功能开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`

View File

@ -85,12 +85,19 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
### 用户功能开关 ### 用户功能开关
`user_vip_settings``(app_code,user_id)` 保存个用户偏好: `user_vip_settings``(app_code,user_id)` 保存 5 个用户偏好:
- `room_entry_notice_enabled`:进房 VIP 高亮通知; - `room_entry_notice_enabled`:进房 VIP 高亮通知;
- `online_global_notice_enabled`:上线 VIP 全服飘屏。 - `online_global_notice_enabled`:上线 VIP 全服飘屏;
- `hide_profile_data_enabled`:隐藏个人数据;
- `anonymous_profile_visit_enabled`:匿名访问主页;
- `leaderboard_invisible_enabled`:榜单隐身。
无记录时两项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。 无记录时 5 项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。
### 权益展示投影
`vip_level_benefits.metadata_json.presentation` 是后台保存的扩展配置wallet 在读取时解析并校验,跨服务和 HTTP 统一投影为结构化 `presentation`。它包含说明文案、多项预览、`image/animation` 媒体类型、`none/user_avatar_center/user_avatar_waveform` 合成方式,以及 `once/daily/monthly` 数值奖励。Flutter 只消费结构化字段,不按 `benefit_code` 解释原始 JSON未知扩展因此不会变成客户端隐形配置表。
### 每日金币返现 ### 每日金币返现
@ -115,8 +122,11 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
- `effective_vip`:最终展示和权限使用的 VIP - `effective_vip`:最终展示和权限使用的 VIP
- `effective_source``paid``trial``none` - `effective_source``paid``trial``none`
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND - `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND
- `evaluated_at_ms`:本次状态合并的服务端时间;
- `program_config`:当前 App 规则; - `program_config`:当前 App 规则;
- `user_settings`:当前 App 下的进房/上线通知偏好。 - `user_settings`:当前 App 下完整的 5 项功能偏好。
`GET /vip/me``GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回完全同构的 `VipState`。写接口成功响应不是 patchFlutter 必须原子替换 store。灰度期间如旧节点返回缺字段状态客户端应丢弃该局部对象并重拉 `/vip/me`
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。 program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
@ -156,8 +166,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- `GET /vip/packages`program、可购买等级、每级权益和完整 state。 - `GET /vip/packages`program、可购买等级、每级权益和完整 state。
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。 - `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。 - `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取;响应 `coin_balance` 是带 `version` 的账后快照,旧 `coin_balance_after` 仅兼容历史客户端
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。 - `GET|PATCH /vip/settings`:读取或局部修改 5 项 VIP 功能偏好;空 PATCH 拒绝。
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。 - `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false` - `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。 - `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
@ -175,6 +185,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- `POST /v1/admin/activity/vip-trial-card-grants` - `POST /v1/admin/activity/vip-trial-card-grants`
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program - `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
App 业务错误通过 xerr 目录稳定映射:购买使用 `VIP_PROGRAM_INACTIVE``VIP_PACKAGE_NOT_PURCHASABLE``VIP_DOWNGRADE_NOT_ALLOWED``VIP_RECHARGE_REQUIRED``VIP_INSUFFICIENT_COIN`;体验卡使用 `VIP_TRIAL_CARD_NOT_FOUND``VIP_TRIAL_CARD_EXPIRED`;权益拒绝使用 `VIP_BENEFIT_REQUIRED`,普通房管防踢/防禁言分别使用 `VIP_ANTI_KICK``VIP_ANTI_MUTE`;返现使用 `VIP_COIN_REBATE_NOT_FOUND``VIP_COIN_REBATE_EXPIRED`。客户端不得解析 message。返现重复领取沿用幂等成功回执不额外定义 already-claimed 错误。
## 权益落地边界 ## 权益落地边界
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。 当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。

View File

@ -198,6 +198,18 @@ Content-Type: application/json
- `room_rps_reveal_countdown` - `room_rps_reveal_countdown`
- `room_rps_finished` - `room_rps_finished`
当挑战到达 `timeout_at_ms` 后,应战接口返回 HTTP `409`
```json
{
"code": "ROOM_RPS_CHALLENGE_EXPIRED",
"message": "猜拳已过期",
"request_id": "req_xxx"
}
```
Flutter 必须按 `code` 识别该状态,关闭应战弹窗并提示“猜拳已过期”;不要依赖 `message` 判断业务类型。
## 7. 查询挑战详情 ## 7. 查询挑战详情
地址: 地址:
@ -319,4 +331,3 @@ IM 示例:
} }
} }
``` ```

View File

@ -1,6 +1,6 @@
# 用户排行榜 Flutter 对接 # 用户排行榜 Flutter 对接
本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 `wallet-service` 已成功的礼物扣费流水;客户端只展示 gateway 返回的榜单投影,不在本地重算排名、礼物价值或统计窗口。 本文描述 Flutter App 对接财富榜、魅力榜、游戏榜和房间榜的 HTTP 接口。`sent/received` 来自钱包已提交的礼物事实,`game` 来自游戏成功扣币的 `game_spend_coin` 事实,`room` 来自 room-service 的房间收礼投影;客户端只展示 gateway 返回的榜单,不在本地重算排名或统计窗口。
## 基础约定 ## 基础约定
@ -49,7 +49,7 @@ Query 参数:
| 字段 | 类型 | 必填 | 默认 | 说明 | | 字段 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼榜;`received` 是收礼榜;`room` 是房间收礼榜。 | | `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼财富榜;`received` 是收礼魅力榜;`game` 是游戏消费榜;`room` 是房间收礼榜。 |
| `period` | string | 否 | `today` | 统计周期。`today``week``month`。 | | `period` | string | 否 | `today` | 统计周期。`today``week``month`。 |
| `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 | | `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 |
| `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0服务端最大返回 `100`。 | | `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0服务端最大返回 `100`。 |
@ -60,6 +60,7 @@ Query 参数:
| --- | --- | --- | | --- | --- | --- |
| `sent` | 空字符串、`send``sender``gift_sent``user_sent` | 按送礼用户聚合,展示用户送礼贡献。 | | `sent` | 空字符串、`send``sender``gift_sent``user_sent` | 按送礼用户聚合,展示用户送礼贡献。 |
| `received` | `receive``receiver``gift_received``user_received` | 按收礼用户聚合,展示用户收礼魅力。 | | `received` | `receive``receiver``gift_received``user_received` | 按收礼用户聚合,展示用户收礼魅力。 |
| `game` | `games``gaming` | 按用户聚合成功游戏扣币额;不累计派奖、退款或净输赢。 |
| `room` | `rooms``room_gift``room_gifts` | 按房间聚合,展示房间礼物流水。 | | `room` | `rooms``room_gift``room_gifts` | 按房间聚合,展示房间礼物流水。 |
`period` 兼容别名: `period` 兼容别名:
@ -70,7 +71,7 @@ Query 参数:
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 | | `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
| `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 | | `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 |
服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。排序固定为 `gift_value DESC, last_gift_at_ms DESC, subject_id ASC`;礼物价值相同的情况下,最近送礼时间更晚的排名更靠前,再用用户 ID 或房间 ID 保证稳定顺序 服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。用户榜按窗口累计值倒序;`game` 的累计值是 `game_spend_coin`,为兼容既有 JSON 暂时仍通过 `gift_value` 返回。房间榜由 room-service 按同一周期返回
### 送礼榜 ### 送礼榜
@ -92,6 +93,16 @@ X-App-Code: lalu
收礼榜按 `target_user_id` 聚合。返回项里的 `user_id``user` 表示收礼用户。 收礼榜按 `target_user_id` 聚合。返回项里的 `user_id``user` 表示收礼用户。
### 游戏榜
```http
GET /api/v1/activities/user-leaderboards?board_type=game&period=today&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: huwaa
```
游戏榜按成功扣币订单的 `user_id` 聚合 `game_spend_coin`。只有 `op_type=debit` 且扣币成功、金额大于 0 的订单进入榜单;派奖、退款和净输赢不会改变游戏榜积分。返回项中的 `gift_value` 是兼容字段,游戏榜场景表示累计游戏扣币额。
### 房间榜 ### 房间榜
```http ```http
@ -104,7 +115,7 @@ X-App-Code: lalu
## 房间内贡献榜边界 ## 房间内贡献榜边界
本接口是全局活动榜,按 `board_type + period` 查询成功送礼流水。房间页里的当前房间送礼贡献榜不是通过本接口读取: 本接口是全局活动榜,按 `board_type + period` 查询对应的用户或房间聚合。房间页里的当前房间送礼贡献榜不是通过本接口读取:
| 场景 | 接口 | 榜单字段 | 说明 | | 场景 | 接口 | 榜单字段 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -210,24 +221,24 @@ X-App-Code: lalu
| `total` | int64 | 当前榜单和周期下的总上榜主体数。 | | `total` | int64 | 当前榜单和周期下的总上榜主体数。 |
| `page` | int32 | 服务端解析后的页码。 | | `page` | int32 | 服务端解析后的页码。 |
| `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 | | `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 |
| `board_type` | string | 服务端归一化后的榜单类型:`sent``received``room`。 | | `board_type` | string | 服务端归一化后的榜单类型:`sent``received``game`、`room`。 |
| `period` | string | 服务端归一化后的统计周期:`today``week``month`。 | | `period` | string | 服务端归一化后的统计周期:`today``week``month`。 |
| `start_at_ms` | int64 | 本次统计窗口开始时间UTC。 | | `start_at_ms` | int64 | 本次统计窗口开始时间UTC。 |
| `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 | | `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 |
| `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 | | `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 |
| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名;未上榜或 `board_type=room` 时不返回。 | | `my_rank` | object? | 当前登录用户在送礼榜、收礼榜或游戏榜中的排名;未上榜或 `board_type=room` 时不返回。 |
`items[]` / `my_rank` 字段: `items[]` / `my_rank` 字段:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `rank` | int64 | 排名,从 1 开始。 | | `rank` | int64 | 排名,从 1 开始。 |
| `user_id` | string | 用户榜单主体 ID`sent``received` 才有。 | | `user_id` | string | 用户榜单主体 ID`sent``received``game` 才有。 |
| `room_id` | string | 房间榜单主体 ID`room` 才有。 | | `room_id` | string | 房间榜单主体 ID`room` 才有。 |
| `gift_value` | int64 | 统计窗口内礼物价值总和,用于排名和主数值展示。 | | `gift_value` | int64 | 排名累计值。`sent/received/room` 表示礼物价值;`game` 表示成功游戏扣币额。 |
| `gift_count` | int64 | 统计窗口内礼物数量总和。 | | `gift_count` | int64 | 礼物榜的礼物数量总和;`game` 固定为 0。 |
| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 | | `transaction_count` | int64 | 用户榜的成功事实数;`game` 表示成功扣币订单数。 |
| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 | | `last_gift_at_ms` | int64 | 最近一次计榜事实时间;`game` 表示最近一次成功游戏扣币时间。字段名为兼容既有协议保留。 |
| `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 `user_id`。 | | `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 `user_id`。 |
| `room` | object? | 房间基础投影;房间榜返回 `room_id``room_short_id``title``cover_url``room_avatar`。 | | `room` | object? | 房间基础投影;房间榜返回 `room_id``room_short_id``title``cover_url``room_avatar`。 |
@ -415,7 +426,7 @@ class LeaderboardRoom {
请求封装示例: 请求封装示例:
```dart ```dart
enum LeaderboardType { sent, received, room } enum LeaderboardType { sent, received, game, room }
enum LeaderboardPeriod { today, week, month } enum LeaderboardPeriod { today, week, month }
extension on LeaderboardType { extension on LeaderboardType {
@ -493,15 +504,15 @@ class ApiException implements Exception {
| `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 | | `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 |
| `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 | | `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 |
| `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 | | `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 |
| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 | | `502` | `UPSTREAM_ERROR` | 榜单 Redis、用户资料、钱包装扮或房间上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 |
| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败,记录 `request_id`。 | | `503` | `UPSTREAM_ERROR` | gateway 未配置榜单 Redis 读模型。 | 展示通用失败,记录 `request_id`。 |
## 客户端展示规则 ## 客户端展示规则
榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total` 榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total`
`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。 `my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”,游戏榜里是“我的游戏消费排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。
时间展示用 `server_time_ms``end_at_ms` 标注“更新于”。`today``week``month` 的统计口径固定为 UTC展示文案如果需要本地化只能改时间格式不能改变请求周期含义。 时间展示用 `server_time_ms``end_at_ms` 标注“更新于”。`today``week``month` 的统计口径固定为 UTC展示文案如果需要本地化只能改时间格式不能改变请求周期含义。
榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。 榜单数据当前没有单独 IM 推送。送礼或游戏扣币后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。

View File

@ -51,6 +51,7 @@
- `gesture``rock` / `paper` / `scissors` - `gesture``rock` / `paper` / `scissors`
- 返回值: - 返回值:
- `challenge`:应战后的挑战单 - `challenge`:应战后的挑战单
- 超时错误HTTP `409``code=ROOM_RPS_CHALLENGE_EXPIRED`。客户端应关闭当前挑战弹窗并提示“猜拳已过期”。
- 相关 IM - 相关 IM
- `room_rps_challenge_accepted`:服务端通知房间挑战已被锁定,带发起人和应战人头像昵称。 - `room_rps_challenge_accepted`:服务端通知房间挑战已被锁定,带发起人和应战人头像昵称。
- `room_rps_reveal_countdown`:服务端通知双方展示 3 秒倒计时,带发起人和应战人头像昵称。 - `room_rps_reveal_countdown`:服务端通知双方展示 3 秒倒计时,带发起人和应战人头像昵称。

View File

@ -8,6 +8,7 @@
- 默认统计时区是 `UTC`;传 `stat_tz=Asia/Shanghai` 时按中国自然日查询统计读模型。 - 默认统计时区是 `UTC`;传 `stat_tz=Asia/Shanghai` 时按中国自然日查询统计读模型。
- 国家和区域只使用事件里的国家/区域字段或统计服务保存的用户维度不用服务器所在地、IP 或本地时区推断。 - 国家和区域只使用事件里的国家/区域字段或统计服务保存的用户维度不用服务器所在地、IP 或本地时区推断。
- 查询只读 `statistics-service` 聚合表,不回查 wallet、room、game、user owner 明细表。 - 查询只读 `statistics-service` 聚合表,不回查 wallet、room、game、user owner 明细表。
- 运营宽表的 DAU 统一读取 `stat_social_user_day`,再用 `stat_social_identity_day` 把同一登录会话内的 `d:<device_id>` 临时归并到 `u:<user_id>`;维度筛选和国家/区域明细均在归并后计算。
- `coin_total` 为兼容前端保留 JSON 字段名,展示名改为“用户金币数”。 - `coin_total` 为兼容前端保留 JSON 字段名,展示名改为“用户金币数”。
## 字段口径 ## 字段口径
@ -19,7 +20,7 @@
| `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 | | `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 |
| `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 | | `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 |
| `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 | | `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 |
| `active_users` | 活跃用户 | `RoomUserJoined`、`GameOrderSettled` 和注册当天活跃写入 `stat_user_day_activity` 后按用户去重。 | | `active_users` | 活跃用户 | `stat_social_user_day.active_user` 经设备日级 canonical 身份归并后的用户日去重数;多日汇总为活跃人天。 |
| `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 | | `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 |
| `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 | | `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 |
| `recharge_usd_minor` | 总充值 | `WalletRechargeRecorded.usd_minor_amount` 累加,单位是美元最小单位。 | | `recharge_usd_minor` | 总充值 | `WalletRechargeRecorded.usd_minor_amount` 累加,单位是美元最小单位。 |
@ -64,7 +65,7 @@
| `super_lucky_gift_payout` | super lucky 返奖 | `pool_id` 包含 `super_lucky` 的奖池返奖。 | | `super_lucky_gift_payout` | super lucky 返奖 | `pool_id` 包含 `super_lucky` 的奖池返奖。 |
| `super_lucky_gift_profit` | super lucky 利润 | `super_lucky_gift_turnover - super_lucky_gift_payout`。 | | `super_lucky_gift_profit` | super lucky 利润 | `super_lucky_gift_turnover - super_lucky_gift_payout`。 |
| `super_lucky_gift_profit_rate` | super lucky 利润率 | `super_lucky_gift_profit / super_lucky_gift_turnover`。 | | `super_lucky_gift_profit_rate` | super lucky 利润率 | `super_lucky_gift_profit / super_lucky_gift_turnover`。 |
| `arpu_usd_minor` | ARPU | `recharge_usd_minor / active_users`。 | | `arpu_usd_minor` | ARPU | `recharge_usd_minor / canonical active_users`。 |
| `arppu_usd_minor` | ARPPU | `recharge_usd_minor / paid_users`。 | | `arppu_usd_minor` | ARPPU | `recharge_usd_minor / paid_users`。 |
| `up_value_usd_minor` | UP 值 | 当前与 ARPPU 同口径。 | | `up_value_usd_minor` | UP 值 | 当前与 ARPPU 同口径。 |
@ -79,9 +80,9 @@
| 字段 | 口径 | | 字段 | 口径 |
| --- | --- | | --- | --- |
| `retention` | `registered_users` 为注册 cohort`day1/day7/day30_users` 为注册后第 1/7/30 天有活跃的用户rate 为对应用户数除以注册用户数。 | | `retention` | `registered_users` 为注册 cohort`day1/day7/day30_users` 为注册后第 1/7/30 天有活跃的用户rate 为对应用户数除以注册用户数。 |
| `daily_series` | 按日返回本页主要字段;`coin_total``salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 | | `daily_series` | 按日返回本页主要字段;DAU 使用 canonical Social`coin_total``salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;`coin_total``salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 | | `country_breakdown` | 按国家返回本页主要字段;DAU 维度在 canonical 归并后确定,`coin_total``salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 |
| `daily_country_breakdown` | 按日 + 国家返回主要字段,用于国家弹窗明细。 | | `daily_country_breakdown` | 按日 + 国家返回主要字段,DAU 与顶部/按日使用同一 canonical Social 数据源。 |
| `game_ranking` | 按 `platform_code/game_id` 聚合游戏流水、返奖、退款、玩家数、利润和利润率。 | | `game_ranking` | 按 `platform_code/game_id` 聚合游戏流水、返奖、退款、玩家数、利润和利润率。 |
| `lucky_gift_pools` | 按 `pool_id` 聚合 lucky 礼物流水、返奖、利润、返奖率和利润率。 | | `lucky_gift_pools` | 按 `pool_id` 聚合 lucky 礼物流水、返奖、利润、返奖率和利润率。 |
| `report_metric_sources` | 返回字段来源和口径说明,供前端展示或排查。 | | `report_metric_sources` | 返回字段来源和口径说明,供前端展示或排查。 |
@ -92,3 +93,4 @@
- 平台发放的礼物价值同理,需要 `ResourceGranted/ResourceGroupGranted` 事件携带可统计的资源金币价值和来源;当前只可靠计入 COIN 入账和已存在的 `Wallet*RewardCredited` 金币奖励。 - 平台发放的礼物价值同理,需要 `ResourceGranted/ResourceGroupGranted` 事件携带可统计的资源金币价值和来源;当前只可靠计入 COIN 入账和已存在的 `Wallet*RewardCredited` 金币奖励。
- 装扮价值不进入 `platform_grant_coin``output_coin` - 装扮价值不进入 `platform_grant_coin``output_coin`
- 后台人工发放只进入 `manual_grant_coin`,不进入 `platform_grant_coin` - 后台人工发放只进入 `manual_grant_coin`,不进入 `platform_grant_coin`
- `retention` 仍是注册 cohort 对 `stat_user_day_activity` 的回看口径,不属于运营宽表 DAU 分母Social 数据需求页的用户留存则使用 canonical Social 用户日口径。

View File

@ -35,6 +35,20 @@ class VipEntitlementState {
return has('online_global_notice') && return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled; settings.onlineGlobalNoticeEnabled;
} }
bool allowsHideProfileData() {
return has('hide_profile_data') && settings.hideProfileDataEnabled;
}
bool allowsAnonymousProfileVisit() {
return has('anonymous_profile_visit') &&
settings.anonymousProfileVisitEnabled;
}
bool allowsLeaderboardInvisible() {
return has('leaderboard_invisible') &&
settings.leaderboardInvisibleEnabled;
}
} }
``` ```
@ -52,6 +66,8 @@ class VipEntitlementState {
| `config_version` 不一致 | 用户权限以 `/vip/me.state` 为准,重新请求 `/vip/packages`,不得合并不同版本的权益数组 | | `config_version` 不一致 | 用户权限以 `/vip/me.state` 为准,重新请求 `/vip/packages`,不得合并不同版本的权益数组 |
| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 | | VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 |
购买、佩戴体验卡、卸下体验卡成功时,`data.state``GET /api/v1/vip/me``data.state` **完全同构**不是局部补丁。Flutter 必须整体替换 VIP store禁止按字段合并。若灰度期间收到缺少下文任一顶层字段的旧节点响应应放弃该响应中的 `state` 并立即重拉 `/vip/me`
### 1.4 Fami 初始等级权益 ### 1.4 Fami 初始等级权益
下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。 下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。
@ -89,6 +105,52 @@ class VipEntitlementState {
- `request_id` 只用于链路追踪。 - `request_id` 只用于链路追踪。
- 带 `command_id` 的接口,网络重试必须复用原 `command_id`;新的业务动作必须生成新值。 - 带 `command_id` 的接口,网络重试必须复用原 `command_id`;新的业务动作必须生成新值。
### 2.1 `VipState` 唯一结构
`GET /vip/me``GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回的 `state` 使用同一结构,固定包含以下 8 个顶层字段:
| 字段 | 含义 |
|---|---|
| `paid_vip` | 当前付费 VIP没有时为 `null` |
| `equipped_trial_card` | 当前佩戴的体验卡;没有时为 `null` |
| `effective_vip` | 付费 VIP 与体验卡合并后的当前展示身份 |
| `effective_source` | `paid``trial``none` |
| `effective_benefits` | 服务端计算后的最终权益集合 |
| `evaluated_at_ms` | 本次状态计算时间 |
| `program_config` | 本次计算使用的完整 App VIP 配置 |
| `user_settings` | 当前 App 下完整的 5 项用户开关 |
`paid_vip``effective_vip` 非空时都返回完整 `UserVip` 字段:`user_id``level``name``active``started_at_ms``expires_at_ms``updated_at_ms``program_type``config_version``program_config` 返回完整字段,不允许写接口只返回 `app_code/program_type` 的裁剪对象。
### 2.2 特权展示协议
每项 `VipBenefit` 除执行字段外提供结构化 `presentation`Flutter 不解析 `metadata_json`,也不按 `benefit_code` 维护隐形展示配置:
```json
{
"description": "进入房间时展示动态背景和用户头像",
"preview_items": [
{
"preview_id": "room_background_gold",
"title": "鎏金房间背景",
"media_type": "animation",
"asset_url": "https://cdn.example/vip/room-bg.png",
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
"animation_url": "https://cdn.example/vip/room-bg.svga",
"composition_type": "user_avatar_center",
"sort_order": 10
}
],
"numeric_reward": null
}
```
- `media_type``image``animation`;动态资源加载失败时依次使用 `preview_url``asset_url`
- `composition_type``none``user_avatar_center``user_avatar_waveform`,分别表示无合成、头像居中、头像与声波纹合成。
- `preview_items` 可返回多项,按 `sort_order` 升序、`preview_id` 去重。
- `numeric_reward` 用于数值权益,字段为 `label/value/unit/period`,其中 `period``once/daily/monthly`。该字段只用于展示,结算仍以后端接口为准。
- 没有富预览时 `presentation``null`Flutter 回退展示权益名称和通用图标。
## 3. VIP 套餐 ## 3. VIP 套餐
### 接口地址 ### 接口地址
@ -144,7 +206,23 @@ class VipEntitlementState {
"execution_scope": "room", "execution_scope": "room",
"auto_equip": false, "auto_equip": false,
"sort_order": 110, "sort_order": 110,
"metadata_json": "" "metadata_json": "",
"presentation": {
"description": "进入房间时展示动态背景和用户头像",
"preview_items": [
{
"preview_id": "room_background_gold",
"title": "鎏金房间背景",
"media_type": "animation",
"asset_url": "https://cdn.example/vip/room-bg.png",
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
"animation_url": "https://cdn.example/vip/room-bg.svga",
"composition_type": "user_avatar_center",
"sort_order": 10
}
],
"numeric_reward": null
}
} }
], ],
"config_version": 7 "config_version": 7
@ -165,17 +243,27 @@ class VipEntitlementState {
}, },
"state": { "state": {
"paid_vip": { "paid_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true, "active": true,
"expires_at_ms": 1782592000000 "started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"equipped_trial_card": null, "equipped_trial_card": null,
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true, "active": true,
"expires_at_ms": 1782592000000 "started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_source": "paid", "effective_source": "paid",
"effective_benefits": [ "effective_benefits": [
@ -185,9 +273,27 @@ class VipEntitlementState {
} }
], ],
"evaluated_at_ms": 1780000000000, "evaluated_at_ms": 1780000000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": { "user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": true, "online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0 "updated_at_ms": 0
} }
} }
@ -235,11 +341,13 @@ class VipEntitlementState {
}, },
"state": { "state": {
"paid_vip": { "paid_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true, "active": true,
"started_at_ms": 1780000000000, "started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000, "expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"config_version": 7 "config_version": 7
}, },
@ -255,14 +363,20 @@ class VipEntitlementState {
"duration_ms": 2592000000, "duration_ms": 2592000000,
"effective_at_ms": 1780100000000, "effective_at_ms": 1780100000000,
"expires_at_ms": 1782692000000, "expires_at_ms": 1782692000000,
"remaining_duration_ms": 2591000000 "remaining_duration_ms": 2591000000,
"grant_source": "admin",
"source_grant_id": "grant_xxx",
"created_at_ms": 1780100000000,
"updated_at_ms": 1780100000000
}, },
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 8, "level": 8,
"name": "VIP8", "name": "VIP8",
"active": true, "active": true,
"started_at_ms": 1780100000000, "started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000, "expires_at_ms": 1782692000000,
"updated_at_ms": 1780100000000,
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"config_version": 7 "config_version": 7
}, },
@ -279,13 +393,22 @@ class VipEntitlementState {
"execution_scope": "room", "execution_scope": "room",
"auto_equip": false, "auto_equip": false,
"sort_order": 290, "sort_order": 290,
"metadata_json": "" "metadata_json": "",
"presentation": null
} }
], ],
"evaluated_at_ms": 1780101000000, "evaluated_at_ms": 1780101000000,
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7 "config_version": 7
}, },
"user_settings": { "user_settings": {
@ -293,6 +416,9 @@ class VipEntitlementState {
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": true, "online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0 "updated_at_ms": 0
} }
} }
@ -346,35 +472,81 @@ class VipEntitlementState {
}, },
"coin_spent": 4000, "coin_spent": 4000,
"coin_balance_after": 6000, "coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [], "reward_items": [],
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now", "upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7 "config_version": 7
}, },
"state": { "state": {
"effective_source": "paid",
"paid_vip": { "paid_vip": {
"user_id": 10001,
"level": 4, "level": 4,
"name": "VIP4", "name": "VIP4",
"active": true, "active": true,
"expires_at_ms": 1782692000000 "started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"updated_at_ms": 1780100000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"equipped_trial_card": null, "equipped_trial_card": null,
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 4, "level": 4,
"name": "VIP4", "name": "VIP4",
"active": true, "active": true,
"expires_at_ms": 1782692000000 "started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"updated_at_ms": 1780100000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_source": "paid",
"effective_benefits": [ "effective_benefits": [
{ {
"benefit_code": "custom_room_background", "benefit_code": "custom_room_background",
"status": "active" "status": "active"
} }
], ],
"evaluated_at_ms": 1780100000000 "evaluated_at_ms": 1780100000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
}
} }
} }
} }
@ -384,6 +556,8 @@ class VipEntitlementState {
- Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。 - Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
- 低等级购买:服务端拒绝。 - 低等级购买:服务端拒绝。
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state` - 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`
- 钱包缓存只使用 `coin_balance`。应用时比较 `version`,低版本响应不得覆盖本地高版本余额;`coin_balance_after` 仅保留旧客户端兼容。
- 历史幂等订单在升级前没有记录版本时可能返回 `coin_balance.version=0`Flutter 必须调用现有钱包余额接口刷新,不得把版本 0 写入余额 store。
## 6. VIP 体验卡 ## 6. VIP 体验卡
@ -473,22 +647,92 @@ class VipEntitlementState {
"duration_ms": 1728000000, "duration_ms": 1728000000,
"effective_at_ms": 1780200000000, "effective_at_ms": 1780200000000,
"expires_at_ms": 1781928000000, "expires_at_ms": 1781928000000,
"remaining_duration_ms": 1700000000 "remaining_duration_ms": 1700000000,
"grant_source": "admin",
"source_grant_id": "grant_b",
"created_at_ms": 1780200000000,
"updated_at_ms": 1780200000000
}, },
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1", "program_type": "tiered_privilege_v1",
"trial_card_enabled": true "level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
}, },
"state": { "state": {
"effective_source": "trial", "paid_vip": {
"user_id": 10001,
"level": 3,
"name": "VIP3",
"active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"equipped_trial_card": {
"trial_card_id": "vip_trial_card_b",
"entitlement_id": "ent_card_b",
"resource_id": 102,
"user_id": 10001,
"level": 4,
"name": "VIP4",
"status": "active",
"equipped": true,
"duration_ms": 1728000000,
"effective_at_ms": 1780200000000,
"expires_at_ms": 1781928000000,
"remaining_duration_ms": 1700000000,
"grant_source": "admin",
"source_grant_id": "grant_b",
"created_at_ms": 1780200000000,
"updated_at_ms": 1780200000000
},
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 4, "level": 4,
"name": "VIP4", "name": "VIP4",
"active": true, "active": true,
"expires_at_ms": 1781928000000 "started_at_ms": 1780200000000,
"expires_at_ms": 1781928000000,
"updated_at_ms": 1780200000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_benefits": [] "effective_source": "trial",
"effective_benefits": [],
"evaluated_at_ms": 1780228000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
}
}, },
"server_time_ms": 1780228000000 "server_time_ms": 1780228000000
} }
@ -516,22 +760,67 @@ class VipEntitlementState {
"unequipped": true, "unequipped": true,
"program_config": { "program_config": {
"app_code": "fami", "app_code": "fami",
"program_type": "tiered_privilege_v1" "program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
}, },
"state": { "state": {
"effective_source": "paid",
"paid_vip": { "paid_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true "active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"equipped_trial_card": null, "equipped_trial_card": null,
"effective_vip": { "effective_vip": {
"user_id": 10001,
"level": 3, "level": 3,
"name": "VIP3", "name": "VIP3",
"active": true "active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"updated_at_ms": 1780000000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
}, },
"effective_benefits": [] "effective_source": "paid",
"effective_benefits": [],
"evaluated_at_ms": 1780229000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 0
}
}, },
"server_time_ms": 1780229000000 "server_time_ms": 1780229000000
} }
@ -543,7 +832,7 @@ class VipEntitlementState {
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。 - 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。 - 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
## 7. VIP 通知开关 ## 7. VIP 功能开关
### 7.1 查询开关 ### 7.1 查询开关
@ -568,6 +857,9 @@ class VipEntitlementState {
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": true, "room_entry_notice_enabled": true,
"online_global_notice_enabled": false, "online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000 "updated_at_ms": 1780300000000
}, },
"evaluated_at_ms": 1780300001000 "evaluated_at_ms": 1780300001000
@ -588,7 +880,10 @@ class VipEntitlementState {
```json ```json
{ {
"room_entry_notice_enabled": false, "room_entry_notice_enabled": false,
"online_global_notice_enabled": true "online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true
} }
``` ```
@ -605,6 +900,9 @@ class VipEntitlementState {
"user_id": 10001, "user_id": 10001,
"room_entry_notice_enabled": false, "room_entry_notice_enabled": false,
"online_global_notice_enabled": true, "online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300010000 "updated_at_ms": 1780300010000
}, },
"server_time_ms": 1780300010000 "server_time_ms": 1780300010000
@ -612,6 +910,8 @@ class VipEntitlementState {
} }
``` ```
5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时均默认 `true`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定Flutter 不能只看开关。
## 8. 每日 VIP 金币返现 ## 8. 每日 VIP 金币返现
### 8.1 查询当前返现 ### 8.1 查询当前返现
@ -1050,3 +1350,35 @@ Flutter 将 `action_param` 解析为 JSON打开页面后调用 `/vip/coin-reb
接口:`GET /api/v1/im/usersig` 接口:`GET /api/v1/im/usersig`
Flutter 使用返回的 UserSig 登录腾讯云 IM并加入响应 `join_groups``type=global_broadcast` 的群组后,再消费 `vip_online_notice` Flutter 使用返回的 UserSig 登录腾讯云 IM并加入响应 `join_groups``type=global_broadcast` 的群组后,再消费 `vip_online_notice`
## 13. 稳定业务错误码
失败仍使用统一 envelopeFlutter 只判断 `code`,不得解析 `message`
```json
{
"code": "VIP_INSUFFICIENT_COIN",
"message": "insufficient coin balance",
"request_id": "req_purchase",
"data": null
}
```
| HTTP | `code` | 场景 | Flutter 处理 |
|---:|---|---|---|
| 409 | `VIP_PROGRAM_INACTIVE` | 当前 App 的 VIP 体系未启用 | 关闭购买/体验卡操作并刷新配置 |
| 409 | `VIP_PACKAGE_NOT_PURCHASABLE` | 套餐不存在、停用或不在当前体系范围 | 刷新 `/vip/packages` |
| 409 | `VIP_DOWNGRADE_NOT_ALLOWED` | 购买等级低于当前付费等级 | 提示不可降级,刷新 `/vip/me` |
| 409 | `VIP_RECHARGE_REQUIRED` | 未达到套餐充值门槛 | 展示充值引导 |
| 409 | `VIP_INSUFFICIENT_COIN` | 购买金币余额不足 | 展示金币充值入口 |
| 404 | `VIP_TRIAL_CARD_NOT_FOUND` | 卡不存在、不属于本人或背包权益已撤销 | 刷新体验卡列表和 `/vip/me` |
| 409 | `VIP_TRIAL_CARD_EXPIRED` | 卡或背包权益已越过绝对截止时间 | 刷新体验卡列表和 `/vip/me` |
| 403 | `VIP_BENEFIT_REQUIRED` | 操作需要的权益未生效或被用户关闭 | 刷新 `/vip/me`,保持入口锁态 |
| 403 | `VIP_ANTI_KICK` | 普通房主/管理员踢人被目标用户防踢拦截 | 提示目标不可被踢出,不改变房间本地状态 |
| 403 | `VIP_ANTI_MUTE` | 普通房主/管理员禁言被目标用户防禁言拦截 | 提示目标不可被禁言,不改变房间本地状态 |
| 404 | `VIP_COIN_REBATE_NOT_FOUND` | 返现不存在或不属于当前用户/App | 刷新返现列表 |
| 409 | `VIP_COIN_REBATE_EXPIRED` | 已越过 UTC 次日 0 点的领取结束边界 | 标记过期并刷新返现列表 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 被用于不同业务参数 | 停止重试,为新动作生成新 ID |
| 409 | `LEDGER_CONFLICT` | 钱包并发版本冲突 | 刷新余额和 `/vip/me` 后由用户重新发起 |
返现重复领取采用幂等成功语义:相同 `command_id` 重试返回首次成功回执,不返回 `VIP_REBATE_ALREADY_CLAIMED`。因此客户端不应实现该错误分支。

View File

@ -73,7 +73,8 @@ flowchart LR
| 指标 | 口径 | | 指标 | 口径 |
| --- | --- | | --- | --- |
| 新增用户 | `UserRegistered` 按 UTC 日、`app_code`、国家维度聚合 | | 新增用户 | `UserRegistered` 按 UTC 日、`app_code`、国家维度聚合 |
| 活跃用户 | `RoomUserJoined``GameOrderSettled` 写入 `stat_user_day_activity`,按用户去重 | | 运营宽表活跃用户 | `stat_social_user_day.active_user` 先经 `stat_social_identity_day` 完成设备→账号日级归并,再按 canonical 用户日去重 |
| 注册 cohort 留存活跃 | `RoomUserJoined``GameOrderSettled` 写入 `stat_user_day_activity`,按用户去重 |
| 次日留存 | 注册 cohort 中,注册日 + 1 天存在活跃记录的用户数 / 注册用户数 | | 次日留存 | 注册 cohort 中,注册日 + 1 天存在活跃记录的用户数 / 注册用户数 |
| 7 日留存 | 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数 | | 7 日留存 | 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数 |
| 30 日留存 | 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数 | | 30 日留存 | 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数 |
@ -129,6 +130,8 @@ flowchart LR
| `statistics_event_consumption` | 消费幂等表 | `(app_code, source, event_id)` | | `statistics_event_consumption` | 消费幂等表 | `(app_code, source, event_id)` |
| `stat_app_day_country` | App/UTC 日/国家总览 | `(app_code, stat_day, country_id)` | | `stat_app_day_country` | App/UTC 日/国家总览 | `(app_code, stat_day, country_id)` |
| `stat_user_day_activity` | 用户日活跃去重 | `(app_code, stat_day, user_id)` | | `stat_user_day_activity` | 用户日活跃去重 | `(app_code, stat_day, user_id)` |
| `stat_social_user_day` | Social 用户/匿名设备日级宽表 | `(app_code, stat_tz, stat_day, subject_key)` |
| `stat_social_identity_day` | 同日匿名设备的 canonical 登录账号解析 | `(app_code, stat_tz, stat_day, device_id)` |
| `stat_user_registration` | 注册 cohort | `(app_code, user_id)` | | `stat_user_registration` | 注册 cohort | `(app_code, user_id)` |
| `stat_recharge_day_payers` | 日付费用户去重 | `(app_code, stat_day, user_id)` | | `stat_recharge_day_payers` | 日付费用户去重 | `(app_code, stat_day, user_id)` |
| `stat_lucky_gift_day_payers` | 幸运礼物日付费用户去重 | `(app_code, stat_day, country_id, user_id)` | | `stat_lucky_gift_day_payers` | 幸运礼物日付费用户去重 | `(app_code, stat_day, country_id, user_id)` |

View File

@ -84,8 +84,8 @@
- 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。 - 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。
- 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` - 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`
- 提现申请必须先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`审核拒绝解冻,审核通过后进入人工打款流程 - 提现申请必须先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`再依次经过运营初审和财务终审。运营通过只转交财务,不改 frozen任一阶段拒绝都释放 frozen
- 人工打款完成后从冻结余额出账;打款失败回到可重试状态,不能自动解冻后丢失审核上下文。 - 财务确认人工打款凭证并通过终审后从冻结余额出账;外部动作失败时保留当前审核阶段,依靠阶段固定幂等命令重试,不能自动解冻后丢失审核上下文。
## Component Diagram ## Component Diagram
@ -333,25 +333,34 @@ flowchart LR
1. 送礼时钱包给主播增加 `GIFT_POINT` 1. 送礼时钱包给主播增加 `GIFT_POINT`
2. 结算任务读取积分和政策,生成 `anchor_reward_settlements` 2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`
3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`
4. 主播提现时先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,审核拒绝解冻,审核通过后等待人工转账。 4. 主播提现时先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,申请进入运营初审。
5. 人工转账完成后把冻结余额出账,状态改为 `paid` 5. 运营通过只把申请转入财务终审,冻结金额保持不变;运营拒绝立即释放 frozen 并结束申请。
6. 财务结合人工转账凭证终审:通过后扣减 frozen拒绝后释放 frozen。
提现状态机: 提现状态机:
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending_review [*] --> operations_pending
pending_review --> rejected operations_pending --> operations_rejecting
rejected --> refunded operations_rejecting --> operations_rejecting: retry rejection
pending_review --> approved operations_rejecting --> operations_rejected: release + notice + finalize
approved --> paying operations_rejected --> released
paying --> paid operations_pending --> finance_pending
paying --> pay_failed finance_pending --> finance_rejected
pay_failed --> approved finance_rejected --> released
finance_pending --> approved
approved --> settled
``` ```
提现申请时必须从 `available_amount` 转到 `frozen_amount`,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。 提现申请时必须从 `available_amount` 转到 `frozen_amount`,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。
运营拒绝先在 admin 独立短事务把 `operations_status``pending` 改为 `rejecting`,固定第一次审核人、原因和 operations stage command再执行 wallet release 与幂等通知,最后以第二个短事务收敛为 operations/overall 双重 `rejected`。因此 release 已成功但通知或最终提交失败时,申请仍保持 `rejecting`,只能重试拒绝,不能改点运营通过或进入财务。
运营/财务审核的资金幂等边界是 `(app_code, withdrawal_application_id)`,不是审核人、备注或单一版本的 command id。`wallet_withdrawal_terminal_locks` 先串行化同一申请的 settle/release再通过 `wallet_transactions.external_ref` 读取已有终局:同决策换审核人或修改备注时返回原回执,相反决策返回 `IDEMPOTENCY_CONFLICT`。这同时兼容旧 `salary-withdrawal:<id>:approved|rejected` 和新 `salary-withdrawal:<id>:finance` command财务通知继续使用 `finance-withdrawal:<id>:<decision>` 事件 ID避免旧通知已成功但 admin 未终态时重复发送。
新审核流程不能与旧 admin 混跑发布:先执行 wallet 终局锁迁移并完成 wallet-service 滚动,再摘流旧 admin、执行 admin 102、部署新 admin恢复 admin 后才滚动 gateway。admin 102 只把执行时已存在且已经终审的历史行标记为 `operations_status=skipped`;尚未终审的存量申请和发布窗口内由旧 gateway 新建的申请都保持数据库默认 `pending`,必须先进入运营初审。
## RPC Surface ## RPC Surface
当前开发阶段直接使用新的 wallet RPC 契约: 当前开发阶段直接使用新的 wallet RPC 契约:
@ -365,7 +374,7 @@ stateDiagram-v2
- `ExchangeDiamond`: 钻石兑换金币或美元余额。 - `ExchangeDiamond`: 钻石兑换金币或美元余额。
- `CreditRewardBalance`: 结算任务给主播发美元余额奖励。 - `CreditRewardBalance`: 结算任务给主播发美元余额奖励。
- `CreateWithdrawRequest`: 主播提现申请并冻结余额。 - `CreateWithdrawRequest`: 主播提现申请并冻结余额。
- `ReviewWithdrawRequest`: 后台审核提现 - `ReviewWithdrawRequest`: 后台按运营初审、财务终审推进提现;运营通过不调用钱包,任一拒绝调用 release财务通过调用 settle
- `MarkWithdrawPaid`: 人工打款后确认出账。 - `MarkWithdrawPaid`: 人工打款后确认出账。
外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段不做历史兼容保留修改 `api/proto` 后必须运行 `make proto``go test ./...` 外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段不做历史兼容保留修改 `api/proto` 后必须运行 `make proto``go test ./...`
@ -500,8 +509,9 @@ stateDiagram-v2
提现: 提现:
- 提现申请成功后 `identity salary wallet.available_amount` 减少,`frozen_amount` 增加。 - 提现申请成功后 `identity salary wallet.available_amount` 减少,`frozen_amount` 增加。
- 审核拒绝后冻结金额回到 available。 - 运营审核通过后冻结金额保持不变,申请进入财务审核。
- 打款完成后 frozen 减少并写出账分录。 - 运营或财务拒绝后冻结金额回到 available。
- 财务确认打款并通过后 frozen 减少并写出账分录。
- 打款失败后状态可重试,不丢失冻结关系和审核上下文。 - 打款失败后状态可重试,不丢失冻结关系和审核上下文。
验证命令: 验证命令:
@ -535,6 +545,6 @@ docker compose config
- 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。 - 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。
- `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 是可提现负债,调账和奖励发放需要更高权限和审计。 - `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 是可提现负债,调账和奖励发放需要更高权限和审计。
- 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。 - 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态 - 提现人工打款前必须冻结余额并完成运营初审;运营通过不得提前扣减 frozen财务终审通过才 settle任一拒绝都 release
- 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。 - 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。 - 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。

View File

@ -1,4 +1,4 @@
// Package userleaderboard owns the Redis read model for App user gift rankings. // Package userleaderboard owns the Redis read model for App user rankings.
package userleaderboard package userleaderboard
import ( import (
@ -17,6 +17,7 @@ import (
const ( const (
BoardSent = "sent" BoardSent = "sent"
BoardReceived = "received" BoardReceived = "received"
BoardGame = "game"
PeriodToday = "today" PeriodToday = "today"
PeriodWeek = "week" PeriodWeek = "week"
@ -33,8 +34,8 @@ const (
var ErrNotConfigured = errors.New("user leaderboard redis is not configured") var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes. // Store keeps user leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only. // Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection.
type Store struct { type Store struct {
client *redis.Client client *redis.Client
keyPrefix string keyPrefix string
@ -52,6 +53,16 @@ type GiftEvent struct {
DirectGift bool DirectGift bool
} }
// GameEvent is the successful game debit fact used by the game-spend leaderboard.
// CoinSpent follows the existing game growth metric and deliberately excludes payout, refund and net-win values.
type GameEvent struct {
AppCode string
EventID string
UserID int64
CoinSpent int64
OccurredAtMS int64
}
// Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time. // Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
type Query struct { type Query struct {
AppCode string AppCode string
@ -119,14 +130,6 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
} }
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC() 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) updates := make([]leaderboardUpdate, 0, 6)
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} { for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates, updates = append(updates,
@ -134,17 +137,27 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt), s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
) )
} }
args = append(args, strconv.Itoa(len(updates))) return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.GiftValue, event.GiftCount, event.OccurredAtMS, 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)) // ApplyGameEvent projects one successful game debit into UTC day/week/month game buckets.
// The Redis hash keeps the legacy gift_* field names because the public leaderboard JSON already exposes them;
// for board_type=game, gift_value means game_spend_coin, gift_count stays zero and transaction_count counts debit orders.
func (s *Store) ApplyGameEvent(ctx context.Context, event GameEvent) (bool, error) {
if s == nil || s.client == nil {
return false, ErrNotConfigured
}
event = normalizeGameEvent(event)
if event.AppCode == "" || event.EventID == "" || event.UserID <= 0 || event.CoinSpent <= 0 {
return false, nil
} }
result, err := applyGiftEventScript.Run(ctx, s.client, keys, args...).Int64() occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
if err != nil { updates := make([]leaderboardUpdate, 0, 3)
return false, err for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates, s.updateFor(event.AppCode, BoardGame, period, event.UserID, occurredAt))
} }
return result == 1, nil return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.CoinSpent, 0, event.OccurredAtMS, updates)
} }
// List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings. // List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings.
@ -225,6 +238,8 @@ func NormalizeBoardType(raw string) string {
return BoardSent return BoardSent
case BoardReceived, "receive", "receiver", "gift_received", "user_received": case BoardReceived, "receive", "receiver", "gift_received", "user_received":
return BoardReceived return BoardReceived
case BoardGame, "games", "gaming":
return BoardGame
default: default:
return "" return ""
} }
@ -308,6 +323,37 @@ func normalizeGiftEvent(event GiftEvent) GiftEvent {
return event return event
} }
func normalizeGameEvent(event GameEvent) GameEvent {
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 (s *Store) applyValueEvent(ctx context.Context, app string, eventID string, valueDelta int64, countDelta int64, occurredAtMS int64, updates []leaderboardUpdate) (bool, error) {
keys := []string{s.dedupeKey(app, eventID)}
args := []any{
strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10),
strconv.FormatInt(valueDelta, 10),
strconv.FormatInt(countDelta, 10),
strconv.FormatInt(occurredAtMS, 10),
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))
}
// Dedupe and all period updates execute without interleaving; after a successful script, relay retries cannot double-count.
result, err := applyValueEventScript.Run(ctx, s.client, keys, args...).Int64()
if err != nil {
return false, err
}
return result == 1, nil
}
func normalizeQuery(query Query) Query { func normalizeQuery(query Query) Query {
query.AppCode = appcode.Normalize(query.AppCode) query.AppCode = appcode.Normalize(query.AppCode)
query.BoardType = NormalizeBoardType(query.BoardType) query.BoardType = NormalizeBoardType(query.BoardType)
@ -385,13 +431,13 @@ func int64FromHash(values map[string]string, key string) int64 {
return value return value
} }
var applyGiftEventScript = redis.NewScript(` var applyValueEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then if redis.call("EXISTS", KEYS[1]) == 1 then
return 0 return 0
end end
redis.call("PSETEX", KEYS[1], ARGV[1], "1") redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2]) local value_delta = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3]) local count_delta = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4]) local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5]) local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do for i = 0, updates - 1 do
@ -399,8 +445,8 @@ for i = 0, updates - 1 do
local item_key = KEYS[3 + i * 2] local item_key = KEYS[3 + i * 2]
local member = ARGV[6 + i * 2] local member = ARGV[6 + i * 2]
local ttl_seconds = tonumber(ARGV[7 + i * 2]) local ttl_seconds = tonumber(ARGV[7 + i * 2])
local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value) local current_value = redis.call("HINCRBY", item_key, "gift_value", value_delta)
redis.call("HINCRBY", item_key, "gift_count", gift_count) redis.call("HINCRBY", item_key, "gift_count", count_delta)
redis.call("HINCRBY", item_key, "transaction_count", 1) redis.call("HINCRBY", item_key, "transaction_count", 1)
local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0") local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
local last_gift_at_ms = previous_last local last_gift_at_ms = previous_last

View File

@ -1,6 +1,9 @@
package userleaderboard package userleaderboard
import ( import (
"context"
"fmt"
"os"
"testing" "testing"
"time" "time"
) )
@ -34,6 +37,9 @@ func TestNormalizeAliases(t *testing.T) {
if NormalizeBoardType("gift_sent") != BoardSent { if NormalizeBoardType("gift_sent") != BoardSent {
t.Fatal("gift_sent should normalize to sent") t.Fatal("gift_sent should normalize to sent")
} }
if NormalizeBoardType("gaming") != BoardGame {
t.Fatal("gaming should normalize to game")
}
if NormalizePeriod("monthly") != PeriodMonth { if NormalizePeriod("monthly") != PeriodMonth {
t.Fatal("monthly should normalize to month") t.Fatal("monthly should normalize to month")
} }
@ -49,4 +55,63 @@ func TestStoreKeyPrefixAndBucketAreStable(t *testing.T) {
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" { 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) t.Fatalf("item key mismatch: %s", got)
} }
if got := store.scoreKey("huwaa", BoardGame, PeriodMonth, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)); got != "activity:user_leaderboard:huwaa:game:month:202607:scores" {
t.Fatalf("game score key mismatch: %s", got)
}
}
func TestApplyGameEventUsesDedicatedRedisBucket(t *testing.T) {
redisAddr := os.Getenv("HYAPP_TEST_REDIS_ADDR")
if redisAddr == "" {
t.Skip("HYAPP_TEST_REDIS_ADDR is not set")
}
ctx := context.Background()
client, err := NewRedisClient(ctx, redisAddr, "", 0)
if err != nil {
t.Fatalf("connect test Redis: %v", err)
}
defer client.Close()
prefix := fmt.Sprintf("test:user_leaderboard:%d", time.Now().UnixNano())
defer func() {
keys, _, scanErr := client.Scan(ctx, 0, prefix+":*", 100).Result()
if scanErr == nil && len(keys) > 0 {
_ = client.Del(ctx, keys...).Err()
}
}()
store := NewStore(client, prefix)
occurredAt := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
event := GameEvent{
AppCode: "huwaa",
EventID: "game_level:test_order_1",
UserID: 20002,
CoinSpent: 120,
OccurredAtMS: occurredAt.UnixMilli(),
}
applied, err := store.ApplyGameEvent(ctx, event)
if err != nil || !applied {
t.Fatalf("first game event should apply: applied=%v err=%v", applied, err)
}
applied, err = store.ApplyGameEvent(ctx, event)
if err != nil || applied {
t.Fatalf("duplicate game event should no-op: applied=%v err=%v", applied, err)
}
page, err := store.List(ctx, Query{
AppCode: "huwaa",
BoardType: BoardGame,
Period: PeriodToday,
Page: 1,
PageSize: 20,
Now: occurredAt.Add(time.Minute),
})
if err != nil {
t.Fatalf("list game leaderboard: %v", err)
}
if page.BoardType != BoardGame || page.Total != 1 || len(page.Items) != 1 || page.Items[0].UserID != "20002" || page.Items[0].GiftValue != 120 || page.Items[0].GiftCount != 0 || page.Items[0].TransactionCount != 1 || page.Items[0].LastGiftAtMS != occurredAt.UnixMilli() {
t.Fatalf("game leaderboard item mismatch: %+v", page)
}
sent, err := store.List(ctx, Query{AppCode: "huwaa", BoardType: BoardSent, Period: PeriodToday, Page: 1, PageSize: 20, Now: occurredAt.Add(time.Minute)})
if err != nil || sent.Total != 0 {
t.Fatalf("game event must not leak into sent board: page=%+v err=%v", sent, err)
}
} }

View File

@ -27,6 +27,18 @@ var catalog = map[Code]Spec{
NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"), NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"), Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"),
RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"), RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"),
RoomRPSChallengeTaken: spec(
codes.FailedPrecondition,
httpStatusConflict,
RoomRPSChallengeTaken,
"手慢啦,该 PK 已被其他人接单",
),
RoomRPSChallengeExpired: spec(
codes.FailedPrecondition,
httpStatusConflict,
RoomRPSChallengeExpired,
"猜拳已过期",
),
Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"), Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"),
PermissionDenied: spec(codes.PermissionDenied, httpStatusForbidden, PermissionDenied, "permission denied"), PermissionDenied: spec(codes.PermissionDenied, httpStatusForbidden, PermissionDenied, "permission denied"),
@ -84,6 +96,14 @@ var catalog = map[Code]Spec{
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"), VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"), VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"), VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
VIPProgramInactive: spec(codes.FailedPrecondition, httpStatusConflict, VIPProgramInactive, "vip program is inactive"),
VIPPackageNotPurchasable: spec(codes.FailedPrecondition, httpStatusConflict, VIPPackageNotPurchasable, "vip package is not purchasable"),
VIPInsufficientCoin: spec(codes.FailedPrecondition, httpStatusConflict, VIPInsufficientCoin, "insufficient coin balance"),
VIPTrialCardNotFound: spec(codes.NotFound, httpStatusNotFound, VIPTrialCardNotFound, "vip trial card not found"),
VIPTrialCardExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPTrialCardExpired, "vip trial card expired"),
VIPBenefitRequired: spec(codes.PermissionDenied, httpStatusForbidden, VIPBenefitRequired, "vip benefit is required"),
VIPAntiKick: spec(codes.PermissionDenied, httpStatusForbidden, VIPAntiKick, "target user cannot be kicked"),
VIPAntiMute: spec(codes.PermissionDenied, httpStatusForbidden, VIPAntiMute, "target user cannot be muted"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"), VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"), VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"), VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),

View File

@ -18,6 +18,10 @@ const (
Conflict Code = "CONFLICT" Conflict Code = "CONFLICT"
// RoomClosed 表示房间已被后台关闭,客户端不能再进入。 // RoomClosed 表示房间已被后台关闭,客户端不能再进入。
RoomClosed Code = "ROOM_CLOSED" RoomClosed Code = "ROOM_CLOSED"
// RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。
RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN"
// RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。
RoomRPSChallengeExpired Code = "ROOM_RPS_CHALLENGE_EXPIRED"
// Unauthorized 表示身份不存在或认证失败。 // Unauthorized 表示身份不存在或认证失败。
Unauthorized Code = "UNAUTHORIZED" Unauthorized Code = "UNAUTHORIZED"
// PermissionDenied 表示已认证但没有执行权限。 // PermissionDenied 表示已认证但没有执行权限。
@ -102,6 +106,22 @@ const (
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED" VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。 // VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED" VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
// VIPProgramInactive 表示当前 App 未启用可执行的 VIP 体系。
VIPProgramInactive Code = "VIP_PROGRAM_INACTIVE"
// VIPPackageNotPurchasable 表示目标套餐不存在、停用或不在当前体系等级范围内。
VIPPackageNotPurchasable Code = "VIP_PACKAGE_NOT_PURCHASABLE"
// VIPInsufficientCoin 表示 VIP 购买事务中的 COIN 余额不足。
VIPInsufficientCoin Code = "VIP_INSUFFICIENT_COIN"
// VIPTrialCardNotFound 表示体验卡实例不存在、不属于当前用户或其背包权益已撤销。
VIPTrialCardNotFound Code = "VIP_TRIAL_CARD_NOT_FOUND"
// VIPTrialCardExpired 表示体验卡或对应背包权益已越过绝对过期时间。
VIPTrialCardExpired Code = "VIP_TRIAL_CARD_EXPIRED"
// VIPBenefitRequired 表示当前操作需要一项未生效或已被用户关闭的 VIP 权益。
VIPBenefitRequired Code = "VIP_BENEFIT_REQUIRED"
// VIPAntiKick 表示普通房主/管理员踢人动作被目标用户的防踢权益拦截。
VIPAntiKick Code = "VIP_ANTI_KICK"
// VIPAntiMute 表示普通房主/管理员禁言动作被目标用户的防禁言权益拦截。
VIPAntiMute Code = "VIP_ANTI_MUTE"
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。 // VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED" VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。 // VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。

View File

@ -135,6 +135,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(CPAlreadyExistsOther), publicCode: string(CPAlreadyExistsOther),
publicMessage: "The other party already has a CP", publicMessage: "The other party already has a CP",
}, },
{
name: "vip anti kick keeps actionable forbidden code",
code: VIPAntiKick,
grpcCode: codes.PermissionDenied,
httpStatus: httpStatusForbidden,
publicCode: string(VIPAntiKick),
publicMessage: "target user cannot be kicked",
},
{ {
name: "region mismatch keeps forbidden transport but exposes concrete app prompt", name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
code: RegionMismatch, code: RegionMismatch,
@ -143,6 +151,22 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(RegionMismatch), publicCode: string(RegionMismatch),
publicMessage: "不是同一个地区", publicMessage: "不是同一个地区",
}, },
{
name: "room rps taken exposes late challenger prompt",
code: RoomRPSChallengeTaken,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(RoomRPSChallengeTaken),
publicMessage: "手慢啦,该 PK 已被其他人接单",
},
{
name: "room rps expired exposes actionable prompt",
code: RoomRPSChallengeExpired,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(RoomRPSChallengeExpired),
publicMessage: "猜拳已过期",
},
} }
for _, test := range tests { for _, test := range tests {

View File

@ -29,12 +29,14 @@ SQL_FILES=(
) )
# initdb only creates missing tables; a reused local MySQL volume therefore # initdb only creates missing tables; a reused local MySQL volume therefore
# needs incremental migrations as well. Keep the owned user-service migrations # needs incremental migrations as well. Keep owner migrations here when reused
# in this local bootstrap path so authentication schema changes (for example # volumes need more than CREATE TABLE IF NOT EXISTS can provide; otherwise new
# login_audit client-version fields) cannot leave a locally logged-in client # binaries may start against a structurally valid-looking but stale table.
# receiving a misleading 401 during token refresh. INCREMENTAL_MIGRATION_FILES=(
USER_MIGRATION_FILES=(
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql" "services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
"services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql"
"services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql"
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
) )
# Keep MySQL as the only required dependency for this script. Business services # Keep MySQL as the only required dependency for this script. Business services
@ -71,7 +73,7 @@ for sql_file in "${SQL_FILES[@]}"; do
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}" docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
done done
for sql_file in "${USER_MIGRATION_FILES[@]}"; do for sql_file in "${INCREMENTAL_MIGRATION_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then if [[ ! -f "${sql_file}" ]]; then
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}" printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
continue continue

View File

@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS user_vip_settings (
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知', room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知', online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启隐藏个人数据',
anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启匿名访问主页',
leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启榜单隐身',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id) PRIMARY KEY (app_code, user_id)

View File

@ -0,0 +1,33 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
-- user_vip_settings 以 (app_code,user_id) 为主键且新增列均为常量默认值。MySQL 8.4 可用
-- ALGORITHM=INSTANT 仅修改数据字典不扫描或重写历史行MySQL 8.4 的 INSTANT 算法不接受
-- 显式 LOCK 子句,因此只保留算法硬约束,避免语法失败且禁止静默退化为 COPY/INPLACE。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'hide_profile_data_enabled') = 0,
'ALTER TABLE user_vip_settings ADD COLUMN hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启隐藏个人数据'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'anonymous_profile_visit_enabled') = 0,
'ALTER TABLE user_vip_settings ADD COLUMN anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启匿名访问主页'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'leaderboard_invisible_enabled') = 0,
'ALTER TABLE user_vip_settings ADD COLUMN leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启榜单隐身'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,11 @@
USE hyapp_wallet;
-- 该表只按用户提现申请增长,创建空表不扫描 wallet_transactions不会在现有 10GB 级流水表上建索引或持有行锁。
-- 必须在部署包含新终局幂等逻辑的 wallet-service 之前执行CREATE TABLE IF NOT EXISTS 可安全重复执行。
CREATE TABLE IF NOT EXISTS wallet_withdrawal_terminal_locks (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
withdrawal_application_id VARCHAR(64) 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, withdrawal_application_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提现申请钱包终局串行锁';

View File

@ -45,6 +45,7 @@ import (
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard" dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi" databimodule "hyapp-admin-server/internal/modules/databi"
externaladminmodule "hyapp-admin-server/internal/modules/externaladmin"
financeordermodule "hyapp-admin-server/internal/modules/financeorder" financeordermodule "hyapp-admin-server/internal/modules/financeorder"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -103,6 +104,7 @@ import (
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
) )
@ -382,6 +384,23 @@ func main() {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode) return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})), })),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{
SessionTTL: cfg.ExternalAdmin.SessionTTL,
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,
LockDuration: cfg.ExternalAdmin.LockDuration,
CookieSecure: cfg.RefreshCookieSecure,
CookieSameSite: cfg.RefreshCookieSameSite,
LoginRateLimit: externaladminmodule.LoginRateLimitConfig{
Window: cfg.ExternalAdmin.LoginRateLimit.Window,
SystemLimit: cfg.ExternalAdmin.LoginRateLimit.SystemLimit,
AppLimit: cfg.ExternalAdmin.LoginRateLimit.AppLimit,
IPLimit: cfg.ExternalAdmin.LoginRateLimit.IPLimit,
IdentityLimit: cfg.ExternalAdmin.LoginRateLimit.IdentityLimit,
},
}, auditHandler,
externaladminmodule.WithUserHostClient(userv1.NewUserHostServiceClient(userConn)),
externaladminmodule.WithLoginRateLimiter(redisClient),
),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New( Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn), gameclient.NewGRPC(gameConn),

View File

@ -8,6 +8,11 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
http_addr: "127.0.0.1:13100" http_addr: "127.0.0.1:13100"
# 示例只信任本机回源代理;上线必须按实际 RemoteAddr 验证 Nginx/Caddy/
# Docker/EdgeOne 完整链路,仅追加精确 hop禁止信任整段私网。
trusted_proxies:
- "127.0.0.0/8"
- "::1"
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
@ -80,6 +85,16 @@ cors_allowed_origins:
- "https://api-acc.global-interaction.com" - "https://api-acc.global-interaction.com"
refresh_cookie_secure: true refresh_cookie_secure: true
refresh_cookie_same_site: "none" refresh_cookie_same_site: "none"
external_admin:
session_ttl: "12h"
max_login_failures: 5
lock_duration: "15m"
login_rate_limit:
window: "60s"
system_limit: 300
app_limit: 120
ip_limit: 30
identity_limit: 10
bootstrap: bootstrap:
# 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。 # 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。
enabled: false enabled: false

View File

@ -8,6 +8,9 @@ log:
include_response_body: false include_response_body: false
max_payload_bytes: 2048 max_payload_bytes: 2048
http_addr: ":13100" http_addr: ":13100"
trusted_proxies:
- "127.0.0.0/8"
- "::1"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
@ -80,6 +83,16 @@ cors_allowed_origins:
- "http://127.0.0.1:7002" - "http://127.0.0.1:7002"
refresh_cookie_secure: false refresh_cookie_secure: false
refresh_cookie_same_site: "lax" refresh_cookie_same_site: "lax"
external_admin:
session_ttl: "12h"
max_login_failures: 5
lock_duration: "15m"
login_rate_limit:
window: "60s"
system_limit: 300
app_limit: 120
ip_limit: 30
identity_limit: 10
bootstrap: bootstrap:
enabled: true enabled: true
seed_demo_data: true seed_demo_data: true

View File

@ -0,0 +1,67 @@
# 外管后台
## 系统边界
外管后台是独立于主后台、Databi 和 Finance 的浏览器入口:
- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,负责查找 App 用户、签发外管账号、配置权限、启停账号和重置密码。
- 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session 和 CSRF token不读取主后台 token 或 localStorage。
- 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts``external_admin_sessions``external_admin_login_logs``external_admin_operation_logs`
- 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一服务端通过账号行自动确定 `app_code` 并固化到会话Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。
## 账号签发和岗位边界
创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。
- `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create``external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。
- `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。
- 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。
- 创建、重置后的密码可直接使用,不要求首次登录修改;停用或重置仍会撤销该账号的全部活跃会话。
## 权限配置
- `GET /api/v1/admin/external-admin-users/permission-catalog` 返回固定的 20 项业务权限目录;服务端只接受目录内 code不接受通配符或主后台 RBAC code。
- `GET /api/v1/admin/external-admin-users/:id/permissions` 按当前 App 返回权限快照和 `revision``PUT` 必须提交 `permissions``expectedRevision`。版本不一致返回 409防止两个管理员互相覆盖。
- 创建请求省略 `permissions` 时保留兼容行为,使用完整默认目录;显式 `[]` 表示零业务权限。无论是否省略字段,调用方都必须具备权限委派能力。
- 权限实际变化与撤销该账号全部活跃会话在同一事务提交,并递增 `permission_revision`;相同快照重复提交不递增版本、不重复撤销会话。
- 特权道具、用户称号和房间背景图在 owner 资源模型可可靠分类前按一个原子权限包校验,不能只选择其中一项。外管资源与 VIP 发放使用 `manager_center` 来源,由 Wallet 在写事务内再次检查经理可发放规则;主后台仍使用原有来源。
## 外管能力
外管只注册显式白名单路由,不提供主后台的通用代理能力:
- 用户:用户列表、资料编辑、封禁列表、封禁和解封。
- 组织主播、公会、BD、BD Manager、Super Admin 列表和我的团队。
- 房间:房间列表和房间编辑。
- 发放:特权道具、靓号、用户称号、房间背景图和财富/VIP 等级。
- 运营Banner 创建。
底层业务写入仍由各 owner service 或既有后台 Handler 负责外管模块只做独立认证、App 约束、权限白名单和审计,不复制用户、房间或钱包业务事实。
## 登录安全
- Session cookie 为 HttpOnly作用域仅 `/api/v1/external`;写请求同时校验 CSRF cookie、header 和服务端 hash。
- Session 绝对有效期默认 12 小时。每次请求都会确认账号和 App 仍为 activeApp 停用后旧会话不能绕过 `/auth/me` 直接调用业务接口。
- 同一账号连续失败 5 次后锁定 15 分钟;账号、密码或账号所属 App 不可用均对外返回相同的 HTTP 401 / 业务码 40100前端按当前语言渲染本地化文案。
- Redis 在账号查询、bcrypt 和登录日志之前,先按系统全局 300、单真实 IP 30、全局账号名 10 执行 60 秒固定窗口限流;命中唯一账号后,再按服务端解析出的 App 执行单 App 120 限流。两个阶段不会重复消耗系统/IP/账号计数,请求中的旧版 `appCode` 字段即使存在也会被忽略。
- 登录请求体上限为 8 KiB限流 Redis 在 200 ms 内不可用时 fail-closed 返回 503不降级为可绕过的单机计数。
- 认证接口统一返回 `Cache-Control: no-store`
## 上线步骤
1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql``099_external_admin_credential_delegation.sql``100_external_admin_global_username.sql``101_external_admin_permission_assignment.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划,再执行同一查询确认结果为空。该检查和 100 的唯一索引构建只扫描小型凭据表不访问用户、房间或钱包表100、101 使用 `INPLACE + LOCK=NONE`但仍建议避开凭据集中创建时段。101 只为小型凭据表增加定长版本列并写入一项平台权限,不扫描或更新业务数据。若 100 检测到重复DDL 会 fail-closed不会自动改名或删除账号先由业务确认保留的登录名并处理冲突再清理迁移器 dirty 记录并重跑。
2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。
3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback不要信任 `0.0.0.0/0``::/0` 或整段未知私网。
4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。
5. 在 EdgeOne/WAF 对精确路径 `POST /api/v1/external/auth/login` 配置按真实客户端 IP 的 token-bucket/挑战规则,建议基线 30 次/分钟、burst 10同时限制源站直连。WAF 负责 Redis 前的大包和连接洪峰,应用层 Redis 负责系统、App 和账号维度的正确性。
## 上线验收
- 登录请求不带 App 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403省略 header 时仍只能看到 Fami 数据。
- 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。
- 创建或重置密码后可直接进入已授权页面;历史 `password_change_required` 标记也不能阻断登录或业务接口。
- 用旧 `revision` 更新权限必须返回 409成功减少权限后旧会话必须立即返回 401原权限接口必须返回 403。重复提交相同快照时版本保持不变。
- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。
- Redis 临时不可用时新登录应在约 200 ms 后返回 503恢复后不需要重启服务。
- `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML不能回退到主后台入口。
- 停用、密码重置、封禁、房间编辑、资源/VIP 发放和 Banner 创建均应在外管操作日志中留下 App、外管账号、请求 ID、资源 ID 和结果。

View File

@ -282,9 +282,24 @@
| `wallet:view` | `menu` | 钱包概览、余额查询 | | `wallet:view` | `menu` | 钱包概览、余额查询 |
| `wallet:transaction-view` | `menu` | 交易流水 | | `wallet:transaction-view` | `menu` | 交易流水 |
| `wallet:adjust` | `button` | 后台调账 | | `wallet:adjust` | `button` | 后台调账 |
| `withdrawal:view` | `menu` | 提现记录 | | `operations-withdrawal:view` | `menu` | 用户提现运营审核列表;不包含 `operations_status=skipped` 的历史单 |
| `withdrawal:review` | `button` | 提现审核 | | `operations-withdrawal:audit` | `button` | 运营初审;通过后转交财务,拒绝时释放冻结余额并终止流程 |
| `withdrawal:export` | `button` | 提现导出 | | `finance-withdrawal:view` | `menu` | 用户提现财务审核列表;只包含运营已通过和 `skipped` 历史兼容单 |
| `finance-withdrawal:audit` | `button` | 财务终审;通过时扣减 frozen拒绝时释放 frozen |
两个提现工作台都以 `admin_user_money_scopes` 作为 App 数据范围:显式选择 App 时必须命中授权,未选 App 的列表只查授权 App 集合,仅 `platform-admin` 的全量资金范围可跨全部 App。审核请求必须显式携带目标行的 `X-App-Code`,后端会在钱包动作前再校验 MoneyAccess。`GET /admin/finance/scope` 仅返回这份授权目录,其读权限为 `finance:view``finance-withdrawal:view|audit``operations-withdrawal:view|audit`,不因此赋予审核写权。
运营状态 `rejecting` 表示拒绝 claim 已独立提交、钱包释放或通知/终态收敛仍需重试。该状态只能继续调用原拒绝接口,后端沿用第一次审核人、原因和固定 command不能改点通过也不会进入财务列表。运营工作台必须显示“重试拒绝”不能把它当成普通 `pending`
#### 用户提现两级审核发布顺序
`102_user_withdrawal_operations_review.sql` 不能和旧 admin 实例混跑。旧 admin 只识别总体 `status=pending`,会绕过 `operations_status` 直接调钱包终审。生产必须按以下顺序发布:
1. 先执行 `scripts/mysql/068_wallet_withdrawal_terminal_locks.sql`,完成新 wallet-service 的全量滚动,确保所有钱包实例都能按申请 ID 收敛旧/新 command。
2. 摘流所有旧 admin 实例,确认无旧版财务审核请求在飞。
3. 执行 admin 102 迁移。迁移持久化当时的最大申请 ID仅把该范围内已经终审的历史行标记 `skipped`;尚未终审的 `status=pending` 申请和迁移后新申请都保持运营 `pending`
4. 部署新 admin 并恢复流量,验证运营/财务列表和 MoneyAccess 隔离。
5. 最后滚动 gateway。即使窗口内旧 gateway 不写 `operations_status`,数据库默认也会让新单进入运营待审。
### 礼物和活动 ### 礼物和活动
@ -350,6 +365,7 @@
`ops-admin` `ops-admin`
- 负责运营、团队、房间、资源、活动和游戏的日常管理。 - 负责运营、团队、房间、资源、活动和游戏的日常管理。
- 负责用户提现运营初审,不拥有用户提现财务终审权限。
- 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。 - 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。
### 运营专员 ### 运营专员
@ -357,6 +373,7 @@
`operations-specialist` `operations-specialist`
- 可执行用户、团队结构、房间、资源和常规运营动作。 - 可执行用户、团队结构、房间、资源和常规运营动作。
- 可执行用户提现运营初审,不拥有用户提现财务终审权限。
- 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。 - 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。
### 产品负责人 ### 产品负责人
@ -377,13 +394,13 @@
`finance-lead` `finance-lead`
- 只进入财务看板、充值对账、提现审和房内猜拳订单。 - 只进入财务看板、充值对账、用户提现财务终审和房内猜拳订单;不参与运营初审
### 财务专员 ### 财务专员
`finance-specialist` `finance-specialist`
- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。 - 当前与财务负责人使用同一权限矩阵,负责用户提现财务终审且不参与运营初审;保留独立角色用于人员分工和后续数据范围配置。
固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor``readonly` 和用户自建角色不会被删除或重置。 固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor``readonly` 和用户自建角色不会被删除或重置。

View File

@ -3,6 +3,8 @@ package cache
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -18,6 +20,45 @@ type Redis struct {
type UnlockFunc func(context.Context) error type UnlockFunc func(context.Context) error
var ErrRedisUnavailable = errors.New("redis is unavailable")
type FixedWindowRule struct {
Key string
Limit uint64
}
type FixedWindowDecision struct {
Allowed bool
RetryAfter time.Duration
}
// fixedWindowScript consumes every rule in one Redis-side operation. All rate-limit
// keys use one Cluster hash tag at the caller, so EVAL remains atomic without CROSSSLOT.
// Rejected attempts still increment every layer; expiry is assigned only on the first
// increment and is never renewed, preserving true fixed-window behavior.
const fixedWindowScript = `
local allowed = 1
local retry_ms = 0
local window_ms = tonumber(ARGV[1])
for i = 1, #KEYS do
local count = redis.call("INCR", KEYS[i])
if count == 1 then
redis.call("PEXPIRE", KEYS[i], window_ms)
end
local limit = tonumber(ARGV[i + 1])
if count > limit then
allowed = 0
local ttl = redis.call("PTTL", KEYS[i])
if ttl > retry_ms then
retry_ms = ttl
end
end
end
if allowed == 0 and retry_ms <= 0 then
retry_ms = window_ms
end
return {allowed, retry_ms}`
func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) {
if !cfg.Enabled { if !cfg.Enabled {
return nil, nil return nil, nil
@ -26,6 +67,7 @@ func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) {
Addr: cfg.Addr, Addr: cfg.Addr,
Password: cfg.Password, Password: cfg.Password,
DB: cfg.DB, DB: cfg.DB,
ContextTimeoutEnabled: true,
}) })
if err := client.Ping(ctx).Err(); err != nil { if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close() _ = client.Close()
@ -88,6 +130,48 @@ return 0`
return result == 1, err return result == 1, err
} }
// ConsumeFixedWindow atomically increments all supplied counters and decides whether
// every limit still allows the request. A nil/disabled Redis is an availability error,
// not an implicit allow: security callers must fail closed instead of falling back to
// per-process memory that attackers could bypass by switching replicas.
func (r *Redis) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []FixedWindowRule) (FixedWindowDecision, error) {
if r == nil || r.client == nil {
return FixedWindowDecision{}, ErrRedisUnavailable
}
if window < time.Millisecond || len(rules) == 0 {
return FixedWindowDecision{}, errors.New("positive fixed window and at least one rule are required")
}
keys := make([]string, 0, len(rules))
args := make([]any, 0, len(rules)+1)
args = append(args, window.Milliseconds())
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
key := strings.TrimSpace(rule.Key)
if key == "" || rule.Limit == 0 {
return FixedWindowDecision{}, errors.New("fixed-window keys and limits must be non-empty")
}
key = r.key(key)
if _, exists := seen[key]; exists {
return FixedWindowDecision{}, fmt.Errorf("duplicate fixed-window key %q", key)
}
seen[key] = struct{}{}
keys = append(keys, key)
args = append(args, strconv.FormatUint(rule.Limit, 10))
}
result, err := r.client.Eval(ctx, fixedWindowScript, keys, args...).Int64Slice()
if err != nil {
return FixedWindowDecision{}, fmt.Errorf("%w: %v", ErrRedisUnavailable, err)
}
if len(result) != 2 || (result[0] != 0 && result[0] != 1) {
return FixedWindowDecision{}, fmt.Errorf("%w: invalid fixed-window response", ErrRedisUnavailable)
}
retryAfter := time.Duration(result[1]) * time.Millisecond
if result[0] == 0 && retryAfter <= 0 {
retryAfter = window
}
return FixedWindowDecision{Allowed: result[0] == 1, RetryAfter: retryAfter}, nil
}
func (r *Redis) key(key string) string { func (r *Redis) key(key string) string {
if r.prefix == "" { if r.prefix == "" {
return key return key

View File

@ -0,0 +1,34 @@
package cache
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestConsumeFixedWindowFailsClosedWithoutRedis(t *testing.T) {
_, err := (*Redis)(nil).ConsumeFixedWindow(context.Background(), time.Minute, []FixedWindowRule{{Key: "rate:{external-login}:system", Limit: 300}})
if !errors.Is(err, ErrRedisUnavailable) {
t.Fatalf("error = %v, want ErrRedisUnavailable", err)
}
}
func TestFixedWindowLuaConsumesAllKeysWithoutRenewingExistingTTL(t *testing.T) {
// These source-level invariants protect the security semantics even when unit
// tests run without a Redis daemon: all keys increment before the one final
// return, and PEXPIRE remains inside count==1 so rejected requests never slide TTL.
if strings.Count(fixedWindowScript, `redis.call("INCR"`) != 1 {
t.Fatalf("fixed-window script must increment through one loop: %s", fixedWindowScript)
}
if !strings.Contains(fixedWindowScript, `if count == 1 then`) || !strings.Contains(fixedWindowScript, `redis.call("PEXPIRE"`) {
t.Fatal("fixed-window script must assign expiry only on first increment")
}
if strings.Count(fixedWindowScript, "return {") != 1 {
t.Fatal("fixed-window script must not return early before consuming all keys")
}
if strings.Index(fixedWindowScript, `redis.call("INCR"`) > strings.Index(fixedWindowScript, `if count > limit then`) {
t.Fatal("fixed-window script must increment before deciding")
}
}

View File

@ -3,6 +3,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"strings" "strings"
"time" "time"
@ -18,6 +19,7 @@ type Config struct {
Environment string `yaml:"environment"` Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"` Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"` HTTPAddr string `yaml:"http_addr"`
TrustedProxies []string `yaml:"trusted_proxies"`
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"` UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
@ -34,6 +36,7 @@ type Config struct {
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"` CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"` RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"` RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
ExternalAdmin ExternalAdminConfig `yaml:"external_admin"`
Bootstrap BootstrapConfig `yaml:"bootstrap"` Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"` BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"` UserService UserServiceConfig `yaml:"user_service"`
@ -57,6 +60,21 @@ type MigrationConfig struct {
AllowChecksumRepair bool `yaml:"allow_checksum_repair"` AllowChecksumRepair bool `yaml:"allow_checksum_repair"`
} }
type ExternalAdminConfig struct {
SessionTTL time.Duration `yaml:"session_ttl"`
MaxLoginFailures uint `yaml:"max_login_failures"`
LockDuration time.Duration `yaml:"lock_duration"`
LoginRateLimit ExternalAdminLoginRateLimitConfig `yaml:"login_rate_limit"`
}
type ExternalAdminLoginRateLimitConfig struct {
Window time.Duration `yaml:"window"`
SystemLimit uint64 `yaml:"system_limit"`
AppLimit uint64 `yaml:"app_limit"`
IPLimit uint64 `yaml:"ip_limit"`
IdentityLimit uint64 `yaml:"identity_limit"`
}
type UserServiceConfig struct { type UserServiceConfig struct {
Addr string `yaml:"addr"` Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
@ -214,6 +232,7 @@ func Default() Config {
Environment: "local", Environment: "local",
Log: logging.DefaultConfig(), Log: logging.DefaultConfig(),
HTTPAddr: ":13100", HTTPAddr: ":13100",
TrustedProxies: []string{"127.0.0.0/8", "::1"},
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
@ -280,6 +299,14 @@ func Default() Config {
}, },
RefreshCookieSecure: false, RefreshCookieSecure: false,
RefreshCookieSameSite: "lax", RefreshCookieSameSite: "lax",
ExternalAdmin: ExternalAdminConfig{
SessionTTL: 12 * time.Hour,
MaxLoginFailures: 5,
LockDuration: 15 * time.Minute,
LoginRateLimit: ExternalAdminLoginRateLimitConfig{
Window: 60 * time.Second, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10,
},
},
Bootstrap: BootstrapConfig{ Bootstrap: BootstrapConfig{
Enabled: true, Enabled: true,
SeedDemoData: true, SeedDemoData: true,
@ -420,10 +447,51 @@ func (cfg *Config) Normalize() {
if cfg.Log.MaxPayloadBytes <= 0 { if cfg.Log.MaxPayloadBytes <= 0 {
cfg.Log.MaxPayloadBytes = 2048 cfg.Log.MaxPayloadBytes = 2048
} }
// Gin must never retain its trust-all proxy default: a forged X-Forwarded-For
// would otherwise bypass IP login limits and corrupt every audit record. Keep
// only explicit ingress hops and preserve an empty list as "trust no proxy".
trustedProxies := make([]string, 0, len(cfg.TrustedProxies))
seenTrustedProxy := make(map[string]struct{}, len(cfg.TrustedProxies))
for _, proxy := range cfg.TrustedProxies {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
continue
}
if _, exists := seenTrustedProxy[proxy]; exists {
continue
}
seenTrustedProxy[proxy] = struct{}{}
trustedProxies = append(trustedProxies, proxy)
}
cfg.TrustedProxies = trustedProxies
cfg.RefreshCookieSameSite = strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) cfg.RefreshCookieSameSite = strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite))
if cfg.RefreshCookieSameSite == "" { if cfg.RefreshCookieSameSite == "" {
cfg.RefreshCookieSameSite = "lax" cfg.RefreshCookieSameSite = "lax"
} }
if cfg.ExternalAdmin.SessionTTL <= 0 {
cfg.ExternalAdmin.SessionTTL = 12 * time.Hour
}
if cfg.ExternalAdmin.MaxLoginFailures == 0 {
cfg.ExternalAdmin.MaxLoginFailures = 5
}
if cfg.ExternalAdmin.LockDuration <= 0 {
cfg.ExternalAdmin.LockDuration = 15 * time.Minute
}
if cfg.ExternalAdmin.LoginRateLimit.Window <= 0 {
cfg.ExternalAdmin.LoginRateLimit.Window = 60 * time.Second
}
if cfg.ExternalAdmin.LoginRateLimit.SystemLimit == 0 {
cfg.ExternalAdmin.LoginRateLimit.SystemLimit = 300
}
if cfg.ExternalAdmin.LoginRateLimit.AppLimit == 0 {
cfg.ExternalAdmin.LoginRateLimit.AppLimit = 120
}
if cfg.ExternalAdmin.LoginRateLimit.IPLimit == 0 {
cfg.ExternalAdmin.LoginRateLimit.IPLimit = 30
}
if cfg.ExternalAdmin.LoginRateLimit.IdentityLimit == 0 {
cfg.ExternalAdmin.LoginRateLimit.IdentityLimit = 10
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
cfg.NodeID = strings.TrimSpace(cfg.NodeID) cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" { if cfg.NodeID == "" {
@ -642,6 +710,14 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(cfg.HTTPAddr) == "" { if strings.TrimSpace(cfg.HTTPAddr) == "" {
return errors.New("http_addr is required") return errors.New("http_addr is required")
} }
for _, proxy := range cfg.TrustedProxies {
if net.ParseIP(proxy) != nil {
continue
}
if _, _, err := net.ParseCIDR(proxy); err != nil {
return fmt.Errorf("trusted_proxies contains invalid IP or CIDR %q", proxy)
}
}
if strings.TrimSpace(cfg.MySQLDSN) == "" { if strings.TrimSpace(cfg.MySQLDSN) == "" {
return errors.New("mysql_dsn is required") return errors.New("mysql_dsn is required")
} }
@ -666,6 +742,27 @@ func (cfg Config) Validate() error {
if cfg.RefreshTokenTTL <= 0 { if cfg.RefreshTokenTTL <= 0 {
return errors.New("refresh_token_ttl must be greater than 0") return errors.New("refresh_token_ttl must be greater than 0")
} }
if cfg.ExternalAdmin.SessionTTL <= 0 {
return errors.New("external_admin.session_ttl must be greater than 0")
}
if cfg.ExternalAdmin.MaxLoginFailures == 0 || cfg.ExternalAdmin.MaxLoginFailures > 20 {
return errors.New("external_admin.max_login_failures must be between 1 and 20")
}
if cfg.ExternalAdmin.LockDuration <= 0 {
return errors.New("external_admin.lock_duration must be greater than 0")
}
if cfg.ExternalAdmin.LoginRateLimit.Window < time.Second || cfg.ExternalAdmin.LoginRateLimit.Window > time.Hour {
return errors.New("external_admin.login_rate_limit.window must be between 1s and 1h")
}
if cfg.ExternalAdmin.LoginRateLimit.SystemLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.AppLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.IPLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.IdentityLimit == 0 {
return errors.New("external_admin.login_rate_limit limits must be greater than 0")
}
if cfg.ExternalAdmin.LoginRateLimit.SystemLimit < cfg.ExternalAdmin.LoginRateLimit.AppLimit {
return errors.New("external_admin.login_rate_limit.system_limit must be greater than or equal to app_limit")
}
if cfg.ExternalAdmin.LoginRateLimit.AppLimit < cfg.ExternalAdmin.LoginRateLimit.IPLimit || cfg.ExternalAdmin.LoginRateLimit.AppLimit < cfg.ExternalAdmin.LoginRateLimit.IdentityLimit {
return errors.New("external_admin.login_rate_limit.app_limit must cover ip_limit and identity_limit")
}
switch strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) { switch strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) {
case "lax", "strict", "none": case "lax", "strict", "none":
default: default:
@ -872,6 +969,9 @@ func (cfg Config) Validate() error {
if cfg.Redis.Enabled && strings.TrimSpace(cfg.Redis.Addr) == "" { if cfg.Redis.Enabled && strings.TrimSpace(cfg.Redis.Addr) == "" {
return errors.New("redis.addr is required when redis is enabled") return errors.New("redis.addr is required when redis is enabled")
} }
if isLockedEnvironment(cfg.Environment) && !cfg.Redis.Enabled {
return errors.New("redis.enabled must be true outside local/dev for external login rate limiting")
}
if cfg.Redis.DB < 0 { if cfg.Redis.DB < 0 {
return fmt.Errorf("redis.db must be greater than or equal to 0") return fmt.Errorf("redis.db must be greater than or equal to 0")
} }

View File

@ -16,6 +16,9 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.HTTPAddr != ":13100" { if cfg.HTTPAddr != ":13100" {
t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr) t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr)
} }
if !reflect.DeepEqual(cfg.TrustedProxies, []string{"127.0.0.0/8", "::1"}) {
t.Fatalf("TrustedProxies = %#v", cfg.TrustedProxies)
}
if cfg.MySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" { if cfg.MySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" {
t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN) t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN)
} }
@ -28,6 +31,12 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.RefreshTokenTTL != 7*24*time.Hour { if cfg.RefreshTokenTTL != 7*24*time.Hour {
t.Fatalf("RefreshTokenTTL = %s", cfg.RefreshTokenTTL) t.Fatalf("RefreshTokenTTL = %s", cfg.RefreshTokenTTL)
} }
if cfg.ExternalAdmin.SessionTTL != 12*time.Hour || cfg.ExternalAdmin.MaxLoginFailures != 5 || cfg.ExternalAdmin.LockDuration != 15*time.Minute {
t.Fatalf("ExternalAdmin = %#v", cfg.ExternalAdmin)
}
if limit := cfg.ExternalAdmin.LoginRateLimit; limit.Window != time.Minute || limit.SystemLimit != 300 || limit.AppLimit != 120 || limit.IPLimit != 30 || limit.IdentityLimit != 10 {
t.Fatalf("ExternalAdmin.LoginRateLimit = %#v", limit)
}
if !cfg.Migrations.AllowChecksumRepair { if !cfg.Migrations.AllowChecksumRepair {
t.Fatalf("Migrations.AllowChecksumRepair = false, want true for local config") t.Fatalf("Migrations.AllowChecksumRepair = false, want true for local config")
} }
@ -36,6 +45,34 @@ func TestLoadConfigYAML(t *testing.T) {
} }
} }
func TestExternalAdminLoginRateLimitDefaultsAndValidation(t *testing.T) {
cfg := Default()
cfg.ExternalAdmin.LoginRateLimit = ExternalAdminLoginRateLimitConfig{}
cfg.Normalize()
if limit := cfg.ExternalAdmin.LoginRateLimit; limit.Window != time.Minute || limit.SystemLimit != 300 || limit.AppLimit != 120 || limit.IPLimit != 30 || limit.IdentityLimit != 10 {
t.Fatalf("normalized login rate limit = %#v", limit)
}
cfg.ExternalAdmin.LoginRateLimit.AppLimit = 301
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "system_limit") {
t.Fatalf("Validate() error = %v, want system/app hierarchy error", err)
}
}
func TestProductionRequiresRedisForExternalLoginRateLimit(t *testing.T) {
cfg := Default()
cfg.Environment = "prod"
cfg.JWTSecret = strings.Repeat("s", 32)
cfg.MySQLAutoMigrate = false
cfg.Migrations.AllowChecksumRepair = false
cfg.Bootstrap.Enabled = false
cfg.RobotProfileSource.MySQLDSN = "robot:test@tcp(example:3306)/robot?parseTime=true"
cfg.Redis.Enabled = false
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "redis.enabled") {
t.Fatalf("Validate() error = %v, want production Redis requirement", err)
}
}
func TestLoadEmptyPathUsesDefault(t *testing.T) { func TestLoadEmptyPathUsesDefault(t *testing.T) {
cfg, err := Load("") cfg, err := Load("")
if err != nil { if err != nil {
@ -47,6 +84,14 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
} }
} }
func TestValidateRejectsInvalidTrustedProxy(t *testing.T) {
cfg := Default()
cfg.TrustedProxies = []string{"not-a-proxy-range"}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("Validate() error = %v, want trusted_proxies error", err)
}
}
func TestAslanMongoEnvOverride(t *testing.T) { func TestAslanMongoEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin") t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard") t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")

View File

@ -33,6 +33,7 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error) RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error)
RevokeUserResource(ctx context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error)
EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
@ -173,6 +174,10 @@ func (c *GRPCClient) RevokeResourceGrant(ctx context.Context, req *walletv1.Revo
return c.client.RevokeResourceGrant(ctx, req) return c.client.RevokeResourceGrant(ctx, req)
} }
func (c *GRPCClient) RevokeUserResource(ctx context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error) {
return c.client.RevokeUserResource(ctx, req)
}
func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) { func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
return c.client.EquipUserResource(ctx, req) return c.client.EquipUserResource(ctx, req)
} }

View File

@ -1,12 +1,15 @@
package middleware package middleware
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -15,6 +18,15 @@ type fakeAppAccessStore struct {
access repository.AppAccess access repository.AppAccess
} }
type fakeAuthorizationStore struct {
authorization repository.UserAuthorization
err error
}
func (store fakeAuthorizationStore) CurrentAuthorizationForUser(uint) (repository.UserAuthorization, error) {
return store.authorization, store.err
}
func (store fakeAppAccessStore) AppAccessForUser(uint) (repository.AppAccess, error) { func (store fakeAppAccessStore) AppAccessForUser(uint) (repository.AppAccess, error) {
return store.access, nil return store.access, nil
} }
@ -50,3 +62,67 @@ func TestRequireAppScopeAllowsOnlySelectedApp(t *testing.T) {
} }
} }
} }
func TestAuthRequiredUsesCurrentPermissionsInsteadOfJWTClaim(t *testing.T) {
gin.SetMode(gin.TestMode)
auth := service.NewAuthService("live-authorization-test-secret", time.Hour)
// This models an access token issued before a role migration: finance audit
// existed in the claim, while operations audit was absent at issue time.
token, _, err := auth.GenerateAccessToken(7, "stale-name", []string{"finance-withdrawal:audit"})
if err != nil {
t.Fatalf("generate access token: %v", err)
}
router := gin.New()
router.Use(AuthRequired(auth, fakeAuthorizationStore{authorization: repository.UserAuthorization{
Username: "current-name",
Status: model.UserStatusActive,
Permissions: []string{"operations-withdrawal:audit"},
}}))
router.GET("/finance", RequirePermission("finance-withdrawal:audit"), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
router.GET("/operations", RequirePermission("operations-withdrawal:audit"), func(c *gin.Context) {
if CurrentUsername(c) != "current-name" {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusNoContent)
})
financeRequest := httptest.NewRequest(http.MethodGet, "/finance", nil)
financeRequest.Header.Set("Authorization", "Bearer "+token)
financeResponse := httptest.NewRecorder()
router.ServeHTTP(financeResponse, financeRequest)
if financeResponse.Code != http.StatusForbidden {
t.Fatalf("removed JWT permission status = %d body=%s", financeResponse.Code, financeResponse.Body.String())
}
operationsRequest := httptest.NewRequest(http.MethodGet, "/operations", nil)
operationsRequest.Header.Set("Authorization", "Bearer "+token)
operationsResponse := httptest.NewRecorder()
router.ServeHTTP(operationsResponse, operationsRequest)
if operationsResponse.Code != http.StatusNoContent {
t.Fatalf("current database permission status = %d body=%s", operationsResponse.Code, operationsResponse.Body.String())
}
}
func TestAuthRequiredFailsClosedWhenAuthorizationCannotBeResolved(t *testing.T) {
gin.SetMode(gin.TestMode)
auth := service.NewAuthService("authorization-failure-test-secret", time.Hour)
token, _, err := auth.GenerateAccessToken(7, "admin", []string{"role:manage"})
if err != nil {
t.Fatalf("generate access token: %v", err)
}
router := gin.New()
router.Use(AuthRequired(auth, fakeAuthorizationStore{err: errors.New("database unavailable")}))
router.GET("/protected", func(c *gin.Context) { c.Status(http.StatusNoContent) })
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusInternalServerError {
t.Fatalf("authorization database failure status = %d body=%s", response.Code, response.Body.String())
}
}

View File

@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"hyapp-admin-server/internal/config"
"github.com/gin-gonic/gin"
)
func TestCORSAllowsAndExposesExternalCSRFHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(CORS(config.Config{CORSAllowedOrigins: []string{"https://admin.example.com"}}))
engine.GET("/api/v1/external/auth/me", func(c *gin.Context) { c.Status(http.StatusNoContent) })
request := httptest.NewRequest(http.MethodOptions, "/api/v1/external/auth/me", nil)
request.Header.Set("Origin", "https://admin.example.com")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", response.Code)
}
if !strings.Contains(response.Header().Get("Access-Control-Allow-Headers"), "X-CSRF-Token") {
t.Fatalf("allow headers = %q", response.Header().Get("Access-Control-Allow-Headers"))
}
if !strings.Contains(response.Header().Get("Access-Control-Expose-Headers"), "X-CSRF-Token") {
t.Fatalf("expose headers = %q", response.Header().Get("Access-Control-Expose-Headers"))
}
if response.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Fatal("credentialed external session must be allowed")
}
}

View File

@ -8,6 +8,7 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
"hyapp-admin-server/internal/service" "hyapp-admin-server/internal/service"
@ -20,6 +21,10 @@ type AppAccessStore interface {
AppAccessForUser(userID uint) (repository.AppAccess, error) AppAccessForUser(userID uint) (repository.AppAccess, error)
} }
type AuthorizationStore interface {
CurrentAuthorizationForUser(userID uint) (repository.UserAuthorization, error)
}
const ( const (
ContextUserID = "userID" ContextUserID = "userID"
ContextUsername = "username" ContextUsername = "username"
@ -38,8 +43,8 @@ func CORS(cfg config.Config) gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin") c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID, "+appctx.HeaderAppCode) c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID, "+appctx.HeaderAppCode+", X-CSRF-Token")
c.Header("Access-Control-Expose-Headers", "X-Request-ID") c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-CSRF-Token")
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS")
} }
@ -61,7 +66,7 @@ func AppCode() gin.HandlerFunc {
} }
} }
func AuthRequired(auth *service.AuthService) gin.HandlerFunc { func AuthRequired(auth *service.AuthService, store AuthorizationStore) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
header := c.GetHeader("Authorization") header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") { if !strings.HasPrefix(header, "Bearer ") {
@ -70,6 +75,13 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
return return
} }
if auth == nil || store == nil {
// Authorization cannot safely fall back to claims.Permissions: doing so
// would restore removed privileges until the access token expires.
response.ServerError(c, "认证权限服务不可用")
c.Abort()
return
}
claims, err := auth.ParseAccessToken(strings.TrimPrefix(header, "Bearer ")) claims, err := auth.ParseAccessToken(strings.TrimPrefix(header, "Bearer "))
if err != nil { if err != nil {
response.Unauthorized(c, "访问凭证已失效") response.Unauthorized(c, "访问凭证已失效")
@ -77,9 +89,27 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
return return
} }
authorization, err := store.CurrentAuthorizationForUser(claims.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在")
} else {
// Database errors fail closed. A stale JWT permission snapshot is not
// an acceptable availability fallback for privileged admin actions.
response.ServerError(c, "校验登录权限失败")
}
c.Abort()
return
}
if authorization.Status != model.UserStatusActive {
response.Unauthorized(c, "账号不可登录")
c.Abort()
return
}
c.Set(ContextUserID, claims.UserID) c.Set(ContextUserID, claims.UserID)
c.Set(ContextUsername, claims.Username) c.Set(ContextUsername, authorization.Username)
c.Set(ContextPermissions, claims.Permissions) c.Set(ContextPermissions, authorization.Permissions)
c.Next() c.Next()
} }
} }
@ -154,8 +184,9 @@ func HasAnyPermission(c *gin.Context, codes ...string) bool {
return false return false
} }
// HasPermission reads only the JWT-derived permission list. Database state is // HasPermission reads the request-scoped permission list resolved by
// refreshed by login/refresh, so every request uses one consistent permission snapshot. // AuthRequired from current database state. JWT permission claims are retained
// for wire compatibility only and are never trusted for server authorization.
func HasPermission(c *gin.Context, code string) bool { func HasPermission(c *gin.Context, code string) bool {
return slices.Contains(CurrentPermissions(c), code) return slices.Contains(CurrentPermissions(c), code)
} }

View File

@ -148,9 +148,16 @@ func applyFile(ctx context.Context, db *sql.DB, version string, file string) err
return err return err
} }
sum := checksum(body) sum := checksum(body)
// 迁移文件会用 MySQL 会话变量在多条语句之间传递 DDL 决策。database/sql 不保证连续 Exec
// 复用同一底层连接,因此整个文件必须绑定 *sql.Conn否则 @ddl/@checkpoint 可能在 PREPARE 前丢失。
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
var existing migrationRow var existing migrationRow
err = db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&existing.Version, &existing.Checksum, &existing.Dirty) err = conn.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&existing.Version, &existing.Checksum, &existing.Dirty)
if err == nil { if err == nil {
if existing.Dirty { if existing.Dirty {
return fmt.Errorf("migration %s is dirty; repair it before continuing", version) return fmt.Errorf("migration %s is dirty; repair it before continuing", version)
@ -164,16 +171,16 @@ func applyFile(ctx context.Context, db *sql.DB, version string, file string) err
return err return err
} }
if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations(version, checksum, dirty) VALUES (?, ?, TRUE)", version, sum); err != nil { if _, err := conn.ExecContext(ctx, "INSERT INTO schema_migrations(version, checksum, dirty) VALUES (?, ?, TRUE)", version, sum); err != nil {
return err return err
} }
for _, statement := range splitSQL(string(body)) { for _, statement := range splitSQL(string(body)) {
if _, err := db.ExecContext(ctx, statement); err != nil { if _, err := conn.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("apply migration %s: %w", version, err) return fmt.Errorf("apply migration %s: %w", version, err)
} }
} }
if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at_ms = ? WHERE version = ?", sum, time.Now().UTC().UnixMilli(), version); err != nil { if _, err := conn.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at_ms = ? WHERE version = ?", sum, time.Now().UTC().UnixMilli(), version); err != nil {
return err return err
} }
return nil return nil

View File

@ -37,8 +37,94 @@ const (
WithdrawalApplicationStatusPending = "pending" WithdrawalApplicationStatusPending = "pending"
WithdrawalApplicationStatusApproved = "approved" WithdrawalApplicationStatusApproved = "approved"
WithdrawalApplicationStatusRejected = "rejected" WithdrawalApplicationStatusRejected = "rejected"
WithdrawalOperationsStatusSkipped = "skipped"
WithdrawalOperationsStatusPending = "pending"
WithdrawalOperationsStatusRejecting = "rejecting"
WithdrawalOperationsStatusApproved = "approved"
WithdrawalOperationsStatusRejected = "rejected"
ExternalAdminStatusActive = "active"
ExternalAdminStatusDisabled = "disabled"
) )
// ExternalAdminAccount is intentionally not related to admin_users. External operators
// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT.
type ExternalAdminAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;not null" json:"appCode"`
LinkedAppUserID int64 `gorm:"column:linked_app_user_id;uniqueIndex:uk_external_admin_accounts_app_linked_user;not null" json:"-"`
Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_username;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"`
PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"`
Status string `gorm:"size:24;index:idx_external_admin_accounts_app_status_updated,priority:2;not null;default:active" json:"status"`
PasswordChangeRequired bool `gorm:"not null;default:true" json:"passwordChangeRequired"`
FailedLoginCount uint `gorm:"not null;default:0" json:"-"`
LockedUntilMS int64 `gorm:"column:locked_until_ms;not null;default:0" json:"-"`
LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
CreatedByAdminID uint `gorm:"column:created_by_admin_id;index;not null" json:"createdByAdminId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli;index:idx_external_admin_accounts_app_status_updated,priority:3" json:"updatedAtMs"`
}
func (ExternalAdminAccount) TableName() string { return "external_admin_accounts" }
// ExternalAdminSession stores only hashes of the session and CSRF secrets. The browser
// receives the opaque values, so a read-only database leak cannot be replayed as a session.
type ExternalAdminSession struct {
ID uint64 `gorm:"primaryKey"`
AccountID uint64 `gorm:"column:account_id;index:idx_external_admin_sessions_account_active,priority:1;not null"`
Account ExternalAdminAccount `gorm:"foreignKey:AccountID;references:ID"`
AppCode string `gorm:"size:32;index:idx_external_admin_sessions_app_expiry,priority:1;not null"`
TokenHash string `gorm:"size:64;uniqueIndex:uk_external_admin_sessions_token_hash;not null"`
CSRFTokenHash string `gorm:"column:csrf_token_hash;size:64;not null"`
IP string `gorm:"size:64;not null;default:''"`
UserAgent string `gorm:"size:512;not null;default:''"`
ExpiresAtMS int64 `gorm:"column:expires_at_ms;index:idx_external_admin_sessions_account_active,priority:3;index:idx_external_admin_sessions_app_expiry,priority:2;not null"`
LastSeenAtMS int64 `gorm:"column:last_seen_at_ms;not null"`
RevokedAtMS int64 `gorm:"column:revoked_at_ms;index:idx_external_admin_sessions_account_active,priority:2;not null;default:0"`
RevokeReason string `gorm:"size:64;not null;default:''"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli"`
}
func (ExternalAdminSession) TableName() string { return "external_admin_sessions" }
type ExternalAdminLoginLog struct {
ID uint64 `gorm:"primaryKey"`
AccountID *uint64 `gorm:"column:account_id;index:idx_external_admin_login_logs_account_time,priority:1"`
AppCode string `gorm:"size:32;index:idx_external_admin_login_logs_app_username_time,priority:1;not null"`
Username string `gorm:"size:64;index:idx_external_admin_login_logs_app_username_time,priority:2;not null"`
IP string `gorm:"size:64;not null;default:''"`
UserAgent string `gorm:"size:512;not null;default:''"`
Status string `gorm:"size:24;index:idx_external_admin_login_logs_status_time,priority:1;not null"`
Message string `gorm:"size:128;not null;default:''"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_external_admin_login_logs_app_username_time,priority:3;index:idx_external_admin_login_logs_account_time,priority:2;index:idx_external_admin_login_logs_status_time,priority:2"`
}
func (ExternalAdminLoginLog) TableName() string { return "external_admin_login_logs" }
type ExternalAdminOperationLog struct {
ID uint64 `gorm:"primaryKey"`
AccountID uint64 `gorm:"column:account_id;index:idx_external_admin_operation_logs_account_time,priority:1;not null"`
AppCode string `gorm:"size:32;index:idx_external_admin_operation_logs_app_time,priority:1;not null"`
Username string `gorm:"size:64;not null"`
RequestID string `gorm:"size:64;index:idx_external_admin_operation_logs_request;not null;default:''"`
Action string `gorm:"size:160;not null"`
Resource string `gorm:"size:120;not null;default:''"`
ResourceID string `gorm:"size:128;not null;default:''"`
Method string `gorm:"size:12;not null"`
Path string `gorm:"size:255;not null"`
IP string `gorm:"size:64;not null;default:''"`
UserAgent string `gorm:"size:512;not null;default:''"`
Status string `gorm:"size:24;not null"`
HTTPStatus int `gorm:"not null"`
LatencyMS int64 `gorm:"not null;default:0"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_external_admin_operation_logs_account_time,priority:2;index:idx_external_admin_operation_logs_app_time,priority:2"`
}
func (ExternalAdminOperationLog) TableName() string { return "external_admin_operation_logs" }
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"account"` Username string `gorm:"size:64;uniqueIndex;not null" json:"account"`
@ -640,8 +726,8 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
} }
type UserWithdrawalApplication struct { type UserWithdrawalApplication struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"` AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"`
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
@ -663,7 +749,14 @@ type UserWithdrawalApplication struct {
AuditRemark string `gorm:"type:text" json:"auditRemark"` AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"` AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"` ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time" json:"createdAtMs"` OperationsStatus string `gorm:"column:operations_status;size:32;not null;default:pending;index:idx_admin_withdrawal_app_operations_status_time,priority:2" json:"operationsStatus"`
OperationsReviewerUserID *uint `gorm:"column:operations_reviewer_user_id" json:"operationsReviewerUserId"`
OperationsReviewerName string `gorm:"column:operations_reviewer_name;size:64;not null;default:''" json:"operationsReviewerName"`
OperationsAuditRemark string `gorm:"column:operations_audit_remark;type:text" json:"operationsAuditRemark"`
OperationsAuditCommandID string `gorm:"column:operations_audit_command_id;size:128;not null;default:''" json:"operationsAuditCommandId"`
OperationsAuditTransactionID string `gorm:"column:operations_audit_transaction_id;size:128;not null;default:''" json:"operationsAuditTransactionId"`
OperationsReviewedAtMS *int64 `gorm:"column:operations_reviewed_at_ms" json:"operationsReviewedAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:3" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
} }

View File

@ -27,8 +27,8 @@ const (
aslanMongoGoldWaterCollection = "user_gold_running_water_v2" aslanMongoGoldWaterCollection = "user_gold_running_water_v2"
aslanMongoChunkSize = 800 aslanMongoChunkSize = 800
// likei GoldOrigin 枚举以 code 形式落在 user_gold_running_water_v2.origin // 幸运礼物返奖和币商出货各自拥有稳定、单一语义的 origin 前缀,因此这两类可按前缀聚合
// 历史上部分 code 带数字后缀(如 HOT_GAME_200所以统一用前缀匹配 // 游戏命名空间混有排行榜/活动奖励,游戏流水必须走下方 exact code 白名单,不能复用此前缀策略
aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD" aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD"
aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT" aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT"
) )
@ -36,6 +36,41 @@ const (
var ( var (
aslanMongoFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"} aslanMongoFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"}
aslanMongoFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"} aslanMongoFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"}
// 游戏流水只能消费 likei 已确认的具体 GoldOrigin code不能用 *_GAME_% 前缀兜底:
// 同一命名空间里已经存在 HOT_GAME_200 排行榜奖励和 BAISHUN_GAME_1085 活动奖励,
// 前缀匹配会把平台发奖误算成游戏返奖。新增游戏必须先在 likei 的游戏配置中确认业务语义,
// 再显式追加到这里,确保 Databi 历史口径不会随着第三方传入任意 gameUid 静默漂移。
aslanLegacyGameEventTypes = []string{
"HOT_GAME_201", "HOT_GAME_202", "HOT_GAME_203", "HOT_GAME_204",
"HOT_GAME_205", "HOT_GAME_206", "HOT_GAME_207", "HOT_GAME_208",
"HOT_GAME_209", "HOT_GAME_210", "HOT_GAME_211", "HOT_GAME_212",
"HOT_GAME_213", "HOT_GAME_214", "HOT_GAME_215", "HOT_GAME_220",
"HKYS_GAME_101", "HKYS_GAME_102", "HKYS_GAME_103", "HKYS_GAME_104",
"HKYS_GAME_105", "HKYS_GAME_106", "HKYS_GAME_107", "HKYS_GAME_108",
"HKYS_GAME_109", "HKYS_GAME_110", "HKYS_GAME_111", "HKYS_GAME_112",
"HKYS_GAME_113", "HKYS_GAME_114", "HKYS_GAME_115", "HKYS_GAME_116",
"HKYS_GAME_201", "HKYS_GAME_202", "HKYS_GAME_203", "HKYS_GAME_204",
"HKYS_GAME_205", "HKYS_GAME_206", "HKYS_GAME_207", "HKYS_GAME_208",
"HKYS_GAME_209", "HKYS_GAME_210", "HKYS_GAME_211", "HKYS_GAME_212",
"HKYS_GAME_213", "HKYS_GAME_214", "HKYS_GAME_215", "HKYS_GAME_216",
"BAISHUN_GAME_1001", "BAISHUN_GAME_1004", "BAISHUN_GAME_1005", "BAISHUN_GAME_1006",
"BAISHUN_GAME_1007", "BAISHUN_GAME_1008", "BAISHUN_GAME_1009", "BAISHUN_GAME_1010",
"BAISHUN_GAME_1012", "BAISHUN_GAME_1013", "BAISHUN_GAME_1014", "BAISHUN_GAME_1015",
"BAISHUN_GAME_1016", "BAISHUN_GAME_1017", "BAISHUN_GAME_1018", "BAISHUN_GAME_1019",
"BAISHUN_GAME_1020", "BAISHUN_GAME_1021", "BAISHUN_GAME_1022", "BAISHUN_GAME_1023",
"BAISHUN_GAME_1024", "BAISHUN_GAME_1026", "BAISHUN_GAME_1031", "BAISHUN_GAME_1032",
"BAISHUN_GAME_1035", "BAISHUN_GAME_1037", "BAISHUN_GAME_1040", "BAISHUN_GAME_1041",
"BAISHUN_GAME_1043", "BAISHUN_GAME_1044", "BAISHUN_GAME_1048", "BAISHUN_GAME_1051",
"BAISHUN_GAME_1053", "BAISHUN_GAME_1055", "BAISHUN_GAME_1068", "BAISHUN_GAME_1069",
"BAISHUN_GAME_1075", "BAISHUN_GAME_1078", "BAISHUN_GAME_1083", "BAISHUN_GAME_1084",
"BAISHUN_GAME_1090", "BAISHUN_GAME_1100", "BAISHUN_GAME_1107", "BAISHUN_GAME_1114",
"BAISHUN_GAME_1130", "BAISHUN_GAME_1172", "BAISHUN_GAME_1182",
// Yomi 的 gameUid 由第三方动态下发,代码仓库没有静态枚举;以下 8 个值来自 ATYOU
// wallet_gold_count 近 30 天已出现且经游戏回调链路确认的集合,未知 gameUid 默认不纳入口径。
"YOMI_GAME_274", "YOMI_GAME_275", "YOMI_GAME_276", "YOMI_GAME_277",
"YOMI_GAME_278", "YOMI_GAME_279", "YOMI_GAME_281", "YOMI_GAME_282",
}
) )
type AslanMongoDashboardSource struct { type AslanMongoDashboardSource struct {
@ -145,6 +180,12 @@ func (s *AslanMongoDashboardSource) AppCode() string {
return s.appCode return s.appCode
} }
func (s *AslanMongoDashboardSource) legacyGameTotalsAvailable(countryFilter externalDashboardCountryFilter) bool {
// wallet_gold_count 只有 App××事件类型没有用户或国家键区域筛选必须显式关闭该口径
// 否则区域卡片会混入全 App 游戏流水,数值虽非零却属于错误的数据权限和统计语义。
return s.legacyDB != nil && strings.TrimSpace(s.legacyWalletDatabase) != "" && !countryFilter.Applied
}
func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return nil, fmt.Errorf("aslan mongo dashboard source is not configured") return nil, fmt.Errorf("aslan mongo dashboard source is not configured")
@ -200,12 +241,14 @@ func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, quer
response["country_breakdown"] = current.CountryBreakdown response["country_breakdown"] = current.CountryBreakdown
response["daily_country_breakdown"] = current.DailyCountryBreakdown response["daily_country_breakdown"] = current.DailyCountryBreakdown
goldWater := s.hasGoldWater(ctx) goldWater := s.hasGoldWater(ctx)
legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter)
response["retention"] = current.Total.retentionMap() response["retention"] = current.Total.retentionMap()
response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil) response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil, legacyGameTotals, countryFilter.Applied)
response["updated_at_ms"] = time.Now().UTC().UnixMilli() response["updated_at_ms"] = time.Now().UTC().UnixMilli()
applyExternalDashboardDeltas(response, current.Total, previous.Total) applyExternalDashboardDeltas(response, current.Total, previous.Total)
response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor)) response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
applyAslanMongoUnavailableFields(response) applyAslanMongoUnavailableFields(response)
applyAslanMongoGlobalGameOverview(response, current.Total, previous.Total, legacyGameTotals)
applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil) applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil)
if !goldWater { if !goldWater {
applyAslanMongoGoldWaterUnavailable(response) applyAslanMongoGoldWaterUnavailable(response)
@ -296,6 +339,13 @@ type legacyMongoCoinFlow struct {
OutputCoin int64 OutputCoin int64
} }
// legacyMongoGameFlow 是 wallet_gold_count 已预聚合的全 App 游戏日快照。
// 该表没有 user_id/国家维度,因此它只能进入总览与每日趋势,绝不能回填到国家或区域行。
type legacyMongoGameFlow struct {
Turnover int64
Payout int64
}
func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric { func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric {
userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor
metric := externalDashboardMetric{ metric := externalDashboardMetric{
@ -332,11 +382,24 @@ func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDa
return metric return metric
} }
func (m *legacyMongoMetric) toExternalWithGlobalFlows(coinFlow *legacyMongoCoinFlow, gameFlow *legacyMongoGameFlow) externalDashboardMetric {
metric := m.toExternal(coinFlow)
if gameFlow == nil {
return metric
}
metric.GameTurnover = gameFlow.Turnover
metric.GamePayout = gameFlow.Payout
// 游戏利润和利润率属于投注/返奖的派生口径;在同一个 finalize 入口重算,避免总览与 Databi 二次汇总出现公式分叉。
metric.finalize()
return metric
}
type legacyMongoGrid struct { type legacyMongoGrid struct {
days []string days []string
daySet map[string]struct{} daySet map[string]struct{}
metrics map[string]map[string]*legacyMongoMetric metrics map[string]map[string]*legacyMongoMetric
coinFlows map[string]*legacyMongoCoinFlow coinFlows map[string]*legacyMongoCoinFlow
gameFlows map[string]*legacyMongoGameFlow
rechargeUsers map[string]map[string]struct{} rechargeUsers map[string]map[string]struct{}
// 各 loader 并行写入map 结构由锁保护;不同 loader 写 metric 的不同字段,无字段级竞争。 // 各 loader 并行写入map 结构由锁保护;不同 loader 写 metric 的不同字段,无字段级竞争。
mu sync.Mutex mu sync.Mutex
@ -347,6 +410,7 @@ func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid
daySet: map[string]struct{}{}, daySet: map[string]struct{}{},
metrics: map[string]map[string]*legacyMongoMetric{}, metrics: map[string]map[string]*legacyMongoMetric{},
coinFlows: map[string]*legacyMongoCoinFlow{}, coinFlows: map[string]*legacyMongoCoinFlow{},
gameFlows: map[string]*legacyMongoGameFlow{},
rechargeUsers: map[string]map[string]struct{}{}, rechargeUsers: map[string]map[string]struct{}{},
} }
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) { for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
@ -432,6 +496,12 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim
func() error { return s.loadCoinFlows(ctx, startDate, endDate, grid) }, func() error { return s.loadCoinFlows(ctx, startDate, endDate, grid) },
) )
} }
legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter)
if legacyGameTotals {
// wallet_gold_count 已由 likei wallet-service 按日聚合,区间查询只扫描唯一键范围内的小量日快照;
// 这里不回查账变明细,避免 Databi 请求把在线钱包库变成临时报表引擎。
tasks = append(tasks, func() error { return s.loadLegacyGameFlows(ctx, startDate, endDate, grid) })
}
if includeRetention { if includeRetention {
tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) }) tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) })
} }
@ -445,14 +515,15 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim
} }
} }
return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil), nil return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil, legacyGameTotals), nil
} }
func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool) aslanRangeOverview { func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool, legacyGameTotals bool) aslanRangeOverview {
dailySeries := []map[string]any{} dailySeries := []map[string]any{}
dailyCountries := []map[string]any{} dailyCountries := []map[string]any{}
totalFlow := legacyMongoMetric{} totalFlow := legacyMongoMetric{}
totalCoinFlow := legacyMongoCoinFlow{} totalCoinFlow := legacyMongoCoinFlow{}
totalGameFlow := legacyMongoGameFlow{}
countryTotals := map[string]*legacyMongoMetric{} countryTotals := map[string]*legacyMongoMetric{}
for _, day := range grid.days { for _, day := range grid.days {
@ -497,6 +568,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin
totalCoinFlow.OutputCoin += coinFlow.OutputCoin totalCoinFlow.OutputCoin += coinFlow.OutputCoin
} }
gameFlow := grid.gameFlows[day]
if gameFlow != nil {
totalGameFlow.Turnover += gameFlow.Turnover
totalGameFlow.Payout += gameFlow.Payout
}
// 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。 // 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。
daySalary := dayFlow.SalaryUSDMinor daySalary := dayFlow.SalaryUSDMinor
if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 { if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 {
@ -506,10 +582,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
totalFlow.add(&dayFlow) totalFlow.add(&dayFlow)
totalFlow.SalaryUSDMinor = daySalary totalFlow.SalaryUSDMinor = daySalary
dayRow := dayFlow.toExternal(coinFlow).toMap() dayRow := dayFlow.toExternalWithGlobalFlows(coinFlow, gameFlow).toMap()
dayRow["label"] = day dayRow["label"] = day
dayRow["stat_day"] = day dayRow["stat_day"] = day
applyAslanMongoUnavailableFields(dayRow) applyAslanMongoUnavailableFields(dayRow)
applyAslanMongoGlobalGameFields(dayRow, gameFlow, legacyGameTotals)
applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge) applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge)
if !goldWater { if !goldWater {
applyAslanMongoGoldWaterUnavailable(dayRow) applyAslanMongoGoldWaterUnavailable(dayRow)
@ -548,8 +625,13 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
return left > right return left > right
}) })
var gameTotal *legacyMongoGameFlow
if legacyGameTotals {
// 可用口径即使区间内没有流水也应返回真实 0nil 只表示数据源或筛选维度不支持。
gameTotal = &totalGameFlow
}
return aslanRangeOverview{ return aslanRangeOverview{
Total: totalFlow.toExternal(&totalCoinFlow), Total: totalFlow.toExternalWithGlobalFlows(&totalCoinFlow, gameTotal),
DailySeries: dailySeries, DailySeries: dailySeries,
CountryBreakdown: countryRows, CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries, DailyCountryBreakdown: dailyCountries,
@ -632,6 +714,71 @@ func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startD
return nil return nil
} }
// loadLegacyGameFlows 读取 likei wallet-service 已持久化的 App××事件累计快照。
// group_num 的日界线由 likei 固定按 Asia/Riyadh 生成;这里按 [start,end) 的日期号查询并原样映射,
// 不使用 MySQL session 时区做二次转换,避免同一自然日被移到相邻日期。
func (s *AslanMongoDashboardSource) loadLegacyGameFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error {
if s.legacyDB == nil || strings.TrimSpace(s.legacyWalletDatabase) == "" || len(aslanLegacyGameEventTypes) == 0 {
return nil
}
// likei 新迁移的唯一键为 (sys_origin, group_num, type, event_type);固定 App、日期范围和 type 后
// 只扫描目标日快照。event_type 使用 exact IN 白名单收口业务语义,不能改成 LIKE '*_GAME_%'
// 否则 HOT_GAME_200、BAISHUN_GAME_1085 等奖励 code 会被误计。
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(aslanLegacyGameEventTypes)), ",")
query := fmt.Sprintf(`SELECT group_num, type, COALESCE(SUM(amount), 0)
FROM %s.wallet_gold_count
WHERE sys_origin = ?
AND group_num >= ?
AND group_num < ?
AND type IN (0, 1)
AND event_type IN (%s)
GROUP BY group_num, type`, quoteMySQLIdentifier(s.legacyWalletDatabase), placeholders)
args := make([]any, 0, len(aslanLegacyGameEventTypes)+3)
args = append(args, s.sysOrigin, dashboardDateNumber(startDate), dashboardDateNumber(endDate))
for _, eventType := range aslanLegacyGameEventTypes {
args = append(args, eventType)
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
rows, err := s.legacyDB.QueryContext(queryCtx, query, args...)
if err != nil {
return fmt.Errorf("query legacy game day snapshots: %w", err)
}
defer rows.Close()
for rows.Next() {
var groupNum string
var receiptType int
var pennyAmount int64
if err := rows.Scan(&groupNum, &receiptType, &pennyAmount); err != nil {
return fmt.Errorf("scan legacy game day snapshot: %w", err)
}
day, ok := dashboardDayFromNumber(groupNum)
if !ok || !grid.hasDay(day) {
continue
}
flow := grid.gameFlows[day]
if flow == nil {
flow = &legacyMongoGameFlow{}
grid.gameFlows[day] = flow
}
// GoldReceiptCmd 以 PennyAmount 写入统计缓存wallet_gold_count.amount 因此是 1/100 金币;
// 先在数据库按日汇总再统一四舍五入,避免逐 event_type 除法造成累计舍入误差。
amount := roundMinorToWhole(pennyAmount)
switch receiptType {
case 1:
flow.Turnover += amount
case 0:
flow.Payout += amount
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy game day snapshots: %w", err)
}
return nil
}
type aslanMongoDayCountryUsers struct { type aslanMongoDayCountryUsers struct {
day string day string
country string country string
@ -1306,7 +1453,7 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
// applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30 // applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30
// 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D // 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D
//(与 Yumi 的 CDC 预聚合、statistics-service 口径一致。cohort 日无注册时基数保持 0 // (与 Yumi 的 CDC 预聚合、statistics-service 口径一致。cohort 日无注册时基数保持 0
// 上层会把 0 基数转成 nil避免把“无 cohort”展示成真实 0% 留存。 // 上层会把 0 基数转成 nil避免把“无 cohort”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error { func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone) location, err := time.LoadLocation(s.statTimezone)
@ -1597,6 +1744,19 @@ func dashboardDateNumber(day time.Time) string {
return day.Format("20060102") return day.Format("20060102")
} }
func dashboardDayFromNumber(value string) (string, bool) {
value = strings.TrimSpace(value)
if len(value) != 8 {
return "", false
}
// Parse 不只校验字符长度,还会拒绝 20260231 这类脏 group_num输出固定 ISO 日期供 grid 精确匹配。
day, err := time.Parse("20060102", value)
if err != nil {
return "", false
}
return day.Format("2006-01-02"), true
}
// aslanMongoDocument 把聚合结果里的嵌套文档统一转成 bson.M // aslanMongoDocument 把聚合结果里的嵌套文档统一转成 bson.M
// driver 默认把嵌套文档解码成 bson.D直接断言 bson.M 会静默丢行(表现为全零无报错)。 // driver 默认把嵌套文档解码成 bson.D直接断言 bson.M 会静默丢行(表现为全零无报错)。
func aslanMongoDocument(value any) bson.M { func aslanMongoDocument(value any) bson.M {
@ -1763,8 +1923,9 @@ func aslanMongoInt64Chunks(values []int64, size int) [][]int64 {
return out return out
} }
// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径; // aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径。
// 游戏流水的 GoldOrigin code 枚举过杂(大量带后缀的游戏 code 与奖励类混在一起),未确认前不硬凑。 // 游戏总量先置空,再由 applyAslanMongoGlobalGameFields 仅在“全 App + legacy 日快照可用”时恢复;
// 这样国家/区域行沿用同一序列化路径也不会意外泄漏全局游戏数据。
var aslanMongoUnavailableMetricFields = []string{ var aslanMongoUnavailableMetricFields = []string{
"coin_seller_stock_coin", "coin_seller_stock_coin",
"new_dealer_recharge_usd_minor", "new_dealer_recharge_usd_minor",
@ -1797,6 +1958,33 @@ func applyAslanMongoUnavailableFields(row map[string]any) {
} }
} }
func applyAslanMongoGlobalGameFields(row map[string]any, flow *legacyMongoGameFlow, available bool) {
if !available {
return
}
if flow == nil {
flow = &legacyMongoGameFlow{}
}
profit := flow.Turnover - flow.Payout
row["game_turnover"] = flow.Turnover
row["game_payout"] = flow.Payout
row["game_profit"] = profit
row["game_profit_rate"] = ratioFloat64(profit, flow.Turnover)
// wallet_gold_count 没有 user_idgame_players 必须继续保持 nil不能用事件类型数量冒充玩家数。
}
func applyAslanMongoGlobalGameOverview(row map[string]any, current externalDashboardMetric, previous externalDashboardMetric, available bool) {
if !available {
return
}
applyAslanMongoGlobalGameFields(row, &legacyMongoGameFlow{Turnover: current.GameTurnover, Payout: current.GamePayout}, true)
// 环比基于同一个 Riyadh 日快照口径计算;上一周期真实为 0 时 deltaRate 按公共规则返回 nil
// 不伪造无穷增长率。
row["game_turnover_delta_rate"] = deltaRate(current.GameTurnover, previous.GameTurnover)
row["game_payout_delta_rate"] = deltaRate(current.GamePayout, previous.GamePayout)
row["game_profit_delta_rate"] = deltaRate(current.GameProfit, previous.GameProfit)
}
var aslanMongoLegacyMetricFields = []string{ var aslanMongoLegacyMetricFields = []string{
"coin_seller_recharge_usd_minor", "coin_seller_recharge_usd_minor",
} }
@ -1830,7 +2018,7 @@ func applyAslanMongoGoldWaterUnavailable(row map[string]any) {
} }
} }
func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map[string]any { func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool, legacyGameTotals bool, countryFiltered bool) []map[string]any {
goldWaterSources := []map[string]any{ goldWaterSources := []map[string]any{
{"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"}, {"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"},
{"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"}, {"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"},
@ -1864,6 +2052,29 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
mifaPaySource += " + legacy MySQL order_other_recharge amount" mifaPaySource += " + legacy MySQL order_other_recharge amount"
newUserRechargeSource += "/legacy 其他充值/legacy 币商充值" newUserRechargeSource += "/legacy 其他充值/legacy 币商充值"
} }
gameSources := []map[string]any{}
if legacyGameTotals {
gameSource := "likei legacy MySQL atyou_wallet.wallet_gold_countAsia/Riyadh 日口径exact game event_type 白名单type=1 投注、type=0 返奖)"
gameSources = []map[string]any{
{"field": "game_turnover", "available": true, "source": gameSource},
{"field": "game_payout", "available": true, "source": gameSource},
{"field": "game_profit", "available": true, "source": "game_turnover - game_payout"},
{"field": "game_profit_rate", "available": true, "source": "game_profit / game_turnover"},
{"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id不能从日金额快照推导游戏人数"},
}
} else {
reason := "未配置 legacy MySQL 钱包库,无法读取 Aslan 游戏日快照"
if countryFiltered {
reason = "wallet_gold_count 只有全 App 日汇总,区域筛选时不能返回全局游戏值"
}
gameSources = []map[string]any{
{"field": "game_turnover", "available": false, "missing_reason": reason},
{"field": "game_payout", "available": false, "missing_reason": reason},
{"field": "game_profit", "available": false, "missing_reason": reason},
{"field": "game_profit_rate", "available": false, "missing_reason": reason},
{"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id不能从日金额快照推导游戏人数"},
}
}
baseSources := []map[string]any{ baseSources := []map[string]any{
{"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"}, {"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"},
{"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"}, {"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"},
@ -1876,7 +2087,6 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
{"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount含幸运礼物"}, {"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount含幸运礼物"},
{"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"}, {"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"}, {"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"},
{"field": "game_turnover", "available": false, "missing_reason": "likei GoldOrigin 游戏 code 枚举过多且与奖励类混排,未确认口径前不聚合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"}, {"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"},
{"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"}, {"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"}, {"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"},
@ -1892,5 +2102,6 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
{"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"}, {"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"},
} }
out := append(baseSources, legacySources...) out := append(baseSources, legacySources...)
out = append(out, gameSources...)
return append(out, goldWaterSources...) return append(out, goldWaterSources...)
} }

View File

@ -2,6 +2,7 @@ package dashboard
import ( import (
"context" "context"
"database/sql/driver"
"testing" "testing"
"time" "time"
@ -288,6 +289,94 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
} }
} }
func TestAslanMongoLegacyGameFlowsUseSelectedDayNumberForRiyadhSnapshots(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
// Social BI 仍按上海时区选择日期wallet 游戏快照虽按 Riyadh 切日,但 group_num 是自然日号,
// 查询必须直接使用所选 yyyyMMdd不能把请求时间点换区后再偏移日期。
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load Shanghai location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, location)
end := start.AddDate(0, 0, 1)
grid := newLegacyMongoGrid(start, end)
// 国家行存在时也不能继承全 App 游戏值;它只用于验证 assembleRange 的维度隔离。
grid.at("2026-07-05", "SA").NewUsers = 1
source := &AslanMongoDashboardSource{
legacyDB: db,
legacyWalletDatabase: "atyou_wallet",
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
arguments := []driver.Value{"ATYOU", "20260705", "20260706"}
for _, eventType := range aslanLegacyGameEventTypes {
arguments = append(arguments, eventType)
}
mock.ExpectQuery("FROM `atyou_wallet`\\.wallet_gold_count[\\s\\S]*sys_origin = \\?[\\s\\S]*event_type IN").
WithArgs(arguments...).
WillReturnRows(sqlmock.NewRows([]string{"group_num", "type", "amount"}).
AddRow(20260705, 1, int64(12_345)).
AddRow(20260705, 0, int64(2_345)))
if err := source.loadLegacyGameFlows(context.Background(), start, end, grid); err != nil {
t.Fatalf("loadLegacyGameFlows: %v", err)
}
flow := grid.gameFlows["2026-07-05"]
if flow == nil || flow.Turnover != 123 || flow.Payout != 23 {
t.Fatalf("legacy game flow mismatch: %+v", flow)
}
overview := source.assembleRange(grid, externalDashboardCountryFilter{}, false, true, true)
if overview.Total.GameTurnover != 123 || overview.Total.GamePayout != 23 || overview.Total.GameProfit != 100 {
t.Fatalf("legacy game overview mismatch: %+v", overview.Total)
}
assertInt64(t, overview.DailySeries[0]["game_turnover"], 123)
assertInt64(t, overview.DailySeries[0]["game_payout"], 23)
assertInt64(t, overview.DailySeries[0]["game_profit"], 100)
if overview.DailySeries[0]["game_players"] != nil {
t.Fatalf("game players must stay unavailable: %#v", overview.DailySeries[0])
}
if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0]["game_turnover"] != nil {
t.Fatalf("country rows must not contain global game totals: %#v", overview.CountryBreakdown)
}
// 即使 grid 中已有全局快照,区域筛选路径传入 unavailable 后也必须继续输出 nil防止全局值泄漏。
regionOverview := source.assembleRange(grid, externalDashboardCountryFilter{Applied: true, RegionID: 9}, false, true, false)
if regionOverview.DailySeries[0]["game_turnover"] != nil || regionOverview.Total.GameTurnover != 0 {
t.Fatalf("region overview must not expose global game totals: daily=%#v total=%+v", regionOverview.DailySeries, regionOverview.Total)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoLegacyGameWhitelistExcludesRewardOrigins(t *testing.T) {
seen := make(map[string]struct{}, len(aslanLegacyGameEventTypes))
for _, eventType := range aslanLegacyGameEventTypes {
if _, duplicated := seen[eventType]; duplicated {
t.Fatalf("duplicate game event type %s", eventType)
}
seen[eventType] = struct{}{}
}
for _, required := range []string{"HOT_GAME_201", "HKYS_GAME_101", "BAISHUN_GAME_1182", "YOMI_GAME_282"} {
if _, ok := seen[required]; !ok {
t.Fatalf("confirmed game event type %s missing from whitelist", required)
}
}
for _, reward := range []string{"HOT_GAME_200", "BAISHUN_GAME_1085"} {
if _, ok := seen[reward]; ok {
t.Fatalf("reward origin %s must not be counted as game flow", reward)
}
}
}
func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) { func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
match := bson.M{} match := bson.M{}
aslanMongoExcludeFreightGoldRecharge(match) aslanMongoExcludeFreightGoldRecharge(match)
@ -340,11 +429,16 @@ func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) {
} }
func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) { func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) {
available := aslanMongoDashboardMetricSources(false, true) available := aslanMongoDashboardMetricSources(false, true, true, false)
assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor") assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor")
assertMetricSourceAvailable(t, available, "game_turnover")
unavailable := aslanMongoDashboardMetricSources(false, false) unavailable := aslanMongoDashboardMetricSources(false, false, false, false)
assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor") assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor")
assertMetricSourceUnavailable(t, unavailable, "game_turnover")
regionFiltered := aslanMongoDashboardMetricSources(false, true, false, true)
assertMetricSourceUnavailable(t, regionFiltered, "game_turnover")
} }
func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) { func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) {
@ -358,7 +452,7 @@ func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) {
} }
applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil) applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil)
overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true) overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true, false)
if overview.Total.PaidUsers != 1 { if overview.Total.PaidUsers != 1 {
t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers) t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers)
} }

View File

@ -22,6 +22,7 @@ import (
const ( const (
appKindHyapp = "hyapp" appKindHyapp = "hyapp"
appKindLegacy = "legacy" appKindLegacy = "legacy"
unknownCountryName = "未知国家"
overviewConcurrency = 4 overviewConcurrency = 4
) )
@ -341,14 +342,18 @@ type RequirementsResult struct {
// Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service // Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service
// legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。 // legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。
func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) { func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) {
apps, err := s.listAllowedApps(ctx, query.AppCodes, access) registeredApps, err := s.listApps(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
apps := filterAllowedApps(registeredApps, query.AppCodes, access)
catalog, err := s.regionCatalog(ctx, apps) catalog, err := s.regionCatalog(ctx, apps)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 国家主数据属于 App 业务域;即使 country_id 是物理全局自增,也必须按当前可查询 App 建目录,
// 否则跨 App 污染会被另一个 App 的主数据包装成合法国家,掩盖上游事实错误。
countryDirectories := s.countryDirectories(ctx, apps)
results := make([]AppOverview, len(apps)) results := make([]AppOverview, len(apps))
var wg sync.WaitGroup var wg sync.WaitGroup
@ -359,7 +364,7 @@ func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, q
defer wg.Done() defer wg.Done()
semaphore <- struct{}{} semaphore <- struct{}{}
defer func() { <-semaphore }() defer func() { <-semaphore }()
results[index] = s.appOverview(ctx, app, catalog[app.AppCode], access, query) results[index] = s.appOverview(ctx, app, catalog[app.AppCode], countryDirectories[app.AppCode], access, query)
}(index, app) }(index, app)
} }
wg.Wait() wg.Wait()
@ -600,7 +605,7 @@ func supportsAppTrackingFunnel(appCode string) bool {
return ok return ok
} }
func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query OverviewQuery) AppOverview { func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, countries map[int64]countryDirectoryEntry, access repository.MoneyAccess, query OverviewQuery) AppOverview {
out := AppOverview{ out := AppOverview{
AppCode: app.AppCode, AppCode: app.AppCode,
AppName: app.AppName, AppName: app.AppName,
@ -652,8 +657,10 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
return out return out
} }
countryRows := anyRowSlice(overview["country_breakdown"]) // statisticsOverview 会短时缓存并共享底层 map补展示字段时必须复制行避免并发请求互相改写缓存。
dailyCountryRows := anyRowSlice(overview["daily_country_breakdown"]) preserveSourceNames := app.Kind == appKindLegacy
countryRows := enrichCountryRows(anyRowSlice(overview["country_breakdown"]), countries, preserveSourceNames)
dailyCountryRows := enrichCountryRows(anyRowSlice(overview["daily_country_breakdown"]), countries, preserveSourceNames)
resolve := regionResolver(regions) resolve := regionResolver(regions)
restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0 restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0
@ -1199,6 +1206,12 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
if err != nil { if err != nil {
return nil, err return nil, err
} }
return filterAllowedApps(apps, requested, access), nil
}
// filterAllowedApps 只裁剪可查询的 App不改变 appregistry 给出的稳定顺序。
// Overview 会保留裁剪前的注册 App 集合,供全局 country_id 展示目录使用。
func filterAllowedApps(apps []AppInfo, requested []string, access repository.MoneyAccess) []AppInfo {
requestedSet := map[string]struct{}{} requestedSet := map[string]struct{}{}
for _, appCode := range requested { for _, appCode := range requested {
appCode = appctx.Normalize(appCode) appCode = appctx.Normalize(appCode)
@ -1221,7 +1234,145 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
} }
out = append(out, app) out = append(out, app)
} }
return out, nil return out
}
type countryDirectoryEntry struct {
AppCode string
ID int64
Code string
Name string
DisplayName string
Flag string
Enabled bool
}
// countryDirectories 按 app_code + country_id 汇总当前可查询 HyApp 的国家主数据。
// country_id 虽是全局自增主键,但国家启停和归属仍受 app_code 隔离;目录不能跨 App 复用。
func (s *Service) countryDirectories(ctx context.Context, apps []AppInfo) map[string]map[int64]countryDirectoryEntry {
byApp := make(map[string]map[int64]countryDirectoryEntry)
if s.user == nil {
return byApp
}
// appregistry 当前按名称排序;改按 app_code 查询让下游调用顺序和日志保持稳定。
hyappApps := make([]AppInfo, 0, len(apps))
for _, app := range apps {
if app.Kind == appKindHyapp {
hyappApps = append(hyappApps, app)
}
}
sort.Slice(hyappApps, func(i, j int) bool {
return hyappApps[i].AppCode < hyappApps[j].AppCode
})
for _, app := range hyappApps {
// user-service 依赖 RequestMeta.app_code 选择 App 数据域;每次调用都必须显式覆盖请求上下文。
appContext := appctx.WithContext(ctx, app.AppCode)
countries, err := s.user.ListCountries(appContext, userclient.ListCountriesRequest{
Caller: "admin-server",
// nil 表示同时读取 enabled 与 disabled。已停用国家仍可能存在于历史统计事实中不能从展示目录丢失。
Enabled: nil,
})
if err != nil {
// 国家目录只用于展示;单 App 故障时该 App 降级为未知,不能拖垮整个 Social BI Overview 接口。
slog.Warn("databi_list_hyapp_countries_failed", "app_code", app.AppCode, "error", err)
continue
}
byID := make(map[int64]countryDirectoryEntry, len(countries))
mergeCountryDirectory(byID, app.AppCode, countries)
byApp[app.AppCode] = byID
}
return byApp
}
// mergeCountryDirectory 将一次 App 查询投影为 BI 展示字段ID=0 是统计未知值,不伪造国家主数据。
// 同一 App 返回重复 ID 时保留第一项,避免异常目录响应在同一次请求内反复覆盖展示语义。
func mergeCountryDirectory(byID map[int64]countryDirectoryEntry, appCode string, countries []*userclient.Country) {
for _, country := range countries {
if country == nil || country.CountryID <= 0 {
continue
}
if _, exists := byID[country.CountryID]; exists {
continue
}
byID[country.CountryID] = countryDirectoryEntry{
AppCode: appctx.Normalize(appCode),
ID: country.CountryID,
Code: strings.TrimSpace(country.CountryCode),
Name: strings.TrimSpace(country.CountryName),
DisplayName: strings.TrimSpace(country.CountryDisplayName),
Flag: strings.TrimSpace(country.Flag),
Enabled: country.Enabled,
}
}
}
// enrichCountryRows 复制统计行后补展示字段,不能原地修改 statisticsOverview 的共享缓存。
func enrichCountryRows(rows []map[string]any, countries map[int64]countryDirectoryEntry, preserveSourceNames bool) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, source := range rows {
row := make(map[string]any, len(source)+4)
for key, value := range source {
row[key] = value
}
enrichCountryRow(row, countries, preserveSourceNames)
out = append(out, row)
}
return out
}
func enrichCountryRow(row map[string]any, countries map[int64]countryDirectoryEntry, preserveSourceNames bool) {
// 外接 legacy 源可能使用 country_name、country 或 country_code 标识国家;任一字段存在时都以源数据为准,
// 避免它携带的本地数字 ID 碰巧等于某个 HyApp 全局 ID 后被错误覆盖。
if preserveSourceNames {
sourceDisplayName := rowString(row, "country_display_name")
sourceName := rowString(row, "country_name", "country")
sourceCode := rowString(row, "country_code")
if firstNonEmptyString(sourceDisplayName, sourceName, sourceCode) != "" {
row["country_name"] = firstNonEmptyString(sourceDisplayName, sourceName, sourceCode)
setCountryFieldIfEmpty(row, "country_code", "")
// 字段必须存在供前端统一读取,但不把 legacy 的英文名伪装成原始中文 display_name。
setCountryFieldIfEmpty(row, "country_display_name", "")
setCountryFieldIfEmpty(row, "flag", "")
return
}
// legacy 的数字 ID 由外部数据源定义,不能碰巧命中 HyApp ID 后借用其名称;
// 源没有返回名称/国家码时明确保持未知,等待该源补齐自己的展示契约。
row["country_name"] = unknownCountryName
setCountryFieldIfEmpty(row, "country_code", "")
setCountryFieldIfEmpty(row, "country_display_name", unknownCountryName)
setCountryFieldIfEmpty(row, "flag", "")
return
}
countryID, hasCountryID := rowInt64(row, "country_id")
country, found := countries[countryID]
if !hasCountryID || countryID <= 0 || !found {
// 0 和目录未命中都属于未知维度;保留原 country_id 便于继续追查事实来源,不伪造 ID 或国家码。
setCountryFieldIfEmpty(row, "country_code", "")
row["country_name"] = unknownCountryName
setCountryFieldIfEmpty(row, "country_display_name", unknownCountryName)
setCountryFieldIfEmpty(row, "flag", "")
return
}
row["country_code"] = country.Code
// HyApp 数字统计行以 country_id 对应的主数据为准country_display_name 保持原始字段,
// country_name 则按中文展示名、英文名、国家码依次 fallback供现有前端直接渲染。
row["country_display_name"] = country.DisplayName
row["flag"] = country.Flag
row["country_name"] = firstNonEmptyString(
country.DisplayName,
country.Name,
country.Code,
unknownCountryName,
)
}
func setCountryFieldIfEmpty(row map[string]any, key string, fallback string) {
if rowString(row, key) == "" {
row[key] = fallback
}
} }
// regionCatalog 汇总每个 App 的区域目录hyapp App 共用 user-service 的区域主数据, // regionCatalog 汇总每个 App 的区域目录hyapp App 共用 user-service 的区域主数据,

View File

@ -2,9 +2,13 @@ package databi
import ( import (
"context" "context"
"errors"
"reflect"
"testing" "testing"
"time" "time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
) )
@ -102,3 +106,143 @@ func TestRegionDisplayToleratesRoundedIDs(t *testing.T) {
t.Fatalf("expected rounded id to match catalog") t.Fatalf("expected rounded id to match catalog")
} }
} }
func TestMergeAndEnrichCountryDirectory(t *testing.T) {
laluDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(laluDirectory, "lalu", []*userclient.Country{
{CountryID: 158, CountryCode: "PK", CountryName: "Pakistan", CountryDisplayName: "巴基斯坦", Flag: "🇵🇰", Enabled: true},
// disabled 国家仍可能出现在历史统计中,必须进入目录。
{CountryID: 157, CountryCode: "BS", CountryName: "Philippines", CountryDisplayName: "菲律宾", Enabled: false},
// country_id=0 是未知维度,不允许目录把它伪装成真实国家。
{CountryID: 0, CountryCode: "UNKNOWN", CountryName: "Unknown", Enabled: true},
})
famiDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(famiDirectory, "fami", []*userclient.Country{
{CountryID: 1163, CountryCode: "BD", CountryName: "Bangladesh", CountryDisplayName: "孟加拉国", Enabled: true},
})
huwaaDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(huwaaDirectory, "huwaa", []*userclient.Country{
{CountryID: 953, CountryCode: "IN", CountryName: "India", CountryDisplayName: "印度", Enabled: true},
})
if len(laluDirectory) != 2 {
t.Fatalf("expected two lalu country ids, got %#v", laluDirectory)
}
if country := laluDirectory[158]; country.AppCode != "lalu" || country.Name != "Pakistan" {
t.Fatalf("expected lalu country retained, got %+v", country)
}
if country := laluDirectory[157]; country.Enabled {
t.Fatalf("expected disabled country retained, got %+v", country)
}
sourceRows := []map[string]any{
{"country_id": int64(158)},
{"country_id": int64(1163)},
{"country_id": int64(953)},
{"country_id": int64(0)},
{"country_id": int64(9999)},
}
rows := enrichCountryRows(sourceRows, laluDirectory, false)
if got := rowString(rows[0], "country_name"); got != "巴基斯坦" {
t.Fatalf("expected display name to win for country 158, got %q", got)
}
for _, index := range []int{1, 2, 3, 4} {
if got := rowString(rows[index], "country_name"); got != unknownCountryName {
t.Fatalf("expected row %d to remain unknown in lalu scope, got %q", index, got)
}
if got := rowString(rows[index], "country_display_name"); got != unknownCountryName {
t.Fatalf("expected row %d unknown display name, got %q", index, got)
}
}
if got := rowString(enrichCountryRows([]map[string]any{{"country_id": int64(1163)}}, famiDirectory, false)[0], "country_name"); got != "孟加拉国" {
t.Fatalf("expected fami country resolved only in fami scope, got %q", got)
}
if got := rowString(enrichCountryRows([]map[string]any{{"country_id": int64(953)}}, huwaaDirectory, false)[0], "country_name"); got != "印度" {
t.Fatalf("expected huwaa country resolved only in huwaa scope, got %q", got)
}
if _, mutated := sourceRows[0]["country_name"]; mutated {
t.Fatalf("expected enrichment to copy shared statistics rows")
}
legacyRows := enrichCountryRows([]map[string]any{
{
"country_id": int64(158),
"country_code": "LEGACY-PK",
"country_name": "Legacy Pakistan",
},
{"country_id": int64(158), "country": "Legacy country field"},
{"country_id": int64(158), "country_code": "LEGACY-CODE"},
{"country_id": int64(158)},
}, laluDirectory, true)
if got := rowString(legacyRows[0], "country_name"); got != "Legacy Pakistan" {
t.Fatalf("expected legacy source name preserved, got %q", got)
}
if got := rowString(legacyRows[0], "country_code"); got != "LEGACY-PK" {
t.Fatalf("expected legacy source code preserved, got %q", got)
}
if got := rowString(legacyRows[1], "country_name"); got != "Legacy country field" {
t.Fatalf("expected legacy country field preserved, got %q", got)
}
if got := rowString(legacyRows[2], "country_name"); got != "LEGACY-CODE" {
t.Fatalf("expected legacy code used instead of colliding HyApp id, got %q", got)
}
if got := rowString(legacyRows[3], "country_name"); got != unknownCountryName {
t.Fatalf("legacy numeric id must not borrow HyApp country name, got %q", got)
}
}
type countryDirectoryClientStub struct {
userclient.Client
countries map[string][]*userclient.Country
errors map[string]error
calls []string
filtered bool
}
func (c *countryDirectoryClientStub) ListCountries(ctx context.Context, req userclient.ListCountriesRequest) ([]*userclient.Country, error) {
appCode := appctx.FromContext(ctx)
c.calls = append(c.calls, appCode)
if req.Enabled != nil {
c.filtered = true
}
if err := c.errors[appCode]; err != nil {
return nil, err
}
return c.countries[appCode], nil
}
func TestCountryDirectoriesQueryEachAllowedHyappAndContinueAfterFailure(t *testing.T) {
client := &countryDirectoryClientStub{
countries: map[string][]*userclient.Country{
"fami": {{CountryID: 1163, CountryCode: "BD", CountryName: "Bangladesh", CountryDisplayName: "孟加拉国", Enabled: true}},
"lalu": {{CountryID: 158, CountryCode: "PK", CountryName: "Pakistan", CountryDisplayName: "巴基斯坦", Enabled: true}},
},
errors: map[string]error{"huwaa": errors.New("temporary user-service failure")},
}
service := NewService(nil, nil, nil, client)
directories := service.countryDirectories(context.Background(), []AppInfo{
{AppCode: "lalu", Kind: appKindHyapp},
{AppCode: "aslan", Kind: appKindLegacy},
{AppCode: "huwaa", Kind: appKindHyapp},
{AppCode: "fami", Kind: appKindHyapp},
})
if want := []string{"fami", "huwaa", "lalu"}; !reflect.DeepEqual(client.calls, want) {
t.Fatalf("expected each HyApp queried with its own context, got %v want %v", client.calls, want)
}
if client.filtered {
t.Fatalf("expected enabled filter omitted so disabled history can resolve")
}
if _, ok := directories["fami"][1163]; !ok {
t.Fatalf("expected successful app before failure retained")
}
if _, ok := directories["lalu"][158]; !ok {
t.Fatalf("expected apps after failure still queried")
}
if _, ok := directories["huwaa"]; ok {
t.Fatalf("failed app must not receive another app's directory: %#v", directories)
}
if len(directories) != 2 {
t.Fatalf("unexpected directories after one app failure: %#v", directories)
}
}

View File

@ -0,0 +1,110 @@
package externaladmin
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestDisableAccountRevokesEveryActiveSession(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(externalAccountRows())
adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*status.* WHERE `id` = \\?").
WillReturnResult(sqlmock.NewResult(0, 1))
// No session id predicate is allowed here: disabling an account must revoke
// every still-active session for that account, including other devices.
adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0").
WithArgs("account_disabled", sqlmock.AnyArg(), uint64(7)).
WillReturnResult(sqlmock.NewResult(0, 3))
adminMock.ExpectCommit()
expectReloadedAccountAndLinkedUser(adminMock, userMock)
account, err := service.SetStatus(t.Context(), "fami", 7, "disabled")
if err != nil {
t.Fatalf("disable account: %v", err)
}
if account.Status != "disabled" {
t.Fatalf("status = %q", account.Status)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func TestResetPasswordRevokesEveryActiveSession(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(externalAccountRows())
adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*password_change_required.*password_hash.* WHERE `id` = \\?").
WillReturnResult(sqlmock.NewResult(0, 1))
// A reset is an administrator credential replacement, so even the currently
// active browser must re-authenticate with the temporary password.
adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0").
WithArgs("password_reset", sqlmock.AnyArg(), uint64(7)).
WillReturnResult(sqlmock.NewResult(0, 3))
adminMock.ExpectCommit()
expectReloadedAccountAndLinkedUser(adminMock, userMock)
account, err := service.ResetPassword(t.Context(), "fami", 7, "replacement-password")
if err != nil {
t.Fatalf("reset password: %v", err)
}
if account.LinkedUser.UserID != 9001 {
t.Fatalf("linked user = %d", account.LinkedUser.UserID)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func newAccountMutationFixture(t *testing.T) (*Service, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) {
t.Helper()
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
adminSQL.Close()
t.Fatalf("create admin gorm: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
adminSQL.Close()
t.Fatalf("create user sql mock: %v", err)
}
return NewService(adminDB, userDB, Config{}), adminMock, userMock, func() {
_ = userDB.Close()
_ = adminSQL.Close()
}
}
func externalAccountRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", "hash", `[]`, "disabled", true, 0, 0, 1, 1, 1)
}
func expectReloadedAccountAndLinkedUser(adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) {
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\?").
WithArgs(uint64(7), uint64(7), 1).WillReturnRows(externalAccountRows())
userMock.ExpectQuery("SELECT u.user_id,").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)).
WillReturnRows(sqlmock.NewRows([]string{
"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status",
}).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active"))
}
func assertAccountMutationExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) {
t.Helper()
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations: %v", err)
}
}

View File

@ -0,0 +1,206 @@
package externaladmin
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/appctx"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/security"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestChangePasswordRejectsCurrentPasswordWithoutClearingGateOrRevokingSessions(t *testing.T) {
service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
defer cleanup()
err := service.ChangePassword(t.Context(), SessionPrincipal{
AccountID: 7, SessionID: 21, AppCode: "fami",
}, ChangePasswordInput{CurrentPassword: "initial-password", NewPassword: "initial-password"})
if !errors.Is(err, ErrPasswordReused) {
t.Fatalf("change password error = %v, want ErrPasswordReused", err)
}
// The mock intentionally has no UPDATE expectation. Any attempt to clear
// password_change_required or revoke sessions makes the transaction/test fail.
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("password reuse performed a write: %v", err)
}
}
func TestChangePasswordHandlerMapsPasswordReuseToExplicitBadRequest(t *testing.T) {
service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
defer cleanup()
handler := &Handler{service: service}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.POST("/change-password", func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
c.Next()
}, handler.ChangePassword)
body := bytes.NewBufferString(`{"currentPassword":"initial-password","newPassword":"initial-password"}`)
request := httptest.NewRequest(http.MethodPost, "/change-password", body)
request.Header.Set("Content-Type", "application/json")
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
var responseBody struct {
Message string `json:"message"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
t.Fatalf("decode response: %v", err)
}
if responseBody.Message != "新密码不能与当前密码相同" {
t.Fatalf("message = %q", responseBody.Message)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("password reuse handler performed a write: %v", err)
}
}
func TestAllPasswordWritersRejectWhitespaceOnlyCredential(t *testing.T) {
// A non-nil inert DB is enough because all three methods must reject the shared
// validation error before any App/user lookup or database write can run.
service := NewService(&gorm.DB{}, nil, Config{})
tests := []struct {
name string
call func() error
}{
{
name: "create",
call: func() error {
_, err := service.CreateAccount(t.Context(), CreateInput{
AppCode: "fami", TargetUserID: "123456", Username: "operator", Password: " ", CreatedByAdminID: 1,
})
return err
},
},
{
name: "reset",
call: func() error {
_, err := service.ResetPassword(t.Context(), "fami", 7, " ")
return err
},
},
{
name: "change",
call: func() error {
return service.ChangePassword(t.Context(), SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}, ChangePasswordInput{
CurrentPassword: "initial-password", NewPassword: " ",
})
},
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
if err := testCase.call(); !errors.Is(err, ErrPasswordBlank) {
t.Fatalf("error = %v, want ErrPasswordBlank", err)
}
})
}
}
func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T) {
tests := []struct {
name string
path string
body string
wantMessage string
register func(*gin.Engine, *Handler)
}{
{
name: "create", path: "/create", wantMessage: "密码不能全部为空白字符",
body: `{"targetUserId":"123456","username":"operator","password":" "}`,
register: func(engine *gin.Engine, handler *Handler) {
engine.POST("/create", func(c *gin.Context) {
c.Set(adminmiddleware.ContextUserID, uint(1))
c.Set(adminmiddleware.ContextPermissions, []string{"external-admin-user:permissions"})
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Next()
}, handler.CreateAccount)
},
},
{
name: "reset", path: "/reset/7", wantMessage: "密码不能全部为空白字符",
body: `{"password":" "}`,
register: func(engine *gin.Engine, handler *Handler) {
engine.POST("/reset/:id", func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Next()
}, handler.ResetPassword)
},
},
{
name: "change", path: "/change", wantMessage: "新密码不能全部为空白字符",
body: `{"currentPassword":"initial-password","newPassword":" "}`,
register: func(engine *gin.Engine, handler *Handler) {
engine.POST("/change", func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
c.Next()
}, handler.ChangePassword)
},
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &Handler{service: NewService(&gorm.DB{}, nil, Config{})}
engine := gin.New()
testCase.register(engine, handler)
request := httptest.NewRequest(http.MethodPost, testCase.path, bytes.NewBufferString(testCase.body))
request.Header.Set("Content-Type", "application/json")
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
var responseBody struct {
Message string `json:"message"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
t.Fatalf("decode response: %v", err)
}
if responseBody.Message != testCase.wantMessage {
t.Fatalf("message = %q, want %q", responseBody.Message, testCase.wantMessage)
}
})
}
}
func newPasswordReuseFixture(t *testing.T, currentPassword string) (*Service, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock: %v", err)
}
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
sqlDB.Close()
t.Fatalf("create gorm db: %v", err)
}
passwordHash, err := security.HashPassword(currentPassword)
if err != nil {
sqlDB.Close()
t.Fatalf("hash fixture password: %v", err)
}
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", true, 0, 0, 1, 1, 1))
mock.ExpectCommit()
return NewService(adminDB, nil, Config{}), mock, func() { _ = sqlDB.Close() }
}

View File

@ -0,0 +1,123 @@
package externaladmin
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
adminmiddleware "hyapp-admin-server/internal/middleware"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestCreateAccountRejectsUsernameAlreadyUsedByAnotherApp(t *testing.T) {
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
defer adminSQL.Close()
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
t.Fatalf("create admin gorm: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock: %v", err)
}
defer userDB.Close()
// The check intentionally has no app_code predicate. It avoids owner-DB and
// bcrypt work for an obvious conflict; migration 100's unique index closes the
// concurrent-create race after this advisory check.
adminMock.ExpectQuery("SELECT `id` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
WithArgs("shared-operator", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(9))
service := NewService(adminDB, userDB, Config{})
_, err = service.CreateAccount(t.Context(), CreateInput{
AppCode: "lalu", TargetUserID: "8888", Username: "Shared-Operator",
Password: "temporary-password", CreatedByAdminID: 1,
})
if !errors.Is(err, ErrAccountConflict) {
t.Fatalf("create error = %v, want global username conflict", err)
}
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user DB must not be queried after global conflict: %v", err)
}
}
func TestLoginIgnoresLegacyRequestAppAndAuditsUnknownAccountWithoutTenant(t *testing.T) {
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
defer adminSQL.Close()
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
t.Fatalf("create admin gorm: %v", err)
}
adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
WithArgs("missing-operator", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"}))
// Unknown names use an empty app_code sentinel. The legacy request value "lalu"
// must not be copied into security audit data or used to select a tenant.
adminMock.ExpectBegin()
adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`").
WithArgs(nil, "", "missing-operator", "192.0.2.10", "", "failed", "invalid_credentials", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
adminMock.ExpectCommit()
gin.SetMode(gin.TestMode)
engine := gin.New()
// Production installs the global App middleware before external routes. Login
// must remove its fallback "lalu" response header until credentials resolve.
engine.Use(adminmiddleware.AppCode())
handler := New(adminDB, nil, Config{}, nil, WithLoginRateLimiter(allowFixedWindowLimiter{}))
RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(`{"appCode":"lalu","account":"missing-operator","password":"wrong-password"}`))
request.Header.Set("Content-Type", "application/json")
request.RemoteAddr = "192.0.2.10:12345"
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if got := responseRecorder.Header().Get("X-App-Code"); got != "" {
t.Fatalf("failed tenant-less login exposed default App header %q", got)
}
if !strings.Contains(responseRecorder.Body.String(), `"code":40100`) || strings.Contains(responseRecorder.Body.String(), "App") {
t.Fatalf("login response must expose only stable auth code and generic text: %s", responseRecorder.Body.String())
}
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}
}
func TestGlobalUsernameMigrationIsOnlineAndFailClosed(t *testing.T) {
body, err := os.ReadFile("../../../migrations/100_external_admin_global_username.sql")
if err != nil {
t.Fatalf("read migration 100: %v", err)
}
sqlText := string(body)
for _, token := range []string{
"ADD UNIQUE KEY uk_external_admin_accounts_username (username)",
"DROP INDEX uk_external_admin_accounts_app_username",
"ALGORITHM=INPLACE", "LOCK=NONE",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("migration 100 missing %q", token)
}
}
if strings.Contains(strings.ToUpper(sqlText), "UPDATE EXTERNAL_ADMIN_ACCOUNTS") || strings.Contains(strings.ToUpper(sqlText), "DELETE FROM EXTERNAL_ADMIN_ACCOUNTS") {
t.Fatal("migration must not silently rename or delete duplicate credentials")
}
}

View File

@ -0,0 +1,514 @@
package externaladmin
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
userv1 "hyapp.local/api/proto/user/v1"
)
type Handler struct {
service *Service
cfg Config
audit shared.OperationLogger
userHost userv1.UserHostServiceClient
}
type HandlerOption func(*Handler)
// WithUserHostClient enables only the external portal's explicitly whitelisted
// owner-service reads. The raw client is not exposed to generic proxy routing.
func WithUserHostClient(client userv1.UserHostServiceClient) HandlerOption {
return func(handler *Handler) {
handler.userHost = client
}
}
func WithLoginRateLimiter(limiter FixedWindowLimiter) HandlerOption {
return func(handler *Handler) {
handler.service.limiter = limiter
}
}
func New(db *gorm.DB, userDB *sql.DB, cfg Config, audit shared.OperationLogger, options ...HandlerOption) *Handler {
handler := &Handler{service: NewService(db, userDB, cfg), cfg: cfg, audit: audit}
for _, option := range options {
if option != nil {
option(handler)
}
}
return handler
}
const (
myTeamAgencyRPCPageSize = int32(5000)
myTeamAgencyRPCTimeout = 5 * time.Second
maxLoginBodyBytes = int64(8 << 10)
)
type createAccountRequest struct {
TargetUserID FlexibleString `json:"targetUserId" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Permissions optionalPermissionSelection `json:"permissions"`
}
type statusRequest struct {
Status string `json:"status" binding:"required"`
}
type passwordRequest struct {
Password string `json:"password" binding:"required"`
}
type loginRequest struct {
Account string `json:"account"`
Username string `json:"username"`
Password string `json:"password"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword" binding:"required"`
}
func (h *Handler) ListAccounts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSizeRaw := c.Query("pageSize")
if pageSizeRaw == "" {
pageSizeRaw = c.DefaultQuery("page_size", "20")
}
pageSize, _ := strconv.Atoi(pageSizeRaw)
result, err := h.service.ListAccounts(c.Request.Context(), ListInput{
AppCode: appctx.FromContext(c.Request.Context()), Page: page, PageSize: pageSize,
Keyword: c.Query("keyword"), Status: c.Query("status"),
})
if err != nil {
response.ServerError(c, "获取外管用户失败")
return
}
response.OK(c, result)
}
func (h *Handler) ResolveTarget(c *gin.Context) {
target := strings.TrimSpace(c.Query("display_user_id"))
if target == "" {
target = strings.TrimSpace(c.Query("displayUserId"))
}
user, err := h.service.ResolveTarget(c.Request.Context(), appctx.FromContext(c.Request.Context()), target)
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "请输入用户短 ID")
return
}
if errors.Is(err, ErrTargetNotFound) {
response.NotFound(c, "当前 App 未找到该用户")
return
}
if err != nil {
response.ServerError(c, "查询用户失败")
return
}
existing, err := h.service.FindAccountByLinkedUser(c.Request.Context(), appctx.FromContext(c.Request.Context()), user)
if err != nil {
response.ServerError(c, "查询外管用户绑定失败")
return
}
response.OK(c, gin.H{"linkedUser": user, "existingExternalAdminUser": existing})
}
func (h *Handler) CreateAccount(c *gin.Context) {
var request createAccountRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "创建参数不正确")
return
}
// Every created account receives a permission snapshot, including the legacy
// omitted-field shape which expands to the full default. Recheck delegation here
// as defense in depth for direct handler mounting and future route refactors.
if !middleware.HasPermission(c, "external-admin-user:permissions") {
response.Forbidden(c, "没有配置外管用户权限的权限")
return
}
account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{
AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID),
Username: request.Username, Password: request.Password, Permissions: request.Permissions.Value,
PermissionsSet: request.Permissions.Set, CreatedByAdminID: middleware.CurrentUserID(c),
})
if errors.Is(err, ErrAccountConflict) {
response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在")
return
}
if errors.Is(err, ErrTargetNotFound) {
response.NotFound(c, "当前 App 未找到该用户")
return
}
if errors.Is(err, ErrPasswordBlank) {
response.BadRequest(c, "密码不能全部为空白字符")
return
}
if errors.Is(err, ErrInvalidPermissions) {
response.BadRequest(c, permissionValidationMessage(err))
return
}
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "账户名称格式不正确,或密码超过 72 字节")
return
}
if err != nil {
response.ServerError(c, "创建外管用户失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s permission_count=%d", account.AppCode, account.LinkedUser.UserID, account.Username, len(account.Permissions)))
response.Created(c, account)
}
func (h *Handler) SetStatus(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
return
}
var request statusRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "状态参数不正确")
return
}
account, err := h.service.SetStatus(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Status)
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "状态仅支持 active 或 disabled")
return
}
if errors.Is(err, ErrAccountNotFound) {
response.NotFound(c, "外管用户不存在")
return
}
if err != nil {
response.ServerError(c, "更新外管用户状态失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-external-admin-user-status", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s status=%s sessions_revoked=%t", account.AppCode, account.Status, account.Status == "disabled"))
response.OK(c, account)
}
func (h *Handler) ResetPassword(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
return
}
var request passwordRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "密码参数不正确")
return
}
account, err := h.service.ResetPassword(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Password)
if errors.Is(err, ErrPasswordBlank) {
response.BadRequest(c, "密码不能全部为空白字符")
return
}
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "密码不能超过 72 字节")
return
}
if errors.Is(err, ErrAccountNotFound) {
response.NotFound(c, "外管用户不存在")
return
}
if err != nil {
response.ServerError(c, "重置外管用户密码失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "reset-external-admin-user-password", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s sessions_revoked=true", account.AppCode))
response.OK(c, account)
}
func (h *Handler) Login(c *gin.Context) {
// The global App middleware supplies the main-admin default before this anonymous
// route runs. No tenant is authoritative until a credential row resolves, so do
// not leak or imply that default on malformed, throttled or failed login responses.
c.Writer.Header().Del(appctx.HeaderAppCode)
// Limit parsing memory before decoding untrusted credentials. Binding tags are
// intentionally absent on loginRequest: every syntactically valid JSON attempt,
// including missing fields, must reach the distributed limiter before rejection.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxLoginBodyBytes)
var request loginRequest
if err := c.ShouldBindJSON(&request); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
response.PayloadTooLarge(c, "登录请求体不能超过 8 KiB")
return
}
response.BadRequest(c, "登录参数不正确")
return
}
username := strings.TrimSpace(request.Account)
if username == "" {
username = request.Username
}
result, err := h.service.Login(c.Request.Context(), LoginInput{
Username: username, Password: request.Password,
IP: c.ClientIP(), UserAgent: c.Request.UserAgent(),
})
var rateLimitErr *LoginRateLimitError
if errors.As(err, &rateLimitErr) {
retrySeconds := int64((rateLimitErr.RetryAfter + time.Second - 1) / time.Second)
if retrySeconds < 1 {
retrySeconds = 1
}
c.Header("Retry-After", strconv.FormatInt(retrySeconds, 10))
response.TooManyRequests(c, "登录尝试过于频繁,请稍后重试")
return
}
if errors.Is(err, ErrLoginLimiterFailed) {
response.ServiceUnavailable(c, "登录安全服务暂不可用")
return
}
if errors.Is(err, ErrInvalidCredentials) {
// App is deliberately absent from both input and error text. The account row is
// the only tenant authority, while the stable 401/40100 pair lets every locale
// render its own generic message without parsing this server-side fallback.
response.Unauthorized(c, "账号或密码错误")
return
}
if err != nil {
response.ServerError(c, "登录服务暂不可用")
return
}
// The global App middleware cannot know the tenant before authentication and
// therefore writes its generic default header. Replace it with the App resolved
// from the credential row so the successful response has one consistent tenant.
c.Header(appctx.HeaderAppCode, result.View.AppCode)
h.setSessionCookies(c, result.SessionToken, result.CSRFToken)
c.Header(CSRFHeaderName, result.CSRFToken)
response.OK(c, result.View)
}
func (h *Handler) Me(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
return
}
csrfToken := CurrentCSRFToken(c)
view, err := h.service.Me(c.Request.Context(), principal, csrfToken)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, sql.ErrNoRows) || errors.Is(err, ErrTargetNotFound) {
h.clearSessionCookies(c)
response.Unauthorized(c, "外管会话已失效")
} else {
response.ServerError(c, "获取外管会话失败")
}
return
}
if csrfToken != "" {
c.Header(CSRFHeaderName, csrfToken)
}
response.OK(c, view)
}
func (h *Handler) Logout(c *gin.Context) {
principal, _ := CurrentPrincipal(c)
if err := h.service.RevokeSession(c.Request.Context(), principal.SessionID, "logout"); err != nil {
response.ServerError(c, "退出登录失败")
return
}
h.clearSessionCookies(c)
response.OK(c, gin.H{"loggedOut": true})
}
func (h *Handler) ChangePassword(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
return
}
var request changePasswordRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "密码参数不正确")
return
}
currentPassword := request.CurrentPassword
if strings.TrimSpace(currentPassword) == "" {
currentPassword = request.OldPassword
}
err := h.service.ChangePassword(c.Request.Context(), principal, ChangePasswordInput{CurrentPassword: currentPassword, NewPassword: request.NewPassword})
if errors.Is(err, ErrInvalidCredentials) {
response.BadRequest(c, "当前密码不正确")
return
}
if errors.Is(err, ErrPasswordReused) {
response.BadRequest(c, "新密码不能与当前密码相同")
return
}
if errors.Is(err, ErrPasswordBlank) {
response.BadRequest(c, "新密码不能全部为空白字符")
return
}
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "新密码不能超过 72 字节")
return
}
if err != nil {
response.ServerError(c, "修改密码失败")
return
}
response.OK(c, gin.H{"passwordChanged": true})
}
// ListMyTeamAgencies returns only the team rooted at the App user bound to this
// external account. Deliberately do not read manager_user_id/user_id from query or
// path parameters: otherwise a valid external session could enumerate another
// manager's organization tree by changing one client-controlled value.
func (h *Handler) ListMyTeamAgencies(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok || principal.LinkedAppUserID <= 0 {
response.Unauthorized(c, "外管会话已失效")
return
}
if h.userHost == nil {
response.ServerError(c, "团队服务暂不可用")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSizeRaw := strings.TrimSpace(c.Query("pageSize"))
if pageSizeRaw == "" {
pageSizeRaw = c.DefaultQuery("page_size", "20")
}
pageSize, _ := strconv.Atoi(pageSizeRaw)
page, pageSize = normalizePage(page, pageSize)
// The owner service resolves the complete active manager tree up to its
// defensive 5,000-row cap. Local slicing keeps total and pagination mutually
// consistent, while truncated explicitly tells clients when the owner cap hit.
ctx, cancel := context.WithTimeout(c.Request.Context(), myTeamAgencyRPCTimeout)
defer cancel()
result, err := h.userHost.ListManagerTeamAgencies(ctx, &userv1.ListManagerTeamAgenciesRequest{
Meta: &userv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "hyapp-admin-server-external",
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: principal.AppCode,
ClientIp: c.ClientIP(),
UserAgent: truncateText(c.Request.UserAgent(), 512),
},
ManagerUserId: principal.LinkedAppUserID,
PageSize: myTeamAgencyRPCPageSize,
})
if err != nil {
if status.Code(err) == codes.PermissionDenied {
response.Forbidden(c, "当前绑定用户不是有效的团队管理员")
return
}
response.ServerError(c, "获取我的团队失败")
return
}
allItems := make([]MyTeamAgency, 0, len(result.GetAgencies()))
for _, agency := range result.GetAgencies() {
if agency == nil || agency.GetAgencyId() <= 0 {
continue
}
allItems = append(allItems, MyTeamAgency{
AgencyID: agency.GetAgencyId(), OwnerUserID: agency.GetOwnerUserId(),
ParentBDUserID: agency.GetParentBdUserId(), Status: "active",
})
}
// The owner RPC intentionally returns only relation IDs. Honor the UI's keyword
// without widening scope or scanning user tables: match only IDs already present
// in this session-scoped result, then calculate total and pagination from that set.
allItems = filterMyTeamAgencies(allItems, c.Query("keyword"))
items := paginateMyTeamAgencies(allItems, page, pageSize)
response.OK(c, MyTeamAgencyPage{
Items: items, Page: page, PageSize: pageSize, Total: len(allItems),
TotalBDLeaders: result.GetTotalBdLeaders(), TotalBDs: result.GetTotalBds(), Truncated: result.GetTruncated(),
})
}
func filterMyTeamAgencies(items []MyTeamAgency, keyword string) []MyTeamAgency {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return items
}
filtered := make([]MyTeamAgency, 0, len(items))
for _, item := range items {
if strings.Contains(strconv.FormatInt(item.AgencyID, 10), keyword) ||
strings.Contains(strconv.FormatInt(item.OwnerUserID, 10), keyword) ||
strings.Contains(strconv.FormatInt(item.ParentBDUserID, 10), keyword) {
filtered = append(filtered, item)
}
}
return filtered
}
func paginateMyTeamAgencies(items []MyTeamAgency, page int, pageSize int) []MyTeamAgency {
if len(items) == 0 {
return []MyTeamAgency{}
}
lastPage := (len(items) + pageSize - 1) / pageSize
if page > lastPage {
return []MyTeamAgency{}
}
start := (page - 1) * pageSize
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func (h *Handler) setSessionCookies(c *gin.Context, sessionToken string, csrfToken string) {
maxAge := int(h.cfg.SessionTTL.Seconds())
if maxAge <= 0 {
maxAge = int((12 * time.Hour).Seconds())
}
h.writeCookie(c, SessionCookieName, sessionToken, true, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second))
h.writeCookie(c, CSRFCookieName, csrfToken, false, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second))
}
func (h *Handler) clearSessionCookies(c *gin.Context) {
expired := time.Unix(1, 0).UTC()
h.writeCookie(c, SessionCookieName, "", true, -1, expired)
h.writeCookie(c, CSRFCookieName, "", false, -1, expired)
}
func (h *Handler) writeCookie(c *gin.Context, name string, value string, httpOnly bool, maxAge int, expires time.Time) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name, Value: value, Path: CookiePath, MaxAge: maxAge, Expires: expires,
HttpOnly: httpOnly, Secure: h.cfg.CookieSecure, SameSite: parseSameSite(h.cfg.CookieSameSite),
})
}
func parseSameSite(value string) http.SameSite {
switch strings.ToLower(strings.TrimSpace(value)) {
case "strict":
return http.SameSiteStrictMode
case "none":
return http.SameSiteNoneMode
default:
return http.SameSiteLaxMode
}
}
func parseUint64Param(c *gin.Context, name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 参数不正确")
return 0, false
}
return id, true
}

View File

@ -0,0 +1,194 @@
package externaladmin
import (
"crypto/subtle"
"errors"
"net/http"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/response"
"hyapp-admin-server/internal/security"
"github.com/gin-gonic/gin"
)
const (
contextPrincipal = "externalAdminPrincipal"
contextCSRFToken = "externalAdminCSRFToken"
)
func (h *Handler) AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
response.Unauthorized(c, "缺少外管会话")
c.Abort()
return
}
principal, err := h.service.Authenticate(c.Request.Context(), cookie.Value)
if err != nil {
if errors.Is(err, ErrInvalidSession) {
h.clearSessionCookies(c)
response.Unauthorized(c, "外管会话已失效")
} else {
response.ServerError(c, "外管会话服务暂不可用")
}
c.Abort()
return
}
// The session is the tenant authority. A caller may omit X-App-Code, but it can never
// switch tenants by sending a header that differs from the App fixed at login.
headerAppCode := strings.ToLower(strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)))
if headerAppCode != "" && headerAppCode != principal.AppCode {
response.Forbidden(c, "请求 App 与外管会话不一致")
c.Abort()
return
}
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), principal.AppCode))
c.Header(appctx.HeaderAppCode, principal.AppCode)
c.Set(contextPrincipal, principal)
// Existing business handlers forward these generic actor fields to owner services. Use a
// collision-free synthetic identity there; the real account remains in the external audit log.
c.Set(adminmiddleware.ContextUserID, principal.OperatorID)
c.Set(adminmiddleware.ContextUsername, operatorUsername(principal.AppCode, principal.Username))
c.Set(adminmiddleware.ContextPermissions, principal.Permissions)
// The raw CSRF secret is never persisted server-side. Echo it only when the browser's
// double-submit cookie still matches the hash stored in this exact session.
if csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName); cookieErr == nil && secureEqualHash(csrfCookie.Value, principal.CSRFTokenHash) {
c.Set(contextCSRFToken, csrfCookie.Value)
}
c.Next()
}
}
func (h *Handler) RequireCSRF() gin.HandlerFunc {
return func(c *gin.Context) {
if isSafeMethod(c.Request.Method) {
c.Next()
return
}
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
c.Abort()
return
}
csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName)
headerToken := strings.TrimSpace(c.GetHeader(CSRFHeaderName))
if cookieErr != nil || headerToken == "" || !secureEqual(csrfCookie.Value, headerToken) || !secureEqualHash(headerToken, principal.CSRFTokenHash) {
response.Forbidden(c, "CSRF 校验失败")
c.Abort()
return
}
c.Set(contextCSRFToken, headerToken)
c.Next()
}
}
func (h *Handler) RequirePasswordChanged() gin.HandlerFunc {
// Deprecated helper retained for downstream source compatibility. External routes
// never install this gate, and session principals always expose the marker as false.
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
c.Abort()
return
}
if principal.PasswordChangeRequired {
response.Forbidden(c, "请先修改初始密码")
c.Abort()
return
}
c.Next()
}
}
func (h *Handler) Audit() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
if isSafeMethod(c.Request.Method) {
return
}
principal, ok := CurrentPrincipal(c)
if !ok {
return
}
status := "success"
if c.Writer.Status() >= http.StatusBadRequest {
status = "failed"
}
fullPath := c.FullPath()
if fullPath == "" {
fullPath = c.Request.URL.Path
}
_ = h.service.CreateOperationLog(c.Request.Context(), model.ExternalAdminOperationLog{
AccountID: principal.AccountID, AppCode: principal.AppCode, Username: principal.Username,
RequestID: adminmiddleware.CurrentRequestID(c), Action: strings.ToLower(c.Request.Method) + " " + fullPath,
Resource: externalAuditResource(fullPath), ResourceID: externalAuditResourceID(c),
Method: c.Request.Method, Path: c.Request.URL.Path, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(),
Status: status, HTTPStatus: c.Writer.Status(), LatencyMS: time.Since(start).Milliseconds(),
})
}
}
func CurrentPrincipal(c *gin.Context) (SessionPrincipal, bool) {
value, ok := c.Get(contextPrincipal)
if !ok {
return SessionPrincipal{}, false
}
principal, ok := value.(SessionPrincipal)
return principal, ok
}
func CurrentCSRFToken(c *gin.Context) string {
value, _ := c.Get(contextCSRFToken)
token, _ := value.(string)
return token
}
func secureEqual(left string, right string) bool {
if len(left) == 0 || len(left) != len(right) {
return false
}
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
func secureEqualHash(raw string, expectedHash string) bool {
return secureEqual(security.HashToken(raw), expectedHash)
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
func externalAuditResourceID(c *gin.Context) string {
for _, name := range []string{"id", "user_id", "room_id", "resource_id", "grant_id", "pretty_id", "banner_id"} {
if value := c.Param(name); value != "" {
return value
}
}
return ""
}
func externalAuditResource(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for index, part := range parts {
if part == "external" && index+1 < len(parts) {
return parts[index+1]
}
}
return "external-admin"
}

View File

@ -0,0 +1,271 @@
package externaladmin
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/security"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestRequireCSRFAcceptsOnlyMatchingCookieHeaderAndSessionHash(t *testing.T) {
gin.SetMode(gin.TestMode)
csrfToken := "csrf-secret"
handler := &Handler{}
engine := gin.New()
engine.POST("/write", func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{CSRFTokenHash: security.HashToken(csrfToken)})
}, handler.RequireCSRF(), func(c *gin.Context) { c.Status(http.StatusNoContent) })
validRequest := httptest.NewRequest(http.MethodPost, "/write", nil)
validRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken})
validRequest.Header.Set(CSRFHeaderName, csrfToken)
validResponse := httptest.NewRecorder()
engine.ServeHTTP(validResponse, validRequest)
if validResponse.Code != http.StatusNoContent {
t.Fatalf("valid csrf status = %d body=%s", validResponse.Code, validResponse.Body.String())
}
invalidRequest := httptest.NewRequest(http.MethodPost, "/write", nil)
invalidRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken})
invalidRequest.Header.Set(CSRFHeaderName, "different")
invalidResponse := httptest.NewRecorder()
engine.ServeHTTP(invalidResponse, invalidRequest)
if invalidResponse.Code != http.StatusForbidden {
t.Fatalf("invalid csrf status = %d body=%s", invalidResponse.Code, invalidResponse.Body.String())
}
}
func TestExternalSessionCookiesAreHostOnlyAndPathScoped(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: true, CookieSameSite: "none"}}
engine := gin.New()
engine.POST("/login", func(c *gin.Context) {
handler.setSessionCookies(c, "session-token", "csrf-token")
c.Status(http.StatusNoContent)
})
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil))
cookies := response.Result().Cookies()
if len(cookies) != 2 {
t.Fatalf("cookie count = %d, want 2", len(cookies))
}
for _, cookie := range cookies {
if cookie.Path != CookiePath || cookie.Domain != "" || !cookie.Secure || cookie.SameSite != http.SameSiteNoneMode {
t.Fatalf("unsafe cookie attributes: %+v", cookie)
}
}
if !cookieByName(cookies, SessionCookieName).HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
if cookieByName(cookies, CSRFCookieName).HttpOnly {
t.Fatal("double-submit CSRF cookie must be readable")
}
}
func TestExternalSessionCookiesAllowLocalHTTPWithLaxSameSite(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: false, CookieSameSite: "lax"}}
engine := gin.New()
engine.POST("/login", func(c *gin.Context) {
handler.setSessionCookies(c, "session-token", "csrf-token")
c.Status(http.StatusNoContent)
})
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil))
for _, cookie := range response.Result().Cookies() {
if cookie.Secure || cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("local cookie must work over HTTP with Lax SameSite: %+v", cookie)
}
}
}
func TestAuthRequiredRejectsMismatchedAppHeaderAndBindsOmittedHeaderToSessionApp(t *testing.T) {
for _, testCase := range []struct {
name string
headerApp string
wantStatus int
wantBound bool
}{
{name: "mismatch rejected", headerApp: "lalu", wantStatus: http.StatusForbidden},
{name: "omitted header bound", headerApp: "", wantStatus: http.StatusNoContent, wantBound: true},
} {
t.Run(testCase.name, func(t *testing.T) {
handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "active")
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/protected", handler.AuthRequired(), func(c *gin.Context) {
if testCase.wantBound {
principal, ok := CurrentPrincipal(c)
if !ok || principal.SessionID != 11 {
t.Fatalf("session principal = %+v ok=%t", principal, ok)
}
if got := appctx.FromContext(c.Request.Context()); got != "fami" {
t.Fatalf("request app = %q, want session app fami", got)
}
if got := c.Writer.Header().Get(appctx.HeaderAppCode); got != "fami" {
t.Fatalf("response app header = %q", got)
}
if got := adminmiddleware.CurrentUserID(c); uint64(got) != externalOperatorNamespace|7 {
t.Fatalf("synthetic operator = %d", got)
}
}
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
if testCase.headerApp != "" {
request.Header.Set(appctx.HeaderAppCode, testCase.headerApp)
}
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != testCase.wantStatus {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
assertAuthFixtureExpectations(t, adminMock, userMock)
})
}
}
func TestAuthRequiredBlocksBusinessDirectlyWhenSessionAppWasDisabled(t *testing.T) {
handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "disabled")
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
reachedBusiness := false
// Intentionally omit /auth/me: this proves a stale browser cannot bypass the
// App-state check by navigating straight to a business API.
engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) {
reachedBusiness = true
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/business", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusUnauthorized || reachedBusiness {
t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String())
}
assertAuthFixtureExpectations(t, adminMock, userMock)
}
func TestAuthRequiredFailsClosedWithoutRevokingOnAppDatabaseError(t *testing.T) {
handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "error")
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
reachedBusiness := false
engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) {
reachedBusiness = true
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/business", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"})
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusInternalServerError || reachedBusiness {
t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String())
}
// The fixture has no revocation UPDATE expectation. A transient owner-DB error
// must fail this request closed without converting a recoverable session to 401.
assertAuthFixtureExpectations(t, adminMock, userMock)
}
func TestRequirePasswordChangedBlocksBusinessRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &Handler{}
engine := gin.New()
engine.GET("/business", func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{PasswordChangeRequired: true})
c.Next()
}, handler.RequirePasswordChanged(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/business", nil))
if responseRecorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
}
func newAuthRequiredFixture(t *testing.T, rawToken string, appState string) (*Handler, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) {
t.Helper()
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
adminSQL.Close()
t.Fatalf("create gorm db: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
adminSQL.Close()
t.Fatalf("create user sql mock: %v", err)
}
nowMS := time.Now().UTC().UnixMilli()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_sessions` WHERE token_hash = \\? AND revoked_at_ms = 0 AND expires_at_ms > \\?").
WithArgs(security.HashToken(rawToken), sqlmock.AnyArg(), 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "account_id", "app_code", "token_hash", "csrf_token_hash", "ip", "user_agent", "expires_at_ms", "last_seen_at_ms", "revoked_at_ms", "revoke_reason", "created_at_ms",
}).AddRow(11, 7, "fami", security.HashToken(rawToken), security.HashToken("csrf"), "127.0.0.1", "browser", nowMS+3600000, nowMS, 0, "", nowMS))
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE `external_admin_accounts`.`id` = \\?").
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "active", false, 0, 0, 1, nowMS, nowMS))
appQuery := userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps WHERE app_code = \\? AND status = 'active' LIMIT 1").WithArgs("fami")
switch appState {
case "active":
appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
case "disabled":
appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}))
adminMock.ExpectBegin()
adminMock.ExpectExec("UPDATE `external_admin_sessions`").
WithArgs("app_disabled", sqlmock.AnyArg(), uint64(11)).
WillReturnResult(sqlmock.NewResult(0, 1))
adminMock.ExpectCommit()
case "error":
appQuery.WillReturnError(errors.New("user database temporarily unavailable"))
default:
t.Fatalf("unknown app state %q", appState)
}
return &Handler{service: NewService(adminDB, userDB, Config{})}, adminMock, userMock, func() {
_ = userDB.Close()
_ = adminSQL.Close()
}
}
func assertAuthFixtureExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) {
t.Helper()
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin auth sql expectations: %v", err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user auth sql expectations: %v", err)
}
}
func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return &http.Cookie{}
}

View File

@ -0,0 +1,344 @@
package externaladmin
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PermissionCatalogItem is the only assignable permission source for external accounts.
// Keeping route permissions, capability aliases and form metadata in one catalog prevents
// a client from persisting arbitrary main-admin permissions into an external session.
type PermissionCatalogItem struct {
Code string `json:"code"`
Label string `json:"label"`
Group string `json:"group"`
Dependencies []string `json:"dependencies"`
Capabilities []string `json:"capabilities"`
DefaultGranted bool `json:"defaultGranted"`
}
type AccountPermissionView struct {
AccountID uint64 `json:"accountId,string"`
Permissions []string `json:"permissions"`
Revision uint64 `json:"revision"`
}
type UpdatePermissionsInput struct {
Permissions []string
ExpectedRevision uint64
}
type PermissionUpdateResult struct {
Account AccountDTO
PreviousPermissions []string
Changed bool
SessionsRevoked bool
}
type PermissionValidationError struct {
Permission string
Dependency string
}
func (err *PermissionValidationError) Error() string {
if err.Dependency != "" {
return fmt.Sprintf("%s: %s requires %s", ErrInvalidPermissions, err.Permission, err.Dependency)
}
return fmt.Sprintf("%s: unsupported permission %s", ErrInvalidPermissions, err.Permission)
}
func (err *PermissionValidationError) Unwrap() error { return ErrInvalidPermissions }
var permissionCatalog = []PermissionCatalogItem{
{Code: "user:list", Label: "用户列表", Group: "用户管理", Capabilities: []string{"user:list"}, DefaultGranted: true},
{Code: "user:update", Label: "用户信息编辑", Group: "用户管理", Capabilities: []string{"user:update"}, DefaultGranted: true},
{Code: "user-ban:list", Label: "账号封禁列表", Group: "用户管理", Capabilities: []string{"user-ban:list"}, DefaultGranted: true},
{Code: "user:ban", Label: "执行封禁", Group: "用户管理", Capabilities: []string{"user:ban"}, DefaultGranted: true},
{Code: "user:unban", Label: "解除封禁", Group: "用户管理", Capabilities: []string{"user:unban"}, DefaultGranted: true},
{Code: "host:list", Label: "主播列表", Group: "组织管理", Capabilities: []string{"host:list"}, DefaultGranted: true},
{Code: "agency:list", Label: "公会列表", Group: "组织管理", Capabilities: []string{"agency:list"}, DefaultGranted: true},
{Code: "bd:list", Label: "BD 列表", Group: "组织管理", Capabilities: []string{"bd:list"}, DefaultGranted: true},
{Code: "bd-manager:list", Label: "BD Manager 列表", Group: "组织管理", Capabilities: []string{"bd-manager:list"}, DefaultGranted: true},
{Code: "super-admin:list", Label: "Super Admin 列表", Group: "组织管理", Capabilities: []string{"super-admin:list"}, DefaultGranted: true},
{Code: "team:view", Label: "我的团队", Group: "组织管理", Capabilities: []string{"team:view"}, DefaultGranted: true},
{Code: "room:list", Label: "房间管理", Group: "房间管理", Capabilities: []string{"room:list"}, DefaultGranted: true},
{Code: "room:update", Label: "房间编辑", Group: "房间管理", Capabilities: []string{"room:update"}, DefaultGranted: true},
{Code: "privilege:list", Label: "用户特权道具列表", Group: "资源与发放", Capabilities: []string{"privilege:list"}, DefaultGranted: true},
{Code: "privilege:grant", Label: "特权道具发放", Group: "资源与发放", Dependencies: []string{"user-title:grant", "room-background:grant"}, Capabilities: []string{"privilege:grant"}, DefaultGranted: true},
{Code: "pretty-id:grant", Label: "靓号下发", Group: "资源与发放", Capabilities: []string{"pretty-id:grant"}, DefaultGranted: true},
// Wallet resources currently have no authoritative user-title/room-background semantic type.
// Keep the three labels requested by the product, but make them one fail-closed assignment
// bundle until the owner model can classify them without guessing from names or URLs.
{Code: "user-title:grant", Label: "用户称号下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "room-background:grant"}, Capabilities: []string{"user-title:grant"}, DefaultGranted: true},
{Code: "room-background:grant", Label: "房间背景图下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "user-title:grant"}, Capabilities: []string{"room-background:grant"}, DefaultGranted: true},
{Code: "user-level:grant", Label: "财富/VIP等级下发", Group: "资源与发放", Capabilities: []string{"user-level:grant"}, DefaultGranted: true},
{Code: "banner:create", Label: "Banner创建", Group: "运营管理", Capabilities: []string{"banner:create"}, DefaultGranted: true},
}
// effectivePermissions upgrades the original 18 route-oriented snapshot into the
// 20 independently displayed business capabilities. New snapshots already contain
// catalog codes and pass through unchanged. Conditional legacy closures avoid turning
// a manually reduced old snapshot into broader Banner, VIP or grant access.
func effectivePermissions(stored []string) []string {
stored = normalizePermissions(stored)
storedSet := make(map[string]struct{}, len(stored))
for _, permission := range stored {
storedSet[permission] = struct{}{}
}
assignable := make(map[string]struct{}, len(permissionCatalog))
for _, item := range permissionCatalog {
assignable[item.Code] = struct{}{}
}
effective := make([]string, 0, len(permissionCatalog))
for _, permission := range stored {
if _, ok := assignable[permission]; ok {
effective = append(effective, permission)
}
}
legacyAliases := map[string][]string{
"app-user:view": {"user:list", "user-ban:list"},
"app-user:update": {"user:update"},
"app-user:status": {"user:ban", "user:unban"},
"host:view": {"host:list"},
"agency:view": {"agency:list"},
"bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"},
"room:view": {"room:list"},
"resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"},
}
for legacy, aliases := range legacyAliases {
if _, ok := storedSet[legacy]; ok {
effective = append(effective, aliases...)
}
}
if containsPermission(storedSet, "resource:view") && containsPermission(storedSet, "resource-grant:view") {
effective = append(effective, "privilege:list")
}
if containsPermission(storedSet, "app-user:level") && containsPermission(storedSet, "vip-config:grant") {
effective = append(effective, "user-level:grant")
}
if containsPermission(storedSet, "app-config:view") && containsPermission(storedSet, "app-config:update") && containsPermission(storedSet, "upload:create") {
effective = append(effective, "banner:create")
}
effective = normalizePermissions(effective)
// A hand-edited/corrupt new snapshot must not bypass the atomic grant bundle.
// Unknown codes and wildcard-looking values were already discarded above.
effectiveSet := make(map[string]struct{}, len(effective))
for _, permission := range effective {
effectiveSet[permission] = struct{}{}
}
grantBundle := []string{"privilege:grant", "user-title:grant", "room-background:grant"}
completeGrantBundle := true
for _, permission := range grantBundle {
if !containsPermission(effectiveSet, permission) {
completeGrantBundle = false
}
}
if !completeGrantBundle {
filtered := effective[:0]
for _, permission := range effective {
if permission != "privilege:grant" && permission != "user-title:grant" && permission != "room-background:grant" {
filtered = append(filtered, permission)
}
}
effective = filtered
}
return effective
}
func containsPermission(permissions map[string]struct{}, code string) bool {
_, ok := permissions[code]
return ok
}
func PermissionCatalog() []PermissionCatalogItem {
items := make([]PermissionCatalogItem, 0, len(permissionCatalog))
for _, item := range permissionCatalog {
item.Dependencies = append([]string(nil), item.Dependencies...)
item.Capabilities = append([]string(nil), item.Capabilities...)
items = append(items, item)
}
return items
}
func permissionCapabilityAliases() map[string][]string {
aliases := make(map[string][]string, len(permissionCatalog))
for _, item := range permissionCatalog {
aliases[item.Code] = item.Capabilities
}
return aliases
}
// validatePermissionSelection normalizes duplicates/order only after checking every
// code against the fixed catalog and every write dependency against the same snapshot.
// An explicit empty list is valid and represents an account with no business access.
func validatePermissionSelection(permissions []string) ([]string, error) {
normalized := normalizePermissions(permissions)
selected := make(map[string]struct{}, len(normalized))
assignable := make(map[string]PermissionCatalogItem, len(permissionCatalog))
for _, item := range permissionCatalog {
assignable[item.Code] = item
}
for _, permission := range normalized {
if _, ok := assignable[permission]; !ok {
return nil, &PermissionValidationError{Permission: permission}
}
selected[permission] = struct{}{}
}
for _, permission := range normalized {
for _, dependency := range assignable[permission].Dependencies {
if _, ok := selected[dependency]; !ok {
return nil, &PermissionValidationError{Permission: permission, Dependency: dependency}
}
}
}
return normalized, nil
}
// createPermissionSnapshot intentionally keeps omitted and explicit-empty requests
// distinct. Existing credential-creation clients omit the field and retain the full
// legacy snapshot; a permission-aware client may submit [] to create a locked-down
// account that can authenticate but cannot enter any business page.
func createPermissionSnapshot(input CreateInput) ([]string, error) {
if !input.PermissionsSet {
return DefaultPermissionSnapshot(), nil
}
return validatePermissionSelection(input.Permissions)
}
func (s *Service) GetAccountPermissions(ctx context.Context, appCode string, id uint64) (AccountPermissionView, error) {
appCode = normalizeAppCode(appCode)
if s.db == nil {
return AccountPermissionView{}, errors.New("admin mysql is not configured")
}
if appCode == "" || id == 0 {
return AccountPermissionView{}, ErrInvalidInput
}
var account model.ExternalAdminAccount
err := s.db.WithContext(ctx).Select("id", "permissions_json", "permission_revision").Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return AccountPermissionView{}, ErrAccountNotFound
}
if err != nil {
return AccountPermissionView{}, err
}
permissions, err := decodePermissions(account.PermissionsJSON)
if err != nil {
return AccountPermissionView{}, err
}
return AccountPermissionView{AccountID: account.ID, Permissions: permissions, Revision: account.PermissionRevision}, nil
}
func (s *Service) UpdateAccountPermissions(ctx context.Context, appCode string, id uint64, input UpdatePermissionsInput) (PermissionUpdateResult, error) {
appCode = normalizeAppCode(appCode)
if s.db == nil {
return PermissionUpdateResult{}, errors.New("admin mysql is not configured")
}
if appCode == "" || id == 0 || input.ExpectedRevision == 0 {
return PermissionUpdateResult{}, ErrInvalidInput
}
normalized, err := validatePermissionSelection(input.Permissions)
if err != nil {
return PermissionUpdateResult{}, err
}
encoded, err := encodePermissions(normalized)
if err != nil {
return PermissionUpdateResult{}, err
}
result := PermissionUpdateResult{}
var account model.ExternalAdminAccount
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// The account row lock serializes concurrent permission writers and makes the
// old/new audit snapshot match the exact value replaced by this transaction.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return err
}
if account.PermissionRevision != input.ExpectedRevision {
return ErrPermissionConflict
}
previous, err := decodePermissions(account.PermissionsJSON)
if err != nil {
return err
}
result.PreviousPermissions = previous
if reflect.DeepEqual(previous, normalized) {
return nil
}
if err := tx.Model(&account).Updates(map[string]any{
"permissions_json": encoded,
"permission_revision": account.PermissionRevision + 1,
}).Error; err != nil {
return err
}
// Revocation is committed atomically with the snapshot. No browser can keep a
// still-valid session after a permission reduction or silently retain a broader UI.
if err := revokeAllSessions(tx, account.ID, "permissions_changed", time.Now().UTC().UnixMilli()); err != nil {
return err
}
result.Changed = true
result.SessionsRevoked = true
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return PermissionUpdateResult{}, ErrAccountNotFound
}
if errors.Is(err, ErrPermissionConflict) {
return PermissionUpdateResult{}, ErrPermissionConflict
}
if err != nil {
return PermissionUpdateResult{}, err
}
// Clear the transaction-loaded primary key before reloading. Otherwise GORM adds a
// redundant implicit id predicate, which hides the intended App-scope contract and
// makes future key changes harder to reason about.
account = model.ExternalAdminAccount{}
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return PermissionUpdateResult{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
if err != nil {
return PermissionUpdateResult{}, err
}
result.Account = accountDTO(account, users[account.LinkedAppUserID])
return result, nil
}
func permissionValidationMessage(err error) string {
var validationErr *PermissionValidationError
if !errors.As(err, &validationErr) {
return "外管权限配置不正确"
}
permissionLabel, permissionKnown := permissionCatalogLabel(validationErr.Permission)
if !permissionKnown {
// Unsupported raw codes are implementation details and may contain attacker-
// controlled input. Keep the client error stable without reflecting that value.
return "外管权限中包含不支持的权限"
}
if validationErr.Dependency != "" {
dependencyLabel, dependencyKnown := permissionCatalogLabel(validationErr.Dependency)
if !dependencyKnown {
return "外管权限配置不正确"
}
return fmt.Sprintf("权限“%s”需同时选择“%s”", permissionLabel, dependencyLabel)
}
return "外管权限配置不正确"
}
func permissionCatalogLabel(code string) (string, bool) {
code = strings.TrimSpace(code)
for _, item := range permissionCatalog {
if item.Code == code {
return item.Label, true
}
}
return "", false
}

View File

@ -0,0 +1,122 @@
package externaladmin
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
// optionalPermissionSelection preserves the JSON distinction required by account
// creation: omitted keeps the legacy full snapshot, while an explicit [] creates a
// valid account with no business permissions. JSON null is rejected as ambiguous.
type optionalPermissionSelection struct {
Value []string
Set bool
}
func (selection *optionalPermissionSelection) UnmarshalJSON(body []byte) error {
selection.Set = true
body = bytes.TrimSpace(body)
if bytes.Equal(body, []byte("null")) {
return errors.New("permissions must be an array")
}
var permissions []string
if err := json.Unmarshal(body, &permissions); err != nil {
return err
}
selection.Value = permissions
return nil
}
type updatePermissionsRequest struct {
Permissions optionalPermissionSelection `json:"permissions"`
ExpectedRevision uint64 `json:"expectedRevision"`
}
func (h *Handler) ListPermissionCatalog(c *gin.Context) {
items := PermissionCatalog()
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) GetAccountPermissions(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
return
}
permissions, err := h.service.GetAccountPermissions(c.Request.Context(), appctx.FromContext(c.Request.Context()), id)
if errors.Is(err, ErrAccountNotFound) {
response.NotFound(c, "外管用户不存在")
return
}
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "外管用户参数不正确")
return
}
if err != nil {
response.ServerError(c, "获取外管用户权限失败")
return
}
response.OK(c, permissions)
}
func (h *Handler) UpdateAccountPermissions(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
return
}
var request updatePermissionsRequest
if err := c.ShouldBindJSON(&request); err != nil || !request.Permissions.Set || request.ExpectedRevision == 0 {
response.BadRequest(c, "权限参数不正确")
return
}
result, err := h.service.UpdateAccountPermissions(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, UpdatePermissionsInput{
Permissions: request.Permissions.Value, ExpectedRevision: request.ExpectedRevision,
})
if errors.Is(err, ErrInvalidPermissions) {
response.BadRequest(c, permissionValidationMessage(err))
return
}
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "外管用户参数不正确")
return
}
if errors.Is(err, ErrAccountNotFound) {
response.NotFound(c, "外管用户不存在")
return
}
if errors.Is(err, ErrPermissionConflict) {
response.Conflict(c, "外管用户权限已被其他管理员修改,请刷新后重试")
return
}
if err != nil {
response.ServerError(c, "更新外管用户权限失败")
return
}
shared.OperationLogWithResourceID(
c,
h.audit,
"update-external-admin-user-permissions",
"external_admin_accounts",
strconv.FormatUint(id, 10),
"success",
fmt.Sprintf(
"app_code=%s previous_permissions=%s permissions=%s revision=%d changed=%t sessions_revoked=%t",
result.Account.AppCode,
strings.Join(result.PreviousPermissions, ","),
strings.Join(result.Account.Permissions, ","),
result.Account.PermissionRevision,
result.Changed,
result.SessionsRevoked,
),
)
response.OK(c, result.Account)
}

View File

@ -0,0 +1,294 @@
package externaladmin
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"hyapp-admin-server/internal/appctx"
adminmiddleware "hyapp-admin-server/internal/middleware"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func TestPermissionCatalogMatchesTwentyProductCapabilities(t *testing.T) {
items := PermissionCatalog()
want := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view",
"room:list", "room:update", "privilege:list", "privilege:grant", "pretty-id:grant",
"user-title:grant", "room-background:grant", "user-level:grant", "banner:create",
}
if len(items) != len(want) {
t.Fatalf("catalog count = %d, want %d", len(items), len(want))
}
for index, item := range items {
if item.Code != want[index] || item.Label == "" || item.Group == "" || !item.DefaultGranted {
t.Fatalf("catalog[%d] = %+v, want code %s with display metadata/default", index, item, want[index])
}
if !reflect.DeepEqual(item.Capabilities, []string{item.Code}) {
t.Fatalf("catalog capability must be independently addressable: %+v", item)
}
}
}
func TestPermissionSelectionAllowlistDependenciesAndNormalization(t *testing.T) {
if got, err := validatePermissionSelection(nil); err != nil || len(got) != 0 {
t.Fatalf("explicit empty permissions must be valid: got=%v err=%v", got, err)
}
all := append(DefaultPermissionSnapshot(), " user:list ", "user:list")
got, err := validatePermissionSelection(all)
if err != nil {
t.Fatalf("default permission validation: %v", err)
}
if !reflect.DeepEqual(got, DefaultPermissionSnapshot()) {
t.Fatalf("permissions not deduplicated/sorted: got=%v", got)
}
for _, invalid := range [][]string{
{"unknown:permission"},
{"*"},
{"external-admin:*"},
{"privilege:grant"},
{"user-title:grant", "room-background:grant"},
} {
if _, err := validatePermissionSelection(invalid); !errors.Is(err, ErrInvalidPermissions) {
t.Fatalf("invalid selection %v error = %v", invalid, err)
}
}
}
func TestPermissionValidationMessagesUseCatalogLabelsWithoutReflectingCodes(t *testing.T) {
_, dependencyErr := validatePermissionSelection([]string{"privilege:grant"})
dependencyMessage := permissionValidationMessage(dependencyErr)
if !strings.Contains(dependencyMessage, "特权道具发放") || !strings.Contains(dependencyMessage, "用户称号下发") || strings.Contains(dependencyMessage, "privilege:grant") {
t.Fatalf("dependency message leaked internal code: %q", dependencyMessage)
}
_, unknownErr := validatePermissionSelection([]string{"attacker:controlled"})
unknownMessage := permissionValidationMessage(unknownErr)
if unknownMessage != "外管权限中包含不支持的权限" || strings.Contains(unknownMessage, "attacker") {
t.Fatalf("unknown message reflected input: %q", unknownMessage)
}
}
func TestEffectivePermissionsSafelyUpgradesLegacyAndRejectsCorruptSnapshots(t *testing.T) {
legacy := []string{
"app-user:view", "app-user:update", "app-user:status", "app-user:level",
"host:view", "agency:view", "bd:view", "room:view", "room:update",
"resource:view", "resource-grant:view", "resource-grant:create",
"pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update",
"vip-config:grant", "upload:create",
}
if got := effectivePermissions(legacy); !reflect.DeepEqual(got, DefaultPermissionSnapshot()) {
t.Fatalf("legacy default did not expand to full 20: got=%v want=%v", got, DefaultPermissionSnapshot())
}
got := effectivePermissions([]string{"user:list", "privilege:grant", "unknown:permission", "*", "external-admin:*"})
if !reflect.DeepEqual(got, []string{"user:list"}) {
t.Fatalf("corrupt new snapshot must remove incomplete bundle/unknowns: %v", got)
}
got = effectivePermissions([]string{"app-config:view", "app-config:update", "app-user:level", "resource:view"})
if len(got) != 0 {
t.Fatalf("incomplete legacy closures must fail closed: %v", got)
}
}
func TestCreatePermissionFieldDistinguishesOmittedEmptyAndNull(t *testing.T) {
var omitted createAccountRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password"}`), &omitted); err != nil {
t.Fatalf("decode omitted: %v", err)
}
if omitted.Permissions.Set {
t.Fatal("omitted permissions must keep legacy default")
}
var empty createAccountRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":[]}`), &empty); err != nil {
t.Fatalf("decode empty: %v", err)
}
if !empty.Permissions.Set || len(empty.Permissions.Value) != 0 {
t.Fatalf("explicit [] was not preserved: %+v", empty.Permissions)
}
var nullValue createAccountRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":null}`), &nullValue); err == nil {
t.Fatal("permissions:null must be rejected as ambiguous")
}
}
func TestCreatePermissionSnapshotKeepsOmittedAndExplicitEmptyDistinct(t *testing.T) {
omitted, err := createPermissionSnapshot(CreateInput{})
if err != nil || !reflect.DeepEqual(omitted, DefaultPermissionSnapshot()) {
t.Fatalf("omitted snapshot=%v err=%v", omitted, err)
}
empty, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{}})
if err != nil || len(empty) != 0 {
t.Fatalf("explicit empty snapshot=%v err=%v", empty, err)
}
if _, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{"*"}}); !errors.Is(err, ErrInvalidPermissions) {
t.Fatalf("explicit wildcard error=%v", err)
}
}
func TestCreateAccountRequiresCredentialAndPermissionDelegationCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
permissions []string
wantStatus int
}{
{name: "create alone cannot delegate default full snapshot", permissions: []string{"external-admin-user:create"}, wantStatus: http.StatusForbidden},
{name: "delegation alone cannot create credential", permissions: []string{"external-admin-user:permissions"}, wantStatus: http.StatusForbidden},
{name: "both capabilities reach request validation", permissions: []string{"external-admin-user:create", "external-admin-user:permissions"}, wantStatus: http.StatusBadRequest},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Set(adminmiddleware.ContextPermissions, testCase.permissions)
c.Next()
})
RegisterAdminRoutes(engine.Group(""), &Handler{service: NewService(nil, nil, Config{})})
request := httptest.NewRequest(http.MethodPost, "/admin/external-admin-users", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != testCase.wantStatus {
t.Fatalf("permissions=%v status=%d body=%s", testCase.permissions, recorder.Code, recorder.Body.String())
}
})
}
// Handler-level defense must also reject omitted permissions when a caller
// bypasses the standard route middleware during a future refactor.
engine := gin.New()
engine.POST("/direct", (&Handler{service: NewService(nil, nil, Config{})}).CreateAccount)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/direct", strings.NewReader(`{"targetUserId":"1","username":"operator","password":"password1"}`))
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("direct omitted-permission create status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestGetAccountPermissionsIsScopedToAppAndReturnsRevision(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectQuery("SELECT `id`,`permissions_json`,`permission_revision` FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\? LIMIT \\?").
WithArgs(uint64(7), "fami", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "permissions_json", "permission_revision"}).AddRow(7, `["user:update","user:list"]`, 9))
view, err := service.GetAccountPermissions(t.Context(), " FAMI ", 7)
if err != nil {
t.Fatalf("get permissions: %v", err)
}
if view.AccountID != 7 || view.Revision != 9 || !reflect.DeepEqual(view.Permissions, []string{"user:list", "user:update"}) {
t.Fatalf("permission view=%+v", view)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func TestUpdatePermissionsUsesRevisionAndRevokesSessionsAtomically(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(4, `["user:list"]`))
adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*permission_revision.*permissions_json.* WHERE `id` = \\?").
WillReturnResult(sqlmock.NewResult(0, 1))
adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0").
WithArgs("permissions_changed", sqlmock.AnyArg(), uint64(7)).WillReturnResult(sqlmock.NewResult(0, 2))
adminMock.ExpectCommit()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list","user:update"]`))
expectPermissionLinkedUser(userMock)
result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{
Permissions: []string{"user:update", "user:list", "user:update"}, ExpectedRevision: 4,
})
if err != nil {
t.Fatalf("update permissions: %v", err)
}
if !result.Changed || !result.SessionsRevoked || result.Account.PermissionRevision != 5 || !reflect.DeepEqual(result.PreviousPermissions, []string{"user:list"}) {
t.Fatalf("unexpected update result: %+v", result)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func TestUpdatePermissionsRejectsStaleRevisionWithoutWriting(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list"]`))
adminMock.ExpectRollback()
_, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{
Permissions: []string{"user:list", "user:update"}, ExpectedRevision: 4,
})
if !errors.Is(err, ErrPermissionConflict) {
t.Fatalf("stale revision error = %v", err)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func TestUpdatePermissionsNoopIsIdempotentWithoutRevisionOrSessionChange(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`))
adminMock.ExpectCommit()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`))
expectPermissionLinkedUser(userMock)
result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{
Permissions: []string{" user:list ", "user:list"}, ExpectedRevision: 6,
})
if err != nil || result.Changed || result.SessionsRevoked || result.Account.PermissionRevision != 6 {
t.Fatalf("noop result=%+v err=%v", result, err)
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func TestUpdatePermissionsHandlerMapsRevisionConflictTo409(t *testing.T) {
service, adminMock, userMock, cleanup := newAccountMutationFixture(t)
defer cleanup()
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(3, `["user:list"]`))
adminMock.ExpectRollback()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.PUT("/accounts/:id", func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Next()
}, (&Handler{service: service}).UpdateAccountPermissions)
request := httptest.NewRequest(http.MethodPut, "/accounts/7", strings.NewReader(`{"permissions":["user:list"],"expectedRevision":2}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
assertAccountMutationExpectations(t, adminMock, userMock)
}
func permissionAccountRows(revision uint64, permissions string) *sqlmock.Rows {
return sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "permission_revision", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", "hash", permissions, revision, "active", false, 0, 0, 1, 1, 1)
}
func expectPermissionLinkedUser(userMock sqlmock.Sqlmock) {
userMock.ExpectQuery("SELECT u.user_id,").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)).
WillReturnRows(sqlmock.NewRows([]string{
"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status",
}).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active"))
}

View File

@ -0,0 +1,180 @@
package externaladmin
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/cache"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type allowFixedWindowLimiter struct{}
func (allowFixedWindowLimiter) ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
return cache.FixedWindowDecision{Allowed: true}, nil
}
type recordingFixedWindowLimiter struct {
decision cache.FixedWindowDecision
err error
calls int
window time.Duration
rules []cache.FixedWindowRule
batches [][]cache.FixedWindowRule
deadlineSet bool
deadlineIn time.Duration
}
func (limiter *recordingFixedWindowLimiter) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
limiter.calls++
limiter.window = window
limiter.rules = append([]cache.FixedWindowRule(nil), rules...)
limiter.batches = append(limiter.batches, append([]cache.FixedWindowRule(nil), rules...))
deadline, ok := ctx.Deadline()
limiter.deadlineSet = ok
if ok {
limiter.deadlineIn = time.Until(deadline)
}
return limiter.decision, limiter.err
}
func TestLoginRateLimiterUsesTrustedHashedLayersBeforeAccountResolution(t *testing.T) {
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
service := NewService(&gorm.DB{}, nil, Config{LoginRateLimit: LoginRateLimitConfig{
Window: time.Minute, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10,
}})
service.limiter = limiter
// Empty password is invalid, but this syntactically valid attempt must consume
// distributed counters before dummy bcrypt/input rejection.
_, err := service.Login(t.Context(), LoginInput{Username: "Operator", IP: "203.0.113.44"})
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("login error = %v", err)
}
if limiter.calls != 1 || limiter.window != time.Minute || len(limiter.rules) != 3 {
t.Fatalf("limiter call=%d window=%s rules=%+v", limiter.calls, limiter.window, limiter.rules)
}
if !limiter.deadlineSet || limiter.deadlineIn <= 0 || limiter.deadlineIn > loginLimiterTimeout+25*time.Millisecond {
t.Fatalf("independent limiter deadline = %s set=%t", limiter.deadlineIn, limiter.deadlineSet)
}
wantLimits := []uint64{300, 30, 10}
for index, rule := range limiter.rules {
if rule.Limit != wantLimits[index] {
t.Fatalf("rule %d limit=%d", index, rule.Limit)
}
if !strings.Contains(rule.Key, loginLimiterClusterTag) {
t.Fatalf("rule %d is not Redis Cluster-safe: %s", index, rule.Key)
}
for _, secret := range []string{"operator", "203.0.113.44"} {
if strings.Contains(rule.Key, secret) {
t.Fatalf("rule %d leaks %q: %s", index, secret, rule.Key)
}
}
}
if limiter.rules[0].Key != "rate:{external-login}:system" {
t.Fatalf("system-global key must not vary by App: %s", limiter.rules[0].Key)
}
if len(loginRateLimitDigest("fami")) != 32 || loginRateLimitDigest("fami") == loginRateLimitDigest("lalu") {
t.Fatal("rate-limit identifiers must use a truncated SHA-256 digest")
}
}
func TestLoginRateLimitDenialAndRedisFailurePrecedeAllDatabaseWork(t *testing.T) {
t.Run("limited", func(t *testing.T) {
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}}
service := NewService(nil, nil, Config{})
service.limiter = limiter
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
var rateErr *LoginRateLimitError
if !errors.As(err, &rateErr) || rateErr.RetryAfter != 1500*time.Millisecond {
t.Fatalf("rate error = %#v", err)
}
})
t.Run("redis unavailable", func(t *testing.T) {
limiter := &recordingFixedWindowLimiter{err: errors.New("redis down")}
service := NewService(nil, nil, Config{})
service.limiter = limiter
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
if !errors.Is(err, ErrLoginLimiterFailed) {
t.Fatalf("login error = %v", err)
}
})
t.Run("missing redis", func(t *testing.T) {
service := NewService(nil, nil, Config{})
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
if !errors.Is(err, ErrLoginLimiterFailed) {
t.Fatalf("login error = %v", err)
}
})
}
func TestLoginHandlerReturns429RetryAfterAnd503OnLimiterFailure(t *testing.T) {
for _, testCase := range []struct {
name string
limiter *recordingFixedWindowLimiter
wantStatus int
wantRetry string
body string
}{
{
name: "limited", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}},
wantStatus: http.StatusTooManyRequests, wantRetry: "2", body: `{"account":"operator","password":"password"}`,
},
{
name: "valid empty json still counted", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: time.Second}},
wantStatus: http.StatusTooManyRequests, wantRetry: "1", body: `{}`,
},
{
name: "redis unavailable", limiter: &recordingFixedWindowLimiter{err: errors.New("redis down")},
wantStatus: http.StatusServiceUnavailable, body: `{"account":"operator","password":"password"}`,
},
} {
t.Run(testCase.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(testCase.limiter))
RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(testCase.body))
request.Header.Set("Content-Type", "application/json")
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != testCase.wantStatus {
t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if got := responseRecorder.Header().Get("Retry-After"); got != testCase.wantRetry {
t.Fatalf("Retry-After=%q want=%q", got, testCase.wantRetry)
}
if responseRecorder.Header().Get("Cache-Control") != "no-store" {
t.Fatal("rate-limit auth responses must remain non-cacheable")
}
})
}
}
func TestLoginHandlerCapsBodyBeforeDistributedLimiter(t *testing.T) {
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
gin.SetMode(gin.TestMode)
engine := gin.New()
handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(limiter))
RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
body := `{"account":"operator","password":"` + strings.Repeat("x", int(maxLoginBodyBytes)) + `"}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if limiter.calls != 0 {
t.Fatalf("oversized, unparseable body reached limiter %d times", limiter.calls)
}
}

View File

@ -0,0 +1,112 @@
package externaladmin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/walletclient"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/appuser"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/vipconfig"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestExternalBusinessRoutesKeepIndependentPermissionBoundaries(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
// Each request gets exactly one product capability. Allowed reads reach an
// intentionally unconfigured handler and return 500; denied routes must stop
// at the permission middleware with 403 before any dependency is touched.
c.Set(adminmiddleware.ContextPermissions, []string{c.GetHeader("X-Test-Permission")})
c.Next()
})
registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{
AppUser: appuser.New(nil, nil, nil, nil, nil, config.Config{}, nil),
HostOrg: hostorg.New(nil, nil, nil, nil, nil, nil),
})
tests := []struct {
name string
method string
path string
permission string
allowed bool
}{
{name: "BD Manager list accepts its capability", method: http.MethodGet, path: "/admin/managers", permission: "bd-manager:list", allowed: true},
{name: "BD Manager list rejects Super Admin capability", method: http.MethodGet, path: "/admin/managers", permission: "super-admin:list"},
{name: "Super Admin list accepts its capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "super-admin:list", allowed: true},
{name: "Super Admin list rejects BD Manager capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "bd-manager:list"},
{name: "user update can read support list", method: http.MethodGet, path: "/app/users", permission: "user:update", allowed: true},
{name: "user update cannot ban", method: http.MethodPost, path: "/app/users/9001/ban", permission: "user:update"},
{name: "user unban can read ban support list", method: http.MethodGet, path: "/app/users/bans", permission: "user:unban", allowed: true},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequest(testCase.method, testCase.path, nil)
request.Header.Set("X-Test-Permission", testCase.permission)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if testCase.allowed && recorder.Code == http.StatusForbidden {
t.Fatalf("permission %s was rejected for %s %s: %s", testCase.permission, testCase.method, testCase.path, recorder.Body.String())
}
if !testCase.allowed && recorder.Code != http.StatusForbidden {
t.Fatalf("permission %s unexpectedly reached %s %s: status=%d body=%s", testCase.permission, testCase.method, testCase.path, recorder.Code, recorder.Body.String())
}
})
}
}
func TestExternalVIPRoutesUseManagerCenterOwnerPolicySource(t *testing.T) {
gin.SetMode(gin.TestMode)
wallet := &externalVIPRouteWallet{}
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Set(adminmiddleware.ContextPermissions, []string{"user-level:grant"})
c.Set(adminmiddleware.ContextRequestID, "external-vip-route-test")
c.Set(adminmiddleware.ContextUserID, uint(77))
c.Next()
})
registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{VIPConfig: vipconfig.New(wallet, nil)})
vipRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"reason":"manual"}`))
vipRequest.Header.Set("Content-Type", "application/json")
vipRecorder := httptest.NewRecorder()
engine.ServeHTTP(vipRecorder, vipRequest)
if vipRecorder.Code != http.StatusCreated || wallet.vipRequest == nil || wallet.vipRequest.GetGrantSource() != "manager_center" {
t.Fatalf("external VIP route source: status=%d request=%+v body=%s", vipRecorder.Code, wallet.vipRequest, vipRecorder.Body.String())
}
trialRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-trial-card-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`))
trialRequest.Header.Set("Content-Type", "application/json")
trialRecorder := httptest.NewRecorder()
engine.ServeHTTP(trialRecorder, trialRequest)
if trialRecorder.Code != http.StatusCreated || wallet.trialRequest == nil || wallet.trialRequest.GetGrantSource() != "manager_center" {
t.Fatalf("external trial route source: status=%d request=%+v body=%s", trialRecorder.Code, wallet.trialRequest, trialRecorder.Body.String())
}
}
type externalVIPRouteWallet struct {
walletclient.Client
vipRequest *walletv1.GrantVipRequest
trialRequest *walletv1.GrantVipTrialCardRequest
}
func (wallet *externalVIPRouteWallet) GrantVip(_ context.Context, request *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
wallet.vipRequest = request
return &walletv1.GrantVipResponse{TransactionId: "vip-route"}, nil
}
func (wallet *externalVIPRouteWallet) GrantVipTrialCard(_ context.Context, request *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) {
wallet.trialRequest = request
return &walletv1.GrantVipTrialCardResponse{TrialCard: &walletv1.VipTrialCard{TrialCardId: "trial-route"}}, nil
}

View File

@ -0,0 +1,137 @@
package externaladmin
import (
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/appconfig"
"hyapp-admin-server/internal/modules/appuser"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/prettyid"
"hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/modules/vipconfig"
"github.com/gin-gonic/gin"
)
type BusinessHandlers struct {
AppUser *appuser.Handler
HostOrg *hostorg.Handler
RoomAdmin *roomadmin.Handler
Resource *resource.Handler
PrettyID *prettyid.Handler
AppConfig *appconfig.Handler
VIPConfig *vipconfig.Handler
Upload *upload.Handler
}
func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) {
if protected == nil || h == nil {
return
}
protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts)
protected.GET("/admin/external-admin-users/permission-catalog", middleware.RequirePermission("external-admin-user:permissions"), h.ListPermissionCatalog)
protected.GET("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.GetAccountPermissions)
protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget)
protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), middleware.RequirePermission("external-admin-user:permissions"), h.CreateAccount)
protected.PUT("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateAccountPermissions)
protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus)
protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword)
}
func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessHandlers) {
if api == nil || h == nil {
return
}
external := api.Group("/external")
auth := external.Group("/auth")
// Credentials, session state and CSRF secrets must never be cached by browsers,
// service workers or intermediary proxies. This middleware runs before session
// auth so 4xx/5xx responses carry the same protection as successful responses.
auth.Use(noStore())
auth.POST("/login", h.Login)
protectedAuth := auth.Group("")
// Audit wraps CSRF so forged writes are recorded as failed attempts instead of disappearing
// before the audit middleware gets control.
protectedAuth.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF())
protectedAuth.GET("/me", h.Me)
protectedAuth.POST("/logout", h.Logout)
protectedAuth.POST("/change-password", h.ChangePassword)
// Password changes remain available as a self-service action, but they are not a
// business-route gate. Legacy rows may still carry password_change_required=true;
// authorization must therefore depend only on the active session and permissions.
business := external.Group("")
business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF())
registerBusinessWhitelist(business, h, handlers)
}
func noStore() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("Pragma", "no-cache")
c.Next()
}
}
func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) {
// "My team" is not a generic manager-list alias. Its handler derives the root
// manager from the external session and never accepts a client-selected user ID.
group.GET("/admin/my-team/agencies", middleware.RequirePermission("team:view"), h.ListMyTeamAgencies)
if handlers.AppUser != nil {
group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), handlers.AppUser.ListUsers)
group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), handlers.AppUser.ListBannedUsers)
// Detail is a support read for each user-targeted action, not a second product
// capability. Any independently assigned user action can resolve its exact target.
group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), handlers.AppUser.GetUser)
group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), handlers.AppUser.UpdateUser)
group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), handlers.AppUser.AdjustTemporaryLevels)
group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), handlers.AppUser.BanUser)
group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), handlers.AppUser.UnbanUser)
}
if handlers.HostOrg != nil {
group.GET("/admin/hosts", middleware.RequirePermission("host:list"), handlers.HostOrg.ListHosts)
group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), handlers.HostOrg.ListAgencies)
group.GET("/admin/bds", middleware.RequirePermission("bd:list"), handlers.HostOrg.ListBDs)
// The shared host-org API names are historical: external BD Manager uses
// /managers, while the Super Admin projection uses /bd-leaders.
group.GET("/admin/bd-leaders", middleware.RequirePermission("super-admin:list"), handlers.HostOrg.ListBDLeaders)
group.GET("/admin/managers", middleware.RequirePermission("bd-manager:list"), handlers.HostOrg.ListManagers)
}
if handlers.RoomAdmin != nil {
group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update"), handlers.RoomAdmin.ListRooms)
group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom)
}
if handlers.Resource != nil {
// Resource owner data cannot yet classify generic entitlements into the three
// product labels below. The assignment validator therefore keeps them atomic,
// and the route repeats all three checks so corrupt DB JSON still fails closed.
group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources)
group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), handlers.Resource.ListResourceGrants)
group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), handlers.Resource.LookupResourceGrantTarget)
group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.GrantExternalResource)
// External UI never uses group grants. Without an owner-level semantic category,
// a mixed group cannot be authorized safely, so it is intentionally not exposed.
}
if handlers.PrettyID != nil {
group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.ListIDs)
group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant)
}
if handlers.AppConfig != nil {
group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners)
group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner)
}
if handlers.VIPConfig != nil {
// Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped
// grant permission so the portal can select direct-VIP vs trial-card without config write access.
group.GET("/admin/activity/vip-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram)
group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.ListLevels)
group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIPTrialCard)
group.POST("/admin/activity/vip-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIP)
}
if handlers.Upload != nil {
// Banner/avatar forms need a real upload endpoint; no other file-management route is exposed.
group.POST("/admin/files/image/upload", middleware.RequirePermission("banner:create"), handlers.Upload.UploadImage)
}
}

View File

@ -0,0 +1,58 @@
package externaladmin
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestExternalAuthResponsesAlwaysDisableCachingIncludingErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
RegisterExternalRoutes(engine.Group("/api/v1"), New(nil, nil, Config{}, nil), BusinessHandlers{})
tests := []struct {
name string
method string
path string
body string
}{
{name: "login validation error", method: http.MethodPost, path: "/api/v1/external/auth/login", body: `{}`},
{name: "me auth error", method: http.MethodGet, path: "/api/v1/external/auth/me"},
{name: "logout auth error", method: http.MethodPost, path: "/api/v1/external/auth/logout"},
{name: "change password auth error", method: http.MethodPost, path: "/api/v1/external/auth/change-password", body: `{}`},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequest(testCase.method, testCase.path, bytes.NewBufferString(testCase.body))
request.Header.Set("Content-Type", "application/json")
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code < http.StatusBadRequest {
t.Fatalf("expected error response, status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if got := responseRecorder.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
if got := responseRecorder.Header().Get("Pragma"); got != "no-cache" {
t.Fatalf("Pragma = %q", got)
}
})
}
}
func TestExternalAuthDoesNotExposeAppCatalogBeforeLogin(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
RegisterExternalRoutes(engine.Group("/api/v1"), New(nil, nil, Config{}, nil), BusinessHandlers{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/external/auth/apps", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusNotFound {
t.Fatalf("anonymous App catalog status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
}

View File

@ -0,0 +1,881 @@
package externaladmin
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/security"
mysqlDriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
lastSeenWriteInterval = 5 * time.Minute
loginLimiterTimeout = 200 * time.Millisecond
loginLimiterClusterTag = "{external-login}"
)
type Service struct {
db *gorm.DB
userDB *sql.DB
cfg Config
dummyHash string
limiter FixedWindowLimiter
}
func NewService(db *gorm.DB, userDB *sql.DB, cfg Config) *Service {
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 12 * time.Hour
}
if cfg.MaxLoginFailures == 0 {
cfg.MaxLoginFailures = 5
}
if cfg.LockDuration <= 0 {
cfg.LockDuration = 15 * time.Minute
}
if cfg.LoginRateLimit.Window <= 0 {
cfg.LoginRateLimit.Window = 60 * time.Second
}
if cfg.LoginRateLimit.SystemLimit == 0 {
cfg.LoginRateLimit.SystemLimit = 300
}
if cfg.LoginRateLimit.AppLimit == 0 {
cfg.LoginRateLimit.AppLimit = 120
}
if cfg.LoginRateLimit.IPLimit == 0 {
cfg.LoginRateLimit.IPLimit = 30
}
if cfg.LoginRateLimit.IdentityLimit == 0 {
cfg.LoginRateLimit.IdentityLimit = 10
}
dummyHash, _ := security.HashPassword("external-login-dummy-password")
return &Service{db: db, userDB: userDB, cfg: cfg, dummyHash: dummyHash}
}
func (s *Service) ResolveTarget(ctx context.Context, appCode string, target string) (LinkedUser, error) {
appCode = normalizeAppCode(appCode)
target = strings.TrimSpace(target)
if appCode == "" || target == "" || s.userDB == nil {
return LinkedUser{}, ErrInvalidInput
}
userID, matched, err := shared.ResolveExactUserID(ctx, s.userDB, appCode, target, time.Now().UTC().UnixMilli())
if err != nil {
return LinkedUser{}, err
}
if !matched {
return LinkedUser{}, ErrTargetNotFound
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{userID})
if err != nil {
return LinkedUser{}, err
}
user, ok := users[userID]
if !ok {
return LinkedUser{}, ErrTargetNotFound
}
// ResolveExactUserID keeps nickname fallback for generic admin forms. External-account creation
// accepts only an exact long/short/pretty ID so an ambiguous nickname can never bind credentials.
if !matchesExactIdentity(user, target) {
return LinkedUser{}, ErrTargetNotFound
}
return user, nil
}
func (s *Service) ListAccounts(ctx context.Context, input ListInput) (ListResult, error) {
if s.db == nil {
return ListResult{}, errors.New("admin mysql is not configured")
}
input.AppCode = normalizeAppCode(input.AppCode)
input.Page, input.PageSize = normalizePage(input.Page, input.PageSize)
query := s.db.WithContext(ctx).Model(&model.ExternalAdminAccount{}).Where("app_code = ?", input.AppCode)
if status := normalizeStatus(input.Status); status != "" {
query = query.Where("status = ?", status)
}
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
like := "%" + keyword + "%"
linkedUserID := int64(0)
if user, resolveErr := s.ResolveTarget(ctx, input.AppCode, keyword); resolveErr == nil {
linkedUserID = user.UserID
} else if userID, parseErr := strconv.ParseInt(keyword, 10, 64); parseErr == nil && userID > 0 {
linkedUserID = userID
}
if linkedUserID > 0 {
query = query.Where("username LIKE ? OR linked_app_user_id = ?", like, linkedUserID)
} else {
query = query.Where("username LIKE ?", like)
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ListResult{}, err
}
accounts := []model.ExternalAdminAccount{}
if err := query.Order("updated_at_ms DESC, id DESC").Offset((input.Page - 1) * input.PageSize).Limit(input.PageSize).Find(&accounts).Error; err != nil {
return ListResult{}, err
}
userIDs := make([]int64, 0, len(accounts))
for _, account := range accounts {
userIDs = append(userIDs, account.LinkedAppUserID)
}
users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs)
if err != nil {
return ListResult{}, err
}
items := make([]AccountDTO, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountDTO(account, users[account.LinkedAppUserID]))
}
return ListResult{Items: items, Page: input.Page, PageSize: input.PageSize, Total: total}, nil
}
func (s *Service) FindAccountByLinkedUser(ctx context.Context, appCode string, linkedUser LinkedUser) (*AccountDTO, error) {
if s.db == nil || linkedUser.UserID <= 0 {
return nil, ErrInvalidInput
}
var account model.ExternalAdminAccount
err := s.db.WithContext(ctx).Where("app_code = ? AND linked_app_user_id = ?", normalizeAppCode(appCode), linkedUser.UserID).First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
dto := accountDTO(account, linkedUser)
return &dto, nil
}
func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (AccountDTO, error) {
if s.db == nil {
return AccountDTO{}, errors.New("admin mysql is not configured")
}
input.AppCode = normalizeAppCode(input.AppCode)
input.Username = normalizeUsername(input.Username)
if input.AppCode == "" || !validUsername(input.Username) || input.CreatedByAdminID == 0 {
return AccountDTO{}, ErrInvalidInput
}
if err := validatePassword(input.Password); err != nil {
return AccountDTO{}, err
}
permissions, err := createPermissionSnapshot(input)
if err != nil {
return AccountDTO{}, err
}
// Login has no client-selected App. Check the normalized username globally before
// doing an owner-DB lookup or bcrypt work; the global unique index remains the
// authoritative race-safe guard if two administrators create it concurrently.
var existing model.ExternalAdminAccount
err = s.db.WithContext(ctx).Select("id").Where("username = ?", input.Username).Take(&existing).Error
if err == nil {
return AccountDTO{}, ErrAccountConflict
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return AccountDTO{}, err
}
if _, err := s.findActiveApp(ctx, input.AppCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return AccountDTO{}, ErrInvalidInput
}
return AccountDTO{}, err
}
linkedUser, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID)
if err != nil {
return AccountDTO{}, err
}
passwordHash, err := security.HashPasswordWithoutMinimum(input.Password)
if err != nil {
return AccountDTO{}, ErrInvalidInput
}
permissionsJSON, err := encodePermissions(permissions)
if err != nil {
return AccountDTO{}, err
}
account := model.ExternalAdminAccount{
AppCode: input.AppCode,
LinkedAppUserID: linkedUser.UserID,
Username: input.Username,
PasswordHash: passwordHash,
PermissionsJSON: permissionsJSON,
PermissionRevision: 1,
Status: model.ExternalAdminStatusActive,
CreatedByAdminID: input.CreatedByAdminID,
}
// The legacy model/database default is true, so assigning Go's false zero value
// is not enough: GORM substitutes the default during Create. Clear the marker in
// the same transaction so a rolling-release request handled by an older instance
// cannot revive the retired first-login gate for a newly created account.
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&account).Error; err != nil {
return err
}
if err := tx.Model(&account).UpdateColumn("password_change_required", false).Error; err != nil {
return err
}
account.PasswordChangeRequired = false
return nil
})
if err != nil {
if isDuplicateKey(err) {
return AccountDTO{}, ErrAccountConflict
}
return AccountDTO{}, err
}
return accountDTO(account, linkedUser), nil
}
func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, status string) (AccountDTO, error) {
appCode = normalizeAppCode(appCode)
status = normalizeStatus(status)
if id == 0 || status == "" {
return AccountDTO{}, ErrInvalidInput
}
var account model.ExternalAdminAccount
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return err
}
updates := map[string]any{"status": status}
if status == model.ExternalAdminStatusActive {
updates["failed_login_count"] = 0
updates["locked_until_ms"] = 0
}
if err := tx.Model(&account).Updates(updates).Error; err != nil {
return err
}
if status == model.ExternalAdminStatusDisabled {
return revokeAllSessions(tx, account.ID, "account_disabled", time.Now().UTC().UnixMilli())
}
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return AccountDTO{}, ErrAccountNotFound
}
if err != nil {
return AccountDTO{}, err
}
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return AccountDTO{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
return accountDTO(account, users[account.LinkedAppUserID]), err
}
func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, password string) (AccountDTO, error) {
appCode = normalizeAppCode(appCode)
if id == 0 {
return AccountDTO{}, ErrInvalidInput
}
if err := validatePassword(password); err != nil {
return AccountDTO{}, err
}
hash, err := security.HashPasswordWithoutMinimum(password)
if err != nil {
return AccountDTO{}, ErrInvalidInput
}
var account model.ExternalAdminAccount
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return err
}
if err := tx.Model(&account).Updates(map[string]any{
"password_hash": hash,
// A reset still revokes every active session, but the replacement password
// is immediately usable and never creates a first-login rotation gate.
"password_change_required": false,
"failed_login_count": 0,
"locked_until_ms": 0,
}).Error; err != nil {
return err
}
return revokeAllSessions(tx, account.ID, "password_reset", time.Now().UTC().UnixMilli())
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return AccountDTO{}, ErrAccountNotFound
}
if err != nil {
return AccountDTO{}, err
}
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return AccountDTO{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
return accountDTO(account, users[account.LinkedAppUserID]), err
}
func (s *Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
input.Username = normalizeUsername(input.Username)
input.IP = truncateText(strings.TrimSpace(input.IP), 64)
input.UserAgent = truncateText(strings.TrimSpace(input.UserAgent), 512)
// The pre-resolution limiter uses only facts the server can trust: system, real
// client IP and the globally normalized username. AppCode is intentionally absent,
// so a forged tenant cannot select another account or evade any of these counters.
if err := s.consumeUnresolvedLoginRateLimit(ctx, input); err != nil {
return LoginResult{}, err
}
if s.db == nil {
return LoginResult{}, errors.New("admin mysql is not configured")
}
if !validUsername(input.Username) || input.Password == "" {
security.CheckPassword(s.dummyHash, input.Password)
return LoginResult{}, ErrInvalidCredentials
}
// The global unique index makes this indexed projection the sole tenant resolver.
// Do not accept a request/header App here: only the AppCode stored with the account
// may select owner data, create the session or populate audit rows.
var resolvedAccount model.ExternalAdminAccount
err := s.db.WithContext(ctx).Select("id", "app_code").Where("username = ?", input.Username).Take(&resolvedAccount).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
security.CheckPassword(s.dummyHash, input.Password)
s.writeUnknownLoginLog(ctx, input, "invalid_credentials")
return LoginResult{}, ErrInvalidCredentials
}
if err != nil {
return LoginResult{}, err
}
resolvedAccount.AppCode = normalizeAppCode(resolvedAccount.AppCode)
if !validAppCode(resolvedAccount.AppCode) {
security.CheckPassword(s.dummyHash, input.Password)
s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "invalid_account_app")
return LoginResult{}, ErrInvalidCredentials
}
// App quota is consumed in a second Redis call only after the indexed account
// resolution. System/IP/username rules are not repeated, so one attempt increments
// each layer exactly once while still containing a targeted tenant attack.
if err := s.consumeResolvedAppLoginRateLimit(ctx, resolvedAccount.AppCode); err != nil {
return LoginResult{}, err
}
app, err := s.findActiveApp(ctx, resolvedAccount.AppCode)
if err != nil {
// Keep disabled App, unknown account and wrong password indistinguishable.
security.CheckPassword(s.dummyHash, input.Password)
if errors.Is(err, sql.ErrNoRows) {
s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "app_inactive")
return LoginResult{}, ErrInvalidCredentials
}
return LoginResult{}, err
}
nowMS := time.Now().UTC().UnixMilli()
var account model.ExternalAdminAccount
var session model.ExternalAdminSession
var sessionToken string
var csrfToken string
var authErr error
// Lock the account row while checking/updating the failure counter. Concurrent bad passwords
// must serialize on one counter; otherwise attackers could race requests below the lock threshold.
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND username = ?", resolvedAccount.ID, input.Username).First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
security.CheckPassword(s.dummyHash, input.Password)
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{
AppCode: resolvedAccount.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
Status: "failed", Message: "invalid_credentials",
}).Error
}
if err != nil {
return err
}
accountID := account.ID
validPassword := security.CheckPassword(account.PasswordHash, input.Password)
accountAppCode := normalizeAppCode(account.AppCode)
if accountAppCode != resolvedAccount.AppCode {
// AppCode is not mutable through this module, but a concurrent/manual data
// correction between projection and row lock must not create a session whose
// App metadata and tenant scope came from different rows/snapshots.
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: accountAppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_app_changed"}).Error
}
account.AppCode = accountAppCode
if account.Status != model.ExternalAdminStatusActive {
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_disabled"}).Error
}
if account.LockedUntilMS > nowMS {
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_locked"}).Error
}
if !validPassword {
failedCount := account.FailedLoginCount
if account.LockedUntilMS > 0 && account.LockedUntilMS <= nowMS {
failedCount = 0
}
failedCount++
lockedUntilMS := int64(0)
message := "invalid_credentials"
if failedCount >= s.cfg.MaxLoginFailures {
lockedUntilMS = time.Now().UTC().Add(s.cfg.LockDuration).UnixMilli()
failedCount = 0
message = "failure_limit_locked"
}
if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": failedCount, "locked_until_ms": lockedUntilMS}).Error; err != nil {
return err
}
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message}).Error
}
var tokenHash string
sessionToken, tokenHash, err = security.NewRefreshToken()
if err != nil {
return err
}
var csrfHash string
csrfToken, csrfHash, err = security.NewRefreshToken()
if err != nil {
return err
}
session = model.ExternalAdminSession{
AccountID: account.ID, AppCode: account.AppCode, TokenHash: tokenHash, CSRFTokenHash: csrfHash,
IP: input.IP, UserAgent: input.UserAgent, ExpiresAtMS: time.Now().UTC().Add(s.cfg.SessionTTL).UnixMilli(), LastSeenAtMS: nowMS,
}
if err := tx.Create(&session).Error; err != nil {
return err
}
if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": 0, "locked_until_ms": 0, "last_login_at_ms": nowMS}).Error; err != nil {
return err
}
account.LastLoginAtMS = &nowMS
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "success", Message: "login_success"}).Error
})
if err != nil {
return LoginResult{}, err
}
if authErr != nil {
return LoginResult{}, authErr
}
view, err := s.sessionView(ctx, account, app, csrfToken)
if err != nil {
_ = s.RevokeSession(ctx, session.ID, "profile_load_failed")
return LoginResult{}, err
}
return LoginResult{SessionToken: sessionToken, CSRFToken: csrfToken, View: view}, nil
}
func (s *Service) consumeUnresolvedLoginRateLimit(ctx context.Context, input LoginInput) error {
if s.limiter == nil {
return ErrLoginLimiterFailed
}
ipDigest := loginRateLimitDigest(input.IP)
identityDigest := loginRateLimitDigest(input.Username)
rules := []cache.FixedWindowRule{
// These keys are independent of App because no client-provided tenant value
// is trusted before the globally unique username resolves an account.
{Key: "rate:" + loginLimiterClusterTag + ":system", Limit: s.cfg.LoginRateLimit.SystemLimit},
{Key: "rate:" + loginLimiterClusterTag + ":ip:" + ipDigest, Limit: s.cfg.LoginRateLimit.IPLimit},
{Key: "rate:" + loginLimiterClusterTag + ":identity:" + identityDigest, Limit: s.cfg.LoginRateLimit.IdentityLimit},
}
return s.consumeLoginRateLimitRules(ctx, rules)
}
func (s *Service) consumeResolvedAppLoginRateLimit(ctx context.Context, appCode string) error {
if s.limiter == nil {
return ErrLoginLimiterFailed
}
rules := []cache.FixedWindowRule{{
Key: "rate:" + loginLimiterClusterTag + ":app:" + loginRateLimitDigest(normalizeAppCode(appCode)),
Limit: s.cfg.LoginRateLimit.AppLimit,
}}
return s.consumeLoginRateLimitRules(ctx, rules)
}
func (s *Service) consumeLoginRateLimitRules(ctx context.Context, rules []cache.FixedWindowRule) error {
limitCtx, cancel := context.WithTimeout(ctx, loginLimiterTimeout)
defer cancel()
decision, err := s.limiter.ConsumeFixedWindow(limitCtx, s.cfg.LoginRateLimit.Window, rules)
if err != nil {
return fmt.Errorf("%w: %v", ErrLoginLimiterFailed, err)
}
if !decision.Allowed {
retryAfter := decision.RetryAfter
if retryAfter <= 0 {
retryAfter = s.cfg.LoginRateLimit.Window
}
return &LoginRateLimitError{RetryAfter: retryAfter}
}
return nil
}
func loginRateLimitDigest(value string) string {
digest := sha256.Sum256([]byte(value))
// 128 bits of the SHA-256 output keeps keys compact while retaining ample
// collision resistance; no App, IP or account identifier appears in Redis.
return hex.EncodeToString(digest[:16])
}
func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPrincipal, error) {
if strings.TrimSpace(rawToken) == "" || s.db == nil {
return SessionPrincipal{}, ErrInvalidSession
}
nowMS := time.Now().UTC().UnixMilli()
var session model.ExternalAdminSession
err := s.db.WithContext(ctx).Preload("Account").Where("token_hash = ? AND revoked_at_ms = 0 AND expires_at_ms > ?", security.HashToken(rawToken), nowMS).First(&session).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return SessionPrincipal{}, ErrInvalidSession
}
if err != nil {
return SessionPrincipal{}, err
}
if session.Account.Status != model.ExternalAdminStatusActive || session.Account.AppCode != session.AppCode {
_ = s.RevokeSession(ctx, session.ID, "account_invalid")
return SessionPrincipal{}, ErrInvalidSession
}
// Login-time App validation is insufficient because an already authenticated
// browser can skip /auth/me and call a business route directly after operators
// disable the App. Re-read the indexed App row on every opaque-session auth.
// Only a confirmed missing/inactive App revokes the session; transient user DB
// failures fail closed for this request without destroying a recoverable session.
if _, err := s.findActiveApp(ctx, session.AppCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
_ = s.RevokeSession(ctx, session.ID, "app_disabled")
return SessionPrincipal{}, ErrInvalidSession
}
return SessionPrincipal{}, err
}
permissions, err := decodePermissions(session.Account.PermissionsJSON)
if err != nil {
_ = s.RevokeSession(ctx, session.ID, "permission_snapshot_invalid")
return SessionPrincipal{}, ErrInvalidSession
}
if nowMS-session.LastSeenAtMS >= lastSeenWriteInterval.Milliseconds() {
// last_seen is intentionally throttled so read-heavy external pages do not turn every request into a write.
_ = s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).Where("id = ? AND revoked_at_ms = 0", session.ID).Update("last_seen_at_ms", nowMS).Error
}
operatorID, err := operatorIDForAccount(session.Account.ID)
if err != nil {
_ = s.RevokeSession(ctx, session.ID, "account_id_out_of_range")
return SessionPrincipal{}, ErrInvalidSession
}
return SessionPrincipal{
SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode,
LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username,
// The persisted flag is a deprecated compatibility column. Always expose false
// so historical rows marked by older releases cannot recreate the retired gate.
Permissions: permissions, PasswordChangeRequired: false,
CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS,
}, nil
}
func (s *Service) Me(ctx context.Context, principal SessionPrincipal, csrfToken string) (SessionView, error) {
var account model.ExternalAdminAccount
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
return SessionView{}, err
}
app, err := s.findActiveApp(ctx, principal.AppCode)
if err != nil {
return SessionView{}, err
}
return s.sessionView(ctx, account, app, csrfToken)
}
func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal, input ChangePasswordInput) error {
if err := validatePassword(input.NewPassword); err != nil {
return err
}
if strings.TrimSpace(input.CurrentPassword) == "" {
return ErrInvalidInput
}
hash, err := security.HashPasswordWithoutMinimum(input.NewPassword)
if err != nil {
return ErrInvalidInput
}
var passwordErr error
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var account model.ExternalAdminAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
return err
}
if !security.CheckPassword(account.PasswordHash, input.CurrentPassword) {
passwordErr = ErrInvalidCredentials
return nil
}
// This comparison must use the hash read under the account row lock. Otherwise
// two concurrent password changes could both compare against a stale hash.
// Returning before both Updates calls also guarantees a rejected request does
// not revoke the account's other active sessions.
if security.CheckPassword(account.PasswordHash, input.NewPassword) {
passwordErr = ErrPasswordReused
return nil
}
if err := tx.Model(&account).Updates(map[string]any{
"password_hash": hash, "password_change_required": false,
"failed_login_count": 0, "locked_until_ms": 0,
}).Error; err != nil {
return err
}
nowMS := time.Now().UTC().UnixMilli()
return tx.Model(&model.ExternalAdminSession{}).
Where("account_id = ? AND id <> ? AND revoked_at_ms = 0", principal.AccountID, principal.SessionID).
Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": "password_changed"}).Error
})
if err != nil {
return err
}
return passwordErr
}
func (s *Service) RevokeSession(ctx context.Context, sessionID uint64, reason string) error {
if sessionID == 0 || s.db == nil {
return nil
}
return s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).
Where("id = ? AND revoked_at_ms = 0", sessionID).
Updates(map[string]any{"revoked_at_ms": time.Now().UTC().UnixMilli(), "revoke_reason": strings.TrimSpace(reason)}).Error
}
func (s *Service) CreateOperationLog(ctx context.Context, log model.ExternalAdminOperationLog) error {
if s.db == nil {
return errors.New("admin mysql is not configured")
}
return s.db.WithContext(ctx).Create(&log).Error
}
func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAccount, app App, csrfToken string) (SessionView, error) {
users, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID})
if err != nil {
return SessionView{}, err
}
user, ok := users[account.LinkedAppUserID]
if !ok {
return SessionView{}, ErrTargetNotFound
}
permissions, err := decodePermissions(account.PermissionsJSON)
if err != nil {
return SessionView{}, err
}
return SessionView{
User: user,
Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status},
App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions),
// Keep the wire field during rolling upgrades, but never surface the deprecated
// database marker as an instruction to block navigation or business APIs.
PasswordChangeRequired: false, CSRFToken: csrfToken,
}, nil
}
func (s *Service) findActiveApp(ctx context.Context, appCode string) (App, error) {
if s.userDB == nil {
return App{}, errors.New("user mysql is not configured")
}
var app App
err := s.userDB.QueryRowContext(ctx, `SELECT app_code, app_name, logo_url FROM apps WHERE app_code = ? AND status = 'active' LIMIT 1`, normalizeAppCode(appCode)).Scan(&app.AppCode, &app.AppName, &app.LogoURL)
return app, err
}
func (s *Service) loadLinkedUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]LinkedUser, error) {
users := make(map[int64]LinkedUser, len(userIDs))
if len(userIDs) == 0 {
return users, nil
}
if s.userDB == nil {
return nil, errors.New("user mysql is not configured")
}
uniqueIDs := make([]int64, 0, len(userIDs))
seen := map[int64]struct{}{}
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
uniqueIDs = append(uniqueIDs, userID)
}
if len(uniqueIDs) == 0 {
return users, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
nowMS := time.Now().UTC().UnixMilli()
args := []any{nowMS, nowMS, normalizeAppCode(appCode)}
for _, userID := range uniqueIDs {
args = append(args, userID)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id,
COALESCE(u.current_display_user_id, ''),
COALESCE(u.default_display_user_id, ''),
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
), ''),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pdi ON pdi.app_code = lease.app_code
AND pdi.assigned_lease_id = lease.lease_id AND pdi.status = 'assigned'
WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
), ''),
COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.status, '')
FROM users u
WHERE u.app_code = ? AND u.user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var user LinkedUser
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar, &user.Status); err != nil {
return nil, err
}
users[user.UserID] = user
}
return users, rows.Err()
}
func (s *Service) writeUnknownLoginLog(ctx context.Context, input LoginInput, message string) {
if s.db == nil {
return
}
// Empty app_code is the audit sentinel for an unresolved username. Persisting a
// request-supplied tenant here would incorrectly attribute an attack to a real App.
_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
AppCode: "", Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
Status: "failed", Message: message,
}).Error
}
func (s *Service) writeResolvedLoginLog(ctx context.Context, accountID uint64, appCode string, input LoginInput, message string) {
if s.db == nil {
return
}
_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
AccountID: &accountID, AppCode: normalizeAppCode(appCode), Username: input.Username,
IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message,
}).Error
}
func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) AccountDTO {
permissions, _ := decodePermissions(account.PermissionsJSON)
if linkedUser.UserID == 0 {
linkedUser.UserID = account.LinkedAppUserID
}
return AccountDTO{
ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status,
LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision, CreatedByAdminID: account.CreatedByAdminID,
CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS,
}
}
func revokeAllSessions(tx *gorm.DB, accountID uint64, reason string, nowMS int64) error {
return tx.Model(&model.ExternalAdminSession{}).Where("account_id = ? AND revoked_at_ms = 0", accountID).
Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": reason}).Error
}
func matchesExactIdentity(user LinkedUser, target string) bool {
target = strings.TrimSpace(target)
return target == strconv.FormatInt(user.UserID, 10) ||
target == user.DisplayUserID || target == user.DefaultDisplayUserID || target == user.PrettyDisplayUserID || target == user.PrettyID
}
func normalizePage(page int, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func normalizeAppCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
return appctx.Normalize(value)
}
func normalizeUsername(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case model.ExternalAdminStatusActive:
return model.ExternalAdminStatusActive
case model.ExternalAdminStatusDisabled:
return model.ExternalAdminStatusDisabled
default:
return ""
}
}
func validUsername(value string) bool {
if len(value) < 3 || len(value) > 64 {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' {
continue
}
return false
}
return true
}
func validAppCode(value string) bool {
if len(value) == 0 || len(value) > 32 {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '-' {
continue
}
return false
}
return true
}
func truncateText(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func validatePassword(password string) error {
// External administrators intentionally have no minimum password length. Preserve
// exact bytes (no trim), reject unusable all-whitespace credentials, and enforce
// bcrypt's hard 72-byte input limit before doing the expensive hash operation.
if strings.TrimSpace(password) == "" {
return ErrPasswordBlank
}
if len(password) > 72 {
return fmt.Errorf("%w: password length must not exceed 72 bytes", ErrInvalidInput)
}
return nil
}
func isDuplicateKey(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

View File

@ -0,0 +1,165 @@
package externaladmin
import (
"database/sql/driver"
"errors"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/security"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestLoginFailureLimitPersistsTimedLockAndUnifiedError(t *testing.T) {
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
defer adminSQL.Close()
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
t.Fatalf("create admin gorm db: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock: %v", err)
}
defer userDB.Close()
passwordHash, err := security.HashPassword("correct-password")
if err != nil {
t.Fatalf("hash fixture password: %v", err)
}
// Login resolves the tenant from the globally unique account name before it
// touches the owner App database; no request App participates in either query.
adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
WithArgs("operator", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"}).AddRow(7, "fami"))
userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps").
WithArgs("fami").
WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND username = \\?").
WithArgs(uint64(7), "operator", 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", false, 0, 0, 1, 1, 1))
adminMock.ExpectExec("UPDATE `external_admin_accounts` SET").
WithArgs(uint(0), futureMillis{}, sqlmock.AnyArg(), uint64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))
adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`").
WithArgs(sqlmock.AnyArg(), "fami", "operator", "127.0.0.1", "browser", "failed", "failure_limit_locked", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
adminMock.ExpectCommit()
service := NewService(adminDB, userDB, Config{MaxLoginFailures: 1, LockDuration: 10 * time.Minute})
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
service.limiter = limiter
_, err = service.Login(t.Context(), LoginInput{
Username: "Operator", Password: "wrong-password", IP: "127.0.0.1", UserAgent: "browser",
})
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("login error = %v, want unified invalid credentials", err)
}
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations: %v", err)
}
if limiter.calls != 2 || len(limiter.batches) != 2 || len(limiter.batches[0]) != 3 || len(limiter.batches[1]) != 1 {
t.Fatalf("rate-limit batches = %+v", limiter.batches)
}
if !strings.Contains(limiter.batches[1][0].Key, ":app:") || limiter.batches[1][0].Limit != 120 {
t.Fatalf("resolved App limiter = %+v", limiter.batches[1])
}
for _, rule := range limiter.batches[1] {
if strings.Contains(rule.Key, ":system") || strings.Contains(rule.Key, ":ip:") || strings.Contains(rule.Key, ":identity:") {
t.Fatalf("resolved App phase repeated a pre-resolution counter: %s", rule.Key)
}
}
}
func TestLoginWithoutAppCreatesSessionFromResolvedAccountTenant(t *testing.T) {
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create admin sql mock: %v", err)
}
defer adminSQL.Close()
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
t.Fatalf("create admin gorm db: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock: %v", err)
}
defer userDB.Close()
passwordHash, err := security.HashPassword("correct-password")
if err != nil {
t.Fatalf("hash fixture password: %v", err)
}
adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
WithArgs("operator", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"}).AddRow(7, "fami"))
userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps").
WithArgs("fami").
WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
adminMock.ExpectBegin()
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND username = \\?").
WithArgs(uint64(7), "operator", 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "last_login_at_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", passwordHash, `["bd:view"]`, "active", false, 0, 0, nil, 1, 1, 1))
// The session insert is the persistence proof: AppCode comes from the account
// projection even though LoginInput has no App field at all.
adminMock.ExpectExec("INSERT INTO `external_admin_sessions`").
WithArgs(uint64(7), "fami", sqlmock.AnyArg(), sqlmock.AnyArg(), "203.0.113.10", "browser", sqlmock.AnyArg(), sqlmock.AnyArg(), int64(0), "", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(21, 1))
adminMock.ExpectExec("UPDATE `external_admin_accounts` SET").WillReturnResult(sqlmock.NewResult(0, 1))
adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`").
WithArgs(uint64(7), "fami", "operator", "203.0.113.10", "browser", "success", "login_success", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(31, 1))
adminMock.ExpectCommit()
userMock.ExpectQuery("SELECT u.user_id,").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)).
WillReturnRows(sqlmock.NewRows([]string{
"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status",
}).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active"))
service := NewService(adminDB, userDB, Config{})
service.limiter = allowFixedWindowLimiter{}
result, err := service.Login(t.Context(), LoginInput{
Username: "Operator", Password: "correct-password", IP: "203.0.113.10", UserAgent: "browser",
})
if err != nil {
t.Fatalf("login without App: %v", err)
}
if result.SessionToken == "" || result.CSRFToken == "" {
t.Fatal("successful login must issue opaque session and CSRF tokens")
}
if result.View.AppCode != "fami" || result.View.App.AppCode != "fami" || result.View.Account.Username != "operator" {
t.Fatalf("resolved session view = %+v", result.View)
}
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations: %v", err)
}
}
type futureMillis struct{}
func (futureMillis) Match(value driver.Value) bool {
millis, ok := value.(int64)
return ok && millis > time.Now().UTC().UnixMilli()
}

View File

@ -0,0 +1,155 @@
package externaladmin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
adminmiddleware "hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
)
type managerTeamHostClient struct {
userv1.UserHostServiceClient
request *userv1.ListManagerTeamAgenciesRequest
calls int
}
func (client *managerTeamHostClient) ListManagerTeamAgencies(_ context.Context, request *userv1.ListManagerTeamAgenciesRequest, _ ...grpc.CallOption) (*userv1.ListManagerTeamAgenciesResponse, error) {
client.calls++
client.request = request
return &userv1.ListManagerTeamAgenciesResponse{
Agencies: []*userv1.ManagerTeamAgency{
{AgencyId: 101, OwnerUserId: 1001, ParentBdUserId: 2001},
{AgencyId: 102, OwnerUserId: 1002, ParentBdUserId: 2002},
{AgencyId: 103, OwnerUserId: 1003, ParentBdUserId: 2003},
},
TotalBdLeaders: 4,
TotalBds: 9,
Truncated: true,
}, nil
}
func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
// This fixture models fields populated by AuthRequired. Query parameters below
// intentionally carry attacker-selected IDs and must never override the principal.
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?page=2&page_size=2&manager_user_id=999&user_id=888", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 1 || client.request == nil {
t.Fatalf("owner service calls = %d request=%v", client.calls, client.request)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("manager user id = %d, want session linked user 42", client.request.GetManagerUserId())
}
if client.request.GetMeta().GetAppCode() != "fami" || client.request.GetPageSize() != myTeamAgencyRPCPageSize {
t.Fatalf("owner request is not session scoped: %+v", client.request)
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Page != 2 || body.Data.PageSize != 2 || body.Data.Total != 3 || len(body.Data.Items) != 1 {
t.Fatalf("unexpected page: %+v", body.Data)
}
item := body.Data.Items[0]
if item.AgencyID != 103 || item.OwnerUserID != 1003 || item.ParentBDUserID != 2003 || item.Status != "active" {
t.Fatalf("unexpected agency projection: %+v", item)
}
if body.Data.TotalBDLeaders != 4 || body.Data.TotalBDs != 9 || !body.Data.Truncated {
t.Fatalf("owner totals not preserved: %+v", body.Data)
}
if got := responseRecorder.Body.String(); !containsAll(got, `"agencyId":"103"`, `"ownerUserId":"1003"`, `"parentBdUserId":"2003"`) {
t.Fatalf("int64 ids must be JSON strings: %s", got)
}
}
func TestMyTeamRouteRequiresTeamViewPermissionBeforeOwnerCall(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"agency:list"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil))
if responseRecorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 0 {
t.Fatalf("owner service called %d times without team:view", client.calls)
}
}
func TestMyTeamRouteFiltersScopedIDProjectionBeforeTotalAndPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
// "002" matches owner 1002 and parent BD 2002. Filtering happens only over
// the already session-scoped RPC projection and must precede total/pagination.
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?keyword=002&page=1&page_size=1", nil))
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Total != 1 || len(body.Data.Items) != 1 || body.Data.Items[0].AgencyID != 102 {
t.Fatalf("keyword filter/page = %+v", body.Data)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("keyword must not change team scope: manager=%d", client.request.GetManagerUserId())
}
}
func containsAll(value string, targets ...string) bool {
for _, target := range targets {
if !strings.Contains(value, target) {
return false
}
}
return true
}

View File

@ -0,0 +1,305 @@
package externaladmin
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/cache"
)
const (
SessionCookieName = "hyapp_external_session"
CSRFCookieName = "hyapp_external_csrf"
CSRFHeaderName = "X-CSRF-Token"
CookiePath = "/api/v1/external"
)
var (
ErrInvalidCredentials = errors.New("invalid external admin credentials")
ErrAccountConflict = errors.New("external admin account conflict")
ErrAccountNotFound = errors.New("external admin account not found")
ErrTargetNotFound = errors.New("linked app user not found")
ErrInvalidInput = errors.New("invalid external admin input")
ErrInvalidPermissions = errors.New("invalid external admin permissions")
ErrPermissionConflict = errors.New("external admin permission revision conflict")
ErrInvalidSession = errors.New("invalid external admin session")
ErrPasswordReused = errors.New("new external admin password matches current password")
ErrPasswordBlank = errors.New("external admin password cannot be only whitespace")
ErrLoginRateLimited = errors.New("external admin login rate limited")
ErrLoginLimiterFailed = errors.New("external admin login rate limiter unavailable")
)
const externalOperatorNamespace uint64 = 1 << 62
// Config contains only external-portal controls. Cookie transport attributes are inherited
// from the already validated admin cookie settings so local/prod deployments stay consistent.
type Config struct {
SessionTTL time.Duration
MaxLoginFailures uint
LockDuration time.Duration
CookieSecure bool
CookieSameSite string
LoginRateLimit LoginRateLimitConfig
}
type LoginRateLimitConfig struct {
Window time.Duration
SystemLimit uint64
AppLimit uint64
IPLimit uint64
IdentityLimit uint64
}
type FixedWindowLimiter interface {
ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error)
}
type LoginRateLimitError struct {
RetryAfter time.Duration
}
func (err *LoginRateLimitError) Error() string {
return ErrLoginRateLimited.Error()
}
func (err *LoginRateLimitError) Unwrap() error {
return ErrLoginRateLimited
}
type App struct {
AppCode string `json:"appCode"`
AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
}
type LinkedUser struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
PrettyID string `json:"prettyId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
}
type AccountDTO struct {
ID uint64 `json:"id"`
AppCode string `json:"appCode"`
Username string `json:"username"`
Status string `json:"status"`
LinkedUser LinkedUser `json:"linkedUser"`
Permissions []string `json:"permissions"`
PermissionRevision uint64 `json:"permissionRevision"`
CreatedByAdminID uint `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
LastLoginAtMS *int64 `json:"lastLoginAtMs"`
}
type AccountSummary struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
}
type SessionView struct {
User LinkedUser `json:"user"`
Account AccountSummary `json:"account"`
App App `json:"app"`
AppCode string `json:"appCode"`
Permissions []string `json:"permissions"`
Capabilities []string `json:"capabilities"`
PasswordChangeRequired bool `json:"passwordChangeRequired"`
CSRFToken string `json:"csrfToken,omitempty"`
}
type LoginInput struct {
Username string
Password string
IP string
UserAgent string
}
type LoginResult struct {
SessionToken string
CSRFToken string
View SessionView
}
type ListInput struct {
AppCode string
Page int
PageSize int
Keyword string
Status string
}
type ListResult struct {
Items []AccountDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
}
type CreateInput struct {
AppCode string
TargetUserID string
Username string
Password string
Permissions []string
PermissionsSet bool
CreatedByAdminID uint
}
type ChangePasswordInput struct {
CurrentPassword string
NewPassword string
}
type SessionPrincipal struct {
SessionID uint64
AccountID uint64
OperatorID uint
AppCode string
LinkedAppUserID int64
Username string
Permissions []string
PasswordChangeRequired bool
CSRFTokenHash string
ExpiresAtMS int64
}
// MyTeamAgency is intentionally a narrow projection of the owner service's active
// manager-team relation. IDs are encoded as strings so browser clients cannot lose
// precision when HYApp's int64 identifiers exceed JavaScript's safe integer range.
type MyTeamAgency struct {
AgencyID int64 `json:"agencyId,string"`
OwnerUserID int64 `json:"ownerUserId,string"`
ParentBDUserID int64 `json:"parentBdUserId,string"`
Status string `json:"status"`
}
type MyTeamAgencyPage struct {
Items []MyTeamAgency `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
TotalBDLeaders int32 `json:"totalBdLeaders"`
TotalBDs int32 `json:"totalBds"`
Truncated bool `json:"truncated"`
}
func operatorIDForAccount(accountID uint64) (uint, error) {
if accountID == 0 || accountID >= externalOperatorNamespace {
return 0, ErrInvalidSession
}
// Reused business handlers accept only a positive admin/operator integer. A high-bit namespace
// prevents external account #1 from being recorded as main admin #1 while staying <= MaxInt64
// for signed downstream protos and positive for UNSIGNED audit columns.
return uint(externalOperatorNamespace | accountID), nil
}
func operatorUsername(appCode string, username string) string {
return truncateText("external:"+normalizeAppCode(appCode)+":"+normalizeUsername(username), 64)
}
// FlexibleString accepts either a JSON string or number. Short IDs must remain textual in
// storage, but accepting numeric JSON prevents clients from silently losing older contracts.
type FlexibleString string
func (value *FlexibleString) UnmarshalJSON(body []byte) error {
body = bytes.TrimSpace(body)
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
*value = ""
return nil
}
if body[0] == '"' {
var text string
if err := json.Unmarshal(body, &text); err != nil {
return err
}
*value = FlexibleString(strings.TrimSpace(text))
return nil
}
var number json.Number
if err := json.Unmarshal(body, &number); err != nil {
return fmt.Errorf("targetUserId must be a string or integer: %w", err)
}
if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil {
return fmt.Errorf("targetUserId must be an integer: %w", err)
}
*value = FlexibleString(number.String())
return nil
}
func DefaultPermissionSnapshot() []string {
permissions := make([]string, 0, len(permissionCatalog))
for _, item := range permissionCatalog {
if item.DefaultGranted {
permissions = append(permissions, item.Code)
}
}
return normalizePermissions(permissions)
}
func encodePermissions(permissions []string) (string, error) {
normalized := normalizePermissions(permissions)
body, err := json.Marshal(normalized)
return string(body), err
}
func decodePermissions(raw string) ([]string, error) {
permissions := []string{}
if err := json.Unmarshal([]byte(raw), &permissions); err != nil {
return nil, err
}
return effectivePermissions(permissions), nil
}
func normalizePermissions(permissions []string) []string {
seen := make(map[string]struct{}, len(permissions))
out := make([]string, 0, len(permissions))
for _, permission := range permissions {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
if _, ok := seen[permission]; ok {
continue
}
seen[permission] = struct{}{}
out = append(out, permission)
}
sort.Strings(out)
return out
}
func capabilitiesFor(permissions []string) []string {
permissions = effectivePermissions(permissions)
permissionSet := make(map[string]struct{}, len(permissions))
for _, permission := range permissions {
permissionSet[permission] = struct{}{}
}
aliases := permissionCapabilityAliases()
seen := map[string]struct{}{}
capabilities := []string{}
for permission := range permissionSet {
for _, capability := range aliases[permission] {
if _, ok := seen[capability]; ok {
continue
}
seen[capability] = struct{}{}
capabilities = append(capabilities, capability)
}
}
sort.Strings(capabilities)
return capabilities
}

View File

@ -0,0 +1,115 @@
package externaladmin
import (
"encoding/json"
"errors"
"reflect"
"sort"
"strings"
"testing"
)
func TestExternalOperatorIdentityUsesStablePositiveNamespace(t *testing.T) {
first, err := operatorIDForAccount(1)
if err != nil {
t.Fatalf("operator id for first account: %v", err)
}
second, err := operatorIDForAccount(2)
if err != nil {
t.Fatalf("operator id for second account: %v", err)
}
if first == 1 || second == 2 || first == second {
t.Fatalf("operator ids must be namespaced and unique: first=%d second=%d", first, second)
}
if uint64(first) > uint64(1<<63-1) || uint64(second) > uint64(1<<63-1) {
t.Fatalf("operator ids must fit signed downstream fields: first=%d second=%d", first, second)
}
if _, err := operatorIDForAccount(externalOperatorNamespace); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("out-of-range account id error = %v", err)
}
if got := operatorUsername("FAMI", "Operator"); got != "external:fami:operator" {
t.Fatalf("operator username = %q", got)
}
if got := operatorUsername("fami", strings.Repeat("x", 80)); len([]rune(got)) != 64 || !strings.HasPrefix(got, "external:fami:") {
t.Fatalf("long operator username is not safely prefixed/truncated: %q", got)
}
}
func TestLinkedUserMarshalsLongIDAsString(t *testing.T) {
body, err := json.Marshal(LinkedUser{UserID: 9223372036854770000})
if err != nil {
t.Fatalf("marshal linked user: %v", err)
}
if string(body) != `{"userId":"9223372036854770000","displayUserId":"","defaultDisplayUserId":"","prettyDisplayUserId":"","prettyId":"","username":"","avatar":"","status":""}` {
t.Fatalf("linked user json = %s", body)
}
}
func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.T) {
permissions := DefaultPermissionSnapshot()
wantPermissions := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list",
"room:list", "room:update", "privilege:list", "privilege:grant", "banner:create",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view",
}
for _, permission := range wantPermissions {
if !contains(permissions, permission) {
t.Fatalf("default permission snapshot missing %s", permission)
}
}
capabilities := capabilitiesFor(permissions)
wantCapabilities := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list",
"room:list", "room:update", "privilege:list", "privilege:grant", "banner:create",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view",
}
sort.Strings(wantCapabilities)
if !reflect.DeepEqual(capabilities, wantCapabilities) {
t.Fatalf("capabilities = %#v, want %#v", capabilities, wantCapabilities)
}
}
func TestFlexibleStringAcceptsShortIDStringAndInteger(t *testing.T) {
for _, testCase := range []struct {
body string
want string
}{
{body: `"001234"`, want: "001234"},
{body: `123456`, want: "123456"},
} {
var value FlexibleString
if err := json.Unmarshal([]byte(testCase.body), &value); err != nil {
t.Fatalf("unmarshal %s: %v", testCase.body, err)
}
if string(value) != testCase.want {
t.Fatalf("value = %q, want %q", value, testCase.want)
}
}
}
func TestExactIdentityDoesNotAcceptNicknameFallback(t *testing.T) {
user := LinkedUser{
UserID: 77, DisplayUserID: "1234567", DefaultDisplayUserID: "7654321",
PrettyDisplayUserID: "8888", PrettyID: "8888", Username: "nickname",
}
for _, accepted := range []string{"77", "1234567", "7654321", "8888"} {
if !matchesExactIdentity(user, accepted) {
t.Fatalf("exact identity %s rejected", accepted)
}
}
if matchesExactIdentity(user, "nickname") {
t.Fatal("nickname must not bind an external credential")
}
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
@ -26,14 +27,37 @@ func New(store *repository.Store, wallet walletclient.Client, activity activityc
} }
func (h *Handler) ListApplications(c *gin.Context) { func (h *Handler) ListApplications(c *gin.Context) {
h.listApplications(c, false)
}
func (h *Handler) ListOperationsApplications(c *gin.Context) {
h.listApplications(c, true)
}
func (h *Handler) listApplications(c *gin.Context, operations bool) {
options := shared.ListOptions(c) options := shared.ListOptions(c)
items, total, err := h.service.ListApplications(repository.WithdrawalApplicationListOptions{ listOptions := repository.WithdrawalApplicationListOptions{
Page: options.Page, Page: options.Page,
PageSize: options.PageSize, PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"), AppCode: firstQuery(c, "app_code", "appCode"),
Keyword: options.Keyword, Keyword: options.Keyword,
}) }
var (
items []withdrawalApplicationDTO
total int64
err error
)
actor := shared.ActorFromContext(c)
if operations {
items, total, err = h.service.ListOperationsApplications(actor, listOptions)
} else {
items, total, err = h.service.ListApplications(actor, listOptions)
}
if err != nil { if err != nil {
if errors.Is(err, errWithdrawalMoneyScopeForbidden) {
response.Forbidden(c, err.Error())
return
}
response.ServerError(c, "获取用户提现申请列表失败") response.ServerError(c, "获取用户提现申请列表失败")
return return
} }
@ -41,18 +65,31 @@ func (h *Handler) ListApplications(c *gin.Context) {
} }
func (h *Handler) ApproveApplication(c *gin.Context) { func (h *Handler) ApproveApplication(c *gin.Context) {
h.auditApplication(c, "approved") h.auditApplication(c, "approved", false)
} }
func (h *Handler) RejectApplication(c *gin.Context) { func (h *Handler) RejectApplication(c *gin.Context) {
h.auditApplication(c, "rejected") h.auditApplication(c, "rejected", false)
} }
func (h *Handler) auditApplication(c *gin.Context, decision string) { func (h *Handler) ApproveOperationsApplication(c *gin.Context) {
h.auditApplication(c, "approved", true)
}
func (h *Handler) RejectOperationsApplication(c *gin.Context) {
h.auditApplication(c, "rejected", true)
}
func (h *Handler) auditApplication(c *gin.Context, decision string, operations bool) {
id, ok := shared.ParseID(c, "application_id") id, ok := shared.ParseID(c, "application_id")
if !ok { if !ok {
return return
} }
if strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)) == "" {
// 财务工作台可跨 App 列表,审核时必须由前端显式选中目标行 App不能让 appctx 的 lalu 默认值静默决定资金数据域。
response.BadRequest(c, "缺少 "+appctx.HeaderAppCode)
return
}
var req struct { var req struct {
AuditRemark string `json:"auditRemark"` AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"` AuditImageURL string `json:"auditImageUrl"`
@ -66,7 +103,11 @@ func (h *Handler) auditApplication(c *gin.Context, decision string) {
item *withdrawalApplicationDTO item *withdrawalApplicationDTO
err error err error
) )
if decision == "approved" { if operations && decision == "approved" {
item, err = h.service.ApproveOperationsApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c))
} else if operations {
item, err = h.service.RejectOperationsApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c))
} else if decision == "approved" {
item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, firstNonEmpty(req.AuditImageURL, req.AuditImageURLSnake), middleware.CurrentRequestID(c)) item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, firstNonEmpty(req.AuditImageURL, req.AuditImageURLSnake), middleware.CurrentRequestID(c))
} else { } else {
item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c)) item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c))
@ -75,7 +116,11 @@ func (h *Handler) auditApplication(c *gin.Context, decision string) {
writeWithdrawalServiceError(c, err) writeWithdrawalServiceError(c, err)
return return
} }
shared.OperationLogWithResourceID(c, h.audit, "audit-user-withdrawal-application", "admin_user_withdrawal_applications", strconv.FormatUint(uint64(item.ID), 10), "success", decision) action := "audit-user-withdrawal-finance"
if operations {
action = "audit-user-withdrawal-operations"
}
shared.OperationLogWithResourceID(c, h.audit, action, "admin_user_withdrawal_applications", strconv.FormatUint(uint64(item.ID), 10), "success", decision)
response.OK(c, item) response.OK(c, item)
} }
@ -88,6 +133,14 @@ func writeWithdrawalServiceError(c *gin.Context, err error) {
response.BadRequest(c, "提现申请已审核") response.BadRequest(c, "提现申请已审核")
return return
} }
if errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
response.BadRequest(c, "提现申请尚未进入当前审核阶段")
return
}
if errors.Is(err, errWithdrawalMoneyScopeForbidden) {
response.Forbidden(c, err.Error())
return
}
switch strings.TrimSpace(err.Error()) { switch strings.TrimSpace(err.Error()) {
case "没有操作权限": case "没有操作权限":
response.Forbidden(c, err.Error()) response.Forbidden(c, err.Error())

View File

@ -1,12 +1,56 @@
package financewithdrawal package financewithdrawal
import "testing" import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) { func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) {
if !canAuditWithdrawal([]string{"finance-withdrawal:audit"}) { if !canAuditWithdrawal([]string{"finance-withdrawal:audit"}) {
t.Fatal("dedicated withdrawal audit permission must allow auditing") t.Fatal("finance withdrawal permission must allow finance final review")
}
if canAuditWithdrawal([]string{"operations-withdrawal:audit"}) {
t.Fatal("operations permission must not allow finance final review")
} }
if canAuditWithdrawal([]string{"finance-application:audit"}) { if canAuditWithdrawal([]string{"finance-application:audit"}) {
t.Fatal("removed finance application permission must not authorize withdrawal auditing") t.Fatal("removed finance application permission must not authorize withdrawal auditing")
} }
} }
func TestCanAuditOperationsWithdrawalUsesDedicatedPermission(t *testing.T) {
if !canAuditOperationsWithdrawal([]string{"operations-withdrawal:audit"}) {
t.Fatal("operations withdrawal permission must allow operations initial review")
}
if canAuditOperationsWithdrawal([]string{"finance-withdrawal:audit"}) {
t.Fatal("finance permission must not allow operations initial review")
}
}
func TestWithdrawalAuditHandlersRequireExplicitAppHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &Handler{}
router := gin.New()
router.POST("/finance/:application_id/approve", handler.ApproveApplication)
router.POST("/finance/:application_id/reject", handler.RejectApplication)
router.POST("/operations/:application_id/approve", handler.ApproveOperationsApplication)
router.POST("/operations/:application_id/reject", handler.RejectOperationsApplication)
for _, path := range []string{
"/finance/77/approve",
"/finance/77/reject",
"/operations/77/approve",
"/operations/77/reject",
} {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "X-App-Code") {
t.Fatalf("missing App header path %s status=%d body=%s", path, response.Code, response.Body.String())
}
}
}

View File

@ -26,6 +26,12 @@ type withdrawalApplicationDTO struct {
AuditRemark string `json:"auditRemark"` AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"` AuditImageURL string `json:"auditImageUrl"`
ApprovedAtMS *int64 `json:"approvedAtMs"` ApprovedAtMS *int64 `json:"approvedAtMs"`
OperationsStatus string `json:"operationsStatus"`
OperationsReviewerUserID *uint `json:"operationsReviewerUserId"`
OperationsReviewerName string `json:"operationsReviewerName"`
OperationsAuditRemark string `json:"operationsAuditRemark"`
OperationsAuditTransactionID string `json:"operationsAuditTransactionId"`
OperationsReviewedAtMS *int64 `json:"operationsReviewedAtMs"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"`
} }
@ -49,6 +55,12 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
AuditRemark: item.AuditRemark, AuditRemark: item.AuditRemark,
AuditImageURL: item.AuditImageURL, AuditImageURL: item.AuditImageURL,
ApprovedAtMS: item.ApprovedAtMS, ApprovedAtMS: item.ApprovedAtMS,
OperationsStatus: item.OperationsStatus,
OperationsReviewerUserID: item.OperationsReviewerUserID,
OperationsReviewerName: item.OperationsReviewerName,
OperationsAuditRemark: item.OperationsAuditRemark,
OperationsAuditTransactionID: item.OperationsAuditTransactionID,
OperationsReviewedAtMS: item.OperationsReviewedAtMS,
CreatedAtMS: item.CreatedAtMS, CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS, UpdatedAtMS: item.UpdatedAtMS,
} }

View File

@ -13,4 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications) protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications)
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
protected.GET("/admin/operations/withdrawal-applications", middleware.RequireAnyPermission(permissionViewOperationsWithdrawals, permissionAuditOperationsWithdrawals), h.ListOperationsApplications)
protected.POST("/admin/operations/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication)
protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication)
} }

View File

@ -22,11 +22,15 @@ import (
const ( const (
permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit" permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionViewOperationsWithdrawals = "operations-withdrawal:view"
permissionAuditOperationsWithdrawals = "operations-withdrawal:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500) pointWithdrawalDefaultFeeBPS = int32(500)
) )
var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围")
type Service struct { type Service struct {
store *repository.Store store *repository.Store
wallet walletclient.Client wallet walletclient.Client
@ -43,10 +47,16 @@ func NewService(store *repository.Store, wallet walletclient.Client, activity ac
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }} return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
} }
func (s *Service) ListApplications(options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil { if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured") return nil, 0, errors.New("admin store is not configured")
} }
var err error
options, err = s.scopeWithdrawalList(actor, options)
if err != nil {
return nil, 0, err
}
options.Stage = repository.WithdrawalApplicationReviewStageFinance
items, total, err := s.store.ListWithdrawalApplications(options) items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -54,52 +64,115 @@ func (s *Service) ListApplications(options repository.WithdrawalApplicationListO
return withdrawalApplicationDTOsFromModel(items), total, nil return withdrawalApplicationDTOsFromModel(items), total, nil
} }
func (s *Service) ListOperationsApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
var err error
options, err = s.scopeWithdrawalList(actor, options)
if err != nil {
return nil, 0, err
}
options.Stage = repository.WithdrawalApplicationReviewStageOperations
items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil {
return nil, 0, err
}
return withdrawalApplicationDTOsFromModel(items), total, nil
}
func (s *Service) scopeWithdrawalList(actor shared.Actor, options repository.WithdrawalApplicationListOptions) (repository.WithdrawalApplicationListOptions, error) {
access, err := s.store.MoneyAccessForUser(actor.UserID)
if err != nil {
return options, err
}
if requestedApp := strings.TrimSpace(options.AppCode); requestedApp != "" {
options.AppCode = appctx.Normalize(requestedApp)
if !access.AllowsApp(options.AppCode) {
return options, errWithdrawalMoneyScopeForbidden
}
return options, nil
}
options.AppCode = ""
if !access.All {
// 空 app 查询不代表全局;非全量财务用户只能在 admin_user_money_scopes 授权的 App 集合中分页。
options.RestrictAppCodes = true
options.AllowedAppCodes = access.AppCodes()
}
return options, nil
}
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID) return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID)
} }
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusRejected, remark, "", requestID) return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
} }
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { func (s *Service) ApproveOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusApproved, remark, "", requestID)
}
func (s *Service) RejectOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, stage repository.WithdrawalApplicationReviewStage, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
if s == nil || s.store == nil || s.wallet == nil || s.activity == nil { if s == nil || s.store == nil || s.wallet == nil || s.activity == nil {
return nil, errors.New("admin finance withdrawal service is not configured") return nil, errors.New("admin finance withdrawal service is not configured")
} }
if id == 0 || actor.UserID == 0 || !canAuditWithdrawal(actor.Permissions) { if id == 0 || actor.UserID == 0 || !canAuditWithdrawalStage(stage, actor.Permissions) {
return nil, errors.New("没有操作权限") return nil, errors.New("没有操作权限")
} }
remark = strings.TrimSpace(remark) remark = strings.TrimSpace(remark)
auditImageURL = strings.TrimSpace(auditImageURL) auditImageURL = strings.TrimSpace(auditImageURL)
if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" { if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
return nil, errors.New("提现通过凭证图片不能为空") return nil, errors.New("提现通过凭证图片不能为空")
} }
if decision == model.WithdrawalApplicationStatusRejected && remark == "" { if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
return nil, errors.New("拒绝原因不能为空") return nil, errors.New("拒绝原因不能为空")
} }
appCode := appctx.FromContext(ctx) appCode := appctx.FromContext(ctx)
item, err := s.store.GetWithdrawalApplicationForApp(appCode, id) access, err := s.store.MoneyAccessForUser(actor.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if item.Status != model.WithdrawalApplicationStatusPending { if !access.AllowsApp(appCode) {
return nil, repository.ErrWithdrawalApplicationAlreadyAudited // 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。
} return nil, errWithdrawalMoneyScopeForbidden
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
if err != nil || userID <= 0 || item.SalaryAssetType == "" || item.WithdrawAmountMinor <= 0 || item.FreezeTransactionID == "" {
return nil, errors.New("提现单冻结信息不完整")
}
commandID := withdrawalAuditCommandID(id, decision)
amountMinor := item.WithdrawAmountMinor
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id)
if err != nil {
return nil, err
} }
nowMS := s.now().UnixMilli() nowMS := s.now().UnixMilli()
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ commandID := withdrawalAuditCommandID(id, stage)
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected {
return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS)
}
updated, err := s.store.ReviewWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Stage: stage,
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
userID, parseErr := withdrawalWalletUserID(item)
if parseErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, parseErr
}
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。
return repository.WithdrawalApplicationAuditEffect{}, nil
}
transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id)
if walletErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, walletErr
}
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id, ApplicationID: id,
AppCode: appCode, AppCode: appCode,
UserID: userID, UserID: userID,
Stage: stage,
Decision: decision, Decision: decision,
Remark: remark, Remark: remark,
AuditImageURL: auditImageURL, AuditImageURL: auditImageURL,
@ -108,19 +181,11 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
WithdrawMethod: item.WithdrawMethod, WithdrawMethod: item.WithdrawMethod,
RequestID: requestID, RequestID: requestID,
SentAtMS: nowMS, SentAtMS: nowMS,
}); err != nil { }); noticeErr != nil {
// 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知 // 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息
return nil, err return repository.WithdrawalApplicationAuditEffect{}, noticeErr
} }
updated, err := s.store.AuditWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
AuditCommandID: commandID,
AuditTransactionID: transactionID,
ApprovedAtMS: nowMS,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -129,6 +194,80 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
return &dto, nil return &dto, nil
} }
// executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。
// claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting不能被改点通过或进入财务。
func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
claimed, err := s.store.ClaimWithdrawalOperationsRejectionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditCommandID: commandID,
ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) error {
_, err := withdrawalWalletUserID(item)
return err
})
if err != nil {
return nil, err
}
if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected {
// 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox前端重试是安全的。
dto := withdrawalApplicationDTOFromModel(*claimed)
return &dto, nil
}
userID, err := withdrawalWalletUserID(*claimed)
if err != nil {
return nil, err
}
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
return nil, errors.New("提现单运营拒绝 claim 不完整")
}
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id)
if err != nil {
return nil, err
}
sentAtMS := nowMS
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
sentAtMS = *claimed.OperationsReviewedAtMS
}
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
Remark: claimedRemark,
AuditTransactionID: transactionID,
WithdrawAmount: claimed.WithdrawAmount,
WithdrawMethod: claimed.WithdrawMethod,
RequestID: requestID,
SentAtMS: sentAtMS,
}); err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
}
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil
}
func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) {
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" {
return 0, errors.New("提现单冻结信息不完整")
}
return userID, nil
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) { func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
reason := strings.TrimSpace(remark) reason := strings.TrimSpace(remark)
if reason == "" { if reason == "" {
@ -223,8 +362,9 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
} }
} }
func withdrawalAuditCommandID(id uint, decision string) string { func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string {
return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision) // 同一阶段的通过和拒绝复用 command id并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。
return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage)
} }
func isPointWithdrawalAssetType(assetType string) bool { func isPointWithdrawalAssetType(assetType string) bool {
@ -240,6 +380,7 @@ type auditNoticeInput struct {
ApplicationID uint ApplicationID uint
AppCode string AppCode string
UserID int64 UserID int64
Stage repository.WithdrawalApplicationReviewStage
Decision string Decision string
Remark string Remark string
AuditImageURL string AuditImageURL string
@ -259,6 +400,9 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark) body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark)
title = "Withdrawal request rejected" title = "Withdrawal request rejected"
eventType = "finance_withdrawal_rejected" eventType = "finance_withdrawal_rejected"
if input.Stage == repository.WithdrawalApplicationReviewStageOperations {
eventType = "operations_withdrawal_rejected"
}
} else { } else {
imageURL = strings.TrimSpace(input.AuditImageURL) imageURL = strings.TrimSpace(input.AuditImageURL)
} }
@ -267,6 +411,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
"app_code": strings.TrimSpace(input.AppCode), "app_code": strings.TrimSpace(input.AppCode),
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID), "audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
"decision": strings.TrimSpace(input.Decision), "decision": strings.TrimSpace(input.Decision),
"review_stage": string(input.Stage),
"withdraw_amount": strings.TrimSpace(input.WithdrawAmount), "withdraw_amount": strings.TrimSpace(input.WithdrawAmount),
"withdraw_method": strings.TrimSpace(input.WithdrawMethod), "withdraw_method": strings.TrimSpace(input.WithdrawMethod),
}) })
@ -282,7 +427,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
}, },
TargetUserId: input.UserID, TargetUserId: input.UserID,
Producer: "admin-server", Producer: "admin-server",
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Decision), ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision),
ProducerEventType: eventType, ProducerEventType: eventType,
MessageType: "system", MessageType: "system",
AggregateType: "user_withdrawal_application", AggregateType: "user_withdrawal_application",
@ -300,14 +445,32 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
return nil return nil
} }
func withdrawalAuditNotificationEventID(id uint, decision string) string { func withdrawalAuditNotificationEventID(id uint, stage repository.WithdrawalApplicationReviewStage, decision string) string {
if stage == repository.WithdrawalApplicationReviewStageFinance {
// 旧财务通知可能已成功而 admin 申请仍 pending继续使用原 event id部署后重试才不会重复发用户通知。
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision)) return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
return fmt.Sprintf("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision))
} }
func canAuditWithdrawal(permissions []string) bool { func canAuditWithdrawal(permissions []string) bool {
return hasWithdrawalPermission(permissions, permissionAuditWithdrawalApplications)
}
func canAuditOperationsWithdrawal(permissions []string) bool {
return hasWithdrawalPermission(permissions, permissionAuditOperationsWithdrawals)
}
func canAuditWithdrawalStage(stage repository.WithdrawalApplicationReviewStage, permissions []string) bool {
if stage == repository.WithdrawalApplicationReviewStageOperations {
return canAuditOperationsWithdrawal(permissions)
}
return canAuditWithdrawal(permissions)
}
func hasWithdrawalPermission(permissions []string, expected string) bool {
for _, permission := range permissions { for _, permission := range permissions {
switch strings.TrimSpace(permission) { if strings.TrimSpace(permission) == expected {
case permissionAuditWithdrawalApplications:
return true return true
} }
} }

View File

@ -2,14 +2,229 @@ package financewithdrawal
import ( import (
"context" "context"
"errors"
"testing" "testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
) )
func TestOperationsApprovalMovesToFinanceWithoutWalletOrUserNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalReviewLock(mock, model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
dto, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "资料无误", "request-ops-approve")
if err != nil {
t.Fatalf("approve operations withdrawal failed: %v", err)
}
if dto.Status != model.WithdrawalApplicationStatusPending || dto.OperationsStatus != model.WithdrawalOperationsStatusApproved || dto.OperationsReviewerName != "operations" {
t.Fatalf("operations approval state mismatch: %+v", dto)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("operations approval must not touch wallet or send final notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsRejectionReleasesFrozenBalanceAndSendsFinalNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
expectWithdrawalRejectionFinalize(mock)
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "收款资料不符", "request-ops-reject")
if err != nil {
t.Fatalf("reject operations withdrawal failed: %v", err)
}
if wallet.releaseSalary == nil || wallet.releaseSalary.GetCommandId() != "salary-withdrawal:77:operations" || wallet.releaseSalary.GetSalaryUsdMinor() != 5000 {
t.Fatalf("operations rejection release mismatch: %+v", wallet.releaseSalary)
}
if wallet.settleSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil {
t.Fatalf("operations rejection must only release matching salary asset: %+v", wallet)
}
if activity.calls != 1 || activity.last.GetProducerEventId() != "operations-withdrawal:77:rejected" || activity.last.GetProducerEventType() != "operations_withdrawal_rejected" {
t.Fatalf("operations rejection final notice mismatch: calls=%d request=%+v", activity.calls, activity.last)
}
if dto.Status != model.WithdrawalApplicationStatusRejected || dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || dto.OperationsAuditTransactionID != "salary-release-tx" {
t.Fatalf("operations rejection state mismatch: %+v", dto)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsRejectionNoticeFailureStaysRejectingAndRetryKeepsFirstAuditContext(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
noticeFailure := errors.New("activity unavailable")
activity.failures = []error{noticeFailure, nil}
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
_, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "first-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "first rejection reason", "request-ops-reject-first")
if !errors.Is(err, noticeFailure) {
t.Fatalf("first rejection must surface notice failure, got %v", err)
}
// 第二个运营人员重试时只能沿用第一次 claim 快照;数据库 rejecting 行禁止改点通过,也不允许改写拒绝原因。
expectWithdrawalMoneyAccess(mock, 18, "lalu")
expectWithdrawalRejectionRetryClaim(mock, 8, "first-operator", "first rejection reason")
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "first-operator", "first rejection reason")
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "changed retry reason", "request-ops-reject-retry")
if err != nil {
t.Fatalf("retry rejecting withdrawal failed: %v", err)
}
if dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || wallet.releaseSalary.GetOperatorUserId() != 8 || wallet.releaseSalary.GetReason() != "first rejection reason" {
t.Fatalf("retry must preserve first claim context: dto=%+v wallet=%+v", dto, wallet.releaseSalary)
}
if activity.calls != 2 || activity.last == nil || activity.last.GetBody() != "Your withdrawal request has been rejected. Reason: first rejection reason" {
t.Fatalf("retry notice must preserve first rejection reason: calls=%d request=%+v", activity.calls, activity.last)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsApprovalRejectsDurableRejectingClaimBeforeWalletOrNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 18, "lalu")
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "first-operator", "first rejection reason"))
mock.ExpectRollback()
_, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "改为通过", "request-ops-approve-after-reject")
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
t.Fatalf("rejecting claim must block operations approval, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("rejecting gate must run before integrations: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestFinanceReviewRejectsOperationsPendingBeforeWalletOrNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 9, "lalu")
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
mock.ExpectRollback()
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
}, 77, "已打款", "https://example.com/proof.png", "request-finance-approve")
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
t.Fatalf("finance must reject operations-pending application, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("stage gate must run before wallet or notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsListRejectsExplicitAppOutsideMoneyScope(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
_, _, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{AppCode: "huwaa"})
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
t.Fatalf("out-of-scope list must be forbidden, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || activity.calls != 0 {
t.Fatalf("out-of-scope list must not call integrations: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsListWithoutAppRestrictsToMoneyScopeApps(t *testing.T) {
svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu", "fami")
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped, 20).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
items, total, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{Page: 1, PageSize: 20})
if err != nil || total != 0 || len(items) != 0 {
t.Fatalf("money-scoped operations list mismatch: total=%d items=%+v err=%v", total, items, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestFinanceAuditRejectsHeaderAppOutsideMoneyScopeBeforeWallet(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 9, "fami")
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
}, 77, "paid", "https://example.com/proof.png", "request-out-of-scope")
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
t.Fatalf("out-of-scope audit must be forbidden, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("money scope gate must run before row lock, wallet, or notice: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestWithdrawalAuditNotificationEventIDPreservesFinanceCompatibility(t *testing.T) {
t.Parallel()
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved); got != "finance-withdrawal:77:approved" {
t.Fatalf("finance event id = %q", got)
}
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected); got != "operations-withdrawal:77:rejected" {
t.Fatalf("operations event id = %q", got)
}
}
func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) { func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
wallet := &fakeAuditWalletClient{} wallet := &fakeAuditWalletClient{}
svc := &Service{wallet: wallet} svc := &Service{wallet: wallet}
@ -120,3 +335,121 @@ func (f *fakeAuditWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *
f.releaseSalary = req f.releaseSalary = req
return &walletv1.ReleaseSalaryWithdrawalResponse{TransactionId: "salary-release-tx"}, nil return &walletv1.ReleaseSalaryWithdrawalResponse{TransactionId: "salary-release-tx"}, nil
} }
type fakeAuditActivityClient struct {
activityclient.Client
last *activityv1.CreateInboxMessageRequest
calls int
failures []error
}
func (f *fakeAuditActivityClient) CreateInboxMessage(_ context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
f.last = req
f.calls++
if index := f.calls - 1; index < len(f.failures) && f.failures[index] != nil {
return nil, f.failures[index]
}
return &activityv1.CreateInboxMessageResponse{}, nil
}
func newWithdrawalAuditServiceTest(t *testing.T) (*Service, sqlmock.Sqlmock, *fakeAuditWalletClient, *fakeAuditActivityClient, 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)
}
wallet := &fakeAuditWalletClient{}
activity := &fakeAuditActivityClient{}
svc := &Service{
store: repository.New(gormDB), wallet: wallet, activity: activity,
now: func() time.Time { return time.UnixMilli(1_700_000_300_000).UTC() },
}
return svc, mock, wallet, activity, func() { _ = sqlDB.Close() }
}
func expectWithdrawalReviewLock(mock sqlmock.Sqlmock, status string, operationsStatus string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(status, operationsStatus))
}
func expectWithdrawalRejectionClaim(mock sqlmock.Sqlmock, operationsStatus string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, operationsStatus))
if operationsStatus == model.WithdrawalOperationsStatusPending {
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
}
mock.ExpectCommit()
}
func expectWithdrawalRejectionRetryClaim(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
mock.ExpectCommit()
}
func expectWithdrawalRejectionFinalize(mock sqlmock.Sqlmock) {
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "operations", "收款资料不符")
}
func expectWithdrawalRejectionFinalizeWithContext(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
}
func expectWithdrawalMoneyAccess(mock sqlmock.Sqlmock, userID uint, appCodes ...string) {
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, "reviewer", "Reviewer", "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, appCode := range appCodes {
rows.AddRow(index+1, userID, appCode, 0, int64(1_700_000_000_000), int64(1_700_000_000_000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(rows)
}
func withdrawalAuditRows(status string, operationsStatus string) *sqlmock.Rows {
return withdrawalAuditRowsWithOperationsContext(status, operationsStatus, 0, "", "")
}
func withdrawalAuditRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows {
var storedReviewerID any
var reviewedAtMS any
operationsCommandID := ""
if reviewerID > 0 {
storedReviewerID = reviewerID
reviewedAtMS = int64(1_700_000_300_000)
operationsCommandID = "salary-withdrawal:77:operations"
}
return sqlmock.NewRows([]string{
"id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor",
"withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id",
"audit_command_id", "audit_transaction_id", "status", "operations_status",
"approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms",
"operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark",
"operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms",
"created_at_ms", "updated_at_ms",
}).AddRow(
77, "lalu", "42001", "HOST_SALARY_USD", "50.00", int64(5000),
"usdt_trc20", "TRON-address", "freeze-command", "freeze-tx",
"", "", status, operationsStatus, nil, "", "", "", nil,
storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS,
int64(1_700_000_000_000), int64(1_700_000_000_000),
)
}

View File

@ -0,0 +1,391 @@
package payment
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// financePaidCoinSellerRechargeSource 只暴露支付系统已经成功落单的币商充值事实。
// WebConsole 手工填写的 USDT/金币和 legacy 钱包发货流水不实现这个接口,避免运营输入进入财务口径。
type financePaidCoinSellerRechargeSource interface {
financePaidCoinSellerRechargeStats(context.Context, financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error)
listFinancePaidCoinSellerRechargeBills(context.Context, financeCoinSellerRechargeQuery, int) ([]rechargeBillDTO, int64, error)
}
func (h *Handler) financePaidCoinSellerRechargeSource(appCode string) financePaidCoinSellerRechargeSource {
if h == nil || appctx.Normalize(appCode) != "aslan" {
return nil
}
// 目前只有 Aslan legacy H5 的 FREIGHT_GOLD 真实支付单需要从 Mongo 补齐;
// 其他 App 继续使用自己的已校验后台订单或新 wallet H5 订单,避免无依据扩大旧平台口径。
source, ok := h.billSources["aslan"]
if !ok || source == nil {
return nil
}
paidSource, _ := source.(financePaidCoinSellerRechargeSource)
return paidSource
}
// financePaidCoinSellerFilter 只匹配有支付订单号、支付渠道、正数美元金额且已经成功的 FREIGHT_GOLD 单。
// 这组字段来自 in_app_purchase_details 支付事实,不读取 user_freight_balance_running_water 的发货金额。
func (s *MongoRechargeBillSource) financePaidCoinSellerFilter(ctx context.Context, query financeCoinSellerRechargeQuery) (bson.M, []string, bool, error) {
if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != "aslan" {
return nil, nil, false, nil
}
filter := bson.M{
"sysOrigin": s.config.SysOrigin,
"status": "SUCCESS",
"trialPeriod": bson.M{"$ne": true},
"amountUsd": bson.M{"$gt": 0},
"orderId": bson.M{"$type": "string", "$ne": ""},
"factory.factoryCode": bson.M{"$type": "string", "$ne": ""},
}
conditions := bson.A{bson.M{"$or": legacyFreightCommodityMatchConditions()}}
if query.TargetUserID > 0 {
filter["acceptUserId"] = query.TargetUserID
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
keywordConditions := bson.A{
bson.M{"_id": keyword},
bson.M{"orderId": keyword},
}
if userID, err := strconv.ParseInt(keyword, 10, 64); err == nil && userID > 0 {
keywordConditions = append(keywordConditions, bson.M{"_id": userID}, bson.M{"acceptUserId": userID})
}
conditions = append(conditions, bson.M{"$or": keywordConditions})
}
paidAt := legacyPaidCoinSellerAtExpression()
timeConditions := bson.A{}
if query.StartAtMS > 0 {
timeConditions = append(timeConditions, bson.M{"$gte": bson.A{paidAt, time.UnixMilli(query.StartAtMS)}})
}
if query.EndAtMS > 0 {
timeConditions = append(timeConditions, bson.M{"$lt": bson.A{paidAt, time.UnixMilli(query.EndAtMS)}})
}
if len(timeConditions) == 1 {
conditions = append(conditions, bson.M{"$expr": timeConditions[0]})
} else if len(timeConditions) > 1 {
conditions = append(conditions, bson.M{"$expr": bson.M{"$and": timeConditions}})
}
filter["$and"] = conditions
if query.RegionID <= 0 {
return filter, nil, true, nil
}
codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
if err != nil {
return nil, nil, false, err
}
if !handled || len(codes) == 0 {
return nil, nil, false, nil
}
return filter, codes, true, nil
}
func legacyFreightCommodityMatchConditions() bson.A {
conditions := make(bson.A, 0, len(legacyFreightCommodityFields))
for _, field := range legacyFreightCommodityFields {
conditions = append(conditions, bson.M{field: bson.M{"$in": legacyFreightCommodityTypes}})
}
return conditions
}
// updateTime 是支付回调把订单推进 SUCCESS 的时间;旧单缺失时才依次回退 purchaseDateMs/createTime。
func legacyPaidCoinSellerAtExpression() bson.M {
return bson.M{"$ifNull": bson.A{
"$updateTime",
bson.M{"$ifNull": bson.A{"$purchaseDateMs", "$createTime"}},
}}
}
func legacyFreightCoinAggregateExpression() bson.M {
typedTotal := bson.M{"$reduce": bson.M{
"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
"initialValue": int64(0),
"in": bson.M{"$add": bson.A{
"$$value",
bson.M{"$cond": bson.A{
bson.M{"$or": bson.A{
bson.M{"$in": bson.A{"$$this.code", legacyFreightCommodityTypes}},
bson.M{"$in": bson.A{"$$this.name", legacyFreightCommodityTypes}},
}},
bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": int64(0), "onNull": int64(0)}},
int64(0),
}},
}},
}}
allNumericTotal := bson.M{"$reduce": bson.M{
"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
"initialValue": int64(0),
"in": bson.M{"$add": bson.A{
"$$value",
bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": int64(0), "onNull": int64(0)}},
}},
}}
return bson.M{"$let": bson.M{
"vars": bson.M{"typedTotal": typedTotal, "allNumericTotal": allNumericTotal},
"in": bson.M{"$cond": bson.A{
bson.M{"$gt": bson.A{"$$typedTotal", 0}},
"$$typedTotal",
bson.M{"$cond": bson.A{
bson.M{"$in": bson.A{"$trackCommodityType", legacyFreightCommodityTypes}},
"$$allNumericTotal",
int64(0),
}},
}},
}}
}
func (s *MongoRechargeBillSource) financePaidCoinSellerRechargeStats(ctx context.Context, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) {
stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}}
filter, regionCodes, matchable, err := s.financePaidCoinSellerFilter(ctx, query)
if err != nil || !matchable {
return stats, err
}
base := mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}
if len(regionCodes) > 0 {
base = append(base, legacyRegionLookupStages(regionCodes)...)
} else {
base = append(base, legacyCountryResolveStages()...)
}
tzOffsetMinutes := query.TzOffsetMinutes
if tzOffsetMinutes == 0 {
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
pipeline := append(base,
bson.D{{Key: "$addFields", Value: bson.M{
"financePaidAt": legacyPaidCoinSellerAtExpression(),
"financeCoinAmount": legacyFreightCoinAggregateExpression(),
}}},
bson.D{{Key: "$facet", Value: bson.M{
"total": mongo.Pipeline{
bson.D{{Key: "$group", Value: bson.M{
"_id": nil,
"billCount": bson.M{"$sum": 1},
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
"coin": bson.M{"$sum": "$financeCoinAmount"},
}}},
},
"daily": mongo.Pipeline{
bson.D{{Key: "$group", Value: bson.M{
"_id": bson.M{"$dateToString": bson.M{
"format": "%Y-%m-%d",
"date": "$financePaidAt",
"timezone": legacyTimezoneOffset(tzOffsetMinutes),
}},
"billCount": bson.M{"$sum": 1},
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
"coin": bson.M{"$sum": "$financeCoinAmount"},
}}},
},
"regions": mongo.Pipeline{
bson.D{{Key: "$group", Value: bson.M{
"_id": "$legacyBillCountry",
"billCount": bson.M{"$sum": 1},
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
}}},
},
}}},
)
cursor, err := s.collection.Aggregate(ctx, pipeline)
if err != nil {
return stats, fmt.Errorf("aggregate Aslan paid coin seller bills: %w", err)
}
defer cursor.Close(ctx)
type bucketRow struct {
Date string `bson:"_id"`
BillCount int64 `bson:"billCount"`
USD any `bson:"usd"`
Coin int64 `bson:"coin"`
}
type regionRow struct {
Country string `bson:"_id"`
BillCount int64 `bson:"billCount"`
USD any `bson:"usd"`
}
var result struct {
Total []bucketRow `bson:"total"`
Daily []bucketRow `bson:"daily"`
Regions []regionRow `bson:"regions"`
}
if cursor.Next(ctx) {
if err := cursor.Decode(&result); err != nil {
return stats, fmt.Errorf("decode Aslan paid coin seller stats: %w", err)
}
}
if err := cursor.Err(); err != nil {
return stats, fmt.Errorf("iterate Aslan paid coin seller stats: %w", err)
}
if len(result.Total) > 0 {
stats.addBucket(rechargeBillSummaryBucketDTO{
BillCount: result.Total[0].BillCount,
CoinAmount: result.Total[0].Coin,
USDMinorAmount: legacyDecimalToMinor(result.Total[0].USD),
})
}
for _, row := range result.Daily {
stats.addDaily(row.Date, rechargeBillSummaryBucketDTO{
BillCount: row.BillCount,
CoinAmount: row.Coin,
USDMinorAmount: legacyDecimalToMinor(row.USD),
})
}
countryToRegion := s.legacyCountryRegionIndex(ctx)
for _, row := range result.Regions {
region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.Country))]
if !ok {
continue
}
stats.addRegion(region.RegionID, row.BillCount, legacyDecimalToMinor(row.USD))
}
return stats, nil
}
func (s *MongoRechargeBillSource) listFinancePaidCoinSellerRechargeBills(ctx context.Context, query financeCoinSellerRechargeQuery, limit int) ([]rechargeBillDTO, int64, error) {
filter, regionCodes, matchable, err := s.financePaidCoinSellerFilter(ctx, query)
if err != nil || !matchable {
return []rechargeBillDTO{}, 0, err
}
base := mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}
var total int64
if len(regionCodes) == 0 {
total, err = s.collection.CountDocuments(ctx, filter)
} else {
base = append(base, legacyRegionLookupStages(regionCodes)...)
countCursor, countErr := s.collection.Aggregate(ctx, append(clonePipeline(base), bson.D{{Key: "$count", Value: "n"}}))
if countErr != nil {
return nil, 0, fmt.Errorf("count Aslan paid coin seller bills by region: %w", countErr)
}
if countCursor.Next(ctx) {
var row struct {
N int64 `bson:"n"`
}
if decodeErr := countCursor.Decode(&row); decodeErr != nil {
countCursor.Close(ctx)
return nil, 0, fmt.Errorf("decode Aslan paid coin seller bill count: %w", decodeErr)
}
total = row.N
}
if countErr := countCursor.Err(); countErr != nil {
countCursor.Close(ctx)
return nil, 0, fmt.Errorf("iterate Aslan paid coin seller bill count: %w", countErr)
}
countCursor.Close(ctx)
}
if err != nil {
return nil, 0, fmt.Errorf("count Aslan paid coin seller bills: %w", err)
}
if total == 0 || limit < 1 {
return []rechargeBillDTO{}, total, nil
}
base = append(base,
bson.D{{Key: "$addFields", Value: bson.M{"financePaidAt": legacyPaidCoinSellerAtExpression()}}},
bson.D{{Key: "$sort", Value: bson.D{{Key: "financePaidAt", Value: -1}, {Key: "_id", Value: -1}}}},
bson.D{{Key: "$limit", Value: int64(limit)}},
)
if len(regionCodes) == 0 {
// 无区域筛选时先分页再查用户国家,避免为整段历史订单执行 $lookup。
base = append(base, legacyCountryResolveStages()...)
}
cursor, err := s.collection.Aggregate(ctx, base, options.Aggregate().SetAllowDiskUse(false))
if err != nil {
return nil, 0, fmt.Errorf("query Aslan paid coin seller bills: %w", err)
}
defer cursor.Close(ctx)
countryToRegion := s.legacyCountryRegionIndex(ctx)
items := make([]rechargeBillDTO, 0, limit)
for cursor.Next(ctx) {
var document legacyPurchaseDocument
if err := cursor.Decode(&document); err != nil {
return nil, 0, fmt.Errorf("decode Aslan paid coin seller bill: %w", err)
}
item := legacyPaidCoinSellerRechargeBillDTO(s.config, document)
if region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(document.LegacyBillCountry))]; ok {
item.SellerRegionID = region.RegionID
item.TargetRegionID = region.RegionID
}
items = append(items, item)
}
if err := cursor.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate Aslan paid coin seller bills: %w", err)
}
return items, total, nil
}
func legacyPaidCoinSellerRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document legacyPurchaseDocument) rechargeBillDTO {
paidAtMS := legacyPaidCoinSellerAtMS(document)
factoryCode := strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode))
paidCurrency := strings.ToUpper(strings.TrimSpace(document.Currency))
if paidCurrency == "" {
paidCurrency = "USD"
}
return rechargeBillDTO{
AppCode: sourceConfig.AppCode,
TransactionID: legacyDocumentID(document.ID),
RechargeType: "coin_seller",
Status: "succeeded",
ExternalRef: strings.TrimSpace(document.OrderID),
SellerUserID: document.AcceptUserID,
CurrencyCode: "USD",
CoinAmount: legacyFreightCoinAmount(document),
USDMinorAmount: legacyDecimalToMinor(document.AmountUSD),
ProviderAmountMinor: legacyDecimalToMinor(document.Amount),
CreatedAtMS: paidAtMS,
ProviderCode: legacyProviderCode(factoryCode),
UserPaidCurrencyCode: paidCurrency,
UserPaidAmountMicro: legacyDecimalToMicro(document.Amount),
PaidSyncedAtMS: paidAtMS,
PolicyVersion: strings.ToUpper(strings.TrimSpace(document.TrackCommodityType)),
}
}
func legacyPaidCoinSellerAtMS(document legacyPurchaseDocument) int64 {
for _, value := range []time.Time{document.UpdateTime, document.PurchaseDateMS, document.CreateTime} {
if !value.IsZero() {
return value.UnixMilli()
}
}
return 0
}
func legacyFreightCoinAmount(document legacyPurchaseDocument) int64 {
var typedTotal int64
var allNumericTotal int64
for _, product := range document.Products {
amount, err := strconv.ParseInt(strings.TrimSpace(product.Content), 10, 64)
if err != nil || amount <= 0 {
continue
}
allNumericTotal += amount
if legacyFreightCommodityType(product.Code) || legacyFreightCommodityType(product.Name) {
typedTotal += amount
}
}
if typedTotal > 0 {
return typedTotal
}
// 极老订单只在顶层 trackCommodityType 写类型;此时产品中的正数 content 才能作为该账单的发货金币。
if legacyFreightCommodityType(document.TrackCommodityType) {
return allNumericTotal
}
return 0
}
func legacyFreightCommodityType(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "FREIGHT_GOLD", "FREIGHT_GOLD_SUPER":
return true
default:
return false
}
}

View File

@ -0,0 +1,209 @@
package payment
import (
"context"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func TestAslanPaidCoinSellerFilterUsesPaymentFacts(t *testing.T) {
source := &MongoRechargeBillSource{
collection: &mongo.Collection{},
config: aslanBillSourceConfig(),
regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{
codes: []string{"eg"}, handled: true,
}},
}
filter, regionCodes, matchable, err := source.financePaidCoinSellerFilter(context.Background(), financeCoinSellerRechargeQuery{
Keyword: "x5ctfib7800289mrkm5fco0A10001910",
RegionID: 1001,
StartAtMS: 1_784_000_000_000,
EndAtMS: 1_785_000_000_000,
TargetUserID: 2_070_458_148_788_715_521,
})
if err != nil || !matchable {
t.Fatalf("financePaidCoinSellerFilter err=%v matchable=%v", err, matchable)
}
if filter["sysOrigin"] != "ATYOU" || filter["status"] != "SUCCESS" {
t.Fatalf("payment status boundary missing: %#v", filter)
}
if filter["acceptUserId"] != int64(2_070_458_148_788_715_521) {
t.Fatalf("seller filter missing: %#v", filter)
}
if len(regionCodes) != 1 || regionCodes[0] != "EG" {
t.Fatalf("region code mismatch: %#v", regionCodes)
}
conditions, ok := filter["$and"].(bson.A)
if !ok || len(conditions) != 3 {
t.Fatalf("freight/keyword/time conditions missing: %#v", filter)
}
if got := len(legacyFreightCommodityMatchConditions()); got != len(legacyFreightCommodityFields) {
t.Fatalf("freight compatibility fields=%d want=%d", got, len(legacyFreightCommodityFields))
}
if _, exists := filter["recharge_type"]; exists {
t.Fatalf("wallet running-water recharge_type must not enter Mongo bill filter: %#v", filter)
}
}
func TestLegacyPaidCoinSellerRechargeBillDTOUsesOriginalBillAmounts(t *testing.T) {
providerAmount, err := bson.ParseDecimal128("4726")
if err != nil {
t.Fatalf("parse provider amount: %v", err)
}
usdAmount, err := bson.ParseDecimal128("100.00")
if err != nil {
t.Fatalf("parse usd amount: %v", err)
}
createdAt := time.Date(2026, 7, 14, 12, 14, 26, 499_000_000, time.UTC)
paidAt := time.Date(2026, 7, 14, 12, 15, 14, 777_000_000, time.UTC)
document := legacyPurchaseDocument{
ID: int64(2_077_003_764_199_862_273),
OrderID: "x5ctfib7800289mrkm5fco0A10001910",
AcceptUserID: 2_070_458_148_788_715_521,
CountryCode: "EG",
Currency: "EGP",
Amount: providerAmount,
AmountUSD: usdAmount,
Status: "SUCCESS",
TrackCommodityType: "FREIGHT_GOLD",
Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"},
Products: []legacyPurchaseProduct{{Code: "FREIGHT_GOLD", Name: "FREIGHT_GOLD", Content: "8800000"}},
CreateTime: createdAt,
UpdateTime: paidAt,
LegacyBillCountry: "EG",
}
dto := legacyPaidCoinSellerRechargeBillDTO(aslanBillSourceConfig(), document)
if dto.RechargeType != "coin_seller" || dto.Status != "succeeded" {
t.Fatalf("coin seller bill identity mismatch: %+v", dto)
}
if dto.ExternalRef != document.OrderID || dto.SellerUserID != document.AcceptUserID {
t.Fatalf("provider/seller identity mismatch: %+v", dto)
}
if dto.USDMinorAmount != 10_000 || dto.CoinAmount != 8_800_000 {
t.Fatalf("must use original bill USD/product amount: %+v", dto)
}
if dto.ProviderAmountMinor != 472_600 || dto.UserPaidCurrencyCode != "EGP" || dto.UserPaidAmountMicro != 4_726_000_000 {
t.Fatalf("provider paid amount mismatch: %+v", dto)
}
if dto.CreatedAtMS != paidAt.UnixMilli() || dto.PaidSyncedAtMS != paidAt.UnixMilli() {
t.Fatalf("successful payment time mismatch: %+v", dto)
}
if dto.PolicyVersion != "FREIGHT_GOLD" {
t.Fatalf("commodity type mismatch: %+v", dto)
}
}
func TestLegacyFreightCoinAmountFallsBackToTopLevelType(t *testing.T) {
document := legacyPurchaseDocument{
TrackCommodityType: "FREIGHT_GOLD",
Products: []legacyPurchaseProduct{
{Name: "LEGACY_PRODUCT", Content: "8800000"},
{Name: "PROPS", Content: "not-a-number"},
},
}
if got := legacyFreightCoinAmount(document); got != 8_800_000 {
t.Fatalf("legacy freight coin amount=%d", got)
}
}
type fakeAslanPaidCoinSellerSource struct {
stats financeCoinSellerRechargeStats
items []rechargeBillDTO
total int64
statsCalls int
listCalls int
}
func (s *fakeAslanPaidCoinSellerSource) AppCode() string { return "aslan" }
func (s *fakeAslanPaidCoinSellerSource) AppName() string { return "Aslan" }
func (s *fakeAslanPaidCoinSellerSource) ListRechargeBills(context.Context, legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
return []rechargeBillDTO{}, 0, nil
}
func (s *fakeAslanPaidCoinSellerSource) SummarizeRechargeBills(context.Context, legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) {
return rechargeBillSummaryDTO{}, nil
}
func (s *fakeAslanPaidCoinSellerSource) SupportsGooglePaidSync() bool { return false }
func (s *fakeAslanPaidCoinSellerSource) ListPendingGooglePaidTransactionIDs(context.Context, int) ([]string, error) {
return []string{}, nil
}
func (s *fakeAslanPaidCoinSellerSource) RefreshGooglePaidDetails(context.Context, []string) ([]googleRechargePaidDTO, error) {
return []googleRechargePaidDTO{}, nil
}
func (s *fakeAslanPaidCoinSellerSource) Overview(context.Context, legacyRechargeBillQuery, int32) (rechargeBillOverviewDTO, error) {
return rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}, nil
}
func (s *fakeAslanPaidCoinSellerSource) financePaidCoinSellerRechargeStats(context.Context, financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) {
s.statsCalls++
return s.stats, nil
}
func (s *fakeAslanPaidCoinSellerSource) listFinancePaidCoinSellerRechargeBills(context.Context, financeCoinSellerRechargeQuery, int) ([]rechargeBillDTO, int64, error) {
s.listCalls++
return append([]rechargeBillDTO(nil), s.items...), s.total, nil
}
func TestFinanceCoinSellerIncludesAslanPaidBills(t *testing.T) {
source := &fakeAslanPaidCoinSellerSource{
stats: financeCoinSellerRechargeStats{
Total: rechargeBillSummaryBucketDTO{BillCount: 1, CoinAmount: 8_800_000, USDMinorAmount: 10_000},
Daily: map[string]rechargeBillSummaryBucketDTO{
"2026-07-14": {BillCount: 1, CoinAmount: 8_800_000, USDMinorAmount: 10_000},
},
Regions: []rechargeBillRegionBucketDTO{{RegionID: 9, BillCount: 1, UsdMinorAmount: 10_000}},
},
items: []rechargeBillDTO{{
AppCode: "aslan", TransactionID: "2077003764199862273", ExternalRef: "x5ctfib7800289mrkm5fco0A10001910",
RechargeType: "coin_seller", ProviderCode: "mifa_pay", USDMinorAmount: 10_000, CoinAmount: 8_800_000,
}},
total: 1,
}
handler := New(nil, nil, nil, nil, nil, WithRechargeBillSources(source))
stats, err := handler.financeCoinSellerRechargeStats(context.Background(), "aslan", financeCoinSellerRechargeQuery{})
if err != nil {
t.Fatalf("financeCoinSellerRechargeStats: %v", err)
}
if stats.Total.USDMinorAmount != 10_000 || stats.Total.CoinAmount != 8_800_000 || stats.Total.BillCount != 1 {
t.Fatalf("paid bill stats missing: %+v", stats.Total)
}
if stats.Daily["2026-07-14"].USDMinorAmount != 10_000 || len(stats.Regions) != 1 || stats.Regions[0].RegionID != 9 {
t.Fatalf("paid bill dimensions missing: %+v", stats)
}
items, total, err := handler.listFinanceCoinSellerRechargeBills(context.Background(), "aslan", financeCoinSellerRechargeQuery{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("listFinanceCoinSellerRechargeBills: %v", err)
}
if total != 1 || len(items) != 1 || items[0].USDMinorAmount != 10_000 || items[0].ExternalRef != "x5ctfib7800289mrkm5fco0A10001910" {
t.Fatalf("paid bill list mismatch: total=%d items=%+v", total, items)
}
if source.statsCalls != 1 || source.listCalls != 1 {
t.Fatalf("paid source calls mismatch: stats=%d list=%d", source.statsCalls, source.listCalls)
}
}
func TestFinanceCoinSellerBillDedupKeepsOriginalPaymentBill(t *testing.T) {
original := rechargeBillDTO{TransactionID: "mongo", ExternalRef: "ORDER-1", ProviderCode: "MIFA_PAY", USDMinorAmount: 10_000}
operatorBound := rechargeBillDTO{TransactionID: "admin", ExternalRef: "order-1", ProviderCode: "mifapay", USDMinorAmount: 11_000}
items, duplicateCount := deduplicateFinanceCoinSellerRechargeBills([]rechargeBillDTO{original, operatorBound})
if duplicateCount != 1 || len(items) != 1 {
t.Fatalf("dedup mismatch: duplicates=%d items=%+v", duplicateCount, items)
}
if items[0].TransactionID != "mongo" || items[0].USDMinorAmount != 10_000 {
t.Fatalf("original payment bill must win: %+v", items[0])
}
}
func TestFinancePaidCoinSellerSourceIsAslanOnly(t *testing.T) {
source := &fakeAslanPaidCoinSellerSource{}
handler := New(nil, nil, nil, nil, nil, WithRechargeBillSources(source))
if handler.financePaidCoinSellerRechargeSource("aslan") == nil {
t.Fatalf("Aslan paid source should be registered")
}
if handler.financePaidCoinSellerRechargeSource("yumi") != nil {
t.Fatalf("Aslan paid source must not expand to Yumi")
}
}
var _ RechargeBillSource = (*fakeAslanPaidCoinSellerSource)(nil)
var _ financePaidCoinSellerRechargeSource = (*fakeAslanPaidCoinSellerSource)(nil)

View File

@ -145,6 +145,21 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
return stats, nil return stats, nil
} }
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID) query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
if err != nil {
return stats, err
}
// Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实;
// 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。
stats.addBucket(paidStats.Total)
for date, bucket := range paidStats.Daily {
stats.addDaily(date, bucket)
}
for _, region := range paidStats.Regions {
stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount)
}
}
if h != nil && h.store != nil { if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode, AppCode: appCode,
@ -286,6 +301,15 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2) items := make([]rechargeBillDTO, 0, prefetch*2)
var total int64 var total int64
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch)
if err != nil {
return nil, 0, err
}
// 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。
items = append(items, paidItems...)
total += paidTotal
}
if h != nil && h.store != nil { if h != nil && h.store != nil {
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{ orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
Page: 1, Page: 1,
@ -318,6 +342,14 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
total += h5Total total += h5Total
items = append(items, h5Items...) items = append(items, h5Items...)
} }
var duplicateCount int
items, duplicateCount = deduplicateFinanceCoinSellerRechargeBills(items)
if duplicateCount > 0 {
total -= int64(duplicateCount)
if total < 0 {
total = 0
}
}
sortRechargeBills(items) sortRechargeBills(items)
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
if offset >= len(items) { if offset >= len(items) {
@ -330,6 +362,39 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
return items[offset:end], total, nil return items[offset:end], total, nil
} }
func deduplicateFinanceCoinSellerRechargeBills(items []rechargeBillDTO) ([]rechargeBillDTO, int) {
seen := make(map[string]struct{}, len(items))
out := make([]rechargeBillDTO, 0, len(items))
duplicates := 0
for _, item := range items {
identity := financeCoinSellerRechargeBillIdentity(item)
if identity == "" {
out = append(out, item)
continue
}
if _, exists := seen[identity]; exists {
duplicates++
continue
}
seen[identity] = struct{}{}
out = append(out, item)
}
return out, duplicates
}
func financeCoinSellerRechargeBillIdentity(item rechargeBillDTO) string {
externalRef := strings.ToLower(strings.TrimSpace(item.ExternalRef))
if externalRef != "" {
provider := strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(item.ProviderCode)))
return "provider:" + provider + ":" + externalRef
}
transactionID := strings.ToLower(strings.TrimSpace(item.TransactionID))
if transactionID == "" {
return ""
}
return "transaction:" + transactionID
}
func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode string, source RechargeBillSource, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) { func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode string, source RechargeBillSource, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) {
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize) page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
@ -20,6 +21,21 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func TestMoneyScopeReadPermissionsIncludeWithdrawalWorkspaces(t *testing.T) {
t.Parallel()
for _, permission := range []string{
financeViewPermission,
"finance-withdrawal:view",
"finance-withdrawal:audit",
"operations-withdrawal:view",
"operations-withdrawal:audit",
} {
if !slices.Contains(moneyScopeReadPermissions, permission) {
t.Fatalf("money scope read permission missing %s", permission)
}
}
}
func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) { func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
db, sqlMock, err := sqlmock.New() db, sqlMock, err := sqlmock.New()
if err != nil { if err != nil {

View File

@ -316,9 +316,11 @@ type legacyPurchaseDocument struct {
Amount any `bson:"amount"` Amount any `bson:"amount"`
AmountUSD any `bson:"amountUsd"` AmountUSD any `bson:"amountUsd"`
Status string `bson:"status"` Status string `bson:"status"`
TrackCommodityType string `bson:"trackCommodityType"`
Factory legacyPurchaseFactory `bson:"factory"` Factory legacyPurchaseFactory `bson:"factory"`
Products []legacyPurchaseProduct `bson:"products"` Products []legacyPurchaseProduct `bson:"products"`
CreateTime time.Time `bson:"createTime"` CreateTime time.Time `bson:"createTime"`
UpdateTime time.Time `bson:"updateTime"`
PurchaseDateMS time.Time `bson:"purchaseDateMs"` PurchaseDateMS time.Time `bson:"purchaseDateMs"`
// LegacyBillCountry 由聚合阶段legacyCountryResolveStages解析账单国家码优先回退付款用户资料。 // LegacyBillCountry 由聚合阶段legacyCountryResolveStages解析账单国家码优先回退付款用户资料。
LegacyBillCountry string `bson:"legacyBillCountry"` LegacyBillCountry string `bson:"legacyBillCountry"`
@ -330,6 +332,7 @@ type legacyPurchaseFactory struct {
} }
type legacyPurchaseProduct struct { type legacyPurchaseProduct struct {
Code string `bson:"code"`
Name string `bson:"name"` Name string `bson:"name"`
Content string `bson:"content"` Content string `bson:"content"`
} }

View File

@ -6,6 +6,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var moneyScopeReadPermissions = []string{
financeViewPermission,
"finance-withdrawal:view",
"finance-withdrawal:audit",
"operations-withdrawal:view",
"operations-withdrawal:audit",
}
func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.RouterGroup, h *Handler) { func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.RouterGroup, h *Handler) {
if h == nil { if h == nil {
return return
@ -19,7 +27,8 @@ func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.Route
workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails) workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails)
workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions) workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps) workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
workspaceProtected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope) // 用户提现的运营/财务页共用 MoneyAccess App 目录;这里只放开范围读取,不会赋予任何支付或审核写权限。
workspaceProtected.GET("/admin/finance/scope", middleware.RequireAnyPermission(moneyScopeReadPermissions...), h.GetMoneyScope)
workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope) workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance) workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks) workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)

View File

@ -65,6 +65,47 @@ func (h *Handler) ListResources(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
} }
// ListExternalGrantResources exposes only the owner-approved manager grant catalog.
// VIP trial cards remain on their dedicated API because a generic entitlement grant
// would not update the VIP state projection. The external route requires the complete
// grant permission bundle before this handler is reached.
func (h *Handler) ListExternalGrantResources(c *gin.Context) {
options := shared.ListOptions(c)
ctx, cancel := h.walletRequestContext(c)
defer cancel()
resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceType: strings.TrimSpace(c.Query("resource_type")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
ActiveOnly: true,
ManagerGrantOnly: true,
})
if err != nil {
response.ServerError(c, "获取可发放资源失败")
return
}
items := make([]resourceDTO, 0, len(resp.GetResources()))
excluded := int64(0)
for _, item := range resp.GetResources() {
if item.GetResourceType() == resourceTypeVIPTrialCard {
excluded++
continue
}
items = append(items, resourceFromProto(item))
}
// ManagerGrantOnly is indexed owner data; excluding a dedicated-flow item here is
// defense in depth. Total can only be reduced by rows observed on this page because
// the wallet contract does not expose a negative type filter.
total := resp.GetTotal() - excluded
if total < int64(len(items)) {
total = int64(len(items))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) GetResource(c *gin.Context) { func (h *Handler) GetResource(c *gin.Context) {
resourceID, ok := parseID(c, "resource_id") resourceID, ok := parseID(c, "resource_id")
if !ok { if !ok {
@ -629,6 +670,17 @@ func (h *Handler) DisableGift(c *gin.Context) {
} }
func (h *Handler) GrantResource(c *gin.Context) { func (h *Handler) GrantResource(c *gin.Context) {
h.grantResource(c, false)
}
// GrantExternalResource keeps main-admin behavior unchanged while closing the ID
// bypass on the external portal: callers may only submit an active resource that the
// owner explicitly marked grantable for managers.
func (h *Handler) GrantExternalResource(c *gin.Context) {
h.grantResource(c, true)
}
func (h *Handler) grantResource(c *gin.Context, external bool) {
var req grantResourceRequest var req grantResourceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "资源赠送参数不正确") response.BadRequest(c, "资源赠送参数不正确")
@ -639,10 +691,17 @@ func (h *Handler) GrantResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
if err := h.validateGenericGrantResource(c, req.ResourceID); err != nil { if err := h.validateGenericGrantResource(c, req.ResourceID, external); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
grantSource := "admin"
if external {
// Wallet atomically rechecks manager_grant_enabled for manager_center at grant
// time. This closes the GetResource/GrantResource race and records the actual
// external operator channel in the entitlement ledger.
grantSource = grantSourceManagerCenter
}
resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{ resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
CommandId: strings.TrimSpace(req.CommandID), CommandId: strings.TrimSpace(req.CommandID),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -652,7 +711,7 @@ func (h *Handler) GrantResource(c *gin.Context) {
DurationMs: req.DurationMS, DurationMs: req.DurationMS,
Reason: strings.TrimSpace(req.Reason), Reason: strings.TrimSpace(req.Reason),
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
GrantSource: "admin", GrantSource: grantSource,
}) })
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
@ -696,7 +755,7 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
response.Created(c, grant) response.Created(c, grant)
} }
func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64) error { func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64, requireManagerGrant bool) error {
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{ resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -705,6 +764,9 @@ func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64)
if err != nil { if err != nil {
return err return err
} }
if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) {
return fmt.Errorf("该资源未开放外管发放")
}
// VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影; // VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影;
// 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。 // 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。
if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard { if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
@ -765,6 +827,48 @@ func (h *Handler) RevokeResourceGrant(c *gin.Context) {
response.OK(c, grant) response.OK(c, grant)
} }
func (h *Handler) RevokeUserResource(c *gin.Context) {
userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "用户 ID 不正确")
return
}
entitlementID := strings.TrimSpace(c.Param("entitlement_id"))
if entitlementID == "" {
response.BadRequest(c, "用户素材权益不存在")
return
}
var req revokeUserResourceRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Reason) == "" {
response.BadRequest(c, "请填写素材撤回原因")
return
}
resp, err := h.wallet.RevokeUserResource(c.Request.Context(), &walletv1.RevokeUserResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
UserId: userID,
EntitlementId: entitlementID,
Reason: strings.TrimSpace(req.Reason),
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
resource := resp.GetResource()
// 审计对象使用 entitlement 而不是 source grant同一资源组发放可包含多个素材必须能追溯本次只撤回了哪一个。
h.auditLog(c, "revoke-user-resource", "user_resource_entitlements", entitlementID, "success", fmt.Sprintf("target_user_id=%d resource_id=%d", userID, resource.GetResourceId()))
response.OK(c, gin.H{
"entitlementId": resource.GetEntitlementId(),
"resourceId": resource.GetResourceId(),
"status": resource.GetStatus(),
"remainingQuantity": resource.GetRemainingQuantity(),
"equipped": resource.GetEquipped(),
"updatedAtMs": resource.GetUpdatedAtMs(),
})
}
func (h *Handler) LookupResourceGrantTarget(c *gin.Context) { func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword")) keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
if keyword == "" { if keyword == "" {

View File

@ -235,6 +235,83 @@ func TestGenericResourceGrantRejectsVIPTrialCard(t *testing.T) {
} }
} }
func TestExternalResourceGrantRejectsIDOutsideManagerGrantCatalog(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
11: {AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: true},
12: {AppCode: "lalu", ResourceId: 12, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: false},
}}
handler := New(wallet, nil, nil, time.Second, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "external-grant-test")
c.Set(middleware.ContextUserID, uint(7))
c.Next()
})
router.POST("/external-grant", handler.GrantExternalResource)
denied := httptest.NewRecorder()
deniedRequest := httptest.NewRequest(http.MethodPost, "/external-grant", strings.NewReader(`{"commandId":"grant-denied","targetUserId":"318705991371722752","resourceId":12,"quantity":1,"durationMs":0,"reason":"manual"}`))
deniedRequest.Header.Set("Content-Type", "application/json")
router.ServeHTTP(denied, deniedRequest)
if denied.Code != http.StatusBadRequest || !strings.Contains(denied.Body.String(), "未开放外管发放") || len(wallet.grantRequests) != 0 {
t.Fatalf("catalog bypass was not rejected: status=%d body=%s grants=%d", denied.Code, denied.Body.String(), len(wallet.grantRequests))
}
allowed := httptest.NewRecorder()
allowedRequest := httptest.NewRequest(http.MethodPost, "/external-grant", strings.NewReader(`{"commandId":"grant-allowed","targetUserId":"318705991371722752","resourceId":11,"quantity":1,"durationMs":0,"reason":"manual"}`))
allowedRequest.Header.Set("Content-Type", "application/json")
router.ServeHTTP(allowed, allowedRequest)
if allowed.Code != http.StatusCreated || len(wallet.grantRequests) != 1 || wallet.grantRequests[0].GetResourceId() != 11 || wallet.grantRequests[0].GetGrantSource() != "manager_center" {
t.Fatalf("manager grantable resource failed: status=%d body=%s grants=%+v", allowed.Code, allowed.Body.String(), wallet.grantRequests)
}
}
func TestMainAdminResourceGrantKeepsAdminSource(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
11: {AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true},
}}
handler := New(wallet, nil, nil, time.Second, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "admin-grant-source-test")
c.Set(middleware.ContextUserID, uint(7))
c.Next()
})
router.POST("/admin-grant", handler.GrantResource)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin-grant", strings.NewReader(`{"commandId":"grant-admin","targetUserId":"318705991371722752","resourceId":11,"quantity":1,"reason":"manual"}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || len(wallet.grantRequests) != 1 || wallet.grantRequests[0].GetGrantSource() != "admin" {
t.Fatalf("main admin grant source changed: status=%d request=%+v body=%s", recorder.Code, wallet.grantRequests, recorder.Body.String())
}
}
func TestExternalResourceListUsesManagerCatalogAndHidesVIPTrialCard(t *testing.T) {
wallet := &mockResourceWallet{listedResources: []*walletv1.Resource{
{AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: true},
{AppCode: "lalu", ResourceId: 99, ResourceType: resourceTypeVIPTrialCard, Status: "active", Grantable: true, ManagerGrantEnabled: true},
}}
handler := New(wallet, nil, nil, time.Second, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "external-resource-list-test")
c.Next()
})
router.GET("/external-resources", handler.ListExternalGrantResources)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/external-resources?page=1&page_size=20", nil))
if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), `"id":99`) {
t.Fatalf("external list status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(wallet.listResourceRequests) != 1 || !wallet.listResourceRequests[0].GetActiveOnly() || !wallet.listResourceRequests[0].GetManagerGrantOnly() {
t.Fatalf("external list did not use manager catalog: %+v", wallet.listResourceRequests)
}
}
func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) { func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) {
wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{ wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{
88: { 88: {
@ -332,6 +409,29 @@ func TestListResourceGrantsResolvesDisplayIDBeforeWalletFilter(t *testing.T) {
} }
} }
func TestRevokeUserResourceTargetsOneEntitlement(t *testing.T) {
wallet := &mockResourceWallet{}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/users/318705991371722752/resources/ent-host-badge/revoke", strings.NewReader(`{"reason":"remove host badge"}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("revoke user resource status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.revokeUserResourceRequests) != 1 {
t.Fatalf("expected one precise revoke request, got %d", len(wallet.revokeUserResourceRequests))
}
req := wallet.revokeUserResourceRequests[0]
if req.GetUserId() != 318705991371722752 || req.GetEntitlementId() != "ent-host-badge" || req.GetReason() != "remove host badge" || req.GetOperatorUserId() != 7 {
t.Fatalf("precise revoke request mismatch: %+v", req)
}
if !strings.Contains(recorder.Body.String(), `"entitlementId":"ent-host-badge"`) || !strings.Contains(recorder.Body.String(), `"status":"revoked"`) {
t.Fatalf("precise revoke response mismatch: %s", recorder.Body.String())
}
}
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine { func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
@ -378,6 +478,35 @@ type mockResourceWallet struct {
batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest deletedGifts []*walletv1.DeleteGiftConfigRequest
listGrantRequests []*walletv1.ListResourceGrantsRequest listGrantRequests []*walletv1.ListResourceGrantsRequest
listResourceRequests []*walletv1.ListResourcesRequest
listedResources []*walletv1.Resource
grantRequests []*walletv1.GrantResourceRequest
revokeUserResourceRequests []*walletv1.RevokeUserResourceRequest
}
func (m *mockResourceWallet) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
m.listResourceRequests = append(m.listResourceRequests, req)
return &walletv1.ListResourcesResponse{Resources: m.listedResources, Total: int64(len(m.listedResources))}, nil
}
func (m *mockResourceWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
m.grantRequests = append(m.grantRequests, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
AppCode: req.GetAppCode(), GrantId: "grant-result", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(),
GrantSource: req.GetGrantSource(), GrantSubjectType: "resource", GrantSubjectId: fmt.Sprint(req.GetResourceId()), Status: "succeeded",
}}, nil
}
func (m *mockResourceWallet) RevokeUserResource(_ context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error) {
m.revokeUserResourceRequests = append(m.revokeUserResourceRequests, req)
return &walletv1.RevokeUserResourceResponse{Resource: &walletv1.UserResourceEntitlement{
EntitlementId: req.GetEntitlementId(),
UserId: req.GetUserId(),
ResourceId: 91,
Status: "revoked",
RemainingQuantity: 0,
Equipped: false,
}}, nil
} }
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {

View File

@ -235,6 +235,10 @@ type grantGroupRequest struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
type revokeUserResourceRequest struct {
Reason string `json:"reason"`
}
type resourceShopItemsRequest struct { type resourceShopItemsRequest struct {
Items []resourceShopItemRequest `json:"items"` Items []resourceShopItemRequest `json:"items"`
} }
@ -244,6 +248,7 @@ type resourceShopItemRequest struct {
ResourceID int64 `json:"resourceId"` ResourceID int64 `json:"resourceId"`
Status string `json:"status"` Status string `json:"status"`
DurationDays int32 `json:"durationDays"` DurationDays int32 `json:"durationDays"`
CoinPrice int64 `json:"coinPrice"`
EffectiveFromMS int64 `json:"effectiveFromMs"` EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"` EffectiveToMS int64 `json:"effectiveToMs"`
SortOrder int32 `json:"sortOrder"` SortOrder int32 `json:"sortOrder"`
@ -558,6 +563,7 @@ func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.Resourc
ResourceId: item.ResourceID, ResourceId: item.ResourceID,
Status: strings.TrimSpace(item.Status), Status: strings.TrimSpace(item.Status),
DurationDays: item.DurationDays, DurationDays: item.DurationDays,
CoinPrice: item.CoinPrice,
EffectiveFromMs: item.EffectiveFromMS, EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS, EffectiveToMs: item.EffectiveToMS,
SortOrder: item.SortOrder, SortOrder: item.SortOrder,

View File

@ -49,6 +49,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource) protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup) protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant) protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
protected.POST("/admin/users/:user_id/resources/:entitlement_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeUserResource)
// VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。 // VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。
protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget) protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants) protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)

View File

@ -134,6 +134,31 @@ type vipBenefitDTO struct {
MetadataJSON string `json:"metadataJson"` MetadataJSON string `json:"metadataJson"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"`
Presentation *vipBenefitPresentationDTO `json:"presentation"`
}
type vipBenefitPresentationDTO struct {
Description string `json:"description"`
PreviewItems []vipBenefitPreviewItemDTO `json:"previewItems"`
NumericReward *vipBenefitNumericRewardDTO `json:"numericReward"`
}
type vipBenefitPreviewItemDTO struct {
PreviewID string `json:"previewId"`
Title string `json:"title"`
MediaType string `json:"mediaType"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
CompositionType string `json:"compositionType"`
SortOrder int32 `json:"sortOrder"`
}
type vipBenefitNumericRewardDTO struct {
Label string `json:"label"`
Value int64 `json:"value"`
Unit string `json:"unit"`
Period string `json:"period"`
} }
type vipTrialCardDTO struct { type vipTrialCardDTO struct {
@ -163,6 +188,18 @@ type vipStateDTO struct {
EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"` EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"`
EvaluatedAtMS int64 `json:"evaluatedAtMs"` EvaluatedAtMS int64 `json:"evaluatedAtMs"`
ProgramConfig vipProgramConfigDTO `json:"programConfig"` ProgramConfig vipProgramConfigDTO `json:"programConfig"`
UserSettings vipUserSettingsDTO `json:"userSettings"`
}
type vipUserSettingsDTO struct {
AppCode string `json:"appCode"`
UserID string `json:"userId"`
RoomEntryNoticeEnabled bool `json:"roomEntryNoticeEnabled"`
OnlineGlobalNoticeEnabled bool `json:"onlineGlobalNoticeEnabled"`
HideProfileDataEnabled bool `json:"hideProfileDataEnabled"`
AnonymousProfileVisitEnabled bool `json:"anonymousProfileVisitEnabled"`
LeaderboardInvisibleEnabled bool `json:"leaderboardInvisibleEnabled"`
UpdatedAtMS int64 `json:"updatedAtMs"`
} }
type vipRewardItemDTO struct { type vipRewardItemDTO struct {
@ -244,6 +281,17 @@ func (h *Handler) ListLevels(c *gin.Context) {
} }
func (h *Handler) GrantVIP(c *gin.Context) { func (h *Handler) GrantVIP(c *gin.Context) {
h.grantVIP(c, "admin_grant", "admin_vip_grant:")
}
// GrantExternalVIP uses Wallet's manager-center source so the owner service
// atomically applies its external-manager grant policy instead of trusting only
// this gateway's route permission.
func (h *Handler) GrantExternalVIP(c *gin.Context) {
h.grantVIP(c, "manager_center", "external_vip_grant:")
}
func (h *Handler) grantVIP(c *gin.Context, grantSource string, defaultCommandPrefix string) {
var req grantVipRequest var req grantVipRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 赠送参数不正确") response.BadRequest(c, "VIP 赠送参数不正确")
@ -261,14 +309,14 @@ func (h *Handler) GrantVIP(c *gin.Context) {
} }
commandID := strings.TrimSpace(req.CommandID) commandID := strings.TrimSpace(req.CommandID)
if commandID == "" { if commandID == "" {
commandID = "admin_vip_grant:" + middleware.CurrentRequestID(c) commandID = defaultCommandPrefix + middleware.CurrentRequestID(c)
} }
resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{ resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{
CommandId: commandID, CommandId: commandID,
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
TargetUserId: targetUserID, TargetUserId: targetUserID,
Level: req.Level, Level: req.Level,
GrantSource: "admin_grant", GrantSource: grantSource,
OperatorUserId: int64(middleware.CurrentUserID(c)), OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason, Reason: reason,
}) })
@ -281,6 +329,17 @@ func (h *Handler) GrantVIP(c *gin.Context) {
} }
func (h *Handler) GrantVIPTrialCard(c *gin.Context) { func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
h.grantVIPTrialCard(c, "admin_grant", "admin_vip_trial_card_grant:")
}
// GrantExternalVIPTrialCard keeps duration/resource enforcement in Wallet's
// manager-center transaction, including the authoritative ManagerGrantEnabled
// check for an explicitly selected trial-card resource.
func (h *Handler) GrantExternalVIPTrialCard(c *gin.Context) {
h.grantVIPTrialCard(c, "manager_center", "external_vip_trial_card_grant:")
}
func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultCommandPrefix string) {
var req grantVipTrialCardRequest var req grantVipTrialCardRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 体验卡赠送参数不正确") response.BadRequest(c, "VIP 体验卡赠送参数不正确")
@ -298,7 +357,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
} }
commandID := strings.TrimSpace(req.CommandID) commandID := strings.TrimSpace(req.CommandID)
if commandID == "" { if commandID == "" {
commandID = "admin_vip_trial_card_grant:" + middleware.CurrentRequestID(c) commandID = defaultCommandPrefix + middleware.CurrentRequestID(c)
} }
resp, err := h.wallet.GrantVipTrialCard(c.Request.Context(), &walletv1.GrantVipTrialCardRequest{ resp, err := h.wallet.GrantVipTrialCard(c.Request.Context(), &walletv1.GrantVipTrialCardRequest{
CommandId: commandID, CommandId: commandID,
@ -307,7 +366,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
Level: req.Level, Level: req.Level,
DurationMs: req.DurationMS, DurationMs: req.DurationMS,
ResourceId: req.ResourceID, ResourceId: req.ResourceID,
GrantSource: "admin_grant", GrantSource: grantSource,
OperatorUserId: int64(middleware.CurrentUserID(c)), OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason, Reason: reason,
}) })
@ -448,6 +507,7 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitDTO {
MetadataJSON: item.GetMetadataJson(), MetadataJSON: item.GetMetadataJson(),
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(),
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
}) })
} }
return benefits return benefits
@ -467,9 +527,61 @@ func (item vipBenefitDTO) toProto() *walletv1.VipBenefit {
AutoEquip: item.AutoEquip, AutoEquip: item.AutoEquip,
SortOrder: item.SortOrder, SortOrder: item.SortOrder,
MetadataJson: strings.TrimSpace(item.MetadataJSON), MetadataJson: strings.TrimSpace(item.MetadataJSON),
Presentation: item.Presentation.toProto(),
} }
} }
func vipBenefitPresentationFromProto(item *walletv1.VipBenefitPresentation) *vipBenefitPresentationDTO {
if item == nil {
return nil
}
result := &vipBenefitPresentationDTO{
Description: item.GetDescription(),
PreviewItems: make([]vipBenefitPreviewItemDTO, 0, len(item.GetPreviewItems())),
}
for _, preview := range item.GetPreviewItems() {
if preview == nil {
continue
}
result.PreviewItems = append(result.PreviewItems, vipBenefitPreviewItemDTO{
PreviewID: preview.GetPreviewId(), Title: preview.GetTitle(), MediaType: preview.GetMediaType(),
AssetURL: preview.GetAssetUrl(), PreviewURL: preview.GetPreviewUrl(), AnimationURL: preview.GetAnimationUrl(),
CompositionType: preview.GetCompositionType(), SortOrder: preview.GetSortOrder(),
})
}
if reward := item.GetNumericReward(); reward != nil {
result.NumericReward = &vipBenefitNumericRewardDTO{
Label: reward.GetLabel(), Value: reward.GetValue(), Unit: reward.GetUnit(), Period: reward.GetPeriod(),
}
}
return result
}
func (item *vipBenefitPresentationDTO) toProto() *walletv1.VipBenefitPresentation {
if item == nil {
return nil
}
result := &walletv1.VipBenefitPresentation{
Description: strings.TrimSpace(item.Description),
PreviewItems: make([]*walletv1.VipBenefitPreviewItem, 0, len(item.PreviewItems)),
}
for _, preview := range item.PreviewItems {
result.PreviewItems = append(result.PreviewItems, &walletv1.VipBenefitPreviewItem{
PreviewId: strings.TrimSpace(preview.PreviewID), Title: strings.TrimSpace(preview.Title),
MediaType: strings.TrimSpace(preview.MediaType), AssetUrl: strings.TrimSpace(preview.AssetURL),
PreviewUrl: strings.TrimSpace(preview.PreviewURL), AnimationUrl: strings.TrimSpace(preview.AnimationURL),
CompositionType: strings.TrimSpace(preview.CompositionType), SortOrder: preview.SortOrder,
})
}
if item.NumericReward != nil {
result.NumericReward = &walletv1.VipBenefitNumericReward{
Label: strings.TrimSpace(item.NumericReward.Label), Value: item.NumericReward.Value,
Unit: strings.TrimSpace(item.NumericReward.Unit), Period: strings.TrimSpace(item.NumericReward.Period),
}
}
return result
}
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO { func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
if resp == nil { if resp == nil {
return grantVipDTO{} return grantVipDTO{}
@ -546,6 +658,22 @@ func vipStateFromProto(item *walletv1.VipState) vipStateDTO {
EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()), EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()),
EvaluatedAtMS: item.GetEvaluatedAtMs(), EvaluatedAtMS: item.GetEvaluatedAtMs(),
ProgramConfig: vipProgramConfigFromProto(item.GetProgramConfig()), ProgramConfig: vipProgramConfigFromProto(item.GetProgramConfig()),
UserSettings: vipUserSettingsFromProto(item.GetUserSettings()),
}
}
func vipUserSettingsFromProto(item *walletv1.VipUserSettings) vipUserSettingsDTO {
if item == nil {
return vipUserSettingsDTO{}
}
return vipUserSettingsDTO{
AppCode: item.GetAppCode(), UserID: strconv.FormatInt(item.GetUserId(), 10),
RoomEntryNoticeEnabled: item.GetRoomEntryNoticeEnabled(),
OnlineGlobalNoticeEnabled: item.GetOnlineGlobalNoticeEnabled(),
HideProfileDataEnabled: item.GetHideProfileDataEnabled(),
AnonymousProfileVisitEnabled: item.GetAnonymousProfileVisitEnabled(),
LeaderboardInvisibleEnabled: item.GetLeaderboardInvisibleEnabled(),
UpdatedAtMS: item.GetUpdatedAtMs(),
} }
} }

View File

@ -45,6 +45,12 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
MetadataJson: "{}", MetadataJson: "{}",
CreatedAtMs: 101, CreatedAtMs: 101,
UpdatedAtMs: 201, UpdatedAtMs: 201,
Presentation: &walletv1.VipBenefitPresentation{
Description: "普通房管无法踢出",
PreviewItems: []*walletv1.VipBenefitPreviewItem{{
PreviewId: "anti-kick", MediaType: "image", PreviewUrl: "https://cdn.example/anti-kick.png", CompositionType: "none",
}},
},
}}, }},
ServerTimeMs: 300, ServerTimeMs: 300,
}, },
@ -60,6 +66,8 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
`"updatedByAdminId":77`, `"updatedByAdminId":77`,
`"benefitCode":"anti_kick"`, `"benefitCode":"anti_kick"`,
`"executionScope":"room"`, `"executionScope":"room"`,
`"description":"普通房管无法踢出"`,
`"compositionType":"none"`,
`"serverTimeMs":300`, `"serverTimeMs":300`,
} { } {
if !strings.Contains(body, fragment) { if !strings.Contains(body, fragment) {
@ -105,7 +113,7 @@ func TestUpdateLevelsForwardsExplicitBenefitsAndReturnsProgram(t *testing.T) {
ProgramConfig: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9}, ProgramConfig: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9},
ServerTimeMs: 500, ServerTimeMs: 500,
}} }}
body := `{"levels":[{"level":2,"name":"VIP2","status":"active","priceCoin":2000,"durationMs":2592000000,"rewardResourceGroupId":22,"sortOrder":20,"requiredRechargeCoinAmount":123,"benefits":[{"benefitCode":"room_entry_notice","name":"进房通知","benefitType":"function","unlockLevel":2,"status":"active","trialEnabled":true,"resourceId":0,"resourceType":"","executionScope":"room","autoEquip":false,"sortOrder":10,"metadataJson":"{}"}]}]}` body := `{"levels":[{"level":2,"name":"VIP2","status":"active","priceCoin":2000,"durationMs":2592000000,"rewardResourceGroupId":22,"sortOrder":20,"requiredRechargeCoinAmount":123,"benefits":[{"benefitCode":"room_entry_notice","name":"进房通知","benefitType":"function","unlockLevel":2,"status":"active","trialEnabled":true,"resourceId":0,"resourceType":"","executionScope":"room","autoEquip":false,"sortOrder":10,"metadataJson":"{}","presentation":{"description":"当前房间进房动效","previewItems":[{"previewId":"entry-main","mediaType":"animation","animationUrl":"https://cdn.example/entry.svga","compositionType":"user_avatar_center","sortOrder":10}]}}]}]}`
recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-levels", body) recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-levels", body)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("update levels status = %d, body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("update levels status = %d, body=%s", recorder.Code, recorder.Body.String())
@ -118,6 +126,10 @@ func TestUpdateLevelsForwardsExplicitBenefitsAndReturnsProgram(t *testing.T) {
if level.GetRequiredRechargeCoinAmount() != 0 || len(level.GetBenefits()) != 1 || level.GetBenefits()[0].GetBenefitCode() != "room_entry_notice" { if level.GetRequiredRechargeCoinAmount() != 0 || len(level.GetBenefits()) != 1 || level.GetBenefits()[0].GetBenefitCode() != "room_entry_notice" {
t.Fatalf("explicit level benefits mismatch: %+v", level) t.Fatalf("explicit level benefits mismatch: %+v", level)
} }
presentation := level.GetBenefits()[0].GetPresentation()
if presentation.GetDescription() != "当前房间进房动效" || len(presentation.GetPreviewItems()) != 1 || presentation.GetPreviewItems()[0].GetCompositionType() != "user_avatar_center" {
t.Fatalf("structured benefit presentation mismatch: %+v", presentation)
}
if !strings.Contains(recorder.Body.String(), `"programConfig":{"appCode":"fami","programType":"tiered_privilege_v1","levelCount":9`) { if !strings.Contains(recorder.Body.String(), `"programConfig":{"appCode":"fami","programType":"tiered_privilege_v1","levelCount":9`) {
t.Fatalf("level response must include program config: %s", recorder.Body.String()) t.Fatalf("level response must include program config: %s", recorder.Body.String())
} }
@ -135,7 +147,7 @@ func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t
t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String())
} }
req := wallet.grantTrialRequest req := wallet.grantTrialRequest
if req.GetCommandId() != "admin_vip_trial_card_grant:vip-config-test" || req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 { if req.GetCommandId() != "admin_vip_trial_card_grant:vip-config-test" || req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 || req.GetGrantSource() != "admin_grant" {
t.Fatalf("trial grant command context mismatch: %+v", req) t.Fatalf("trial grant command context mismatch: %+v", req)
} }
if req.ResourceId != nil || req.GetTargetUserId() != 318705991371722752 || req.GetDurationMs() != 1_728_000_000 { if req.ResourceId != nil || req.GetTargetUserId() != 318705991371722752 || req.GetDurationMs() != 1_728_000_000 {
@ -146,6 +158,59 @@ func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t
} }
} }
func TestExternalVIPGrantsUseManagerCenterOwnerPolicySource(t *testing.T) {
wallet := &fakeVIPConfigWallet{
grantVIPResponse: &walletv1.GrantVipResponse{TransactionId: "vip-external"},
grantTrialResponse: &walletv1.GrantVipTrialCardResponse{
TrialCard: &walletv1.VipTrialCard{TrialCardId: "trial-external"},
},
}
gintest := func(path string, body string, handler gin.HandlerFunc) *httptest.ResponseRecorder {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Set(middleware.ContextRequestID, "external-vip-test")
c.Set(middleware.ContextUserID, uint(77))
c.Next()
})
router.POST(path, handler)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
return recorder
}
handler := New(wallet, nil)
vipRecorder := gintest("/external-vip", `{"targetUserId":"318705991371722752","level":5,"reason":"manual"}`, handler.GrantExternalVIP)
if vipRecorder.Code != http.StatusCreated || wallet.grantVIPRequest == nil || wallet.grantVIPRequest.GetGrantSource() != "manager_center" || wallet.grantVIPRequest.GetCommandId() != "external_vip_grant:external-vip-test" {
t.Fatalf("external VIP source mismatch: status=%d request=%+v body=%s", vipRecorder.Code, wallet.grantVIPRequest, vipRecorder.Body.String())
}
trialRecorder := gintest("/external-trial", `{"targetUserId":"318705991371722752","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`, handler.GrantExternalVIPTrialCard)
if trialRecorder.Code != http.StatusCreated || wallet.grantTrialRequest == nil || wallet.grantTrialRequest.GetGrantSource() != "manager_center" || wallet.grantTrialRequest.GetCommandId() != "external_vip_trial_card_grant:external-vip-test" {
t.Fatalf("external trial source mismatch: status=%d request=%+v body=%s", trialRecorder.Code, wallet.grantTrialRequest, trialRecorder.Body.String())
}
}
func TestMainAdminVIPGrantKeepsAdminSource(t *testing.T) {
wallet := &fakeVIPConfigWallet{grantVIPResponse: &walletv1.GrantVipResponse{TransactionId: "vip-admin"}}
handler := New(wallet, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Set(middleware.ContextRequestID, "admin-vip-test")
c.Set(middleware.ContextUserID, uint(77))
c.Next()
})
router.POST("/admin-vip", handler.GrantVIP)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin-vip", strings.NewReader(`{"targetUserId":"318705991371722752","level":5,"reason":"manual"}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || wallet.grantVIPRequest == nil || wallet.grantVIPRequest.GetGrantSource() != "admin_grant" || wallet.grantVIPRequest.GetCommandId() != "admin_vip_grant:admin-vip-test" {
t.Fatalf("main admin VIP source changed: status=%d request=%+v body=%s", recorder.Code, wallet.grantVIPRequest, recorder.Body.String())
}
}
func serveVIPConfigRequest(t *testing.T, wallet walletclient.Client, method string, path string, body string) *httptest.ResponseRecorder { func serveVIPConfigRequest(t *testing.T, wallet walletclient.Client, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@ -174,10 +239,12 @@ type fakeVIPConfigWallet struct {
updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse
updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse
grantTrialResponse *walletv1.GrantVipTrialCardResponse grantTrialResponse *walletv1.GrantVipTrialCardResponse
grantVIPResponse *walletv1.GrantVipResponse
getProgramRequest *walletv1.GetVipProgramConfigRequest getProgramRequest *walletv1.GetVipProgramConfigRequest
updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest
updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest
grantTrialRequest *walletv1.GrantVipTrialCardRequest grantTrialRequest *walletv1.GrantVipTrialCardRequest
grantVIPRequest *walletv1.GrantVipRequest
} }
func (f *fakeVIPConfigWallet) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) { func (f *fakeVIPConfigWallet) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
@ -199,3 +266,8 @@ func (f *fakeVIPConfigWallet) GrantVipTrialCard(_ context.Context, req *walletv1
f.grantTrialRequest = req f.grantTrialRequest = req
return f.grantTrialResponse, nil return f.grantTrialResponse, nil
} }
func (f *fakeVIPConfigWallet) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
f.grantVIPRequest = req
return f.grantVIPResponse, nil
}

View File

@ -66,3 +66,31 @@ func TestReplaceUserAppScopesRejectsEmptySelected(t *testing.T) {
t.Fatalf("sql expectations: %v", err) t.Fatalf("sql expectations: %v", err)
} }
} }
func TestCurrentAuthorizationForUserReadsAndDeduplicatesLivePermissions(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("(?s)SELECT admin_users\\.username,.*FROM admin_users.*WHERE admin_users\\.id = \\?.*ORDER BY admin_permissions\\.code ASC").
WithArgs(uint(7)).
WillReturnRows(sqlmock.NewRows([]string{"username", "status", "permission_code"}).
AddRow("ops", model.UserStatusActive, "operations-withdrawal:audit").
AddRow("ops", model.UserStatusActive, "operations-withdrawal:audit").
AddRow("ops", model.UserStatusActive, "operations-withdrawal:view"))
authorization, err := store.CurrentAuthorizationForUser(7)
if err != nil {
t.Fatalf("resolve current authorization: %v", err)
}
if authorization.Username != "ops" || authorization.Status != model.UserStatusActive {
t.Fatalf("unexpected current user state: %+v", authorization)
}
if len(authorization.Permissions) != 2 ||
authorization.Permissions[0] != "operations-withdrawal:audit" ||
authorization.Permissions[1] != "operations-withdrawal:view" {
t.Fatalf("unexpected live permissions: %+v", authorization.Permissions)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -1,12 +1,28 @@
package repository package repository
import ( import (
"hyapp-admin-server/internal/model"
"time" "time"
"hyapp-admin-server/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
// UserAuthorization is the current server-side authorization state used for
// every protected request. JWT claims identify the session, but never own RBAC
// state because role changes must take effect before a long-lived token expires.
type UserAuthorization struct {
Username string
Status string
Permissions []string
}
type userAuthorizationRow struct {
Username string
Status string
PermissionCode *string
}
func (s *Store) FindUserByUsername(username string) (*model.User, error) { func (s *Store) FindUserByUsername(username string) (*model.User, error) {
var user model.User var user model.User
err := s.db.Preload("Roles.Permissions").Preload("TeamRecord").Where("username = ?", username).First(&user).Error err := s.db.Preload("Roles.Permissions").Preload("TeamRecord").Where("username = ?", username).First(&user).Error
@ -27,6 +43,54 @@ func (s *Store) FindUserByID(id uint) (*model.User, error) {
return &user, nil return &user, nil
} }
func (s *Store) CurrentAuthorizationForUser(userID uint) (UserAuthorization, error) {
if userID == 0 {
return UserAuthorization{}, gorm.ErrRecordNotFound
}
var rows []userAuthorizationRow
// The query starts from admin_users.id and follows the two composite-primary-key
// relation tables. It therefore resolves live permissions in one indexed query
// without loading the full user/role models on every protected HTTP request.
result := s.db.Raw(`
SELECT admin_users.username,
admin_users.status,
admin_permissions.code AS permission_code
FROM admin_users
LEFT JOIN admin_user_roles
ON admin_user_roles.user_id = admin_users.id
LEFT JOIN admin_role_permissions
ON admin_role_permissions.role_id = admin_user_roles.role_id
LEFT JOIN admin_permissions
ON admin_permissions.id = admin_role_permissions.permission_id
WHERE admin_users.id = ?
ORDER BY admin_permissions.code ASC
`, userID).Scan(&rows)
if result.Error != nil {
return UserAuthorization{}, result.Error
}
if len(rows) == 0 {
return UserAuthorization{}, gorm.ErrRecordNotFound
}
authorization := UserAuthorization{
Username: rows[0].Username,
Status: rows[0].Status,
}
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
if row.PermissionCode == nil || *row.PermissionCode == "" {
continue
}
if _, ok := seen[*row.PermissionCode]; ok {
continue
}
seen[*row.PermissionCode] = struct{}{}
authorization.Permissions = append(authorization.Permissions, *row.PermissionCode)
}
return authorization, nil
}
func PermissionsForUser(user model.User) []string { func PermissionsForUser(user model.User) []string {
set := map[string]struct{}{} set := map[string]struct{}{}
for _, role := range user.Roles { for _, role := range user.Roles {

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"errors" "errors"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -64,9 +65,24 @@ func (access MoneyAccess) AppCodes() []string {
for appCode := range set { for appCode := range set {
out = append(out, appCode) out = append(out, appCode)
} }
sort.Strings(out)
return out return out
} }
// AllowsApp 用于没有 region 维度的资金事实(例如用户提现):任一归属该 App 的财务范围都可查看和审核该 App 申请。
func (access MoneyAccess) AllowsApp(appCode string) bool {
if access.All {
return true
}
appCode = appctx.Normalize(appCode)
for _, allowed := range access.AppCodes() {
if allowed == appCode {
return true
}
}
return false
}
func (s *Store) MoneyAccessForUser(userID uint) (MoneyAccess, error) { func (s *Store) MoneyAccessForUser(userID uint) (MoneyAccess, error) {
if userID == 0 { if userID == 0 {
return MoneyAccess{All: true}, nil return MoneyAccess{All: true}, nil

View File

@ -135,3 +135,17 @@ func TestMoneyAccessAllowsRoundedRegion(t *testing.T) {
t.Fatalf("unrelated region must stay denied") t.Fatalf("unrelated region must stay denied")
} }
} }
func TestMoneyAccessAllowsAppIgnoresRegionButKeepsAppBoundary(t *testing.T) {
t.Parallel()
access := MoneyAccess{UserID: 7, Scopes: []model.UserMoneyScope{
{UserID: 7, AppCode: "lalu", RegionID: 9},
{UserID: 7, AppCode: "fami", RegionID: 0},
}}
if !access.AllowsApp("LALU") || !access.AllowsApp("fami") || access.AllowsApp("huwaa") {
t.Fatalf("unexpected app-level money access: %+v", access)
}
if got := access.AppCodes(); len(got) != 2 || got[0] != "fami" || got[1] != "lalu" {
t.Fatalf("money scope app codes must be stable and sorted: %v", got)
}
}

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