去掉财务

This commit is contained in:
zhx 2026-07-14 14:08:08 +08:00
parent 6f466fa174
commit 1e7c3115d6
33 changed files with 535 additions and 2006 deletions

View File

@ -134,7 +134,10 @@ X-App-Code: lalu
"user_id": "10001", "user_id": "10001",
"display_user_id": "888888", "display_user_id": "888888",
"username": "Luna", "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 ```json
@ -358,12 +363,14 @@ class LeaderboardUser {
required this.displayUserId, required this.displayUserId,
required this.username, required this.username,
required this.avatar, required this.avatar,
required this.shortBadgeCoverUrls,
}); });
final String userId; final String userId;
final String displayUserId; final String displayUserId;
final String username; final String username;
final String avatar; final String avatar;
final List<String> shortBadgeCoverUrls;
factory LeaderboardUser.fromJson(Map<String, dynamic> json) { factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
return LeaderboardUser( return LeaderboardUser(
@ -371,6 +378,9 @@ class LeaderboardUser {
displayUserId: json['display_user_id'] as String? ?? '', displayUserId: json['display_user_id'] as String? ?? '',
username: json['username'] as String? ?? '', username: json['username'] as String? ?? '',
avatar: json['avatar'] as String? ?? '', avatar: json['avatar'] as String? ?? '',
shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List<dynamic>? ?? const [])
.whereType<String>()
.toList(),
); );
} }
} }

View File

@ -18,7 +18,6 @@ import (
"hyapp-admin-server/internal/cache" "hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
"hyapp-admin-server/internal/integration/gameclient" "hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/googleorders" "hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/integration/luckygiftadmin" "hyapp-admin-server/internal/integration/luckygiftadmin"
@ -44,7 +43,6 @@ import (
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard" dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi" databimodule "hyapp-admin-server/internal/modules/databi"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financeordermodule "hyapp-admin-server/internal/modules/financeorder" financeordermodule "hyapp-admin-server/internal/modules/financeorder"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -320,24 +318,6 @@ func main() {
} }
objectUploader = uploader 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 服务实例,保证外部源路由和统计口径一致。 // 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
dashboardService := dashboardmodule.NewService( dashboardService := dashboardmodule.NewService(
store, store,
@ -387,7 +367,6 @@ func main() {
Dashboard: dashboardmodule.NewWithService(dashboardService), Dashboard: dashboardmodule.NewWithService(dashboardService),
Databi: databimodule.New(databiService, store, auditHandler), Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, 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...)), 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), FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),

View File

@ -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" 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" 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" 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: tencent-cos:
enabled: true enabled: true
secret-id: "REPLACE_ME" secret-id: "REPLACE_ME"

View File

@ -148,14 +148,6 @@ legacy_coin_seller_recharge_sources:
app_mysql_dsn: "" app_mysql_dsn: ""
wallet_mysql_dsn: "" wallet_mysql_dsn: ""
request_timeout: "5s" 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: tencent-cos:
enabled: true enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu" secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -49,7 +49,6 @@ type Config struct {
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
} }
type MigrationConfig struct { type MigrationConfig struct {
@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"` 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 { type RobotProfileSourceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
@ -372,12 +358,6 @@ func Default() Config {
RequestTimeout: 5 * time.Second, RequestTimeout: 5 * time.Second,
}, },
}, },
FinanceNotifications: FinanceNotificationsConfig{
DingTalk: DingTalkRobotConfig{
Enabled: true,
RequestTimeout: 3 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true, Enabled: true,
PollInterval: 15 * time.Second, PollInterval: 15 * time.Second,
@ -575,13 +555,6 @@ func (cfg *Config) Normalize() {
} }
} }
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() 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) cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 { if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second 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) 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) == "" { if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
} }
@ -925,26 +890,6 @@ func (cfg Config) Validate() error {
return nil 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() { func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
for index := range cfg.DashboardExternalSources { for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index] source := &cfg.DashboardExternalSources[index]

View File

@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) {
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" { if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS) 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) { 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) { func TestAslanMongoEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin") t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard") t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")

