Compare commits

...

34 Commits

Author SHA1 Message Date
zhx
bf6d7d4f1e 幸运礼物实验硬约束:两组 v2 高水位加权三元组必须一致
滞回标志按 app+pool 单行存储且由每个用户钉住的组内配置推进;
两组三元组不同(尤其 control 关/treatment 开的灰度形态)会互踩:
control 逐抽清掉 treatment 的标志,阈值不同的组按对方状态加权付奖。
加权特性的灰度必须走常规发布,不能走实验。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:07:24 +08:00
zhx
3ca36d2c8d Merge remote-tracking branch 'origin/main'
# Conflicts:
#	services/lucky-gift-service/internal/storage/mysql/external_repository.go
#	services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go
2026-07-17 01:38:56 +08:00
zhx
5da1421fae 幸运礼物 fixed_v2 高水位返奖加权:阈值/系数/恢复水位三元组与奖池滞回标志
- lucky_gift_rule_versions 新增 v2_high_water_boost_{threshold,factor_ppm,recover}_coins,0 表示关闭
- lucky_pools 新增 v2_boost_active 滞回标志:触发阈值后加权,跌破恢复水位退出
- 迁移 010 全部 ALGORITHM=INSTANT,不回写历史行;proto/admin 透传三元组
- 真库回归 lucky_gift_v2_boost_test.go

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:35:35 +08:00
zhx
a35beb8d5f merge: promote lucky gift test changes to main 2026-07-17 01:20:27 +08:00
zhx
43adaeddf1 Merge remote-tracking branch 'origin/test' 2026-07-17 01:06:55 +08:00
zhx
dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key)
- 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝
- Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛
- AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional)
- admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103)
- 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:12 +08:00
zhx
a11d5aca22 merge main into test 2026-07-16 19:26:31 +08:00
zhx
9ee45a3a52 merge: integrate lucky gift dynamic strategy 2026-07-16 19:22:35 +08:00
zhx
6862edc2f3 增加权限,登录,猜拳修改 2026-07-16 18:51:58 +08:00
zhx
dba3cada59 fix(ops): resolve lucky gift profile short IDs 2026-07-16 18:40:50 +08:00
zhx
98d7743d7a merge: add lucky gift user portraits 2026-07-16 18:08:07 +08:00
zhx
a1f88736db feat(lucky-gift): add user portrait read model 2026-07-16 18:06:24 +08:00
zhx
adc355caa9 修复国家维度展示与跨应用房间事件 2026-07-16 16:14:49 +08:00
zhx
99b225b78a merge: add user 24h RTP downweight 2026-07-16 15:38:44 +08:00
zhx
9cf34db28b feat(lucky-gift): downweight high 24h RTP users 2026-07-16 15:38:03 +08:00
zhx
f79abb496b 修正整体留存成熟度与查询性能 2026-07-16 14:27:42 +08:00
zhx
e1844e26a0 统一运营宽表 canonical Social DAU 2026-07-16 13:35:03 +08:00
zhx
fd8bc1e608 merge: restore lucky gift dual jackpot strategies 2026-07-16 12:53:49 +08:00
zhx
c5acecca49 feat(lucky-gift): restore dual jackpot strategies 2026-07-16 12:52:51 +08:00
zhx
5ef0773ed3 修复 Social 用户身份归并 2026-07-16 12:47:03 +08:00
zhx
506d49de9c merge: independent lucky gift prize paths 2026-07-16 11:25:08 +08:00
zhx
bbdbe77412 feat(lucky-gift): separate ordinary and jackpot tiers 2026-07-16 11:24:51 +08:00
zhx
8a40bec075 移除外管首次改密与密码下限 2026-07-16 10:29:42 +08:00
zhx
e6767899c3 游戏排行榜 2026-07-16 10:04:47 +08:00
zhx
c901bfdb13 merge: settle lucky gift jackpot by round and 48h RTP 2026-07-15 20:36:59 +08:00
zhx
afce96dc42 feat(lucky-gift): settle jackpot eligibility by round and 48h RTP 2026-07-15 20:36:32 +08:00
zhx
fdcd29126d feat(wallet): expand VIP and resource management flows 2026-07-15 19:34:54 +08:00
zhx
c556b44cf6 fix(external-admin): hide app catalog before login 2026-07-15 19:14:55 +08:00
zhx
7748b186f5 feat(admin): configure external account permissions 2026-07-15 18:40:42 +08:00
zhx
9c1dd0e3a4 merge: update lucky gift threshold strategy 2026-07-15 18:16:57 +08:00
zhx
e7389c1e74 fix(admin): omit unresolved tenant header 2026-07-15 16:44:07 +08:00
zhx
d6801f9de2 feat(admin): infer external tenant from account 2026-07-15 16:40:00 +08:00
zhx
ce6b85fb88 feat(admin): add isolated external management portal 2026-07-15 16:05:21 +08:00
zhx
08a0fec530 fix: count Aslan paid coin-seller orders 2026-07-15 14:07:23 +08:00
284 changed files with 44385 additions and 20142 deletions

View File

