去掉财务
This commit is contained in:
parent
6f466fa174
commit
1e7c3115d6
@ -134,7 +134,10 @@ X-App-Code: lalu
|
||||
"user_id": "10001",
|
||||
"display_user_id": "888888",
|
||||
"username": "Luna",
|
||||
"avatar": "https://cdn.example/avatar/10001.png"
|
||||
"avatar": "https://cdn.example/avatar/10001.png",
|
||||
"short_badge_cover_urls": [
|
||||
"https://cdn.example/badges/short-1.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
@ -164,6 +167,8 @@ X-App-Code: lalu
|
||||
}
|
||||
```
|
||||
|
||||
`user.short_badge_cover_urls` 只包含用户当前佩戴的普通短徽章静态封面,长徽章和等级徽章不会混入。用户没有短徽章时字段省略,客户端按空数组处理即可;房间榜没有用户徽章字段。
|
||||
|
||||
房间榜返回示例:
|
||||
|
||||
```json
|
||||
@ -358,12 +363,14 @@ class LeaderboardUser {
|
||||
required this.displayUserId,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
required this.shortBadgeCoverUrls,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String displayUserId;
|
||||
final String username;
|
||||
final String avatar;
|
||||
final List<String> shortBadgeCoverUrls;
|
||||
|
||||
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
|
||||
return LeaderboardUser(
|
||||
@ -371,6 +378,9 @@ class LeaderboardUser {
|
||||
displayUserId: json['display_user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"hyapp-admin-server/internal/cache"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
|
||||
"hyapp-admin-server/internal/integration/gameclient"
|
||||
"hyapp-admin-server/internal/integration/googleorders"
|
||||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||
@ -44,7 +43,6 @@ import (
|
||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
||||
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
|
||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
@ -320,24 +318,6 @@ func main() {
|
||||
}
|
||||
objectUploader = uploader
|
||||
}
|
||||
var financeApplicationOptions []financeapplicationmodule.Option
|
||||
if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" {
|
||||
client, err := dingtalkclient.New(dingtalkclient.Config{
|
||||
WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL,
|
||||
Secret: cfg.FinanceNotifications.DingTalk.Secret,
|
||||
AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles,
|
||||
AtAll: cfg.FinanceNotifications.DingTalk.AtAll,
|
||||
RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
fatalRuntime("create_finance_dingtalk_client_failed", err)
|
||||
}
|
||||
financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client)))
|
||||
}
|
||||
financeApplicationOptions = append(financeApplicationOptions,
|
||||
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
|
||||
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
||||
)
|
||||
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
|
||||
dashboardService := dashboardmodule.NewService(
|
||||
store,
|
||||
@ -387,7 +367,6 @@ func main() {
|
||||
Dashboard: dashboardmodule.NewWithService(dashboardService),
|
||||
Databi: databimodule.New(databiService, store, auditHandler),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
||||
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
|
||||
@ -151,14 +151,6 @@ legacy_coin_seller_recharge_sources:
|
||||
app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||
wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||
request_timeout: "5s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
|
||||
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
|
||||
at_mobiles: []
|
||||
at_all: false
|
||||
request_timeout: "3s"
|
||||
tencent-cos:
|
||||
enabled: true
|
||||
secret-id: "REPLACE_ME"
|
||||
|
||||
@ -148,14 +148,6 @@ legacy_coin_seller_recharge_sources:
|
||||
app_mysql_dsn: ""
|
||||
wallet_mysql_dsn: ""
|
||||
request_timeout: "5s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
|
||||
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
|
||||
at_mobiles: []
|
||||
at_all: false
|
||||
request_timeout: "3s"
|
||||
tencent-cos:
|
||||
enabled: true
|
||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
||||
|
||||
@ -49,7 +49,6 @@ type Config struct {
|
||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
||||
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
||||
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
|
||||
}
|
||||
|
||||
type MigrationConfig struct {
|
||||
@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct {
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
type FinanceNotificationsConfig struct {
|
||||
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
|
||||
}
|
||||
|
||||
type DingTalkRobotConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
WebhookURL string `yaml:"webhook_url"`
|
||||
Secret string `yaml:"secret"`
|
||||
AtMobiles []string `yaml:"at_mobiles"`
|
||||
AtAll bool `yaml:"at_all"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
type RobotProfileSourceConfig struct {
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
@ -372,12 +358,6 @@ func Default() Config {
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
FinanceNotifications: FinanceNotificationsConfig{
|
||||
DingTalk: DingTalkRobotConfig{
|
||||
Enabled: true,
|
||||
RequestTimeout: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
||||
Enabled: true,
|
||||
PollInterval: 15 * time.Second,
|
||||
@ -575,13 +555,6 @@ func (cfg *Config) Normalize() {
|
||||
}
|
||||
}
|
||||
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
|
||||
cfg.applyFinanceNotificationEnvOverrides()
|
||||
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
|
||||
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
|
||||
cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles)
|
||||
if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
|
||||
cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second
|
||||
}
|
||||
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
|
||||
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
||||
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
|
||||
@ -834,14 +807,6 @@ func (cfg Config) Validate() error {
|
||||
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
|
||||
}
|
||||
}
|
||||
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" {
|
||||
if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") {
|
||||
return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url")
|
||||
}
|
||||
}
|
||||
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
|
||||
return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0")
|
||||
}
|
||||
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
|
||||
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
|
||||
}
|
||||
@ -925,26 +890,6 @@ func (cfg Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) applyFinanceNotificationEnvOverrides() {
|
||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" {
|
||||
cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value)
|
||||
}
|
||||
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" {
|
||||
// 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。
|
||||
cfg.FinanceNotifications.DingTalk.WebhookURL = value
|
||||
cfg.FinanceNotifications.DingTalk.Enabled = true
|
||||
}
|
||||
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" {
|
||||
cfg.FinanceNotifications.DingTalk.Secret = value
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" {
|
||||
cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",")
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" {
|
||||
cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
|
||||
for index := range cfg.DashboardExternalSources {
|
||||
source := &cfg.DashboardExternalSources[index]
|
||||
|
||||
@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) {
|
||||
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
|
||||
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
|
||||
}
|
||||
if !cfg.FinanceNotifications.DingTalk.Enabled {
|
||||
t.Fatalf("finance dingtalk notification should be enabled by default")
|
||||
}
|
||||
if cfg.FinanceNotifications.DingTalk.WebhookURL == "" || cfg.FinanceNotifications.DingTalk.Secret == "" {
|
||||
t.Fatalf("finance dingtalk notification config should be populated: %#v", cfg.FinanceNotifications.DingTalk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
||||
@ -53,26 +47,6 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinanceDingTalkEnvOverride(t *testing.T) {
|
||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "https://oapi.dingtalk.com/robot/send?access_token=test")
|
||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "SEC_TEST")
|
||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES", "13800138000, 13900139000")
|
||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL", "false")
|
||||
|
||||
cfg := Default()
|
||||
cfg.Normalize()
|
||||
|
||||
if !cfg.FinanceNotifications.DingTalk.Enabled {
|
||||
t.Fatal("DingTalk should be enabled when webhook env is provided")
|
||||
}
|
||||
if cfg.FinanceNotifications.DingTalk.Secret != "SEC_TEST" {
|
||||
t.Fatalf("secret mismatch: %q", cfg.FinanceNotifications.DingTalk.Secret)
|
||||
}
|
||||
if len(cfg.FinanceNotifications.DingTalk.AtMobiles) != 2 {
|
||||
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAslanMongoEnvOverride(t *testing.T) {
|
||||
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
|
||||
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -14,10 +14,6 @@ const (
|
||||
JobStatusFailed = "failed"
|
||||
JobStatusCanceled = "canceled"
|
||||
|
||||
FinanceApplicationStatusPending = "pending"
|
||||
FinanceApplicationStatusApproved = "approved"
|
||||
FinanceApplicationStatusRejected = "rejected"
|
||||
|
||||
CoinSellerRechargeOrderStatusCreated = "created"
|
||||
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
|
||||
CoinSellerRechargeOrderStatusVerified = "verified"
|
||||
@ -567,36 +563,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string {
|
||||
return "admin_legacy_google_paid_sync_attempts"
|
||||
}
|
||||
|
||||
type FinanceApplication struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`
|
||||
Operation string `gorm:"size:64;index:idx_admin_finance_app_operation;not null" json:"operation"`
|
||||
WalletIdentity string `gorm:"size:32;not null;default:''" json:"walletIdentity"`
|
||||
TargetUserID string `gorm:"size:64;index:idx_admin_finance_app_target;not null" json:"targetUserId"`
|
||||
CoinAmount int64 `gorm:"not null;default:0" json:"coinAmount"`
|
||||
RechargeAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"rechargeAmount"`
|
||||
CredentialImageURL string `gorm:"size:1024;not null;default:''" json:"credentialImageUrl"`
|
||||
CredentialText string `gorm:"type:text" json:"credentialText"`
|
||||
ApplicantUserID uint `gorm:"index:idx_admin_finance_app_applicant;not null" json:"applicantUserId"`
|
||||
ApplicantName string `gorm:"size:64;not null;default:''" json:"applicantName"`
|
||||
AuditorUserID *uint `gorm:"index:idx_admin_finance_app_auditor" json:"auditorUserId"`
|
||||
AuditorName string `gorm:"size:64;not null;default:''" json:"auditorName"`
|
||||
Status string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null;default:pending" json:"status"`
|
||||
AuditRemark string `gorm:"type:text" json:"auditRemark"`
|
||||
AuditedAtMS *int64 `gorm:"column:audited_at_ms" json:"auditedAtMs"`
|
||||
WalletCommandID string `gorm:"size:128;not null;default:''" json:"walletCommandId"`
|
||||
WalletTransactionID string `gorm:"size:128;not null;default:''" json:"walletTransactionId"`
|
||||
WalletAssetType string `gorm:"size:64;not null;default:''" json:"walletAssetType"`
|
||||
WalletAmountDelta int64 `gorm:"not null;default:0" json:"walletAmountDelta"`
|
||||
WalletBalanceAfter int64 `gorm:"not null;default:0" json:"walletBalanceAfter"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func (FinanceApplication) TableName() string {
|
||||
return "admin_finance_applications"
|
||||
}
|
||||
|
||||
// CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
|
||||
type CoinSellerRechargeOrder struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@ -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("", 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)
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package financeapplication
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFinanceApplicationCreatedMarkdown(t *testing.T) {
|
||||
text := financeApplicationCreatedMarkdown(applicationDTO{
|
||||
ID: 12,
|
||||
AppCode: "lalu",
|
||||
Operation: "coin_seller_coin_credit",
|
||||
TargetUserID: "10001",
|
||||
CoinAmount: 1000,
|
||||
RechargeAmount: "10.50",
|
||||
CredentialImageURL: "https://example.com/receipt.png",
|
||||
CredentialText: "bank order\nabc",
|
||||
ApplicantUserID: 7,
|
||||
ApplicantName: "ops",
|
||||
CreatedAtMS: time.Date(2026, 7, 1, 1, 2, 3, 0, time.UTC).UnixMilli(),
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"### 财务申请待审核",
|
||||
"- APP:lalu",
|
||||
"- 操作:增加币商金币",
|
||||
"- 目标用户ID:10001",
|
||||
"- 金币数量:1000",
|
||||
"- 充值金额 $ 10.50",
|
||||
"- 申请人:ops(7)",
|
||||
"- 发起时间:2026-07-01 09:02:03",
|
||||
"- 后台地址:https://admin-acc.global-interaction.com/finance/",
|
||||
"- 凭证文字:bank order abc",
|
||||
"- 凭证图片:",
|
||||
"",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -1,623 +0,0 @@
|
||||
package financeapplication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
permissionCreateApplication = "finance-application:create"
|
||||
permissionAuditApplication = "finance-application:audit"
|
||||
|
||||
financeInternalUserIDMinDigits = 15
|
||||
|
||||
financeAssetCoin = "COIN"
|
||||
financeAssetCoinSellerCoin = "COIN_SELLER_COIN"
|
||||
financeAssetHostSalaryUSD = "HOST_SALARY_USD"
|
||||
financeAssetAgencySalaryUSD = "AGENCY_SALARY_USD"
|
||||
financeAssetBDSalaryUSD = "BD_SALARY_USD"
|
||||
|
||||
financeStockTypeUSDTPurchase = "usdt_purchase"
|
||||
financeStockTypeUSDTDeduction = "usdt_deduction"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidCreateInput = errors.New("财务申请参数不正确")
|
||||
errInvalidAuditInput = errors.New("审核参数不正确")
|
||||
)
|
||||
|
||||
type operationDefinition struct {
|
||||
Value string
|
||||
PermissionCode string
|
||||
RequiresWalletIdentity bool
|
||||
AllowsZeroRechargeAmount bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *repository.Store
|
||||
wallet walletclient.Client
|
||||
user userclient.Client
|
||||
now func() time.Time
|
||||
notifier ApplicationNotifier
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
type ApplicationNotifier interface {
|
||||
NotifyApplicationCreated(ctx context.Context, application applicationDTO) error
|
||||
}
|
||||
|
||||
func WithWalletClient(wallet walletclient.Client) Option {
|
||||
return func(service *Service) {
|
||||
service.wallet = wallet
|
||||
}
|
||||
}
|
||||
|
||||
func WithUserClient(user userclient.Client) Option {
|
||||
return func(service *Service) {
|
||||
service.user = user
|
||||
}
|
||||
}
|
||||
|
||||
func WithApplicationNotifier(notifier ApplicationNotifier) Option {
|
||||
return func(service *Service) {
|
||||
service.notifier = notifier
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(store *repository.Store, options ...Option) *Service {
|
||||
service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) CreateApplication(ctx context.Context, actor shared.Actor, req createApplicationRequest) (*applicationDTO, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, errors.New("admin store is not configured")
|
||||
}
|
||||
input, err := s.normalizeCreateInput(actor, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.CreateFinanceApplication(&input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.store.GetFinanceApplication(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := applicationDTOFromModel(*item)
|
||||
notified := false
|
||||
if s.notifier != nil {
|
||||
// 申请落库后再通知财务群;通知失败只影响提醒,不回滚申请,避免外部机器人抖动造成业务申请丢失。
|
||||
if err := s.notifier.NotifyApplicationCreated(ctx, dto); err != nil {
|
||||
slog.Warn("finance_application_dingtalk_notify_failed", "application_id", dto.ID, "app_code", dto.AppCode, "target_user_id", dto.TargetUserID, "error", err)
|
||||
} else {
|
||||
notified = true
|
||||
}
|
||||
}
|
||||
dto.DingTalkNotified = ¬ified
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListApplications(options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, 0, errors.New("admin store is not configured")
|
||||
}
|
||||
items, total, err := s.store.ListFinanceApplications(options)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return applicationDTOsFromModel(items), total, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListMyApplications(actor shared.Actor, options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, 0, errors.New("admin store is not configured")
|
||||
}
|
||||
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCreateApplication) {
|
||||
return nil, 0, errors.New("没有操作权限")
|
||||
}
|
||||
// 申请人范围必须在服务端写入查询条件;前端只传列表筛选项,不能决定 applicant_user_id。
|
||||
options.ApplicantUserID = actor.UserID
|
||||
items, total, err := s.store.ListFinanceApplications(options)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return applicationDTOsFromModel(items), total, nil
|
||||
}
|
||||
|
||||
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
|
||||
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusApproved, remark, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
|
||||
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusRejected, remark, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, requestID string) (*applicationDTO, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, errors.New("admin store is not configured")
|
||||
}
|
||||
if id == 0 || !hasPermission(actor.Permissions, permissionAuditApplication) {
|
||||
return nil, errInvalidAuditInput
|
||||
}
|
||||
execution := walletExecutionResult{}
|
||||
if decision == model.FinanceApplicationStatusApproved {
|
||||
application, err := s.store.GetFinanceApplication(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if application.Status != model.FinanceApplicationStatusPending {
|
||||
return nil, repository.ErrFinanceApplicationAlreadyAudited
|
||||
}
|
||||
execution, err = s.executeApprovedApplication(ctx, actor, *application, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
item, err := s.store.AuditFinanceApplication(id, repository.FinanceApplicationAuditInput{
|
||||
Decision: decision,
|
||||
AuditorUserID: actor.UserID,
|
||||
AuditorName: actor.Username,
|
||||
AuditRemark: strings.TrimSpace(remark),
|
||||
AuditedAtMS: s.now().UnixMilli(),
|
||||
WalletCommandID: execution.CommandID,
|
||||
WalletTransactionID: execution.TransactionID,
|
||||
WalletAssetType: execution.AssetType,
|
||||
WalletAmountDelta: execution.AmountDelta,
|
||||
WalletBalanceAfter: execution.BalanceAfter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := applicationDTOFromModel(*item)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
type walletExecutionResult struct {
|
||||
CommandID string
|
||||
TransactionID string
|
||||
AssetType string
|
||||
AmountDelta int64
|
||||
BalanceAfter int64
|
||||
}
|
||||
|
||||
func (s *Service) executeApprovedApplication(ctx context.Context, actor shared.Actor, application model.FinanceApplication, requestID string) (walletExecutionResult, error) {
|
||||
if s.wallet == nil || s.user == nil {
|
||||
return walletExecutionResult{}, errors.New("finance wallet executor is not configured")
|
||||
}
|
||||
if actor.UserID == 0 {
|
||||
return walletExecutionResult{}, errors.New("没有操作权限")
|
||||
}
|
||||
appCode := appctx.Normalize(application.AppCode)
|
||||
ctx = appctx.WithContext(ctx, appCode)
|
||||
target, err := s.resolveTargetUser(ctx, strings.TrimSpace(requestID), application.TargetUserID)
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
commandID := financeWalletCommandID(application.ID, application.Operation)
|
||||
reason := financeWalletReason(application)
|
||||
evidenceRef := financeWalletEvidenceRef(application.ID)
|
||||
switch application.Operation {
|
||||
case "user_coin_credit", "user_coin_debit":
|
||||
// 普通用户金币只落 COIN 资产;扣减用带符号金额交给 wallet-service 做余额不透支校验。
|
||||
amount := signedAmount(application.CoinAmount, application.Operation == "user_coin_debit")
|
||||
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, financeAssetCoin, amount, int64(actor.UserID), reason, evidenceRef)
|
||||
case "user_wallet_credit", "user_wallet_debit":
|
||||
assetType, err := financeSalaryAssetType(application.WalletIdentity)
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
if err := s.requireWalletIdentity(ctx, strings.TrimSpace(requestID), target.UserID, application.WalletIdentity); err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
amountMinor, err := decimalAmountMinor(application.RechargeAmount)
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
// 身份钱包是美元工资资产,单位使用 wallet-service 现有的 minor 口径;金币数量不参与这类调账。
|
||||
amount := signedAmount(amountMinor, application.Operation == "user_wallet_debit")
|
||||
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, assetType, amount, int64(actor.UserID), reason, evidenceRef)
|
||||
case "coin_seller_coin_credit", "coin_seller_coin_debit":
|
||||
if err := s.requireActiveCoinSeller(ctx, strings.TrimSpace(requestID), target); err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
paidAmountMicro, err := decimalAmountMicro(application.RechargeAmount)
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
stockType := financeStockType(application.Operation)
|
||||
resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||||
CommandId: commandID,
|
||||
SellerUserId: target.UserID,
|
||||
StockType: stockType,
|
||||
CoinAmount: application.CoinAmount,
|
||||
PaidCurrencyCode: "USDT",
|
||||
PaidAmountMicro: paidAmountMicro,
|
||||
PaymentRef: evidenceRef,
|
||||
EvidenceRef: evidenceRef,
|
||||
OperatorUserId: int64(actor.UserID),
|
||||
Reason: reason,
|
||||
AppCode: appCode,
|
||||
SellerCountryId: target.CountryID,
|
||||
SellerRegionId: target.RegionID,
|
||||
})
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
return walletExecutionResult{
|
||||
CommandID: commandID,
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
AssetType: financeAssetCoinSellerCoin,
|
||||
AmountDelta: resp.GetCoinAmount(),
|
||||
BalanceAfter: resp.GetBalanceAfter(),
|
||||
}, nil
|
||||
default:
|
||||
return walletExecutionResult{}, errors.New("财务操作不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminAdjustAsset(ctx context.Context, commandID string, appCode string, targetUserID int64, assetType string, amount int64, operatorUserID int64, reason string, evidenceRef string) (walletExecutionResult, error) {
|
||||
resp, err := s.wallet.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
|
||||
CommandId: commandID,
|
||||
TargetUserId: targetUserID,
|
||||
AssetType: assetType,
|
||||
Amount: amount,
|
||||
OperatorUserId: operatorUserID,
|
||||
Reason: reason,
|
||||
EvidenceRef: evidenceRef,
|
||||
AppCode: appCode,
|
||||
})
|
||||
if err != nil {
|
||||
return walletExecutionResult{}, err
|
||||
}
|
||||
balanceAfter := int64(0)
|
||||
if balance := resp.GetBalance(); balance != nil {
|
||||
balanceAfter = balance.GetAvailableAmount()
|
||||
}
|
||||
return walletExecutionResult{
|
||||
CommandID: commandID,
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
AssetType: assetType,
|
||||
AmountDelta: amount,
|
||||
BalanceAfter: balanceAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveTargetUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
|
||||
keyword := strings.TrimSpace(rawTarget)
|
||||
if keyword == "" {
|
||||
return nil, errors.New("目标用户ID不正确")
|
||||
}
|
||||
numericID, numericOK := parsePositiveInt64(keyword)
|
||||
userIDChecked := false
|
||||
if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeInternalUserIDMinDigits {
|
||||
userIDChecked = true
|
||||
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "admin-server",
|
||||
DisplayUserID: keyword,
|
||||
}); err != nil {
|
||||
if !isGRPCNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
} else if identity != nil && identity.UserID > 0 {
|
||||
user, found, err := s.getUserByID(ctx, requestID, identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
if numericOK && !userIDChecked {
|
||||
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
return nil, errors.New("目标用户不存在")
|
||||
}
|
||||
|
||||
func (s *Service) getUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) {
|
||||
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "admin-server",
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
if isGRPCNotFound(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if user == nil || user.UserID <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) requireWalletIdentity(ctx context.Context, requestID string, userID int64, walletIdentity string) error {
|
||||
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "admin-server",
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.TrimSpace(walletIdentity) {
|
||||
case "host":
|
||||
if summary != nil && summary.IsHost {
|
||||
return nil
|
||||
}
|
||||
case "agency":
|
||||
if summary != nil && summary.IsAgency {
|
||||
return nil
|
||||
}
|
||||
case "bd":
|
||||
if summary != nil && summary.IsBD {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("目标用户没有对应钱包身份")
|
||||
}
|
||||
|
||||
func (s *Service) requireActiveCoinSeller(ctx context.Context, requestID string, user *userclient.User) error {
|
||||
if user == nil || user.UserID <= 0 {
|
||||
return errors.New("目标用户不存在")
|
||||
}
|
||||
if user.CountryID <= 0 || user.RegionID <= 0 {
|
||||
return errors.New("币商国家或区域不完整")
|
||||
}
|
||||
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "admin-server",
|
||||
UserID: user.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if summary == nil || !summary.IsCoinSeller {
|
||||
return errors.New("目标用户不是启用中的币商")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) normalizeCreateInput(actor shared.Actor, req createApplicationRequest) (model.FinanceApplication, error) {
|
||||
appCode := strings.TrimSpace(req.AppCode)
|
||||
operation := strings.TrimSpace(req.Operation)
|
||||
targetUserID := strings.TrimSpace(req.TargetUserID)
|
||||
credentialImageURL := strings.TrimSpace(req.CredentialImageURL)
|
||||
credentialText := strings.TrimSpace(req.CredentialText)
|
||||
definition, ok := financeOperationDefinitions()[operation]
|
||||
if !ok || appCode == "" || targetUserID == "" || req.CoinAmount <= 0 || actor.UserID == 0 {
|
||||
return model.FinanceApplication{}, errInvalidCreateInput
|
||||
}
|
||||
if !hasPermission(actor.Permissions, permissionCreateApplication) || !hasPermission(actor.Permissions, definition.PermissionCode) {
|
||||
return model.FinanceApplication{}, errors.New("没有操作权限")
|
||||
}
|
||||
rechargeAmount, err := normalizeRechargeAmount(req.RechargeAmount, definition.AllowsZeroRechargeAmount)
|
||||
if err != nil {
|
||||
return model.FinanceApplication{}, err
|
||||
}
|
||||
walletIdentity := strings.TrimSpace(req.WalletIdentity)
|
||||
if definition.RequiresWalletIdentity {
|
||||
if !validWalletIdentity(walletIdentity) {
|
||||
return model.FinanceApplication{}, errors.New("钱包身份不正确")
|
||||
}
|
||||
} else {
|
||||
// 非钱包类申请不保存身份字段,避免后续审核或执行阶段把历史残留身份误当成业务参数。
|
||||
walletIdentity = ""
|
||||
}
|
||||
if credentialImageURL == "" && credentialText == "" {
|
||||
return model.FinanceApplication{}, errors.New("请上传凭证图片或填写凭证文字")
|
||||
}
|
||||
return model.FinanceApplication{
|
||||
AppCode: appCode,
|
||||
Operation: operation,
|
||||
WalletIdentity: walletIdentity,
|
||||
TargetUserID: targetUserID,
|
||||
CoinAmount: req.CoinAmount,
|
||||
RechargeAmount: rechargeAmount,
|
||||
CredentialImageURL: credentialImageURL,
|
||||
CredentialText: credentialText,
|
||||
ApplicantUserID: actor.UserID,
|
||||
ApplicantName: actor.Username,
|
||||
Status: model.FinanceApplicationStatusPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func financeOperationDefinitions() map[string]operationDefinition {
|
||||
return map[string]operationDefinition{
|
||||
"user_coin_credit": {
|
||||
Value: "user_coin_credit",
|
||||
PermissionCode: "finance-operation:user-coin-credit",
|
||||
AllowsZeroRechargeAmount: true,
|
||||
},
|
||||
"user_coin_debit": {
|
||||
Value: "user_coin_debit",
|
||||
PermissionCode: "finance-operation:user-coin-debit",
|
||||
AllowsZeroRechargeAmount: true,
|
||||
},
|
||||
"user_wallet_debit": {
|
||||
Value: "user_wallet_debit",
|
||||
PermissionCode: "finance-operation:user-wallet-debit",
|
||||
RequiresWalletIdentity: true,
|
||||
},
|
||||
"user_wallet_credit": {
|
||||
Value: "user_wallet_credit",
|
||||
PermissionCode: "finance-operation:user-wallet-credit",
|
||||
RequiresWalletIdentity: true,
|
||||
},
|
||||
"coin_seller_coin_credit": {
|
||||
Value: "coin_seller_coin_credit",
|
||||
PermissionCode: "finance-operation:coin-seller-coin-credit",
|
||||
},
|
||||
"coin_seller_coin_debit": {
|
||||
Value: "coin_seller_coin_debit",
|
||||
PermissionCode: "finance-operation:coin-seller-coin-debit",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRechargeAmount(amount float64, allowZero bool) (string, error) {
|
||||
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
|
||||
return "", errors.New("充值金额不正确")
|
||||
}
|
||||
cents := int64(math.Round(amount * 100))
|
||||
// 普通用户金币调账的执行金额来自 coin_amount,recharge_amount 只是凭证口径,允许记录 0;身份钱包和币商进货会把它转换成真实 USD/USDT 金额,必须保持正数。
|
||||
if cents < 0 || (!allowZero && cents == 0) {
|
||||
return "", errors.New("充值金额不正确")
|
||||
}
|
||||
return fmt.Sprintf("%d.%02d", cents/100, cents%100), nil
|
||||
}
|
||||
|
||||
func validWalletIdentity(value string) bool {
|
||||
switch value {
|
||||
case "host", "agency", "bd":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func financeSalaryAssetType(identity string) (string, error) {
|
||||
switch strings.TrimSpace(identity) {
|
||||
case "host":
|
||||
return financeAssetHostSalaryUSD, nil
|
||||
case "agency":
|
||||
return financeAssetAgencySalaryUSD, nil
|
||||
case "bd":
|
||||
return financeAssetBDSalaryUSD, nil
|
||||
default:
|
||||
return "", errors.New("钱包身份不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func financeStockType(operation string) string {
|
||||
if operation == "coin_seller_coin_debit" {
|
||||
return financeStockTypeUSDTDeduction
|
||||
}
|
||||
return financeStockTypeUSDTPurchase
|
||||
}
|
||||
|
||||
func signedAmount(amount int64, debit bool) int64 {
|
||||
if debit {
|
||||
return -amount
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func decimalAmountMinor(value string) (int64, error) {
|
||||
return decimalAmountWithScale(value, 2)
|
||||
}
|
||||
|
||||
func decimalAmountMicro(value string) (int64, error) {
|
||||
return decimalAmountWithScale(value, 6)
|
||||
}
|
||||
|
||||
func decimalAmountWithScale(value string, scale int) (int64, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
parts := strings.Split(text, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || whole < 0 {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
fracText := ""
|
||||
if len(parts) == 2 {
|
||||
fracText = parts[1]
|
||||
}
|
||||
if len(fracText) > scale {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
for _, r := range fracText {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
}
|
||||
for len(fracText) < scale {
|
||||
fracText += "0"
|
||||
}
|
||||
frac := int64(0)
|
||||
if fracText != "" {
|
||||
frac, err = strconv.ParseInt(fracText, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
}
|
||||
multiplier := int64(1)
|
||||
for i := 0; i < scale; i++ {
|
||||
multiplier *= 10
|
||||
}
|
||||
const maxInt64 = 1<<63 - 1
|
||||
if whole > (maxInt64-frac)/multiplier {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
amount := whole*multiplier + frac
|
||||
if amount <= 0 {
|
||||
return 0, errors.New("充值金额不正确")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt64(value string) (int64, bool) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return parsed, err == nil && parsed > 0
|
||||
}
|
||||
|
||||
func financeWalletCommandID(id uint, operation string) string {
|
||||
return fmt.Sprintf("finance-application:%d:%s", id, strings.TrimSpace(operation))
|
||||
}
|
||||
|
||||
func financeWalletEvidenceRef(id uint) string {
|
||||
return fmt.Sprintf("finance-application:%d", id)
|
||||
}
|
||||
|
||||
func financeWalletReason(application model.FinanceApplication) string {
|
||||
return fmt.Sprintf("finance application %d %s", application.ID, operationLabel(application.Operation))
|
||||
}
|
||||
|
||||
func isGRPCNotFound(err error) bool {
|
||||
return status.Code(err) == codes.NotFound
|
||||
}
|
||||
|
||||
func hasPermission(permissions []string, code string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -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},
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ApproveApplication)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication)
|
||||
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
|
||||
}
|
||||
|
||||
@ -22,7 +22,6 @@ import (
|
||||
const (
|
||||
permissionViewWithdrawalApplications = "finance-withdrawal:view"
|
||||
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
|
||||
permissionAuditFinanceApplications = "finance-application:audit"
|
||||
|
||||
pointWithdrawalDefaultPointsPerUSD = int64(100000)
|
||||
pointWithdrawalDefaultFeeBPS = int32(500)
|
||||
@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string {
|
||||
func canAuditWithdrawal(permissions []string) bool {
|
||||
for _, permission := range permissions {
|
||||
switch strings.TrimSpace(permission) {
|
||||
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications:
|
||||
case permissionAuditWithdrawalApplications:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -88,7 +88,6 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.LegacyGooglePaidSyncAttempt{},
|
||||
&model.FinanceApplication{},
|
||||
&model.CoinSellerRechargeOrder{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
&model.AdminJob{},
|
||||
|
||||
@ -128,21 +128,12 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
|
||||
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
|
||||
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
|
||||
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit;是否能看到审核列表由 audit 权限单独决定。
|
||||
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
|
||||
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
|
||||
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
|
||||
{Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"},
|
||||
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"},
|
||||
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
|
||||
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
|
||||
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
|
||||
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"},
|
||||
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
|
||||
{Name: "财务增加用户钱包", Code: "finance-operation:user-wallet-credit", Kind: "button"},
|
||||
{Name: "财务扣减用户钱包", Code: "finance-operation:user-wallet-debit", Kind: "button"},
|
||||
{Name: "财务增加币商金币", Code: "finance-operation:coin-seller-coin-credit", Kind: "button"},
|
||||
{Name: "财务减少币商金币", Code: "finance-operation:coin-seller-coin-debit", Kind: "button"},
|
||||
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
||||
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
||||
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
|
||||
@ -662,7 +653,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||
"bd:view", "bd:create", "bd:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
@ -774,7 +765,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
|
||||
@ -17,19 +17,25 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
"payment-temporary-link:create",
|
||||
"finance:view",
|
||||
"finance-withdrawal:view",
|
||||
"host-withdrawal:view",
|
||||
} {
|
||||
if _, ok := permissions[code]; !ok {
|
||||
t.Fatalf("finance permission %s missing from default seed", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []string{
|
||||
"finance-application:create",
|
||||
"finance-application:audit",
|
||||
"finance-withdrawal:view",
|
||||
"finance-operation:user-coin-credit",
|
||||
"finance-operation:user-coin-debit",
|
||||
"finance-operation:user-wallet-credit",
|
||||
"finance-operation:user-wallet-debit",
|
||||
"finance-operation:coin-seller-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit",
|
||||
"host-withdrawal:view",
|
||||
} {
|
||||
if _, ok := permissions[code]; !ok {
|
||||
t.Fatalf("finance permission %s missing from default seed", code)
|
||||
if _, ok := permissions[code]; ok {
|
||||
t.Fatalf("removed finance application permission %s must not be seeded", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,13 +46,6 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
|
||||
|
||||
for _, code := range []string{
|
||||
"payment-temporary-link:create",
|
||||
"finance-application:create",
|
||||
"finance-operation:user-coin-credit",
|
||||
"finance-operation:user-coin-debit",
|
||||
"finance-operation:user-wallet-credit",
|
||||
"finance-operation:user-wallet-debit",
|
||||
"finance-operation:coin-seller-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit",
|
||||
"host-withdrawal:view",
|
||||
} {
|
||||
if _, ok := opsPermissions[code]; !ok {
|
||||
@ -57,11 +56,22 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := opsPermissions["finance-application:audit"]; ok {
|
||||
t.Fatal("ops-admin must not receive finance audit permission by default")
|
||||
}
|
||||
if _, ok := opsMigrationPermissions["finance-application:audit"]; ok {
|
||||
t.Fatal("ops-admin migration must not append finance audit permission by default")
|
||||
for _, code := range []string{
|
||||
"finance-application:create",
|
||||
"finance-application:audit",
|
||||
"finance-operation:user-coin-credit",
|
||||
"finance-operation:user-coin-debit",
|
||||
"finance-operation:user-wallet-credit",
|
||||
"finance-operation:user-wallet-debit",
|
||||
"finance-operation:coin-seller-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit",
|
||||
} {
|
||||
if _, ok := opsPermissions[code]; ok {
|
||||
t.Fatalf("ops-admin must not receive removed permission %s", code)
|
||||
}
|
||||
if _, ok := opsMigrationPermissions[code]; ok {
|
||||
t.Fatalf("ops-admin migration must not append removed permission %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@ import (
|
||||
"hyapp-admin-server/internal/modules/dailytask"
|
||||
"hyapp-admin-server/internal/modules/dashboard"
|
||||
"hyapp-admin-server/internal/modules/databi"
|
||||
"hyapp-admin-server/internal/modules/financeapplication"
|
||||
"hyapp-admin-server/internal/modules/financeorder"
|
||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
@ -84,7 +83,6 @@ type Handlers struct {
|
||||
Dashboard *dashboard.Handler
|
||||
Databi *databi.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
FinanceApplication *financeapplication.Handler
|
||||
FinanceOrder *financeorder.Handler
|
||||
FinanceWithdrawal *financewithdrawal.Handler
|
||||
FullServerNotice *fullservernotice.Handler
|
||||
@ -156,7 +154,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
|
||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
|
||||
financeorder.RegisterRoutes(protected, h.FinanceOrder)
|
||||
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
||||
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
||||
|
||||
39
server/admin/migrations/092_remove_finance_applications.sql
Normal file
39
server/admin/migrations/092_remove_finance_applications.sql
Normal 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'
|
||||
);
|
||||
@ -0,0 +1,35 @@
|
||||
package financewithdrawal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithdrawalApplicationCreatedMarkdownKeepsFinanceReviewDetails(t *testing.T) {
|
||||
message := withdrawalApplicationCreatedMarkdown(Application{
|
||||
ID: 77,
|
||||
AppCode: "lalu",
|
||||
UserID: 42,
|
||||
SalaryAssetType: "HOST_SALARY_USD",
|
||||
WithdrawAmount: "50.00",
|
||||
WithdrawMethod: MethodUSDTTRC20,
|
||||
WithdrawAddress: "TRON-address",
|
||||
FreezeTransactionID: "freeze-tx-1",
|
||||
CreatedAtMS: 1_700_000_000_000,
|
||||
})
|
||||
|
||||
for _, expected := range []string{
|
||||
"用户提现申请待审核",
|
||||
"申请ID:77",
|
||||
"APP:lalu",
|
||||
"用户ID:42",
|
||||
"提现金额:$ 50.00",
|
||||
"提现方式:usdt_trc20",
|
||||
"冻结交易:freeze-tx-1",
|
||||
"https://admin-acc.global-interaction.com/finance/",
|
||||
} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Fatalf("withdrawal DingTalk message missing %q: %s", expected, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,19 +15,21 @@ type levelOverviewData struct {
|
||||
}
|
||||
|
||||
type levelTrackOverviewData struct {
|
||||
Track string `json:"track"`
|
||||
Name string `json:"name"`
|
||||
Level int32 `json:"level"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TotalValue int64 `json:"total_value"`
|
||||
CurrentLevelRequiredValue int64 `json:"current_level_required_value"`
|
||||
NextLevel int32 `json:"next_level"`
|
||||
NextLevelRequiredValue int64 `json:"next_level_required_value"`
|
||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
|
||||
RewardPendingCount int64 `json:"reward_pending_count"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
Track string `json:"track"`
|
||||
Name string `json:"name"`
|
||||
Level int32 `json:"level"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TotalValue int64 `json:"total_value"`
|
||||
CurrentLevelRequiredValue int64 `json:"current_level_required_value"`
|
||||
NextLevel int32 `json:"next_level"`
|
||||
NextLevelRequiredValue int64 `json:"next_level_required_value"`
|
||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
|
||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
|
||||
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
|
||||
RewardPendingCount int64 `json:"reward_pending_count"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type levelTrackDetailData struct {
|
||||
@ -38,35 +40,39 @@ type levelTrackDetailData struct {
|
||||
}
|
||||
|
||||
type levelRuleData struct {
|
||||
Track string `json:"track"`
|
||||
Level int32 `json:"level"`
|
||||
RequiredValue int64 `json:"required_value"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
DisplayConfigJSON string `json:"display_config_json"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Track string `json:"track"`
|
||||
Level int32 `json:"level"`
|
||||
RequiredValue int64 `json:"required_value"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
DisplayConfigJSON string `json:"display_config_json"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type levelTierData struct {
|
||||
TierID int64 `json:"tier_id"`
|
||||
Track string `json:"track"`
|
||||
MinLevel int32 `json:"min_level"`
|
||||
MaxLevel int32 `json:"max_level"`
|
||||
Name string `json:"name"`
|
||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||
Status string `json:"status"`
|
||||
DisplayConfigJSON string `json:"display_config_json"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
Track string `json:"track"`
|
||||
MinLevel int32 `json:"min_level"`
|
||||
MaxLevel int32 `json:"max_level"`
|
||||
Name string `json:"name"`
|
||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
|
||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
|
||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DisplayConfigJSON string `json:"display_config_json"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type levelRewardData struct {
|
||||
@ -98,9 +104,15 @@ func (h *Handler) getMyLevelOverview(writer http.ResponseWriter, request *http.R
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// activity-service 只拥有等级关系和资源 ID;素材 URL 属于 wallet-service。Gateway 在同一响应补齐当前等级素材,
|
||||
// 避免 H5 再依赖后台接口或把线上资源 ID 映射硬编码进页面。
|
||||
resources, ok := h.resolveAchievementResources(writer, request, levelOverviewResourceIDs(resp.GetTracks()))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tracks := make([]levelTrackOverviewData, 0, len(resp.GetTracks()))
|
||||
for _, item := range resp.GetTracks() {
|
||||
tracks = append(tracks, levelTrackOverviewFromProto(item))
|
||||
tracks = append(tracks, levelTrackOverviewFromProto(item, resources))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, levelOverviewData{Tracks: tracks, ServerTimeMS: resp.GetServerTimeMs()})
|
||||
}
|
||||
@ -124,16 +136,26 @@ func (h *Handler) getLevelTrack(writer http.ResponseWriter, request *http.Reques
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// 等级层页面必须一次拿到区间、徽章、头像框和升级礼物;这里按正 ID 去重后解析,
|
||||
// 既保持 activity/wallet 的 owner 边界,也避免前端为每一行产生额外请求。
|
||||
resources, ok := h.resolveAchievementResources(writer, request, levelTrackResourceIDs(resp.GetOverview(), resp.GetTiers()))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resourceGroups, ok := h.resolveAchievementResourceGroups(writer, request, levelTrackResourceGroupIDs(resp.GetRules(), resp.GetTiers()))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rules := make([]levelRuleData, 0, len(resp.GetRules()))
|
||||
for _, rule := range resp.GetRules() {
|
||||
rules = append(rules, levelRuleFromProto(rule))
|
||||
rules = append(rules, levelRuleFromProto(rule, resourceGroups))
|
||||
}
|
||||
tiers := make([]levelTierData, 0, len(resp.GetTiers()))
|
||||
for _, tier := range resp.GetTiers() {
|
||||
tiers = append(tiers, levelTierFromProto(tier))
|
||||
tiers = append(tiers, levelTierFromProto(tier, resources, resourceGroups))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, levelTrackDetailData{
|
||||
Overview: levelTrackOverviewFromProto(resp.GetOverview()),
|
||||
Overview: levelTrackOverviewFromProto(resp.GetOverview(), resources),
|
||||
Rules: rules,
|
||||
Tiers: tiers,
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
@ -179,8 +201,8 @@ func (h *Handler) listLevelRewards(writer http.ResponseWriter, request *http.Req
|
||||
})
|
||||
}
|
||||
|
||||
func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrackOverviewData {
|
||||
return levelTrackOverviewData{
|
||||
func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview, resources map[int64]achievementResourceData) levelTrackOverviewData {
|
||||
data := levelTrackOverviewData{
|
||||
Track: item.GetTrack(),
|
||||
Name: item.GetName(),
|
||||
Level: item.GetLevel(),
|
||||
@ -195,10 +217,17 @@ func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrack
|
||||
RewardPendingCount: item.GetRewardPendingCount(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
}
|
||||
if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok {
|
||||
data.DisplayAvatarFrameResource = &resource
|
||||
}
|
||||
if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok {
|
||||
data.DisplayBadgeResource = &resource
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData {
|
||||
return levelRuleData{
|
||||
func levelRuleFromProto(item *activityv1.LevelRule, resourceGroups map[int64]achievementResourceGroupData) levelRuleData {
|
||||
data := levelRuleData{
|
||||
Track: item.GetTrack(),
|
||||
Level: item.GetLevel(),
|
||||
RequiredValue: item.GetRequiredValue(),
|
||||
@ -212,10 +241,14 @@ func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData {
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok {
|
||||
data.RewardResourceGroup = &group
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func levelTierFromProto(item *activityv1.LevelTier) levelTierData {
|
||||
return levelTierData{
|
||||
func levelTierFromProto(item *activityv1.LevelTier, resources map[int64]achievementResourceData, resourceGroups map[int64]achievementResourceGroupData) levelTierData {
|
||||
data := levelTierData{
|
||||
TierID: item.GetTierId(),
|
||||
Track: item.GetTrack(),
|
||||
MinLevel: item.GetMinLevel(),
|
||||
@ -231,6 +264,43 @@ func levelTierFromProto(item *activityv1.LevelTier) levelTierData {
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok {
|
||||
data.DisplayAvatarFrameResource = &resource
|
||||
}
|
||||
if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok {
|
||||
data.DisplayBadgeResource = &resource
|
||||
}
|
||||
if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok {
|
||||
data.RewardResourceGroup = &group
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func levelOverviewResourceIDs(items []*activityv1.LevelTrackOverview) []int64 {
|
||||
ids := make([]int64, 0, len(items)*2)
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.GetDisplayAvatarFrameResourceId(), item.GetDisplayBadgeResourceId())
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func levelTrackResourceIDs(overview *activityv1.LevelTrackOverview, tiers []*activityv1.LevelTier) []int64 {
|
||||
ids := levelOverviewResourceIDs([]*activityv1.LevelTrackOverview{overview})
|
||||
for _, tier := range tiers {
|
||||
ids = append(ids, tier.GetDisplayAvatarFrameResourceId(), tier.GetDisplayBadgeResourceId())
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func levelTrackResourceGroupIDs(rules []*activityv1.LevelRule, tiers []*activityv1.LevelTier) []int64 {
|
||||
ids := make([]int64, 0, len(rules)+len(tiers))
|
||||
for _, rule := range rules {
|
||||
ids = append(ids, rule.GetRewardResourceGroupId())
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
ids = append(ids, tier.GetRewardResourceGroupId())
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func levelRewardFromProto(item *activityv1.LevelRewardJob) levelRewardData {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package activityapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -53,10 +55,11 @@ type userLeaderboardItem struct {
|
||||
}
|
||||
|
||||
type userLeaderboardUser struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
ShortBadgeCoverURLs []string `json:"short_badge_cover_urls,omitempty"`
|
||||
}
|
||||
|
||||
type userLeaderboardRoom struct {
|
||||
@ -234,11 +237,17 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
applyUserLeaderboardProfile(&items[i], profiles)
|
||||
applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers)
|
||||
}
|
||||
if myRank != nil {
|
||||
applyUserLeaderboardProfile(myRank, profiles)
|
||||
applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -336,6 +345,89 @@ func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]*
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userLeaderboardShortBadgeCovers(request *http.Request, userIDs []int64) (map[int64][]string, error) {
|
||||
result := make(map[int64][]string)
|
||||
userIDs = uniqueUserLeaderboardInt64s(userIDs)
|
||||
if h.walletClient == nil || len(userIDs) == 0 {
|
||||
// 榜单基础资料不依赖装扮服务;未接 wallet 的环境保持兼容,只是不返回短徽章。
|
||||
return result, nil
|
||||
}
|
||||
// 一页榜单只发起一次批量查询,避免按用户逐个读取权益和资源造成 N+1 放大。
|
||||
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserIds: userIDs,
|
||||
ResourceTypes: []string{"badge"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userLeaderboardShortBadgeCoversFromResponse(resp), nil
|
||||
}
|
||||
|
||||
func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) map[int64][]string {
|
||||
result := make(map[int64][]string)
|
||||
if resp == nil {
|
||||
return result
|
||||
}
|
||||
for _, user := range resp.GetUsers() {
|
||||
if user.GetUserId() <= 0 {
|
||||
continue
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, entitlement := range user.GetResources() {
|
||||
resource := entitlement.GetResource()
|
||||
if resource == nil || !isUserLeaderboardShortBadge(resource.GetMetadataJson()) {
|
||||
continue
|
||||
}
|
||||
// preview_url 是静态封面契约;动画 asset_url 不能直接交给 H5 img 渲染。
|
||||
coverURL := strings.TrimSpace(resource.GetPreviewUrl())
|
||||
if coverURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[coverURL]; exists {
|
||||
continue
|
||||
}
|
||||
seen[coverURL] = struct{}{}
|
||||
result[user.GetUserId()] = append(result[user.GetUserId()], coverURL)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isUserLeaderboardShortBadge(rawMetadata string) bool {
|
||||
metadata := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(toUserLeaderboardString(metadata["badge_kind"])), "level") {
|
||||
// 等级徽章有独立展示位,不能混入用户主动佩戴的普通短徽章。
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(toUserLeaderboardString(metadata["badge_form"]))) {
|
||||
case "short", "tile", "icon", "medal":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toUserLeaderboardString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func applyUserLeaderboardShortBadgeCovers(item *userLeaderboardItem, covers map[int64][]string) {
|
||||
if item == nil || item.User == nil {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(item.UserID, 10, 64)
|
||||
if err != nil || userID <= 0 || len(covers[userID]) == 0 {
|
||||
return
|
||||
}
|
||||
item.User.ShortBadgeCoverURLs = append([]string(nil), covers[userID]...)
|
||||
}
|
||||
|
||||
func uniqueUserLeaderboardInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) {
|
||||
@ -49,6 +50,27 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) {
|
||||
response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{
|
||||
UserId: 20002,
|
||||
Resources: []*walletv1.UserResourceEntitlement{
|
||||
{Resource: &walletv1.Resource{ResourceId: 1, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`}},
|
||||
{Resource: &walletv1.Resource{ResourceId: 2, ResourceType: "badge", PreviewUrl: "https://cdn.example/long.png", MetadataJson: `{"badge_form":"strip","badge_kind":"normal"}`}},
|
||||
{Resource: &walletv1.Resource{ResourceId: 3, ResourceType: "badge", PreviewUrl: "https://cdn.example/level.png", MetadataJson: `{"badge_form":"icon","badge_kind":"level"}`}},
|
||||
{Resource: &walletv1.Resource{ResourceId: 4, ResourceType: "badge", AssetUrl: "https://cdn.example/animated.svga", MetadataJson: `{"badge_form":"short","badge_kind":"normal"}`}},
|
||||
{Resource: &walletv1.Resource{ResourceId: 5, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"medal","badge_kind":"normal"}`}},
|
||||
},
|
||||
}}}
|
||||
|
||||
covers := userLeaderboardShortBadgeCoversFromResponse(response)
|
||||
if len(covers[20002]) != 1 || covers[20002][0] != "https://cdn.example/short.png" {
|
||||
t.Fatalf("only unique normal short-badge covers should be returned: %+v", covers)
|
||||
}
|
||||
if len(covers[99999]) != 0 {
|
||||
t.Fatalf("users without a short badge must stay absent: %+v", covers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) {
|
||||
room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{
|
||||
RoomId: "room_1",
|
||||
|
||||
@ -719,6 +719,18 @@ type fakeWithdrawalApplicationWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeWithdrawalApplicationNotifier struct {
|
||||
last financewithdrawal.Application
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeWithdrawalApplicationNotifier) NotifyApplicationCreated(_ context.Context, application financewithdrawal.Application) error {
|
||||
f.last = application
|
||||
f.calls++
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
|
||||
f.last = command
|
||||
f.calls++
|
||||
@ -6048,10 +6060,12 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
|
||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
|
||||
profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
|
||||
withdrawalWriter := &fakeWithdrawalApplicationWriter{}
|
||||
withdrawalNotifier := &fakeWithdrawalApplicationNotifier{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetWithdrawalApplicationWriter(withdrawalWriter)
|
||||
handler.SetWithdrawalApplicationNotifier(withdrawalNotifier)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
@ -6090,6 +6104,13 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
|
||||
withdrawalWriter.last.CreatedAtMS <= 0 {
|
||||
t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls)
|
||||
}
|
||||
if withdrawalNotifier.calls != 1 ||
|
||||
withdrawalNotifier.last.ID != 77 ||
|
||||
withdrawalNotifier.last.UserID != 42 ||
|
||||
withdrawalNotifier.last.WithdrawAmount != "50.00" ||
|
||||
withdrawalNotifier.last.FreezeTransactionID != "freeze-tx-1" {
|
||||
t.Fatalf("withdraw application must notify DingTalk after persistence: %+v calls=%d", withdrawalNotifier.last, withdrawalNotifier.calls)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user