View File

@ -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"`
}

View File

@ -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")
}
}

View File

@ -14,10 +14,6 @@ const (
JobStatusFailed = "failed" JobStatusFailed = "failed"
JobStatusCanceled = "canceled" JobStatusCanceled = "canceled"
FinanceApplicationStatusPending = "pending"
FinanceApplicationStatusApproved = "approved"
FinanceApplicationStatusRejected = "rejected"
CoinSellerRechargeOrderStatusCreated = "created" CoinSellerRechargeOrderStatusCreated = "created"
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed" CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
CoinSellerRechargeOrderStatusVerified = "verified" CoinSellerRechargeOrderStatusVerified = "verified"
@ -567,36 +563,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string {
return "admin_legacy_google_paid_sync_attempts" 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 钱包账套持有。 // CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
type CoinSellerRechargeOrder struct { type CoinSellerRechargeOrder struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`

View File

@ -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)
}

View File

@ -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{
"### 财务申请待审核",
"- APPlalu",
"- 操作:增加币商金币",
"- 目标用户ID10001",
"- 金币数量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)
}
}
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 = &notified
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_amountrecharge_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
}

View File

@ -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},
}
}

View File

@ -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")
}
}

View File

@ -10,7 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil { if h == nil {
return return
} }
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications) protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), 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/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
} }

View File

@ -22,7 +22,6 @@ import (
const ( const (
permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit" permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionAuditFinanceApplications = "finance-application:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500) pointWithdrawalDefaultFeeBPS = int32(500)
@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string {
func canAuditWithdrawal(permissions []string) bool { func canAuditWithdrawal(permissions []string) bool {
for _, permission := range permissions { for _, permission := range permissions {
switch strings.TrimSpace(permission) { switch strings.TrimSpace(permission) {
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications: case permissionAuditWithdrawalApplications:
return true return true
} }
} }

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -88,7 +88,6 @@ func (s *Store) AutoMigrate() error {
&model.TemporaryPaymentLinkOwner{}, &model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{}, &model.LegacyGooglePaidDetail{},
&model.LegacyGooglePaidSyncAttempt{}, &model.LegacyGooglePaidSyncAttempt{},
&model.FinanceApplication{},
&model.CoinSellerRechargeOrder{}, &model.CoinSellerRechargeOrder{},
&model.UserWithdrawalApplication{}, &model.UserWithdrawalApplication{},
&model.AdminJob{}, &model.AdminJob{},

View File

@ -128,21 +128,12 @@ var defaultPermissions = []model.Permission{
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"}, {Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"}, {Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"}, {Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit是否能看到审核列表由 audit 权限单独决定。
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"}, {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:view", Kind: "menu"},
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"}, {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:verify", Kind: "button"},
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"}, {Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"}, {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:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"}, {Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", 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", "agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update", "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-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", "lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete", "game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "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", "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", "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-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", "lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete", "game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",

View File

@ -17,19 +17,25 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
for _, code := range []string{ for _, code := range []string{
"payment-temporary-link:create", "payment-temporary-link:create",
"finance:view", "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:create",
"finance-application:audit", "finance-application:audit",
"finance-withdrawal:view",
"finance-operation:user-coin-credit", "finance-operation:user-coin-credit",
"finance-operation:user-coin-debit", "finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit", "finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit", "finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit", "finance-operation:coin-seller-coin-debit",
"host-withdrawal:view",
} { } {
if _, ok := permissions[code]; !ok { if _, ok := permissions[code]; ok {
t.Fatalf("finance permission %s missing from default seed", code) 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{ for _, code := range []string{
"payment-temporary-link:create", "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", "host-withdrawal:view",
} { } {
if _, ok := opsPermissions[code]; !ok { if _, ok := opsPermissions[code]; !ok {
@ -57,11 +56,22 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
} }
} }
if _, ok := opsPermissions["finance-application:audit"]; ok { for _, code := range []string{
t.Fatal("ops-admin must not receive finance audit permission by default") "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)
} }
if _, ok := opsMigrationPermissions["finance-application:audit"]; ok {
t.Fatal("ops-admin migration must not append finance audit permission by default")
} }
} }

View File

@ -19,7 +19,6 @@ import (
"hyapp-admin-server/internal/modules/dailytask" "hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/databi" "hyapp-admin-server/internal/modules/databi"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financeorder" "hyapp-admin-server/internal/modules/financeorder"
"hyapp-admin-server/internal/modules/financewithdrawal" "hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward" "hyapp-admin-server/internal/modules/firstrechargereward"
@ -84,7 +83,6 @@ type Handlers struct {
Dashboard *dashboard.Handler Dashboard *dashboard.Handler
Databi *databi.Handler Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceOrder *financeorder.Handler FinanceOrder *financeorder.Handler
FinanceWithdrawal *financewithdrawal.Handler FinanceWithdrawal *financewithdrawal.Handler
FullServerNotice *fullservernotice.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) cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask) dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward) firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
financeorder.RegisterRoutes(protected, h.FinanceOrder) financeorder.RegisterRoutes(protected, h.FinanceOrder)
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal) financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice) fullservernotice.RegisterRoutes(protected, h.FullServerNotice)

View File

@ -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'
);

View File

@ -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{
"用户提现申请待审核",
"申请ID77",
"APPlalu",
"用户ID42",
"提现金额:$ 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)
}
}
}

View File

@ -24,7 +24,9 @@ type levelTrackOverviewData struct {
NextLevel int32 `json:"next_level"` NextLevel int32 `json:"next_level"`
NextLevelRequiredValue int64 `json:"next_level_required_value"` NextLevelRequiredValue int64 `json:"next_level_required_value"`
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"` DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
RewardPendingCount int64 `json:"reward_pending_count"` RewardPendingCount int64 `json:"reward_pending_count"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
@ -44,6 +46,7 @@ type levelRuleData struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"` RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
DisplayConfigJSON string `json:"display_config_json"` DisplayConfigJSON string `json:"display_config_json"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
@ -59,8 +62,11 @@ type levelTierData struct {
MaxLevel int32 `json:"max_level"` MaxLevel int32 `json:"max_level"`
Name string `json:"name"` Name string `json:"name"`
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"` RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
Status string `json:"status"` Status string `json:"status"`
DisplayConfigJSON string `json:"display_config_json"` DisplayConfigJSON string `json:"display_config_json"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
@ -98,9 +104,15 @@ func (h *Handler) getMyLevelOverview(writer http.ResponseWriter, request *http.R
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return 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())) tracks := make([]levelTrackOverviewData, 0, len(resp.GetTracks()))
for _, item := range 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()}) 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) httpkit.WriteRPCError(writer, request, err)
return 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())) rules := make([]levelRuleData, 0, len(resp.GetRules()))
for _, rule := range 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())) tiers := make([]levelTierData, 0, len(resp.GetTiers()))
for _, tier := range resp.GetTiers() { for _, tier := range resp.GetTiers() {
tiers = append(tiers, levelTierFromProto(tier)) tiers = append(tiers, levelTierFromProto(tier, resources, resourceGroups))
} }
httpkit.WriteOK(writer, request, levelTrackDetailData{ httpkit.WriteOK(writer, request, levelTrackDetailData{
Overview: levelTrackOverviewFromProto(resp.GetOverview()), Overview: levelTrackOverviewFromProto(resp.GetOverview(), resources),
Rules: rules, Rules: rules,
Tiers: tiers, Tiers: tiers,
ServerTimeMS: resp.GetServerTimeMs(), ServerTimeMS: resp.GetServerTimeMs(),
@ -179,8 +201,8 @@ func (h *Handler) listLevelRewards(writer http.ResponseWriter, request *http.Req
}) })
} }
func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrackOverviewData { func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview, resources map[int64]achievementResourceData) levelTrackOverviewData {
return levelTrackOverviewData{ data := levelTrackOverviewData{
Track: item.GetTrack(), Track: item.GetTrack(),
Name: item.GetName(), Name: item.GetName(),
Level: item.GetLevel(), Level: item.GetLevel(),
@ -195,10 +217,17 @@ func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrack
RewardPendingCount: item.GetRewardPendingCount(), RewardPendingCount: item.GetRewardPendingCount(),
SortOrder: item.GetSortOrder(), 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 { func levelRuleFromProto(item *activityv1.LevelRule, resourceGroups map[int64]achievementResourceGroupData) levelRuleData {
return levelRuleData{ data := levelRuleData{
Track: item.GetTrack(), Track: item.GetTrack(),
Level: item.GetLevel(), Level: item.GetLevel(),
RequiredValue: item.GetRequiredValue(), RequiredValue: item.GetRequiredValue(),
@ -212,10 +241,14 @@ func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData {
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(),
} }
if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok {
data.RewardResourceGroup = &group
}
return data
} }
func levelTierFromProto(item *activityv1.LevelTier) levelTierData { func levelTierFromProto(item *activityv1.LevelTier, resources map[int64]achievementResourceData, resourceGroups map[int64]achievementResourceGroupData) levelTierData {
return levelTierData{ data := levelTierData{
TierID: item.GetTierId(), TierID: item.GetTierId(),
Track: item.GetTrack(), Track: item.GetTrack(),
MinLevel: item.GetMinLevel(), MinLevel: item.GetMinLevel(),
@ -231,6 +264,43 @@ func levelTierFromProto(item *activityv1.LevelTier) levelTierData {
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), 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 { func levelRewardFromProto(item *activityv1.LevelRewardJob) levelRewardData {

View File

@ -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)
}
}

View File

@ -1,6 +1,7 @@
package activityapi package activityapi
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -13,6 +14,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
) )
const ( const (
@ -57,6 +59,7 @@ type userLeaderboardUser struct {
DisplayUserID string `json:"display_user_id,omitempty"` DisplayUserID string `json:"display_user_id,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
ShortBadgeCoverURLs []string `json:"short_badge_cover_urls,omitempty"`
} }
type userLeaderboardRoom struct { type userLeaderboardRoom struct {
@ -234,11 +237,17 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st
if err != nil { if err != nil {
return err return err
} }
shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs)
if err != nil {
return err
}
for i := range items { for i := range items {
applyUserLeaderboardProfile(&items[i], profiles) applyUserLeaderboardProfile(&items[i], profiles)
applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers)
} }
if myRank != nil { if myRank != nil {
applyUserLeaderboardProfile(myRank, profiles) applyUserLeaderboardProfile(myRank, profiles)
applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers)
} }
return nil 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 { func uniqueUserLeaderboardInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values)) seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values)) out := make([]int64, 0, len(values))

View File

@ -13,6 +13,7 @@ import (
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
) )
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) { 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) { func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) {
room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{ room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{
RoomId: "room_1", RoomId: "room_1",

View File

@ -719,6 +719,18 @@ type fakeWithdrawalApplicationWriter struct {
err error 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) { func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
f.last = command f.last = command
f.calls++ f.calls++
@ -6048,10 +6060,12 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
withdrawalWriter := &fakeWithdrawalApplicationWriter{} withdrawalWriter := &fakeWithdrawalApplicationWriter{}
withdrawalNotifier := &fakeWithdrawalApplicationNotifier{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient) handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient) handler.SetUserHostClient(hostClient)
handler.SetWithdrawalApplicationWriter(withdrawalWriter) handler.SetWithdrawalApplicationWriter(withdrawalWriter)
handler.SetWithdrawalApplicationNotifier(withdrawalNotifier)
router := handler.Routes(auth.NewVerifier("secret")) 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 := 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)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -6090,6 +6104,13 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
withdrawalWriter.last.CreatedAtMS <= 0 { withdrawalWriter.last.CreatedAtMS <= 0 {
t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls) 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 var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err) t.Fatalf("decode response failed: %v", err)