From 1e7c3115d6c8ae3389330fe82fffb8d2f10baa31 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 14 Jul 2026 14:08:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=B4=A2=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/flutter对接/用户排行榜Flutter对接.md | 12 +- server/admin/cmd/server/main.go | 21 - .../admin/configs/config.tencent.example.yaml | 8 - server/admin/configs/config.yaml | 8 - server/admin/internal/config/config.go | 55 -- server/admin/internal/config/config_test.go | 26 - .../internal/integration/dingtalk/client.go | 169 ----- .../integration/dingtalk/client_test.go | 75 --- server/admin/internal/model/models.go | 34 - .../financeapplication/dingtalk_notifier.go | 119 ---- .../dingtalk_notifier_test.go | 42 -- .../modules/financeapplication/handler.go | 180 ----- .../modules/financeapplication/request.go | 16 - .../modules/financeapplication/response.go | 68 -- .../modules/financeapplication/routes.go | 19 - .../modules/financeapplication/service.go | 623 ------------------ .../financeapplication/service_test.go | 269 -------- .../financewithdrawal/permission_test.go | 12 + .../modules/financewithdrawal/routes.go | 6 +- .../modules/financewithdrawal/service.go | 3 +- .../finance_application_repository.go | 127 ---- .../finance_application_repository_test.go | 57 -- .../admin/internal/repository/repository.go | 1 - server/admin/internal/repository/seed.go | 13 +- server/admin/internal/repository/seed_test.go | 42 +- server/admin/internal/router/router.go | 3 - .../092_remove_finance_applications.sql | 39 ++ .../dingtalk_notifier_test.go | 35 + .../http/activityapi/level_handler.go | 168 +++-- .../http/activityapi/level_handler_test.go | 148 +++++ .../activityapi/user_leaderboard_handler.go | 100 ++- .../user_leaderboard_handler_test.go | 22 + .../internal/transport/http/response_test.go | 21 + 33 files changed, 535 insertions(+), 2006 deletions(-) delete mode 100644 server/admin/internal/integration/dingtalk/client.go delete mode 100644 server/admin/internal/integration/dingtalk/client_test.go delete mode 100644 server/admin/internal/modules/financeapplication/dingtalk_notifier.go delete mode 100644 server/admin/internal/modules/financeapplication/dingtalk_notifier_test.go delete mode 100644 server/admin/internal/modules/financeapplication/handler.go delete mode 100644 server/admin/internal/modules/financeapplication/request.go delete mode 100644 server/admin/internal/modules/financeapplication/response.go delete mode 100644 server/admin/internal/modules/financeapplication/routes.go delete mode 100644 server/admin/internal/modules/financeapplication/service.go delete mode 100644 server/admin/internal/modules/financeapplication/service_test.go create mode 100644 server/admin/internal/modules/financewithdrawal/permission_test.go delete mode 100644 server/admin/internal/repository/finance_application_repository.go delete mode 100644 server/admin/internal/repository/finance_application_repository_test.go create mode 100644 server/admin/migrations/092_remove_finance_applications.sql create mode 100644 services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go create mode 100644 services/gateway-service/internal/transport/http/activityapi/level_handler_test.go diff --git a/docs/flutter对接/用户排行榜Flutter对接.md b/docs/flutter对接/用户排行榜Flutter对接.md index 6a056840..4fe78485 100644 --- a/docs/flutter对接/用户排行榜Flutter对接.md +++ b/docs/flutter对接/用户排行榜Flutter对接.md @@ -134,7 +134,10 @@ X-App-Code: lalu "user_id": "10001", "display_user_id": "888888", "username": "Luna", - "avatar": "https://cdn.example/avatar/10001.png" + "avatar": "https://cdn.example/avatar/10001.png", + "short_badge_cover_urls": [ + "https://cdn.example/badges/short-1.png" + ] } } ], @@ -164,6 +167,8 @@ X-App-Code: lalu } ``` +`user.short_badge_cover_urls` 只包含用户当前佩戴的普通短徽章静态封面,长徽章和等级徽章不会混入。用户没有短徽章时字段省略,客户端按空数组处理即可;房间榜没有用户徽章字段。 + 房间榜返回示例: ```json @@ -358,12 +363,14 @@ class LeaderboardUser { required this.displayUserId, required this.username, required this.avatar, + required this.shortBadgeCoverUrls, }); final String userId; final String displayUserId; final String username; final String avatar; + final List shortBadgeCoverUrls; factory LeaderboardUser.fromJson(Map json) { return LeaderboardUser( @@ -371,6 +378,9 @@ class LeaderboardUser { displayUserId: json['display_user_id'] as String? ?? '', username: json['username'] as String? ?? '', avatar: json['avatar'] as String? ?? '', + shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List? ?? const []) + .whereType() + .toList(), ); } } diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 779bf7ba..02dbe155 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -18,7 +18,6 @@ import ( "hyapp-admin-server/internal/cache" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/integration/activityclient" - dingtalkclient "hyapp-admin-server/internal/integration/dingtalk" "hyapp-admin-server/internal/integration/gameclient" "hyapp-admin-server/internal/integration/googleorders" "hyapp-admin-server/internal/integration/luckygiftadmin" @@ -44,7 +43,6 @@ import ( dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dashboardmodule "hyapp-admin-server/internal/modules/dashboard" databimodule "hyapp-admin-server/internal/modules/databi" - financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication" financeordermodule "hyapp-admin-server/internal/modules/financeorder" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" @@ -320,24 +318,6 @@ func main() { } objectUploader = uploader } - var financeApplicationOptions []financeapplicationmodule.Option - if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" { - client, err := dingtalkclient.New(dingtalkclient.Config{ - WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL, - Secret: cfg.FinanceNotifications.DingTalk.Secret, - AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles, - AtAll: cfg.FinanceNotifications.DingTalk.AtAll, - RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout, - }, nil) - if err != nil { - fatalRuntime("create_finance_dingtalk_client_failed", err) - } - financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client))) - } - financeApplicationOptions = append(financeApplicationOptions, - financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)), - financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)), - ) // 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。 dashboardService := dashboardmodule.NewService( store, @@ -387,7 +367,6 @@ func main() { Dashboard: dashboardmodule.NewWithService(dashboardService), Databi: databimodule.New(databiService, store, auditHandler), FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), - FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...), FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)), FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index 9a5d5421..1a5f190f 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -151,14 +151,6 @@ legacy_coin_seller_recharge_sources: app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai" wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai" request_timeout: "5s" -finance_notifications: - dingtalk: - enabled: true - webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c" - secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03" - at_mobiles: [] - at_all: false - request_timeout: "3s" tencent-cos: enabled: true secret-id: "REPLACE_ME" diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index e0b5310f..30734e78 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -148,14 +148,6 @@ legacy_coin_seller_recharge_sources: app_mysql_dsn: "" wallet_mysql_dsn: "" request_timeout: "5s" -finance_notifications: - dingtalk: - enabled: true - webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c" - secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03" - at_mobiles: [] - at_all: false - request_timeout: "3s" tencent-cos: enabled: true secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu" diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index 5d5f2877..3b2dcd1d 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -49,7 +49,6 @@ type Config struct { StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` - FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"` } type MigrationConfig struct { @@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct { RequestTimeout time.Duration `yaml:"request_timeout"` } -type FinanceNotificationsConfig struct { - DingTalk DingTalkRobotConfig `yaml:"dingtalk"` -} - -type DingTalkRobotConfig struct { - Enabled bool `yaml:"enabled"` - WebhookURL string `yaml:"webhook_url"` - Secret string `yaml:"secret"` - AtMobiles []string `yaml:"at_mobiles"` - AtAll bool `yaml:"at_all"` - RequestTimeout time.Duration `yaml:"request_timeout"` -} - type RobotProfileSourceConfig struct { MySQLDSN string `yaml:"mysql_dsn"` RequestTimeout time.Duration `yaml:"request_timeout"` @@ -372,12 +358,6 @@ func Default() Config { RequestTimeout: 5 * time.Second, }, }, - FinanceNotifications: FinanceNotificationsConfig{ - DingTalk: DingTalkRobotConfig{ - Enabled: true, - RequestTimeout: 3 * time.Second, - }, - }, FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ Enabled: true, PollInterval: 15 * time.Second, @@ -575,13 +555,6 @@ func (cfg *Config) Normalize() { } } cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() - cfg.applyFinanceNotificationEnvOverrides() - cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) - cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret) - cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles) - if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 { - cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second - } cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) if cfg.RobotProfileSource.RequestTimeout <= 0 { cfg.RobotProfileSource.RequestTimeout = 5 * time.Second @@ -834,14 +807,6 @@ func (cfg Config) Validate() error { return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index) } } - if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" { - if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") { - return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url") - } - } - if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 { - return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0") - } if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") } @@ -925,26 +890,6 @@ func (cfg Config) Validate() error { return nil } -func (cfg *Config) applyFinanceNotificationEnvOverrides() { - if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" { - cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value) - } - if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" { - // 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。 - cfg.FinanceNotifications.DingTalk.WebhookURL = value - cfg.FinanceNotifications.DingTalk.Enabled = true - } - if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" { - cfg.FinanceNotifications.DingTalk.Secret = value - } - if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" { - cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",") - } - if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" { - cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value) - } -} - func (cfg *Config) applyDashboardExternalSourceEnvOverrides() { for index := range cfg.DashboardExternalSources { source := &cfg.DashboardExternalSources[index] diff --git a/server/admin/internal/config/config_test.go b/server/admin/internal/config/config_test.go index 8a58963c..0e2f1c05 100644 --- a/server/admin/internal/config/config_test.go +++ b/server/admin/internal/config/config_test.go @@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) { if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" { t.Fatalf("TencentCOS = %#v", cfg.TencentCOS) } - if !cfg.FinanceNotifications.DingTalk.Enabled { - t.Fatalf("finance dingtalk notification should be enabled by default") - } - if cfg.FinanceNotifications.DingTalk.WebhookURL == "" || cfg.FinanceNotifications.DingTalk.Secret == "" { - t.Fatalf("finance dingtalk notification config should be populated: %#v", cfg.FinanceNotifications.DingTalk) - } } func TestLoadEmptyPathUsesDefault(t *testing.T) { @@ -53,26 +47,6 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) { } } -func TestFinanceDingTalkEnvOverride(t *testing.T) { - t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "https://oapi.dingtalk.com/robot/send?access_token=test") - t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "SEC_TEST") - t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES", "13800138000, 13900139000") - t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL", "false") - - cfg := Default() - cfg.Normalize() - - if !cfg.FinanceNotifications.DingTalk.Enabled { - t.Fatal("DingTalk should be enabled when webhook env is provided") - } - if cfg.FinanceNotifications.DingTalk.Secret != "SEC_TEST" { - t.Fatalf("secret mismatch: %q", cfg.FinanceNotifications.DingTalk.Secret) - } - if len(cfg.FinanceNotifications.DingTalk.AtMobiles) != 2 { - t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles) - } -} - 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") diff --git a/server/admin/internal/integration/dingtalk/client.go b/server/admin/internal/integration/dingtalk/client.go deleted file mode 100644 index 7a16edbc..00000000 --- a/server/admin/internal/integration/dingtalk/client.go +++ /dev/null @@ -1,169 +0,0 @@ -package dingtalk - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -type Config struct { - WebhookURL string - Secret string - AtMobiles []string - AtAll bool - RequestTimeout time.Duration -} - -type MarkdownMessage struct { - Title string - Text string -} - -type Client struct { - webhookURL string - secret string - atMobiles []string - atAll bool - httpClient *http.Client - now func() time.Time -} - -func New(config Config, httpClient *http.Client) (*Client, error) { - webhookURL := strings.TrimSpace(config.WebhookURL) - if webhookURL == "" { - return nil, errors.New("dingtalk webhook url is required") - } - if _, err := url.ParseRequestURI(webhookURL); err != nil { - return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err) - } - timeout := config.RequestTimeout - if timeout <= 0 { - timeout = 3 * time.Second - } - if httpClient == nil { - httpClient = &http.Client{Timeout: timeout} - } - return &Client{ - webhookURL: webhookURL, - secret: strings.TrimSpace(config.Secret), - atMobiles: compactStrings(config.AtMobiles), - atAll: config.AtAll, - httpClient: httpClient, - now: func() time.Time { return time.Now().UTC() }, - }, nil -} - -func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error { - if c == nil || c.httpClient == nil { - return errors.New("dingtalk client is not configured") - } - title := strings.TrimSpace(message.Title) - text := strings.TrimSpace(message.Text) - if title == "" || text == "" { - return errors.New("dingtalk markdown title and text are required") - } - body, err := json.Marshal(markdownPayload{ - MsgType: "markdown", - Markdown: markdownBody{ - Title: title, - Text: text, - }, - At: atBody{ - AtMobiles: c.atMobiles, - IsAtAll: c.atAll, - }, - }) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if err != nil { - return err - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("dingtalk returned http %d: %s", resp.StatusCode, strings.TrimSpace(string(payload))) - } - var result sendResponse - if err := json.Unmarshal(payload, &result); err != nil { - return fmt.Errorf("decode dingtalk response: %w", err) - } - if result.ErrCode != 0 { - return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg) - } - return nil -} - -func (c *Client) signedWebhookURL() string { - if c.secret == "" { - return c.webhookURL - } - parsed, err := url.Parse(c.webhookURL) - if err != nil { - return c.webhookURL - } - timestamp := c.now().UnixMilli() - query := parsed.Query() - query.Set("timestamp", fmt.Sprintf("%d", timestamp)) - query.Set("sign", sign(timestamp, c.secret)) - parsed.RawQuery = query.Encode() - return parsed.String() -} - -func sign(timestamp int64, secret string) string { - // 钉钉加签要求 timestamp + "\n" + secret 作为明文,secret 同时作为 HMAC key;签名再 Base64 并作为 URL query 参数传递。 - payload := fmt.Sprintf("%d\n%s", timestamp, secret) - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(payload)) - return base64.StdEncoding.EncodeToString(mac.Sum(nil)) -} - -func compactStrings(values []string) []string { - out := make([]string, 0, len(values)) - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - out = append(out, trimmed) - } - } - return out -} - -type markdownPayload struct { - MsgType string `json:"msgtype"` - Markdown markdownBody `json:"markdown"` - At atBody `json:"at"` -} - -type markdownBody struct { - Title string `json:"title"` - Text string `json:"text"` -} - -type atBody struct { - AtMobiles []string `json:"atMobiles"` - IsAtAll bool `json:"isAtAll"` -} - -type sendResponse struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` -} diff --git a/server/admin/internal/integration/dingtalk/client_test.go b/server/admin/internal/integration/dingtalk/client_test.go deleted file mode 100644 index 946b0128..00000000 --- a/server/admin/internal/integration/dingtalk/client_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package dingtalk - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestSendMarkdownSignsRequestAndSendsPayload(t *testing.T) { - var captured struct { - Query map[string]string - Body markdownPayload - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured.Query = map[string]string{ - "access_token": r.URL.Query().Get("access_token"), - "timestamp": r.URL.Query().Get("timestamp"), - "sign": r.URL.Query().Get("sign"), - } - if err := json.NewDecoder(r.Body).Decode(&captured.Body); err != nil { - t.Fatalf("decode request body: %v", err) - } - _, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`)) - })) - defer server.Close() - - client, err := New(Config{ - WebhookURL: server.URL + "?access_token=token", - Secret: "SEC_TEST", - AtMobiles: []string{" 13800138000 ", ""}, - AtAll: false, - }, server.Client()) - if err != nil { - t.Fatalf("New() error = %v", err) - } - client.now = func() time.Time { return time.UnixMilli(1710000000123).UTC() } - - if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "### 财务申请待审核"}); err != nil { - t.Fatalf("SendMarkdown() error = %v", err) - } - if captured.Query["access_token"] != "token" { - t.Fatalf("access token mismatch: %q", captured.Query["access_token"]) - } - if captured.Query["timestamp"] != "1710000000123" { - t.Fatalf("timestamp mismatch: %q", captured.Query["timestamp"]) - } - if captured.Query["sign"] != sign(1710000000123, "SEC_TEST") { - t.Fatalf("sign mismatch: %q", captured.Query["sign"]) - } - if captured.Body.MsgType != "markdown" || captured.Body.Markdown.Title != "财务申请" || captured.Body.Markdown.Text == "" { - t.Fatalf("markdown payload mismatch: %+v", captured.Body) - } - if len(captured.Body.At.AtMobiles) != 1 || captured.Body.At.AtMobiles[0] != "13800138000" { - t.Fatalf("at mobiles mismatch: %+v", captured.Body.At.AtMobiles) - } -} - -func TestSendMarkdownReturnsDingTalkError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"errcode":310000,"errmsg":"keywords not in content"}`)) - })) - defer server.Close() - - client, err := New(Config{WebhookURL: server.URL}, server.Client()) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "text"}); err == nil { - t.Fatal("SendMarkdown() error = nil, want dingtalk errcode") - } -} diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index cc2d3b04..fc458270 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -14,10 +14,6 @@ const ( JobStatusFailed = "failed" JobStatusCanceled = "canceled" - FinanceApplicationStatusPending = "pending" - FinanceApplicationStatusApproved = "approved" - FinanceApplicationStatusRejected = "rejected" - CoinSellerRechargeOrderStatusCreated = "created" CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed" CoinSellerRechargeOrderStatusVerified = "verified" @@ -567,36 +563,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string { return "admin_legacy_google_paid_sync_attempts" } -type FinanceApplication struct { - ID uint `gorm:"primaryKey" json:"id"` - AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"` - Operation string `gorm:"size:64;index:idx_admin_finance_app_operation;not null" json:"operation"` - WalletIdentity string `gorm:"size:32;not null;default:''" json:"walletIdentity"` - TargetUserID string `gorm:"size:64;index:idx_admin_finance_app_target;not null" json:"targetUserId"` - CoinAmount int64 `gorm:"not null;default:0" json:"coinAmount"` - RechargeAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"rechargeAmount"` - CredentialImageURL string `gorm:"size:1024;not null;default:''" json:"credentialImageUrl"` - CredentialText string `gorm:"type:text" json:"credentialText"` - ApplicantUserID uint `gorm:"index:idx_admin_finance_app_applicant;not null" json:"applicantUserId"` - ApplicantName string `gorm:"size:64;not null;default:''" json:"applicantName"` - AuditorUserID *uint `gorm:"index:idx_admin_finance_app_auditor" json:"auditorUserId"` - AuditorName string `gorm:"size:64;not null;default:''" json:"auditorName"` - Status string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null;default:pending" json:"status"` - AuditRemark string `gorm:"type:text" json:"auditRemark"` - AuditedAtMS *int64 `gorm:"column:audited_at_ms" json:"auditedAtMs"` - WalletCommandID string `gorm:"size:128;not null;default:''" json:"walletCommandId"` - WalletTransactionID string `gorm:"size:128;not null;default:''" json:"walletTransactionId"` - WalletAssetType string `gorm:"size:64;not null;default:''" json:"walletAssetType"` - WalletAmountDelta int64 `gorm:"not null;default:0" json:"walletAmountDelta"` - WalletBalanceAfter int64 `gorm:"not null;default:0" json:"walletBalanceAfter"` - CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` - UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` -} - -func (FinanceApplication) TableName() string { - return "admin_finance_applications" -} - // CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。 type CoinSellerRechargeOrder struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/server/admin/internal/modules/financeapplication/dingtalk_notifier.go b/server/admin/internal/modules/financeapplication/dingtalk_notifier.go deleted file mode 100644 index 5f4e5f43..00000000 --- a/server/admin/internal/modules/financeapplication/dingtalk_notifier.go +++ /dev/null @@ -1,119 +0,0 @@ -package financeapplication - -import ( - "context" - "fmt" - "strings" - "time" - - "hyapp-admin-server/internal/integration/dingtalk" -) - -type dingTalkNotifier struct { - client *dingtalk.Client -} - -func NewDingTalkNotifier(client *dingtalk.Client) ApplicationNotifier { - if client == nil { - return nil - } - return &dingTalkNotifier{client: client} -} - -func (n *dingTalkNotifier) NotifyApplicationCreated(ctx context.Context, application applicationDTO) error { - if n == nil || n.client == nil { - return nil - } - return n.client.SendMarkdown(ctx, dingtalk.MarkdownMessage{ - Title: "财务申请待审核", - Text: financeApplicationCreatedMarkdown(application), - }) -} - -func financeApplicationCreatedMarkdown(application applicationDTO) string { - lines := []string{ - "### 财务申请待审核", - "", - fmt.Sprintf("- 申请ID:%d", application.ID), - fmt.Sprintf("- APP:%s", markdownValue(application.AppCode)), - fmt.Sprintf("- 操作:%s", markdownValue(operationLabel(application.Operation))), - fmt.Sprintf("- 目标用户ID:%s", markdownValue(application.TargetUserID)), - fmt.Sprintf("- 金币数量:%d", application.CoinAmount), - fmt.Sprintf("- 充值金额 $ %s", markdownValue(application.RechargeAmount)), - fmt.Sprintf("- 申请人:%s", markdownValue(actorLabel(application.ApplicantName, application.ApplicantUserID))), - fmt.Sprintf("- 发起时间:%s", markdownValue(formatNotifyMillis(application.CreatedAtMS))), - "- 后台地址:https://admin-acc.global-interaction.com/finance/", - } - if application.WalletIdentity != "" { - lines = append(lines, fmt.Sprintf("- 钱包身份:%s", markdownValue(walletIdentityLabel(application.WalletIdentity)))) - } - if application.CredentialText != "" { - lines = append(lines, fmt.Sprintf("- 凭证文字:%s", markdownValue(application.CredentialText))) - } - if application.CredentialImageURL != "" { - imageURL := strings.TrimSpace(application.CredentialImageURL) - lines = append(lines, "- 凭证图片:", fmt.Sprintf("![凭证图片](%s)", imageURL)) - } - return strings.Join(lines, "\n") -} - -func operationLabel(value string) string { - switch value { - case "user_coin_credit": - return "增加用户金币" - case "user_coin_debit": - return "减少用户金币" - case "user_wallet_debit": - return "用户钱包扣减" - case "user_wallet_credit": - return "用户钱包增加" - case "coin_seller_coin_credit": - return "增加币商金币" - case "coin_seller_coin_debit": - return "减少币商金币" - default: - return value - } -} - -func walletIdentityLabel(value string) string { - switch value { - case "host": - return "host" - case "agency": - return "agency" - case "bd": - return "bd" - default: - return value - } -} - -func actorLabel(name string, userID uint) string { - name = strings.TrimSpace(name) - if name == "" { - return fmt.Sprintf("%d", userID) - } - return fmt.Sprintf("%s(%d)", name, userID) -} - -func formatNotifyMillis(value int64) string { - if value <= 0 { - return "-" - } - // 财务审批群主要按北京时间协作;数据库仍保存 Unix 毫秒,这里只在通知文本中转换展示。 - return time.UnixMilli(value).In(time.FixedZone("UTC+8", 8*60*60)).Format("2006-01-02 15:04:05") -} - -func markdownValue(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - if len([]rune(value)) > 240 { - runes := []rune(value) - value = string(runes[:240]) + "..." - } - replacer := strings.NewReplacer("\r", " ", "\n", " ", "\t", " ") - return replacer.Replace(value) -} diff --git a/server/admin/internal/modules/financeapplication/dingtalk_notifier_test.go b/server/admin/internal/modules/financeapplication/dingtalk_notifier_test.go deleted file mode 100644 index be71c94f..00000000 --- a/server/admin/internal/modules/financeapplication/dingtalk_notifier_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package financeapplication - -import ( - "strings" - "testing" - "time" -) - -func TestFinanceApplicationCreatedMarkdown(t *testing.T) { - text := financeApplicationCreatedMarkdown(applicationDTO{ - ID: 12, - AppCode: "lalu", - Operation: "coin_seller_coin_credit", - TargetUserID: "10001", - CoinAmount: 1000, - RechargeAmount: "10.50", - CredentialImageURL: "https://example.com/receipt.png", - CredentialText: "bank order\nabc", - ApplicantUserID: 7, - ApplicantName: "ops", - CreatedAtMS: time.Date(2026, 7, 1, 1, 2, 3, 0, time.UTC).UnixMilli(), - }) - - for _, want := range []string{ - "### 财务申请待审核", - "- APP:lalu", - "- 操作:增加币商金币", - "- 目标用户ID:10001", - "- 金币数量:1000", - "- 充值金额 $ 10.50", - "- 申请人:ops(7)", - "- 发起时间:2026-07-01 09:02:03", - "- 后台地址:https://admin-acc.global-interaction.com/finance/", - "- 凭证文字:bank order abc", - "- 凭证图片:", - "![凭证图片](https://example.com/receipt.png)", - } { - if !strings.Contains(text, want) { - t.Fatalf("markdown missing %q:\n%s", want, text) - } - } -} diff --git a/server/admin/internal/modules/financeapplication/handler.go b/server/admin/internal/modules/financeapplication/handler.go deleted file mode 100644 index d23b4248..00000000 --- a/server/admin/internal/modules/financeapplication/handler.go +++ /dev/null @@ -1,180 +0,0 @@ -package financeapplication - -import ( - "errors" - "strconv" - "strings" - - "hyapp-admin-server/internal/middleware" - "hyapp-admin-server/internal/model" - "hyapp-admin-server/internal/modules/shared" - "hyapp-admin-server/internal/repository" - "hyapp-admin-server/internal/response" - - "github.com/gin-gonic/gin" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "gorm.io/gorm" -) - -type Handler struct { - service *Service - audit shared.OperationLogger -} - -func New(store *repository.Store, audit shared.OperationLogger, options ...Option) *Handler { - return &Handler{service: NewService(store, options...), audit: audit} -} - -func (h *Handler) ListApplications(c *gin.Context) { - options := shared.ListOptions(c) - items, total, err := h.service.ListApplications(repository.FinanceApplicationListOptions{ - Page: options.Page, - PageSize: options.PageSize, - AppCode: firstQuery(c, "app_code", "appCode"), - Operation: firstQuery(c, "operation"), - Status: options.Status, - Keyword: options.Keyword, - }) - if err != nil { - response.ServerError(c, "获取财务申请列表失败") - return - } - response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) -} - -func (h *Handler) ListMyApplications(c *gin.Context) { - options := shared.ListOptions(c) - items, total, err := h.service.ListMyApplications(shared.ActorFromContext(c), repository.FinanceApplicationListOptions{ - Page: options.Page, - PageSize: options.PageSize, - AppCode: firstQuery(c, "app_code", "appCode"), - Operation: firstQuery(c, "operation"), - Status: options.Status, - Keyword: options.Keyword, - }) - if err != nil { - writeServiceError(c, err, "获取我的财务申请列表失败") - return - } - response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) -} - -func (h *Handler) CreateApplication(c *gin.Context) { - var req createApplicationRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "财务申请参数不正确") - return - } - item, err := h.service.CreateApplication(c.Request.Context(), shared.ActorFromContext(c), req) - if err != nil { - writeServiceError(c, err, "发起财务申请失败") - return - } - shared.OperationLogWithResourceID(c, h.audit, "create-finance-application", "admin_finance_applications", idString(item.ID), "success", item.Operation) - response.Created(c, item) -} - -func (h *Handler) ApproveApplication(c *gin.Context) { - h.auditApplication(c, model.FinanceApplicationStatusApproved) -} - -func (h *Handler) RejectApplication(c *gin.Context) { - h.auditApplication(c, model.FinanceApplicationStatusRejected) -} - -func (h *Handler) AuditApplication(c *gin.Context) { - var req struct { - Decision string `json:"decision"` - AuditRemark string `json:"auditRemark"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "审核参数不正确") - return - } - switch strings.TrimSpace(req.Decision) { - case model.FinanceApplicationStatusApproved: - h.auditApplicationWithRemark(c, model.FinanceApplicationStatusApproved, req.AuditRemark) - case model.FinanceApplicationStatusRejected: - h.auditApplicationWithRemark(c, model.FinanceApplicationStatusRejected, req.AuditRemark) - default: - response.BadRequest(c, "审核结果不正确") - } -} - -func (h *Handler) auditApplication(c *gin.Context, decision string) { - var req auditApplicationRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "审核参数不正确") - return - } - h.auditApplicationWithRemark(c, decision, req.AuditRemark) -} - -func (h *Handler) auditApplicationWithRemark(c *gin.Context, decision string, remark string) { - id, ok := shared.ParseID(c, "application_id") - if !ok { - return - } - var ( - item *applicationDTO - err error - ) - if decision == model.FinanceApplicationStatusApproved { - item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c)) - } else { - item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c)) - } - if err != nil { - writeServiceError(c, err, "审核财务申请失败") - return - } - shared.OperationLogWithResourceID(c, h.audit, "audit-finance-application", "admin_finance_applications", idString(item.ID), "success", decision) - response.OK(c, item) -} - -func writeServiceError(c *gin.Context, err error, fallback string) { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.NotFound(c, "财务申请不存在") - return - } - if errors.Is(err, repository.ErrFinanceApplicationAlreadyAudited) { - response.BadRequest(c, "申请已审核") - return - } - switch err.Error() { - case "没有操作权限": - response.Forbidden(c, err.Error()) - case "admin store is not configured", "finance wallet executor is not configured": - response.ServerError(c, fallback) - default: - if status.Code(err) == codes.NotFound { - response.NotFound(c, err.Error()) - return - } - if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" { - switch st.Code() { - case codes.PermissionDenied: - response.Forbidden(c, st.Message()) - return - case codes.InvalidArgument, codes.FailedPrecondition, codes.AlreadyExists, codes.Aborted, codes.ResourceExhausted: - response.BadRequest(c, st.Message()) - return - } - } - response.BadRequest(c, err.Error()) - } -} - -func firstQuery(c *gin.Context, keys ...string) string { - for _, key := range keys { - if value := strings.TrimSpace(c.Query(key)); value != "" { - return value - } - } - return "" -} - -func idString(id uint) string { - return strconv.FormatUint(uint64(id), 10) -} diff --git a/server/admin/internal/modules/financeapplication/request.go b/server/admin/internal/modules/financeapplication/request.go deleted file mode 100644 index 9e1cdd5e..00000000 --- a/server/admin/internal/modules/financeapplication/request.go +++ /dev/null @@ -1,16 +0,0 @@ -package financeapplication - -type createApplicationRequest struct { - AppCode string `json:"appCode"` - Operation string `json:"operation"` - WalletIdentity string `json:"walletIdentity"` - TargetUserID string `json:"targetUserId"` - CoinAmount int64 `json:"coinAmount"` - RechargeAmount float64 `json:"rechargeAmount"` - CredentialImageURL string `json:"credentialImageUrl"` - CredentialText string `json:"credentialText"` -} - -type auditApplicationRequest struct { - AuditRemark string `json:"auditRemark"` -} diff --git a/server/admin/internal/modules/financeapplication/response.go b/server/admin/internal/modules/financeapplication/response.go deleted file mode 100644 index 0acf51bd..00000000 --- a/server/admin/internal/modules/financeapplication/response.go +++ /dev/null @@ -1,68 +0,0 @@ -package financeapplication - -import "hyapp-admin-server/internal/model" - -type applicationDTO struct { - ID uint `json:"id"` - AppCode string `json:"appCode"` - AppName string `json:"appName"` - Operation string `json:"operation"` - WalletIdentity string `json:"walletIdentity"` - TargetUserID string `json:"targetUserId"` - CoinAmount int64 `json:"coinAmount"` - RechargeAmount string `json:"rechargeAmount"` - CredentialImageURL string `json:"credentialImageUrl"` - CredentialText string `json:"credentialText"` - ApplicantUserID uint `json:"applicantUserId"` - ApplicantName string `json:"applicantName"` - AuditorUserID *uint `json:"auditorUserId"` - AuditorName string `json:"auditorName"` - Status string `json:"status"` - AuditRemark string `json:"auditRemark"` - AuditedAtMS *int64 `json:"auditedAtMs"` - WalletCommandID string `json:"walletCommandId"` - WalletTransactionID string `json:"walletTransactionId"` - WalletAssetType string `json:"walletAssetType"` - WalletAmountDelta int64 `json:"walletAmountDelta"` - WalletBalanceAfter int64 `json:"walletBalanceAfter"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` - DingTalkNotified *bool `json:"dingTalkNotified,omitempty"` -} - -func applicationDTOFromModel(item model.FinanceApplication) applicationDTO { - return applicationDTO{ - ID: item.ID, - AppCode: item.AppCode, - AppName: item.AppCode, - Operation: item.Operation, - WalletIdentity: item.WalletIdentity, - TargetUserID: item.TargetUserID, - CoinAmount: item.CoinAmount, - RechargeAmount: item.RechargeAmount, - CredentialImageURL: item.CredentialImageURL, - CredentialText: item.CredentialText, - ApplicantUserID: item.ApplicantUserID, - ApplicantName: item.ApplicantName, - AuditorUserID: item.AuditorUserID, - AuditorName: item.AuditorName, - Status: item.Status, - AuditRemark: item.AuditRemark, - AuditedAtMS: item.AuditedAtMS, - WalletCommandID: item.WalletCommandID, - WalletTransactionID: item.WalletTransactionID, - WalletAssetType: item.WalletAssetType, - WalletAmountDelta: item.WalletAmountDelta, - WalletBalanceAfter: item.WalletBalanceAfter, - CreatedAtMS: item.CreatedAtMS, - UpdatedAtMS: item.UpdatedAtMS, - } -} - -func applicationDTOsFromModel(items []model.FinanceApplication) []applicationDTO { - out := make([]applicationDTO, 0, len(items)) - for _, item := range items { - out = append(out, applicationDTOFromModel(item)) - } - return out -} diff --git a/server/admin/internal/modules/financeapplication/routes.go b/server/admin/internal/modules/financeapplication/routes.go deleted file mode 100644 index 8de4f432..00000000 --- a/server/admin/internal/modules/financeapplication/routes.go +++ /dev/null @@ -1,19 +0,0 @@ -package financeapplication - -import ( - "hyapp-admin-server/internal/middleware" - - "github.com/gin-gonic/gin" -) - -func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { - if h == nil { - return - } - protected.GET("/admin/finance/applications", middleware.RequirePermission(permissionAuditApplication), h.ListApplications) - protected.GET("/admin/finance/applications/mine", middleware.RequirePermission(permissionCreateApplication), h.ListMyApplications) - protected.POST("/admin/finance/applications", middleware.RequirePermission(permissionCreateApplication), h.CreateApplication) - protected.POST("/admin/finance/applications/:application_id/approve", middleware.RequirePermission(permissionAuditApplication), h.ApproveApplication) - protected.POST("/admin/finance/applications/:application_id/reject", middleware.RequirePermission(permissionAuditApplication), h.RejectApplication) - protected.POST("/admin/finance/applications/:application_id/audit", middleware.RequirePermission(permissionAuditApplication), h.AuditApplication) -} diff --git a/server/admin/internal/modules/financeapplication/service.go b/server/admin/internal/modules/financeapplication/service.go deleted file mode 100644 index e0a97b53..00000000 --- a/server/admin/internal/modules/financeapplication/service.go +++ /dev/null @@ -1,623 +0,0 @@ -package financeapplication - -import ( - "context" - "errors" - "fmt" - "log/slog" - "math" - "strconv" - "strings" - "time" - - "hyapp-admin-server/internal/appctx" - "hyapp-admin-server/internal/integration/userclient" - "hyapp-admin-server/internal/integration/walletclient" - "hyapp-admin-server/internal/model" - "hyapp-admin-server/internal/modules/shared" - "hyapp-admin-server/internal/repository" - walletv1 "hyapp.local/api/proto/wallet/v1" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -const ( - permissionCreateApplication = "finance-application:create" - permissionAuditApplication = "finance-application:audit" - - financeInternalUserIDMinDigits = 15 - - financeAssetCoin = "COIN" - financeAssetCoinSellerCoin = "COIN_SELLER_COIN" - financeAssetHostSalaryUSD = "HOST_SALARY_USD" - financeAssetAgencySalaryUSD = "AGENCY_SALARY_USD" - financeAssetBDSalaryUSD = "BD_SALARY_USD" - - financeStockTypeUSDTPurchase = "usdt_purchase" - financeStockTypeUSDTDeduction = "usdt_deduction" -) - -var ( - errInvalidCreateInput = errors.New("财务申请参数不正确") - errInvalidAuditInput = errors.New("审核参数不正确") -) - -type operationDefinition struct { - Value string - PermissionCode string - RequiresWalletIdentity bool - AllowsZeroRechargeAmount bool -} - -type Service struct { - store *repository.Store - wallet walletclient.Client - user userclient.Client - now func() time.Time - notifier ApplicationNotifier -} - -type Option func(*Service) - -type ApplicationNotifier interface { - NotifyApplicationCreated(ctx context.Context, application applicationDTO) error -} - -func WithWalletClient(wallet walletclient.Client) Option { - return func(service *Service) { - service.wallet = wallet - } -} - -func WithUserClient(user userclient.Client) Option { - return func(service *Service) { - service.user = user - } -} - -func WithApplicationNotifier(notifier ApplicationNotifier) Option { - return func(service *Service) { - service.notifier = notifier - } -} - -func NewService(store *repository.Store, options ...Option) *Service { - service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }} - for _, option := range options { - if option != nil { - option(service) - } - } - return service -} - -func (s *Service) CreateApplication(ctx context.Context, actor shared.Actor, req createApplicationRequest) (*applicationDTO, error) { - if s == nil || s.store == nil { - return nil, errors.New("admin store is not configured") - } - input, err := s.normalizeCreateInput(actor, req) - if err != nil { - return nil, err - } - if err := s.store.CreateFinanceApplication(&input); err != nil { - return nil, err - } - item, err := s.store.GetFinanceApplication(input.ID) - if err != nil { - return nil, err - } - dto := applicationDTOFromModel(*item) - notified := false - if s.notifier != nil { - // 申请落库后再通知财务群;通知失败只影响提醒,不回滚申请,避免外部机器人抖动造成业务申请丢失。 - if err := s.notifier.NotifyApplicationCreated(ctx, dto); err != nil { - slog.Warn("finance_application_dingtalk_notify_failed", "application_id", dto.ID, "app_code", dto.AppCode, "target_user_id", dto.TargetUserID, "error", err) - } else { - notified = true - } - } - dto.DingTalkNotified = ¬ified - return &dto, nil -} - -func (s *Service) ListApplications(options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) { - if s == nil || s.store == nil { - return nil, 0, errors.New("admin store is not configured") - } - items, total, err := s.store.ListFinanceApplications(options) - if err != nil { - return nil, 0, err - } - return applicationDTOsFromModel(items), total, nil -} - -func (s *Service) ListMyApplications(actor shared.Actor, options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) { - if s == nil || s.store == nil { - return nil, 0, errors.New("admin store is not configured") - } - if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCreateApplication) { - return nil, 0, errors.New("没有操作权限") - } - // 申请人范围必须在服务端写入查询条件;前端只传列表筛选项,不能决定 applicant_user_id。 - options.ApplicantUserID = actor.UserID - items, total, err := s.store.ListFinanceApplications(options) - if err != nil { - return nil, 0, err - } - return applicationDTOsFromModel(items), total, nil -} - -func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) { - return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusApproved, remark, requestID) -} - -func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) { - return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusRejected, remark, requestID) -} - -func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, requestID string) (*applicationDTO, error) { - if s == nil || s.store == nil { - return nil, errors.New("admin store is not configured") - } - if id == 0 || !hasPermission(actor.Permissions, permissionAuditApplication) { - return nil, errInvalidAuditInput - } - execution := walletExecutionResult{} - if decision == model.FinanceApplicationStatusApproved { - application, err := s.store.GetFinanceApplication(id) - if err != nil { - return nil, err - } - if application.Status != model.FinanceApplicationStatusPending { - return nil, repository.ErrFinanceApplicationAlreadyAudited - } - execution, err = s.executeApprovedApplication(ctx, actor, *application, requestID) - if err != nil { - return nil, err - } - } - item, err := s.store.AuditFinanceApplication(id, repository.FinanceApplicationAuditInput{ - Decision: decision, - AuditorUserID: actor.UserID, - AuditorName: actor.Username, - AuditRemark: strings.TrimSpace(remark), - AuditedAtMS: s.now().UnixMilli(), - WalletCommandID: execution.CommandID, - WalletTransactionID: execution.TransactionID, - WalletAssetType: execution.AssetType, - WalletAmountDelta: execution.AmountDelta, - WalletBalanceAfter: execution.BalanceAfter, - }) - if err != nil { - return nil, err - } - dto := applicationDTOFromModel(*item) - return &dto, nil -} - -type walletExecutionResult struct { - CommandID string - TransactionID string - AssetType string - AmountDelta int64 - BalanceAfter int64 -} - -func (s *Service) executeApprovedApplication(ctx context.Context, actor shared.Actor, application model.FinanceApplication, requestID string) (walletExecutionResult, error) { - if s.wallet == nil || s.user == nil { - return walletExecutionResult{}, errors.New("finance wallet executor is not configured") - } - if actor.UserID == 0 { - return walletExecutionResult{}, errors.New("没有操作权限") - } - appCode := appctx.Normalize(application.AppCode) - ctx = appctx.WithContext(ctx, appCode) - target, err := s.resolveTargetUser(ctx, strings.TrimSpace(requestID), application.TargetUserID) - if err != nil { - return walletExecutionResult{}, err - } - commandID := financeWalletCommandID(application.ID, application.Operation) - reason := financeWalletReason(application) - evidenceRef := financeWalletEvidenceRef(application.ID) - switch application.Operation { - case "user_coin_credit", "user_coin_debit": - // 普通用户金币只落 COIN 资产;扣减用带符号金额交给 wallet-service 做余额不透支校验。 - amount := signedAmount(application.CoinAmount, application.Operation == "user_coin_debit") - return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, financeAssetCoin, amount, int64(actor.UserID), reason, evidenceRef) - case "user_wallet_credit", "user_wallet_debit": - assetType, err := financeSalaryAssetType(application.WalletIdentity) - if err != nil { - return walletExecutionResult{}, err - } - if err := s.requireWalletIdentity(ctx, strings.TrimSpace(requestID), target.UserID, application.WalletIdentity); err != nil { - return walletExecutionResult{}, err - } - amountMinor, err := decimalAmountMinor(application.RechargeAmount) - if err != nil { - return walletExecutionResult{}, err - } - // 身份钱包是美元工资资产,单位使用 wallet-service 现有的 minor 口径;金币数量不参与这类调账。 - amount := signedAmount(amountMinor, application.Operation == "user_wallet_debit") - return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, assetType, amount, int64(actor.UserID), reason, evidenceRef) - case "coin_seller_coin_credit", "coin_seller_coin_debit": - if err := s.requireActiveCoinSeller(ctx, strings.TrimSpace(requestID), target); err != nil { - return walletExecutionResult{}, err - } - paidAmountMicro, err := decimalAmountMicro(application.RechargeAmount) - if err != nil { - return walletExecutionResult{}, err - } - stockType := financeStockType(application.Operation) - resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ - CommandId: commandID, - SellerUserId: target.UserID, - StockType: stockType, - CoinAmount: application.CoinAmount, - PaidCurrencyCode: "USDT", - PaidAmountMicro: paidAmountMicro, - PaymentRef: evidenceRef, - EvidenceRef: evidenceRef, - OperatorUserId: int64(actor.UserID), - Reason: reason, - AppCode: appCode, - SellerCountryId: target.CountryID, - SellerRegionId: target.RegionID, - }) - if err != nil { - return walletExecutionResult{}, err - } - return walletExecutionResult{ - CommandID: commandID, - TransactionID: resp.GetTransactionId(), - AssetType: financeAssetCoinSellerCoin, - AmountDelta: resp.GetCoinAmount(), - BalanceAfter: resp.GetBalanceAfter(), - }, nil - default: - return walletExecutionResult{}, errors.New("财务操作不正确") - } -} - -func (s *Service) adminAdjustAsset(ctx context.Context, commandID string, appCode string, targetUserID int64, assetType string, amount int64, operatorUserID int64, reason string, evidenceRef string) (walletExecutionResult, error) { - resp, err := s.wallet.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{ - CommandId: commandID, - TargetUserId: targetUserID, - AssetType: assetType, - Amount: amount, - OperatorUserId: operatorUserID, - Reason: reason, - EvidenceRef: evidenceRef, - AppCode: appCode, - }) - if err != nil { - return walletExecutionResult{}, err - } - balanceAfter := int64(0) - if balance := resp.GetBalance(); balance != nil { - balanceAfter = balance.GetAvailableAmount() - } - return walletExecutionResult{ - CommandID: commandID, - TransactionID: resp.GetTransactionId(), - AssetType: assetType, - AmountDelta: amount, - BalanceAfter: balanceAfter, - }, nil -} - -func (s *Service) resolveTargetUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) { - keyword := strings.TrimSpace(rawTarget) - if keyword == "" { - return nil, errors.New("目标用户ID不正确") - } - numericID, numericOK := parsePositiveInt64(keyword) - userIDChecked := false - if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeInternalUserIDMinDigits { - userIDChecked = true - if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found { - return user, err - } - } - if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{ - RequestID: requestID, - Caller: "admin-server", - DisplayUserID: keyword, - }); err != nil { - if !isGRPCNotFound(err) { - return nil, err - } - } else if identity != nil && identity.UserID > 0 { - user, found, err := s.getUserByID(ctx, requestID, identity.UserID) - if err != nil { - return nil, err - } - if found { - return user, nil - } - } - if numericOK && !userIDChecked { - if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found { - return user, err - } - } - return nil, errors.New("目标用户不存在") -} - -func (s *Service) getUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) { - user, err := s.user.GetUser(ctx, userclient.GetUserRequest{ - RequestID: requestID, - Caller: "admin-server", - UserID: userID, - }) - if err != nil { - if isGRPCNotFound(err) { - return nil, false, nil - } - return nil, false, err - } - if user == nil || user.UserID <= 0 { - return nil, false, nil - } - return user, true, nil -} - -func (s *Service) requireWalletIdentity(ctx context.Context, requestID string, userID int64, walletIdentity string) error { - summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{ - RequestID: requestID, - Caller: "admin-server", - UserID: userID, - }) - if err != nil { - return err - } - switch strings.TrimSpace(walletIdentity) { - case "host": - if summary != nil && summary.IsHost { - return nil - } - case "agency": - if summary != nil && summary.IsAgency { - return nil - } - case "bd": - if summary != nil && summary.IsBD { - return nil - } - } - return errors.New("目标用户没有对应钱包身份") -} - -func (s *Service) requireActiveCoinSeller(ctx context.Context, requestID string, user *userclient.User) error { - if user == nil || user.UserID <= 0 { - return errors.New("目标用户不存在") - } - if user.CountryID <= 0 || user.RegionID <= 0 { - return errors.New("币商国家或区域不完整") - } - summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{ - RequestID: requestID, - Caller: "admin-server", - UserID: user.UserID, - }) - if err != nil { - return err - } - if summary == nil || !summary.IsCoinSeller { - return errors.New("目标用户不是启用中的币商") - } - return nil -} - -func (s *Service) normalizeCreateInput(actor shared.Actor, req createApplicationRequest) (model.FinanceApplication, error) { - appCode := strings.TrimSpace(req.AppCode) - operation := strings.TrimSpace(req.Operation) - targetUserID := strings.TrimSpace(req.TargetUserID) - credentialImageURL := strings.TrimSpace(req.CredentialImageURL) - credentialText := strings.TrimSpace(req.CredentialText) - definition, ok := financeOperationDefinitions()[operation] - if !ok || appCode == "" || targetUserID == "" || req.CoinAmount <= 0 || actor.UserID == 0 { - return model.FinanceApplication{}, errInvalidCreateInput - } - if !hasPermission(actor.Permissions, permissionCreateApplication) || !hasPermission(actor.Permissions, definition.PermissionCode) { - return model.FinanceApplication{}, errors.New("没有操作权限") - } - rechargeAmount, err := normalizeRechargeAmount(req.RechargeAmount, definition.AllowsZeroRechargeAmount) - if err != nil { - return model.FinanceApplication{}, err - } - walletIdentity := strings.TrimSpace(req.WalletIdentity) - if definition.RequiresWalletIdentity { - if !validWalletIdentity(walletIdentity) { - return model.FinanceApplication{}, errors.New("钱包身份不正确") - } - } else { - // 非钱包类申请不保存身份字段,避免后续审核或执行阶段把历史残留身份误当成业务参数。 - walletIdentity = "" - } - if credentialImageURL == "" && credentialText == "" { - return model.FinanceApplication{}, errors.New("请上传凭证图片或填写凭证文字") - } - return model.FinanceApplication{ - AppCode: appCode, - Operation: operation, - WalletIdentity: walletIdentity, - TargetUserID: targetUserID, - CoinAmount: req.CoinAmount, - RechargeAmount: rechargeAmount, - CredentialImageURL: credentialImageURL, - CredentialText: credentialText, - ApplicantUserID: actor.UserID, - ApplicantName: actor.Username, - Status: model.FinanceApplicationStatusPending, - }, nil -} - -func financeOperationDefinitions() map[string]operationDefinition { - return map[string]operationDefinition{ - "user_coin_credit": { - Value: "user_coin_credit", - PermissionCode: "finance-operation:user-coin-credit", - AllowsZeroRechargeAmount: true, - }, - "user_coin_debit": { - Value: "user_coin_debit", - PermissionCode: "finance-operation:user-coin-debit", - AllowsZeroRechargeAmount: true, - }, - "user_wallet_debit": { - Value: "user_wallet_debit", - PermissionCode: "finance-operation:user-wallet-debit", - RequiresWalletIdentity: true, - }, - "user_wallet_credit": { - Value: "user_wallet_credit", - PermissionCode: "finance-operation:user-wallet-credit", - RequiresWalletIdentity: true, - }, - "coin_seller_coin_credit": { - Value: "coin_seller_coin_credit", - PermissionCode: "finance-operation:coin-seller-coin-credit", - }, - "coin_seller_coin_debit": { - Value: "coin_seller_coin_debit", - PermissionCode: "finance-operation:coin-seller-coin-debit", - }, - } -} - -func normalizeRechargeAmount(amount float64, allowZero bool) (string, error) { - if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { - return "", errors.New("充值金额不正确") - } - cents := int64(math.Round(amount * 100)) - // 普通用户金币调账的执行金额来自 coin_amount,recharge_amount 只是凭证口径,允许记录 0;身份钱包和币商进货会把它转换成真实 USD/USDT 金额,必须保持正数。 - if cents < 0 || (!allowZero && cents == 0) { - return "", errors.New("充值金额不正确") - } - return fmt.Sprintf("%d.%02d", cents/100, cents%100), nil -} - -func validWalletIdentity(value string) bool { - switch value { - case "host", "agency", "bd": - return true - default: - return false - } -} - -func financeSalaryAssetType(identity string) (string, error) { - switch strings.TrimSpace(identity) { - case "host": - return financeAssetHostSalaryUSD, nil - case "agency": - return financeAssetAgencySalaryUSD, nil - case "bd": - return financeAssetBDSalaryUSD, nil - default: - return "", errors.New("钱包身份不正确") - } -} - -func financeStockType(operation string) string { - if operation == "coin_seller_coin_debit" { - return financeStockTypeUSDTDeduction - } - return financeStockTypeUSDTPurchase -} - -func signedAmount(amount int64, debit bool) int64 { - if debit { - return -amount - } - return amount -} - -func decimalAmountMinor(value string) (int64, error) { - return decimalAmountWithScale(value, 2) -} - -func decimalAmountMicro(value string) (int64, error) { - return decimalAmountWithScale(value, 6) -} - -func decimalAmountWithScale(value string, scale int) (int64, error) { - text := strings.TrimSpace(value) - if text == "" { - return 0, errors.New("充值金额不正确") - } - parts := strings.Split(text, ".") - if len(parts) > 2 { - return 0, errors.New("充值金额不正确") - } - whole, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil || whole < 0 { - return 0, errors.New("充值金额不正确") - } - fracText := "" - if len(parts) == 2 { - fracText = parts[1] - } - if len(fracText) > scale { - return 0, errors.New("充值金额不正确") - } - for _, r := range fracText { - if r < '0' || r > '9' { - return 0, errors.New("充值金额不正确") - } - } - for len(fracText) < scale { - fracText += "0" - } - frac := int64(0) - if fracText != "" { - frac, err = strconv.ParseInt(fracText, 10, 64) - if err != nil { - return 0, errors.New("充值金额不正确") - } - } - multiplier := int64(1) - for i := 0; i < scale; i++ { - multiplier *= 10 - } - const maxInt64 = 1<<63 - 1 - if whole > (maxInt64-frac)/multiplier { - return 0, errors.New("充值金额不正确") - } - amount := whole*multiplier + frac - if amount <= 0 { - return 0, errors.New("充值金额不正确") - } - return amount, nil -} - -func parsePositiveInt64(value string) (int64, bool) { - parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) - return parsed, err == nil && parsed > 0 -} - -func financeWalletCommandID(id uint, operation string) string { - return fmt.Sprintf("finance-application:%d:%s", id, strings.TrimSpace(operation)) -} - -func financeWalletEvidenceRef(id uint) string { - return fmt.Sprintf("finance-application:%d", id) -} - -func financeWalletReason(application model.FinanceApplication) string { - return fmt.Sprintf("finance application %d %s", application.ID, operationLabel(application.Operation)) -} - -func isGRPCNotFound(err error) bool { - return status.Code(err) == codes.NotFound -} - -func hasPermission(permissions []string, code string) bool { - for _, permission := range permissions { - if permission == code { - return true - } - } - return false -} diff --git a/server/admin/internal/modules/financeapplication/service_test.go b/server/admin/internal/modules/financeapplication/service_test.go deleted file mode 100644 index cd2927e1..00000000 --- a/server/admin/internal/modules/financeapplication/service_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package financeapplication - -import ( - "context" - "testing" - - "hyapp-admin-server/internal/appctx" - "hyapp-admin-server/internal/integration/userclient" - "hyapp-admin-server/internal/integration/walletclient" - "hyapp-admin-server/internal/model" - "hyapp-admin-server/internal/modules/shared" - walletv1 "hyapp.local/api/proto/wallet/v1" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestNormalizeCreateInputRequiresOperationPermission(t *testing.T) { - service := NewService(nil) - _, err := service.normalizeCreateInput(shared.Actor{ - UserID: 7, - Username: "ops", - Permissions: []string{permissionCreateApplication}, - }, validCreateRequest()) - if err == nil || err.Error() != "没有操作权限" { - t.Fatalf("operation permission must be required, got err=%v", err) - } -} - -func TestNormalizeCreateInputRequiresWalletIdentityForWalletOperation(t *testing.T) { - service := NewService(nil) - req := validCreateRequest() - req.Operation = "user_wallet_debit" - _, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req) - if err == nil || err.Error() != "钱包身份不正确" { - t.Fatalf("wallet operation must require wallet identity, got err=%v", err) - } - - req.WalletIdentity = "agency" - item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req) - if err != nil { - t.Fatalf("wallet identity should be accepted: %v", err) - } - if item.WalletIdentity != "agency" { - t.Fatalf("wallet identity mismatch: %q", item.WalletIdentity) - } -} - -func TestNormalizeCreateInputClearsWalletIdentityForCoinOperation(t *testing.T) { - service := NewService(nil) - req := validCreateRequest() - req.WalletIdentity = "host" - item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:coin-seller-coin-credit"), req) - if err != nil { - t.Fatalf("coin operation should be accepted: %v", err) - } - if item.WalletIdentity != "" { - t.Fatalf("non-wallet operation must clear wallet identity, got %q", item.WalletIdentity) - } - if item.RechargeAmount != "10.50" { - t.Fatalf("recharge amount mismatch: %q", item.RechargeAmount) - } -} - -func TestNormalizeCreateInputAllowsZeroRechargeAmountForUserCoinOperation(t *testing.T) { - service := NewService(nil) - req := validCreateRequest() - req.Operation = "user_coin_credit" - req.RechargeAmount = 0 - - item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-coin-credit"), req) - if err != nil { - t.Fatalf("user coin operation should allow zero recharge amount: %v", err) - } - if item.RechargeAmount != "0.00" { - t.Fatalf("zero recharge amount must be persisted with decimal scale, got %q", item.RechargeAmount) - } -} - -func TestNormalizeCreateInputRejectsZeroRechargeAmountForWalletAndCoinSellerOperations(t *testing.T) { - service := NewService(nil) - tests := []struct { - name string - operation string - permission string - identity string - }{ - {name: "wallet", operation: "user_wallet_credit", permission: "finance-operation:user-wallet-credit", identity: "agency"}, - {name: "coin seller", operation: "coin_seller_coin_credit", permission: "finance-operation:coin-seller-coin-credit"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := validCreateRequest() - req.Operation = tt.operation - req.WalletIdentity = tt.identity - req.RechargeAmount = 0 - _, err := service.normalizeCreateInput(actorWithPermissions(tt.permission), req) - if err == nil || err.Error() != "充值金额不正确" { - t.Fatalf("operation %s must still require positive recharge amount, got err=%v", tt.operation, err) - } - }) - } -} - -func TestExecuteApprovedApplicationAdjustsUserCoinDebit(t *testing.T) { - wallet := &fakeFinanceWallet{} - service := NewService(nil, WithUserClient(&fakeFinanceUser{ - displays: map[string]int64{"163001": 312899006709637120}, - users: map[int64]*userclient.User{ - 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, - }, - }), WithWalletClient(wallet)) - application := model.FinanceApplication{ - ID: 9, - AppCode: "lalu", - Operation: "user_coin_debit", - TargetUserID: "163001", - CoinAmount: 120, - RechargeAmount: "10.00", - } - - execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 7}, application, "req-1") - if err != nil { - t.Fatalf("execute approved application failed: %v", err) - } - if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetCoin || wallet.assetReq.GetAmount() != -120 || wallet.assetReq.GetTargetUserId() != 312899006709637120 { - t.Fatalf("wallet asset request mismatch: %+v", wallet.assetReq) - } - if execution.CommandID != "finance-application:9:user_coin_debit" || execution.AmountDelta != -120 || execution.BalanceAfter != 880 { - t.Fatalf("execution mismatch: %+v", execution) - } -} - -func TestExecuteApprovedApplicationUsesWalletIdentityAmountMinor(t *testing.T) { - wallet := &fakeFinanceWallet{} - service := NewService(nil, WithUserClient(&fakeFinanceUser{ - users: map[int64]*userclient.User{ - 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, - }, - summaries: map[int64]*userclient.UserRoleSummary{ - 312899006709637120: {UserID: 312899006709637120, IsAgency: true}, - }, - }), WithWalletClient(wallet)) - application := model.FinanceApplication{ - ID: 10, - AppCode: "lalu", - Operation: "user_wallet_credit", - WalletIdentity: "agency", - TargetUserID: "312899006709637120", - CoinAmount: 999, - RechargeAmount: "12.34", - } - - _, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 8}, application, "req-2") - if err != nil { - t.Fatalf("execute wallet application failed: %v", err) - } - if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetAgencySalaryUSD || wallet.assetReq.GetAmount() != 1234 { - t.Fatalf("identity wallet request must use salary minor amount, got %+v", wallet.assetReq) - } -} - -func TestExecuteApprovedApplicationAdjustsCoinSellerStockDeduction(t *testing.T) { - wallet := &fakeFinanceWallet{} - service := NewService(nil, WithUserClient(&fakeFinanceUser{ - users: map[int64]*userclient.User{ - 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, - }, - summaries: map[int64]*userclient.UserRoleSummary{ - 312899006709637120: {UserID: 312899006709637120, IsCoinSeller: true, CoinSellerStatus: "active"}, - }, - }), WithWalletClient(wallet)) - application := model.FinanceApplication{ - ID: 11, - AppCode: "lalu", - Operation: "coin_seller_coin_debit", - TargetUserID: "312899006709637120", - CoinAmount: 3000000, - RechargeAmount: "37.50", - } - - execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 9}, application, "req-3") - if err != nil { - t.Fatalf("execute coin seller application failed: %v", err) - } - if wallet.stockReq == nil || wallet.stockReq.GetStockType() != financeStockTypeUSDTDeduction || wallet.stockReq.GetCoinAmount() != 3000000 || wallet.stockReq.GetPaidAmountMicro() != 37500000 { - t.Fatalf("coin seller stock request mismatch: %+v", wallet.stockReq) - } - if wallet.stockReq.GetSellerCountryId() != 63 || wallet.stockReq.GetSellerRegionId() != 7 { - t.Fatalf("coin seller region snapshot mismatch: %+v", wallet.stockReq) - } - if execution.AssetType != financeAssetCoinSellerCoin || execution.AmountDelta != -3000000 || execution.BalanceAfter != 7000000 { - t.Fatalf("coin seller execution mismatch: %+v", execution) - } -} - -func validCreateRequest() createApplicationRequest { - return createApplicationRequest{ - AppCode: "lalu", - Operation: "coin_seller_coin_credit", - TargetUserID: "10001", - CoinAmount: 100, - RechargeAmount: 10.5, - CredentialImageURL: "https://example.com/receipt.png", - } -} - -type fakeFinanceUser struct { - userclient.Client - users map[int64]*userclient.User - displays map[string]int64 - summaries map[int64]*userclient.UserRoleSummary -} - -func (f *fakeFinanceUser) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) { - if user := f.users[req.UserID]; user != nil { - return user, nil - } - return nil, status.Error(codes.NotFound, "user not found") -} - -func (f *fakeFinanceUser) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) { - if userID := f.displays[req.DisplayUserID]; userID > 0 { - return &userclient.UserIdentity{UserID: userID, DisplayUserID: req.DisplayUserID}, nil - } - return nil, status.Error(codes.NotFound, "display user id not found") -} - -func (f *fakeFinanceUser) GetUserRoleSummary(_ context.Context, req userclient.GetUserRoleSummaryRequest) (*userclient.UserRoleSummary, error) { - if summary := f.summaries[req.UserID]; summary != nil { - return summary, nil - } - return &userclient.UserRoleSummary{UserID: req.UserID}, nil -} - -type fakeFinanceWallet struct { - walletclient.Client - assetReq *walletv1.AdminCreditAssetRequest - stockReq *walletv1.AdminCreditCoinSellerStockRequest -} - -func (f *fakeFinanceWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) { - f.assetReq = req - return &walletv1.AdminCreditAssetResponse{ - TransactionId: "tx-" + req.GetCommandId(), - Balance: &walletv1.AssetBalance{AvailableAmount: 880}, - }, nil -} - -func (f *fakeFinanceWallet) AdminCreditCoinSellerStock(_ context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { - f.stockReq = req - coinAmount := req.GetCoinAmount() - if req.GetStockType() == financeStockTypeUSDTDeduction { - coinAmount = -coinAmount - } - return &walletv1.AdminCreditCoinSellerStockResponse{ - TransactionId: "tx-" + req.GetCommandId(), - CoinAmount: coinAmount, - BalanceAfter: 7000000, - }, nil -} - -func actorWithPermissions(operationPermission string) shared.Actor { - return shared.Actor{ - UserID: 7, - Username: "ops", - Permissions: []string{permissionCreateApplication, operationPermission}, - } -} diff --git a/server/admin/internal/modules/financewithdrawal/permission_test.go b/server/admin/internal/modules/financewithdrawal/permission_test.go new file mode 100644 index 00000000..1ff44f59 --- /dev/null +++ b/server/admin/internal/modules/financewithdrawal/permission_test.go @@ -0,0 +1,12 @@ +package financewithdrawal + +import "testing" + +func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) { + if !canAuditWithdrawal([]string{"finance-withdrawal:audit"}) { + t.Fatal("dedicated withdrawal audit permission must allow auditing") + } + if canAuditWithdrawal([]string{"finance-application:audit"}) { + t.Fatal("removed finance application permission must not authorize withdrawal auditing") + } +} diff --git a/server/admin/internal/modules/financewithdrawal/routes.go b/server/admin/internal/modules/financewithdrawal/routes.go index 18f7ea31..94cf26fa 100644 --- a/server/admin/internal/modules/financewithdrawal/routes.go +++ b/server/admin/internal/modules/financewithdrawal/routes.go @@ -10,7 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { if h == nil { return } - protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications) - protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ApproveApplication) - protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication) + 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) } diff --git a/server/admin/internal/modules/financewithdrawal/service.go b/server/admin/internal/modules/financewithdrawal/service.go index b41ebf42..4b263952 100644 --- a/server/admin/internal/modules/financewithdrawal/service.go +++ b/server/admin/internal/modules/financewithdrawal/service.go @@ -22,7 +22,6 @@ import ( const ( permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionAuditWithdrawalApplications = "finance-withdrawal:audit" - permissionAuditFinanceApplications = "finance-application:audit" pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultFeeBPS = int32(500) @@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string { func canAuditWithdrawal(permissions []string) bool { for _, permission := range permissions { switch strings.TrimSpace(permission) { - case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications: + case permissionAuditWithdrawalApplications: return true } } diff --git a/server/admin/internal/repository/finance_application_repository.go b/server/admin/internal/repository/finance_application_repository.go deleted file mode 100644 index 4e6de37f..00000000 --- a/server/admin/internal/repository/finance_application_repository.go +++ /dev/null @@ -1,127 +0,0 @@ -package repository - -import ( - "errors" - "strings" - - "hyapp-admin-server/internal/model" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -var ErrFinanceApplicationAlreadyAudited = errors.New("finance application already audited") - -type FinanceApplicationListOptions struct { - Page int - PageSize int - AppCode string - Operation string - Status string - Keyword string - ApplicantUserID uint -} - -type FinanceApplicationAuditInput struct { - Decision string - AuditorUserID uint - AuditorName string - AuditRemark string - AuditedAtMS int64 - WalletCommandID string - WalletTransactionID string - WalletAssetType string - WalletAmountDelta int64 - WalletBalanceAfter int64 -} - -func (s *Store) CreateFinanceApplication(application *model.FinanceApplication) error { - if application == nil { - return errors.New("finance application is required") - } - return s.db.Create(application).Error -} - -func (s *Store) GetFinanceApplication(id uint) (*model.FinanceApplication, error) { - var application model.FinanceApplication - if err := s.db.First(&application, id).Error; err != nil { - return nil, err - } - return &application, nil -} - -func (s *Store) ListFinanceApplications(options FinanceApplicationListOptions) ([]model.FinanceApplication, int64, error) { - page, pageSize := normalizePage(options.Page, options.PageSize) - query := s.db.Model(&model.FinanceApplication{}) - query = applyFinanceApplicationFilters(query, options) - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - var applications []model.FinanceApplication - err := query. - Order("created_at_ms DESC, id DESC"). - Limit(pageSize). - Offset((page - 1) * pageSize). - Find(&applications).Error - return applications, total, err -} - -func (s *Store) AuditFinanceApplication(id uint, input FinanceApplicationAuditInput) (*model.FinanceApplication, error) { - if id == 0 { - return nil, errors.New("finance application id is required") - } - if input.Decision != model.FinanceApplicationStatusApproved && input.Decision != model.FinanceApplicationStatusRejected { - return nil, errors.New("finance application decision is invalid") - } - - var application model.FinanceApplication - err := s.db.Transaction(func(tx *gorm.DB) error { - // 审批是一次性状态流转:先用行锁读取当前状态,再只允许 pending 进入终态,防止两个财务并发点击导致后写覆盖先写。 - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&application, id).Error; err != nil { - return err - } - if application.Status != model.FinanceApplicationStatusPending { - return ErrFinanceApplicationAlreadyAudited - } - return tx.Model(&application).Updates(map[string]any{ - "status": input.Decision, - "auditor_user_id": input.AuditorUserID, - "auditor_name": strings.TrimSpace(input.AuditorName), - "audit_remark": strings.TrimSpace(input.AuditRemark), - "audited_at_ms": input.AuditedAtMS, - "wallet_command_id": strings.TrimSpace(input.WalletCommandID), - "wallet_transaction_id": strings.TrimSpace(input.WalletTransactionID), - "wallet_asset_type": strings.TrimSpace(input.WalletAssetType), - "wallet_amount_delta": input.WalletAmountDelta, - "wallet_balance_after": input.WalletBalanceAfter, - "updated_at_ms": input.AuditedAtMS, - }).Error - }) - if err != nil { - return nil, err - } - return s.GetFinanceApplication(id) -} - -func applyFinanceApplicationFilters(query *gorm.DB, options FinanceApplicationListOptions) *gorm.DB { - if options.ApplicantUserID > 0 { - // “我的申请”接口只允许服务端用登录态限定申请人,不能信任前端传申请人 ID,避免普通发起人越权查看他人财务申请。 - query = query.Where("applicant_user_id = ?", options.ApplicantUserID) - } - if appCode := strings.TrimSpace(options.AppCode); appCode != "" { - query = query.Where("app_code = ?", appCode) - } - if operation := strings.TrimSpace(options.Operation); operation != "" { - query = query.Where("operation = ?", operation) - } - if status := strings.TrimSpace(options.Status); status != "" && status != "all" { - query = query.Where("status = ?", status) - } - if keyword := strings.TrimSpace(options.Keyword); keyword != "" { - like := "%" + keyword + "%" - query = query.Where("target_user_id LIKE ? OR applicant_name LIKE ? OR auditor_name LIKE ? OR CAST(applicant_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like) - } - return query -} diff --git a/server/admin/internal/repository/finance_application_repository_test.go b/server/admin/internal/repository/finance_application_repository_test.go deleted file mode 100644 index e0609c51..00000000 --- a/server/admin/internal/repository/finance_application_repository_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/DATA-DOG/go-sqlmock" -) - -func TestListFinanceApplicationsFiltersByApplicant(t *testing.T) { - store, mock, closeStore := newRepositorySQLMock(t) - defer closeStore() - - mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_finance_applications`"). - WithArgs(uint(7), "pending"). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - mock.ExpectQuery("SELECT \\* FROM `admin_finance_applications`"). - WithArgs(uint(7), "pending", 50). - WillReturnRows(sqlmock.NewRows([]string{ - "id", - "app_code", - "operation", - "wallet_identity", - "target_user_id", - "coin_amount", - "recharge_amount", - "credential_image_url", - "credential_text", - "applicant_user_id", - "applicant_name", - "auditor_user_id", - "auditor_name", - "status", - "audit_remark", - "audited_at_ms", - "created_at_ms", - "updated_at_ms", - }).AddRow(9, "lalu", "user_coin_credit", "", "10001", int64(100), "12.50", "", "receipt", 7, "ops", nil, "", "pending", "", nil, int64(1700000100000), int64(1700000100000))) - - items, total, err := store.ListFinanceApplications(FinanceApplicationListOptions{ - ApplicantUserID: 7, - Page: 1, - PageSize: 50, - Status: "pending", - }) - if err != nil { - t.Fatalf("list finance applications failed: %v", err) - } - if total != 1 || len(items) != 1 { - t.Fatalf("finance application page mismatch: total=%d items=%+v", total, items) - } - if items[0].ApplicantUserID != 7 || items[0].TargetUserID != "10001" { - t.Fatalf("finance application row mismatch: %+v", items[0]) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sql expectations mismatch: %v", err) - } -} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index c1e62a6c..d932fc22 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -88,7 +88,6 @@ func (s *Store) AutoMigrate() error { &model.TemporaryPaymentLinkOwner{}, &model.LegacyGooglePaidDetail{}, &model.LegacyGooglePaidSyncAttempt{}, - &model.FinanceApplication{}, &model.CoinSellerRechargeOrder{}, &model.UserWithdrawalApplication{}, &model.AdminJob{}, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 9391c566..aaf1a4a4 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -128,21 +128,12 @@ var defaultPermissions = []model.Permission{ {Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"}, {Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"}, {Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"}, - // 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit;是否能看到审核列表由 audit 权限单独决定。 {Name: "财务范围查看", Code: "finance:view", Kind: "menu"}, - {Name: "财务申请发起", Code: "finance-application:create", Kind: "button"}, - {Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"}, {Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"}, {Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"}, {Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"}, {Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"}, {Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"}, - {Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"}, - {Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"}, - {Name: "财务增加用户钱包", Code: "finance-operation:user-wallet-credit", Kind: "button"}, - {Name: "财务扣减用户钱包", Code: "finance-operation:user-wallet-debit", Kind: "button"}, - {Name: "财务增加币商金币", Code: "finance-operation:coin-seller-coin-credit", Kind: "button"}, - {Name: "财务减少币商金币", Code: "finance-operation:coin-seller-coin-debit", Kind: "button"}, {Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"}, {Name: "内购配置创建", Code: "payment-product:create", Kind: "button"}, {Name: "内购配置更新", Code: "payment-product:update", Kind: "button"}, @@ -662,7 +653,7 @@ func defaultRolePermissionCodes(code string) []string { "agency:view", "agency:create", "agency:status", "agency:delete", "bd:view", "bd:create", "bd:update", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit", - "coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", + "coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", "lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update", "game:view", "game:create", "game:update", "game:status", "game:delete", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", @@ -774,7 +765,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "region:view", "region:create", "region:update", "region:status", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit", - "coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", + "coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", "lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update", "game:view", "game:create", "game:update", "game:status", "game:delete", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index 2ad0b007..c6e1804c 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -17,19 +17,25 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) { for _, code := range []string{ "payment-temporary-link:create", "finance:view", + "finance-withdrawal:view", + "host-withdrawal:view", + } { + if _, ok := permissions[code]; !ok { + t.Fatalf("finance permission %s missing from default seed", code) + } + } + for _, code := range []string{ "finance-application:create", "finance-application:audit", - "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", - "host-withdrawal:view", } { - if _, ok := permissions[code]; !ok { - t.Fatalf("finance permission %s missing from default seed", code) + if _, ok := permissions[code]; ok { + t.Fatalf("removed finance application permission %s must not be seeded", code) } } } @@ -40,13 +46,6 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) { for _, code := range []string{ "payment-temporary-link:create", - "finance-application:create", - "finance-operation:user-coin-credit", - "finance-operation:user-coin-debit", - "finance-operation:user-wallet-credit", - "finance-operation:user-wallet-debit", - "finance-operation:coin-seller-coin-credit", - "finance-operation:coin-seller-coin-debit", "host-withdrawal:view", } { if _, ok := opsPermissions[code]; !ok { @@ -57,11 +56,22 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) { } } - if _, ok := opsPermissions["finance-application:audit"]; ok { - t.Fatal("ops-admin must not receive finance audit permission by default") - } - if _, ok := opsMigrationPermissions["finance-application:audit"]; ok { - t.Fatal("ops-admin migration must not append finance audit permission by default") + for _, code := range []string{ + "finance-application:create", + "finance-application:audit", + "finance-operation:user-coin-credit", + "finance-operation:user-coin-debit", + "finance-operation:user-wallet-credit", + "finance-operation:user-wallet-debit", + "finance-operation:coin-seller-coin-credit", + "finance-operation:coin-seller-coin-debit", + } { + if _, ok := opsPermissions[code]; ok { + t.Fatalf("ops-admin must not receive removed permission %s", code) + } + if _, ok := opsMigrationPermissions[code]; ok { + t.Fatalf("ops-admin migration must not append removed permission %s", code) + } } } diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 3b2b0324..48feb5b2 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -19,7 +19,6 @@ import ( "hyapp-admin-server/internal/modules/dailytask" "hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/databi" - "hyapp-admin-server/internal/modules/financeapplication" "hyapp-admin-server/internal/modules/financeorder" "hyapp-admin-server/internal/modules/financewithdrawal" "hyapp-admin-server/internal/modules/firstrechargereward" @@ -84,7 +83,6 @@ type Handlers struct { Dashboard *dashboard.Handler Databi *databi.Handler FirstRechargeReward *firstrechargereward.Handler - FinanceApplication *financeapplication.Handler FinanceOrder *financeorder.Handler FinanceWithdrawal *financewithdrawal.Handler FullServerNotice *fullservernotice.Handler @@ -156,7 +154,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge) dailytask.RegisterRoutes(protected, h.DailyTask) firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward) - financeapplication.RegisterRoutes(protected, h.FinanceApplication) financeorder.RegisterRoutes(protected, h.FinanceOrder) financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal) fullservernotice.RegisterRoutes(protected, h.FullServerNotice) diff --git a/server/admin/migrations/092_remove_finance_applications.sql b/server/admin/migrations/092_remove_finance_applications.sql new file mode 100644 index 00000000..640f5881 --- /dev/null +++ b/server/admin/migrations/092_remove_finance_applications.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 旧 finance-application:audit 曾兼容用户提现审核;先迁移到专属权限,避免删除申请模块时意外收回仍在使用的提现审核能力。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT existing.role_id, withdrawal_permission.id +FROM admin_role_permissions existing +JOIN admin_permissions legacy_permission + ON legacy_permission.id = existing.permission_id +JOIN admin_permissions withdrawal_permission + ON withdrawal_permission.code = 'finance-withdrawal:audit' +WHERE legacy_permission.code = 'finance-application:audit'; + +-- 权限 code 有唯一索引;这里只匹配 8 个废弃 code。角色权限表规模受角色数约束,一次性清理不会扫描业务流水或申请历史表。 +DELETE role_permission +FROM admin_role_permissions role_permission +JOIN admin_permissions permission + ON permission.id = role_permission.permission_id +WHERE permission.code IN ( + 'finance-application:create', + 'finance-application:audit', + 'finance-operation:user-coin-credit', + 'finance-operation:user-coin-debit', + 'finance-operation:user-wallet-credit', + 'finance-operation:user-wallet-debit', + 'finance-operation:coin-seller-coin-credit', + 'finance-operation:coin-seller-coin-debit' +); + +DELETE FROM admin_permissions +WHERE code IN ( + 'finance-application:create', + 'finance-application:audit', + 'finance-operation:user-coin-credit', + 'finance-operation:user-coin-debit', + 'finance-operation:user-wallet-credit', + 'finance-operation:user-wallet-debit', + 'finance-operation:coin-seller-coin-credit', + 'finance-operation:coin-seller-coin-debit' +); diff --git a/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go new file mode 100644 index 00000000..6b90eea0 --- /dev/null +++ b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go @@ -0,0 +1,35 @@ +package financewithdrawal + +import ( + "strings" + "testing" +) + +func TestWithdrawalApplicationCreatedMarkdownKeepsFinanceReviewDetails(t *testing.T) { + message := withdrawalApplicationCreatedMarkdown(Application{ + ID: 77, + AppCode: "lalu", + UserID: 42, + SalaryAssetType: "HOST_SALARY_USD", + WithdrawAmount: "50.00", + WithdrawMethod: MethodUSDTTRC20, + WithdrawAddress: "TRON-address", + FreezeTransactionID: "freeze-tx-1", + CreatedAtMS: 1_700_000_000_000, + }) + + for _, expected := range []string{ + "用户提现申请待审核", + "申请ID:77", + "APP:lalu", + "用户ID:42", + "提现金额:$ 50.00", + "提现方式:usdt_trc20", + "冻结交易:freeze-tx-1", + "https://admin-acc.global-interaction.com/finance/", + } { + if !strings.Contains(message, expected) { + t.Fatalf("withdrawal DingTalk message missing %q: %s", expected, message) + } + } +} diff --git a/services/gateway-service/internal/transport/http/activityapi/level_handler.go b/services/gateway-service/internal/transport/http/activityapi/level_handler.go index 58650a7f..61890775 100644 --- a/services/gateway-service/internal/transport/http/activityapi/level_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/level_handler.go @@ -15,19 +15,21 @@ type levelOverviewData struct { } type levelTrackOverviewData struct { - Track string `json:"track"` - Name string `json:"name"` - Level int32 `json:"level"` - TierID int64 `json:"tier_id"` - TotalValue int64 `json:"total_value"` - CurrentLevelRequiredValue int64 `json:"current_level_required_value"` - NextLevel int32 `json:"next_level"` - NextLevelRequiredValue int64 `json:"next_level_required_value"` - DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` - DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` - DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"` - RewardPendingCount int64 `json:"reward_pending_count"` - SortOrder int32 `json:"sort_order"` + Track string `json:"track"` + Name string `json:"name"` + Level int32 `json:"level"` + TierID int64 `json:"tier_id"` + TotalValue int64 `json:"total_value"` + CurrentLevelRequiredValue int64 `json:"current_level_required_value"` + NextLevel int32 `json:"next_level"` + NextLevelRequiredValue int64 `json:"next_level_required_value"` + DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` + DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"` + DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` + DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"` + DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"` + RewardPendingCount int64 `json:"reward_pending_count"` + SortOrder int32 `json:"sort_order"` } type levelTrackDetailData struct { @@ -38,35 +40,39 @@ type levelTrackDetailData struct { } type levelRuleData struct { - Track string `json:"track"` - Level int32 `json:"level"` - RequiredValue int64 `json:"required_value"` - Name string `json:"name"` - Status string `json:"status"` - RewardResourceGroupID int64 `json:"reward_resource_group_id"` - SortOrder int32 `json:"sort_order"` - DisplayConfigJSON string `json:"display_config_json"` - CreatedByAdminID int64 `json:"created_by_admin_id"` - UpdatedByAdminID int64 `json:"updated_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - UpdatedAtMS int64 `json:"updated_at_ms"` + Track string `json:"track"` + Level int32 `json:"level"` + RequiredValue int64 `json:"required_value"` + Name string `json:"name"` + Status string `json:"status"` + RewardResourceGroupID int64 `json:"reward_resource_group_id"` + RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"` + SortOrder int32 `json:"sort_order"` + DisplayConfigJSON string `json:"display_config_json"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + UpdatedByAdminID int64 `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` } type levelTierData struct { - TierID int64 `json:"tier_id"` - Track string `json:"track"` - MinLevel int32 `json:"min_level"` - MaxLevel int32 `json:"max_level"` - Name string `json:"name"` - DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` - DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` - RewardResourceGroupID int64 `json:"reward_resource_group_id"` - Status string `json:"status"` - DisplayConfigJSON string `json:"display_config_json"` - CreatedByAdminID int64 `json:"created_by_admin_id"` - UpdatedByAdminID int64 `json:"updated_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - UpdatedAtMS int64 `json:"updated_at_ms"` + TierID int64 `json:"tier_id"` + Track string `json:"track"` + MinLevel int32 `json:"min_level"` + MaxLevel int32 `json:"max_level"` + Name string `json:"name"` + DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` + DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"` + DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` + DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"` + RewardResourceGroupID int64 `json:"reward_resource_group_id"` + RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"` + Status string `json:"status"` + DisplayConfigJSON string `json:"display_config_json"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + UpdatedByAdminID int64 `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` } type levelRewardData struct { @@ -98,9 +104,15 @@ func (h *Handler) getMyLevelOverview(writer http.ResponseWriter, request *http.R httpkit.WriteRPCError(writer, request, err) return } + // activity-service 只拥有等级关系和资源 ID;素材 URL 属于 wallet-service。Gateway 在同一响应补齐当前等级素材, + // 避免 H5 再依赖后台接口或把线上资源 ID 映射硬编码进页面。 + resources, ok := h.resolveAchievementResources(writer, request, levelOverviewResourceIDs(resp.GetTracks())) + if !ok { + return + } tracks := make([]levelTrackOverviewData, 0, len(resp.GetTracks())) for _, item := range resp.GetTracks() { - tracks = append(tracks, levelTrackOverviewFromProto(item)) + tracks = append(tracks, levelTrackOverviewFromProto(item, resources)) } httpkit.WriteOK(writer, request, levelOverviewData{Tracks: tracks, ServerTimeMS: resp.GetServerTimeMs()}) } @@ -124,16 +136,26 @@ func (h *Handler) getLevelTrack(writer http.ResponseWriter, request *http.Reques httpkit.WriteRPCError(writer, request, err) return } + // 等级层页面必须一次拿到区间、徽章、头像框和升级礼物;这里按正 ID 去重后解析, + // 既保持 activity/wallet 的 owner 边界,也避免前端为每一行产生额外请求。 + resources, ok := h.resolveAchievementResources(writer, request, levelTrackResourceIDs(resp.GetOverview(), resp.GetTiers())) + if !ok { + return + } + resourceGroups, ok := h.resolveAchievementResourceGroups(writer, request, levelTrackResourceGroupIDs(resp.GetRules(), resp.GetTiers())) + if !ok { + return + } rules := make([]levelRuleData, 0, len(resp.GetRules())) for _, rule := range resp.GetRules() { - rules = append(rules, levelRuleFromProto(rule)) + rules = append(rules, levelRuleFromProto(rule, resourceGroups)) } tiers := make([]levelTierData, 0, len(resp.GetTiers())) for _, tier := range resp.GetTiers() { - tiers = append(tiers, levelTierFromProto(tier)) + tiers = append(tiers, levelTierFromProto(tier, resources, resourceGroups)) } httpkit.WriteOK(writer, request, levelTrackDetailData{ - Overview: levelTrackOverviewFromProto(resp.GetOverview()), + Overview: levelTrackOverviewFromProto(resp.GetOverview(), resources), Rules: rules, Tiers: tiers, ServerTimeMS: resp.GetServerTimeMs(), @@ -179,8 +201,8 @@ func (h *Handler) listLevelRewards(writer http.ResponseWriter, request *http.Req }) } -func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrackOverviewData { - return levelTrackOverviewData{ +func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview, resources map[int64]achievementResourceData) levelTrackOverviewData { + data := levelTrackOverviewData{ Track: item.GetTrack(), Name: item.GetName(), Level: item.GetLevel(), @@ -195,10 +217,17 @@ func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrack RewardPendingCount: item.GetRewardPendingCount(), SortOrder: item.GetSortOrder(), } + if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok { + data.DisplayAvatarFrameResource = &resource + } + if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok { + data.DisplayBadgeResource = &resource + } + return data } -func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData { - return levelRuleData{ +func levelRuleFromProto(item *activityv1.LevelRule, resourceGroups map[int64]achievementResourceGroupData) levelRuleData { + data := levelRuleData{ Track: item.GetTrack(), Level: item.GetLevel(), RequiredValue: item.GetRequiredValue(), @@ -212,10 +241,14 @@ func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData { CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } + if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok { + data.RewardResourceGroup = &group + } + return data } -func levelTierFromProto(item *activityv1.LevelTier) levelTierData { - return levelTierData{ +func levelTierFromProto(item *activityv1.LevelTier, resources map[int64]achievementResourceData, resourceGroups map[int64]achievementResourceGroupData) levelTierData { + data := levelTierData{ TierID: item.GetTierId(), Track: item.GetTrack(), MinLevel: item.GetMinLevel(), @@ -231,6 +264,43 @@ func levelTierFromProto(item *activityv1.LevelTier) levelTierData { CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } + if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok { + data.DisplayAvatarFrameResource = &resource + } + if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok { + data.DisplayBadgeResource = &resource + } + if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok { + data.RewardResourceGroup = &group + } + return data +} + +func levelOverviewResourceIDs(items []*activityv1.LevelTrackOverview) []int64 { + ids := make([]int64, 0, len(items)*2) + for _, item := range items { + ids = append(ids, item.GetDisplayAvatarFrameResourceId(), item.GetDisplayBadgeResourceId()) + } + return ids +} + +func levelTrackResourceIDs(overview *activityv1.LevelTrackOverview, tiers []*activityv1.LevelTier) []int64 { + ids := levelOverviewResourceIDs([]*activityv1.LevelTrackOverview{overview}) + for _, tier := range tiers { + ids = append(ids, tier.GetDisplayAvatarFrameResourceId(), tier.GetDisplayBadgeResourceId()) + } + return ids +} + +func levelTrackResourceGroupIDs(rules []*activityv1.LevelRule, tiers []*activityv1.LevelTier) []int64 { + ids := make([]int64, 0, len(rules)+len(tiers)) + for _, rule := range rules { + ids = append(ids, rule.GetRewardResourceGroupId()) + } + for _, tier := range tiers { + ids = append(ids, tier.GetRewardResourceGroupId()) + } + return ids } func levelRewardFromProto(item *activityv1.LevelRewardJob) levelRewardData { diff --git a/services/gateway-service/internal/transport/http/activityapi/level_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/level_handler_test.go new file mode 100644 index 00000000..1693544b --- /dev/null +++ b/services/gateway-service/internal/transport/http/activityapi/level_handler_test.go @@ -0,0 +1,148 @@ +package activityapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + activityv1 "hyapp.local/api/proto/activity/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/services/gateway-service/internal/client" +) + +type levelGrowthClientStub struct { + client.GrowthLevelClient + trackResponse *activityv1.GetLevelTrackResponse +} + +func (s *levelGrowthClientStub) GetLevelTrack(context.Context, *activityv1.GetLevelTrackRequest) (*activityv1.GetLevelTrackResponse, error) { + return s.trackResponse, nil +} + +type levelWalletClientStub struct { + client.WalletClient + resources map[int64]*walletv1.Resource + resourceGroups map[int64]*walletv1.ResourceGroup + resourceCallCounts map[int64]int +} + +func (s *levelWalletClientStub) GetResource(_ context.Context, request *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { + s.resourceCallCounts[request.GetResourceId()]++ + return &walletv1.GetResourceResponse{Resource: s.resources[request.GetResourceId()]}, nil +} + +func (s *levelWalletClientStub) GetResourceGroup(_ context.Context, request *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) { + return &walletv1.GetResourceGroupResponse{Group: s.resourceGroups[request.GetGroupId()]}, nil +} + +func TestGetLevelTrackEmbedsTierMaterialsAndDeduplicatesResources(t *testing.T) { + badge := &walletv1.Resource{ + ResourceId: 407, + ResourceCode: "Charm_Badge_LV10-19", + ResourceType: "badge", + Name: "Charm Badge 10-19", + Status: "active", + PreviewUrl: "https://cdn.example/charm-badge.png", + } + frame := &walletv1.Resource{ + ResourceId: 440, + ResourceCode: "Charm_Frame_LV10-19", + ResourceType: "avatar_frame", + Name: "Charm Frame 10-19", + Status: "active", + PreviewUrl: "https://cdn.example/charm-frame.png", + } + reward := &walletv1.Resource{ + ResourceId: 900, + ResourceCode: "level_reward_gift", + ResourceType: "gift", + Name: "Level Reward Gift", + Status: "active", + PreviewUrl: "https://cdn.example/level-gift.png", + } + wallet := &levelWalletClientStub{ + resources: map[int64]*walletv1.Resource{407: badge, 440: frame}, + resourceGroups: map[int64]*walletv1.ResourceGroup{77: { + GroupId: 77, + GroupCode: "charm_level_reward", + Name: "Charm Level Reward", + Status: "active", + Items: []*walletv1.ResourceGroupItem{{ + GroupItemId: 1, + ItemType: "resource", + ResourceId: 900, + Resource: reward, + Quantity: 1, + }}, + }}, + resourceCallCounts: map[int64]int{}, + } + growth := &levelGrowthClientStub{trackResponse: &activityv1.GetLevelTrackResponse{ + Overview: &activityv1.LevelTrackOverview{ + Track: "charm", + Level: 12, + DisplayAvatarFrameResourceId: 440, + DisplayBadgeResourceId: 407, + }, + Rules: []*activityv1.LevelRule{{Track: "charm", Level: 10, RewardResourceGroupId: 77}}, + Tiers: []*activityv1.LevelTier{{ + TierId: 2, + Track: "charm", + MinLevel: 10, + MaxLevel: 19, + DisplayAvatarFrameResourceId: 440, + DisplayBadgeResourceId: 407, + RewardResourceGroupId: 77, + Status: "active", + }}, + }} + handler := New(Config{GrowthLevelClient: growth, WalletClient: wallet}) + request := httptest.NewRequest(http.MethodGet, "/api/v1/levels/tracks/charm", nil) + request.SetPathValue("track", "charm") + recorder := httptest.NewRecorder() + + handler.getLevelTrack(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data struct { + Overview struct { + DisplayBadgeResource struct { + PreviewURL string `json:"preview_url"` + } `json:"display_badge_resource"` + } `json:"overview"` + Tiers []struct { + DisplayAvatarFrameResource struct { + PreviewURL string `json:"preview_url"` + } `json:"display_avatar_frame_resource"` + RewardResourceGroup struct { + Items []struct { + Resource struct { + PreviewURL string `json:"preview_url"` + } `json:"resource"` + } `json:"items"` + } `json:"reward_resource_group"` + } `json:"tiers"` + } `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if envelope.Data.Overview.DisplayBadgeResource.PreviewURL != badge.GetPreviewUrl() { + t.Fatalf("overview badge material missing: %+v", envelope.Data.Overview) + } + if len(envelope.Data.Tiers) != 1 || envelope.Data.Tiers[0].DisplayAvatarFrameResource.PreviewURL != frame.GetPreviewUrl() { + t.Fatalf("tier frame material missing: %+v", envelope.Data.Tiers) + } + if envelope.Data.Tiers[0].RewardResourceGroup.Items[0].Resource.PreviewURL != reward.GetPreviewUrl() { + t.Fatalf("tier reward material missing: %+v", envelope.Data.Tiers[0].RewardResourceGroup) + } + // Overview 与 tier 会引用同一素材;请求内按 ID 去重,避免每次打开等级页重复打 wallet-service。 + if wallet.resourceCallCounts[407] != 1 || wallet.resourceCallCounts[440] != 1 { + t.Fatalf("resource lookup was not deduplicated: %+v", wallet.resourceCallCounts) + } +} diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go index 014ce87e..3c81d6b4 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -1,6 +1,7 @@ package activityapi import ( + "encoding/json" "net/http" "strconv" "strings" @@ -13,6 +14,7 @@ import ( roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" ) const ( @@ -53,10 +55,11 @@ type userLeaderboardItem struct { } type userLeaderboardUser struct { - UserID string `json:"user_id"` - DisplayUserID string `json:"display_user_id,omitempty"` - Username string `json:"username,omitempty"` - Avatar string `json:"avatar,omitempty"` + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id,omitempty"` + Username string `json:"username,omitempty"` + Avatar string `json:"avatar,omitempty"` + ShortBadgeCoverURLs []string `json:"short_badge_cover_urls,omitempty"` } type userLeaderboardRoom struct { @@ -234,11 +237,17 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st if err != nil { return err } + shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs) + if err != nil { + return err + } for i := range items { applyUserLeaderboardProfile(&items[i], profiles) + applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers) } if myRank != nil { applyUserLeaderboardProfile(myRank, profiles) + applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers) } return nil } @@ -336,6 +345,89 @@ func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]* } } +func (h *Handler) userLeaderboardShortBadgeCovers(request *http.Request, userIDs []int64) (map[int64][]string, error) { + result := make(map[int64][]string) + userIDs = uniqueUserLeaderboardInt64s(userIDs) + if h.walletClient == nil || len(userIDs) == 0 { + // 榜单基础资料不依赖装扮服务;未接 wallet 的环境保持兼容,只是不返回短徽章。 + return result, nil + } + // 一页榜单只发起一次批量查询,避免按用户逐个读取权益和资源造成 N+1 放大。 + resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ + RequestId: httpkit.RequestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + UserIds: userIDs, + ResourceTypes: []string{"badge"}, + }) + if err != nil { + return nil, err + } + return userLeaderboardShortBadgeCoversFromResponse(resp), nil +} + +func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) map[int64][]string { + result := make(map[int64][]string) + if resp == nil { + return result + } + for _, user := range resp.GetUsers() { + if user.GetUserId() <= 0 { + continue + } + seen := make(map[string]struct{}) + for _, entitlement := range user.GetResources() { + resource := entitlement.GetResource() + if resource == nil || !isUserLeaderboardShortBadge(resource.GetMetadataJson()) { + continue + } + // preview_url 是静态封面契约;动画 asset_url 不能直接交给 H5 img 渲染。 + coverURL := strings.TrimSpace(resource.GetPreviewUrl()) + if coverURL == "" { + continue + } + if _, exists := seen[coverURL]; exists { + continue + } + seen[coverURL] = struct{}{} + result[user.GetUserId()] = append(result[user.GetUserId()], coverURL) + } + } + return result +} + +func isUserLeaderboardShortBadge(rawMetadata string) bool { + metadata := map[string]any{} + if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil { + return false + } + if strings.EqualFold(strings.TrimSpace(toUserLeaderboardString(metadata["badge_kind"])), "level") { + // 等级徽章有独立展示位,不能混入用户主动佩戴的普通短徽章。 + return false + } + switch strings.ToLower(strings.TrimSpace(toUserLeaderboardString(metadata["badge_form"]))) { + case "short", "tile", "icon", "medal": + return true + default: + return false + } +} + +func toUserLeaderboardString(value any) string { + text, _ := value.(string) + return text +} + +func applyUserLeaderboardShortBadgeCovers(item *userLeaderboardItem, covers map[int64][]string) { + if item == nil || item.User == nil { + return + } + userID, err := strconv.ParseInt(item.UserID, 10, 64) + if err != nil || userID <= 0 || len(covers[userID]) == 0 { + return + } + item.User.ShortBadgeCoverURLs = append([]string(nil), covers[userID]...) +} + func uniqueUserLeaderboardInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go index daf40fd4..51f9eac2 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go @@ -13,6 +13,7 @@ import ( "hyapp/services/gateway-service/internal/auth" roomv1 "hyapp.local/api/proto/room/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" ) func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) { @@ -49,6 +50,27 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) { } } +func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) { + response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{ + UserId: 20002, + Resources: []*walletv1.UserResourceEntitlement{ + {Resource: &walletv1.Resource{ResourceId: 1, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`}}, + {Resource: &walletv1.Resource{ResourceId: 2, ResourceType: "badge", PreviewUrl: "https://cdn.example/long.png", MetadataJson: `{"badge_form":"strip","badge_kind":"normal"}`}}, + {Resource: &walletv1.Resource{ResourceId: 3, ResourceType: "badge", PreviewUrl: "https://cdn.example/level.png", MetadataJson: `{"badge_form":"icon","badge_kind":"level"}`}}, + {Resource: &walletv1.Resource{ResourceId: 4, ResourceType: "badge", AssetUrl: "https://cdn.example/animated.svga", MetadataJson: `{"badge_form":"short","badge_kind":"normal"}`}}, + {Resource: &walletv1.Resource{ResourceId: 5, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"medal","badge_kind":"normal"}`}}, + }, + }}} + + covers := userLeaderboardShortBadgeCoversFromResponse(response) + if len(covers[20002]) != 1 || covers[20002][0] != "https://cdn.example/short.png" { + t.Fatalf("only unique normal short-badge covers should be returned: %+v", covers) + } + if len(covers[99999]) != 0 { + t.Fatalf("users without a short badge must stay absent: %+v", covers) + } +} + func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) { room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{ RoomId: "room_1", diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 3f1b3bdd..97ad9952 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -719,6 +719,18 @@ type fakeWithdrawalApplicationWriter struct { err error } +type fakeWithdrawalApplicationNotifier struct { + last financewithdrawal.Application + calls int + err error +} + +func (f *fakeWithdrawalApplicationNotifier) NotifyApplicationCreated(_ context.Context, application financewithdrawal.Application) error { + f.last = application + f.calls++ + return f.err +} + func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) { f.last = command f.calls++ @@ -6048,10 +6060,12 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}} profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} withdrawalWriter := &fakeWithdrawalApplicationWriter{} + withdrawalNotifier := &fakeWithdrawalApplicationNotifier{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) handler.SetWithdrawalApplicationWriter(withdrawalWriter) + handler.SetWithdrawalApplicationNotifier(withdrawalNotifier) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) @@ -6090,6 +6104,13 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin withdrawalWriter.last.CreatedAtMS <= 0 { t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls) } + if withdrawalNotifier.calls != 1 || + withdrawalNotifier.last.ID != 77 || + withdrawalNotifier.last.UserID != 42 || + withdrawalNotifier.last.WithdrawAmount != "50.00" || + withdrawalNotifier.last.FreezeTransactionID != "freeze-tx-1" { + t.Fatalf("withdraw application must notify DingTalk after persistence: %+v calls=%d", withdrawalNotifier.last, withdrawalNotifier.calls) + } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err)