@ -14,6 +14,13 @@
"runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"],
"port": 13100,
"autoPort": false
},
{
"name": "lucky-gift-service",
"runtimeExecutable": "go",
"runtimeArgs": ["run", "./services/lucky-gift-service/cmd/server"],
"port": 13013,
"autoPort": false
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,9 @@ message LuckyGiftMeta {
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 22;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close
int64 user_registered_at_ms = 23;
}
message LuckyGiftRuleTier {
@ -91,8 +94,9 @@ message LuckyGiftRuleConfig {
int64 recharge_boost_factor_ppm = 26;
repeated int64 jackpot_multiplier_ppms = 27;
int64 jackpot_global_rtp_max_ppm = 28;
int64 jackpot_user_day_rtp_max_ppm = 29;
int64 jackpot_user_72h_rtp_max_ppm = 30;
int64 jackpot_user_round_rtp_max_ppm = 29;
int64 jackpot_user_48h_rtp_max_ppm = 30;
// UTC
int64 jackpot_spend_threshold_coins = 31;
int64 max_jackpot_hits_per_user_day = 32;
int64 max_single_payout = 33;
@ -101,6 +105,17 @@ message LuckyGiftRuleConfig {
int64 device_daily_payout_cap = 36;
int64 room_hourly_payout_cap = 37;
int64 anchor_daily_payout_cap = 38;
// 24 RTP 0
int64 user_24h_rtp_threshold_ppm = 39;
// 24 RTP 1000000
int64 user_24h_ordinary_win_factor_ppm = 40;
// fixed_v2 RTP
// 线退0 dynamic_v3 0
int64 v2_high_water_boost_threshold_coins = 41;
// ppm1500000 RTP 150% 1000000
int64 v2_high_water_boost_factor_ppm = 42;
// 退
int64 v2_high_water_boost_recover_coins = 43;
}
message CheckLuckyGiftRequest {
@ -183,6 +198,8 @@ message ExternalGiftDrawRequest {
string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
// user_registered_at_ms App dynamic_v3 fail-close
int64 user_registered_at_ms = 14;
}
message ExternalGiftDrawResponse {
@ -299,6 +316,120 @@ message ListLuckyGiftPoolBalancesResponse {
repeated LuckyGiftPoolBalance pools = 1;
}
// LuckyGiftExperimentOverride
// user_key user_id "ext:" + external_user_id
message LuckyGiftExperimentOverride {
string user_key = 1;
string arm = 2;
}
// LuckyGiftExperimentArmStats lucky_draw_records rule_version
message LuckyGiftExperimentArmStats {
string arm = 1;
int64 rule_version = 2;
string strategy_version = 3;
int64 total_draws = 4;
int64 unique_users = 5;
int64 total_spent_coins = 6;
int64 total_reward_coins = 7;
int64 actual_rtp_ppm = 8;
}
// LuckyGiftExperiment AB rule_version
// bucket_salt+user
message LuckyGiftExperiment {
string app_code = 1;
string pool_id = 2;
string experiment_id = 3;
string name = 4;
int64 control_rule_version = 5;
int64 treatment_rule_version = 6;
// treatment ppm0..10000000 override treatment
int64 treatment_traffic_ppm = 7;
string bucket_salt = 8;
// running / paused / completedpaused control
string status = 9;
string winner_arm = 10;
int64 winner_rule_version = 11;
//
int64 final_rule_version = 12;
int64 created_by_admin_id = 13;
int64 started_at_ms = 14;
int64 ended_at_ms = 15;
int64 updated_at_ms = 16;
repeated LuckyGiftExperimentOverride overrides = 17;
repeated LuckyGiftExperimentArmStats arm_stats = 18;
}
// CreateLuckyGiftExperimentRequest treatment
// "先发布后建实验"
// control
message CreateLuckyGiftExperimentRequest {
RequestMeta meta = 1;
string name = 2;
LuckyGiftRuleConfig treatment_config = 3;
int64 treatment_traffic_ppm = 4;
int64 operator_admin_id = 5;
repeated LuckyGiftExperimentOverride overrides = 6;
}
message CreateLuckyGiftExperimentResponse {
LuckyGiftExperiment experiment = 1;
LuckyGiftRuleConfig treatment_config = 2;
}
message ListLuckyGiftExperimentsRequest {
RequestMeta meta = 1;
string pool_id = 2;
string status = 3;
bool with_stats = 4;
}
message ListLuckyGiftExperimentsResponse {
repeated LuckyGiftExperiment experiments = 1;
}
// UpdateLuckyGiftExperimentRequest /
// treatment_traffic_ppm 使 proto3 0 "放量清零"
// status
message UpdateLuckyGiftExperimentRequest {
RequestMeta meta = 1;
string experiment_id = 2;
optional int64 treatment_traffic_ppm = 3;
string status = 4;
int64 operator_admin_id = 5;
}
message UpdateLuckyGiftExperimentResponse {
LuckyGiftExperiment experiment = 1;
}
message SetLuckyGiftExperimentOverridesRequest {
RequestMeta meta = 1;
string experiment_id = 2;
repeated LuckyGiftExperimentOverride upserts = 3;
repeated string removes = 4;
int64 operator_admin_id = 5;
}
message SetLuckyGiftExperimentOverridesResponse {
LuckyGiftExperiment experiment = 1;
}
// EndLuckyGiftExperimentRequest winner_arm
//
message EndLuckyGiftExperimentRequest {
RequestMeta meta = 1;
string experiment_id = 2;
string winner_arm = 3;
int64 operator_admin_id = 4;
}
message EndLuckyGiftExperimentResponse {
LuckyGiftExperiment experiment = 1;
LuckyGiftRuleConfig final_config = 2;
}
service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
@ -314,6 +445,14 @@ service AdminLuckyGiftService {
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
rpc CreateLuckyGiftExperiment(CreateLuckyGiftExperimentRequest) returns (CreateLuckyGiftExperimentResponse);
rpc ListLuckyGiftExperiments(ListLuckyGiftExperimentsRequest) returns (ListLuckyGiftExperimentsResponse);
rpc UpdateLuckyGiftExperiment(UpdateLuckyGiftExperimentRequest) returns (UpdateLuckyGiftExperimentResponse);
rpc SetLuckyGiftExperimentOverrides(SetLuckyGiftExperimentOverridesRequest) returns (SetLuckyGiftExperimentOverridesResponse);
rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse);
rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse);
rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse);
rpc ListLuckyGiftDailyStats(ListLuckyGiftDailyStatsRequest) returns (ListLuckyGiftDailyStatsResponse);
}
// LuckyGiftHit
@ -358,3 +497,168 @@ message AdjustLuckyGiftPoolBalanceResponse {
LuckyGiftPoolBalance pool = 2;
bool idempotent_replay = 3;
}
// message index owner
// external_user_id
message LuckyGiftUserProfileWindow {
int64 draws = 1;
int64 wager_coins = 2;
int64 payout_coins = 3;
int64 net_coins = 4;
int64 rtp_ppm = 5;
bool has_rtp = 6;
}
message LuckyGiftUserProfile {
string app_code = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
int64 rule_version = 6;
string strategy_version = 7;
string stage = 8;
int64 paid_draws = 9;
int64 equivalent_draws = 10;
int64 loss_streak = 11;
int64 pending_spend_jackpot_tokens = 12;
int64 guarantee_draws_remaining = 13;
bool downweight_active = 14;
int64 downweight_factor_ppm = 15;
LuckyGiftUserProfileWindow one_hour = 16;
LuckyGiftUserProfileWindow twenty_four_hours = 17;
LuckyGiftUserProfileWindow forty_eight_hours = 18;
LuckyGiftUserProfileWindow lifetime = 19;
int64 base_reward_coins = 20;
int64 ordinary_win_count = 21;
int64 high_multiplier_ordinary_win_count = 22;
int64 rtp_compensation_jackpot_count = 23;
int64 cumulative_spend_jackpot_count = 24;
int64 guarantee_hit_count = 25;
int64 zero_draw_count = 26;
int64 granted_draw_count = 27;
int64 pending_draw_count = 28;
int64 failed_draw_count = 29;
int64 max_multiplier_ppm = 30;
int64 max_reward_coins = 31;
int64 first_draw_at_ms = 32;
int64 last_draw_at_ms = 33;
int64 aggregated_at_ms = 34;
int64 data_lag_ms = 35;
int64 user_24h_rtp_threshold_ppm = 36;
}
message ListLuckyGiftUserProfilesRequest {
RequestMeta meta = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
string stage = 6;
string status = 7;
string sort_by = 8;
string sort_direction = 9;
int32 page = 10;
int32 page_size = 11;
// cursor owner service keyset page OFFSET
string cursor = 12;
}
message ListLuckyGiftUserProfilesResponse {
repeated LuckyGiftUserProfile profiles = 1;
int64 total = 2;
int64 snapshot_at_ms = 3;
string next_cursor = 4;
bool has_more = 5;
}
message GetLuckyGiftUserProfileRequest {
RequestMeta meta = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
int32 jackpot_page = 6;
int32 jackpot_page_size = 7;
// jackpot_cursor next_jackpot_cursor jackpot_page
string jackpot_cursor = 8;
}
message LuckyGiftUserMultiplierStat {
string hit_type = 1;
int64 multiplier_ppm = 2;
int64 draw_count = 3;
int64 wager_coins = 4;
int64 payout_coins = 5;
}
message LuckyGiftUserJackpotCondition {
string name = 1;
int64 numerator = 2;
int64 denominator = 3;
int64 ratio_ppm = 4;
int64 limit_ppm = 5;
int64 factor_ppm = 6;
bool passed = 7;
string reason = 8;
}
message LuckyGiftUserJackpotHit {
string draw_id = 1;
string request_id = 2;
int64 rule_version = 3;
string stage = 4;
string mechanism = 5;
string reason_code = 6;
string reason_summary = 7;
string tier_id = 8;
int64 multiplier_ppm = 9;
int64 wager_coins = 10;
int64 payout_coins = 11;
int64 occurred_at_ms = 12;
repeated LuckyGiftUserJackpotCondition conditions = 13;
// event_key
string event_key = 14;
}
message GetLuckyGiftUserProfileResponse {
LuckyGiftUserProfile profile = 1;
repeated LuckyGiftUserMultiplierStat multiplier_distribution = 2;
repeated LuckyGiftUserJackpotHit jackpot_hits = 3;
int64 jackpot_hit_total = 4;
int64 snapshot_at_ms = 5;
int32 jackpot_page = 6;
int32 jackpot_page_size = 7;
string next_jackpot_cursor = 8;
bool jackpot_has_more = 9;
}
// ListLuckyGiftDailyStats Databi lucky_draw_pool_day_stats
// +08:00 BI 访
// strategy_version V3 tab dynamic_v3
message ListLuckyGiftDailyStatsRequest {
RequestMeta meta = 1;
// pool_id
string pool_id = 2;
// strategy_version fixed_v2 / dynamic_v3
string strategy_version = 3;
// [start_ms, end_ms) UTC epoch ms stat_day
int64 start_ms = 4;
int64 end_ms = 5;
// region_ids ID 0=GLOBAL
repeated int64 region_ids = 6;
}
message LuckyGiftDailyStat {
// stat_day +08:00YYYY-MM-DD
string stat_day = 1;
int64 draw_count = 2;
int64 turnover_coin = 3;
// payout_coin effective DrawSummary /
int64 payout_coin = 4;
int64 base_reward_coin = 5;
}
message ListLuckyGiftDailyStatsResponse {
repeated LuckyGiftDailyStat stats = 1;
}

View File

@ -235,13 +235,21 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
}
const (
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/CreateLuckyGiftExperiment"
AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftExperiments"
AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpdateLuckyGiftExperiment"
AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/SetLuckyGiftExperimentOverrides"
AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment"
AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles"
AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile"
AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDailyStats"
)
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
@ -255,6 +263,14 @@ type AdminLuckyGiftServiceClient interface {
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error)
CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error)
ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error)
UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error)
EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error)
ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftDailyStats(ctx context.Context, in *ListLuckyGiftDailyStatsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDailyStatsResponse, error)
}
type adminLuckyGiftServiceClient struct {
@ -335,6 +351,86 @@ func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Con
return out, nil
}
func (c *adminLuckyGiftServiceClient) CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateLuckyGiftExperimentResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftExperimentsResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateLuckyGiftExperimentResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetLuckyGiftExperimentOverridesResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EndLuckyGiftExperimentResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftUserProfilesResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLuckyGiftUserProfileResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftDailyStats(ctx context.Context, in *ListLuckyGiftDailyStatsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDailyStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftDailyStatsResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility.
@ -346,6 +442,14 @@ type AdminLuckyGiftServiceServer interface {
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error)
ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error)
UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error)
EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error)
ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftDailyStats(context.Context, *ListLuckyGiftDailyStatsRequest) (*ListLuckyGiftDailyStatsResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
}
@ -377,6 +481,30 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(contex
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateLuckyGiftExperiment not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftExperiments not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateLuckyGiftExperiment not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetLuckyGiftExperimentOverrides not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method EndLuckyGiftExperiment not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftUserProfiles not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftUserProfile not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDailyStats(context.Context, *ListLuckyGiftDailyStatsRequest) (*ListLuckyGiftDailyStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDailyStats not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -524,6 +652,150 @@ func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{},
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateLuckyGiftExperimentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, req.(*CreateLuckyGiftExperimentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftExperimentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, req.(*ListLuckyGiftExperimentsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateLuckyGiftExperimentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, req.(*UpdateLuckyGiftExperimentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetLuckyGiftExperimentOverridesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, req.(*SetLuckyGiftExperimentOverridesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EndLuckyGiftExperimentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, req.(*EndLuckyGiftExperimentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftUserProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, req.(*ListLuckyGiftUserProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLuckyGiftUserProfileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, req.(*GetLuckyGiftUserProfileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftDailyStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftDailyStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftDailyStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftDailyStats(ctx, req.(*ListLuckyGiftDailyStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -559,6 +831,38 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdjustLuckyGiftPoolBalance",
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
},
{
MethodName: "CreateLuckyGiftExperiment",
Handler: _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler,
},
{
MethodName: "ListLuckyGiftExperiments",
Handler: _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler,
},
{
MethodName: "UpdateLuckyGiftExperiment",
Handler: _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler,
},
{
MethodName: "SetLuckyGiftExperimentOverrides",
Handler: _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler,
},
{
MethodName: "EndLuckyGiftExperiment",
Handler: _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler,
},
{
MethodName: "ListLuckyGiftUserProfiles",
Handler: _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler,
},
{
MethodName: "GetLuckyGiftUserProfile",
Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler,
},
{
MethodName: "ListLuckyGiftDailyStats",
Handler: _AdminLuckyGiftService_ListLuckyGiftDailyStats_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/luckygift/v1/luckygift.proto",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1163,6 +1163,9 @@ message SendGiftRequest {
// combo_session_id + batch_seq Flutter command_id
string combo_session_id = 19;
int64 batch_seq = 20;
// sender_registered_at_ms gateway user-service
// room-service lucky-gift-service
int64 sender_registered_at_ms = 21;
}
// SendGiftResponse

View File

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

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1
@ -1001,11 +1001,13 @@ func (x *QuickCreateAccountResponse) GetPasswordSet() bool {
// RefreshTokenRequest 使用 refresh token 换取新 token。
type RefreshTokenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
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"`
// 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
sizeCache protoimpl.SizeCache
}
func (x *RefreshTokenRequest) Reset() {
@ -1052,6 +1054,13 @@ func (x *RefreshTokenRequest) GetRefreshToken() string {
return ""
}
func (x *RefreshTokenRequest) GetRefreshRequestId() string {
if x != nil {
return x.RefreshRequestId
}
return ""
}
// RefreshTokenResponse 返回轮换后的令牌。
type RefreshTokenResponse struct {
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" +
"\x1aQuickCreateAccountResponse\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" +
"\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" +
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\"\x83\x01\n" +
"\rLogoutRequest\x12.\n" +

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -416,6 +416,8 @@ message CPGiftSnapshot {
int32 gift_count = 5;
int64 gift_value = 6;
string billing_receipt_id = 7;
// coin_spent wallet gift_value/
int64 coin_spent = 8;
}
// CPApplication A B CP//
@ -649,6 +651,8 @@ message RoomGiftCPEvent {
string gift_name = 16;
string gift_icon_url = 17;
string gift_animation_url = 18;
// coin_spent RoomGiftSent wallet gift_value
int64 coin_spent = 19;
}
message ConsumeRoomGiftCPEventRequest {
@ -1457,6 +1461,24 @@ message AdminGrantPrettyDisplayIDResponse {
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
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
@ -1511,6 +1533,7 @@ service UserCPService {
rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse);
rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse);
rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse);
rpc ListCPFormationGiftFeed(ListCPFormationGiftFeedRequest) returns (ListCPFormationGiftFeedResponse);
}
// UserCPInternalService owner outbox worker App
@ -1545,6 +1568,18 @@ message ListAppsResponse {
repeated App apps = 1;
}
// ResolveAdminUserIdentifierRequest
// user_identifier user_id
message ResolveAdminUserIdentifierRequest {
RequestMeta meta = 1;
string user_identifier = 2;
}
// ResolveAdminUserIdentifierResponse
message ResolveAdminUserIdentifierResponse {
UserIdentity identity = 1;
}
// AppRegistryService gateway package_name -> app_code
service AppRegistryService {
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
@ -1577,6 +1612,8 @@ service RegionAdminService {
service UserIdentityService {
rpc GetUserIdentity(GetUserIdentityRequest) returns (GetUserIdentityResponse);
rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse);
// ResolveAdminUserIdentifier active
rpc ResolveAdminUserIdentifier(ResolveAdminUserIdentifierRequest) returns (ResolveAdminUserIdentifierResponse);
rpc ChangeDisplayUserID(ChangeDisplayUserIDRequest) returns (ChangeDisplayUserIDResponse);
rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse);
rpc ListAvailablePrettyDisplayIDs(ListAvailablePrettyDisplayIDsRequest) returns (ListAvailablePrettyDisplayIDsResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1
@ -1531,6 +1531,7 @@ const (
UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship"
UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship"
UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship"
UserCPService_ListCPFormationGiftFeed_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPFormationGiftFeed"
)
// 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)
ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, 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 {
@ -1637,6 +1639,16 @@ func (c *userCPServiceClient) CancelBreakCPRelationship(ctx context.Context, in
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.
// All implementations must embed UnimplementedUserCPServiceServer
// for forward compatibility.
@ -1651,6 +1663,7 @@ type UserCPServiceServer interface {
PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error)
ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error)
CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error)
ListCPFormationGiftFeed(context.Context, *ListCPFormationGiftFeedRequest) (*ListCPFormationGiftFeedResponse, error)
mustEmbedUnimplementedUserCPServiceServer()
}
@ -1685,6 +1698,9 @@ func (UnimplementedUserCPServiceServer) ConfirmBreakCPRelationship(context.Conte
func (UnimplementedUserCPServiceServer) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) {
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) testEmbeddedByValue() {}
@ -1850,6 +1866,24 @@ func _UserCPService_CancelBreakCPRelationship_Handler(srv interface{}, ctx conte
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.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1889,6 +1923,10 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CancelBreakCPRelationship",
Handler: _UserCPService_CancelBreakCPRelationship_Handler,
},
{
MethodName: "ListCPFormationGiftFeed",
Handler: _UserCPService_ListCPFormationGiftFeed_Handler,
},
},
Streams: []grpc.StreamDesc{},
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")
}
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) testEmbeddedByValue() {}
@ -3175,6 +3213,7 @@ var RegionAdminService_ServiceDesc = grpc.ServiceDesc{
const (
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ResolveAdminUserIdentifier_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveAdminUserIdentifier"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserIdentityService/ListAvailablePrettyDisplayIDs"
@ -3190,6 +3229,8 @@ const (
type UserIdentityServiceClient interface {
GetUserIdentity(ctx context.Context, in *GetUserIdentityRequest, opts ...grpc.CallOption) (*GetUserIdentityResponse, error)
ResolveDisplayUserID(ctx context.Context, in *ResolveDisplayUserIDRequest, opts ...grpc.CallOption) (*ResolveDisplayUserIDResponse, error)
// ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。
ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error)
ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(ctx context.Context, in *ApplyPrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error)
@ -3225,6 +3266,16 @@ func (c *userIdentityServiceClient) ResolveDisplayUserID(ctx context.Context, in
return out, nil
}
func (c *userIdentityServiceClient) ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResolveAdminUserIdentifierResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ResolveAdminUserIdentifier_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeDisplayUserIDResponse)
@ -3283,6 +3334,8 @@ func (c *userIdentityServiceClient) ExpirePrettyDisplayUserID(ctx context.Contex
type UserIdentityServiceServer interface {
GetUserIdentity(context.Context, *GetUserIdentityRequest) (*GetUserIdentityResponse, error)
ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error)
// ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。
ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error)
ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error)
@ -3304,6 +3357,9 @@ func (UnimplementedUserIdentityServiceServer) GetUserIdentity(context.Context, *
func (UnimplementedUserIdentityServiceServer) ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveDisplayUserID not implemented")
}
func (UnimplementedUserIdentityServiceServer) ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveAdminUserIdentifier not implemented")
}
func (UnimplementedUserIdentityServiceServer) ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ChangeDisplayUserID not implemented")
}
@ -3376,6 +3432,24 @@ func _UserIdentityService_ResolveDisplayUserID_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ResolveAdminUserIdentifier_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResolveAdminUserIdentifierRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ResolveAdminUserIdentifier_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, req.(*ResolveAdminUserIdentifierRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ChangeDisplayUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeDisplayUserIDRequest)
if err := dec(in); err != nil {
@ -3481,6 +3555,10 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveDisplayUserID",
Handler: _UserIdentityService_ResolveDisplayUserID_Handler,
},
{
MethodName: "ResolveAdminUserIdentifier",
Handler: _UserIdentityService_ResolveAdminUserIdentifier_Handler,
},
{
MethodName: "ChangeDisplayUserID",
Handler: _UserIdentityService_ChangeDisplayUserID_Handler,

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_to_ms = 6;
int32 sort_order = 7;
// coin_price 0 使 0
int64 coin_price = 8;
}
message ListResourceShopItemsRequest {
@ -2025,6 +2027,7 @@ message VipBenefit {
string metadata_json = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15;
}
message VipLevel {
@ -2082,13 +2085,16 @@ message VipTrialCard {
}
// VipUserSettings App VIP wallet
// updated_at_ms=0 VIP
// updated_at_ms=0 VIP
message VipUserSettings {
string app_code = 1;
int64 user_id = 2;
bool room_entry_notice_enabled = 3;
bool online_global_notice_enabled = 4;
int64 updated_at_ms = 5;
bool hide_profile_data_enabled = 6;
bool anonymous_profile_visit_enabled = 7;
bool leaderboard_invisible_enabled = 8;
}
// VipState
@ -2145,6 +2151,8 @@ message PurchaseVipResponse {
int64 coin_balance_after = 5;
repeated VipRewardItem reward_items = 6;
VipState state = 7;
// coin_balance coin_balance_after
AssetBalance coin_balance = 8;
}
message DebitCPBreakupFeeRequest {
@ -2229,6 +2237,9 @@ message UpdateMyVipSettingsRequest {
int64 user_id = 3;
optional bool room_entry_notice_enabled = 4;
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 {
@ -2949,6 +2960,52 @@ message GetPointWithdrawalConfigResponse {
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
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -3013,6 +3070,7 @@ service WalletService {
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse);
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
rpc RevokeUserResource(RevokeUserResourceRequest) returns (RevokeUserResourceResponse);
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -292,6 +292,7 @@ const (
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup"
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_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
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)
GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, 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)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserResourcesResponse)
@ -1830,6 +1842,7 @@ type WalletServiceServer interface {
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error)
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error)
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
@ -2072,6 +2085,9 @@ func (UnimplementedWalletServiceServer) GrantPinnedResourceGroup(context.Context
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
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) {
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)
}
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) {
in := new(ListUserResourcesRequest)
if err := dec(in); err != nil {
@ -4799,6 +4833,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RevokeResourceGrant",
Handler: _WalletService_RevokeResourceGrant_Handler,
},
{
MethodName: "RevokeUserResource",
Handler: _WalletService_RevokeUserResource_Handler,
},
{
MethodName: "ListUserResources",
Handler: _WalletService_ListUserResources_Handler,

View File

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

View File

@ -2,6 +2,14 @@
本文定义 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. 通用约定
- 地址前缀:`/api/v1`,实际 host 按环境配置。
@ -82,6 +90,20 @@ class VipEntitlementSnapshot {
return has('online_global_notice') &&
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)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,分别处理 `VIP_ANTI_KICK``VIP_ANTI_MUTE` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
@ -176,7 +198,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `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/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
@ -250,6 +272,22 @@ final benefitHandlers = <String, VipBenefitHandler>{
"auto_equip": false,
"sort_order": 110,
"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,
"updated_at_ms": 0
}
@ -302,6 +340,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"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
}
}
@ -412,7 +453,8 @@ final benefitHandlers = <String, VipBenefitHandler>{
"execution_scope": "room",
"auto_equip": false,
"sort_order": 290,
"metadata_json": ""
"metadata_json": "",
"presentation": null
}
],
"evaluated_at_ms": 1780101000000,
@ -426,6 +468,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"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
}
}
@ -447,7 +492,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
- 无 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. 购买、续期和升级
@ -490,6 +535,12 @@ final benefitHandlers = <String, VipBenefitHandler>{
},
"coin_spent": 4000,
"coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [],
"program_config": {
"app_code": "fami",
@ -523,6 +574,16 @@ final benefitHandlers = <String, VipBenefitHandler>{
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"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 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
- 购买更低等级由后端拒绝,错误 `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` 为准。
- 购买成功后用 `coin_balance.version` 防止旧响应覆盖新余额。`version=0` 只可能来自升级前的历史幂等订单,必须刷新钱包余额。
## 9. 体验卡背包列表
@ -660,17 +722,73 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"program_config": {
"app_code": "fami",
"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": {
"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": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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
}
@ -710,22 +828,67 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"unequipped": true,
"program_config": {
"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": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 3,
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"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
}
@ -898,7 +1061,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 14. VIP 通知开关
## 14. VIP 功能开关
### 接口地址
@ -909,17 +1072,15 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
### 参数
GET 无参数。PATCH 是局部更新,个字段至少传一个;显式 `false` 不能被当成未传:
GET 无参数。PATCH 是局部更新,5 个字段至少传一个;显式 `false` 不能被当成未传:
```json
{
"room_entry_notice_enabled": false
}
```
```json
{
"online_global_notice_enabled": true
"room_entry_notice_enabled": false,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true
}
```
@ -940,6 +1101,9 @@ GET
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000
},
"evaluated_at_ms": 1780300001000
@ -960,6 +1124,9 @@ PATCH
"user_id": 10001,
"room_entry_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
},
"server_time_ms": 1780300010000
@ -967,7 +1134,7 @@ PATCH
}
```
用户从未修改过时,两项默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖另一项。
用户从未修改过时,5 项均默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。
### 相关 IM
@ -978,6 +1145,7 @@ PATCH
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
- 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true`
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 隐藏个人数据、匿名访问主页、榜单隐身分别要求对应权益和同名开关同时成立;客户端不得只看设置值授权。
- 设置按 App 隔离Fami 的修改不影响 Lalu。
## 15. 每日 VIP 金币返现
@ -1246,7 +1414,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则。
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `VIP_BENEFIT_REQUIRED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 17. 权益场景和完成边界
@ -1258,10 +1426,10 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡权益 | 只渲染 `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不发全服 |
| 防踢 | 房管操作返回 `PERMISSION_DENIED` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `PERMISSION_DENIED` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 防踢 | 房管操作返回 `VIP_ANTI_KICK` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `VIP_ANTI_MUTE` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
| 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
@ -1277,4 +1445,4 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
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 高亮通知;
- `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_source``paid``trial``none`
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND
- `evaluated_at_ms`:本次状态合并的服务端时间;
- `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`、不下发权益。
@ -156,8 +166,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- `GET /vip/packages`program、可购买等级、每级权益和完整 state。
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取;响应 `coin_balance` 是带 `version` 的账后快照,旧 `coin_balance_after` 仅兼容历史客户端
- `GET|PATCH /vip/settings`:读取或局部修改 5 项 VIP 功能偏好;空 PATCH 拒绝。
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`
- `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-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 展示链路执行。

View File

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

View File

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

View File

@ -0,0 +1,121 @@
# 幸运礼物 AB 实验(灰度放量)
> 2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本,只对按用户哈希圈定的部分用户生效,
> 其余用户继续使用原版本;支持白名单验证、放量调整、暂停与选择获胜方收口。
## 一、动机与总体设计
幸运礼物配置是不可变版本流(`lucky_gift_rule_versions`PK = app_code + pool_id + rule_version
发布 = 追加 `max(rule_version)+1` 的新行,运行时永远取最新版本。因此**发布即全量**,没有灰度空间。
AB 实验在其上加一层"版本钉住"语义,不改变版本流本身:
- 实验(`lucky_gift_experiments`)为一个奖池钉住两个已存在的不可变版本:
`control_rule_version`(对照组 = 创建实验时的最新启用版本)与 `treatment_rule_version`(实验组 = 随实验**同事务原子发布**的新版本)。
- 有未完成实验时,抽奖/预检查按用户解析组并加载对应版本;无实验时保持"最新版本生效"原语义。
- 同一奖池同时最多一个未完成实验(`active` 生成列 + 唯一键兜底)。
### 为什么"创建实验 = 原子发布 + 登记"
若先正常发布 v10 再建实验,发布提交到实验生效之间存在窗口:此时 v10 已是最新版本,**全量用户瞬间切到 v10**。
因此 `CreateLuckyGiftExperiment` 在一个 MySQL 事务里完成 treatment 发布与实验登记(`lucky_gift_experiment_repository.go`)。
同理实验进行中常规发布被拒绝409实验期两组各自钉住版本新发布的"最新版本"不会生效,只会造成困惑。
### 分流算法
`FNV-1a 64(salt ":" userKey) % 1,000,000 < treatment_traffic_ppm → treatment``internal/domain/luckygift/experiment.go`)。
- userKey内部用户为十进制 user_id外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。
白名单 user_key 列使用 utf8mb4_bin字节精确与 FNV 分桶口径一致。
- **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组room-service 送礼 saga 在 CheckLuckyGift
固化 strategy_version、恢复阶段不重新 Check。放量/白名单/暂停操作会让分组在两次解析间漂移,因此——
- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对
device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验
(旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。
- **单调放量**`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入;
调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。
- 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单);
单实验累计上限 500。
- `paused` 是紧急停闸:全部流量(**含白名单**)回落对照组;恢复 running 后原分组不变。
### 与既有机制的关系(为什么这套结构对实验天然安全)
| 机制 | 维度 | 实验语义 |
|---|---|---|
| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离,两组 RTP 观察互不污染 |
| 资金池 `lucky_pools` | fixed_v2: `pool`+poolIDdynamic_v3: `pool_dynamic_v3`+poolID | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 |
| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间,不含版本 | 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度 |
| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 |
### 结束实验(收口)
`EndLuckyGiftExperiment(winner_arm)` 在一个事务里:把获胜组钉住的配置**快照重发布**为新的最新版本
`final_rule_version`),并把实验标记 completed。结束瞬间"最新版本生效"的默认语义恰好收敛到获胜配置,无中间态:
- treatment 胜 → 全量放开新配置(等效全量上线);
- control 胜 → 回滚等效撤销本次变更treatment 版本留在版本流里成为历史)。
注意:若 treatment 是 dynamic_v3 而 control 胜出,实验期物化的 dynamic 账本及其人工注资仍在,需要用现有"扣减水位"人工调账收回。
## 二、数据模型
`services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql`initdb 001 已同步):
- `lucky_gift_experiments`PK (app_code, experiment_id)`active TINYINT AS (IF(status IN ('running','paused'),1,NULL)) STORED`
`UNIQUE (app_code, pool_id, active)` ⇒ 每池至多一个未完成实验completed 历史不限量。
- `lucky_gift_experiment_overrides`PK (app_code, experiment_id, user_key)arm ∈ {control, treatment}。白名单上限 500service 校验)。
## 三、运行时接入点lucky-gift-service
`resolveLuckyGiftRuleConfigForUser``lucky_gift_experiment_repository.go`)替换了四处配置加载:
1. `CheckLuckyGift`(送礼预检查,非锁读)
2. `executeSingleLuckyGiftDraw`(单抽,事务内 FOR UPDATE
3. `executeOptimizedLuckyGiftDrawBatch`(批量抽,事务内 FOR UPDATE
4. `ExecuteExternalGiftDraw`(外部 Appext: 前缀 userKey
实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行,
锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。
钉住版本行缺失 = 不可变事实被外部破坏fail-close 报错,不静默回落最新版本。
已知取舍(均无资金风险):
- 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。
- 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。
- 结束实验以获胜方快照重发布时dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。
## 四、管理 API
gRPC `luckygift.v1.AdminLuckyGiftService` 新增CreateLuckyGiftExperiment / ListLuckyGiftExperimentswith_stats 返回分组聚合)/
UpdateLuckyGiftExperiment放量、暂停恢复/ SetLuckyGiftExperimentOverrides / EndLuckyGiftExperiment。
admin HTTP前缀 /api/v1/admin/ops-center权限查看 `lucky-gift:view`、管理 `lucky-gift:experiment`
| Method | Path | 说明 |
|---|---|---|
| GET | /lucky-gifts/experiments?app_code&pool_id&status&with_stats | 实验列表(含分组统计) |
| POST | /lucky-gifts/experiments?app_code | 创建body: name + treatment_traffic_percent + overrides + config |
| PATCH | /lucky-gifts/experiments/{id}?app_code | 放量 / 暂停 / 恢复 |
| PUT | /lucky-gifts/experiments/{id}/overrides?app_code | 白名单增删 |
| POST | /lucky-gifts/experiments/{id}/end?app_code | 结束并选择获胜方 |
ops-center 前端在幸运礼物配置页集成:实验中奖池带徽章与"实验详情",行操作可"创建 AB 实验"(复用配置表单 + 实验字段),
管理弹窗支持放量滑调、白名单、分组指标对比与结束实验二次确认。
## 五、标准操作流程(以 lalu super_lucky 升级为例)
1. 配置页 super_lucky 行 → "创建 AB 实验":填新配置内容、实验名,流量先设 0%白名单加测试账号arm=treatment
2. 测试账号真机验证:送幸运礼物应命中新版本(抽奖记录 rule_version = treatment 版本)。
3. 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户(结算口径差异需累计足够流水才有意义)→ 20% → 50%。
4. 异常随时"暂停"(全量回对照组,不销毁分组);修复后恢复。
5. 数据达标 → "结束实验",选获胜方;系统重发布获胜配置为新版本并全量收敛。
## 六、发布与排障
- **迁移**:生产按既有流程手动执行 `006_ab_experiments.sql``mysql_auto_migrate` 不建表);本地 docker 新库由 initdb 覆盖,
旧库需手动执行 006。测试环境同理。
- 分组核对 SQL
`SELECT rule_version, COUNT(*), SUM(coin_spent), SUM(effective_reward_coins) FROM lucky_draw_records
WHERE app_code=? AND pool_id=? AND created_at_ms>=? GROUP BY rule_version;`
- 某用户实际在哪组:查 `lucky_gift_experiment_overrides`;无 override 时按 salt 哈希(域函数 `ExperimentBucketPPM`
可用 strategy-sim 或临时 Go 片段复算)。
- 真库回归:`LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN=... go test ./services/lucky-gift-service/internal/storage/mysql/ -run Experiment`

View File

@ -6,9 +6,9 @@
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐普通/高阶充值门槛及六项金额上限,才允许启用;独立的“累计消费达到门槛就获得大奖”机制已经停用,不再是发布必填项,也不能绕过 RTP 资格
- 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id``fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close不得用 `session_id`/`command_id` 伪造设备。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`可信 `device_id`、owner `paid_at_ms` 缺失,或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时,整次动态开奖 fail-close`user_registered_at_ms` 缺失或晚于扣费时间时普通开奖继续,但本轮补偿大奖资格 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
@ -38,7 +38,8 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
- 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,该抽完成后立即关闭并结算当前大盘轮次,下一抽进入新轮次。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- 大盘轮次按 `app_code + pool_id + rule_version` 独立结算。大盘在 9:00 重置、10:00 再次重置时10:00 形成的资格只读取刚关闭的 9:0010:00 大盘 RTP以及同一个 9:0010:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。
### 2.3 原图概率表不能作为生产默认概率
@ -55,7 +56,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。
`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x``200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表
`dynamic_v3` 的安全发布草稿采用 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`普通概率表可以配置 `200x/500x/1000x`,也可以与大奖集合使用相同倍率,但这些普通高倍档必须与其他普通档一起纳入静态 EV 和控制带校验,不能因为倍率也出现在大奖集合就从成本计算中扣除。运行时为同倍率建立独立的 `multiplier_*` 普通 tier 和 `jackpot_*` 补偿 tier前者按阶段概率随机后者普通权重固定为 0只能通过大奖资格路径命中
## 3. 一次抽奖的确定顺序
@ -65,10 +66,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
3. `lucky-gift-service` 只消费上述可信事实不直连钱包库不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`
5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。
5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
@ -126,33 +127,44 @@ adjusted_weight = base_weight × water_factor × recharge_factor
原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。
奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。
## 6. 两种大奖机制
## 6. 大奖资格:已结算轮次的下一次尝试
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 `multiplier_*``jackpot_*` tier ID不能用倍率反推命中来源。若产品需要补偿大奖非等权必须先新增显式权重契约和成本验收不能借用普通奖档概率猜算。
### 6.1 机制一RTP 亏损补偿大奖
### 6.1 RTP、注册年龄和安全门必须同时通过
本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:
大盘轮次结算时固化本轮快照;用户下一次送出幸运礼物时,只有以下条件同时通过,才允许从当前可支付大奖集合随机选择:
- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%
- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%
- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 96%
- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5
- 刚关闭的大盘轮次 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%;等于 98% 可以继续判断,超过 98% 直接失败,保证平台不因补偿大奖扩大亏损;
- 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过;
- 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长;
- `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96%
- 用户本 UTC 日通过补偿路径命中的大奖总次数 `< max_jackpot_hits_per_user_day`,默认 5所有 `jackpot_*` 倍率合计计数,`multiplier_*` 普通命中不计数;
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”
用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过
### 6.2 机制二:消费里程碑大奖
滚动 48 小时必须先汇总总额再相除,不允许对小时 RTP、单次 RTP或若干分段 RTP 做算术平均。例如 48 小时消费 10,000 金币、返奖 9,500 金币RTP 为 95%。分母为 0 表示没有有效样本,不是 0% RTP不能获得大奖资格。
- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
### 6.2 注册时间和“只玩最后 5 分钟”边界
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
- 注册 47 小时的用户,即使本轮 RTP=95%、已有流水的滚动 RTP=95%,也因账号未满 48 小时而不通过。
- 注册恰满 48 小时即满足成熟度边界;不需要“玩满 48 小时”。
- 用户已注册超过 48 小时,但前 47 小时 55 分钟都没玩、最后 5 分钟消费 100 金币并返奖 95 金币时,最近 48 小时的有效总流水就是 100/95滚动 RTP=95%。若这 5 分钟也正好构成刚关闭的用户轮次,则两条用户 RTP 门都通过。
- 注册时间必须来自 user owner 的 `created_at_ms` 可信事实。内部链路缺失或恢复记录无法还原时,大奖资格 fail-close不能用首次送礼时间、请求到达时间或外部自报的活跃时长兜底。
### 6.3 一轮资格只尝试一次
资格属于一个已关闭的 `app_code + pool_id + rule_version + window_index + user_id` 轮次,只能用于该用户的下一次子抽:
- 能支付且风控允许:随机命中一个可支付大奖,资格消费;
- 奖池不足、达到日次数上限或任一风控不足:不强发大奖,资格仍消费,本抽继续走普通概率和保底;
- 用户迟迟不送礼:该轮资格可以等到下一次个人送礼再尝试,但不会因后续关闭更多轮次而叠加多个机会;只保留当前规则版本下最新待尝试轮次,旧轮次失效;
- 规则版本改变:旧版本资格不跨版本继承。
独立的累计消费门槛/里程碑 token 机制在新版规则中停用。历史字段只用于旧数据兼容读取,生产 `dynamic_v3` 不产生、不优先、不保留这类 token也不能把消费金额作为第三条获奖通道。
## 7. 六维风控与时间口径
@ -171,20 +183,20 @@ adjusted_weight = base_weight × water_factor × recharge_factor
设备日 scope 只接受 user-service 已落库 session 的 `device_id`,由签名 JWT 贯通到 room 和 lucky旧 token/旧 command 缺字段时 `fixed_v2` 可继续重放,但 `dynamic_v3` 必须拒绝开奖,不能回退到 `session_id` 或每单变化的 `command_id`。外部 App 的 device ID 由已认证调用方负责稳定提供V3 缺失同样拒绝;服务只保存 App 内稳定 hash避免暴露原始标识。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 `time.Local`、容器时区或客户端时区切分。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 48 小时都不得使用 `time.Local`、容器时区或客户端时区切分。滚动 48 小时以上一个已关闭轮次的 `closed_at_ms` 为上界,不能用用户下一次送礼的到达时间漂移窗口。
## 8. 批量 N 次的语义
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1三资金桶使用联合累计配额保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数;不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager``i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、已结算轮次资格、日大奖次数和风控计数;不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`该抽完成后立即关闭大盘和参与用户的同轮次快照;第 `i+1` 抽先使用这个已关闭轮次判断该用户的一次性资格,再进入新窗口。不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record重放整批不能跳过、补写或重复支付其中一抽。
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id` 和真实扣费 `paid_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性,所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 `external_user_id``request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id`、真实扣费 `paid_at_ms` 和账号真实 `user_registered_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明这些调用方事实,所以接入方必须在自身认证/请求签名边界内把它们绑定到 `external_user_id`。owner 不得用 `request_id`、首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
## 9. 接口和配置字段
@ -200,13 +212,14 @@ adjusted_weight = base_weight × water_factor × recharge_factor
`luckygift.v1.LuckyGiftMeta` 接收:
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
- `user_registered_at_ms`(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时)
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
- `recharge_7d_coins`
- `recharge_30d_coins`
- `last_recharged_at_ms`
- `gift_income_coins`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;`ExternalGiftDrawRequest.device_id` 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;内部送礼请求的注册时间由 gateway 查询 user owner 后固化到可恢复 room command`ExternalGiftDrawRequest.device_id``user_registered_at_ms` 由外部认证调用方承担真实性责任。V3 对两个入口都不提供 session/command/首次送礼时间 fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
### 9.2 不可变规则字段
@ -218,7 +231,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` |
| 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` |
| 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_day_rtp_max_ppm`, `jackpot_user_72h_rtp_max_ppm`, `jackpot_spend_threshold_coins`, `max_jackpot_hits_per_user_day` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_round_rtp_max_ppm`, `jackpot_user_48h_rtp_max_ppm`, `max_jackpot_hits_per_user_day`;历史 `jackpot_spend_threshold_coins` 在新版规则中停用 |
| 六维风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` |
| 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` |
@ -231,18 +244,19 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
| `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 |
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖、`started_at_ms/closed_at_ms``open/closed/underpaid` 状态关闭时间是48小时统计与注册年龄判断的固定锚点 |
| `lucky_user_rtp_windows` | 每个用户在同一大盘 `rule_version + window_index` 内的总消费/总返奖,以及 `pending/attempted/ineligible/expired` 资格状态;一轮只尝试一次 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[closed_at+1ms-48h,closed_at+1ms)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 |
| `lucky_user_states` | 累计抽数、等价流水和 `loss_streak`;历史 token 字段仅兼容旧记录,新版规则不产生或消费 |
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 |
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。同倍率双来源必须保存实际 `selected_tier_id` 和该来源的实际选择权重:普通奖记录调整后的阶段权重,补偿大奖记录大奖集合内权重,不能把补偿 tier 的普通权重 0 写成命中权重
状态边界:
@ -267,12 +281,13 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动 |
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间两个用户RTP严格<96%且AND大盘允许=98%只玩5分钟按实际总额计算零样本不触发 |
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次补偿大奖可中,第 6 次补偿大奖被阻断,新 UTC 日重新计数;普通奖不占次数 |
| 同倍率双来源 | 100/200/500/1000x 分别配置普通与补偿路径,并在达到日上限后再抽普通 1000x | 每个倍率的普通命中都不计大奖、补偿命中都计数补偿达到上限后同倍率普通奖仍可命中tier ID 和选择权重可区分来源 |
| 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP再普通安全门永远最后裁决 |
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言 |
@ -289,6 +304,8 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
```
2026-07-15 按上述默认规模生成的完整机器可读报告保存在 `幸运礼物动态策略V3模拟结果-20260715.json`;旧的 2026-07-12 文件只代表历史 day/72h 规则,不得作为本次 48 小时版本的验收依据。
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足
- `nominal_probability_table.expected_multiplier=26.5`
@ -301,6 +318,6 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close
3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 时整次开奖 fail-close缺失/无效 `user_registered_at_ms` 时只关闭补偿大奖资格,普通开奖继续
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@
对接 App 负责:
- 调用前完成用户扣费。
- 从自己的账务和用户主数据提供真实的扣费时间、稳定设备标识和账号注册时间。
- 在自己的鉴权或请求签名边界内绑定上述业务事实,不能让 App 或 Web 客户端直接伪造。
- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`
- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。
@ -43,9 +45,19 @@ GET https://lucky-api.global-interaction.com/healthz/ready
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
2. 确认该 App 的幸运礼物规则已启用。
3. 对接 App 必须先扣费,再调用抽奖接口。
4. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
4. 如果使用 `dynamic_v3`,对接 App 必须能够提供真实 `paid_at_ms`、稳定 `device_id` 和真实 `user_registered_at_ms`
5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
当前版本的 HTTP 入口使用 `app_code` 白名单,尚未启用 Authorization Token 或请求签名,因此必须仅由已开通的对接 App 服务端调用。
### 3.1 真实性与信任边界
当前 HTTP 入口只校验 `app_code` 是否在白名单,尚未启用 Authorization Token 或平台侧请求签名。白名单只能决定“这个 App 是否已开通”,不能独立证明某次请求中的用户、设备、扣费时间和注册时间是真实的。
对接 App 必须在自己的服务端边界内完成以下保障:
- `external_user_id` 来自已登录用户,不直接信任客户端自报值。
- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。
- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。
- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。
## 4. 发送幸运礼物
@ -67,8 +79,11 @@ X-Request-Id: trace-20260715-000001
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -85,8 +100,11 @@ X-Request-Id: trace-20260715-000001
| `app_code` | string | 是 | 去除首尾空格后 132 字符 | 平台分配的 App 编码;按小写存储和校验。 |
| `request_id` | string | 是 | 去除首尾空格后 1128 字符 | 对接 App 生成的业务幂等 ID建议直接使用送礼订单号或 UUID。 |
| `external_user_id` | string | 是 | 去除首尾空格后 1128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 |
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 |
| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 |
| `user_registered_at_ms` | int64 | `dynamic_v3` 业务必填 | `> 0`Unix epoch milliseconds | 用户账号的真实创建时间,必须从对接 App 的用户主数据获取。技术上缺失不会阻断普通抽奖,但系统会按不满足注册 48 小时处理,关闭该用户的 RTP 补偿大奖资格。 |
| `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 |
| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 |
@ -96,7 +114,9 @@ X-Request-Id: trace-20260715-000001
total_amount = gift_count * unit_amount
```
请求体不得传入 `total_amount``pool_id``paid_at_ms`。奖池由平台规则选择,扣费完成时间由网关按 UTC 服务端时间写入。接口会拒绝所有未定义的顶层字段。
请求体不得传入 `total_amount``pool_id``total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。
`dynamic_v3` 下,缺失 `paid_at_ms``device_id` 会使整次抽奖失败;缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。`fixed_v2` 为历史接入保留了缺省兼容,对接方不应依赖该兼容,否则规则切换到 `dynamic_v3` 后会失败或丢失大奖资格。
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
@ -183,7 +203,7 @@ app_code + request_id
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
| 500 | 50000 | 抽奖服务超时或内部处理失败 | 保持原 `request_id` 重试;不得重新扣费。 |
| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` / `device_id` 缺失 | 先本地校验 V3 业务事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 |
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code``0`
@ -198,8 +218,11 @@ curl --request POST \
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -211,17 +234,22 @@ curl --request POST \
## 8. 标准处理流程
1. 对接 App 生成全局唯一的 `request_id`
2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`
3. 对接 App 调用幸运礼物接口。
4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。
1. 对接 App 在已鉴权的服务端上确定 `external_user_id`、稳定 `device_id` 和用户主数据中的 `user_registered_at_ms`
2. 对接 App 生成全局唯一的 `request_id`
3. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`,并以扣费成功事实的 UTC 毫秒时间作为 `paid_at_ms`
4. 对接 App 调用幸运礼物接口,一次传入上述业务事实。
5. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
6. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
7. 任何未知结果都使用原 `request_id` 和原始业务事实重试;禁止重复扣费或重新生成抽奖业务号。
## 9. 联调检查清单
- [ ] `app_code` 已加入幸运礼物网关白名单。
- [ ] 规则已启用,并确认金币单位一致。
- [ ] `dynamic_v3` 请求中的 `paid_at_ms` 来自真实扣费事实,不是当前时间的临时填充值。
- [ ] `device_id` 已绑定已鉴权设备且跨请求稳定,不用请求号或会话 token 代替。
- [ ] `user_registered_at_ms` 来自用户主数据已验证缺失时普通抽奖可继续、RTP 补偿大奖资格会关闭。
- [ ] 调用能力仅保留在对接 App 服务端,上述业务事实已纳入自身鉴权或请求签名边界。
- [ ] 正常送礼可获得 HTTP 200 / `code=0`
- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。
- [ ] 模拟超时后只重试抽奖请求,不重复扣费。

View File

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

View File

@ -8,6 +8,7 @@
- 默认统计时区是 `UTC`;传 `stat_tz=Asia/Shanghai` 时按中国自然日查询统计读模型。
- 国家和区域只使用事件里的国家/区域字段或统计服务保存的用户维度不用服务器所在地、IP 或本地时区推断。
- 查询只读 `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 字段名,展示名改为“用户金币数”。
## 字段口径
@ -19,7 +20,7 @@
| `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 |
| `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 |
| `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` 后按用户去重。 |
| `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 |
| `recharge_usd_minor` | 总充值 | `WalletRechargeRecorded.usd_minor_amount` 累加,单位是美元最小单位。 |
@ -64,7 +65,7 @@
| `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_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`。 |
| `up_value_usd_minor` | UP 值 | 当前与 ARPPU 同口径。 |
@ -79,9 +80,9 @@
| 字段 | 口径 |
| --- | --- |
| `retention` | `registered_users` 为注册 cohort`day1/day7/day30_users` 为注册后第 1/7/30 天有活跃的用户rate 为对应用户数除以注册用户数。 |
| `daily_series` | 按日返回本页主要字段;`coin_total``salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;`coin_total``salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 |
| `daily_country_breakdown` | 按日 + 国家返回主要字段,用于国家弹窗明细。 |
| `daily_series` | 按日返回本页主要字段;DAU 使用 canonical Social`coin_total``salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;DAU 维度在 canonical 归并后确定,`coin_total``salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 |
| `daily_country_breakdown` | 按日 + 国家返回主要字段,DAU 与顶部/按日使用同一 canonical Social 数据源。 |
| `game_ranking` | 按 `platform_code/game_id` 聚合游戏流水、返奖、退款、玩家数、利润和利润率。 |
| `lucky_gift_pools` | 按 `pool_id` 聚合 lucky 礼物流水、返奖、利润、返奖率和利润率。 |
| `report_metric_sources` | 返回字段来源和口径说明,供前端展示或排查。 |
@ -92,3 +93,4 @@
- 平台发放的礼物价值同理,需要 `ResourceGranted/ResourceGroupGranted` 事件携带可统计的资源金币价值和来源;当前只可靠计入 COIN 入账和已存在的 `Wallet*RewardCredited` 金币奖励。
- 装扮价值不进入 `platform_grant_coin``output_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') &&
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`,不得合并不同版本的权益数组 |
| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 |
购买、佩戴体验卡、卸下体验卡成功时,`data.state``GET /api/v1/vip/me``data.state` **完全同构**不是局部补丁。Flutter 必须整体替换 VIP store禁止按字段合并。若灰度期间收到缺少下文任一顶层字段的旧节点响应应放弃该响应中的 `state` 并立即重拉 `/vip/me`
### 1.4 Fami 初始等级权益
下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。
@ -89,6 +105,52 @@ class VipEntitlementState {
- `request_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 套餐
### 接口地址
@ -144,7 +206,23 @@ class VipEntitlementState {
"execution_scope": "room",
"auto_equip": false,
"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
@ -165,17 +243,27 @@ class VipEntitlementState {
},
"state": {
"paid_vip": {
"user_id": 10001,
"level": 3,
"name": "VIP3",
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"name": "VIP3",
"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_benefits": [
@ -185,9 +273,27 @@ class VipEntitlementState {
}
],
"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": {
"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
}
}
@ -235,11 +341,13 @@ class VipEntitlementState {
},
"state": {
"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
},
@ -255,14 +363,20 @@ class VipEntitlementState {
"duration_ms": 2592000000,
"effective_at_ms": 1780100000000,
"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": {
"user_id": 10001,
"level": 8,
"name": "VIP8",
"active": true,
"started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"updated_at_ms": 1780100000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
@ -279,13 +393,22 @@ class VipEntitlementState {
"execution_scope": "room",
"auto_equip": false,
"sort_order": 290,
"metadata_json": ""
"metadata_json": "",
"presentation": null
}
],
"evaluated_at_ms": 1780101000000,
"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": {
@ -293,6 +416,9 @@ class VipEntitlementState {
"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
}
}
@ -346,35 +472,81 @@ class VipEntitlementState {
},
"coin_spent": 4000,
"coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [],
"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
},
"state": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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,
"effective_vip": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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": [
{
"benefit_code": "custom_room_background",
"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 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
- 低等级购买:服务端拒绝。
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`
- 钱包缓存只使用 `coin_balance`。应用时比较 `version`,低版本响应不得覆盖本地高版本余额;`coin_balance_after` 仅保留旧客户端兼容。
- 历史幂等订单在升级前没有记录版本时可能返回 `coin_balance.version=0`Flutter 必须调用现有钱包余额接口刷新,不得把版本 0 写入余额 store。
## 6. VIP 体验卡
@ -473,22 +647,92 @@ class VipEntitlementState {
"duration_ms": 1728000000,
"effective_at_ms": 1780200000000,
"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": {
"app_code": "fami",
"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": {
"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": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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
}
@ -516,22 +760,67 @@ class VipEntitlementState {
"unequipped": true,
"program_config": {
"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": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 3,
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"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
}
@ -543,7 +832,7 @@ class VipEntitlementState {
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
## 7. VIP 通知开关
## 7. VIP 功能开关
### 7.1 查询开关
@ -568,6 +857,9 @@ class VipEntitlementState {
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000
},
"evaluated_at_ms": 1780300001000
@ -588,7 +880,10 @@ class VipEntitlementState {
```json
{
"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,
"room_entry_notice_enabled": false,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300010000
},
"server_time_ms": 1780300010000
@ -612,6 +910,8 @@ class VipEntitlementState {
}
```
5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时均默认 `true`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定Flutter 不能只看开关。
## 8. 每日 VIP 金币返现
### 8.1 查询当前返现
@ -1050,3 +1350,35 @@ Flutter 将 `action_param` 解析为 JSON打开页面后调用 `/vip/coin-reb
接口:`GET /api/v1/im/usersig`
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`、国家维度聚合 |
| 活跃用户 | `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 天存在活跃记录的用户数 / 注册用户数 |
| 7 日留存 | 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数 |
| 30 日留存 | 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数 |
@ -129,6 +130,8 @@ flowchart LR
| `statistics_event_consumption` | 消费幂等表 | `(app_code, source, event_id)` |
| `stat_app_day_country` | App/UTC 日/国家总览 | `(app_code, stat_day, country_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_recharge_day_payers` | 日付费用户去重 | `(app_code, stat_day, user_id)` |
| `stat_lucky_gift_day_payers` | 幸运礼物日付费用户去重 | `(app_code, stat_day, country_id, user_id)` |

View File

@ -84,8 +84,8 @@
- 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。
- 奖励结算生成 `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
@ -333,25 +333,34 @@ flowchart LR
1. 送礼时钱包给主播增加 `GIFT_POINT`
2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`
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`,审核拒绝解冻,审核通过后等待人工转账。
5. 人工转账完成后把冻结余额出账,状态改为 `paid`
4. 主播提现时先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,申请进入运营初审。
5. 运营通过只把申请转入财务终审,冻结金额保持不变;运营拒绝立即释放 frozen 并结束申请。
6. 财务结合人工转账凭证终审:通过后扣减 frozen拒绝后释放 frozen。
提现状态机:
```mermaid
stateDiagram-v2
[*] --> pending_review
pending_review --> rejected
rejected --> refunded
pending_review --> approved
approved --> paying
paying --> paid
paying --> pay_failed
pay_failed --> approved
[*] --> operations_pending
operations_pending --> operations_rejecting
operations_rejecting --> operations_rejecting: retry rejection
operations_rejecting --> operations_rejected: release + notice + finalize
operations_rejected --> released
operations_pending --> finance_pending
finance_pending --> finance_rejected
finance_rejected --> released
finance_pending --> approved
approved --> settled
```
提现申请时必须从 `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
当前开发阶段直接使用新的 wallet RPC 契约:
@ -365,7 +374,7 @@ stateDiagram-v2
- `ExchangeDiamond`: 钻石兑换金币或美元余额。
- `CreditRewardBalance`: 结算任务给主播发美元余额奖励。
- `CreateWithdrawRequest`: 主播提现申请并冻结余额。
- `ReviewWithdrawRequest`: 后台审核提现
- `ReviewWithdrawRequest`: 后台按运营初审、财务终审推进提现;运营通过不调用钱包,任一拒绝调用 release财务通过调用 settle
- `MarkWithdrawPaid`: 人工打款后确认出账。
外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段不做历史兼容保留修改 `api/proto` 后必须运行 `make proto``go test ./...`
@ -500,8 +509,9 @@ stateDiagram-v2
提现:
- 提现申请成功后 `identity salary wallet.available_amount` 减少,`frozen_amount` 增加。
- 审核拒绝后冻结金额回到 available。
- 打款完成后 frozen 减少并写出账分录。
- 运营审核通过后冻结金额保持不变,申请进入财务审核。
- 运营或财务拒绝后冻结金额回到 available。
- 财务确认打款并通过后 frozen 减少并写出账分录。
- 打款失败后状态可重试,不丢失冻结关系和审核上下文。
验证命令:
@ -535,6 +545,6 @@ docker compose config
- 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。
- `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 是可提现负债,调账和奖励发放需要更高权限和审计。
- 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态
- 提现人工打款前必须冻结余额并完成运营初审;运营通过不得提前扣减 frozen财务终审通过才 settle任一拒绝都 release
- 钱包事件投递使用 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
import (
@ -17,6 +17,7 @@ import (
const (
BoardSent = "sent"
BoardReceived = "received"
BoardGame = "game"
PeriodToday = "today"
PeriodWeek = "week"
@ -33,8 +34,8 @@ const (
var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only.
// Store keeps user leaderboard buckets in Redis zsets plus per-user hashes.
// Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection.
type Store struct {
client *redis.Client
keyPrefix string
@ -52,6 +53,16 @@ type GiftEvent struct {
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.
type Query struct {
AppCode string
@ -119,14 +130,6 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
}
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
keys := []string{s.dedupeKey(event.AppCode, event.EventID)}
args := []any{
strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10),
strconv.FormatInt(event.GiftValue, 10),
strconv.FormatInt(event.GiftCount, 10),
strconv.FormatInt(event.OccurredAtMS, 10),
}
updates := make([]leaderboardUpdate, 0, 6)
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates,
@ -134,17 +137,27 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
)
}
args = append(args, strconv.Itoa(len(updates)))
for _, update := range updates {
keys = append(keys, update.scoreKey, update.itemKey)
args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10))
return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.GiftValue, event.GiftCount, event.OccurredAtMS, updates)
}
// 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()
if err != nil {
return false, err
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
updates := make([]leaderboardUpdate, 0, 3)
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.
@ -225,6 +238,8 @@ func NormalizeBoardType(raw string) string {
return BoardSent
case BoardReceived, "receive", "receiver", "gift_received", "user_received":
return BoardReceived
case BoardGame, "games", "gaming":
return BoardGame
default:
return ""
}
@ -308,6 +323,37 @@ func normalizeGiftEvent(event GiftEvent) GiftEvent {
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 {
query.AppCode = appcode.Normalize(query.AppCode)
query.BoardType = NormalizeBoardType(query.BoardType)
@ -385,13 +431,13 @@ func int64FromHash(values map[string]string, key string) int64 {
return value
}
var applyGiftEventScript = redis.NewScript(`
var applyValueEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then
return 0
end
redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3])
local value_delta = tonumber(ARGV[2])
local count_delta = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do
@ -399,8 +445,8 @@ for i = 0, updates - 1 do
local item_key = KEYS[3 + i * 2]
local member = ARGV[6 + i * 2]
local ttl_seconds = tonumber(ARGV[7 + i * 2])
local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value)
redis.call("HINCRBY", item_key, "gift_count", gift_count)
local current_value = redis.call("HINCRBY", item_key, "gift_value", value_delta)
redis.call("HINCRBY", item_key, "gift_count", count_delta)
redis.call("HINCRBY", item_key, "transaction_count", 1)
local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
local last_gift_at_ms = previous_last

View File

@ -1,6 +1,9 @@
package userleaderboard
import (
"context"
"fmt"
"os"
"testing"
"time"
)
@ -34,6 +37,9 @@ func TestNormalizeAliases(t *testing.T) {
if NormalizeBoardType("gift_sent") != BoardSent {
t.Fatal("gift_sent should normalize to sent")
}
if NormalizeBoardType("gaming") != BoardGame {
t.Fatal("gaming should normalize to game")
}
if NormalizePeriod("monthly") != PeriodMonth {
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" {
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"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"),
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"),
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"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
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"),
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),

View File

@ -18,6 +18,10 @@ const (
Conflict Code = "CONFLICT"
// RoomClosed 表示房间已被后台关闭,客户端不能再进入。
RoomClosed Code = "ROOM_CLOSED"
// RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。
RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN"
// RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。
RoomRPSChallengeExpired Code = "ROOM_RPS_CHALLENGE_EXPIRED"
// Unauthorized 表示身份不存在或认证失败。
Unauthorized Code = "UNAUTHORIZED"
// PermissionDenied 表示已认证但没有执行权限。
@ -102,6 +106,22 @@ const (
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
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 Code = "VIP_RECHARGE_REQUIRED"
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。

View File

@ -135,6 +135,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(CPAlreadyExistsOther),
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",
code: RegionMismatch,
@ -143,6 +151,22 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(RegionMismatch),
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 {

View File

@ -29,12 +29,14 @@ SQL_FILES=(
)
# initdb only creates missing tables; a reused local MySQL volume therefore
# needs incremental migrations as well. Keep the owned user-service migrations
# in this local bootstrap path so authentication schema changes (for example
# login_audit client-version fields) cannot leave a locally logged-in client
# receiving a misleading 401 during token refresh.
USER_MIGRATION_FILES=(
# needs incremental migrations as well. Keep owner migrations here when reused
# volumes need more than CREATE TABLE IF NOT EXISTS can provide; otherwise new
# binaries may start against a structurally valid-looking but stale table.
INCREMENTAL_MIGRATION_FILES=(
"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
@ -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}"
done
for sql_file in "${USER_MIGRATION_FILES[@]}"; do
for sql_file in "${INCREMENTAL_MIGRATION_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
continue

View File

@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS user_vip_settings (
user_id BIGINT NOT NULL COMMENT '用户 ID',
room_entry_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',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
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"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi"
externaladminmodule "hyapp-admin-server/internal/modules/externaladmin"
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -103,6 +104,7 @@ import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
@ -348,10 +350,12 @@ func main() {
appRegistryService,
userclient.NewGRPC(userConn),
databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
databimodule.WithLuckyGiftDailyStats(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler)
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler).
BindUserClient(userclient.NewGRPC(userConn))
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
@ -381,7 +385,24 @@ func main() {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(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),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),

View File

@ -8,6 +8,11 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
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"
@ -80,6 +85,16 @@ cors_allowed_origins:
- "https://api-acc.global-interaction.com"
refresh_cookie_secure: true
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 时打开;生产常驻进程不要每次启动重放 seed。
enabled: false

View File

@ -8,6 +8,9 @@ log:
include_response_body: false
max_payload_bytes: 2048
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"
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"
@ -80,6 +83,16 @@ cors_allowed_origins:
- "http://127.0.0.1:7002"
refresh_cookie_secure: false
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:
enabled: 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:transaction-view` | `menu` | 交易流水 |
| `wallet:adjust` | `button` | 后台调账 |
| `withdrawal:view` | `menu` | 提现记录 |
| `withdrawal:review` | `button` | 提现审核 |
| `withdrawal:export` | `button` | 提现导出 |
| `operations-withdrawal:view` | `menu` | 用户提现运营审核列表;不包含 `operations_status=skipped` 的历史单 |
| `operations-withdrawal:audit` | `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`
- 负责运营、团队、房间、资源、活动和游戏的日常管理。
- 负责用户提现运营初审,不拥有用户提现财务终审权限。
- 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。
### 运营专员
@ -357,6 +373,7 @@
`operations-specialist`
- 可执行用户、团队结构、房间、资源和常规运营动作。
- 可执行用户提现运营初审,不拥有用户提现财务终审权限。
- 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。
### 产品负责人
@ -377,13 +394,13 @@
`finance-lead`
- 只进入财务看板、充值对账、提现审和房内猜拳订单。
- 只进入财务看板、充值对账、用户提现财务终审和房内猜拳订单;不参与运营初审
### 财务专员
`finance-specialist`
- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。
- 当前与财务负责人使用同一权限矩阵,负责用户提现财务终审且不参与运营初审;保留独立角色用于人员分工和后续数据范围配置。
固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor``readonly` 和用户自建角色不会被删除或重置。

View File

@ -3,6 +3,8 @@ package cache
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
@ -18,14 +20,54 @@ type Redis struct {
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) {
if !cfg.Enabled {
return nil, nil
}
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
ContextTimeoutEnabled: true,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
@ -88,6 +130,48 @@ return 0`
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 {
if r.prefix == "" {
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 (
"errors"
"fmt"
"net"
"os"
"strings"
"time"
@ -18,6 +19,7 @@ type Config struct {
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
TrustedProxies []string `yaml:"trusted_proxies"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
@ -34,6 +36,7 @@ type Config struct {
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
ExternalAdmin ExternalAdminConfig `yaml:"external_admin"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
@ -57,6 +60,21 @@ type MigrationConfig struct {
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 {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
@ -214,6 +232,7 @@ func Default() Config {
Environment: "local",
Log: logging.DefaultConfig(),
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",
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",
@ -280,6 +299,14 @@ func Default() Config {
},
RefreshCookieSecure: false,
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{
Enabled: true,
SeedDemoData: true,
@ -420,10 +447,51 @@ func (cfg *Config) Normalize() {
if cfg.Log.MaxPayloadBytes <= 0 {
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))
if cfg.RefreshCookieSameSite == "" {
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.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
@ -642,6 +710,14 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(cfg.HTTPAddr) == "" {
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) == "" {
return errors.New("mysql_dsn is required")
}
@ -666,6 +742,27 @@ func (cfg Config) Validate() error {
if cfg.RefreshTokenTTL <= 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)) {
case "lax", "strict", "none":
default:
@ -872,6 +969,9 @@ func (cfg Config) Validate() error {
if cfg.Redis.Enabled && strings.TrimSpace(cfg.Redis.Addr) == "" {
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 {
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" {
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" {
t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN)
}
@ -28,6 +31,12 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.RefreshTokenTTL != 7*24*time.Hour {
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 {
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) {
cfg, err := Load("")
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) {
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")

View File

@ -16,8 +16,16 @@ type Client interface {
ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error)
ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error)
UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error)
EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error)
ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error)
}
type GRPCClient struct {
@ -48,6 +56,14 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift
return c.client.GetLuckyGiftDrawSummary(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) {
return c.client.ListLuckyGiftUserProfiles(ctx, req)
}
func (c *GRPCClient) GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) {
return c.client.GetLuckyGiftUserProfile(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
return c.client.ListLuckyGiftPoolBalances(ctx, req)
}
@ -55,3 +71,27 @@ func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygi
func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
return c.client.AdjustLuckyGiftPoolBalance(ctx, req)
}
func (c *GRPCClient) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
return c.client.CreateLuckyGiftExperiment(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) {
return c.client.ListLuckyGiftExperiments(ctx, req)
}
func (c *GRPCClient) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
return c.client.UpdateLuckyGiftExperiment(ctx, req)
}
func (c *GRPCClient) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
return c.client.SetLuckyGiftExperimentOverrides(ctx, req)
}
func (c *GRPCClient) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
return c.client.EndLuckyGiftExperiment(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) {
return c.client.ListLuckyGiftDailyStats(ctx, req)
}

View File

@ -177,6 +177,13 @@ type ResolveDisplayUserIDRequest struct {
DisplayUserID string
}
// ResolveAdminUserIdentifierRequest 仅供后台精确搜索永久默认短号、当前有效展示号或系统长 user_id。
type ResolveAdminUserIdentifierRequest struct {
RequestID string
Caller string
UserIdentifier string
}
type UserIdentity struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
@ -437,6 +444,19 @@ func (c *GRPCClient) ResolveDisplayUserID(ctx context.Context, req ResolveDispla
return fromProtoUserIdentity(resp.GetIdentity()), nil
}
// ResolveAdminUserIdentifier 调用 user-service owner 的后台专用解析契约admin-server 不读取用户库,
// 也不会为了搜索默认短号而改变客户端 ResolveDisplayUserID 的 active-only 语义。
func (c *GRPCClient) ResolveAdminUserIdentifier(ctx context.Context, req ResolveAdminUserIdentifierRequest) (*UserIdentity, error) {
resp, err := c.identityClient.ResolveAdminUserIdentifier(ctx, &userv1.ResolveAdminUserIdentifierRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserIdentifier: req.UserIdentifier,
})
if err != nil {
return nil, err
}
return fromProtoUserIdentity(resp.GetIdentity()), nil
}
func fromProtoRegisterRiskConfig(config *userv1.RegisterRiskConfig) *RegisterRiskConfig {
if config == nil {
return nil

View File

@ -33,6 +33,7 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*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)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, 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)
}
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) {
return c.client.EquipUserResource(ctx, req)
}

View File

@ -1,12 +1,15 @@
package middleware
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin"
)
@ -15,6 +18,15 @@ type fakeAppAccessStore struct {
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) {
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/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"hyapp-admin-server/internal/service"
@ -20,6 +21,10 @@ type AppAccessStore interface {
AppAccessForUser(userID uint) (repository.AppAccess, error)
}
type AuthorizationStore interface {
CurrentAuthorizationForUser(userID uint) (repository.UserAuthorization, error)
}
const (
ContextUserID = "userID"
ContextUsername = "username"
@ -38,8 +43,8 @@ func CORS(cfg config.Config) gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
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-Expose-Headers", "X-Request-ID")
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, X-CSRF-Token")
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) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
@ -70,6 +75,13 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
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 "))
if err != nil {
response.Unauthorized(c, "访问凭证已失效")
@ -77,9 +89,27 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
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(ContextUsername, claims.Username)
c.Set(ContextPermissions, claims.Permissions)
c.Set(ContextUsername, authorization.Username)
c.Set(ContextPermissions, authorization.Permissions)
c.Next()
}
}
@ -154,8 +184,9 @@ func HasAnyPermission(c *gin.Context, codes ...string) bool {
return false
}
// HasPermission reads only the JWT-derived permission list. Database state is
// refreshed by login/refresh, so every request uses one consistent permission snapshot.
// HasPermission reads the request-scoped permission list resolved by
// 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 {
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
}
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
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 existing.Dirty {
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
}
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
}
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)
}
}
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 nil

View File

@ -37,8 +37,94 @@ const (
WithdrawalApplicationStatusPending = "pending"
WithdrawalApplicationStatusApproved = "approved"
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 {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"account"`
@ -640,31 +726,38 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
}
type UserWithdrawalApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"`
AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"`
AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"`
Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"`
ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"`
ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"`
AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
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"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
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;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"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"`
AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"`
AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"`
Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"`
ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"`
ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"`
AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"`
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"`
}
func (UserWithdrawalApplication) TableName() string {

View File

@ -27,8 +27,8 @@ const (
aslanMongoGoldWaterCollection = "user_gold_running_water_v2"
aslanMongoChunkSize = 800
// likei GoldOrigin 枚举以 code 形式落在 user_gold_running_water_v2.origin
// 历史上部分 code 带数字后缀(如 HOT_GAME_200所以统一用前缀匹配
// 幸运礼物返奖和币商出货各自拥有稳定、单一语义的 origin 前缀,因此这两类可按前缀聚合
// 游戏命名空间混有排行榜/活动奖励,游戏流水必须走下方 exact code 白名单,不能复用此前缀策略
aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD"
aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT"
)
@ -36,6 +36,41 @@ const (
var (
aslanMongoFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"}
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 {
@ -145,6 +180,12 @@ func (s *AslanMongoDashboardSource) AppCode() string {
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) {
if s == nil || s.db == nil {
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["daily_country_breakdown"] = current.DailyCountryBreakdown
goldWater := s.hasGoldWater(ctx)
legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter)
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()
applyExternalDashboardDeltas(response, current.Total, previous.Total)
response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
applyAslanMongoUnavailableFields(response)
applyAslanMongoGlobalGameOverview(response, current.Total, previous.Total, legacyGameTotals)
applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(response)
@ -296,6 +339,13 @@ type legacyMongoCoinFlow struct {
OutputCoin int64
}
// legacyMongoGameFlow 是 wallet_gold_count 已预聚合的全 App 游戏日快照。
// 该表没有 user_id/国家维度,因此它只能进入总览与每日趋势,绝不能回填到国家或区域行。
type legacyMongoGameFlow struct {
Turnover int64
Payout int64
}
func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric {
userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor
metric := externalDashboardMetric{
@ -332,11 +382,24 @@ func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDa
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 {
days []string
daySet map[string]struct{}
metrics map[string]map[string]*legacyMongoMetric
coinFlows map[string]*legacyMongoCoinFlow
gameFlows map[string]*legacyMongoGameFlow
rechargeUsers map[string]map[string]struct{}
// 各 loader 并行写入map 结构由锁保护;不同 loader 写 metric 的不同字段,无字段级竞争。
mu sync.Mutex
@ -347,6 +410,7 @@ func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid
daySet: map[string]struct{}{},
metrics: map[string]map[string]*legacyMongoMetric{},
coinFlows: map[string]*legacyMongoCoinFlow{},
gameFlows: map[string]*legacyMongoGameFlow{},
rechargeUsers: map[string]map[string]struct{}{},
}
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) },
)
}
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 {
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{}
dailyCountries := []map[string]any{}
totalFlow := legacyMongoMetric{}
totalCoinFlow := legacyMongoCoinFlow{}
totalGameFlow := legacyMongoGameFlow{}
countryTotals := map[string]*legacyMongoMetric{}
for _, day := range grid.days {
@ -497,6 +568,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin
totalCoinFlow.OutputCoin += coinFlow.OutputCoin
}
gameFlow := grid.gameFlows[day]
if gameFlow != nil {
totalGameFlow.Turnover += gameFlow.Turnover
totalGameFlow.Payout += gameFlow.Payout
}
// 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。
daySalary := dayFlow.SalaryUSDMinor
if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 {
@ -506,10 +582,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
totalFlow.add(&dayFlow)
totalFlow.SalaryUSDMinor = daySalary
dayRow := dayFlow.toExternal(coinFlow).toMap()
dayRow := dayFlow.toExternalWithGlobalFlows(coinFlow, gameFlow).toMap()
dayRow["label"] = day
dayRow["stat_day"] = day
applyAslanMongoUnavailableFields(dayRow)
applyAslanMongoGlobalGameFields(dayRow, gameFlow, legacyGameTotals)
applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge)
if !goldWater {
applyAslanMongoGoldWaterUnavailable(dayRow)
@ -548,8 +625,13 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country
return left > right
})
var gameTotal *legacyMongoGameFlow
if legacyGameTotals {
// 可用口径即使区间内没有流水也应返回真实 0nil 只表示数据源或筛选维度不支持。
gameTotal = &totalGameFlow
}
return aslanRangeOverview{
Total: totalFlow.toExternal(&totalCoinFlow),
Total: totalFlow.toExternalWithGlobalFlows(&totalCoinFlow, gameTotal),
DailySeries: dailySeries,
CountryBreakdown: countryRows,
DailyCountryBreakdown: dailyCountries,
@ -632,6 +714,71 @@ func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startD
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 {
day string
country string
@ -1306,7 +1453,7 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
// applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30
// 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D
//(与 Yumi 的 CDC 预聚合、statistics-service 口径一致。cohort 日无注册时基数保持 0
// (与 Yumi 的 CDC 预聚合、statistics-service 口径一致。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 {
location, err := time.LoadLocation(s.statTimezone)
@ -1597,6 +1744,19 @@ func dashboardDateNumber(day time.Time) string {
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
// driver 默认把嵌套文档解码成 bson.D直接断言 bson.M 会静默丢行(表现为全零无报错)。
func aslanMongoDocument(value any) bson.M {
@ -1763,8 +1923,9 @@ func aslanMongoInt64Chunks(values []int64, size int) [][]int64 {
return out
}
// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径;
// 游戏流水的 GoldOrigin code 枚举过杂(大量带后缀的游戏 code 与奖励类混在一起),未确认前不硬凑。
// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径。
// 游戏总量先置空,再由 applyAslanMongoGlobalGameFields 仅在“全 App + legacy 日快照可用”时恢复;
// 这样国家/区域行沿用同一序列化路径也不会意外泄漏全局游戏数据。
var aslanMongoUnavailableMetricFields = []string{
"coin_seller_stock_coin",
"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{
"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{
{"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*"},
@ -1864,6 +2052,29 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
mifaPaySource += " + legacy MySQL order_other_recharge amount"
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{
{"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"},
@ -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": "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": "game_turnover", "available": false, "missing_reason": "likei GoldOrigin 游戏 code 枚举过多且与奖励类混排,未确认口径前不聚合"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"},
{"field": "coin_total", "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"},
}
out := append(baseSources, legacySources...)
out = append(out, gameSources...)
return append(out, goldWaterSources...)
}

View File

@ -2,6 +2,7 @@ package dashboard
import (
"context"
"database/sql/driver"
"testing"
"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) {
match := bson.M{}
aslanMongoExcludeFreightGoldRecharge(match)
@ -340,11 +429,16 @@ func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(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, "game_turnover")
unavailable := aslanMongoDashboardMetricSources(false, false)
unavailable := aslanMongoDashboardMetricSources(false, false, false, false)
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) {
@ -358,7 +452,7 @@ func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) {
}
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 {
t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers)
}
@ -409,11 +503,11 @@ func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) {
"2026-06-11": {"EG": {3}},
}
earlier := map[string]map[string][]int64{
"2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort
"2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort
"2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort
"2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort
"2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50}
"2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort
"2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort
"2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort
"2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort
"2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50}
}
activeByDay := map[string]map[int64]struct{}{
"2026-06-10": {10: {}, 20: {}, 30: {}},

View File

@ -0,0 +1,162 @@
package databi
// 幸运礼物V3 section数据来自 lucky-gift-service 的按日读模型lucky_draw_pool_day_stats
// 不走 statistics-service。注入方式与 Finance overlay 同层appRequirements 组装 sections 时追加。
// 口径:只统计 dynamic_v3 策略族的房间抽奖(不含外部接入),返奖为用户可见金额(含未发放/失败行)。
import (
"context"
"strings"
"time"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
)
const luckyGiftV3SectionKey = "lucky_gift_v3"
// luckyGiftV3MaxDays 与 lucky-gift-service 侧的窗口上限对齐,防御异常长区间把零填充行撑爆。
const luckyGiftV3MaxDays = 400
// luckyGiftStatZone 与 lucky_draw_pool_day_stats 的北京日分桶一致FixedZone 规避容器缺 tzdata。
var luckyGiftStatZone = time.FixedZone("Asia/Shanghai", 8*60*60)
// LuckyGiftDailyStatsSource 是 databi 对幸运礼物统计的最小依赖面luckygiftadmin.Client 天然满足。
type LuckyGiftDailyStatsSource interface {
ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error)
}
// WithLuckyGiftDailyStats 注入幸运礼物按日统计源不注入时数据需求宽表没有幸运礼物V3板块。
func WithLuckyGiftDailyStats(source LuckyGiftDailyStatsSource, requestTimeout time.Duration) ServiceOption {
return func(s *Service) {
s.luckyGiftStats = source
s.luckyGiftTimeout = requestTimeout
}
}
// requirementsIncludesLuckyGiftV3 与 requirementsIncludesRevenue 同语义:空/all 视为命中。
func requirementsIncludesLuckyGiftV3(section string) bool {
switch strings.ToLower(strings.TrimSpace(section)) {
case "", "all", luckyGiftV3SectionKey:
return true
}
return false
}
// requirementsOnlyLuckyGiftV3 为真时整个请求只要幸运礼物板块;此时必须跳过 statistics-service——
// 它不认识该 section key会回退成全量六板块的最重查询路径。
func requirementsOnlyLuckyGiftV3(section string) bool {
return strings.ToLower(strings.TrimSpace(section)) == luckyGiftV3SectionKey
}
// luckyGiftV3RegionIDs 与 Finance overlay 相同的区域参数收敛:单区域查询归一化后覆盖多区域列表。
func luckyGiftV3RegionIDs(query RequirementsQuery, regions []RegionInfo, regionIDs []int64) []int64 {
if query.RegionID > 0 {
return []int64{normalizeRegionID(regions, query.RegionID)}
}
return regionIDs
}
// luckyGiftV3StatDays 按北京日展开 [startMS, endMS) 覆盖的日列表,用于零填充。
func luckyGiftV3StatDays(startMS, endMS int64) []string {
if endMS <= startMS {
return nil
}
start := time.UnixMilli(startMS).In(luckyGiftStatZone)
end := time.UnixMilli(endMS - 1).In(luckyGiftStatZone)
startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, luckyGiftStatZone)
endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, luckyGiftStatZone)
days := make([]string, 0, 32)
for day := startDay; !day.After(endDay) && len(days) < luckyGiftV3MaxDays; day = day.AddDate(0, 0, 1) {
days = append(days, day.Format("2006-01-02"))
}
return days
}
// luckyGiftV3Row 组装单行;比例在投入为 0 时输出 nil前端渲染 "--"),不伪造 0%。
func luckyGiftV3Row(statDay, label string, drawCount, turnoverCoin, payoutCoin float64) map[string]any {
row := map[string]any{
"stat_day": statDay,
"label": label,
"draw_count": drawCount,
"turnover_coin": turnoverCoin,
"payout_coin": payoutCoin,
"profit_coin": turnoverCoin - payoutCoin,
}
if turnoverCoin > 0 {
row["payout_rate"] = payoutCoin / turnoverCoin
row["profit_rate"] = (turnoverCoin - payoutCoin) / turnoverCoin
} else {
row["payout_rate"] = nil
row["profit_rate"] = nil
}
return row
}
func luckyGiftV3Columns() []map[string]any {
return []map[string]any{
{"key": "draw_count", "label": "抽奖次数", "type": "count", "tooltip": "dynamic_v3 策略下的房间付费抽奖次数(不含外部 App 接入)"},
{"key": "turnover_coin", "label": "投入金币", "type": "coin", "tooltip": "抽奖消耗金币"},
{"key": "payout_coin", "label": "返奖金币", "type": "coin", "tooltip": "用户可见返奖金币,含未发放/失败行,与幸运礼物后台汇总口径一致"},
{"key": "profit_coin", "label": "利润金币", "type": "coin", "tooltip": "投入金币 - 返奖金币"},
{"key": "payout_rate", "label": "返奖率", "type": "ratio", "tooltip": "返奖金币 / 投入金币(实际 RTP"},
{"key": "profit_rate", "label": "利润率", "type": "ratio", "tooltip": "利润金币 / 投入金币"},
}
}
// luckyGiftV3Section 拉取 dynamic_v3 按日统计并组装成数据需求 section。
// 数据按北京日零填充整个查询窗口v3 未开量时明确展示 0而不是空表让人误以为接口失败。
func (s *Service) luckyGiftV3Section(ctx context.Context, appCode string, query RequirementsQuery, regionIDs []int64) (map[string]any, error) {
if s.luckyGiftTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.luckyGiftTimeout)
defer cancel()
}
resp, err := s.luckyGiftStats.ListLuckyGiftDailyStats(ctx, &luckygiftv1.ListLuckyGiftDailyStatsRequest{
Meta: &luckygiftv1.RequestMeta{Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UnixMilli()},
StrategyVersion: "dynamic_v3",
StartMs: query.StartMS,
EndMs: query.EndMS,
RegionIds: regionIDs,
})
if err != nil {
return nil, err
}
byDay := map[string]*luckygiftv1.LuckyGiftDailyStat{}
for _, stat := range resp.GetStats() {
byDay[stat.GetStatDay()] = stat
}
days := luckyGiftV3StatDays(query.StartMS, query.EndMS)
rows := make([]map[string]any, 0, len(days))
var totalDraws, totalTurnover, totalPayout int64
for _, day := range days {
stat := byDay[day]
rows = append(rows, luckyGiftV3Row(day, day, float64(stat.GetDrawCount()), float64(stat.GetTurnoverCoin()), float64(stat.GetPayoutCoin())))
totalDraws += stat.GetDrawCount()
totalTurnover += stat.GetTurnoverCoin()
totalPayout += stat.GetPayoutCoin()
}
section := map[string]any{
"key": luckyGiftV3SectionKey,
"label": "幸运礼物V3",
// 幸运礼物没有用户身份/付费身份/新老用户维度,筛选恒为 all前端该 tab 不渲染筛选器。
"filters": map[string]any{"user_role": "all", "payer_type": "all", "new_user_type": "all"},
"strategy_version": "dynamic_v3",
"columns": luckyGiftV3Columns(),
"metric_definitions": []map[string]any{
{"metric": "draw_count", "definition": "dynamic_v3 策略规则版本下的房间付费抽奖次数"},
{"metric": "payout_coin", "definition": "用户可见返奖effective含未发放/失败行;口径与幸运礼物后台汇总一致"},
{"metric": "payout_rate", "definition": "汇总/平均值行的比例按区间合计重新计算,不是逐日比例的算术平均"},
},
"total": luckyGiftV3Row("total", "汇总", float64(totalDraws), float64(totalTurnover), float64(totalPayout)),
"daily_series": rows,
}
if dayCount := len(days); dayCount > 0 {
average := luckyGiftV3Row("average", "平均值",
float64(totalDraws)/float64(dayCount),
float64(totalTurnover)/float64(dayCount),
float64(totalPayout)/float64(dayCount),
)
section["average"] = average
}
return section, nil
}

View File

@ -0,0 +1,110 @@
package databi
import (
"context"
"testing"
"time"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
)
type fakeLuckyGiftDailyStatsSource struct {
lastReq *luckygiftv1.ListLuckyGiftDailyStatsRequest
stats []*luckygiftv1.LuckyGiftDailyStat
}
func (f *fakeLuckyGiftDailyStatsSource) ListLuckyGiftDailyStats(_ context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) {
f.lastReq = req
return &luckygiftv1.ListLuckyGiftDailyStatsResponse{Stats: f.stats}, nil
}
func TestRequirementsLuckyGiftV3Gating(t *testing.T) {
for _, tc := range []struct {
section string
includes bool
only bool
}{
{"", true, false},
{"all", true, false},
{"lucky_gift_v3", true, true},
{" Lucky_Gift_V3 ", true, true},
{"revenue", false, false},
{"game", false, false},
} {
if got := requirementsIncludesLuckyGiftV3(tc.section); got != tc.includes {
t.Fatalf("includes(%q)=%v, want %v", tc.section, got, tc.includes)
}
if got := requirementsOnlyLuckyGiftV3(tc.section); got != tc.only {
t.Fatalf("only(%q)=%v, want %v", tc.section, got, tc.only)
}
}
}
// 回归section 组装——按北京日零填充整个窗口、比例按合计重算、v3 策略与区域过滤透传到 RPC。
func TestLuckyGiftV3Section(t *testing.T) {
source := &fakeLuckyGiftDailyStatsSource{stats: []*luckygiftv1.LuckyGiftDailyStat{
{StatDay: "2026-07-11", DrawCount: 4, TurnoverCoin: 8_000, PayoutCoin: 6_000, BaseRewardCoin: 5_500},
}}
service := &Service{luckyGiftStats: source, luckyGiftTimeout: time.Second}
// 北京 2026-07-10 00:00 ~ 2026-07-12 00:00排他= 两个北京日。
startMS := time.Date(2026, 7, 9, 16, 0, 0, 0, time.UTC).UnixMilli()
endMS := time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC).UnixMilli()
section, err := service.luckyGiftV3Section(context.Background(), "lalu", RequirementsQuery{StartMS: startMS, EndMS: endMS}, []int64{7, 8})
if err != nil {
t.Fatalf("build section: %v", err)
}
if source.lastReq.GetStrategyVersion() != "dynamic_v3" || source.lastReq.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("rpc request mismatch: %+v", source.lastReq)
}
if regions := source.lastReq.GetRegionIds(); len(regions) != 2 || regions[0] != 7 || regions[1] != 8 {
t.Fatalf("rpc region ids mismatch: %v", regions)
}
if section["key"] != luckyGiftV3SectionKey {
t.Fatalf("section key mismatch: %v", section["key"])
}
rows, ok := section["daily_series"].([]map[string]any)
if !ok || len(rows) != 2 {
t.Fatalf("daily_series rows=%v, want 2 zero-filled days", section["daily_series"])
}
if rows[0]["stat_day"] != "2026-07-10" || rows[0]["draw_count"] != float64(0) {
t.Fatalf("zero-filled day mismatch: %+v", rows[0])
}
if rows[0]["payout_rate"] != nil {
t.Fatalf("zero turnover day must render nil ratio, got %v", rows[0]["payout_rate"])
}
if rows[1]["stat_day"] != "2026-07-11" || rows[1]["turnover_coin"] != float64(8_000) || rows[1]["profit_coin"] != float64(2_000) {
t.Fatalf("data day mismatch: %+v", rows[1])
}
if rate := rows[1]["payout_rate"].(float64); rate != 0.75 {
t.Fatalf("payout_rate=%v, want 0.75", rate)
}
total, ok := section["total"].(map[string]any)
if !ok || total["draw_count"] != float64(4) || total["profit_coin"] != float64(2_000) {
t.Fatalf("total mismatch: %+v", total)
}
average, ok := section["average"].(map[string]any)
if !ok || average["draw_count"] != float64(2) || average["turnover_coin"] != float64(4_000) {
t.Fatalf("average mismatch: %+v", average)
}
// 平均值行的比例 = 合计比例4000/… 与 6000/8000 相同),不是逐日比例算术平均。
if rate := average["payout_rate"].(float64); rate != 0.75 {
t.Fatalf("average payout_rate=%v, want 0.75", rate)
}
}
func TestLuckyGiftV3StatDaysCrossesBeijingMidnight(t *testing.T) {
// UTC 2026-07-10 15:00 ~ 17:00 跨北京 7-10/7-11 两日。
startMS := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC).UnixMilli()
endMS := time.Date(2026, 7, 10, 17, 0, 0, 0, time.UTC).UnixMilli()
days := luckyGiftV3StatDays(startMS, endMS)
if len(days) != 2 || days[0] != "2026-07-10" || days[1] != "2026-07-11" {
t.Fatalf("days=%v, want [2026-07-10 2026-07-11]", days)
}
if got := luckyGiftV3StatDays(endMS, startMS); got != nil {
t.Fatalf("inverted range must return nil, got %v", got)
}
}

View File

@ -20,8 +20,9 @@ import (
)
const (
appKindHyapp = "hyapp"
appKindLegacy = "legacy"
appKindHyapp = "hyapp"
appKindLegacy = "legacy"
unknownCountryName = "未知国家"
overviewConcurrency = 4
)
@ -51,6 +52,8 @@ type Service struct {
apps *appregistry.Service
user userclient.Client
financeRecharge payment.FinanceRechargeOverviewSource
luckyGiftStats LuckyGiftDailyStatsSource
luckyGiftTimeout time.Duration
legacyApps []LegacyAppDescriptor
legacyRegionCatalogs []LegacyRegionCatalogSource
@ -341,14 +344,18 @@ type RequirementsResult struct {
// Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service
// legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。
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 {
return nil, err
}
apps := filterAllowedApps(registeredApps, query.AppCodes, access)
catalog, err := s.regionCatalog(ctx, apps)
if err != nil {
return nil, err
}
// 国家主数据属于 App 业务域;即使 country_id 是物理全局自增,也必须按当前可查询 App 建目录,
// 否则跨 App 污染会被另一个 App 的主数据包装成合法国家,掩盖上游事实错误。
countryDirectories := s.countryDirectories(ctx, apps)
results := make([]AppOverview, len(apps))
var wg sync.WaitGroup
@ -359,7 +366,7 @@ func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, q
defer wg.Done()
semaphore <- struct{}{}
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)
}
wg.Wait()
@ -478,6 +485,22 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
// 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。
regionIDs = requestRegionIDs
}
if requirementsOnlyLuckyGiftV3(query.Section) {
// 只要幸运礼物V3板块时跳过 statistics-service它不认识该 section key
// 透传会触发"未知 key 回退全量六板块"的最重查询路径。
if s.luckyGiftStats == nil {
out.Error = "幸运礼物V3统计源未配置"
return out
}
luckySection, err := s.luckyGiftV3Section(ctx, app.AppCode, query, luckyGiftV3RegionIDs(query, regions, regionIDs))
if err != nil {
out.Error = fmt.Sprintf("获取幸运礼物V3统计失败: %v", err)
return out
}
out.Sections = append(out.Sections, luckySection)
out.UpdatedAtMS = time.Now().UnixMilli()
return out
}
requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{
AppCode: app.AppCode,
StatTZ: query.StatTZ,
@ -495,6 +518,14 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
return out
}
out.Sections = anyRowSlice(requirements["sections"])
if s.luckyGiftStats != nil && requirementsIncludesLuckyGiftV3(query.Section) {
luckySection, err := s.luckyGiftV3Section(ctx, app.AppCode, query, luckyGiftV3RegionIDs(query, regions, regionIDs))
if err != nil {
out.Error = fmt.Sprintf("获取幸运礼物V3统计失败: %v", err)
return out
}
out.Sections = append(out.Sections, luckySection)
}
if s.financeRecharge != nil && requirementsIncludesRevenue(query.Section) {
if requirementsHasCohortFilter(query) {
// Finance 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用,
@ -600,7 +631,7 @@ func supportsAppTrackingFunnel(appCode string) bool {
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{
AppCode: app.AppCode,
AppName: app.AppName,
@ -652,8 +683,10 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
return out
}
countryRows := anyRowSlice(overview["country_breakdown"])
dailyCountryRows := anyRowSlice(overview["daily_country_breakdown"])
// statisticsOverview 会短时缓存并共享底层 map补展示字段时必须复制行避免并发请求互相改写缓存。
preserveSourceNames := app.Kind == appKindLegacy
countryRows := enrichCountryRows(anyRowSlice(overview["country_breakdown"]), countries, preserveSourceNames)
dailyCountryRows := enrichCountryRows(anyRowSlice(overview["daily_country_breakdown"]), countries, preserveSourceNames)
resolve := regionResolver(regions)
restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0
@ -1199,6 +1232,12 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
if err != nil {
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{}{}
for _, appCode := range requested {
appCode = appctx.Normalize(appCode)
@ -1221,7 +1260,145 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
}
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 的区域主数据,

View File

@ -2,9 +2,13 @@ package databi
import (
"context"
"errors"
"reflect"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
@ -102,3 +106,143 @@ func TestRegionDisplayToleratesRoundedIDs(t *testing.T) {
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"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"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) {
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)
items, total, err := h.service.ListApplications(repository.WithdrawalApplicationListOptions{
listOptions := repository.WithdrawalApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
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 errors.Is(err, errWithdrawalMoneyScopeForbidden) {
response.Forbidden(c, err.Error())
return
}
response.ServerError(c, "获取用户提现申请列表失败")
return
}
@ -41,18 +65,31 @@ func (h *Handler) ListApplications(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) {
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")
if !ok {
return
}
if strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)) == "" {
// 财务工作台可跨 App 列表,审核时必须由前端显式选中目标行 App不能让 appctx 的 lalu 默认值静默决定资金数据域。
response.BadRequest(c, "缺少 "+appctx.HeaderAppCode)
return
}
var req struct {
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
@ -66,7 +103,11 @@ func (h *Handler) auditApplication(c *gin.Context, decision string) {
item *withdrawalApplicationDTO
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))
} else {
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)
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)
}
@ -88,6 +133,14 @@ func writeWithdrawalServiceError(c *gin.Context, err error) {
response.BadRequest(c, "提现申请已审核")
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()) {
case "没有操作权限":
response.Forbidden(c, err.Error())

View File

@ -1,12 +1,56 @@
package financewithdrawal
import "testing"
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) {
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"}) {
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

@ -3,54 +3,66 @@ package financewithdrawal
import "hyapp-admin-server/internal/model"
type withdrawalApplicationDTO struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
UserID string `json:"userId"`
SalaryAssetType string `json:"salaryAssetType"`
WithdrawAmount string `json:"withdrawAmount"`
WithdrawAmountMinor int64 `json:"withdrawAmountMinor"`
PointGrossAmount int64 `json:"pointGrossAmount,omitempty"`
PointFeeAmount int64 `json:"pointFeeAmount,omitempty"`
PointNetAmount int64 `json:"pointNetAmount,omitempty"`
PointsPerUSD int64 `json:"pointsPerUsd,omitempty"`
PointFeeBPS int32 `json:"pointFeeBps,omitempty"`
PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"`
WithdrawMethod string `json:"withdrawMethod"`
WithdrawAddress string `json:"withdrawAddress"`
FreezeTransactionID string `json:"freezeTransactionId"`
AuditTransactionID string `json:"auditTransactionId"`
Status string `json:"status"`
ApproverUserID *uint `json:"approverUserId"`
ApproverName string `json:"approverName"`
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
ApprovedAtMS *int64 `json:"approvedAtMs"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
UserID string `json:"userId"`
SalaryAssetType string `json:"salaryAssetType"`
WithdrawAmount string `json:"withdrawAmount"`
WithdrawAmountMinor int64 `json:"withdrawAmountMinor"`
PointGrossAmount int64 `json:"pointGrossAmount,omitempty"`
PointFeeAmount int64 `json:"pointFeeAmount,omitempty"`
PointNetAmount int64 `json:"pointNetAmount,omitempty"`
PointsPerUSD int64 `json:"pointsPerUsd,omitempty"`
PointFeeBPS int32 `json:"pointFeeBps,omitempty"`
PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"`
WithdrawMethod string `json:"withdrawMethod"`
WithdrawAddress string `json:"withdrawAddress"`
FreezeTransactionID string `json:"freezeTransactionId"`
AuditTransactionID string `json:"auditTransactionId"`
Status string `json:"status"`
ApproverUserID *uint `json:"approverUserId"`
ApproverName string `json:"approverName"`
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
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"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) withdrawalApplicationDTO {
dto := withdrawalApplicationDTO{
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
UserID: item.UserID,
SalaryAssetType: item.SalaryAssetType,
WithdrawAmount: item.WithdrawAmount,
WithdrawAmountMinor: item.WithdrawAmountMinor,
WithdrawMethod: item.WithdrawMethod,
WithdrawAddress: item.WithdrawAddress,
FreezeTransactionID: item.FreezeTransactionID,
AuditTransactionID: item.AuditTransactionID,
Status: item.Status,
ApproverUserID: item.ApproverUserID,
ApproverName: item.ApproverName,
AuditRemark: item.AuditRemark,
AuditImageURL: item.AuditImageURL,
ApprovedAtMS: item.ApprovedAtMS,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
UserID: item.UserID,
SalaryAssetType: item.SalaryAssetType,
WithdrawAmount: item.WithdrawAmount,
WithdrawAmountMinor: item.WithdrawAmountMinor,
WithdrawMethod: item.WithdrawMethod,
WithdrawAddress: item.WithdrawAddress,
FreezeTransactionID: item.FreezeTransactionID,
AuditTransactionID: item.AuditTransactionID,
Status: item.Status,
ApproverUserID: item.ApproverUserID,
ApproverName: item.ApproverName,
AuditRemark: item.AuditRemark,
AuditImageURL: item.AuditImageURL,
ApprovedAtMS: item.ApprovedAtMS,
OperationsStatus: item.OperationsStatus,
OperationsReviewerUserID: item.OperationsReviewerUserID,
OperationsReviewerName: item.OperationsReviewerName,
OperationsAuditRemark: item.OperationsAuditRemark,
OperationsAuditTransactionID: item.OperationsAuditTransactionID,
OperationsReviewedAtMS: item.OperationsReviewedAtMS,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
if isPointWithdrawalAssetType(item.SalaryAssetType) {
feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(item)

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.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.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 (
permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionViewOperationsWithdrawals = "operations-withdrawal:view"
permissionAuditOperationsWithdrawals = "operations-withdrawal:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500)
)
var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围")
type Service struct {
store *repository.Store
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() }}
}
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 {
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)
if err != nil {
return nil, 0, err
@ -54,73 +64,128 @@ func (s *Service) ListApplications(options repository.WithdrawalApplicationListO
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) {
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) {
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 {
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("没有操作权限")
}
remark = strings.TrimSpace(remark)
auditImageURL = strings.TrimSpace(auditImageURL)
if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
return nil, errors.New("提现通过凭证图片不能为空")
}
if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
return nil, errors.New("拒绝原因不能为空")
}
appCode := appctx.FromContext(ctx)
item, err := s.store.GetWithdrawalApplicationForApp(appCode, id)
access, err := s.store.MoneyAccessForUser(actor.UserID)
if err != nil {
return nil, err
}
if item.Status != model.WithdrawalApplicationStatusPending {
return nil, repository.ErrWithdrawalApplicationAlreadyAudited
}
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
if !access.AllowsApp(appCode) {
// 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。
return nil, errWithdrawalMoneyScopeForbidden
}
nowMS := s.now().UnixMilli()
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); err != nil {
// 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知。
return nil, err
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.AuditWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
AuditCommandID: commandID,
AuditTransactionID: transactionID,
ApprovedAtMS: 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,
AppCode: appCode,
UserID: userID,
Stage: stage,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
}
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
})
if err != nil {
return nil, err
@ -129,6 +194,80 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
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) {
reason := strings.TrimSpace(remark)
if reason == "" {
@ -223,8 +362,9 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
}
}
func withdrawalAuditCommandID(id uint, decision string) string {
return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision)
func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string {
// 同一阶段的通过和拒绝复用 command id并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。
return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage)
}
func isPointWithdrawalAssetType(assetType string) bool {
@ -240,6 +380,7 @@ type auditNoticeInput struct {
ApplicationID uint
AppCode string
UserID int64
Stage repository.WithdrawalApplicationReviewStage
Decision string
Remark 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)
title = "Withdrawal request rejected"
eventType = "finance_withdrawal_rejected"
if input.Stage == repository.WithdrawalApplicationReviewStageOperations {
eventType = "operations_withdrawal_rejected"
}
} else {
imageURL = strings.TrimSpace(input.AuditImageURL)
}
@ -267,6 +411,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
"app_code": strings.TrimSpace(input.AppCode),
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
"decision": strings.TrimSpace(input.Decision),
"review_stage": string(input.Stage),
"withdraw_amount": strings.TrimSpace(input.WithdrawAmount),
"withdraw_method": strings.TrimSpace(input.WithdrawMethod),
})
@ -282,7 +427,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
},
TargetUserId: input.UserID,
Producer: "admin-server",
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Decision),
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision),
ProducerEventType: eventType,
MessageType: "system",
AggregateType: "user_withdrawal_application",
@ -300,14 +445,32 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti
return nil
}
func withdrawalAuditNotificationEventID(id uint, decision string) string {
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
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("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
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 {
switch strings.TrimSpace(permission) {
case permissionAuditWithdrawalApplications:
if strings.TrimSpace(permission) == expected {
return true
}
}

View File

@ -2,14 +2,229 @@ package financewithdrawal
import (
"context"
"errors"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"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"
"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) {
wallet := &fakeAuditWalletClient{}
svc := &Service{wallet: wallet}
@ -120,3 +335,121 @@ func (f *fakeAuditWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *
f.releaseSalary = req
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,312 @@
package luckygift
import (
"fmt"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
)
type experimentOverrideDTO struct {
UserKey string `json:"user_key"`
Arm string `json:"arm"`
}
type experimentArmStatsDTO struct {
Arm string `json:"arm"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
TotalDraws int64 `json:"total_draws"`
UniqueUsers int64 `json:"unique_users"`
TotalSpentCoins int64 `json:"total_spent_coins"`
TotalRewardCoins int64 `json:"total_reward_coins"`
ActualRTPPercent float64 `json:"actual_rtp_percent"`
}
type experimentDTO struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
ExperimentID string `json:"experiment_id"`
Name string `json:"name"`
ControlRuleVersion int64 `json:"control_rule_version"`
TreatmentRuleVersion int64 `json:"treatment_rule_version"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
Status string `json:"status"`
WinnerArm string `json:"winner_arm"`
WinnerRuleVersion int64 `json:"winner_rule_version"`
FinalRuleVersion int64 `json:"final_rule_version"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
StartedAtMS int64 `json:"started_at_ms"`
EndedAtMS int64 `json:"ended_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Overrides []experimentOverrideDTO `json:"overrides"`
ArmStats []experimentArmStatsDTO `json:"arm_stats"`
}
type createExperimentRequest struct {
Name string `json:"name"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
Overrides []experimentOverrideDTO `json:"overrides"`
Config configRequest `json:"config"`
}
type updateExperimentRequest struct {
// 指针区分「没传」与「显式传 0」缺省时向 owner 传 -1 表示保持现有放量。
TreatmentTrafficPercent *float64 `json:"treatment_traffic_percent"`
Status string `json:"status"`
}
type setExperimentOverridesRequest struct {
Upserts []experimentOverrideDTO `json:"upserts"`
Removes []string `json:"removes"`
}
type endExperimentRequest struct {
WinnerArm string `json:"winner_arm"`
}
func (h *Handler) ListLuckyGiftExperiments(c *gin.Context) {
if strings.TrimSpace(c.Query("app_code")) == "" {
response.BadRequest(c, "app_code is required")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.ListLuckyGiftExperiments(ctx, &luckygiftv1.ListLuckyGiftExperimentsRequest{
Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")),
Status: strings.ToLower(strings.TrimSpace(c.Query("status"))),
WithStats: parseOptionalBool(c.Query("with_stats")),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]experimentDTO, 0, len(resp.GetExperiments()))
for _, experiment := range resp.GetExperiments() {
items = append(items, experimentFromProto(experiment))
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
var req createExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
if req.Config.AppCode == "" {
req.Config.AppCode = appCode
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.CreateLuckyGiftExperiment(ctx, &luckygiftv1.CreateLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
Name: strings.TrimSpace(req.Name),
// treatment 配置复用 PUT /lucky-gifts/config 的解析归一逻辑tier_id 生成、percent→ppm、
// dynamic_v3 initial_pool_coins 清零),保证实验发布的新版本与常规发布口径一致。
TreatmentConfig: configToProto(req.Config),
TreatmentTrafficPpm: percentToPPM(req.TreatmentTrafficPercent),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
Overrides: experimentOverridesToProto(req.Overrides),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
experiment := experimentFromProto(resp.GetExperiment())
shared.OperationLogWithResourceID(
c, h.audit, "create-lucky-gift-experiment", "lucky_gift_experiments", experiment.ExperimentID, "success",
fmt.Sprintf("app_code=%s pool_id=%s treatment_traffic_ppm=%d", appCode, experiment.PoolID, percentToPPM(req.TreatmentTrafficPercent)),
)
response.OK(c, gin.H{
"experiment": experiment,
"treatment_config": configFromProto(resp.GetTreatmentConfig()),
})
}
func (h *Handler) UpdateLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req updateExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
trafficPPM := int64(-1)
grpcReq := &luckygiftv1.UpdateLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
Status: strings.ToLower(strings.TrimSpace(req.Status)),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
if req.TreatmentTrafficPercent != nil {
trafficPPM = percentToPPM(*req.TreatmentTrafficPercent)
grpcReq.TreatmentTrafficPpm = &trafficPPM
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.UpdateLuckyGiftExperiment(ctx, grpcReq)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "update-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s treatment_traffic_ppm=%d status=%s", appCode, trafficPPM, strings.ToLower(strings.TrimSpace(req.Status))),
)
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
}
func (h *Handler) SetLuckyGiftExperimentOverrides(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req setExperimentOverridesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验白名单参数不正确")
return
}
removes := make([]string, 0, len(req.Removes))
for _, userKey := range req.Removes {
removes = append(removes, strings.TrimSpace(userKey))
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.SetLuckyGiftExperimentOverrides(ctx, &luckygiftv1.SetLuckyGiftExperimentOverridesRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
Upserts: experimentOverridesToProto(req.Upserts),
Removes: removes,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "set-lucky-gift-experiment-overrides", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s upserts=%d removes=%d", appCode, len(req.Upserts), len(removes)),
)
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
}
func (h *Handler) EndLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req endExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
winnerArm := strings.ToLower(strings.TrimSpace(req.WinnerArm))
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.EndLuckyGiftExperiment(ctx, &luckygiftv1.EndLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
WinnerArm: winnerArm,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "end-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s winner_arm=%s", appCode, winnerArm),
)
response.OK(c, gin.H{
"experiment": experimentFromProto(resp.GetExperiment()),
"final_config": configFromProto(resp.GetFinalConfig()),
})
}
func experimentOverridesToProto(overrides []experimentOverrideDTO) []*luckygiftv1.LuckyGiftExperimentOverride {
out := make([]*luckygiftv1.LuckyGiftExperimentOverride, 0, len(overrides))
for _, override := range overrides {
out = append(out, &luckygiftv1.LuckyGiftExperimentOverride{
UserKey: strings.TrimSpace(override.UserKey),
Arm: strings.ToLower(strings.TrimSpace(override.Arm)),
})
}
return out
}
func experimentFromProto(experiment *luckygiftv1.LuckyGiftExperiment) experimentDTO {
if experiment == nil {
return experimentDTO{Overrides: []experimentOverrideDTO{}, ArmStats: []experimentArmStatsDTO{}}
}
overrides := make([]experimentOverrideDTO, 0, len(experiment.GetOverrides()))
for _, override := range experiment.GetOverrides() {
overrides = append(overrides, experimentOverrideDTO{
UserKey: override.GetUserKey(),
Arm: override.GetArm(),
})
}
armStats := make([]experimentArmStatsDTO, 0, len(experiment.GetArmStats()))
for _, stats := range experiment.GetArmStats() {
armStats = append(armStats, experimentArmStatsDTO{
Arm: stats.GetArm(),
RuleVersion: stats.GetRuleVersion(),
StrategyVersion: stats.GetStrategyVersion(),
TotalDraws: stats.GetTotalDraws(),
UniqueUsers: stats.GetUniqueUsers(),
TotalSpentCoins: stats.GetTotalSpentCoins(),
TotalRewardCoins: stats.GetTotalRewardCoins(),
ActualRTPPercent: ppmToPercent(stats.GetActualRtpPpm()),
})
}
return experimentDTO{
AppCode: experiment.GetAppCode(),
PoolID: experiment.GetPoolId(),
ExperimentID: experiment.GetExperimentId(),
Name: experiment.GetName(),
ControlRuleVersion: experiment.GetControlRuleVersion(),
TreatmentRuleVersion: experiment.GetTreatmentRuleVersion(),
TreatmentTrafficPercent: ppmToPercent(experiment.GetTreatmentTrafficPpm()),
Status: experiment.GetStatus(),
WinnerArm: experiment.GetWinnerArm(),
WinnerRuleVersion: experiment.GetWinnerRuleVersion(),
FinalRuleVersion: experiment.GetFinalRuleVersion(),
CreatedByAdminID: experiment.GetCreatedByAdminId(),
StartedAtMS: experiment.GetStartedAtMs(),
EndedAtMS: experiment.GetEndedAtMs(),
UpdatedAtMS: experiment.GetUpdatedAtMs(),
Overrides: overrides,
ArmStats: armStats,
}
}

View File

@ -0,0 +1,277 @@
package luckygift
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"hyapp-admin-server/internal/integration/luckygiftadmin"
"hyapp-admin-server/internal/middleware"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
)
type experimentClientStub struct {
luckygiftadmin.Client
createRequest *luckygiftv1.CreateLuckyGiftExperimentRequest
createResponse *luckygiftv1.CreateLuckyGiftExperimentResponse
updateRequest *luckygiftv1.UpdateLuckyGiftExperimentRequest
updateResponse *luckygiftv1.UpdateLuckyGiftExperimentResponse
overridesRequest *luckygiftv1.SetLuckyGiftExperimentOverridesRequest
endRequest *luckygiftv1.EndLuckyGiftExperimentRequest
endResponse *luckygiftv1.EndLuckyGiftExperimentResponse
err error
}
func (s *experimentClientStub) CreateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
s.createRequest = request
return s.createResponse, s.err
}
func (s *experimentClientStub) UpdateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
s.updateRequest = request
return s.updateResponse, s.err
}
func (s *experimentClientStub) SetLuckyGiftExperimentOverrides(_ context.Context, request *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
s.overridesRequest = request
return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: request.GetExperimentId(), Overrides: request.GetUpserts()},
}, s.err
}
func (s *experimentClientStub) EndLuckyGiftExperiment(_ context.Context, request *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
s.endRequest = request
return s.endResponse, s.err
}
func experimentTestContext(t *testing.T, method, target, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(method, target, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextRequestID, "request-1")
return c, recorder
}
func TestCreateLuckyGiftExperimentReusesConfigNormalizationAndPPM(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{createResponse: &luckygiftv1.CreateLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{
AppCode: "lalu", PoolId: "super_lucky", ExperimentId: "lucky_exp_1", Name: "rtp push",
ControlRuleVersion: 9, TreatmentRuleVersion: 10, TreatmentTrafficPpm: 50_000, Status: "running",
Overrides: []*luckygiftv1.LuckyGiftExperimentOverride{{UserKey: "1001", Arm: "treatment"}},
ArmStats: []*luckygiftv1.LuckyGiftExperimentArmStats{{Arm: "control", RuleVersion: 9, ActualRtpPpm: 955_000}},
},
TreatmentConfig: &luckygiftv1.LuckyGiftRuleConfig{AppCode: "lalu", PoolId: "super_lucky", RuleVersion: 10, TargetRtpPpm: 960_000},
}}
body := `{
"name": " rtp push ",
"treatment_traffic_percent": 5,
"overrides": [{"user_key": " 1001 ", "arm": " TREATMENT "}],
"config": {
"pool_id": "super_lucky",
"strategy_version": "DYNAMIC_V3",
"enabled": true,
"target_rtp_percent": 96,
"initial_pool_coins": 1000000,
"stages": [{"stage": "normal", "tiers": [{"multiplier": 0.5, "probability_percent": 22, "enabled": true}]}]
}
}`
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments?app_code=LALU", body)
New(client, time.Second, nil).CreateLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.createRequest
if request == nil {
t.Fatal("owner create request was not sent")
}
if request.GetName() != "rtp push" || request.GetTreatmentTrafficPpm() != 50_000 || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if request.GetMeta().GetAppCode() != "lalu" || request.GetMeta().GetRequestId() != "request-1" {
t.Fatalf("owner request meta=%+v", request.GetMeta())
}
if len(request.GetOverrides()) != 1 || request.GetOverrides()[0].GetUserKey() != "1001" || request.GetOverrides()[0].GetArm() != "treatment" {
t.Fatalf("owner request overrides=%+v", request.GetOverrides())
}
config := request.GetTreatmentConfig()
if config.GetAppCode() != "lalu" || config.GetStrategyVersion() != "dynamic_v3" {
t.Fatalf("treatment config app/strategy=%+v", config)
}
if config.GetTargetRtpPpm() != 960_000 {
t.Fatalf("treatment config target rtp ppm=%d, want 960000", config.GetTargetRtpPpm())
}
if config.GetInitialPoolCoins() != 0 {
t.Fatalf("dynamic_v3 initial pool coins should be forced to 0, got %d", config.GetInitialPoolCoins())
}
tier := config.GetStages()[0].GetTiers()[0]
if tier.GetTierId() != "normal_0_5x" || tier.GetBaseWeightPpm() != 220_000 {
t.Fatalf("treatment config tier=%+v", tier)
}
var envelope struct {
Code int `json:"code"`
Data struct {
Experiment struct {
ExperimentID string `json:"experiment_id"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
ArmStats []struct {
Arm string `json:"arm"`
ActualRTPPercent float64 `json:"actual_rtp_percent"`
} `json:"arm_stats"`
} `json:"experiment"`
TreatmentConfig struct {
RuleVersion int64 `json:"rule_version"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
} `json:"treatment_config"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.Experiment.ExperimentID != "lucky_exp_1" || envelope.Data.Experiment.TreatmentTrafficPercent != 5 {
t.Fatalf("response=%s", recorder.Body.String())
}
if len(envelope.Data.Experiment.ArmStats) != 1 || envelope.Data.Experiment.ArmStats[0].ActualRTPPercent != 95.5 {
t.Fatalf("arm stats response=%s", recorder.Body.String())
}
if envelope.Data.TreatmentConfig.RuleVersion != 10 || envelope.Data.TreatmentConfig.TargetRTPPercent != 96 {
t.Fatalf("treatment config response=%s", recorder.Body.String())
}
}
func TestUpdateLuckyGiftExperimentOmittedFieldsKeepOwnerDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
// 缺省流量以 proto3 optional 的"字段不存在"表达wantTraffic=nil显式 0 必须以存在的 0 传递;
// 这是防止"仅改状态的调用把放量静默清零"的契约核心。
tests := []struct {
name string
body string
wantTraffic *int64
wantStatus string
}{
{name: "omitted fields", body: `{}`, wantTraffic: nil, wantStatus: ""},
{name: "explicit zero traffic", body: `{"treatment_traffic_percent": 0}`, wantTraffic: int64Ptr(0), wantStatus: ""},
{name: "pause only", body: `{"status": "paused"}`, wantTraffic: nil, wantStatus: "paused"},
{name: "both fields", body: `{"treatment_traffic_percent": 12.5, "status": "running"}`, wantTraffic: int64Ptr(125_000), wantStatus: "running"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &experimentClientStub{updateResponse: &luckygiftv1.UpdateLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_1", Status: "running"},
}}
c, recorder := experimentTestContext(t, http.MethodPatch, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1?app_code=lalu", tt.body)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).UpdateLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.updateRequest
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if (request.TreatmentTrafficPpm == nil) != (tt.wantTraffic == nil) ||
(tt.wantTraffic != nil && request.GetTreatmentTrafficPpm() != *tt.wantTraffic) ||
request.GetStatus() != tt.wantStatus {
t.Fatalf("traffic=%v status=%q, want traffic=%v status=%q", request.TreatmentTrafficPpm, request.GetStatus(), tt.wantTraffic, tt.wantStatus)
}
})
}
}
func int64Ptr(v int64) *int64 { return &v }
func TestSetLuckyGiftExperimentOverridesForwardsUpsertsAndRemoves(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{}
body := `{"upserts": [{"user_key": "ext:abc", "arm": "control"}], "removes": [" 1001 "]}`
c, recorder := experimentTestContext(t, http.MethodPut, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/overrides?app_code=lalu", body)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).SetLuckyGiftExperimentOverrides(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.overridesRequest
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if len(request.GetUpserts()) != 1 || request.GetUpserts()[0].GetUserKey() != "ext:abc" || request.GetUpserts()[0].GetArm() != "control" {
t.Fatalf("owner upserts=%+v", request.GetUpserts())
}
if len(request.GetRemoves()) != 1 || request.GetRemoves()[0] != "1001" {
t.Fatalf("owner removes=%+v", request.GetRemoves())
}
var envelope struct {
Data struct {
Experiment struct {
ExperimentID string `json:"experiment_id"`
} `json:"experiment"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Data.Experiment.ExperimentID != "lucky_exp_1" {
t.Fatalf("response=%s", recorder.Body.String())
}
}
func TestEndLuckyGiftExperimentReturnsExperimentAndFinalConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{endResponse: &luckygiftv1.EndLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{
ExperimentId: "lucky_exp_1", Status: "completed", WinnerArm: "treatment",
WinnerRuleVersion: 10, FinalRuleVersion: 11,
},
FinalConfig: &luckygiftv1.LuckyGiftRuleConfig{PoolId: "super_lucky", RuleVersion: 11, TargetRtpPpm: 960_000},
}}
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/end?app_code=lalu", `{"winner_arm": "Treatment"}`)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).EndLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if client.endRequest == nil || client.endRequest.GetWinnerArm() != "treatment" || client.endRequest.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", client.endRequest)
}
var envelope struct {
Code int `json:"code"`
Data struct {
Experiment struct {
Status string `json:"status"`
WinnerArm string `json:"winner_arm"`
FinalRuleVersion int64 `json:"final_rule_version"`
} `json:"experiment"`
FinalConfig struct {
RuleVersion int64 `json:"rule_version"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
} `json:"final_config"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.Experiment.Status != "completed" || envelope.Data.Experiment.WinnerArm != "treatment" || envelope.Data.Experiment.FinalRuleVersion != 11 {
t.Fatalf("experiment response=%s", recorder.Body.String())
}
if envelope.Data.FinalConfig.RuleVersion != 11 || envelope.Data.FinalConfig.TargetRTPPercent != 96 {
t.Fatalf("final config response=%s", recorder.Body.String())
}
}
func TestListLuckyGiftExperimentsRequiresAppCode(t *testing.T) {
gin.SetMode(gin.TestMode)
c, recorder := experimentTestContext(t, http.MethodGet, "/admin/ops-center/lucky-gifts/experiments", "")
New(&experimentClientStub{}, time.Second, nil).ListLuckyGiftExperiments(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status=%d want=400 body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@ -2,12 +2,14 @@ package luckygift
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"hyapp-admin-server/internal/integration/luckygiftadmin"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -20,10 +22,18 @@ import (
type Handler struct {
luckyGift luckygiftadmin.Client
users luckyGiftUserClient
requestTimeout time.Duration
audit shared.OperationLogger
}
// luckyGiftUserClient 只暴露画像页面需要的 owner 能力。后台搜索必须走专用精确解析 RPC
// 用户资料仍批量读取,避免给全局 userclient.Client 增加画像模块专属依赖。
type luckyGiftUserClient interface {
BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error)
ResolveAdminUserIdentifier(ctx context.Context, req userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error)
}
func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
if requestTimeout <= 0 {
requestTimeout = 3 * time.Second
@ -31,42 +41,84 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit}
}
// BindUserClient 注入 user-service 主数据读取能力。幸运礼物 owner 只保存不可变的内部 user_id
// 短号、默认号、靓号和用户资料必须在 admin-server 展示边界实时解析,不能复制进幸运礼物画像表。
func (h *Handler) BindUserClient(users luckyGiftUserClient) *Handler {
h.users = users
return h
}
type configRequest struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
V2HighWaterBoostThresholdCoins int64 `json:"v2_high_water_boost_threshold_coins"`
V2HighWaterBoostFactorPercent float64 `json:"v2_high_water_boost_factor_percent"`
V2HighWaterBoostRecoverCoins int64 `json:"v2_high_water_boost_recover_coins"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
User24HourOrdinaryWinFactorPercent float64 `json:"user_24h_ordinary_win_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
JackpotUserDayRTPMaxPercent float64 `json:"-"`
JackpotUser72hRTPMaxPercent float64 `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
}
// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段,
// 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct {
configRequest
JackpotUserRoundRTPMaxPercent *float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent *float64 `json:"jackpot_user_48h_rtp_max_percent"`
LegacyUserDayRTPMaxPercent *float64 `json:"jackpot_user_day_rtp_max_percent"`
LegacyUser72hRTPMaxPercent *float64 `json:"jackpot_user_72h_rtp_max_percent"`
}
func (req configUpsertRequest) canonicalConfig() configRequest {
config := req.configRequest
// canonical 字段优先;指针用于区分“未发送”和显式发送 0避免新旧字段同时存在时旧值覆盖新值。
if req.JackpotUserRoundRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.JackpotUserRoundRTPMaxPercent
} else if req.LegacyUserDayRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.LegacyUserDayRTPMaxPercent
}
if req.JackpotUser48hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.JackpotUser48hRTPMaxPercent
} else if req.LegacyUser72hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.LegacyUser72hRTPMaxPercent
}
return config
}
type configDTO struct {
@ -189,11 +241,15 @@ func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
}
func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
var input configUpsertRequest
decoder := json.NewDecoder(c.Request.Body)
// 兼容字段必须进入显式 DTO不能退回 map 或宽松二次反序列化,否则拼错的配置字段会被静默忽略。
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
response.BadRequest(c, "幸运礼物配置参数不正确")
return
}
req := input.canonicalConfig()
if req.PoolID == "" {
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
}
@ -413,6 +469,11 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion))
user24HourOrdinaryWinFactorPercent := req.User24HourOrdinaryWinFactorPercent
if req.User24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
// 旧 ops-center 不认识这两个字段proto3 又会把缺失标量解成 00/0 统一解释为关闭并保留 100% 权重。
user24HourOrdinaryWinFactorPercent = 100
}
initialPoolCoins := req.InitialPoolCoins
if strategyVersion == "dynamic_v3" {
// V3 启动资金已经迁移为独立人工 credit旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
@ -443,42 +504,55 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
Tiers: tiers,
})
}
roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent
if roundRTPMaxPercent == 0 {
roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent
}
rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent
if rolling48hRTPMaxPercent == 0 {
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion,
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages,
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion,
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
V2HighWaterBoostThresholdCoins: req.V2HighWaterBoostThresholdCoins,
V2HighWaterBoostFactorPpm: percentToPPM(req.V2HighWaterBoostFactorPercent),
V2HighWaterBoostRecoverCoins: req.V2HighWaterBoostRecoverCoins,
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages,
}
}
@ -512,46 +586,59 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
// 兼容读取迁移前 V3 快照时也展示 0避免运营误以为规则字段仍会自动形成账本资金。
initialPoolCoins = 0
}
user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm())
user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm())
if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
// 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态,前端无需猜测 0 因子是否表示全禁中。
user24HourOrdinaryWinFactorPercent = 100
}
return configDTO{
RuleVersion: config.GetRuleVersion(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
configRequest: configRequest{
AppCode: config.GetAppCode(),
PoolID: poolID,
StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
AppCode: config.GetAppCode(),
PoolID: poolID,
StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
V2HighWaterBoostThresholdCoins: config.GetV2HighWaterBoostThresholdCoins(),
V2HighWaterBoostFactorPercent: ppmToPercent(config.GetV2HighWaterBoostFactorPpm()),
V2HighWaterBoostRecoverCoins: config.GetV2HighWaterBoostRecoverCoins(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent,
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
},
}
}

View File

@ -0,0 +1,604 @@
package luckygift
import (
"context"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
)
type luckyGiftInternalUserDTO struct {
// UserID 使用字符串返回,避免浏览器把 64 位长号转成 JavaScript number 后丢失末位精度。
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
DefaultDisplayID string `json:"default_display_user_id"`
PrettyID string `json:"pretty_id"`
PrettyDisplayUserID string `json:"pretty_display_user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Status string `json:"status"`
}
type luckyGiftUserProfileWindowDTO struct {
Draws int64 `json:"draws"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
NetCoins int64 `json:"net_coins"`
RTPPPM int64 `json:"rtp_ppm"`
HasRTP bool `json:"has_rtp"`
}
type luckyGiftUserProfileDTO struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
IdentityType string `json:"identity_type"`
InternalUserID string `json:"internal_user_id,omitempty"`
ExternalUserID string `json:"external_user_id,omitempty"`
InternalUser *luckyGiftInternalUserDTO `json:"internal_user,omitempty"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Stage string `json:"stage"`
PaidDraws int64 `json:"paid_draws"`
EquivalentDraws int64 `json:"equivalent_draws"`
LossStreak int64 `json:"loss_streak"`
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
GuaranteeDrawsRemaining int64 `json:"guarantee_draws_remaining"`
DownweightActive bool `json:"downweight_active"`
DownweightFactorPPM int64 `json:"downweight_factor_ppm"`
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
CurrentRTPPPM int64 `json:"current_rtp_ppm"`
HasCurrentRTP bool `json:"has_current_rtp"`
OneHour luckyGiftUserProfileWindowDTO `json:"one_hour"`
TwentyFourHours luckyGiftUserProfileWindowDTO `json:"twenty_four_hours"`
FortyEightHours luckyGiftUserProfileWindowDTO `json:"forty_eight_hours"`
Lifetime luckyGiftUserProfileWindowDTO `json:"lifetime"`
BaseRewardCoins int64 `json:"base_reward_coins"`
OrdinaryWinCount int64 `json:"ordinary_win_count"`
HighMultiplierOrdinaryWinCount int64 `json:"high_multiplier_ordinary_win_count"`
RTPCompensationJackpotCount int64 `json:"rtp_compensation_jackpot_count"`
CumulativeSpendJackpotCount int64 `json:"cumulative_spend_jackpot_count"`
JackpotWinCount int64 `json:"jackpot_win_count"`
GuaranteeHitCount int64 `json:"guarantee_hit_count"`
ZeroDrawCount int64 `json:"zero_draw_count"`
GrantedDrawCount int64 `json:"granted_draw_count"`
PendingDrawCount int64 `json:"pending_draw_count"`
FailedDrawCount int64 `json:"failed_draw_count"`
MaxMultiplierPPM int64 `json:"max_multiplier_ppm"`
MaxRewardCoins int64 `json:"max_reward_coins"`
FirstDrawAtMS int64 `json:"first_draw_at_ms"`
LastDrawAtMS int64 `json:"last_draw_at_ms"`
AggregatedAtMS int64 `json:"aggregated_at_ms"`
DataLagMS int64 `json:"data_lag_ms"`
}
type luckyGiftUserProfilePageDTO struct {
Items []luckyGiftUserProfileDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
SnapshotAtMS int64 `json:"snapshot_at_ms"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
type luckyGiftUserMultiplierStatDTO struct {
HitType string `json:"hit_type"`
HitTypeLabel string `json:"hit_type_label"`
MultiplierPPM int64 `json:"multiplier_ppm"`
DrawCount int64 `json:"draw_count"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
}
type luckyGiftUserJackpotConditionDTO struct {
Name string `json:"name"`
Label string `json:"label"`
Actual string `json:"actual"`
Threshold string `json:"threshold"`
Operator string `json:"operator"`
Numerator int64 `json:"numerator"`
Denominator int64 `json:"denominator"`
RatioPPM int64 `json:"ratio_ppm"`
LimitPPM int64 `json:"limit_ppm"`
FactorPPM int64 `json:"factor_ppm"`
Factor string `json:"factor"`
Passed bool `json:"passed"`
Reason string `json:"reason"`
}
type luckyGiftUserJackpotHitDTO struct {
EventKey string `json:"event_key"`
DrawID string `json:"draw_id"`
RequestID string `json:"request_id"`
RuleVersion int64 `json:"rule_version"`
Stage string `json:"stage"`
Mechanism string `json:"mechanism"`
MechanismLabel string `json:"mechanism_label"`
ReasonCode string `json:"reason_code"`
ReasonSummary string `json:"reason_summary"`
TierID string `json:"tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
OccurredAtMS int64 `json:"occurred_at_ms"`
Conditions []luckyGiftUserJackpotConditionDTO `json:"conditions"`
}
type luckyGiftUserProfileDetailDTO struct {
Profile luckyGiftUserProfileDTO `json:"profile"`
MultiplierDistribution []luckyGiftUserMultiplierStatDTO `json:"multiplier_distribution"`
JackpotHits []luckyGiftUserJackpotHitDTO `json:"jackpot_hits"`
JackpotHitTotal int64 `json:"jackpot_hit_total"`
JackpotPage int32 `json:"jackpot_page"`
JackpotPageSize int32 `json:"jackpot_page_size"`
NextJackpotCursor string `json:"next_jackpot_cursor,omitempty"`
JackpotHasMore bool `json:"jackpot_has_more"`
SnapshotAtMS int64 `json:"snapshot_at_ms"`
}
// ListLuckyGiftUserProfiles 查询已经由 lucky-gift-service 增量物化的用户画像。列表只按 app+pool
// 读取聚合表;内部账号的短号/靓号解析和资料补全统一走 user-service不在 HTTP 请求里扫描开奖事实表。
func (h *Handler) ListLuckyGiftUserProfiles(c *gin.Context) {
appCode, poolID, ok := requiredLuckyGiftProfileScope(c)
if !ok {
return
}
identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source")))
if !ok {
response.BadRequest(c, "identity_type must be all, internal or external")
return
}
options := shared.ListOptions(c)
if options.PageSize > 100 {
options.PageSize = 100
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
ctx = appctx.WithContext(ctx, appCode)
internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id")))
externalUserID := strings.TrimSpace(c.Query("external_user_id"))
keyword := strings.TrimSpace(c.Query("user_keyword"))
if keyword != "" {
resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "搜索用户失败")
return
}
internalUserID, externalUserID = resolvedInternalID, resolvedExternalID
if identityType == "internal" && !internalMatched {
// 未解析的内部短号/长号/靓号必须返回空页,不能把“查无此人”退化为无条件全量列表。
response.OK(c, luckyGiftUserProfilePageDTO{Items: []luckyGiftUserProfileDTO{}, Page: options.Page, PageSize: options.PageSize})
return
}
}
resp, err := h.luckyGift.ListLuckyGiftUserProfiles(ctx, &luckygiftv1.ListLuckyGiftUserProfilesRequest{
Meta: h.metaForApp(c, appCode),
PoolId: poolID,
IdentityType: identityType,
InternalUserId: internalUserID,
ExternalUserId: externalUserID,
Stage: strings.TrimSpace(c.Query("stage")),
Status: strings.TrimSpace(c.Query("status")),
SortBy: strings.TrimSpace(c.Query("sort_by")),
SortDirection: strings.TrimSpace(c.Query("sort_direction")),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像失败")
return
}
users, err := h.loadLuckyGiftInternalUsers(ctx, resp.GetProfiles(), middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "获取用户资料失败")
return
}
items := make([]luckyGiftUserProfileDTO, 0, len(resp.GetProfiles()))
for _, profile := range resp.GetProfiles() {
items = append(items, luckyGiftUserProfileFromProto(profile, users[profile.GetInternalUserId()]))
}
response.OK(c, luckyGiftUserProfilePageDTO{
Items: items, Page: options.Page, PageSize: options.PageSize,
Total: resp.GetTotal(), SnapshotAtMS: resp.GetSnapshotAtMs(),
NextCursor: resp.GetNextCursor(), HasMore: resp.GetHasMore(),
})
}
// GetLuckyGiftUserProfile 返回单个用户的倍率分布和大奖解释。大奖明细只在展开行时请求,
// 避免列表页同时解码所有用户的条件 trace保证高密度页面仍保持稳定响应时间。
func (h *Handler) GetLuckyGiftUserProfile(c *gin.Context) {
appCode, poolID, ok := requiredLuckyGiftProfileScope(c)
if !ok {
return
}
identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source")))
if !ok || identityType == "" {
response.BadRequest(c, "identity_type must be internal or external")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
ctx = appctx.WithContext(ctx, appCode)
internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id")))
externalUserID := strings.TrimSpace(c.Query("external_user_id"))
if keyword := strings.TrimSpace(c.Query("user_keyword")); keyword != "" {
resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "搜索用户失败")
return
}
internalUserID, externalUserID = resolvedInternalID, resolvedExternalID
if identityType == "internal" && !internalMatched {
response.NotFound(c, "未找到该用户的幸运礼物画像")
return
}
}
if (identityType == "internal" && internalUserID <= 0) || (identityType == "external" && externalUserID == "") {
response.BadRequest(c, "internal_user_id or external_user_id is required")
return
}
jackpotPage := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page"), 1, 1_000_000)
jackpotPageSize := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page_size"), 20, 50)
resp, err := h.luckyGift.GetLuckyGiftUserProfile(ctx, &luckygiftv1.GetLuckyGiftUserProfileRequest{
Meta: h.metaForApp(c, appCode), PoolId: poolID, IdentityType: identityType,
InternalUserId: internalUserID, ExternalUserId: externalUserID,
JackpotPage: int32(jackpotPage), JackpotPageSize: int32(jackpotPageSize),
JackpotCursor: strings.TrimSpace(c.Query("jackpot_cursor")),
})
if err != nil {
h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像详情失败")
return
}
users, err := h.loadLuckyGiftInternalUsers(ctx, []*luckygiftv1.LuckyGiftUserProfile{resp.GetProfile()}, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "获取用户资料失败")
return
}
detail := luckyGiftUserProfileDetailDTO{
Profile: luckyGiftUserProfileFromProto(resp.GetProfile(), users[resp.GetProfile().GetInternalUserId()]),
MultiplierDistribution: make([]luckyGiftUserMultiplierStatDTO, 0, len(resp.GetMultiplierDistribution())),
JackpotHits: make([]luckyGiftUserJackpotHitDTO, 0, len(resp.GetJackpotHits())),
JackpotHitTotal: resp.GetJackpotHitTotal(), JackpotPage: resp.GetJackpotPage(),
JackpotPageSize: resp.GetJackpotPageSize(), NextJackpotCursor: resp.GetNextJackpotCursor(),
JackpotHasMore: resp.GetJackpotHasMore(), SnapshotAtMS: resp.GetSnapshotAtMs(),
}
for _, stat := range resp.GetMultiplierDistribution() {
detail.MultiplierDistribution = append(detail.MultiplierDistribution, luckyGiftUserMultiplierStatFromProto(stat))
}
for _, hit := range resp.GetJackpotHits() {
detail.JackpotHits = append(detail.JackpotHits, luckyGiftUserJackpotHitFromProto(hit))
}
response.OK(c, detail)
}
func parseLuckyGiftProfilePositiveInt(value string, fallback, maximum int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
if parsed > maximum {
return maximum
}
return parsed
}
func requiredLuckyGiftProfileScope(c *gin.Context) (string, string, bool) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
poolID := strings.TrimSpace(c.Query("pool_id"))
if appCode == "" || poolID == "" {
response.BadRequest(c, "app_code and pool_id are required")
return "", "", false
}
return appCode, poolID, true
}
func normalizeLuckyGiftProfileIdentity(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
// owner 服务用空值表达“内部与外部”,避免引入第三种持久身份类型。
return "", true
case "internal", "external":
return strings.ToLower(strings.TrimSpace(value)), true
default:
return "", false
}
}
func (h *Handler) resolveLuckyGiftProfileKeyword(ctx context.Context, appCode, identityType, keyword, requestID string) (int64, string, bool, error) {
var externalUserID string
if identityType == "" || identityType == "external" {
// 外部接入没有 HyApp 用户主数据,原样按 external_user_id 精确匹配;稳定策略哈希永不出 owner 服务。
externalUserID = keyword
}
if identityType == "external" {
return 0, externalUserID, false, nil
}
if h.users == nil {
return 0, externalUserID, false, fmt.Errorf("lucky gift user profile resolver is not configured")
}
identity, err := h.users.ResolveAdminUserIdentifier(appctx.WithContext(ctx, appCode), userclient.ResolveAdminUserIdentifierRequest{
RequestID: requestID, Caller: "admin-server", UserIdentifier: keyword,
})
if err == nil && identity != nil && identity.UserID > 0 {
return identity.UserID, externalUserID, true, nil
}
if err != nil {
switch grpcstatus.Code(err) {
case codes.NotFound, codes.InvalidArgument:
// all 模式下 keyword 也可能是任意 external_user_id内部展示号格式不合法只代表内部未命中不能阻断外部精确搜索。
return 0, externalUserID, false, nil
default:
return 0, externalUserID, false, err
}
}
return 0, externalUserID, false, nil
}
func (h *Handler) loadLuckyGiftInternalUsers(ctx context.Context, profiles []*luckygiftv1.LuckyGiftUserProfile, requestID string) (map[int64]*userclient.User, error) {
ids := make([]int64, 0, len(profiles))
seen := make(map[int64]struct{}, len(profiles))
for _, profile := range profiles {
if profile == nil || profile.GetIdentityType() != "internal" || profile.GetInternalUserId() <= 0 {
continue
}
if _, exists := seen[profile.GetInternalUserId()]; exists {
continue
}
seen[profile.GetInternalUserId()] = struct{}{}
ids = append(ids, profile.GetInternalUserId())
}
if len(ids) == 0 {
return map[int64]*userclient.User{}, nil
}
if h.users == nil {
return nil, fmt.Errorf("lucky gift user profile user client is not configured")
}
return h.users.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{RequestID: requestID, Caller: "admin-server", UserIDs: ids})
}
func luckyGiftUserProfileFromProto(profile *luckygiftv1.LuckyGiftUserProfile, user *userclient.User) luckyGiftUserProfileDTO {
if profile == nil {
return luckyGiftUserProfileDTO{}
}
jackpotCount := profile.GetRtpCompensationJackpotCount() + profile.GetCumulativeSpendJackpotCount()
dto := luckyGiftUserProfileDTO{
AppCode: profile.GetAppCode(), PoolID: profile.GetPoolId(), IdentityType: profile.GetIdentityType(),
RuleVersion: profile.GetRuleVersion(),
StrategyVersion: profile.GetStrategyVersion(), Stage: profile.GetStage(), PaidDraws: profile.GetPaidDraws(),
EquivalentDraws: profile.GetEquivalentDraws(), LossStreak: profile.GetLossStreak(),
PendingSpendJackpotTokens: profile.GetPendingSpendJackpotTokens(), GuaranteeDrawsRemaining: profile.GetGuaranteeDrawsRemaining(),
DownweightActive: profile.GetDownweightActive(), DownweightFactorPPM: profile.GetDownweightFactorPpm(),
User24HourRTPThresholdPPM: profile.GetUser_24HRtpThresholdPpm(),
OneHour: luckyGiftUserProfileWindowFromProto(profile.GetOneHour()),
TwentyFourHours: luckyGiftUserProfileWindowFromProto(profile.GetTwentyFourHours()),
FortyEightHours: luckyGiftUserProfileWindowFromProto(profile.GetFortyEightHours()),
Lifetime: luckyGiftUserProfileWindowFromProto(profile.GetLifetime()),
BaseRewardCoins: profile.GetBaseRewardCoins(), OrdinaryWinCount: profile.GetOrdinaryWinCount(),
HighMultiplierOrdinaryWinCount: profile.GetHighMultiplierOrdinaryWinCount(),
RTPCompensationJackpotCount: profile.GetRtpCompensationJackpotCount(),
CumulativeSpendJackpotCount: profile.GetCumulativeSpendJackpotCount(), JackpotWinCount: jackpotCount,
GuaranteeHitCount: profile.GetGuaranteeHitCount(), ZeroDrawCount: profile.GetZeroDrawCount(),
GrantedDrawCount: profile.GetGrantedDrawCount(), PendingDrawCount: profile.GetPendingDrawCount(), FailedDrawCount: profile.GetFailedDrawCount(),
MaxMultiplierPPM: profile.GetMaxMultiplierPpm(), MaxRewardCoins: profile.GetMaxRewardCoins(),
FirstDrawAtMS: profile.GetFirstDrawAtMs(), LastDrawAtMS: profile.GetLastDrawAtMs(),
AggregatedAtMS: profile.GetAggregatedAtMs(), DataLagMS: profile.GetDataLagMs(),
CurrentRTPPPM: profile.GetTwentyFourHours().GetRtpPpm(), HasCurrentRTP: profile.GetTwentyFourHours().GetHasRtp(),
}
if profile.GetIdentityType() == "internal" && profile.GetInternalUserId() > 0 {
// 外部画像即使 owner 发生协议回归误填了 internal_user_id也不得在 admin HTTP 边界透出策略哈希。
dto.InternalUserID = strconv.FormatInt(profile.GetInternalUserId(), 10)
dto.InternalUser = luckyGiftInternalUserFromClient(profile.GetInternalUserId(), user)
} else if profile.GetIdentityType() == "external" {
dto.ExternalUserID = profile.GetExternalUserId()
}
return dto
}
func luckyGiftInternalUserFromClient(userID int64, user *userclient.User) *luckyGiftInternalUserDTO {
dto := &luckyGiftInternalUserDTO{UserID: strconv.FormatInt(userID, 10)}
if user == nil {
return dto
}
dto.DisplayUserID = user.DisplayUserID
dto.DefaultDisplayID = user.DefaultDisplayUserID
dto.PrettyID = user.PrettyID
dto.PrettyDisplayUserID = user.PrettyDisplayUserID
dto.Name = user.Username
dto.Avatar = user.Avatar
dto.Status = user.Status
return dto
}
func luckyGiftUserProfileWindowFromProto(window *luckygiftv1.LuckyGiftUserProfileWindow) luckyGiftUserProfileWindowDTO {
if window == nil {
return luckyGiftUserProfileWindowDTO{}
}
return luckyGiftUserProfileWindowDTO{
Draws: window.GetDraws(), WagerCoins: window.GetWagerCoins(), PayoutCoins: window.GetPayoutCoins(),
NetCoins: window.GetNetCoins(), RTPPPM: window.GetRtpPpm(), HasRTP: window.GetHasRtp(),
}
}
func luckyGiftUserMultiplierStatFromProto(stat *luckygiftv1.LuckyGiftUserMultiplierStat) luckyGiftUserMultiplierStatDTO {
if stat == nil {
return luckyGiftUserMultiplierStatDTO{}
}
return luckyGiftUserMultiplierStatDTO{
HitType: stat.GetHitType(), HitTypeLabel: luckyGiftHitTypeLabel(stat.GetHitType()),
MultiplierPPM: stat.GetMultiplierPpm(), DrawCount: stat.GetDrawCount(),
WagerCoins: stat.GetWagerCoins(), PayoutCoins: stat.GetPayoutCoins(),
}
}
func luckyGiftUserJackpotHitFromProto(hit *luckygiftv1.LuckyGiftUserJackpotHit) luckyGiftUserJackpotHitDTO {
if hit == nil {
return luckyGiftUserJackpotHitDTO{}
}
conditions := make([]luckyGiftUserJackpotConditionDTO, 0, len(hit.GetConditions()))
for _, condition := range hit.GetConditions() {
conditions = append(conditions, luckyGiftUserJackpotConditionFromProto(condition))
}
return luckyGiftUserJackpotHitDTO{
EventKey: hit.GetEventKey(), DrawID: hit.GetDrawId(), RequestID: hit.GetRequestId(), RuleVersion: hit.GetRuleVersion(), Stage: hit.GetStage(),
Mechanism: hit.GetMechanism(), MechanismLabel: luckyGiftJackpotMechanismLabel(hit.GetMechanism()),
ReasonCode: hit.GetReasonCode(), ReasonSummary: luckyGiftJackpotReasonSummary(hit), TierID: hit.GetTierId(),
MultiplierPPM: hit.GetMultiplierPpm(), WagerCoins: hit.GetWagerCoins(), PayoutCoins: hit.GetPayoutCoins(),
OccurredAtMS: hit.GetOccurredAtMs(), Conditions: conditions,
}
}
func luckyGiftUserJackpotConditionFromProto(condition *luckygiftv1.LuckyGiftUserJackpotCondition) luckyGiftUserJackpotConditionDTO {
if condition == nil {
return luckyGiftUserJackpotConditionDTO{}
}
actual, threshold, operator := luckyGiftJackpotConditionValues(condition)
return luckyGiftUserJackpotConditionDTO{
Name: condition.GetName(), Label: luckyGiftJackpotConditionLabel(condition.GetName()),
Actual: actual, Threshold: threshold, Operator: operator,
Numerator: condition.GetNumerator(), Denominator: condition.GetDenominator(),
RatioPPM: condition.GetRatioPpm(), LimitPPM: condition.GetLimitPpm(), FactorPPM: condition.GetFactorPpm(),
Factor: luckyGiftPercent(condition.GetFactorPpm()), Passed: condition.GetPassed(), Reason: condition.GetReason(),
}
}
func luckyGiftJackpotConditionValues(condition *luckygiftv1.LuckyGiftUserJackpotCondition) (string, string, string) {
name := condition.GetName()
switch name {
case "global_rtp", "spend_global_rtp", "user_round_rtp", "user_48h_rtp", "user_day_rtp", "user_72h_rtp":
return fmt.Sprintf("%s返奖 %d / 消耗 %d", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), "≤"
case "user_24h_rtp_downweight":
return fmt.Sprintf("%s返奖 %d / 消耗 %d", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), ">"
case "user_registered_48h":
return luckyGiftDurationHours(condition.GetNumerator()), luckyGiftDurationHours(condition.GetDenominator()), "≥"
case "daily_jackpot_limit", "spend_daily_jackpot_limit":
return fmt.Sprintf("%d 次", condition.GetNumerator()), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<"
case "settled_round_qualification", "payable_jackpot_candidate":
if condition.GetPassed() {
return "已满足", "必须满足", "="
}
return "未满足", "必须满足", "="
default:
if condition.GetLimitPpm() != 0 || condition.GetRatioPpm() != 0 {
return luckyGiftPercent(condition.GetRatioPpm()), luckyGiftPercent(condition.GetLimitPpm()), "≤"
}
if condition.GetPassed() {
return "已满足", "必须满足", "="
}
return "未满足", "必须满足", "="
}
}
func luckyGiftJackpotConditionLabel(name string) string {
labels := map[string]string{
"global_rtp": "全奖池当前返奖率",
"spend_global_rtp": "累计消费大奖检查时的全奖池返奖率",
"settled_round_qualification": "上一结算轮次是否达到补偿条件",
"user_round_rtp": "用户当前结算轮次返奖率",
"user_48h_rtp": "用户近 48 小时返奖率",
"user_registered_48h": "用户注册是否已满 48 小时",
"user_24h_rtp_downweight": "用户近 24 小时普通中奖降权",
"user_day_rtp": "用户当天返奖率",
"user_72h_rtp": "用户近 72 小时返奖率",
"daily_jackpot_limit": "用户当天已中大奖次数",
"spend_daily_jackpot_limit": "累计消费大奖的当天次数限制",
"payable_jackpot_candidate": "奖池余额是否足够支付所选大奖",
}
if label := labels[name]; label != "" {
return label
}
return strings.ReplaceAll(strings.TrimSpace(name), "_", " ")
}
func luckyGiftJackpotMechanismLabel(mechanism string) string {
switch mechanism {
case "rtp_compensation", "jackpot_mechanism_1":
return "RTP 补偿大奖"
case "spend_milestone", "jackpot_mechanism_2":
return "累计消费大奖"
default:
return "大奖"
}
}
func luckyGiftJackpotReasonSummary(hit *luckygiftv1.LuckyGiftUserJackpotHit) string {
switch hit.GetReasonCode() {
case "rtp_compensation_jackpot":
return "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。"
case "milestone_token_jackpot":
return "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。"
}
if summary := strings.TrimSpace(hit.GetReasonSummary()); summary != "" {
return summary
}
return "本次开奖满足下列全部条件,因此命中该大奖。"
}
func luckyGiftHitTypeLabel(hitType string) string {
switch hitType {
case "zero":
return "未中奖"
case "ordinary":
return "普通奖"
case "rtp_compensation":
return "RTP 补偿大奖"
case "spend_milestone":
return "累计消费大奖"
default:
return hitType
}
}
func luckyGiftPercent(ppm int64) string {
value := strconv.FormatFloat(float64(ppm)/10_000, 'f', 2, 64)
value = strings.TrimRight(strings.TrimRight(value, "0"), ".")
if value == "" {
value = "0"
}
return value + "%"
}
func luckyGiftDurationHours(milliseconds int64) string {
value := strconv.FormatFloat(float64(milliseconds)/3_600_000, 'f', 1, 64)
value = strings.TrimRight(strings.TrimRight(value, "0"), ".")
return value + " 小时"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func (h *Handler) writeLuckyGiftUserProfileError(c *gin.Context, err error, fallback string) {
status := grpcstatus.Convert(err)
switch status.Code() {
case codes.InvalidArgument:
response.BadRequest(c, status.Message())
case codes.NotFound:
response.NotFound(c, "未找到该用户的幸运礼物画像")
default:
response.ServerError(c, fallback)
}
}

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