去掉财务
This commit is contained in:
parent
6f466fa174
commit
1e7c3115d6
@ -134,7 +134,10 @@ X-App-Code: lalu
|
|||||||
"user_id": "10001",
|
"user_id": "10001",
|
||||||
"display_user_id": "888888",
|
"display_user_id": "888888",
|
||||||
"username": "Luna",
|
"username": "Luna",
|
||||||
"avatar": "https://cdn.example/avatar/10001.png"
|
"avatar": "https://cdn.example/avatar/10001.png",
|
||||||
|
"short_badge_cover_urls": [
|
||||||
|
"https://cdn.example/badges/short-1.png"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -164,6 +167,8 @@ X-App-Code: lalu
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`user.short_badge_cover_urls` 只包含用户当前佩戴的普通短徽章静态封面,长徽章和等级徽章不会混入。用户没有短徽章时字段省略,客户端按空数组处理即可;房间榜没有用户徽章字段。
|
||||||
|
|
||||||
房间榜返回示例:
|
房间榜返回示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -358,12 +363,14 @@ class LeaderboardUser {
|
|||||||
required this.displayUserId,
|
required this.displayUserId,
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.avatar,
|
required this.avatar,
|
||||||
|
required this.shortBadgeCoverUrls,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String userId;
|
final String userId;
|
||||||
final String displayUserId;
|
final String displayUserId;
|
||||||
final String username;
|
final String username;
|
||||||
final String avatar;
|
final String avatar;
|
||||||
|
final List<String> shortBadgeCoverUrls;
|
||||||
|
|
||||||
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
|
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
|
||||||
return LeaderboardUser(
|
return LeaderboardUser(
|
||||||
@ -371,6 +378,9 @@ class LeaderboardUser {
|
|||||||
displayUserId: json['display_user_id'] as String? ?? '',
|
displayUserId: json['display_user_id'] as String? ?? '',
|
||||||
username: json['username'] as String? ?? '',
|
username: json['username'] as String? ?? '',
|
||||||
avatar: json['avatar'] as String? ?? '',
|
avatar: json['avatar'] as String? ?? '',
|
||||||
|
shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import (
|
|||||||
"hyapp-admin-server/internal/cache"
|
"hyapp-admin-server/internal/cache"
|
||||||
"hyapp-admin-server/internal/config"
|
"hyapp-admin-server/internal/config"
|
||||||
"hyapp-admin-server/internal/integration/activityclient"
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
|
|
||||||
"hyapp-admin-server/internal/integration/gameclient"
|
"hyapp-admin-server/internal/integration/gameclient"
|
||||||
"hyapp-admin-server/internal/integration/googleorders"
|
"hyapp-admin-server/internal/integration/googleorders"
|
||||||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||||
@ -44,7 +43,6 @@ import (
|
|||||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||||
databimodule "hyapp-admin-server/internal/modules/databi"
|
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
|
||||||
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
|
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
|
||||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -320,24 +318,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
objectUploader = uploader
|
objectUploader = uploader
|
||||||
}
|
}
|
||||||
var financeApplicationOptions []financeapplicationmodule.Option
|
|
||||||
if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" {
|
|
||||||
client, err := dingtalkclient.New(dingtalkclient.Config{
|
|
||||||
WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL,
|
|
||||||
Secret: cfg.FinanceNotifications.DingTalk.Secret,
|
|
||||||
AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles,
|
|
||||||
AtAll: cfg.FinanceNotifications.DingTalk.AtAll,
|
|
||||||
RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
fatalRuntime("create_finance_dingtalk_client_failed", err)
|
|
||||||
}
|
|
||||||
financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client)))
|
|
||||||
}
|
|
||||||
financeApplicationOptions = append(financeApplicationOptions,
|
|
||||||
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
|
|
||||||
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
|
||||||
)
|
|
||||||
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
|
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
|
||||||
dashboardService := dashboardmodule.NewService(
|
dashboardService := dashboardmodule.NewService(
|
||||||
store,
|
store,
|
||||||
@ -387,7 +367,6 @@ func main() {
|
|||||||
Dashboard: dashboardmodule.NewWithService(dashboardService),
|
Dashboard: dashboardmodule.NewWithService(dashboardService),
|
||||||
Databi: databimodule.New(databiService, store, auditHandler),
|
Databi: databimodule.New(databiService, store, auditHandler),
|
||||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
|
||||||
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
||||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
|
|||||||
@ -151,14 +151,6 @@ legacy_coin_seller_recharge_sources:
|
|||||||
app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||||
wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||||
request_timeout: "5s"
|
request_timeout: "5s"
|
||||||
finance_notifications:
|
|
||||||
dingtalk:
|
|
||||||
enabled: true
|
|
||||||
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
|
|
||||||
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
|
|
||||||
at_mobiles: []
|
|
||||||
at_all: false
|
|
||||||
request_timeout: "3s"
|
|
||||||
tencent-cos:
|
tencent-cos:
|
||||||
enabled: true
|
enabled: true
|
||||||
secret-id: "REPLACE_ME"
|
secret-id: "REPLACE_ME"
|
||||||
|
|||||||
@ -148,14 +148,6 @@ legacy_coin_seller_recharge_sources:
|
|||||||
app_mysql_dsn: ""
|
app_mysql_dsn: ""
|
||||||
wallet_mysql_dsn: ""
|
wallet_mysql_dsn: ""
|
||||||
request_timeout: "5s"
|
request_timeout: "5s"
|
||||||
finance_notifications:
|
|
||||||
dingtalk:
|
|
||||||
enabled: true
|
|
||||||
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
|
|
||||||
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
|
|
||||||
at_mobiles: []
|
|
||||||
at_all: false
|
|
||||||
request_timeout: "3s"
|
|
||||||
tencent-cos:
|
tencent-cos:
|
||||||
enabled: true
|
enabled: true
|
||||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
||||||
|
|||||||
@ -49,7 +49,6 @@ type Config struct {
|
|||||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||||
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
||||||
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
||||||
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MigrationConfig struct {
|
type MigrationConfig struct {
|
||||||
@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct {
|
|||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FinanceNotificationsConfig struct {
|
|
||||||
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DingTalkRobotConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
WebhookURL string `yaml:"webhook_url"`
|
|
||||||
Secret string `yaml:"secret"`
|
|
||||||
AtMobiles []string `yaml:"at_mobiles"`
|
|
||||||
AtAll bool `yaml:"at_all"`
|
|
||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RobotProfileSourceConfig struct {
|
type RobotProfileSourceConfig struct {
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
@ -372,12 +358,6 @@ func Default() Config {
|
|||||||
RequestTimeout: 5 * time.Second,
|
RequestTimeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
FinanceNotifications: FinanceNotificationsConfig{
|
|
||||||
DingTalk: DingTalkRobotConfig{
|
|
||||||
Enabled: true,
|
|
||||||
RequestTimeout: 3 * time.Second,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
PollInterval: 15 * time.Second,
|
PollInterval: 15 * time.Second,
|
||||||
@ -575,13 +555,6 @@ func (cfg *Config) Normalize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
|
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
|
||||||
cfg.applyFinanceNotificationEnvOverrides()
|
|
||||||
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
|
|
||||||
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
|
|
||||||
cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles)
|
|
||||||
if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
|
|
||||||
cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second
|
|
||||||
}
|
|
||||||
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
|
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
|
||||||
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
||||||
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
|
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
|
||||||
@ -834,14 +807,6 @@ func (cfg Config) Validate() error {
|
|||||||
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
|
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" {
|
|
||||||
if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") {
|
|
||||||
return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
|
|
||||||
return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0")
|
|
||||||
}
|
|
||||||
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
|
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
|
||||||
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
|
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
|
||||||
}
|
}
|
||||||
@ -925,26 +890,6 @@ func (cfg Config) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) applyFinanceNotificationEnvOverrides() {
|
|
||||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" {
|
|
||||||
cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value)
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" {
|
|
||||||
// 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。
|
|
||||||
cfg.FinanceNotifications.DingTalk.WebhookURL = value
|
|
||||||
cfg.FinanceNotifications.DingTalk.Enabled = true
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" {
|
|
||||||
cfg.FinanceNotifications.DingTalk.Secret = value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" {
|
|
||||||
cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",")
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" {
|
|
||||||
cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
|
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
|
||||||
for index := range cfg.DashboardExternalSources {
|
for index := range cfg.DashboardExternalSources {
|
||||||
source := &cfg.DashboardExternalSources[index]
|
source := &cfg.DashboardExternalSources[index]
|
||||||
|
|||||||
@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) {
|
|||||||
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
|
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
|
||||||
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
|
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
|
||||||
}
|
}
|
||||||
if !cfg.FinanceNotifications.DingTalk.Enabled {
|
|
||||||
t.Fatalf("finance dingtalk notification should be enabled by default")
|
|
||||||
}
|
|
||||||
if cfg.FinanceNotifications.DingTalk.WebhookURL == "" || cfg.FinanceNotifications.DingTalk.Secret == "" {
|
|
||||||
t.Fatalf("finance dingtalk notification config should be populated: %#v", cfg.FinanceNotifications.DingTalk)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
||||||
@ -53,26 +47,6 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFinanceDingTalkEnvOverride(t *testing.T) {
|
|
||||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "https://oapi.dingtalk.com/robot/send?access_token=test")
|
|
||||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "SEC_TEST")
|
|
||||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES", "13800138000, 13900139000")
|
|
||||||
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL", "false")
|
|
||||||
|
|
||||||
cfg := Default()
|
|
||||||
cfg.Normalize()
|
|
||||||
|
|
||||||
if !cfg.FinanceNotifications.DingTalk.Enabled {
|
|
||||||
t.Fatal("DingTalk should be enabled when webhook env is provided")
|
|
||||||
}
|
|
||||||
if cfg.FinanceNotifications.DingTalk.Secret != "SEC_TEST" {
|
|
||||||
t.Fatalf("secret mismatch: %q", cfg.FinanceNotifications.DingTalk.Secret)
|
|
||||||
}
|
|
||||||
if len(cfg.FinanceNotifications.DingTalk.AtMobiles) != 2 {
|
|
||||||
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAslanMongoEnvOverride(t *testing.T) {
|
func TestAslanMongoEnvOverride(t *testing.T) {
|
||||||
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
|
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
|
||||||
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")
|
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")
|
||||||
|
|||||||
@ -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"
|
JobStatusFailed = "failed"
|
||||||
JobStatusCanceled = "canceled"
|
JobStatusCanceled = "canceled"
|
||||||
|
|
||||||
FinanceApplicationStatusPending = "pending"
|
|
||||||
FinanceApplicationStatusApproved = "approved"
|
|
||||||
FinanceApplicationStatusRejected = "rejected"
|
|
||||||
|
|
||||||
CoinSellerRechargeOrderStatusCreated = "created"
|
CoinSellerRechargeOrderStatusCreated = "created"
|
||||||
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
|
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
|
||||||
CoinSellerRechargeOrderStatusVerified = "verified"
|
CoinSellerRechargeOrderStatusVerified = "verified"
|
||||||
@ -567,36 +563,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string {
|
|||||||
return "admin_legacy_google_paid_sync_attempts"
|
return "admin_legacy_google_paid_sync_attempts"
|
||||||
}
|
}
|
||||||
|
|
||||||
type FinanceApplication struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`
|
|
||||||
Operation string `gorm:"size:64;index:idx_admin_finance_app_operation;not null" json:"operation"`
|
|
||||||
WalletIdentity string `gorm:"size:32;not null;default:''" json:"walletIdentity"`
|
|
||||||
TargetUserID string `gorm:"size:64;index:idx_admin_finance_app_target;not null" json:"targetUserId"`
|
|
||||||
CoinAmount int64 `gorm:"not null;default:0" json:"coinAmount"`
|
|
||||||
RechargeAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"rechargeAmount"`
|
|
||||||
CredentialImageURL string `gorm:"size:1024;not null;default:''" json:"credentialImageUrl"`
|
|
||||||
CredentialText string `gorm:"type:text" json:"credentialText"`
|
|
||||||
ApplicantUserID uint `gorm:"index:idx_admin_finance_app_applicant;not null" json:"applicantUserId"`
|
|
||||||
ApplicantName string `gorm:"size:64;not null;default:''" json:"applicantName"`
|
|
||||||
AuditorUserID *uint `gorm:"index:idx_admin_finance_app_auditor" json:"auditorUserId"`
|
|
||||||
AuditorName string `gorm:"size:64;not null;default:''" json:"auditorName"`
|
|
||||||
Status string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null;default:pending" json:"status"`
|
|
||||||
AuditRemark string `gorm:"type:text" json:"auditRemark"`
|
|
||||||
AuditedAtMS *int64 `gorm:"column:audited_at_ms" json:"auditedAtMs"`
|
|
||||||
WalletCommandID string `gorm:"size:128;not null;default:''" json:"walletCommandId"`
|
|
||||||
WalletTransactionID string `gorm:"size:128;not null;default:''" json:"walletTransactionId"`
|
|
||||||
WalletAssetType string `gorm:"size:64;not null;default:''" json:"walletAssetType"`
|
|
||||||
WalletAmountDelta int64 `gorm:"not null;default:0" json:"walletAmountDelta"`
|
|
||||||
WalletBalanceAfter int64 `gorm:"not null;default:0" json:"walletBalanceAfter"`
|
|
||||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
|
||||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (FinanceApplication) TableName() string {
|
|
||||||
return "admin_finance_applications"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
|
// CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
|
||||||
type CoinSellerRechargeOrder struct {
|
type CoinSellerRechargeOrder struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@ -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 {
|
if h == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications)
|
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications)
|
||||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ApproveApplication)
|
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
|
||||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication)
|
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
permissionViewWithdrawalApplications = "finance-withdrawal:view"
|
permissionViewWithdrawalApplications = "finance-withdrawal:view"
|
||||||
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
|
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
|
||||||
permissionAuditFinanceApplications = "finance-application:audit"
|
|
||||||
|
|
||||||
pointWithdrawalDefaultPointsPerUSD = int64(100000)
|
pointWithdrawalDefaultPointsPerUSD = int64(100000)
|
||||||
pointWithdrawalDefaultFeeBPS = int32(500)
|
pointWithdrawalDefaultFeeBPS = int32(500)
|
||||||
@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string {
|
|||||||
func canAuditWithdrawal(permissions []string) bool {
|
func canAuditWithdrawal(permissions []string) bool {
|
||||||
for _, permission := range permissions {
|
for _, permission := range permissions {
|
||||||
switch strings.TrimSpace(permission) {
|
switch strings.TrimSpace(permission) {
|
||||||
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications:
|
case permissionAuditWithdrawalApplications:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.TemporaryPaymentLinkOwner{},
|
||||||
&model.LegacyGooglePaidDetail{},
|
&model.LegacyGooglePaidDetail{},
|
||||||
&model.LegacyGooglePaidSyncAttempt{},
|
&model.LegacyGooglePaidSyncAttempt{},
|
||||||
&model.FinanceApplication{},
|
|
||||||
&model.CoinSellerRechargeOrder{},
|
&model.CoinSellerRechargeOrder{},
|
||||||
&model.UserWithdrawalApplication{},
|
&model.UserWithdrawalApplication{},
|
||||||
&model.AdminJob{},
|
&model.AdminJob{},
|
||||||
|
|||||||
@ -128,21 +128,12 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
|
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
|
||||||
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
|
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
|
||||||
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
|
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
|
||||||
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit;是否能看到审核列表由 audit 权限单独决定。
|
|
||||||
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
|
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
|
||||||
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
|
|
||||||
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
|
|
||||||
{Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"},
|
{Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"},
|
||||||
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"},
|
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"},
|
||||||
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
|
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
|
||||||
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
|
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
|
||||||
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
|
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
|
||||||
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"},
|
|
||||||
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
|
|
||||||
{Name: "财务增加用户钱包", Code: "finance-operation:user-wallet-credit", Kind: "button"},
|
|
||||||
{Name: "财务扣减用户钱包", Code: "finance-operation:user-wallet-debit", Kind: "button"},
|
|
||||||
{Name: "财务增加币商金币", Code: "finance-operation:coin-seller-coin-credit", Kind: "button"},
|
|
||||||
{Name: "财务减少币商金币", Code: "finance-operation:coin-seller-coin-debit", Kind: "button"},
|
|
||||||
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
||||||
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
||||||
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
|
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
|
||||||
@ -662,7 +653,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||||
"bd:view", "bd:create", "bd:update",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
@ -774,7 +765,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"region:view", "region:create", "region:update", "region:status",
|
"region:view", "region:create", "region:update", "region:status",
|
||||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
|
|||||||
@ -17,19 +17,25 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
|
|||||||
for _, code := range []string{
|
for _, code := range []string{
|
||||||
"payment-temporary-link:create",
|
"payment-temporary-link:create",
|
||||||
"finance:view",
|
"finance:view",
|
||||||
|
"finance-withdrawal:view",
|
||||||
|
"host-withdrawal:view",
|
||||||
|
} {
|
||||||
|
if _, ok := permissions[code]; !ok {
|
||||||
|
t.Fatalf("finance permission %s missing from default seed", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range []string{
|
||||||
"finance-application:create",
|
"finance-application:create",
|
||||||
"finance-application:audit",
|
"finance-application:audit",
|
||||||
"finance-withdrawal:view",
|
|
||||||
"finance-operation:user-coin-credit",
|
"finance-operation:user-coin-credit",
|
||||||
"finance-operation:user-coin-debit",
|
"finance-operation:user-coin-debit",
|
||||||
"finance-operation:user-wallet-credit",
|
"finance-operation:user-wallet-credit",
|
||||||
"finance-operation:user-wallet-debit",
|
"finance-operation:user-wallet-debit",
|
||||||
"finance-operation:coin-seller-coin-credit",
|
"finance-operation:coin-seller-coin-credit",
|
||||||
"finance-operation:coin-seller-coin-debit",
|
"finance-operation:coin-seller-coin-debit",
|
||||||
"host-withdrawal:view",
|
|
||||||
} {
|
} {
|
||||||
if _, ok := permissions[code]; !ok {
|
if _, ok := permissions[code]; ok {
|
||||||
t.Fatalf("finance permission %s missing from default seed", code)
|
t.Fatalf("removed finance application permission %s must not be seeded", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,13 +46,6 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
|
|||||||
|
|
||||||
for _, code := range []string{
|
for _, code := range []string{
|
||||||
"payment-temporary-link:create",
|
"payment-temporary-link:create",
|
||||||
"finance-application:create",
|
|
||||||
"finance-operation:user-coin-credit",
|
|
||||||
"finance-operation:user-coin-debit",
|
|
||||||
"finance-operation:user-wallet-credit",
|
|
||||||
"finance-operation:user-wallet-debit",
|
|
||||||
"finance-operation:coin-seller-coin-credit",
|
|
||||||
"finance-operation:coin-seller-coin-debit",
|
|
||||||
"host-withdrawal:view",
|
"host-withdrawal:view",
|
||||||
} {
|
} {
|
||||||
if _, ok := opsPermissions[code]; !ok {
|
if _, ok := opsPermissions[code]; !ok {
|
||||||
@ -57,11 +56,22 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := opsPermissions["finance-application:audit"]; ok {
|
for _, code := range []string{
|
||||||
t.Fatal("ops-admin must not receive finance audit permission by default")
|
"finance-application:create",
|
||||||
}
|
"finance-application:audit",
|
||||||
if _, ok := opsMigrationPermissions["finance-application:audit"]; ok {
|
"finance-operation:user-coin-credit",
|
||||||
t.Fatal("ops-admin migration must not append finance audit permission by default")
|
"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/dailytask"
|
||||||
"hyapp-admin-server/internal/modules/dashboard"
|
"hyapp-admin-server/internal/modules/dashboard"
|
||||||
"hyapp-admin-server/internal/modules/databi"
|
"hyapp-admin-server/internal/modules/databi"
|
||||||
"hyapp-admin-server/internal/modules/financeapplication"
|
|
||||||
"hyapp-admin-server/internal/modules/financeorder"
|
"hyapp-admin-server/internal/modules/financeorder"
|
||||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -84,7 +83,6 @@ type Handlers struct {
|
|||||||
Dashboard *dashboard.Handler
|
Dashboard *dashboard.Handler
|
||||||
Databi *databi.Handler
|
Databi *databi.Handler
|
||||||
FirstRechargeReward *firstrechargereward.Handler
|
FirstRechargeReward *firstrechargereward.Handler
|
||||||
FinanceApplication *financeapplication.Handler
|
|
||||||
FinanceOrder *financeorder.Handler
|
FinanceOrder *financeorder.Handler
|
||||||
FinanceWithdrawal *financewithdrawal.Handler
|
FinanceWithdrawal *financewithdrawal.Handler
|
||||||
FullServerNotice *fullservernotice.Handler
|
FullServerNotice *fullservernotice.Handler
|
||||||
@ -156,7 +154,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
|
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
|
||||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||||
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
|
|
||||||
financeorder.RegisterRoutes(protected, h.FinanceOrder)
|
financeorder.RegisterRoutes(protected, h.FinanceOrder)
|
||||||
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
||||||
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
||||||
|
|||||||
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 {
|
type levelTrackOverviewData struct {
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Level int32 `json:"level"`
|
Level int32 `json:"level"`
|
||||||
TierID int64 `json:"tier_id"`
|
TierID int64 `json:"tier_id"`
|
||||||
TotalValue int64 `json:"total_value"`
|
TotalValue int64 `json:"total_value"`
|
||||||
CurrentLevelRequiredValue int64 `json:"current_level_required_value"`
|
CurrentLevelRequiredValue int64 `json:"current_level_required_value"`
|
||||||
NextLevel int32 `json:"next_level"`
|
NextLevel int32 `json:"next_level"`
|
||||||
NextLevelRequiredValue int64 `json:"next_level_required_value"`
|
NextLevelRequiredValue int64 `json:"next_level_required_value"`
|
||||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
|
||||||
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
|
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||||
RewardPendingCount int64 `json:"reward_pending_count"`
|
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
|
||||||
|
RewardPendingCount int64 `json:"reward_pending_count"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type levelTrackDetailData struct {
|
type levelTrackDetailData struct {
|
||||||
@ -38,35 +40,39 @@ type levelTrackDetailData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type levelRuleData struct {
|
type levelRuleData struct {
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
Level int32 `json:"level"`
|
Level int32 `json:"level"`
|
||||||
RequiredValue int64 `json:"required_value"`
|
RequiredValue int64 `json:"required_value"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
|
||||||
DisplayConfigJSON string `json:"display_config_json"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
DisplayConfigJSON string `json:"display_config_json"`
|
||||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type levelTierData struct {
|
type levelTierData struct {
|
||||||
TierID int64 `json:"tier_id"`
|
TierID int64 `json:"tier_id"`
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
MinLevel int32 `json:"min_level"`
|
MinLevel int32 `json:"min_level"`
|
||||||
MaxLevel int32 `json:"max_level"`
|
MaxLevel int32 `json:"max_level"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
|
||||||
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
DisplayAvatarFrameResource *achievementResourceData `json:"display_avatar_frame_resource,omitempty"`
|
||||||
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
|
||||||
Status string `json:"status"`
|
DisplayBadgeResource *achievementResourceData `json:"display_badge_resource,omitempty"`
|
||||||
DisplayConfigJSON string `json:"display_config_json"`
|
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
|
||||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
RewardResourceGroup *achievementResourceGroupData `json:"reward_resource_group,omitempty"`
|
||||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
Status string `json:"status"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
DisplayConfigJSON string `json:"display_config_json"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
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 {
|
type levelRewardData struct {
|
||||||
@ -98,9 +104,15 @@ func (h *Handler) getMyLevelOverview(writer http.ResponseWriter, request *http.R
|
|||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// activity-service 只拥有等级关系和资源 ID;素材 URL 属于 wallet-service。Gateway 在同一响应补齐当前等级素材,
|
||||||
|
// 避免 H5 再依赖后台接口或把线上资源 ID 映射硬编码进页面。
|
||||||
|
resources, ok := h.resolveAchievementResources(writer, request, levelOverviewResourceIDs(resp.GetTracks()))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
tracks := make([]levelTrackOverviewData, 0, len(resp.GetTracks()))
|
tracks := make([]levelTrackOverviewData, 0, len(resp.GetTracks()))
|
||||||
for _, item := range resp.GetTracks() {
|
for _, item := range resp.GetTracks() {
|
||||||
tracks = append(tracks, levelTrackOverviewFromProto(item))
|
tracks = append(tracks, levelTrackOverviewFromProto(item, resources))
|
||||||
}
|
}
|
||||||
httpkit.WriteOK(writer, request, levelOverviewData{Tracks: tracks, ServerTimeMS: resp.GetServerTimeMs()})
|
httpkit.WriteOK(writer, request, levelOverviewData{Tracks: tracks, ServerTimeMS: resp.GetServerTimeMs()})
|
||||||
}
|
}
|
||||||
@ -124,16 +136,26 @@ func (h *Handler) getLevelTrack(writer http.ResponseWriter, request *http.Reques
|
|||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 等级层页面必须一次拿到区间、徽章、头像框和升级礼物;这里按正 ID 去重后解析,
|
||||||
|
// 既保持 activity/wallet 的 owner 边界,也避免前端为每一行产生额外请求。
|
||||||
|
resources, ok := h.resolveAchievementResources(writer, request, levelTrackResourceIDs(resp.GetOverview(), resp.GetTiers()))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceGroups, ok := h.resolveAchievementResourceGroups(writer, request, levelTrackResourceGroupIDs(resp.GetRules(), resp.GetTiers()))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
rules := make([]levelRuleData, 0, len(resp.GetRules()))
|
rules := make([]levelRuleData, 0, len(resp.GetRules()))
|
||||||
for _, rule := range resp.GetRules() {
|
for _, rule := range resp.GetRules() {
|
||||||
rules = append(rules, levelRuleFromProto(rule))
|
rules = append(rules, levelRuleFromProto(rule, resourceGroups))
|
||||||
}
|
}
|
||||||
tiers := make([]levelTierData, 0, len(resp.GetTiers()))
|
tiers := make([]levelTierData, 0, len(resp.GetTiers()))
|
||||||
for _, tier := range resp.GetTiers() {
|
for _, tier := range resp.GetTiers() {
|
||||||
tiers = append(tiers, levelTierFromProto(tier))
|
tiers = append(tiers, levelTierFromProto(tier, resources, resourceGroups))
|
||||||
}
|
}
|
||||||
httpkit.WriteOK(writer, request, levelTrackDetailData{
|
httpkit.WriteOK(writer, request, levelTrackDetailData{
|
||||||
Overview: levelTrackOverviewFromProto(resp.GetOverview()),
|
Overview: levelTrackOverviewFromProto(resp.GetOverview(), resources),
|
||||||
Rules: rules,
|
Rules: rules,
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
ServerTimeMS: resp.GetServerTimeMs(),
|
ServerTimeMS: resp.GetServerTimeMs(),
|
||||||
@ -179,8 +201,8 @@ func (h *Handler) listLevelRewards(writer http.ResponseWriter, request *http.Req
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrackOverviewData {
|
func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview, resources map[int64]achievementResourceData) levelTrackOverviewData {
|
||||||
return levelTrackOverviewData{
|
data := levelTrackOverviewData{
|
||||||
Track: item.GetTrack(),
|
Track: item.GetTrack(),
|
||||||
Name: item.GetName(),
|
Name: item.GetName(),
|
||||||
Level: item.GetLevel(),
|
Level: item.GetLevel(),
|
||||||
@ -195,10 +217,17 @@ func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrack
|
|||||||
RewardPendingCount: item.GetRewardPendingCount(),
|
RewardPendingCount: item.GetRewardPendingCount(),
|
||||||
SortOrder: item.GetSortOrder(),
|
SortOrder: item.GetSortOrder(),
|
||||||
}
|
}
|
||||||
|
if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok {
|
||||||
|
data.DisplayAvatarFrameResource = &resource
|
||||||
|
}
|
||||||
|
if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok {
|
||||||
|
data.DisplayBadgeResource = &resource
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData {
|
func levelRuleFromProto(item *activityv1.LevelRule, resourceGroups map[int64]achievementResourceGroupData) levelRuleData {
|
||||||
return levelRuleData{
|
data := levelRuleData{
|
||||||
Track: item.GetTrack(),
|
Track: item.GetTrack(),
|
||||||
Level: item.GetLevel(),
|
Level: item.GetLevel(),
|
||||||
RequiredValue: item.GetRequiredValue(),
|
RequiredValue: item.GetRequiredValue(),
|
||||||
@ -212,10 +241,14 @@ func levelRuleFromProto(item *activityv1.LevelRule) levelRuleData {
|
|||||||
CreatedAtMS: item.GetCreatedAtMs(),
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
|
if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok {
|
||||||
|
data.RewardResourceGroup = &group
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func levelTierFromProto(item *activityv1.LevelTier) levelTierData {
|
func levelTierFromProto(item *activityv1.LevelTier, resources map[int64]achievementResourceData, resourceGroups map[int64]achievementResourceGroupData) levelTierData {
|
||||||
return levelTierData{
|
data := levelTierData{
|
||||||
TierID: item.GetTierId(),
|
TierID: item.GetTierId(),
|
||||||
Track: item.GetTrack(),
|
Track: item.GetTrack(),
|
||||||
MinLevel: item.GetMinLevel(),
|
MinLevel: item.GetMinLevel(),
|
||||||
@ -231,6 +264,43 @@ func levelTierFromProto(item *activityv1.LevelTier) levelTierData {
|
|||||||
CreatedAtMS: item.GetCreatedAtMs(),
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
|
if resource, ok := resources[item.GetDisplayAvatarFrameResourceId()]; ok {
|
||||||
|
data.DisplayAvatarFrameResource = &resource
|
||||||
|
}
|
||||||
|
if resource, ok := resources[item.GetDisplayBadgeResourceId()]; ok {
|
||||||
|
data.DisplayBadgeResource = &resource
|
||||||
|
}
|
||||||
|
if group, ok := resourceGroups[item.GetRewardResourceGroupId()]; ok {
|
||||||
|
data.RewardResourceGroup = &group
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelOverviewResourceIDs(items []*activityv1.LevelTrackOverview) []int64 {
|
||||||
|
ids := make([]int64, 0, len(items)*2)
|
||||||
|
for _, item := range items {
|
||||||
|
ids = append(ids, item.GetDisplayAvatarFrameResourceId(), item.GetDisplayBadgeResourceId())
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelTrackResourceIDs(overview *activityv1.LevelTrackOverview, tiers []*activityv1.LevelTier) []int64 {
|
||||||
|
ids := levelOverviewResourceIDs([]*activityv1.LevelTrackOverview{overview})
|
||||||
|
for _, tier := range tiers {
|
||||||
|
ids = append(ids, tier.GetDisplayAvatarFrameResourceId(), tier.GetDisplayBadgeResourceId())
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelTrackResourceGroupIDs(rules []*activityv1.LevelRule, tiers []*activityv1.LevelTier) []int64 {
|
||||||
|
ids := make([]int64, 0, len(rules)+len(tiers))
|
||||||
|
for _, rule := range rules {
|
||||||
|
ids = append(ids, rule.GetRewardResourceGroupId())
|
||||||
|
}
|
||||||
|
for _, tier := range tiers {
|
||||||
|
ids = append(ids, tier.GetRewardResourceGroupId())
|
||||||
|
}
|
||||||
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func levelRewardFromProto(item *activityv1.LevelRewardJob) levelRewardData {
|
func levelRewardFromProto(item *activityv1.LevelRewardJob) levelRewardData {
|
||||||
|
|||||||
@ -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
|
package activityapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -53,10 +55,11 @@ type userLeaderboardItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type userLeaderboardUser struct {
|
type userLeaderboardUser struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
ShortBadgeCoverURLs []string `json:"short_badge_cover_urls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type userLeaderboardRoom struct {
|
type userLeaderboardRoom struct {
|
||||||
@ -234,11 +237,17 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
applyUserLeaderboardProfile(&items[i], profiles)
|
applyUserLeaderboardProfile(&items[i], profiles)
|
||||||
|
applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers)
|
||||||
}
|
}
|
||||||
if myRank != nil {
|
if myRank != nil {
|
||||||
applyUserLeaderboardProfile(myRank, profiles)
|
applyUserLeaderboardProfile(myRank, profiles)
|
||||||
|
applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -336,6 +345,89 @@ func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) userLeaderboardShortBadgeCovers(request *http.Request, userIDs []int64) (map[int64][]string, error) {
|
||||||
|
result := make(map[int64][]string)
|
||||||
|
userIDs = uniqueUserLeaderboardInt64s(userIDs)
|
||||||
|
if h.walletClient == nil || len(userIDs) == 0 {
|
||||||
|
// 榜单基础资料不依赖装扮服务;未接 wallet 的环境保持兼容,只是不返回短徽章。
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// 一页榜单只发起一次批量查询,避免按用户逐个读取权益和资源造成 N+1 放大。
|
||||||
|
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
|
||||||
|
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||||
|
AppCode: appcode.FromContext(request.Context()),
|
||||||
|
UserIds: userIDs,
|
||||||
|
ResourceTypes: []string{"badge"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return userLeaderboardShortBadgeCoversFromResponse(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) map[int64][]string {
|
||||||
|
result := make(map[int64][]string)
|
||||||
|
if resp == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, user := range resp.GetUsers() {
|
||||||
|
if user.GetUserId() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, entitlement := range user.GetResources() {
|
||||||
|
resource := entitlement.GetResource()
|
||||||
|
if resource == nil || !isUserLeaderboardShortBadge(resource.GetMetadataJson()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// preview_url 是静态封面契约;动画 asset_url 不能直接交给 H5 img 渲染。
|
||||||
|
coverURL := strings.TrimSpace(resource.GetPreviewUrl())
|
||||||
|
if coverURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[coverURL]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[coverURL] = struct{}{}
|
||||||
|
result[user.GetUserId()] = append(result[user.GetUserId()], coverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUserLeaderboardShortBadge(rawMetadata string) bool {
|
||||||
|
metadata := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(toUserLeaderboardString(metadata["badge_kind"])), "level") {
|
||||||
|
// 等级徽章有独立展示位,不能混入用户主动佩戴的普通短徽章。
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(toUserLeaderboardString(metadata["badge_form"]))) {
|
||||||
|
case "short", "tile", "icon", "medal":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUserLeaderboardString(value any) string {
|
||||||
|
text, _ := value.(string)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUserLeaderboardShortBadgeCovers(item *userLeaderboardItem, covers map[int64][]string) {
|
||||||
|
if item == nil || item.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := strconv.ParseInt(item.UserID, 10, 64)
|
||||||
|
if err != nil || userID <= 0 || len(covers[userID]) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.User.ShortBadgeCoverURLs = append([]string(nil), covers[userID]...)
|
||||||
|
}
|
||||||
|
|
||||||
func uniqueUserLeaderboardInt64s(values []int64) []int64 {
|
func uniqueUserLeaderboardInt64s(values []int64) []int64 {
|
||||||
seen := make(map[int64]struct{}, len(values))
|
seen := make(map[int64]struct{}, len(values))
|
||||||
out := make([]int64, 0, len(values))
|
out := make([]int64, 0, len(values))
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"hyapp/services/gateway-service/internal/auth"
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) {
|
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) {
|
||||||
@ -49,6 +50,27 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) {
|
||||||
|
response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{
|
||||||
|
UserId: 20002,
|
||||||
|
Resources: []*walletv1.UserResourceEntitlement{
|
||||||
|
{Resource: &walletv1.Resource{ResourceId: 1, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`}},
|
||||||
|
{Resource: &walletv1.Resource{ResourceId: 2, ResourceType: "badge", PreviewUrl: "https://cdn.example/long.png", MetadataJson: `{"badge_form":"strip","badge_kind":"normal"}`}},
|
||||||
|
{Resource: &walletv1.Resource{ResourceId: 3, ResourceType: "badge", PreviewUrl: "https://cdn.example/level.png", MetadataJson: `{"badge_form":"icon","badge_kind":"level"}`}},
|
||||||
|
{Resource: &walletv1.Resource{ResourceId: 4, ResourceType: "badge", AssetUrl: "https://cdn.example/animated.svga", MetadataJson: `{"badge_form":"short","badge_kind":"normal"}`}},
|
||||||
|
{Resource: &walletv1.Resource{ResourceId: 5, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"medal","badge_kind":"normal"}`}},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
covers := userLeaderboardShortBadgeCoversFromResponse(response)
|
||||||
|
if len(covers[20002]) != 1 || covers[20002][0] != "https://cdn.example/short.png" {
|
||||||
|
t.Fatalf("only unique normal short-badge covers should be returned: %+v", covers)
|
||||||
|
}
|
||||||
|
if len(covers[99999]) != 0 {
|
||||||
|
t.Fatalf("users without a short badge must stay absent: %+v", covers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) {
|
func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) {
|
||||||
room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{
|
room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{
|
||||||
RoomId: "room_1",
|
RoomId: "room_1",
|
||||||
|
|||||||
@ -719,6 +719,18 @@ type fakeWithdrawalApplicationWriter struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeWithdrawalApplicationNotifier struct {
|
||||||
|
last financewithdrawal.Application
|
||||||
|
calls int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWithdrawalApplicationNotifier) NotifyApplicationCreated(_ context.Context, application financewithdrawal.Application) error {
|
||||||
|
f.last = application
|
||||||
|
f.calls++
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
|
func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
|
||||||
f.last = command
|
f.last = command
|
||||||
f.calls++
|
f.calls++
|
||||||
@ -6048,10 +6060,12 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
|
|||||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
|
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
|
||||||
profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
|
profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
|
||||||
withdrawalWriter := &fakeWithdrawalApplicationWriter{}
|
withdrawalWriter := &fakeWithdrawalApplicationWriter{}
|
||||||
|
withdrawalNotifier := &fakeWithdrawalApplicationNotifier{}
|
||||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||||
handler.SetWalletClient(walletClient)
|
handler.SetWalletClient(walletClient)
|
||||||
handler.SetUserHostClient(hostClient)
|
handler.SetUserHostClient(hostClient)
|
||||||
handler.SetWithdrawalApplicationWriter(withdrawalWriter)
|
handler.SetWithdrawalApplicationWriter(withdrawalWriter)
|
||||||
|
handler.SetWithdrawalApplicationNotifier(withdrawalNotifier)
|
||||||
router := handler.Routes(auth.NewVerifier("secret"))
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`)))
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`)))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
@ -6090,6 +6104,13 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin
|
|||||||
withdrawalWriter.last.CreatedAtMS <= 0 {
|
withdrawalWriter.last.CreatedAtMS <= 0 {
|
||||||
t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls)
|
t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls)
|
||||||
}
|
}
|
||||||
|
if withdrawalNotifier.calls != 1 ||
|
||||||
|
withdrawalNotifier.last.ID != 77 ||
|
||||||
|
withdrawalNotifier.last.UserID != 42 ||
|
||||||
|
withdrawalNotifier.last.WithdrawAmount != "50.00" ||
|
||||||
|
withdrawalNotifier.last.FreezeTransactionID != "freeze-tx-1" {
|
||||||
|
t.Fatalf("withdraw application must notify DingTalk after persistence: %+v calls=%d", withdrawalNotifier.last, withdrawalNotifier.calls)
|
||||||
|
}
|
||||||
var response httpkit.ResponseEnvelope
|
var response httpkit.ResponseEnvelope
|
||||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
t.Fatalf("decode response failed: %v", err)
|
t.Fatalf("decode response failed: %v", err)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user