Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad279049a0 | ||
|
|
1ee7d17712 | ||
|
|
cfabfa8f65 | ||
|
|
6561f925e0 | ||
|
|
327a03672a | ||
|
|
21fbc69c7a | ||
|
|
2337395d8f | ||
|
|
98f6ee9451 | ||
|
|
0aef80fd11 | ||
|
|
2487a2d83f | ||
|
|
527e5ed228 | ||
|
|
c156c478d8 | ||
|
|
c87883cc65 | ||
|
|
a9a9127076 | ||
|
|
41a5b49450 | ||
|
|
a53b9dc617 | ||
|
|
74d1518b66 | ||
|
|
006f93a0e7 | ||
|
|
cdab2d8157 | ||
|
|
cc91979b34 | ||
|
|
16237dcefc | ||
|
|
52dcb18c96 | ||
|
|
d95b95211c | ||
|
|
8f6356a3d7 | ||
|
|
417f7398e4 | ||
|
|
87327fbd91 | ||
|
|
8660d5d4d1 | ||
|
|
7b274d4ab8 | ||
|
|
266abb4aaa | ||
|
|
cc974f954f | ||
|
|
b0d4d5bfef | ||
|
|
47ba202517 | ||
|
|
6e4689edc4 | ||
|
|
9a52b51c89 | ||
|
|
80e329a604 | ||
|
|
0b235d078d | ||
|
|
f1b48f7921 | ||
|
|
c4507caf37 | ||
|
|
1f204f17ad | ||
|
|
c91423a19b | ||
|
|
0bc6cf9f46 | ||
|
|
79ced2d121 | ||
|
|
50b458e5c2 | ||
|
|
76528076a7 | ||
|
|
2265c48a53 | ||
|
|
79eebdc8e5 | ||
|
|
a0bf47f2ed | ||
|
|
f67ee12d72 | ||
|
|
66cb03ebce | ||
|
|
ca4372271a | ||
|
|
7d7141c055 | ||
|
|
4fb79cb37f | ||
|
|
96221d3c00 | ||
|
|
eb06ec3c5f | ||
|
|
7b7d23c743 | ||
|
|
7438fb6725 |
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
push_lines="$(cat)"
|
||||
has_updates=0
|
||||
has_deletions=0
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
[ -z "${local_ref}" ] && continue
|
||||
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||
has_deletions=1
|
||||
else
|
||||
has_updates=1
|
||||
fi
|
||||
done <<< "$push_lines"
|
||||
|
||||
if [ "$has_updates" = "0" ] && [ "$has_deletions" = "1" ]; then
|
||||
echo "[pre-push] 分支删除推送,跳过本地检查"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${SKIP_CHECKS:-0}" = "1" ]; then
|
||||
echo "[pre-push] SKIP_CHECKS=1,跳过本地检查"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pre-push] 运行本地代码门禁..."
|
||||
bash scripts/check.sh
|
||||
echo "[pre-push] 本地检查通过"
|
||||
@@ -40,3 +40,4 @@ deploy/caddy/conf.d/show.caddy
|
||||
|
||||
logs
|
||||
.gocache
|
||||
artifacts/
|
||||
|
||||
@@ -50,6 +50,20 @@ npm run dev
|
||||
|
||||
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。
|
||||
|
||||
数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。
|
||||
|
||||
需要在本地推送前自动执行同一套检查时,启用 Git hook:
|
||||
|
||||
```bash
|
||||
bash scripts/install-git-hooks.sh
|
||||
```
|
||||
|
||||
启用后,`git push` 会执行 `scripts/check.sh`;仅临时跳过时使用:
|
||||
|
||||
```bash
|
||||
SKIP_CHECKS=1 git push
|
||||
```
|
||||
|
||||
## 开发态短信与实名
|
||||
|
||||
- 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
|
||||
|
||||
@@ -8,6 +8,13 @@ MYSQL_USER=hfb
|
||||
MYSQL_PASSWORD=secret
|
||||
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。
|
||||
DATABASE_MAX_OPEN_CONNS=50
|
||||
DATABASE_MAX_IDLE_CONNS=10
|
||||
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
|
||||
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||
|
||||
REDIS_ADDR=127.0.0.1:16379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
+45
-15
@@ -19,6 +19,13 @@ MYSQL_USER=hfb
|
||||
MYSQL_PASSWORD=change-hfb-password
|
||||
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。
|
||||
DATABASE_MAX_OPEN_CONNS=50
|
||||
DATABASE_MAX_IDLE_CONNS=10
|
||||
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
|
||||
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||
|
||||
REDIS_ADDR=redis:6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
@@ -48,26 +55,23 @@ LOG_ENABLE_CONSOLE=false
|
||||
LOG_ENABLE_FILE=true
|
||||
LOG_RETAIN_DAYS=14
|
||||
|
||||
# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验。
|
||||
MINIO_ROOT_USER=change-minio-user
|
||||
MINIO_ROOT_PASSWORD=change-minio-password
|
||||
# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_BUCKET=hfb-sys
|
||||
STORAGE_ACCESS_KEY_ID=change-minio-user
|
||||
STORAGE_SECRET_ACCESS_KEY=change-minio-password
|
||||
STORAGE_REGION=
|
||||
STORAGE_BUCKET_LOOKUP=auto
|
||||
|
||||
# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。
|
||||
# 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除。
|
||||
# 杭州 ECS 应使用内网 Endpoint:https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||
# OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。
|
||||
STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||
STORAGE_BUCKET=hfb-sys-assets
|
||||
STORAGE_ACCESS_KEY_ID=change-oss-access-key-id
|
||||
STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret
|
||||
STORAGE_REGION=cn-hangzhou
|
||||
STORAGE_BUCKET_LOOKUP=dns
|
||||
|
||||
# 历史镜像写入配置,迁移完成后保持为空。
|
||||
STORAGE_MIRROR_ENDPOINT=
|
||||
STORAGE_MIRROR_BUCKET=hfb-sys-assets
|
||||
STORAGE_MIRROR_BUCKET=
|
||||
STORAGE_MIRROR_ACCESS_KEY_ID=
|
||||
STORAGE_MIRROR_SECRET_ACCESS_KEY=
|
||||
STORAGE_MIRROR_REGION=cn-hangzhou
|
||||
STORAGE_MIRROR_BUCKET_LOOKUP=dns
|
||||
STORAGE_MIRROR_REGION=
|
||||
STORAGE_MIRROR_BUCKET_LOOKUP=
|
||||
|
||||
# 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。
|
||||
SMS_PROVIDER=aliyun
|
||||
@@ -102,3 +106,29 @@ FIELD_ENCRYPTION_LEGACY_KEY=
|
||||
EXTERNAL_UPLOAD_SECRET=
|
||||
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
|
||||
EXTERNAL_UPLOAD_ALLOWED_IPS=
|
||||
|
||||
# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。
|
||||
# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。
|
||||
BACKUP_DIR=/data/backups
|
||||
# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。
|
||||
BACKUP_STATUS_DIR=/data/backups/status
|
||||
# backend 容器内的只读状态文件路径,通常保持默认。
|
||||
BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json
|
||||
# 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。
|
||||
BACKUP_RESTORE_DIR=/data/restore
|
||||
# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。
|
||||
# 生成方式:ossutil config 交互配置,或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET。
|
||||
BACKUP_OSS_URI=oss://hfb-backup
|
||||
# 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。
|
||||
# 生成方式:openssl rand -hex 32
|
||||
BACKUP_PASSPHRASE=change-to-random-backup-passphrase
|
||||
# 可选:自有 KMS Key ID。留空时 Bucket 必须开启“OSS 完全托管”服务器端加密。
|
||||
BACKUP_KMS_KEY_ID=
|
||||
# XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。
|
||||
BACKUP_MYSQL_USER=hfb_backup
|
||||
# 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。
|
||||
BACKUP_MYSQL_PASSWORD=change-backup-mysql-password
|
||||
# 本地保留策略(OSS 侧用 Bucket 生命周期规则管理,见脚本头部注释)。
|
||||
BACKUP_KEEP_DAILY=14
|
||||
BACKUP_KEEP_WEEKLY=8
|
||||
BACKUP_KEEP_MONTHLY=12
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||
"hfb_sys/backend/internal/jobs/refundretry"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
@@ -59,7 +58,13 @@ func main() {
|
||||
logAuthRuntimeIdentity(logger, cfg)
|
||||
|
||||
var deps router.Dependencies
|
||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
|
||||
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime,
|
||||
SlowQueryThreshold: cfg.Database.SlowQueryThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
|
||||
} else {
|
||||
@@ -92,7 +97,6 @@ func main() {
|
||||
defer stopJobs()
|
||||
if deps.DB != nil {
|
||||
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
||||
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
||||
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -29,6 +30,8 @@ type Config struct {
|
||||
Realname RealnameConfig
|
||||
Log LogConfig
|
||||
RateLimit RateLimitConfig
|
||||
Database DatabaseConfig
|
||||
BackupStatusFile string
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
@@ -69,6 +72,14 @@ type RateLimitConfig struct {
|
||||
RequestsPerMinute int
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
SlowQueryThreshold time.Duration
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
@@ -130,6 +141,14 @@ func Load() Config {
|
||||
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
|
||||
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1),
|
||||
MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0),
|
||||
ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute,
|
||||
ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute,
|
||||
SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond,
|
||||
},
|
||||
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +225,14 @@ func getEnvInt(key string, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getEnvIntMin(key string, fallback, minimum int) int {
|
||||
value := getEnvInt(key, fallback)
|
||||
if value < minimum {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
|
||||
@@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabaseConfigFromEnvironment(t *testing.T) {
|
||||
t.Setenv("DATABASE_MAX_OPEN_CONNS", "80")
|
||||
t.Setenv("DATABASE_MAX_IDLE_CONNS", "16")
|
||||
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45")
|
||||
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7")
|
||||
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 {
|
||||
t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database)
|
||||
}
|
||||
if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 {
|
||||
t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime)
|
||||
}
|
||||
if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 {
|
||||
t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) {
|
||||
t.Setenv("DATABASE_MAX_OPEN_CONNS", "0")
|
||||
t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1")
|
||||
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0")
|
||||
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1")
|
||||
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 {
|
||||
t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database)
|
||||
}
|
||||
if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 {
|
||||
t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
|
||||
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
|
||||
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
|
||||
|
||||
@@ -21,6 +21,10 @@ type structuredGormLogger struct {
|
||||
}
|
||||
|
||||
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
||||
return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface {
|
||||
level := gormLogger.Warn
|
||||
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
|
||||
level = gormLogger.Info
|
||||
@@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
||||
if logger == nil {
|
||||
logger = zap.L()
|
||||
}
|
||||
if slowThreshold <= 0 {
|
||||
slowThreshold = 500 * time.Millisecond
|
||||
}
|
||||
return &structuredGormLogger{
|
||||
logger: logger.With(zap.String("module", "database")),
|
||||
level: level,
|
||||
slowThreshold: 500 * time.Millisecond,
|
||||
slowThreshold: slowThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,50 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MySQLOptions struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
SlowQueryThreshold time.Duration
|
||||
}
|
||||
|
||||
func DefaultMySQLOptions() MySQLOptions {
|
||||
return MySQLOptions{
|
||||
MaxOpenConns: 50,
|
||||
MaxIdleConns: 10,
|
||||
ConnMaxLifetime: 30 * time.Minute,
|
||||
ConnMaxIdleTime: 5 * time.Minute,
|
||||
SlowQueryThreshold: 500 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
|
||||
return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions())
|
||||
}
|
||||
|
||||
func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) {
|
||||
defaults := DefaultMySQLOptions()
|
||||
if options.MaxOpenConns < 1 {
|
||||
options.MaxOpenConns = defaults.MaxOpenConns
|
||||
}
|
||||
if options.MaxIdleConns < 0 {
|
||||
options.MaxIdleConns = defaults.MaxIdleConns
|
||||
}
|
||||
if options.ConnMaxLifetime <= 0 {
|
||||
options.ConnMaxLifetime = defaults.ConnMaxLifetime
|
||||
}
|
||||
if options.ConnMaxIdleTime < 0 {
|
||||
options.ConnMaxIdleTime = defaults.ConnMaxIdleTime
|
||||
}
|
||||
if options.SlowQueryThreshold <= 0 {
|
||||
options.SlowQueryThreshold = defaults.SlowQueryThreshold
|
||||
}
|
||||
if options.MaxIdleConns > options.MaxOpenConns {
|
||||
options.MaxIdleConns = options.MaxOpenConns
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: newGormLogger(logLevel, appLogger),
|
||||
Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||
// 限制连接池,避免单个实例耗尽 MySQL max_connections。
|
||||
sqlDB.SetMaxOpenConns(options.MaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
|
||||
sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB {
|
||||
func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AdminUser{},
|
||||
&model.GameAccount{},
|
||||
&model.RentalListing{},
|
||||
&model.ListingUpload{},
|
||||
&model.ListingStatusEvent{},
|
||||
)
|
||||
}
|
||||
@@ -61,8 +63,10 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
|
||||
&model.WalletLedger{},
|
||||
&model.RenterGrowthLedger{},
|
||||
&model.AuditLog{},
|
||||
&model.ProcessEvent{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatAdminConversationState{},
|
||||
&model.ChatMessage{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,7 +313,15 @@ func applyMigrations(t *testing.T, db *sql.DB) {
|
||||
t.Fatal("无法定位当前测试文件")
|
||||
}
|
||||
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
|
||||
for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
|
||||
entries, err := os.ReadDir(migrationDir)
|
||||
if err != nil {
|
||||
t.Fatalf("读取迁移目录失败: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
migrationPath := filepath.Join(migrationDir, name)
|
||||
raw, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package fileuploadcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
cleanupLockKey = "hfb:job:file-upload-cleanup:lock"
|
||||
cleanupInterval = 24 * time.Hour
|
||||
cleanupRetention = 30 * 24 * time.Hour
|
||||
cleanupBatchSize = 200
|
||||
)
|
||||
|
||||
// Job 清理长期未关联业务记录的上传归属,避免临时草稿记录无限增长。
|
||||
type Job struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
instanceID string
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Job{db: db, redis: redisClient, logger: logger, instanceID: newInstanceID()}
|
||||
}
|
||||
|
||||
func (j *Job) Start(ctx context.Context) {
|
||||
if j == nil || j.db == nil {
|
||||
return
|
||||
}
|
||||
go j.loop(ctx)
|
||||
}
|
||||
|
||||
func (j *Job) loop(ctx context.Context) {
|
||||
j.run(ctx, time.Now())
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
j.logger.Debug("临时文件归属清理任务已停止")
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
j.run(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) run(ctx context.Context, now time.Time) {
|
||||
release, ok := j.acquireLock(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
deleted, err := j.cleanup(ctx, now)
|
||||
if err != nil {
|
||||
j.logger.Warn("临时文件归属清理失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
j.logger.Info("已清理未关联临时文件归属", zap.Int("count", deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
||||
if j.redis == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
ok, err := j.redis.SetNX(ctx, cleanupLockKey, j.instanceID, 10*time.Minute).Result()
|
||||
if err != nil {
|
||||
j.logger.Warn("临时文件归属清理任务获取锁失败", zap.Error(err))
|
||||
return func() {}, true
|
||||
}
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
||||
if err := script.Run(releaseCtx, j.redis, []string{cleanupLockKey}, j.instanceID).Err(); err != nil {
|
||||
j.logger.Warn("临时文件归属清理任务释放锁失败", zap.Error(err))
|
||||
}
|
||||
}, true
|
||||
}
|
||||
|
||||
func (j *Job) cleanup(ctx context.Context, now time.Time) (int, error) {
|
||||
cutoff := now.Add(-cleanupRetention)
|
||||
lastID := uint64(0)
|
||||
deleted := 0
|
||||
for {
|
||||
var records []model.FileUploadOwner
|
||||
if err := j.db.WithContext(ctx).
|
||||
Where("id > ? AND created_at < ?", lastID, cutoff).
|
||||
Order("id ASC").
|
||||
Limit(cleanupBatchSize).
|
||||
Find(&records).Error; err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return deleted, nil
|
||||
}
|
||||
for _, record := range records {
|
||||
lastID = record.ID
|
||||
referenced, err := j.isReferenced(ctx, record.ObjectKey)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if referenced {
|
||||
continue
|
||||
}
|
||||
result := j.db.WithContext(ctx).
|
||||
Where("id = ? AND created_at < ?", record.ID, cutoff).
|
||||
Delete(&model.FileUploadOwner{})
|
||||
if result.Error != nil {
|
||||
return deleted, result.Error
|
||||
}
|
||||
deleted += int(result.RowsAffected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) isReferenced(ctx context.Context, key string) (bool, error) {
|
||||
encodedKey := url.QueryEscape(key)
|
||||
queries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{sql: "SELECT COUNT(1) FROM game_accounts WHERE INSTR(screenshot_urls, ?) > 0 OR INSTR(screenshot_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
{sql: "SELECT COUNT(1) FROM order_checkouts WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
{sql: "SELECT COUNT(1) FROM disputes WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
{sql: "SELECT COUNT(1) FROM handoff_records WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
{sql: "SELECT COUNT(1) FROM chat_messages WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
{sql: "SELECT COUNT(1) FROM user_payment_accounts WHERE INSTR(certificate_urls, ?) > 0 OR INSTR(certificate_urls, ?) > 0", args: []any{key, encodedKey}},
|
||||
}
|
||||
for _, query := range queries {
|
||||
var count int64
|
||||
if err := j.db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newInstanceID() string {
|
||||
value := make([]byte, 8)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return fmt.Sprintf("file-cleanup-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package fileuploadcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestCleanupOnlyDeletesExpiredUnreferencedUploads(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
createReferenceTables(t, db)
|
||||
now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
|
||||
old := now.Add(-31 * 24 * time.Hour)
|
||||
recent := now.Add(-29 * 24 * time.Hour)
|
||||
records := []model.FileUploadOwner{
|
||||
{UserID: 1, ObjectKey: "listing/orphan.jpg", CreatedAt: old},
|
||||
{UserID: 1, ObjectKey: "listing/referenced.jpg", CreatedAt: old},
|
||||
{UserID: 1, ObjectKey: "listing/recent.jpg", CreatedAt: recent},
|
||||
}
|
||||
if err := db.Create(&records).Error; err != nil {
|
||||
t.Fatalf("create upload owners error = %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO game_accounts (id, screenshot_urls) VALUES (1, ?)", `["/api/files/object?key=listing%2Freferenced.jpg"]`).Error; err != nil {
|
||||
t.Fatalf("create referenced account error = %v", err)
|
||||
}
|
||||
|
||||
job := New(db, nil, nil)
|
||||
deleted, err := job.cleanup(context.Background(), now)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup() error = %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("cleanup() deleted = %d, want 1", deleted)
|
||||
}
|
||||
var remaining []model.FileUploadOwner
|
||||
if err := db.Order("id ASC").Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("load remaining uploads error = %v", err)
|
||||
}
|
||||
if len(remaining) != 2 || remaining[0].ObjectKey != "listing/referenced.jpg" || remaining[1].ObjectKey != "listing/recent.jpg" {
|
||||
t.Fatalf("unexpected remaining uploads: %#v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func createReferenceTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
statements := []string{
|
||||
"CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, screenshot_urls TEXT)",
|
||||
"CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
|
||||
"CREATE TABLE disputes (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
|
||||
"CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
|
||||
"CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
|
||||
"CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, certificate_urls TEXT)",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("create reference table error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
|
||||
@@ -25,7 +25,6 @@ const (
|
||||
ContextAuthCurrentVersion = "auth_current_token_version"
|
||||
ContextAuthFailureDetail = "auth_failure_detail"
|
||||
AdminAccessCookieName = "hfb_admin_access"
|
||||
UserAccessCookieName = "hfb_user_access"
|
||||
)
|
||||
|
||||
type AdminTokenContext struct {
|
||||
@@ -35,6 +34,8 @@ type AdminTokenContext struct {
|
||||
|
||||
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
|
||||
|
||||
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
header := c.GetHeader("Authorization")
|
||||
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
@@ -44,20 +45,28 @@ func extractBearerToken(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
|
||||
func extractToken(c *gin.Context) string {
|
||||
if tokenText := extractBearerToken(c); tokenText != "" {
|
||||
return tokenText
|
||||
}
|
||||
return c.Query("token")
|
||||
}
|
||||
|
||||
func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
|
||||
var validate UserTokenValidatorFunc
|
||||
if len(validators) > 0 {
|
||||
validate = validators[0]
|
||||
func extractAdminToken(c *gin.Context) (string, string) {
|
||||
if tokenText := extractBearerToken(c); tokenText != "" {
|
||||
return tokenText, "bearer"
|
||||
}
|
||||
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||
if tokenText := strings.TrimSpace(cookieToken); tokenText != "" {
|
||||
return tokenText, "cookie"
|
||||
}
|
||||
}
|
||||
return "", "none"
|
||||
}
|
||||
|
||||
func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenText := extractBearerToken(c)
|
||||
if tokenText == "" {
|
||||
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
|
||||
tokenText = strings.TrimSpace(cookieToken)
|
||||
}
|
||||
}
|
||||
tokenText := extractToken(c)
|
||||
if tokenText == "" {
|
||||
response.Unauthorized(c, "缺少访问令牌")
|
||||
c.Abort()
|
||||
@@ -72,7 +81,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
|
||||
response.Unauthorized(c, "访问令牌无效或已过期")
|
||||
if errors.Is(err, auth.ErrDependencyUnavailable) {
|
||||
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
|
||||
} else {
|
||||
response.Unauthorized(c, "登录状态已失效,请重新登录")
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -86,16 +99,9 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
|
||||
|
||||
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenText := extractBearerToken(c)
|
||||
tokenSource := "bearer"
|
||||
tokenText, tokenSource := extractAdminToken(c)
|
||||
if tokenText == "" {
|
||||
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||
tokenText = strings.TrimSpace(cookieToken)
|
||||
tokenSource = "cookie"
|
||||
}
|
||||
}
|
||||
if tokenText == "" {
|
||||
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
|
||||
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
|
||||
response.Unauthorized(c, "缺少后台访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -10,31 +11,26 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUserAuthRejectsQueryToken(t *testing.T) {
|
||||
func TestAuthRejectsRevokedUserToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
|
||||
pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
|
||||
manager := auth.NewJWTManager("test-secret")
|
||||
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
|
||||
t.Fatalf("生成令牌失败:%v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/protected", Auth(manager), func(c *gin.Context) {
|
||||
engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error {
|
||||
return auth.ErrTokenVersionMismatch
|
||||
}), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
|
||||
queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
|
||||
queryResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(queryResponse, queryRequest)
|
||||
if queryResponse.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
bearerResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(bearerResponse, bearerRequest)
|
||||
if bearerResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type rateLimitKeyFunc func(c *gin.Context) string
|
||||
|
||||
type rateLimitBucket struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
@@ -32,6 +36,16 @@ type redisRateLimiter struct {
|
||||
}
|
||||
|
||||
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
|
||||
return rateLimitPerMinute(limit, rdb, ipRateLimitKey)
|
||||
}
|
||||
|
||||
// AdminAwareRateLimitPerMinute isolates authenticated admin traffic by admin ID.
|
||||
// Login and other requests without a valid admin access token fall back to IP.
|
||||
func AdminAwareRateLimitPerMinute(limit int, rdb *redis.Client, jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
return rateLimitPerMinute(limit, rdb, adminOrIPRateLimitKey(jwtManager))
|
||||
}
|
||||
|
||||
func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc) gin.HandlerFunc {
|
||||
if limit <= 0 {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
@@ -48,14 +62,15 @@ func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
|
||||
fallback: limiter,
|
||||
limit: limit,
|
||||
window: time.Minute,
|
||||
}).handle
|
||||
}).handle(keyFunc)
|
||||
}
|
||||
return limiter.handle
|
||||
return limiter.handle(keyFunc)
|
||||
}
|
||||
|
||||
func (l *redisRateLimiter) handle(c *gin.Context) {
|
||||
func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
now := time.Now()
|
||||
key := c.ClientIP()
|
||||
key := keyFunc(c)
|
||||
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
|
||||
if err != nil {
|
||||
allowed, resetAt = l.fallback.allow(key, now)
|
||||
@@ -65,17 +80,44 @@ func (l *redisRateLimiter) handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) handle(c *gin.Context) {
|
||||
func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
now := time.Now()
|
||||
key := c.ClientIP()
|
||||
key := keyFunc(c)
|
||||
allowed, resetAt := l.allow(key, now)
|
||||
if !allowed {
|
||||
writeRateLimited(c, now, resetAt)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ipRateLimitKey(c *gin.Context) string {
|
||||
return "ip:" + c.ClientIP()
|
||||
}
|
||||
|
||||
func adminOrIPRateLimitKey(jwtManager *auth.JWTManager) rateLimitKeyFunc {
|
||||
return func(c *gin.Context) string {
|
||||
if value, ok := c.Get(ContextAdminID); ok {
|
||||
if adminID, ok := value.(uint64); ok && adminID != 0 {
|
||||
return "admin:" + strconv.FormatUint(adminID, 10)
|
||||
}
|
||||
}
|
||||
if jwtManager != nil {
|
||||
tokenText, _ := extractAdminToken(c)
|
||||
if tokenText != "" {
|
||||
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
||||
if err == nil && claims.UserID != 0 {
|
||||
return "admin:" + strconv.FormatUint(claims.UserID, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ipRateLimitKey(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runRateLimitedRequest(router *gin.Engine, bearerToken string) int {
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
if bearerToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder.Code
|
||||
}
|
||||
|
||||
func newRateLimitRouter(limit int, rdb interface{}, handler gin.HandlerFunc) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(handler)
|
||||
router.GET("/ping", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func adminAccessToken(t *testing.T, jwtManager *auth.JWTManager, adminID uint64, phone string) string {
|
||||
t.Helper()
|
||||
pair, err := jwtManager.GenerateSubjectPairWithVersion(adminID, phone, "admin", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("生成后台令牌失败: %v", err)
|
||||
}
|
||||
return pair.AccessToken
|
||||
}
|
||||
|
||||
func TestAdminAwareRateLimitIsolatesAdminsBehindSharedIP(t *testing.T) {
|
||||
jwtManager := auth.NewJWTManager("test-secret")
|
||||
adminA := adminAccessToken(t, jwtManager, 101, "13800000001")
|
||||
adminB := adminAccessToken(t, jwtManager, 102, "13800000002")
|
||||
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if code := runRateLimitedRequest(router, adminA); code != http.StatusOK {
|
||||
t.Fatalf("客服A 第 %d 次请求 = %d, want 200", i+1, code)
|
||||
}
|
||||
}
|
||||
if code := runRateLimitedRequest(router, adminA); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("客服A 超额请求 = %d, want 429", code)
|
||||
}
|
||||
if code := runRateLimitedRequest(router, adminB); code != http.StatusOK {
|
||||
t.Fatalf("同出口IP的客服B 应不受客服A影响, got %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAwareRateLimitFallsBackToIP(t *testing.T) {
|
||||
jwtManager := auth.NewJWTManager("test-secret")
|
||||
userPair, err := jwtManager.GenerateSubjectPairWithVersion(201, "13900000001", "user", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("生成用户令牌失败: %v", err)
|
||||
}
|
||||
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
|
||||
t.Fatalf("匿名请求 第 %d 次 = %d, want 200", i+1, code)
|
||||
}
|
||||
}
|
||||
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("匿名请求超额 = %d, want 429", code)
|
||||
}
|
||||
// 用户令牌无法按 admin 身份解析,应与匿名共享同一个 IP 桶。
|
||||
if code := runRateLimitedRequest(router, userPair.AccessToken); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("用户令牌应回退到IP维度, got %d, want 429", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRateLimitPerMinuteKeepsIPKey(t *testing.T) {
|
||||
router := newRateLimitRouter(1, nil, RateLimitPerMinute(1, nil))
|
||||
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
|
||||
t.Fatalf("首次匿名请求 = %d, want 200", code)
|
||||
}
|
||||
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("第二次匿名请求 = %d, want 429", code)
|
||||
}
|
||||
}
|
||||
@@ -108,16 +108,25 @@ func isPaymentNotifyPath(path string) bool {
|
||||
}
|
||||
|
||||
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
|
||||
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
|
||||
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
|
||||
if status >= 500 || status == http.StatusTooManyRequests {
|
||||
return false
|
||||
}
|
||||
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
|
||||
if isChatSSEPath(path, route) {
|
||||
return true
|
||||
}
|
||||
if latencyMs >= slowRequestThresholdMs {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isChatSSEPath(path, route string) bool {
|
||||
return path == "/api/chats/events" || path == "/api/admin/chats/events" ||
|
||||
route == "/api/chats/events" || route == "/api/admin/chats/events"
|
||||
}
|
||||
|
||||
func meaningfulAuthFailure(c *gin.Context) bool {
|
||||
value, ok := c.Get(ContextAuthFailureReason)
|
||||
if !ok {
|
||||
|
||||
@@ -19,6 +19,10 @@ func TestShouldSkipHTTPLog(t *testing.T) {
|
||||
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
|
||||
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
|
||||
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
|
||||
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
|
||||
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
|
||||
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
|
||||
{name: "SSE 限流不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 429, latencyMs: 1, wantSkip: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -4,12 +4,23 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ChatConversation struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
|
||||
ListingID *uint64 `gorm:"index" json:"listing_id"`
|
||||
// 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。
|
||||
// 这样读取会话列表不必每次扫描全部订单再按商品分组。
|
||||
LatestOrderID *uint64 `gorm:"index" json:"-"`
|
||||
LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"`
|
||||
LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"`
|
||||
LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||
LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||
// 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。
|
||||
LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||
LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
||||
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
@@ -42,6 +53,23 @@ func (ChatParticipant) TableName() string {
|
||||
return "chat_participants"
|
||||
}
|
||||
|
||||
// ChatAdminConversationState stores per-admin state even when the admin is not
|
||||
// an assigned participant of the conversation.
|
||||
type ChatAdminConversationState struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"`
|
||||
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"`
|
||||
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
|
||||
LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"`
|
||||
LastReadAt *time.Time `json:"last_read_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ChatAdminConversationState) TableName() string {
|
||||
return "chat_admin_conversation_states"
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
|
||||
@@ -51,6 +79,7 @@ type ChatMessage struct {
|
||||
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
||||
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -58,6 +87,24 @@ func (ChatMessage) TableName() string {
|
||||
return "chat_messages"
|
||||
}
|
||||
|
||||
// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。
|
||||
func (m *ChatMessage) AfterCreate(tx *gorm.DB) error {
|
||||
if m.ID == 0 || m.ConversationID == 0 {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if m.AdminAttentionType != "" || m.SenderType == "user" {
|
||||
updates["last_attention_message_id"] = m.ID
|
||||
}
|
||||
if m.SenderType == "admin" {
|
||||
updates["last_admin_message_id"] = m.ID
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error
|
||||
}
|
||||
|
||||
type ChatQrCode struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ImageURL string `gorm:"size:512;not null" json:"image_url"`
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// FileUploadOwner 记录用户上传的私有文件归属,用于业务关联创建前的临时访问授权。
|
||||
type FileUploadOwner struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"not null;index"`
|
||||
ObjectKey string `gorm:"size:512;not null;uniqueIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (FileUploadOwner) TableName() string {
|
||||
return "file_upload_owners"
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RentalOrder struct {
|
||||
@@ -41,6 +42,8 @@ type RentalOrder struct {
|
||||
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
|
||||
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||
@@ -71,6 +74,24 @@ func (RentalOrder) TableName() string {
|
||||
return "rental_orders"
|
||||
}
|
||||
|
||||
// AfterSave 将商品关联发布群的最新订单快照一并更新。
|
||||
// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。
|
||||
func (o *RentalOrder) AfterSave(tx *gorm.DB) error {
|
||||
if o.ID == 0 || o.ListingID == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&ChatConversation{}).
|
||||
Where("listing_id = ? AND order_id IS NULL", o.ListingID).
|
||||
Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID).
|
||||
Updates(map[string]any{
|
||||
"latest_order_id": o.ID,
|
||||
"latest_order_no": o.OrderNo,
|
||||
"latest_order_status": o.Status,
|
||||
"latest_order_handoff_status": o.HandoffStatus,
|
||||
"latest_order_refund_status": o.RefundStatus,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
|
||||
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。
|
||||
// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。
|
||||
type ProcessEvent struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"`
|
||||
BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"`
|
||||
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
|
||||
Action string `gorm:"size:64;not null" json:"action"`
|
||||
ActorType string `gorm:"size:16;not null" json:"actor_type"`
|
||||
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
|
||||
ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"`
|
||||
TargetType string `gorm:"size:16;not null;default:''" json:"target_type"`
|
||||
TargetID *uint64 `json:"target_id,omitempty"`
|
||||
TargetName string `gorm:"size:128;not null;default:''" json:"target_name"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
Payload datatypes.JSON `gorm:"not null" json:"payload"`
|
||||
AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"`
|
||||
StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"`
|
||||
StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
func (ProcessEvent) TableName() string {
|
||||
return "process_events"
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type User struct {
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
|
||||
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -28,6 +28,8 @@ type ListingDailyOverviewDTO struct {
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
|
||||
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
@@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct {
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"`
|
||||
}
|
||||
|
||||
type ListingUploaderDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
UploaderID uint64 `json:"uploader_id"`
|
||||
UploaderName string `json:"uploader_name"`
|
||||
UploadCount int64 `json:"upload_count"`
|
||||
}
|
||||
|
||||
type PendingDTO struct {
|
||||
ListingReviews int64 `json:"listing_reviews"`
|
||||
Disputes int64 `json:"disputes"`
|
||||
|
||||
@@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
Scan(&events).Error; err != nil {
|
||||
return ListingDailyOverviewDTO{}, err
|
||||
}
|
||||
uploads := make([]listingUploaderRow, 0)
|
||||
if err := db.Table("listing_uploads").
|
||||
Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).
|
||||
Scan(&uploads).Error; err != nil {
|
||||
return ListingDailyOverviewDTO{}, err
|
||||
}
|
||||
|
||||
byDay := make(map[string]*listingDailyBucket, days)
|
||||
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
||||
byUploader := make(map[listingUploaderKey]int64)
|
||||
for _, ev := range events {
|
||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||
b := byDay[key]
|
||||
@@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
}
|
||||
addListingDailyEvent(cb, ev.EventType, ev.Source)
|
||||
}
|
||||
for _, upload := range uploads {
|
||||
key := upload.CreatedAt.In(loc).Format("2006-01-02")
|
||||
uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName}
|
||||
byUploader[uploaderKey]++
|
||||
}
|
||||
|
||||
trend := make([]ListingDayStatsDTO, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
@@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
todayStats = trend[len(trend)-1]
|
||||
}
|
||||
channelTrend := listingChannelTrend(byChannel)
|
||||
uploaderTrend := listingUploaderTrend(byUploader)
|
||||
todayChannels := make([]ListingChannelDayStatsDTO, 0)
|
||||
for _, item := range channelTrend {
|
||||
if item.Date == todayStats.Date {
|
||||
todayChannels = append(todayChannels, item)
|
||||
}
|
||||
}
|
||||
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
|
||||
for _, item := range uploaderTrend {
|
||||
if item.Date == todayStats.Date {
|
||||
todayUploaders = append(todayUploaders, item)
|
||||
}
|
||||
}
|
||||
return ListingDailyOverviewDTO{
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
TodayChannels: todayChannels,
|
||||
ChannelTrend: channelTrend,
|
||||
TodayUploaders: todayUploaders,
|
||||
UploaderTrend: uploaderTrend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type listingUploaderRow struct {
|
||||
UploaderID uint64
|
||||
UploaderName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type listingChannelKey struct {
|
||||
date string
|
||||
sourceChannel string
|
||||
}
|
||||
|
||||
type listingUploaderKey struct {
|
||||
date string
|
||||
uploaderID uint64
|
||||
uploaderName string
|
||||
}
|
||||
|
||||
type listingDailyBucket struct {
|
||||
published int64
|
||||
activeOffline int64
|
||||
@@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi
|
||||
return items
|
||||
}
|
||||
|
||||
func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO {
|
||||
keys := make([]listingUploaderKey, 0, len(rows))
|
||||
for key := range rows {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].date != keys[j].date {
|
||||
return keys[i].date < keys[j].date
|
||||
}
|
||||
if rows[keys[i]] != rows[keys[j]] {
|
||||
return rows[keys[i]] > rows[keys[j]]
|
||||
}
|
||||
if keys[i].uploaderName != keys[j].uploaderName {
|
||||
return keys[i].uploaderName < keys[j].uploaderName
|
||||
}
|
||||
return keys[i].uploaderID < keys[j].uploaderID
|
||||
})
|
||||
items := make([]ListingUploaderDayStatsDTO, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
items = append(items, ListingUploaderDayStatsDTO{
|
||||
Date: key.date,
|
||||
UploaderID: key.uploaderID,
|
||||
UploaderName: key.uploaderName,
|
||||
UploadCount: rows[key],
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func listingChannelRank(channel string) int {
|
||||
switch channel {
|
||||
case "咸鱼":
|
||||
|
||||
@@ -65,3 +65,44 @@ func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published,
|
||||
t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
|
||||
uploaderX := uint64(11)
|
||||
uploaderY := uint64(12)
|
||||
listingOne := uint64(101)
|
||||
listingTwo := uint64(102)
|
||||
listingThree := uint64(103)
|
||||
uploads := []model.ListingUpload{
|
||||
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now},
|
||||
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)},
|
||||
{UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)},
|
||||
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)},
|
||||
}
|
||||
if err := db.Create(&uploads).Error; err != nil {
|
||||
t.Fatalf("create uploads error = %v", err)
|
||||
}
|
||||
|
||||
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("listingDailyOverview() error = %v", err)
|
||||
}
|
||||
if len(overview.TodayUploaders) != 2 {
|
||||
t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders)
|
||||
}
|
||||
counts := make(map[string]int64)
|
||||
for _, item := range overview.TodayUploaders {
|
||||
counts[item.UploaderName] = item.UploadCount
|
||||
}
|
||||
if counts["小晴"] != 2 || counts["小美"] != 1 {
|
||||
t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts)
|
||||
}
|
||||
if len(overview.UploaderTrend) != 3 {
|
||||
t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,96 @@ package adminfinance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
)
|
||||
|
||||
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
|
||||
dailyItems, err := r.dailyItems(ctx, query)
|
||||
if cached := r.loadDashboardCache(ctx, query); cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
|
||||
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
|
||||
var (
|
||||
dailyItems []FinanceDailyDTO
|
||||
pickup *PickupSummaryDTO
|
||||
mohong *MohongSummaryDTO
|
||||
disbursement *DisbursementSummaryDTO
|
||||
operatingExpense *OperatingExpenseSummaryDTO
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, 5)
|
||||
run := func(fn func() error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fn(); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
run(func() error {
|
||||
var err error
|
||||
dailyItems, err = r.dailyItems(ctx, query)
|
||||
return err
|
||||
})
|
||||
run(func() error {
|
||||
var err error
|
||||
pickup, err = r.pickupSummary(ctx, query)
|
||||
return err
|
||||
})
|
||||
run(func() error {
|
||||
var err error
|
||||
mohong, err = r.mohongSummary(ctx, query)
|
||||
return err
|
||||
})
|
||||
run(func() error {
|
||||
var err error
|
||||
disbursement, err = r.disbursementSummary(ctx, query)
|
||||
return err
|
||||
})
|
||||
run(func() error {
|
||||
var err error
|
||||
operatingExpense, err = r.operatingExpenseSummary(ctx, query)
|
||||
return err
|
||||
})
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pickup, err := r.pickupSummary(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary, err := r.summary(ctx, query, pickup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mohong, err := r.mohongSummary(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
disbursement, err := r.disbursementSummary(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DashboardDTO{
|
||||
result := &DashboardDTO{
|
||||
Summary: *summary,
|
||||
DailyItems: dailyItems,
|
||||
PickupSummary: *pickup,
|
||||
MohongSummary: *mohong,
|
||||
DisbursementSummary: *disbursement,
|
||||
OperatingExpenseSummary: *operatingExpense,
|
||||
GeneratedAt: timeutil.ShanghaiNow(),
|
||||
}, nil
|
||||
}
|
||||
r.storeDashboardCache(ctx, query, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) {
|
||||
var summary OperatingExpenseSummaryDTO
|
||||
err := r.db.WithContext(ctx).Table("operating_expenses").
|
||||
Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count").
|
||||
Where("status = ?", "paid").
|
||||
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
|
||||
Scan(&summary).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
|
||||
@@ -103,6 +160,7 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*
|
||||
func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
var payment paymentSummaryRow
|
||||
originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query)
|
||||
if err := db.Table("payment_orders AS po").
|
||||
Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
|
||||
@@ -112,12 +170,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
|
||||
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT order_id, MAX(amount_cent) AS amount_cent
|
||||
FROM payment_orders
|
||||
WHERE biz_type IN ? AND status = 'paid'
|
||||
GROUP BY order_id
|
||||
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
|
||||
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments).
|
||||
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
|
||||
Scan(&payment).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -147,26 +200,25 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END), 0) AS offline_settlement_pending_count,
|
||||
COUNT(ro.id) AS settled_order_count`).
|
||||
COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0
|
||||
OR COALESCE(p.refunding_amount_cent, 0) > 0
|
||||
OR ro.refund_status = 'refunding'
|
||||
OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed'
|
||||
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
|
||||
ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
|
||||
END) >= ?
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END), 0) AS financial_exception_count,
|
||||
COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent).
|
||||
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
|
||||
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
|
||||
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
|
||||
Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)).
|
||||
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
|
||||
Scan(&settlement).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exceptionCount int64
|
||||
if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{
|
||||
DateType: "settled",
|
||||
StartDate: query.StartDate,
|
||||
EndDate: query.EndDate,
|
||||
})).
|
||||
Where("finance_status <> ?", financeStatusNormal).
|
||||
Where("finance_status <> ?", financeStatusOfflineSettlementPending).
|
||||
Count(&exceptionCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。
|
||||
var estimated estimatedIncomeRow
|
||||
if err := db.Table("rental_orders").
|
||||
@@ -209,7 +261,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
|
||||
SettledOrderCount: settlement.SettledOrderCount,
|
||||
PendingSettleOrderCount: estimated.PendingSettleOrderCount,
|
||||
OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount,
|
||||
FinancialExceptionCount: exceptionCount,
|
||||
FinancialExceptionCount: settlement.FinancialExceptionCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -251,7 +303,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
END), 0) AS offline_settlement_pending_count,
|
||||
COUNT(ro.id) AS settled_order_count`).
|
||||
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
|
||||
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
|
||||
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
|
||||
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
|
||||
Group("DATE(ro.settled_at)").
|
||||
Scan(&settlements).Error; err != nil {
|
||||
@@ -303,6 +355,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemsByDate := make(map[string]FinanceDailyDTO)
|
||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
||||
@@ -392,6 +448,14 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
|
||||
itemsByDate[date] = item
|
||||
}
|
||||
for _, row := range operatingExpenses {
|
||||
date := dailyDateKey(row.Date)
|
||||
item := itemsByDate[date]
|
||||
item.Date = date
|
||||
item.OperatingExpenseAmountCent = row.AmountCent
|
||||
item.OperatingExpenseCount = row.Count
|
||||
itemsByDate[date] = item
|
||||
}
|
||||
|
||||
items := make([]FinanceDailyDTO, 0, len(itemsByDate))
|
||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
||||
@@ -404,7 +468,8 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。
|
||||
func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) {
|
||||
rows := make([]dailyPaymentRow, 0)
|
||||
err := r.db.WithContext(ctx).Table("payment_orders AS po").
|
||||
db := r.db.WithContext(ctx)
|
||||
err := db.Table("payment_orders AS po").
|
||||
Select(`DATE(po.created_at) AS date,
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
|
||||
@@ -415,12 +480,7 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count,
|
||||
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`,
|
||||
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT order_id, MAX(amount_cent) AS amount_cent
|
||||
FROM payment_orders
|
||||
WHERE biz_type IN ? AND status = 'paid'
|
||||
GROUP BY order_id
|
||||
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
|
||||
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)).
|
||||
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
|
||||
Group("DATE(po.created_at)").
|
||||
Scan(&rows).Error
|
||||
@@ -477,6 +537,7 @@ type settlementSummaryRow struct {
|
||||
OfflineSettlementAmountCent int64
|
||||
OfflineSettlementPendingAmountCent int64
|
||||
OfflineSettlementPendingCount int64
|
||||
FinancialExceptionCount int64
|
||||
SettledOrderCount int64
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dashboardCacheTTL = time.Minute
|
||||
|
||||
func (r *Repository) dashboardCacheKey(query DashboardQuery) string {
|
||||
return fmt.Sprintf("admin-finance:dashboard:v1:%d:%d", query.StartDate.Unix(), query.EndDate.Unix())
|
||||
}
|
||||
|
||||
func (r *Repository) loadDashboardCache(ctx context.Context, query DashboardQuery) *DashboardDTO {
|
||||
if r.redis == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := r.redis.Get(ctx, r.dashboardCacheKey(query)).Bytes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value DashboardDTO
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func (r *Repository) storeDashboardCache(ctx context.Context, query DashboardQuery, value *DashboardDTO) {
|
||||
if r.redis == nil || value == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 缓存不可用时静默降级到实时统计,不能影响财务页面可用性。
|
||||
_ = r.redis.Set(ctx, r.dashboardCacheKey(query), raw, dashboardCacheTTL).Err()
|
||||
}
|
||||
@@ -240,3 +240,87 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) {
|
||||
t.Fatalf("调整日汇总 = %+v", adjusted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE payment_orders (
|
||||
id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, biz_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL, amount_cent INTEGER NOT NULL, created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE rental_orders (
|
||||
id INTEGER PRIMARY KEY, settled_at DATETIME NULL, settlement_mode TEXT NOT NULL,
|
||||
offline_settlement_amount_cent INTEGER NOT NULL, offline_settlement_status TEXT NOT NULL,
|
||||
refund_status TEXT NOT NULL, settlement_status TEXT NOT NULL, status TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL, platform_fee_cent INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE order_checkouts (
|
||||
id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, status TEXT NOT NULL,
|
||||
platform_fee_cent INTEGER NOT NULL, owner_income_amount_cent INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE wallet_ledger (
|
||||
id INTEGER PRIMARY KEY, order_id INTEGER NULL, direction TEXT NOT NULL,
|
||||
biz_type TEXT NOT NULL, amount_cent INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE admin_pickups (
|
||||
id INTEGER PRIMARY KEY, status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL,
|
||||
profit_amount_cent INTEGER NOT NULL, completed_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE admin_pickup_financial_adjustments (
|
||||
id INTEGER PRIMARY KEY, pickup_id INTEGER NOT NULL, settle_delta_cent INTEGER NOT NULL,
|
||||
profit_delta_cent INTEGER NOT NULL, created_at DATETIME NOT NULL
|
||||
)`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("创建统计测试表失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc)
|
||||
for _, row := range [][]any{
|
||||
{1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300},
|
||||
{2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200},
|
||||
} {
|
||||
if err := db.Exec(`INSERT INTO rental_orders
|
||||
(id, settled_at, settlement_mode, offline_settlement_amount_cent, offline_settlement_status,
|
||||
refund_status, settlement_status, status, created_at, platform_fee_cent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
|
||||
t.Fatalf("写入订单失败: %v", err)
|
||||
}
|
||||
}
|
||||
for _, row := range [][]any{{1, 1, "accepted", 300, 700}, {2, 2, "accepted", 200, 600}} {
|
||||
if err := db.Exec(`INSERT INTO order_checkouts
|
||||
(id, order_id, status, platform_fee_cent, owner_income_amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil {
|
||||
t.Fatalf("写入结账单失败: %v", err)
|
||||
}
|
||||
}
|
||||
for _, row := range [][]any{{1, 1, "in", "owner_income", 700}, {2, 2, "in", "owner_income", 500}} {
|
||||
if err := db.Exec(`INSERT INTO wallet_ledger (id, order_id, direction, biz_type, amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil {
|
||||
t.Fatalf("写入钱包流水失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
query := DashboardQuery{
|
||||
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
|
||||
EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc),
|
||||
}
|
||||
repo := NewRepository(db)
|
||||
pickup, err := repo.pickupSummary(t.Context(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("读取提号汇总失败: %v", err)
|
||||
}
|
||||
summary, err := repo.summary(t.Context(), query, pickup)
|
||||
if err != nil {
|
||||
t.Fatalf("读取财务汇总失败: %v", err)
|
||||
}
|
||||
if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 {
|
||||
t.Fatalf("普通订单数量或利润错误: %+v", summary)
|
||||
}
|
||||
if summary.FinancialExceptionCount != 1 {
|
||||
t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@ package adminfinance
|
||||
|
||||
import "context"
|
||||
|
||||
type dailyOperatingExpenseRow struct {
|
||||
Date string
|
||||
AmountCent int64
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) {
|
||||
rows := make([]dailyOperatingExpenseRow, 0)
|
||||
err := r.db.WithContext(ctx).Table("operating_expenses").
|
||||
Select(`DATE(occurred_at) AS date, COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
|
||||
Where("status = ?", "paid").
|
||||
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
|
||||
Group("DATE(occurred_at)").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
|
||||
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -81,42 +98,29 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var withdrawalPaid struct {
|
||||
AmountCent int64
|
||||
FeeAmountCent int64
|
||||
ActualAmountCent int64
|
||||
Count int64
|
||||
// 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。
|
||||
var withdrawal struct {
|
||||
PaidAmountCent int64
|
||||
PaidFeeAmountCent int64
|
||||
PaidActualAmountCent int64
|
||||
PaidCount int64
|
||||
PendingAmountCent int64
|
||||
PendingCount int64
|
||||
ReviewAmountCent int64
|
||||
ReviewCount int64
|
||||
}
|
||||
if err := db.Table("withdrawal_requests").
|
||||
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent,
|
||||
COALESCE(SUM(fee_cent), 0) AS fee_amount_cent,
|
||||
COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent,
|
||||
COUNT(id) AS count`).
|
||||
Where("status = ?", "completed").
|
||||
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
|
||||
Scan(&withdrawalPaid).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var withdrawalPending struct {
|
||||
ActualAmountCent int64
|
||||
Count int64
|
||||
}
|
||||
if err := db.Table("withdrawal_requests").
|
||||
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
|
||||
Where("status = ?", "processing").
|
||||
Scan(&withdrawalPending).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var withdrawalReview struct {
|
||||
ActualAmountCent int64
|
||||
Count int64
|
||||
}
|
||||
if err := db.Table("withdrawal_requests").
|
||||
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
|
||||
Where("status = ?", "pending").
|
||||
Scan(&withdrawalReview).Error; err != nil {
|
||||
if err := db.Table("withdrawal_requests").Select(`
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN fee_cent ELSE 0 END), 0) AS paid_fee_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN actual_amount_cent ELSE 0 END), 0) AS paid_actual_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`,
|
||||
query.StartDate, query.EndDate, query.StartDate, query.EndDate,
|
||||
query.StartDate, query.EndDate, query.StartDate, query.EndDate).
|
||||
Scan(&withdrawal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -133,22 +137,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
}
|
||||
|
||||
return &DisbursementSummaryDTO{
|
||||
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
|
||||
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count,
|
||||
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent,
|
||||
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count,
|
||||
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent,
|
||||
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count,
|
||||
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent,
|
||||
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount,
|
||||
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
|
||||
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
|
||||
OfflineSettlementPendingAmountCent: offlinePending.AmountCent,
|
||||
OfflineSettlementPendingCount: offlinePending.Count,
|
||||
WithdrawalAmountCent: withdrawalPaid.AmountCent,
|
||||
WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent,
|
||||
WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent,
|
||||
WithdrawalPaidCount: withdrawalPaid.Count,
|
||||
WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent,
|
||||
WithdrawalPendingCount: withdrawalPending.Count,
|
||||
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
|
||||
WithdrawalReviewCount: withdrawalReview.Count,
|
||||
WithdrawalAmountCent: withdrawal.PaidAmountCent,
|
||||
WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent,
|
||||
WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent,
|
||||
WithdrawalPaidCount: withdrawal.PaidCount,
|
||||
WithdrawalPendingAmountCent: withdrawal.PendingAmountCent,
|
||||
WithdrawalPendingCount: withdrawal.PendingCount,
|
||||
WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent,
|
||||
WithdrawalReviewCount: withdrawal.ReviewCount,
|
||||
ManualPaidAmountCent: manualPaid.AmountCent,
|
||||
ManualPaidCount: manualPaid.Count,
|
||||
}, nil
|
||||
|
||||
@@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
|
||||
ro.offline_settlement_remark AS remark,
|
||||
'' AS category,
|
||||
'' AS custom_category_name,
|
||||
'' AS voucher_url,
|
||||
NULL AS voided_at,
|
||||
NULL AS voided_by,
|
||||
@@ -125,6 +126,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
|
||||
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
|
||||
'' AS category,
|
||||
'' AS custom_category_name,
|
||||
'' AS voucher_url,
|
||||
NULL AS voided_at,
|
||||
NULL AS voided_by,
|
||||
@@ -160,6 +162,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
|
||||
md.remark,
|
||||
md.category,
|
||||
md.custom_category_name,
|
||||
md.voucher_url,
|
||||
md.voided_at,
|
||||
md.voided_by,
|
||||
@@ -227,6 +230,7 @@ type disbursementItemRow struct {
|
||||
OperatorName string
|
||||
Remark string
|
||||
Category string
|
||||
CustomCategoryName string
|
||||
VoucherURL string
|
||||
VoidedAt *time.Time
|
||||
VoidedBy *uint64
|
||||
@@ -271,6 +275,7 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
|
||||
OperatorName: r.OperatorName,
|
||||
Remark: r.Remark,
|
||||
Category: r.Category,
|
||||
CustomCategoryName: r.CustomCategoryName,
|
||||
VoucherURL: r.VoucherURL,
|
||||
VoidedAt: r.VoidedAt,
|
||||
VoidedBy: r.VoidedBy,
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
`CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`,
|
||||
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
|
||||
`CREATE TABLE manual_disbursements (
|
||||
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT,
|
||||
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT,
|
||||
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
|
||||
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
|
||||
void_reason TEXT, created_at DATETIME
|
||||
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO manual_disbursements
|
||||
(id, disbursement_no, category, payee_name, amount_cent, paid_at, remark,
|
||||
(id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark,
|
||||
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
|
||||
VALUES
|
||||
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
|
||||
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
|
||||
(20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
|
||||
(21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
|
||||
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
break
|
||||
}
|
||||
}
|
||||
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" {
|
||||
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" {
|
||||
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,24 @@ type DisbursementQuery struct {
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type OperatingExpenseQuery struct {
|
||||
Status string
|
||||
Category string
|
||||
Keyword string
|
||||
DateType string
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type DashboardDTO struct {
|
||||
Summary FinanceSummaryDTO `json:"summary"`
|
||||
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
||||
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
||||
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
|
||||
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
|
||||
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
@@ -101,6 +113,25 @@ type DisbursementListSummaryDTO struct {
|
||||
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
|
||||
}
|
||||
|
||||
type OperatingExpenseSummaryDTO struct {
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type OperatingExpenseListSummaryDTO struct {
|
||||
RecordCount int64 `json:"record_count"`
|
||||
PaidAmountCent int64 `json:"paid_amount_cent"`
|
||||
PaidCount int64 `json:"paid_count"`
|
||||
}
|
||||
|
||||
type OperatingExpenseListDTO struct {
|
||||
Items []OperatingExpenseDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Summary OperatingExpenseListSummaryDTO `json:"summary"`
|
||||
}
|
||||
|
||||
type DisbursementItemDTO struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID uint64 `json:"source_id"`
|
||||
@@ -127,6 +158,7 @@ type DisbursementItemDTO struct {
|
||||
OperatorName string `json:"operator_name"`
|
||||
Remark string `json:"remark"`
|
||||
Category string `json:"category"`
|
||||
CustomCategoryName string `json:"custom_category_name"`
|
||||
VoucherURL string `json:"voucher_url"`
|
||||
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||
@@ -136,6 +168,7 @@ type DisbursementItemDTO struct {
|
||||
|
||||
type CreateManualDisbursementRequest struct {
|
||||
Category string `json:"category"`
|
||||
CustomCategoryName string `json:"custom_category_name"`
|
||||
PayeeName string `json:"payee_name"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PaidAt time.Time `json:"paid_at"`
|
||||
@@ -151,6 +184,7 @@ type ManualDisbursementDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
DisbursementNo string `json:"disbursement_no"`
|
||||
Category string `json:"category"`
|
||||
CustomCategoryName string `json:"custom_category_name"`
|
||||
PayeeName string `json:"payee_name"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PaidAt time.Time `json:"paid_at"`
|
||||
@@ -166,6 +200,38 @@ type ManualDisbursementDTO struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateOperatingExpenseRequest struct {
|
||||
Category string `json:"category"`
|
||||
PayeeName string `json:"payee_name"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Remark string `json:"remark"`
|
||||
VoucherURL string `json:"voucher_url"`
|
||||
}
|
||||
|
||||
type VoidOperatingExpenseRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type OperatingExpenseDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
ExpenseNo string `json:"expense_no"`
|
||||
Category string `json:"category"`
|
||||
PayeeName string `json:"payee_name"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Remark string `json:"remark"`
|
||||
VoucherURL string `json:"voucher_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy uint64 `json:"created_by"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||
VoidedByName string `json:"voided_by_name"`
|
||||
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||
VoidReason string `json:"void_reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type FinanceSummaryDTO struct {
|
||||
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
|
||||
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
|
||||
@@ -230,6 +296,8 @@ type FinanceDailyDTO struct {
|
||||
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
|
||||
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
||||
ManualPaidCount int64 `json:"manual_paid_count"`
|
||||
OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"`
|
||||
OperatingExpenseCount int64 `json:"operating_expense_count"`
|
||||
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
|
||||
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
||||
}
|
||||
|
||||
@@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) OperatingExpenses(c *gin.Context) {
|
||||
query, ok := parseOperatingExpenseQuery(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.OperatingExpenses(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
writeFinanceError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
|
||||
adminID, ok := financeAdminID(c)
|
||||
if !ok {
|
||||
@@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateOperatingExpense(c *gin.Context) {
|
||||
adminID, ok := financeAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req CreateOperatingExpenseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "运营开支信息不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c))
|
||||
if err != nil {
|
||||
writeFinanceError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) VoidOperatingExpense(c *gin.Context) {
|
||||
adminID, ok := financeAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "运营开支记录 ID 不正确")
|
||||
return
|
||||
}
|
||||
var req VoidOperatingExpenseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请填写作废原因")
|
||||
return
|
||||
}
|
||||
item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
|
||||
if err != nil {
|
||||
writeFinanceError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
|
||||
start, end, ok := parseDateRange(c, 6)
|
||||
if !ok {
|
||||
@@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
||||
return query, true
|
||||
}
|
||||
|
||||
func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
|
||||
start, end, ok := parseDateRange(c, 29)
|
||||
if !ok {
|
||||
return OperatingExpenseQuery{}, false
|
||||
}
|
||||
query := OperatingExpenseQuery{
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
Category: strings.TrimSpace(c.Query("category")),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
DateType: c.DefaultQuery("date_type", "created"),
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
}
|
||||
if query.Status != "" && query.Status != "paid" && query.Status != "voided" {
|
||||
response.BadRequest(c, "运营开支状态不正确")
|
||||
return query, false
|
||||
}
|
||||
if query.DateType != "created" && query.DateType != "occurred" {
|
||||
response.BadRequest(c, "日期类型不正确")
|
||||
return query, false
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
return query, true
|
||||
}
|
||||
|
||||
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
today := timeutil.ShanghaiNow()
|
||||
@@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) {
|
||||
response.NotFound(c, "线下出款记录不存在")
|
||||
case errors.Is(err, ErrManualDisbursementNotPaid):
|
||||
response.BadRequest(c, "该线下出款记录已作废")
|
||||
case errors.Is(err, ErrInvalidOperatingExpense):
|
||||
response.BadRequest(c, "运营开支信息不正确")
|
||||
case errors.Is(err, ErrOperatingExpenseNotFound):
|
||||
response.NotFound(c, "运营开支记录不存在")
|
||||
case errors.Is(err, ErrOperatingExpenseNotPaid):
|
||||
response.BadRequest(c, "该运营开支记录已作废")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func normalRefundBizTypes() []string {
|
||||
// settledSettlementStatuses 是视为"已完成结算"的结算状态集合。
|
||||
// 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。
|
||||
func settledSettlementStatuses() []string {
|
||||
return []string{"settled", "closed"}
|
||||
return []string{"settled", "closed", "arbitrated"}
|
||||
}
|
||||
|
||||
// nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。
|
||||
|
||||
@@ -45,3 +45,15 @@ func TestRefundBizTypesIncludeMohongRefund(t *testing.T) {
|
||||
t.Fatal("refundBizTypes missing mohong_refund")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettledSettlementStatusesIncludeArbitrated(t *testing.T) {
|
||||
values := map[string]bool{}
|
||||
for _, item := range settledSettlementStatuses() {
|
||||
values[item] = true
|
||||
}
|
||||
for _, want := range []string{"settled", "closed", "arbitrated"} {
|
||||
if !values[want] {
|
||||
t.Fatalf("settledSettlementStatuses missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type manualDisbursementRecord struct {
|
||||
ID uint64
|
||||
DisbursementNo string
|
||||
Category string
|
||||
CustomCategoryName string
|
||||
PayeeName string
|
||||
AmountCent int64
|
||||
PaidAt time.Time
|
||||
@@ -50,6 +51,7 @@ func (r *Repository) CreateManualDisbursement(
|
||||
record := manualDisbursementRecord{
|
||||
DisbursementNo: disbursementNo,
|
||||
Category: req.Category,
|
||||
CustomCategoryName: req.CustomCategoryName,
|
||||
PayeeName: req.PayeeName,
|
||||
AmountCent: req.AmountCent,
|
||||
PaidAt: req.PaidAt,
|
||||
@@ -73,6 +75,7 @@ func (r *Repository) CreateManualDisbursement(
|
||||
Detail: map[string]any{
|
||||
"disbursement_no": disbursementNo,
|
||||
"category": req.Category,
|
||||
"custom_category_name": req.CustomCategoryName,
|
||||
"payee_name": req.PayeeName,
|
||||
"amount_cent": req.AmountCent,
|
||||
"paid_at": req.PaidAt,
|
||||
@@ -156,6 +159,7 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
|
||||
ID: row.ID,
|
||||
DisbursementNo: row.DisbursementNo,
|
||||
Category: row.Category,
|
||||
CustomCategoryName: row.CustomCategoryName,
|
||||
PayeeName: row.PayeeName,
|
||||
AmountCent: row.AmountCent,
|
||||
PaidAt: row.PaidAt,
|
||||
@@ -176,6 +180,7 @@ type manualDisbursementDetailRow struct {
|
||||
ID uint64
|
||||
DisbursementNo string
|
||||
Category string
|
||||
CustomCategoryName string
|
||||
PayeeName string
|
||||
AmountCent int64
|
||||
PaidAt time.Time
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||
statements := []string{
|
||||
`CREATE TABLE manual_disbursements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
|
||||
category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
|
||||
category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
|
||||
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
|
||||
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
|
||||
@@ -47,7 +47,8 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||
repo := NewRepository(db)
|
||||
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
|
||||
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
|
||||
Category: "operating_expense",
|
||||
Category: "custom",
|
||||
CustomCategoryName: "临时活动支出",
|
||||
PayeeName: "测试供应商",
|
||||
AmountCent: 8800,
|
||||
PaidAt: paidAt,
|
||||
@@ -57,7 +58,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建其他线下出款失败: %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" {
|
||||
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" {
|
||||
t.Fatalf("创建结果不正确: %+v", created)
|
||||
}
|
||||
|
||||
@@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManualDisbursementValidation(t *testing.T) {
|
||||
if !validManualDisbursementCategory("duoduo_deposit_refund") {
|
||||
t.Fatal("多多退押金分类应为有效分类")
|
||||
}
|
||||
if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") {
|
||||
t.Fatal("自定义分类名称校验不正确")
|
||||
}
|
||||
|
||||
service := NewService(&Repository{})
|
||||
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
|
||||
Category: "unknown",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type operatingExpenseRecord struct {
|
||||
ID uint64
|
||||
ExpenseNo string
|
||||
Category string
|
||||
PayeeName string
|
||||
AmountCent int64
|
||||
OccurredAt time.Time
|
||||
Remark string
|
||||
VoucherURL string
|
||||
Status string
|
||||
CreatedBy uint64
|
||||
VoidedBy *uint64
|
||||
VoidedAt *time.Time
|
||||
VoidReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (operatingExpenseRecord) TableName() string {
|
||||
return "operating_expenses"
|
||||
}
|
||||
|
||||
func (r *Repository) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
|
||||
expenseNo, err := newOperatingExpenseNo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := operatingExpenseRecord{
|
||||
ExpenseNo: expenseNo,
|
||||
Category: req.Category,
|
||||
PayeeName: req.PayeeName,
|
||||
AmountCent: req.AmountCent,
|
||||
OccurredAt: req.OccurredAt,
|
||||
Remark: req.Remark,
|
||||
VoucherURL: req.VoucherURL,
|
||||
Status: "paid",
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
id := record.ID
|
||||
return auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin",
|
||||
ActorID: adminID,
|
||||
Action: "operating_expense_create",
|
||||
BizType: "operating_expense",
|
||||
BizID: &id,
|
||||
Meta: meta,
|
||||
Detail: map[string]any{
|
||||
"expense_no": expenseNo,
|
||||
"category": req.Category,
|
||||
"payee_name": req.PayeeName,
|
||||
"amount_cent": req.AmountCent,
|
||||
"occurred_at": req.OccurredAt,
|
||||
"has_voucher": req.VoucherURL != "",
|
||||
},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.findOperatingExpense(ctx, record.ID)
|
||||
}
|
||||
|
||||
func (r *Repository) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var record operatingExpenseRecord
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrOperatingExpenseNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if record.Status != "paid" {
|
||||
return ErrOperatingExpenseNotPaid
|
||||
}
|
||||
now := timeutil.ShanghaiNow()
|
||||
if err := tx.Model(&record).Updates(map[string]any{
|
||||
"status": "voided",
|
||||
"voided_by": adminID,
|
||||
"voided_at": now,
|
||||
"void_reason": reason,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
bizID := record.ID
|
||||
return auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin",
|
||||
ActorID: adminID,
|
||||
Action: "operating_expense_void",
|
||||
BizType: "operating_expense",
|
||||
BizID: &bizID,
|
||||
Meta: meta,
|
||||
Detail: map[string]any{
|
||||
"expense_no": record.ExpenseNo,
|
||||
"amount_cent": record.AmountCent,
|
||||
"reason": reason,
|
||||
},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.findOperatingExpense(ctx, id)
|
||||
}
|
||||
|
||||
func (r *Repository) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
|
||||
var summary OperatingExpenseListSummaryDTO
|
||||
if err := r.applyOperatingExpenseFilters(r.db.WithContext(ctx).Table("operating_expenses AS oe"), query).
|
||||
Select(`COUNT(*) AS record_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count`).
|
||||
Scan(&summary).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]operatingExpenseDetailRow, 0, query.PageSize)
|
||||
orderColumn := "oe.created_at"
|
||||
if query.DateType == "occurred" {
|
||||
orderColumn = "oe.occurred_at"
|
||||
}
|
||||
if err := r.applyOperatingExpenseFilters(r.operatingExpenseDetailQuery(ctx), query).
|
||||
Order(orderColumn + " DESC").
|
||||
Order("oe.id DESC").
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OperatingExpenseDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO())
|
||||
}
|
||||
return &OperatingExpenseListDTO{Items: items, Total: summary.RecordCount, Page: query.Page, PageSize: query.PageSize, Summary: summary}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) findOperatingExpense(ctx context.Context, id uint64) (*OperatingExpenseDTO, error) {
|
||||
var row operatingExpenseDetailRow
|
||||
err := r.operatingExpenseDetailQuery(ctx).Where("oe.id = ?", id).Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrOperatingExpenseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := row.toDTO()
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) operatingExpenseDetailQuery(ctx context.Context) *gorm.DB {
|
||||
return r.db.WithContext(ctx).Table("operating_expenses AS oe").
|
||||
Select(`oe.*, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
|
||||
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
|
||||
Joins("LEFT JOIN admin_users AS creator ON creator.id = oe.created_by").
|
||||
Joins("LEFT JOIN admin_users AS voider ON voider.id = oe.voided_by")
|
||||
}
|
||||
|
||||
func (r *Repository) applyOperatingExpenseFilters(db *gorm.DB, query OperatingExpenseQuery) *gorm.DB {
|
||||
if query.Status != "" {
|
||||
db = db.Where("oe.status = ?", query.Status)
|
||||
}
|
||||
if query.Category != "" {
|
||||
db = db.Where("oe.category = ?", query.Category)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
db = db.Where("oe.expense_no LIKE ? OR oe.payee_name LIKE ? OR oe.remark LIKE ?", like, like, like)
|
||||
}
|
||||
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
|
||||
column := "oe.created_at"
|
||||
if query.DateType == "occurred" {
|
||||
column = "oe.occurred_at"
|
||||
}
|
||||
db = db.Where(column+" >= ? AND "+column+" <= ?", query.StartDate, query.EndDate)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
type operatingExpenseDetailRow struct {
|
||||
ID uint64
|
||||
ExpenseNo string
|
||||
Category string
|
||||
PayeeName string
|
||||
AmountCent int64
|
||||
OccurredAt time.Time
|
||||
Remark string
|
||||
VoucherURL string
|
||||
Status string
|
||||
CreatedBy uint64
|
||||
CreatedByName string
|
||||
VoidedBy *uint64
|
||||
VoidedByName string
|
||||
VoidedAt *time.Time
|
||||
VoidReason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (r operatingExpenseDetailRow) toDTO() OperatingExpenseDTO {
|
||||
return OperatingExpenseDTO{
|
||||
ID: r.ID, ExpenseNo: r.ExpenseNo, Category: r.Category, PayeeName: r.PayeeName,
|
||||
AmountCent: r.AmountCent, OccurredAt: r.OccurredAt, Remark: r.Remark, VoucherURL: r.VoucherURL,
|
||||
Status: r.Status, CreatedBy: r.CreatedBy, CreatedByName: r.CreatedByName, VoidedBy: r.VoidedBy,
|
||||
VoidedByName: r.VoidedByName, VoidedAt: r.VoidedAt, VoidReason: r.VoidReason, CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func newOperatingExpenseNo() (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("OE%s%s", timeutil.ShanghaiNow().Format("20060102150405"), strings.ToUpper(hex.EncodeToString(buf))), nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestOperatingExpenseCreateListAndVoid(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE operating_expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, expense_no TEXT NOT NULL UNIQUE, category TEXT NOT NULL,
|
||||
payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL,
|
||||
remark TEXT NOT NULL, voucher_url TEXT NOT NULL, status TEXT NOT NULL, created_by INTEGER NOT NULL,
|
||||
voided_by INTEGER, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
|
||||
`CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER, action TEXT, biz_type TEXT,
|
||||
biz_id INTEGER, ip TEXT, user_agent TEXT, detail BLOB, created_at DATETIME
|
||||
)`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("创建测试表失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES (7, '运营甲', 'ops_a'), (8, '运营乙', 'ops_b')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := NewRepository(db)
|
||||
occurredAt := time.Date(2026, 8, 20, 10, 30, 0, 0, timeutil.ShanghaiLocation())
|
||||
created, err := repo.CreateOperatingExpense(t.Context(), CreateOperatingExpenseRequest{
|
||||
Category: "推广投放",
|
||||
PayeeName: "测试媒体",
|
||||
AmountCent: 12800,
|
||||
OccurredAt: occurredAt,
|
||||
Remark: "八月推广费用",
|
||||
VoucherURL: "/api/files/object?key=operating-expense%2Fvoucher.webp",
|
||||
}, 7, auditlog.Meta{RequestID: "req-create"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建运营开支失败: %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.ExpenseNo == "" || created.Status != "paid" || created.CreatedByName != "运营甲" {
|
||||
t.Fatalf("创建结果不正确: %+v", created)
|
||||
}
|
||||
|
||||
list, err := repo.OperatingExpenses(t.Context(), OperatingExpenseQuery{
|
||||
DateType: "occurred", StartDate: occurredAt.Add(-time.Hour), EndDate: occurredAt.Add(time.Hour), Page: 1, PageSize: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("查询运营开支失败: %v", err)
|
||||
}
|
||||
if list.Total != 1 || list.Summary.PaidAmountCent != 12800 || list.Items[0].Category != "推广投放" {
|
||||
t.Fatalf("运营开支列表不正确: %+v", list)
|
||||
}
|
||||
|
||||
voided, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复录入", 8, auditlog.Meta{RequestID: "req-void"})
|
||||
if err != nil {
|
||||
t.Fatalf("作废运营开支失败: %v", err)
|
||||
}
|
||||
if voided.Status != "voided" || voided.VoidedByName != "运营乙" || voided.VoidReason != "重复录入" {
|
||||
t.Fatalf("作废结果不正确: %+v", voided)
|
||||
}
|
||||
if _, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrOperatingExpenseNotPaid) {
|
||||
t.Fatalf("重复作废错误 = %v, want ErrOperatingExpenseNotPaid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingExpenseDashboardSummaryExcludesVoided(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE operating_expenses (
|
||||
id INTEGER PRIMARY KEY, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL, status TEXT NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建运营开支表失败: %v", err)
|
||||
}
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
inRange := time.Date(2026, 8, 20, 10, 0, 0, 0, loc)
|
||||
if err := db.Exec(`INSERT INTO operating_expenses (id, amount_cent, occurred_at, status) VALUES
|
||||
(1, 12000, ?, 'paid'), (2, 8000, ?, 'voided'), (3, 5000, ?, 'paid')`, inRange, inRange, inRange.AddDate(0, 0, -1)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary, err := NewRepository(db).operatingExpenseSummary(t.Context(), DashboardQuery{
|
||||
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
|
||||
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("运营开支仪表盘统计失败: %v", err)
|
||||
}
|
||||
if summary.AmountCent != 12000 || summary.Count != 1 {
|
||||
t.Fatalf("运营开支统计 = %+v, want 12000/1", summary)
|
||||
}
|
||||
daily, err := NewRepository(db).dailyOperatingExpenses(t.Context(), DashboardQuery{
|
||||
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
|
||||
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("每日运营开支统计失败: %v", err)
|
||||
}
|
||||
if len(daily) != 1 || daily[0].Date != "2026-08-20" || daily[0].AmountCent != 12000 || daily[0].Count != 1 {
|
||||
t.Fatalf("每日运营开支统计 = %+v, want 2026-08-20/12000/1", daily)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository {
|
||||
repo := &Repository{db: db}
|
||||
if len(redisClient) > 0 {
|
||||
repo.redis = redisClient[0]
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ var (
|
||||
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
|
||||
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
|
||||
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
|
||||
ErrInvalidOperatingExpense = errors.New("invalid operating expense")
|
||||
ErrOperatingExpenseNotFound = errors.New("operating expense not found")
|
||||
ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
|
||||
return s.repo.Disbursements(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
if query.PageSize > 100 {
|
||||
query.PageSize = 100
|
||||
}
|
||||
return s.repo.OperatingExpenses(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) CreateManualDisbursement(
|
||||
ctx context.Context,
|
||||
req CreateManualDisbursementRequest,
|
||||
@@ -74,10 +93,12 @@ func (s *Service) CreateManualDisbursement(
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
req.Category = strings.TrimSpace(req.Category)
|
||||
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
|
||||
req.PayeeName = strings.TrimSpace(req.PayeeName)
|
||||
req.Remark = strings.TrimSpace(req.Remark)
|
||||
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
|
||||
if !validManualDisbursementCategory(req.Category) ||
|
||||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
|
||||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
|
||||
req.AmountCent <= 0 || req.PaidAt.IsZero() ||
|
||||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
|
||||
@@ -104,15 +125,51 @@ func (s *Service) VoidManualDisbursement(
|
||||
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
|
||||
}
|
||||
|
||||
func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
req.Category = strings.TrimSpace(req.Category)
|
||||
req.PayeeName = strings.TrimSpace(req.PayeeName)
|
||||
req.Remark = strings.TrimSpace(req.Remark)
|
||||
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
|
||||
if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 ||
|
||||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
|
||||
req.AmountCent <= 0 || req.OccurredAt.IsZero() ||
|
||||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
|
||||
!validManualVoucherURL(req.VoucherURL) {
|
||||
return nil, ErrInvalidOperatingExpense
|
||||
}
|
||||
return s.repo.CreateOperatingExpense(ctx, req, adminID, meta)
|
||||
}
|
||||
|
||||
func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
|
||||
return nil, ErrInvalidOperatingExpense
|
||||
}
|
||||
return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta)
|
||||
}
|
||||
|
||||
func validManualDisbursementCategory(category string) bool {
|
||||
switch category {
|
||||
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
|
||||
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validManualDisbursementCustomCategoryName(category, name string) bool {
|
||||
if category != "custom" {
|
||||
return name == ""
|
||||
}
|
||||
return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50
|
||||
}
|
||||
|
||||
func validManualVoucherURL(raw string) bool {
|
||||
if raw == "" {
|
||||
return true
|
||||
|
||||
@@ -28,3 +28,44 @@ func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB {
|
||||
Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}).
|
||||
Group("order_id")
|
||||
}
|
||||
|
||||
// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。
|
||||
// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合,
|
||||
// 再使用 wallet_ledger.order_id 索引关联对应流水。
|
||||
func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
|
||||
return db.Table("rental_orders AS scoped_order").
|
||||
Select(`scoped_order.id AS order_id,
|
||||
COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`).
|
||||
Joins(`LEFT JOIN wallet_ledger AS wallet
|
||||
ON wallet.order_id = scoped_order.id
|
||||
AND wallet.direction = ?
|
||||
AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation"}).
|
||||
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
|
||||
Group("scoped_order.id")
|
||||
}
|
||||
|
||||
// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态,
|
||||
// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。
|
||||
func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
|
||||
return db.Table("rental_orders AS scoped_order").
|
||||
Select(`scoped_order.id AS order_id,
|
||||
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()).
|
||||
Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()).
|
||||
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
|
||||
Group("scoped_order.id")
|
||||
}
|
||||
|
||||
// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。
|
||||
// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。
|
||||
func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
|
||||
refundOrders := db.Table("payment_orders AS refund").
|
||||
Select("DISTINCT refund.order_id").
|
||||
Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded").
|
||||
Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate)
|
||||
return db.Table("payment_orders AS paid").
|
||||
Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent").
|
||||
Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders).
|
||||
Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid").
|
||||
Group("paid.order_id")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
encryptor crypto.Encryptor
|
||||
statusChangeNotifier func(userID uint64)
|
||||
}
|
||||
|
||||
type AuditMeta = auditlog.Meta
|
||||
@@ -33,6 +34,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
|
||||
return &Repository{db: db, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
|
||||
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
|
||||
r.statusChangeNotifier = notifier
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
|
||||
if err != nil {
|
||||
@@ -207,6 +213,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
|
||||
}
|
||||
|
||||
func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
|
||||
statusChanged := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
@@ -214,12 +221,13 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
||||
}
|
||||
beforeStatus := user.Status
|
||||
beforeRisk := user.RiskStatus
|
||||
beforeTokenVersion := user.TokenVersion
|
||||
user.Status = status
|
||||
user.RiskStatus = riskStatus
|
||||
if user.TokenVersion < 1 {
|
||||
user.TokenVersion = 1
|
||||
}
|
||||
if beforeStatus != status {
|
||||
user.TokenVersion++
|
||||
statusChanged = true
|
||||
}
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -230,11 +238,16 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
||||
"after_status": status,
|
||||
"before_risk_status": beforeRisk,
|
||||
"after_risk_status": riskStatus,
|
||||
"before_token_version": beforeTokenVersion,
|
||||
"after_token_version": user.TokenVersion,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if statusChanged && r.statusChangeNotifier != nil {
|
||||
r.statusChangeNotifier(userID)
|
||||
}
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,38 @@ func TestAdjustWalletInsufficientBalance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 0}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
|
||||
repo := NewRepository(db)
|
||||
got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("冻结用户失败:%v", err)
|
||||
}
|
||||
var saved model.User
|
||||
if err := db.First(&saved, user.ID).Error; err != nil {
|
||||
t.Fatalf("查询冻结用户失败:%v", err)
|
||||
}
|
||||
if got.Status != "frozen" || saved.TokenVersion != 1 {
|
||||
t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/1", got.Status, saved.TokenVersion)
|
||||
}
|
||||
|
||||
got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("解冻用户失败:%v", err)
|
||||
}
|
||||
if err := db.First(&saved, user.ID).Error; err != nil {
|
||||
t.Fatalf("查询解冻用户失败:%v", err)
|
||||
}
|
||||
if got.Status != "active" || saved.TokenVersion != 2 {
|
||||
t.Fatalf("解冻后状态/版本 = %s/%d, want active/2", got.Status, saved.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。
|
||||
func (s *Service) SetSessionRevoker(revoker func(userID uint64)) {
|
||||
if s.repo != nil {
|
||||
s.repo.SetStatusChangeNotifier(revoker)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -111,7 +111,6 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAccessCookie(c, result.Tokens.AccessToken)
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -134,10 +133,13 @@ func (h *Handler) Refresh(c *gin.Context) {
|
||||
}
|
||||
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDependencyUnavailable) {
|
||||
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
|
||||
return
|
||||
}
|
||||
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||
return
|
||||
}
|
||||
setAccessCookie(c, tokens.AccessToken)
|
||||
response.OK(c, tokens)
|
||||
}
|
||||
|
||||
@@ -151,16 +153,6 @@ func (h *Handler) Refresh(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Router /auth/logout [post]
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
clearAccessCookie(c)
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
}
|
||||
|
||||
@@ -175,7 +167,6 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAccessCookie(c, result.Tokens.AccessToken)
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -190,7 +181,6 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAccessCookie(c, result.Tokens.AccessToken)
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -267,17 +257,3 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
userID, ok := val.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func setAccessCookie(c *gin.Context, token string) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
|
||||
}
|
||||
|
||||
func clearAccessCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(c *gin.Context) bool {
|
||||
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
@@ -32,14 +32,19 @@ func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, toke
|
||||
if r == nil || r.db == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
user, err := r.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
var user model.User
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("id, phone, status, token_version").
|
||||
First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion {
|
||||
if user.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
return user, nil
|
||||
if user.TokenVersion != tokenVersion {
|
||||
return nil, ErrTokenVersionMismatch
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
|
||||
@@ -61,7 +66,6 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
TokenVersion: 1,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
@@ -89,16 +93,7 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.
|
||||
}
|
||||
|
||||
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"password_hash": hash,
|
||||
"token_version": gorm.Expr("token_version + 1"),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。
|
||||
func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error {
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
|
||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
|
||||
@@ -111,7 +106,6 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
TokenVersion: 1,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var (
|
||||
ErrPasswordTooWeak = errors.New("password too weak")
|
||||
ErrLoginLocked = errors.New("login locked")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrTokenVersionMismatch = errors.New("user token version mismatch")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -180,7 +181,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
tokens, err := s.issueTokenPair(user)
|
||||
tokens, err := s.generateUserTokenPair(user)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
@@ -190,7 +191,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
|
||||
}
|
||||
|
||||
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
|
||||
if s.users == nil {
|
||||
if s.jwt == nil || s.users == nil {
|
||||
return TokenPair{}, ErrDependencyUnavailable
|
||||
}
|
||||
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
|
||||
@@ -201,18 +202,14 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenP
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
return s.issueTokenPair(user)
|
||||
return s.generateUserTokenPair(user)
|
||||
}
|
||||
|
||||
func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) {
|
||||
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, userID uint64) error {
|
||||
if s.users == nil {
|
||||
return ErrDependencyUnavailable
|
||||
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) {
|
||||
if s.jwt == nil {
|
||||
return TokenPair{}, ErrDependencyUnavailable
|
||||
}
|
||||
return s.users.RevokeTokens(ctx, userID)
|
||||
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
|
||||
}
|
||||
|
||||
func codeKey(phone string) string {
|
||||
@@ -282,7 +279,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
|
||||
|
||||
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
|
||||
tokens, err := s.issueTokenPair(user)
|
||||
tokens, err := s.generateUserTokenPair(user)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
@@ -387,7 +384,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
tokens, err := s.issueTokenPair(user)
|
||||
tokens, err := s.generateUserTokenPair(user)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUserTokenVersionRevokesAccessAndRefreshTokens(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败:%v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("数据库迁移失败:%v", err)
|
||||
}
|
||||
|
||||
user := model.User{Phone: "13900000001", Status: "active", TokenVersion: 0}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
repo := NewUserRepository(db)
|
||||
manager := NewJWTManager("test-secret")
|
||||
pair, err := manager.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("生成令牌失败:%v", err)
|
||||
}
|
||||
|
||||
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("有效令牌校验失败:%v", err)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"status": "frozen",
|
||||
"token_version": 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("冻结用户失败:%v", err)
|
||||
}
|
||||
|
||||
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
|
||||
if !errors.Is(err, ErrUserDisabled) {
|
||||
t.Fatalf("冻结用户校验错误 = %v, want %v", err, ErrUserDisabled)
|
||||
}
|
||||
|
||||
service := NewService(repo, nil, manager, nil, nil)
|
||||
_, err = service.RefreshToken(t.Context(), pair.RefreshToken)
|
||||
if !errors.Is(err, ErrUserDisabled) {
|
||||
t.Fatalf("冻结用户刷新错误 = %v, want %v", err, ErrUserDisabled)
|
||||
}
|
||||
|
||||
// 解冻后版本仍然不同,冻结前签发的 token 不能恢复使用。
|
||||
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"status": "active",
|
||||
"token_version": 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("解冻用户失败:%v", err)
|
||||
}
|
||||
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
|
||||
if !errors.Is(err, ErrTokenVersionMismatch) {
|
||||
t.Fatalf("旧令牌版本校验错误 = %v, want %v", err, ErrTokenVersionMismatch)
|
||||
}
|
||||
_, err = repo.FindActiveForToken(t.Context(), user.ID, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("新令牌版本校验失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package backupmonitor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Health string `json:"health"`
|
||||
Schedule struct {
|
||||
Enabled string `json:"enabled"`
|
||||
FullHour string `json:"full_hour"`
|
||||
FullMinute string `json:"full_minute"`
|
||||
BinlogIntervalMinutes string `json:"binlog_interval_minutes"`
|
||||
} `json:"schedule"`
|
||||
LastFull struct {
|
||||
LocalComplete string `json:"local_complete"`
|
||||
RemoteComplete string `json:"remote_complete"`
|
||||
} `json:"last_full"`
|
||||
LastBinlog struct {
|
||||
File string `json:"file"`
|
||||
RemoteDir string `json:"remote_dir"`
|
||||
} `json:"last_binlog"`
|
||||
LastJob struct {
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
At string `json:"at"`
|
||||
Message string `json:"message"`
|
||||
} `json:"last_job"`
|
||||
}
|
||||
|
||||
type Handler struct{ path string }
|
||||
|
||||
func NewHandler(path string) *Handler { return &Handler{path: path} }
|
||||
|
||||
func (h *Handler) Status(c *gin.Context) {
|
||||
content, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
response.Error(c, http.StatusServiceUnavailable, "backup_status_unavailable", "备份状态尚未生成")
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "backup_status_unavailable", "无法读取备份状态")
|
||||
return
|
||||
}
|
||||
var status Status
|
||||
if err := json.Unmarshal(content, &status); err != nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "backup_status_invalid", "备份状态文件格式无效")
|
||||
return
|
||||
}
|
||||
response.OK(c, status)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const adminChatCountsCacheTTL = 5 * time.Second
|
||||
|
||||
func (r *Repository) adminChatCountsCacheKey(principal Principal, filter, stage, keyword string) string {
|
||||
sum := sha256.Sum256([]byte(keyword))
|
||||
return fmt.Sprintf("admin-chat:counts:v2:%s:%d:%s:%s:%s", principal.Type, principal.ID, filter, stage, hex.EncodeToString(sum[:8]))
|
||||
}
|
||||
|
||||
func (r *Repository) loadAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string) *AdminConversationCountsDTO {
|
||||
if r.redis == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := r.redis.Get(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword)).Bytes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value AdminConversationCountsDTO
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func (r *Repository) storeAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string, value *AdminConversationCountsDTO) {
|
||||
if r.redis == nil || value == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = r.redis.Set(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword), raw, adminChatCountsCacheTTL).Err()
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupAdminListTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AdminUser{},
|
||||
&model.RentalListing{},
|
||||
&model.RentalOrder{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatAdminConversationState{},
|
||||
&model.ChatMessage{},
|
||||
); err != nil {
|
||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
|
||||
db := setupAdminListTestDB(t)
|
||||
repo := NewRepository(db, nil, nil)
|
||||
|
||||
admin := model.AdminUser{Username: "cs-1", Nickname: "客服一", Status: "active"}
|
||||
user := model.User{Phone: "13800000001", Nickname: "租客"}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("创建管理员失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{ListingNo: "L202608250001", OwnerID: user.ID, AccountID: 1, Status: "active"}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("创建商品失败: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "RO-CHAT-1", ListingID: listing.ID, AccountID: 1, OwnerID: user.ID, RenterID: user.ID, Status: "renting"}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
conversation := model.ChatConversation{OrderID: &order.ID, Type: ConversationTypeOrderGroup, Title: "订单群", Status: "active"}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("创建会话失败: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.Create(&[]model.ChatParticipant{
|
||||
{ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: user.ID, Role: "renter", JoinedAt: now},
|
||||
{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: now},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建参与人失败: %v", err)
|
||||
}
|
||||
system := model.ChatMessage{ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "欢迎", CreatedAt: now}
|
||||
userMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "user", SenderID: user.ID, SenderRole: "renter", ContentType: "text", Content: "请问?", CreatedAt: now.Add(time.Second)}
|
||||
if err := db.Create(&[]model.ChatMessage{system, userMessage}).Error; err != nil {
|
||||
t.Fatalf("创建消息失败: %v", err)
|
||||
}
|
||||
if err := db.Model(&conversation).Updates(map[string]interface{}{
|
||||
"last_message_id": userMessage.ID,
|
||||
"last_message_preview": userMessage.Content,
|
||||
"last_message_at": userMessage.CreatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("更新会话摘要失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ChatAdminConversationState{
|
||||
ConversationID: conversation.ID,
|
||||
AdminUserID: admin.ID,
|
||||
Remark: "客户一",
|
||||
LastReadMessageID: system.ID,
|
||||
LastReadAt: &system.CreatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建管理员状态失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询后台会话失败: %v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Items.([]ConversationDTO)) != 1 {
|
||||
t.Fatalf("会话数量 = total %d/items %d, want 1/1", result.Total, len(result.Items.([]ConversationDTO)))
|
||||
}
|
||||
item := result.Items.([]ConversationDTO)[0]
|
||||
if item.AdminRemark != "客户一" {
|
||||
t.Fatalf("管理员备注 = %q, want 客户一", item.AdminRemark)
|
||||
}
|
||||
if item.UnreadCount != 1 {
|
||||
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
|
||||
}
|
||||
if item.NeedsReply {
|
||||
t.Fatal("订单群用户消息不应因未回复停留在待处理")
|
||||
}
|
||||
counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询客服统计失败: %v", err)
|
||||
}
|
||||
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
|
||||
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
|
||||
}
|
||||
if err := db.Model(&conversation).Update("type", ConversationTypeGeneralSupport).Error; err != nil {
|
||||
t.Fatalf("切换咨询会话类型失败: %v", err)
|
||||
}
|
||||
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询咨询待处理会话失败: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("咨询未回复待处理数量 = %d, want 1", result.Total)
|
||||
}
|
||||
|
||||
adminMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "admin", SenderID: admin.ID, SenderRole: "support", ContentType: "text", Content: "客服回复", CreatedAt: now.Add(2 * time.Second)}
|
||||
if err := db.Create(&adminMessage).Error; err != nil {
|
||||
t.Fatalf("创建客服回复失败: %v", err)
|
||||
}
|
||||
if err := db.Model(&conversation).Updates(map[string]interface{}{
|
||||
"last_message_id": adminMessage.ID,
|
||||
"last_message_preview": adminMessage.Content,
|
||||
"last_message_at": adminMessage.CreatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("更新客服回复摘要失败: %v", err)
|
||||
}
|
||||
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询已回复待处理会话失败: %v", err)
|
||||
}
|
||||
if result.Total != 0 {
|
||||
t.Fatalf("客服回复后待处理数量 = %d, want 0", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAdminConversationsUsesListingOrderSnapshot(t *testing.T) {
|
||||
db := setupAdminListTestDB(t)
|
||||
repo := NewRepository(db, nil, nil)
|
||||
|
||||
admin := model.AdminUser{Username: "cs-snapshot", Nickname: "客服快照", Status: "active"}
|
||||
owner := model.User{Phone: "13800000011", Nickname: "号主"}
|
||||
renter := model.User{Phone: "13800000012", Nickname: "租客"}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("创建客服失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建号主失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("创建租客失败: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{ListingNo: "L202608250002", OwnerID: owner.ID, AccountID: 2, Status: "active"}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("创建商品失败: %v", err)
|
||||
}
|
||||
conversation := model.ChatConversation{ListingID: &listing.ID, Type: ConversationTypeListingGroup, Title: "发布群", Status: "active"}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("创建发布群失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ChatParticipant{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: time.Now()}).Error; err != nil {
|
||||
t.Fatalf("创建客服成员失败: %v", err)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "RO-SNAPSHOT-1", ListingID: listing.ID, AccountID: listing.AccountID,
|
||||
OwnerID: owner.ID, RenterID: renter.ID, Status: "pending_handoff", HandoffStatus: "pending_owner", RefundStatus: "none",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageHandoff, "")
|
||||
if err != nil {
|
||||
t.Fatalf("按交接阶段查询发布群失败: %v", err)
|
||||
}
|
||||
items := result.Items.([]ConversationDTO)
|
||||
if result.Total != 1 || len(items) != 1 {
|
||||
t.Fatalf("发布群数量 = total %d/items %d, want 1/1", result.Total, len(items))
|
||||
}
|
||||
if items[0].LatestOrderID == nil || *items[0].LatestOrderID != order.ID || items[0].LatestOrderStatus != "pending_handoff" {
|
||||
t.Fatalf("最新订单快照未生效: %+v", items[0])
|
||||
}
|
||||
|
||||
order.Status = "renting"
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("更新订单状态失败: %v", err)
|
||||
}
|
||||
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageRenting, "")
|
||||
if err != nil || result.Total != 1 {
|
||||
t.Fatalf("更新后使用中筛选失败: total=%d err=%v", result.Total, err)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,18 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var state model.ChatAdminConversationState
|
||||
if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
var unreadCount int64
|
||||
if err := db.Table("chat_messages AS cm").
|
||||
Where("cm.conversation_id = ?", conversation.ID).
|
||||
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
|
||||
Where("cm.id > ?", state.LastReadMessageID).
|
||||
Count(&unreadCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := ConversationDTO{
|
||||
ID: conversation.ID,
|
||||
OrderID: conversation.OrderID,
|
||||
@@ -62,11 +74,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
|
||||
Title: conversation.Title,
|
||||
Status: conversation.Status,
|
||||
Role: "admin", // 管理员角色
|
||||
AdminRemark: state.Remark,
|
||||
Participants: participants,
|
||||
LastMessageID: conversation.LastMessageID,
|
||||
LastMessagePreview: conversation.LastMessagePreview,
|
||||
LastMessageAt: conversation.LastMessageAt,
|
||||
UnreadCount: 0, // 管理员不计未读
|
||||
UnreadCount: unreadCount,
|
||||
CreatedAt: conversation.CreatedAt,
|
||||
UpdatedAt: conversation.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
|
||||
&model.RentalOrder{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatAdminConversationState{},
|
||||
&model.ChatMessage{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
|
||||
@@ -21,6 +21,7 @@ type ConversationDTO struct {
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
AdminRemark string `json:"admin_remark,omitempty"`
|
||||
Participants []ParticipantDTO `json:"participants,omitempty"`
|
||||
LastMessageID *uint64 `json:"last_message_id"`
|
||||
LastMessagePreview string `json:"last_message_preview"`
|
||||
@@ -29,6 +30,7 @@ type ConversationDTO struct {
|
||||
LastSenderID uint64 `json:"last_sender_id,omitempty"`
|
||||
LastSenderRole string `json:"last_sender_role,omitempty"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
NeedsReply bool `json:"needs_reply"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -65,6 +67,7 @@ type MessageDTO struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminCounts(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
principal := Principal{Type: "admin", ID: adminID}
|
||||
result, err := h.service.AdminConversationCounts(
|
||||
c.Request.Context(),
|
||||
principal,
|
||||
c.DefaultQuery("filter", "all"),
|
||||
c.DefaultQuery("stage", "all"),
|
||||
c.Query("keyword"),
|
||||
)
|
||||
if err != nil {
|
||||
writeChatError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminDetail(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
@@ -61,6 +82,19 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
|
||||
h.markRead(c, Principal{Type: "admin", ID: adminID})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMarkAllRead(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.MarkAllAdminConversationsRead(c.Request.Context(), adminID); err != nil {
|
||||
writeChatError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"read": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminTransfer(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -128,6 +128,7 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrQrCodeOCRUnavailable) {
|
||||
response.RecordError(c, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
|
||||
return
|
||||
}
|
||||
@@ -164,6 +165,7 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrQrCodeOCRUnavailable) {
|
||||
response.RecordError(c, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
|
||||
if handoffSupportID > 0 {
|
||||
message += ",卖号组客服已接入"
|
||||
}
|
||||
return sendSystemMessage(tx, conv.ID, message)
|
||||
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
|
||||
}
|
||||
|
||||
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
|
||||
@@ -248,6 +248,10 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
|
||||
}
|
||||
|
||||
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
|
||||
return sendSystemMessageWithAttention(tx, conversationID, content, "")
|
||||
}
|
||||
|
||||
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
|
||||
message := model.ChatMessage{
|
||||
ConversationID: conversationID,
|
||||
SenderType: "system",
|
||||
@@ -255,6 +259,7 @@ func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error
|
||||
ContentType: "system",
|
||||
Content: content,
|
||||
AttachmentURLS: emptyJSONList(),
|
||||
AdminAttentionType: attentionType,
|
||||
}
|
||||
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
|
||||
@@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []model.ChatMessage
|
||||
if err := query.Order("id ASC").
|
||||
if err := query.Order("id DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
|
||||
rows[left], rows[right] = rows[right], rows[left]
|
||||
}
|
||||
items, err := r.toMessageDTOs(ctx, principal, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,12 +104,21 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
||||
Content: req.Content,
|
||||
AttachmentURLS: encodeStringList(req.AttachmentURLS),
|
||||
}
|
||||
if principal.Type == "user" {
|
||||
message.AdminAttentionType = "user_inquiry"
|
||||
}
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
conversation.LastMessageID = &message.ID
|
||||
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
|
||||
conversation.LastMessageAt = &message.CreatedAt
|
||||
if message.AdminAttentionType != "" || message.SenderType == "user" {
|
||||
conversation.LastAttentionMessageID = message.ID
|
||||
}
|
||||
if message.SenderType == "admin" {
|
||||
conversation.LastAdminMessageID = message.ID
|
||||
}
|
||||
if err := tx.Save(&conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,6 +164,7 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
||||
ContentType: msg.ContentType,
|
||||
Content: msg.Content,
|
||||
AttachmentURLS: msg.AttachmentURLS,
|
||||
AdminAttentionType: msg.AdminAttentionType,
|
||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
@@ -166,22 +179,45 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
|
||||
now := time.Now()
|
||||
updated := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 管理员可以不是 participant,直接返回成功
|
||||
if principal.Type == "admin" {
|
||||
// 尝试查找 participant 记录,如果有就更新
|
||||
var participant model.ChatParticipant
|
||||
err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
|
||||
conversationID, principal.Type, principal.ID).First(&participant).Error
|
||||
if err == nil {
|
||||
// 有 participant 记录,更新已读时间
|
||||
updated = true
|
||||
return tx.Model(&participant).Update("last_read_at", now).Error
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
|
||||
return nil
|
||||
var conversation model.ChatConversation
|
||||
if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConversationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
lastMessageID := uint64(0)
|
||||
if conversation.LastMessageID != nil {
|
||||
lastMessageID = *conversation.LastMessageID
|
||||
}
|
||||
var existing model.ChatAdminConversationState
|
||||
err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID).
|
||||
First(&existing).Error
|
||||
if err == nil && existing.LastReadMessageID >= lastMessageID {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
state := model.ChatAdminConversationState{
|
||||
ConversationID: conversationID,
|
||||
AdminUserID: principal.ID,
|
||||
LastReadMessageID: lastMessageID,
|
||||
LastReadAt: &now,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"last_read_message_id": lastMessageID,
|
||||
"last_read_at": now,
|
||||
}),
|
||||
}).Create(&state).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通用户必须是 participant
|
||||
participant, err := r.findParticipant(tx, principal, conversationID, true)
|
||||
@@ -209,3 +245,31 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAdminConversationsRead advances one admin's read cursor for every conversation in one transaction.
|
||||
func (r *Repository) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先补齐状态行,再更新游标。拆成两步可兼容本地 MySQL/MariaDB 对
|
||||
// INSERT ... SELECT 同表读取并 ON DUPLICATE KEY UPDATE 的限制。
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO chat_admin_conversation_states
|
||||
(conversation_id, admin_user_id, remark, last_read_message_id, last_read_at)
|
||||
SELECT c.id, ?, COALESCE(existing.remark, ''), COALESCE(c.last_message_id, 0), ?
|
||||
FROM chat_conversations AS c
|
||||
LEFT JOIN chat_admin_conversation_states AS existing
|
||||
ON existing.conversation_id = c.id AND existing.admin_user_id = ?
|
||||
WHERE existing.id IS NULL`, adminID, now, adminID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`
|
||||
UPDATE chat_admin_conversation_states AS cas
|
||||
JOIN chat_conversations AS c ON c.id = cas.conversation_id
|
||||
SET cas.last_read_message_id = c.last_message_id,
|
||||
cas.last_read_at = ?,
|
||||
cas.updated_at = CURRENT_TIMESTAMP
|
||||
WHERE cas.admin_user_id = ?
|
||||
AND c.last_message_id IS NOT NULL
|
||||
AND cas.last_read_message_id < c.last_message_id`, now, adminID).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type conversationRow struct {
|
||||
Title string
|
||||
Status string
|
||||
Role string
|
||||
AdminRemark string
|
||||
LastMessageID *uint64
|
||||
LastMessagePreview string
|
||||
LastMessageAt *time.Time
|
||||
@@ -32,6 +33,7 @@ type conversationRow struct {
|
||||
LastSenderID uint64
|
||||
LastSenderRole string
|
||||
UnreadCount int64
|
||||
NeedsReply int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -44,6 +46,7 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
|
||||
SELECT COUNT(1)
|
||||
FROM chat_messages AS cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.sender_type <> 'system'
|
||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
|
||||
) AS unread_count`, principal.Type, principal.ID).
|
||||
@@ -59,6 +62,7 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa
|
||||
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
|
||||
Where("c.status = ?", "active").
|
||||
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID).
|
||||
Where("cm.sender_type <> ?", "system").
|
||||
Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
@@ -81,15 +85,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat
|
||||
return &participant, nil
|
||||
}
|
||||
func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) {
|
||||
grouped, err := r.participantsForConversations(ctx, []uint64{conversationID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grouped[conversationID], nil
|
||||
}
|
||||
|
||||
func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) {
|
||||
result := make(map[uint64][]ParticipantDTO, len(conversationIDs))
|
||||
if len(conversationIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []model.ChatParticipant
|
||||
if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("conversation_id IN ?", uniqueIDs(conversationIDs)).
|
||||
Order("conversation_id ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ParticipantDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := "系统"
|
||||
avatar := ""
|
||||
@@ -100,19 +118,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([
|
||||
if row.ParticipantType == "admin" {
|
||||
name = adminNames[row.ParticipantID]
|
||||
}
|
||||
items = append(items, ParticipantDTO{
|
||||
result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{
|
||||
ID: row.ID,
|
||||
ConversationID: row.ConversationID,
|
||||
ParticipantType: row.ParticipantType,
|
||||
ParticipantID: row.ParticipantID,
|
||||
Role: row.Role,
|
||||
Remark: row.Remark,
|
||||
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
|
||||
AvatarURL: avatar,
|
||||
LastReadAt: row.LastReadAt,
|
||||
JoinedAt: row.JoinedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
return result, nil
|
||||
}
|
||||
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
|
||||
userIDs := make([]uint64, 0)
|
||||
@@ -170,6 +189,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
||||
ContentType: row.ContentType,
|
||||
Content: row.Content,
|
||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||
AdminAttentionType: row.AdminAttentionType,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
@@ -305,6 +325,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
|
||||
Title: row.Title,
|
||||
Status: row.Status,
|
||||
Role: row.Role,
|
||||
AdminRemark: row.AdminRemark,
|
||||
Participants: participants,
|
||||
LastMessageID: row.LastMessageID,
|
||||
LastMessagePreview: row.LastMessagePreview,
|
||||
@@ -313,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
|
||||
LastSenderID: row.LastSenderID,
|
||||
LastSenderRole: row.LastSenderRole,
|
||||
UnreadCount: row.UnreadCount,
|
||||
NeedsReply: row.NeedsReply > 0,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -6,21 +6,46 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"hfb_sys/backend/internal/logging"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQrCodeOCRFileSize = 10 * 1024 * 1024
|
||||
maxQrCodeOCRPixels = 40_000_000
|
||||
maxQrCodeOCRSide = 2560
|
||||
qrCodeOCRJPEGQuality = 92
|
||||
|
||||
ocrRequestConcurrency = 2
|
||||
ocrRequestMinInterval = 250 * time.Millisecond
|
||||
ocrRequestMaxAttempts = 3
|
||||
ocrRetryBaseDelay = 500 * time.Millisecond
|
||||
ocrResponseBodyLimit = 2 * 1024 * 1024
|
||||
ocrResultBodyLimit = 10 * 1024 * 1024
|
||||
ocrLogBodyLimit = 2048
|
||||
|
||||
ocrJobRedisKeyPrefix = "ocr:job:"
|
||||
ocrJobRedisTTL = 10 * time.Minute
|
||||
@@ -39,8 +64,86 @@ var (
|
||||
ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis")
|
||||
|
||||
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
sharedOCRRequestGate = newOCRRequestGate(ocrRequestConcurrency, ocrRequestMinInterval)
|
||||
)
|
||||
|
||||
type ocrRequestGate struct {
|
||||
slots chan struct{}
|
||||
mu sync.Mutex
|
||||
nextAllowed time.Time
|
||||
minInterval time.Duration
|
||||
}
|
||||
|
||||
func newOCRRequestGate(concurrency int, minInterval time.Duration) *ocrRequestGate {
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
return &ocrRequestGate{
|
||||
slots: make(chan struct{}, concurrency),
|
||||
minInterval: max(0, minInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *ocrRequestGate) acquire(ctx context.Context, sleep func(context.Context, time.Duration) error) (func(), error) {
|
||||
select {
|
||||
case g.slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
release := func() { <-g.slots }
|
||||
g.mu.Lock()
|
||||
now := time.Now()
|
||||
wait := max(time.Duration(0), g.nextAllowed.Sub(now))
|
||||
startAt := now.Add(wait)
|
||||
g.nextAllowed = startAt.Add(g.minInterval)
|
||||
g.mu.Unlock()
|
||||
|
||||
if wait > 0 {
|
||||
if err := sleep(ctx, wait); err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
type ocrUpstreamError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
Code string
|
||||
Message string
|
||||
TraceID string
|
||||
RetryAfter time.Duration
|
||||
BodySnippet string
|
||||
Cause error
|
||||
Retriable bool
|
||||
}
|
||||
|
||||
func (e *ocrUpstreamError) Error() string {
|
||||
parts := []string{ErrQrCodeOCRUnavailable.Error(), "operation=" + e.Operation}
|
||||
if e.StatusCode != 0 {
|
||||
parts = append(parts, "status="+strconv.Itoa(e.StatusCode))
|
||||
}
|
||||
if e.Code != "" {
|
||||
parts = append(parts, "code="+e.Code)
|
||||
}
|
||||
if e.Message != "" {
|
||||
parts = append(parts, "message="+e.Message)
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
parts = append(parts, "trace_id="+e.TraceID)
|
||||
}
|
||||
if e.Cause != nil {
|
||||
parts = append(parts, "cause="+e.Cause.Error())
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (e *ocrUpstreamError) Unwrap() error {
|
||||
return ErrQrCodeOCRUnavailable
|
||||
}
|
||||
|
||||
type QrCodeOCRResult struct {
|
||||
GroupName string `json:"group_name"`
|
||||
Candidates []string `json:"candidates"`
|
||||
@@ -79,13 +182,46 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin
|
||||
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(data)
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
if contentType == "" || !strings.HasPrefix(contentType, "image/") {
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
|
||||
}
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
return data, contentType, nil
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxQrCodeOCRPixels {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
source, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
normalized := resizeOCRImage(source, maxQrCodeOCRSide)
|
||||
var buffer bytes.Buffer
|
||||
if err := jpeg.Encode(&buffer, normalized, &jpeg.Options{Quality: qrCodeOCRJPEGQuality}); err != nil {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
if buffer.Len() == 0 || buffer.Len() > maxQrCodeOCRFileSize {
|
||||
return nil, "", ErrQrCodeOCRInvalidFile
|
||||
}
|
||||
return buffer.Bytes(), "image/jpeg", nil
|
||||
}
|
||||
|
||||
func resizeOCRImage(source image.Image, maxSide int) image.Image {
|
||||
bounds := source.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return source
|
||||
}
|
||||
scale := math.Min(1, float64(maxSide)/float64(max(width, height)))
|
||||
targetWidth := max(1, int(math.Round(float64(width)*scale)))
|
||||
targetHeight := max(1, int(math.Round(float64(height)*scale)))
|
||||
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||
draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
|
||||
xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
|
||||
return target
|
||||
}
|
||||
|
||||
func ocrJobKey(jobID string) string {
|
||||
@@ -132,15 +268,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
|
||||
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID)
|
||||
jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawText, err := fetchPaddleOCRText(ctx, jsonURL)
|
||||
rawText, err := r.fetchPaddleOCRText(ctx, jsonURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,7 +309,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str
|
||||
return "", err
|
||||
}
|
||||
|
||||
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
|
||||
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -206,47 +342,48 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
|
||||
}
|
||||
|
||||
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID
|
||||
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "bearer "+config.Token)
|
||||
|
||||
resp, err := ocrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
// 网络错误不更新 Redis,保留当前状态让前端重试
|
||||
return record, nil
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return req, err
|
||||
})
|
||||
if err != nil {
|
||||
// 保留当前状态,让下一次前端轮询继续尝试。
|
||||
return record, nil
|
||||
}
|
||||
|
||||
var statusPayload paddleOCRJobStatusResponse
|
||||
if err := json.Unmarshal(respBody, &statusPayload); err != nil {
|
||||
upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err)
|
||||
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
switch statusPayload.Data.State {
|
||||
case "pending", "running":
|
||||
record.Status = ocrJobStatusRunning
|
||||
_ = r.saveOCRJobRecord(ctx, jobID, record)
|
||||
r.saveOCRJobRecordWithLog(ctx, jobID, record)
|
||||
return record, nil
|
||||
|
||||
case "done":
|
||||
if statusPayload.Data.ResultURL.JSONURL == "" {
|
||||
record.Status = ocrJobStatusFailed
|
||||
record.Error = "未返回识别结果地址"
|
||||
_ = r.saveOCRJobRecord(ctx, jobID, record)
|
||||
r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址",
|
||||
zap.String("operation", "status"),
|
||||
zap.String("paddle_job_id", record.PaddleJobID),
|
||||
zap.String("upstream_state", statusPayload.Data.State),
|
||||
)
|
||||
r.saveOCRJobRecordWithLog(ctx, jobID, record)
|
||||
return record, nil
|
||||
}
|
||||
rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
|
||||
rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
|
||||
if err != nil {
|
||||
record.Status = ocrJobStatusFailed
|
||||
record.Error = err.Error()
|
||||
_ = r.saveOCRJobRecord(ctx, jobID, record)
|
||||
r.saveOCRJobRecordWithLog(ctx, jobID, record)
|
||||
return record, nil
|
||||
}
|
||||
candidates := parseQrCodeGroupNameCandidates(rawText)
|
||||
@@ -260,7 +397,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
|
||||
Candidates: candidates,
|
||||
RawText: rawText,
|
||||
}
|
||||
_ = r.saveOCRJobRecord(ctx, jobID, record)
|
||||
r.saveOCRJobRecordWithLog(ctx, jobID, record)
|
||||
return record, nil
|
||||
|
||||
case "failed":
|
||||
@@ -270,14 +407,36 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
|
||||
} else {
|
||||
record.Error = "PaddleOCR 识别失败"
|
||||
}
|
||||
_ = r.saveOCRJobRecord(ctx, jobID, record)
|
||||
r.ocrLogger(ctx).Error("PaddleOCR 任务执行失败",
|
||||
zap.String("operation", "status"),
|
||||
zap.String("paddle_job_id", record.PaddleJobID),
|
||||
zap.String("upstream_state", statusPayload.Data.State),
|
||||
zap.String("upstream_message", truncateOCRLogValue(record.Error, 512)),
|
||||
)
|
||||
r.saveOCRJobRecordWithLog(ctx, jobID, record)
|
||||
return record, nil
|
||||
|
||||
default:
|
||||
upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state"))
|
||||
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
|
||||
func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) {
|
||||
if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil {
|
||||
r.ocrLogger(ctx).Error("OCR 任务状态保存失败",
|
||||
zap.String("ocr_job_id", jobID),
|
||||
zap.String("paddle_job_id", record.PaddleJobID),
|
||||
zap.String("ocr_status", record.Status),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
|
||||
filename = normalizeOCRFilename(filename)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("model", config.Model)
|
||||
@@ -301,51 +460,69 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename,
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
requestBody := append([]byte(nil), body.Bytes()...)
|
||||
formContentType := writer.FormDataContentType()
|
||||
respBody, err := r.doPaddleRequest(ctx, "submit", func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, bytes.NewReader(requestBody))
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "bearer "+config.Token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := ocrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
req.Header.Set("Content-Type", formContentType)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
|
||||
return req, err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload paddleOCRJobResponse
|
||||
if err := json.Unmarshal(respBody, &payload); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err)
|
||||
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
|
||||
return "", upstreamErr
|
||||
}
|
||||
if payload.Data.JobID == "" {
|
||||
return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable)
|
||||
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId"))
|
||||
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
|
||||
return "", upstreamErr
|
||||
}
|
||||
r.ocrLogger(ctx).Info("PaddleOCR 任务提交成功",
|
||||
zap.String("operation", "submit"),
|
||||
zap.String("paddle_job_id", payload.Data.JobID),
|
||||
zap.String("model", config.Model),
|
||||
zap.String("file_content_type", contentType),
|
||||
zap.Int("file_size", len(data)),
|
||||
)
|
||||
return payload.Data.JobID, nil
|
||||
}
|
||||
|
||||
func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
|
||||
func normalizeOCRFilename(filename string) string {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return "qrcode.jpg"
|
||||
}
|
||||
if dot := strings.LastIndex(filename, "."); dot > 0 {
|
||||
filename = filename[:dot]
|
||||
}
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
|
||||
for i := 0; i < 12; i++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID
|
||||
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "bearer "+config.Token)
|
||||
resp, err := ocrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
|
||||
return req, err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload paddleOCRJobStatusResponse
|
||||
if err := json.Unmarshal(respBody, &payload); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err)
|
||||
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
|
||||
return "", upstreamErr
|
||||
}
|
||||
switch payload.Data.State {
|
||||
case "done":
|
||||
@@ -359,36 +536,232 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string
|
||||
}
|
||||
return "", ErrQrCodeOCRUnavailable
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
|
||||
case <-time.After(2 * time.Second):
|
||||
if err := r.ocrSleep(ctx, 2*time.Second); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
|
||||
}
|
||||
|
||||
func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
|
||||
func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
|
||||
body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
resp, err := ocrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
return "", err
|
||||
}
|
||||
return collectPaddleOCRJSONLText(string(body)), nil
|
||||
}
|
||||
|
||||
type ocrRequestFactory func(context.Context) (*http.Request, error)
|
||||
|
||||
func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) {
|
||||
var lastErr *ocrUpstreamError
|
||||
for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ {
|
||||
release, err := r.ocrGate.acquire(ctx, r.ocrSleep)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
req, err := factory(ctx)
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
startedAt := time.Now()
|
||||
resp, requestErr := r.ocrHTTPClient.Do(req)
|
||||
duration := time.Since(startedAt)
|
||||
if requestErr != nil {
|
||||
release()
|
||||
lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr)
|
||||
lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr)
|
||||
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
|
||||
} else {
|
||||
bodyLimit := int64(ocrResponseBodyLimit)
|
||||
if operation == "result" {
|
||||
bodyLimit = ocrResultBodyLimit
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
_ = resp.Body.Close()
|
||||
release()
|
||||
if int64(len(body)) > bodyLimit {
|
||||
body = body[:bodyLimit]
|
||||
readErr = errors.New("upstream response exceeded limit")
|
||||
}
|
||||
if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
return body, nil
|
||||
}
|
||||
lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr)
|
||||
lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil
|
||||
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
|
||||
}
|
||||
|
||||
if !lastErr.Retriable || attempt == ocrRequestMaxAttempts {
|
||||
return nil, lastErr
|
||||
}
|
||||
delay := ocrRetryDelay(attempt, lastErr.RetryAfter)
|
||||
if err := r.ocrSleep(ctx, delay); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError {
|
||||
upstreamErr := &ocrUpstreamError{
|
||||
Operation: operation,
|
||||
StatusCode: statusCode,
|
||||
BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit),
|
||||
Cause: cause,
|
||||
}
|
||||
if headers != nil {
|
||||
upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now())
|
||||
upstreamErr.TraceID = firstNonEmpty(
|
||||
headers.Get("X-Request-ID"),
|
||||
headers.Get("X-Trace-ID"),
|
||||
headers.Get("Trace-ID"),
|
||||
)
|
||||
}
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code")
|
||||
upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message")
|
||||
if upstreamErr.TraceID == "" {
|
||||
upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id")
|
||||
}
|
||||
}
|
||||
return upstreamErr
|
||||
}
|
||||
|
||||
func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) {
|
||||
if upstreamErr == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("operation", upstreamErr.Operation),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.Bool("final", final),
|
||||
zap.Bool("retriable", upstreamErr.Retriable),
|
||||
zap.Int("upstream_status", upstreamErr.StatusCode),
|
||||
zap.String("upstream_code", upstreamErr.Code),
|
||||
zap.String("upstream_message", upstreamErr.Message),
|
||||
zap.String("upstream_trace_id", upstreamErr.TraceID),
|
||||
zap.String("upstream_body", upstreamErr.BodySnippet),
|
||||
zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()),
|
||||
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
|
||||
}
|
||||
if upstreamErr.Cause != nil {
|
||||
fields = append(fields, zap.Error(upstreamErr.Cause))
|
||||
}
|
||||
if final {
|
||||
r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...)
|
||||
} else {
|
||||
r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger {
|
||||
logger := r.logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
fields := []zap.Field{zap.String("module", "chat_ocr")}
|
||||
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
if adminID := logging.AdminIDFromContext(ctx); adminID != 0 {
|
||||
fields = append(fields, zap.Uint64("admin_id", adminID))
|
||||
}
|
||||
return logger.With(fields...)
|
||||
}
|
||||
|
||||
func isRetriableOCRStatus(status int) bool {
|
||||
switch status {
|
||||
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRetriableOCRNetworkError(ctx context.Context, err error) bool {
|
||||
return err != nil && ctx.Err() == nil
|
||||
}
|
||||
|
||||
func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration {
|
||||
if retryAfter > 0 {
|
||||
return min(retryAfter, 10*time.Second)
|
||||
}
|
||||
delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1))
|
||||
// Deterministic jitter avoids synchronized retries without relying on global randomness.
|
||||
jitter := time.Duration((attempt*137)%250) * time.Millisecond
|
||||
return delay + jitter
|
||||
}
|
||||
|
||||
func parseOCRRetryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if seconds, err := strconv.Atoi(value); err == nil {
|
||||
return max(0, time.Duration(seconds)*time.Second)
|
||||
}
|
||||
if when, err := http.ParseTime(value); err == nil {
|
||||
return max(0, when.Sub(now))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, duration time.Duration) error {
|
||||
if duration <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func firstJSONText(payload map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value, ok := payload[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return truncateOCRLogValue(text, 512)
|
||||
}
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return truncateOCRLogValue(value, 256)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateOCRLogValue(value string, limit int) string {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit] + "..."
|
||||
}
|
||||
|
||||
func collectPaddleOCRJSONLText(raw string) string {
|
||||
var texts []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func newOCRTestRepository(client *http.Client, logger *zap.Logger, sleep func(context.Context, time.Duration) error) *Repository {
|
||||
return NewRepository(nil, nil, nil,
|
||||
withOCRHTTPClient(client),
|
||||
withOCRGate(newOCRRequestGate(ocrRequestConcurrency, 0)),
|
||||
WithLogger(logger),
|
||||
withOCRSleep(sleep),
|
||||
)
|
||||
}
|
||||
|
||||
func TestDoPaddleRequestRetries429AndHonorsRetryAfter(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if requests.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "2")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"code":"rate_limit","message":"busy"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
var delays []time.Duration
|
||||
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(_ context.Context, delay time.Duration) error {
|
||||
delays = append(delays, delay)
|
||||
return nil
|
||||
})
|
||||
body, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Paddle 请求失败: %v", err)
|
||||
}
|
||||
if string(body) != "ok" || requests.Load() != 2 {
|
||||
t.Fatalf("body = %q, requests = %d", body, requests.Load())
|
||||
}
|
||||
if len(delays) != 1 || delays[0] != 2*time.Second {
|
||||
t.Fatalf("retry delays = %v, want [2s]", delays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoPaddleRequestDoesNotRetryDeterministic4xx(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = w.Write([]byte(`{"code":"invalid_format","message":"unsupported image"}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(context.Context, time.Duration) error { return nil })
|
||||
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
})
|
||||
var upstreamErr *ocrUpstreamError
|
||||
if !errors.As(err, &upstreamErr) {
|
||||
t.Fatalf("error = %v, want ocrUpstreamError", err)
|
||||
}
|
||||
if requests.Load() != 1 || upstreamErr.Retriable {
|
||||
t.Fatalf("requests = %d, retriable = %v", requests.Load(), upstreamErr.Retriable)
|
||||
}
|
||||
if upstreamErr.StatusCode != http.StatusUnsupportedMediaType || upstreamErr.Code != "invalid_format" {
|
||||
t.Fatalf("upstream error = %+v", upstreamErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoPaddleRequestLogsStructured502Failure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Trace-ID", "trace-502")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"code":"capacity","message":"upstream busy"}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
core, observed := observer.New(zap.DebugLevel)
|
||||
repo := newOCRTestRepository(server.Client(), zap.New(core), func(context.Context, time.Duration) error { return nil })
|
||||
|
||||
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
|
||||
req, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
if requestErr == nil {
|
||||
req.Header.Set("Authorization", "bearer secret-token")
|
||||
}
|
||||
return req, requestErr
|
||||
})
|
||||
var upstreamErr *ocrUpstreamError
|
||||
if !errors.As(err, &upstreamErr) {
|
||||
t.Fatalf("error = %v, want ocrUpstreamError", err)
|
||||
}
|
||||
if upstreamErr.StatusCode != http.StatusBadGateway || upstreamErr.TraceID != "trace-502" || upstreamErr.Code != "capacity" {
|
||||
t.Fatalf("upstream error = %+v", upstreamErr)
|
||||
}
|
||||
entries := observed.AllUntimed()
|
||||
if len(entries) != ocrRequestMaxAttempts {
|
||||
t.Fatalf("log entries = %d, want %d", len(entries), ocrRequestMaxAttempts)
|
||||
}
|
||||
fields := entries[len(entries)-1].ContextMap()
|
||||
if fields["final"] != true || fields["upstream_status"] != int64(http.StatusBadGateway) || fields["upstream_trace_id"] != "trace-502" {
|
||||
t.Fatalf("final log fields = %#v", fields)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Message, "secret-token") || strings.Contains(fmt.Sprint(entry.Context), "secret-token") {
|
||||
t.Fatal("OCR 日志泄露了 Authorization Token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAndValidateOCRFileNormalizesJPEGAndSize(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 3000, 1200))
|
||||
draw.Draw(source, source.Bounds(), &image.Uniform{C: color.NRGBA{R: 25, G: 80, B: 160, A: 255}}, image.Point{}, draw.Src)
|
||||
var input bytes.Buffer
|
||||
if err := png.Encode(&input, source); err != nil {
|
||||
t.Fatalf("编码测试图片失败: %v", err)
|
||||
}
|
||||
|
||||
data, contentType, err := readAndValidateOCRFile(&input, "image/png")
|
||||
if err != nil {
|
||||
t.Fatalf("归一化图片失败: %v", err)
|
||||
}
|
||||
if contentType != "image/jpeg" || len(data) < 2 || data[0] != 0xff || data[1] != 0xd8 {
|
||||
t.Fatalf("content_type = %q, signature = %x", contentType, data[:min(2, len(data))])
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("读取归一化图片失败: %v", err)
|
||||
}
|
||||
if format != "jpeg" || config.Width != 2560 || config.Height != 1024 {
|
||||
t.Fatalf("format = %q, size = %dx%d", format, config.Width, config.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAndValidateOCRFileRejectsExcessiveDecodedPixels(t *testing.T) {
|
||||
data := pngHeader(8001, 5000)
|
||||
config, _, decodeErr := image.DecodeConfig(bytes.NewReader(data))
|
||||
if decodeErr != nil || config.Width != 8001 || config.Height != 5000 {
|
||||
t.Fatalf("测试 PNG 头无效: config=%+v, error=%v", config, decodeErr)
|
||||
}
|
||||
_, _, err := readAndValidateOCRFile(bytes.NewReader(data), "image/png")
|
||||
if !errors.Is(err, ErrQrCodeOCRInvalidFile) {
|
||||
t.Fatalf("error = %v, want ErrQrCodeOCRInvalidFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCRRequestGateLimitsConcurrency(t *testing.T) {
|
||||
gate := newOCRRequestGate(2, 0)
|
||||
sleep := func(context.Context, time.Duration) error { return nil }
|
||||
releaseFirst, err := gate.acquire(t.Context(), sleep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseSecond, err := gate.acquire(t.Context(), sleep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
third := make(chan func(), 1)
|
||||
go func() {
|
||||
release, acquireErr := gate.acquire(t.Context(), sleep)
|
||||
if acquireErr == nil {
|
||||
third <- release
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case release := <-third:
|
||||
release()
|
||||
t.Fatal("第三个请求在并发槽释放前进入")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
releaseFirst()
|
||||
select {
|
||||
case release := <-third:
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("并发槽释放后第三个请求仍未进入")
|
||||
}
|
||||
releaseSecond()
|
||||
}
|
||||
|
||||
func TestNormalizeOCRFilenameUsesJPEGExtension(t *testing.T) {
|
||||
if got := normalizeOCRFilename("group.qrcode.webp"); got != "group.qrcode.jpg" {
|
||||
t.Fatalf("filename = %q", got)
|
||||
}
|
||||
if got := normalizeOCRFilename(""); got != "qrcode.jpg" {
|
||||
t.Fatalf("empty filename = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func pngHeader(width, height uint32) []byte {
|
||||
var result bytes.Buffer
|
||||
result.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
|
||||
data := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(data[0:4], width)
|
||||
binary.BigEndian.PutUint32(data[4:8], height)
|
||||
data[8] = 8
|
||||
data[9] = 2
|
||||
binary.Write(&result, binary.BigEndian, uint32(len(data)))
|
||||
result.WriteString("IHDR")
|
||||
result.Write(data)
|
||||
checksum := crc32.ChecksumIEEE(append([]byte("IHDR"), data...))
|
||||
binary.Write(&result, binary.BigEndian, checksum)
|
||||
return result.Bytes()
|
||||
}
|
||||
@@ -2,7 +2,12 @@ package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,9 +18,26 @@ const (
|
||||
)
|
||||
|
||||
func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.ChatParticipant{}).
|
||||
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).
|
||||
Update("remark", remark).Error
|
||||
if principal.Type != "admin" {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
db := r.db.WithContext(ctx)
|
||||
var conversation model.ChatConversation
|
||||
if err := db.Select("id").First(&conversation, conversationID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConversationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
state := model.ChatAdminConversationState{
|
||||
ConversationID: conversationID,
|
||||
AdminUserID: principal.ID,
|
||||
Remark: strings.TrimSpace(remark),
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}),
|
||||
}).Create(&state).Error
|
||||
}
|
||||
func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) {
|
||||
var replies []model.ChatQuickReply
|
||||
|
||||
@@ -1,27 +1,81 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/chathub"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
hub *chathub.Hub
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
ocrHTTPClient *http.Client
|
||||
ocrGate *ocrRequestGate
|
||||
ocrSleep func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSupportRoleCode = "cs"
|
||||
)
|
||||
|
||||
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository {
|
||||
return &Repository{db: db, hub: hub, redis: redis}
|
||||
type RepositoryOption func(*Repository)
|
||||
|
||||
func WithLogger(logger *zap.Logger) RepositoryOption {
|
||||
return func(repo *Repository) {
|
||||
if logger != nil {
|
||||
repo.logger = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withOCRHTTPClient(client *http.Client) RepositoryOption {
|
||||
return func(repo *Repository) {
|
||||
if client != nil {
|
||||
repo.ocrHTTPClient = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withOCRGate(gate *ocrRequestGate) RepositoryOption {
|
||||
return func(repo *Repository) {
|
||||
if gate != nil {
|
||||
repo.ocrGate = gate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withOCRSleep(sleep func(context.Context, time.Duration) error) RepositoryOption {
|
||||
return func(repo *Repository) {
|
||||
if sleep != nil {
|
||||
repo.ocrSleep = sleep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client, options ...RepositoryOption) *Repository {
|
||||
repo := &Repository{
|
||||
db: db,
|
||||
hub: hub,
|
||||
redis: redis,
|
||||
logger: zap.NewNop(),
|
||||
ocrHTTPClient: ocrHTTPClient,
|
||||
ocrGate: sharedOCRRequestGate,
|
||||
ocrSleep: sleepWithContext,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(repo)
|
||||
}
|
||||
return repo
|
||||
}
|
||||
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
|
||||
var existing model.ChatConversation
|
||||
|
||||
@@ -131,6 +131,16 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio
|
||||
return s.repo.MarkRead(ctx, principal, conversationID)
|
||||
}
|
||||
|
||||
func (s *Service) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if adminID == 0 {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return s.repo.MarkAllAdminConversationsRead(ctx, adminID)
|
||||
}
|
||||
|
||||
func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
@@ -155,10 +165,21 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
|
||||
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
|
||||
}
|
||||
|
||||
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
req.Remark = strings.TrimSpace(req.Remark)
|
||||
if len([]rune(req.Remark)) > 128 {
|
||||
return ErrInvalidMessage
|
||||
}
|
||||
return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/chathub"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct {
|
||||
}
|
||||
|
||||
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 验证当前操作者是会话参与者
|
||||
current, err := r.findParticipant(tx, principal, conversationID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Role != "support" || current.ParticipantType != "admin" {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if principal.Type != "admin" {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
var conversation model.ChatConversation
|
||||
if err := tx.First(&conversation, conversationID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConversationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
|
||||
if !adminIsSupport(tx, toAdminID) {
|
||||
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
|
||||
@@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
|
||||
if count > 0 {
|
||||
return fmt.Errorf("该客服已在会话中")
|
||||
}
|
||||
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
|
||||
if err := tx.Model(&model.ChatParticipant{}).
|
||||
Where("id = ?", current.ID).
|
||||
Updates(map[string]interface{}{
|
||||
var current model.ChatParticipant
|
||||
currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID).
|
||||
First(¤t).Error
|
||||
if currentErr == nil {
|
||||
if current.Role != "support" {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
// 只转接当前客服本人,避免误删同群里的其他客服。
|
||||
if err := tx.Model(¤t).Updates(map[string]interface{}{
|
||||
"participant_id": toAdminID,
|
||||
"joined_at": time.Now(),
|
||||
"last_read_at": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 添加系统消息记录转接
|
||||
message := model.ChatMessage{
|
||||
} else if errors.Is(currentErr, gorm.ErrRecordNotFound) {
|
||||
// 未分配会话允许从“全部/未分配”直接指派给目标客服。
|
||||
if err := tx.Create(&model.ChatParticipant{
|
||||
ConversationID: conversationID,
|
||||
SenderType: "system",
|
||||
SenderRole: "system",
|
||||
ContentType: "system",
|
||||
Content: "会话已转接给其他客服",
|
||||
AttachmentURLS: emptyJSONList(),
|
||||
ParticipantType: "admin",
|
||||
ParticipantID: toAdminID,
|
||||
Role: "support",
|
||||
JoinedAt: time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
} else {
|
||||
return currentErr
|
||||
}
|
||||
if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil && r.hub != nil {
|
||||
r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
|
||||
r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -202,11 +221,8 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
|
||||
}
|
||||
|
||||
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||
var total int64
|
||||
countDB := r.adminConversationBase(ctx, principal, keyword)
|
||||
applyAdminChatOwnershipFilter(countDB, filter, principal)
|
||||
applyAdminChatStageFilter(countDB, stage, principal)
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -216,16 +232,21 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
|
||||
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
||||
COALESCE(cp_me.role, 'admin') AS role,
|
||||
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
|
||||
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
|
||||
COALESCE(cas.remark, '') AS admin_remark,
|
||||
COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id,
|
||||
COALESCE(explicit_lo.order_no, c.latest_order_no) AS latest_order_no,
|
||||
COALESCE(explicit_lo.status, c.latest_order_status) AS latest_order_status,
|
||||
COALESCE(explicit_lo.handoff_status, c.latest_order_handoff_status) AS latest_handoff_status,
|
||||
COALESCE(explicit_lo.refund_status, c.latest_order_refund_status) AS latest_refund_status,
|
||||
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
|
||||
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
|
||||
CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply,
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
FROM chat_messages AS cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
|
||||
) END AS unread_count`, principal.Type, principal.ID)
|
||||
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
|
||||
AND cm.id > COALESCE(cas.last_read_message_id, 0)
|
||||
) AS unread_count`)
|
||||
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
||||
applyAdminChatStageFilter(queryDB, stage, principal)
|
||||
if err := queryDB.
|
||||
@@ -236,33 +257,59 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]uint64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
participantsByConversation, err := r.participantsForConversations(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ConversationDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
participants, err := r.participants(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, row.toDTO(participants))
|
||||
items = append(items, row.toDTO(participantsByConversation[row.ID]))
|
||||
}
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
filter = normalizeAdminChatFilter(filter)
|
||||
stage = normalizeAdminChatStage(stage)
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
|
||||
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO)
|
||||
return result.DTO, nil
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
|
||||
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
|
||||
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
|
||||
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
|
||||
var total int64
|
||||
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
||||
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
|
||||
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
|
||||
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
|
||||
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
||||
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
|
||||
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
|
||||
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
|
||||
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
|
||||
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
|
||||
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
|
||||
if keyword != "" {
|
||||
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
|
||||
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
|
||||
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ?
|
||||
OR renter.phone LIKE ? OR owner.phone LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM chat_participants AS cp_kw
|
||||
@@ -272,32 +319,54 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
|
||||
return db
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
counts := &AdminConversationCountsDTO{
|
||||
Ownership: make(map[string]int64, len(adminChatFilters)),
|
||||
Stages: make(map[string]int64, len(adminChatStages)),
|
||||
}
|
||||
type adminConversationCountsResult struct {
|
||||
Total int64
|
||||
DTO *AdminConversationCountsDTO
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) {
|
||||
ownership := make(map[string]string, len(adminChatFilters))
|
||||
ownershipArgs := make(map[string][]interface{}, len(adminChatFilters))
|
||||
for _, item := range adminChatFilters {
|
||||
db := r.adminConversationBase(ctx, principal, keyword)
|
||||
applyAdminChatOwnershipFilter(db, item, principal)
|
||||
applyAdminChatStageFilter(db, stage, principal)
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal)
|
||||
}
|
||||
counts.Ownership[item] = count
|
||||
stages := make(map[string]string, len(adminChatStages))
|
||||
stageArgs := make(map[string][]interface{}, len(adminChatStages))
|
||||
for _, item := range adminChatStages {
|
||||
stages[item], stageArgs[item] = adminChatStageCondition(item, principal)
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages))
|
||||
args := make([]interface{}, 0)
|
||||
addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) {
|
||||
parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias)
|
||||
args = append(args, leftArgs...)
|
||||
args = append(args, rightArgs...)
|
||||
}
|
||||
addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage])
|
||||
for _, item := range adminChatFilters {
|
||||
addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage])
|
||||
}
|
||||
for _, item := range adminChatStages {
|
||||
db := r.adminConversationBase(ctx, principal, keyword)
|
||||
applyAdminChatOwnershipFilter(db, filter, principal)
|
||||
applyAdminChatStageFilter(db, item, principal)
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item])
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Total int64
|
||||
OwnershipMine, OwnershipAll, OwnershipUnassigned int64
|
||||
StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64
|
||||
}
|
||||
var result row
|
||||
if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts.Stages[item] = count
|
||||
}
|
||||
return counts, nil
|
||||
return &adminConversationCountsResult{
|
||||
Total: result.Total,
|
||||
DTO: &AdminConversationCountsDTO{
|
||||
Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned},
|
||||
Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAdminChatFilter(filter string) string {
|
||||
@@ -319,55 +388,61 @@ func normalizeAdminChatStage(stage string) string {
|
||||
}
|
||||
|
||||
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
|
||||
switch filter {
|
||||
case adminChatFilterMine:
|
||||
db.Where("cp_me.id IS NOT NULL")
|
||||
case adminChatFilterUnassigned:
|
||||
db.Where(`NOT EXISTS (
|
||||
SELECT 1 FROM chat_participants AS cp_support
|
||||
WHERE cp_support.conversation_id = c.id
|
||||
AND cp_support.participant_type = ?
|
||||
AND cp_support.role = ?
|
||||
)`, "admin", "support")
|
||||
case adminChatFilterAll:
|
||||
return
|
||||
default:
|
||||
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
|
||||
}
|
||||
condition, args := adminChatOwnershipCondition(filter, principal)
|
||||
db.Where(condition, args...)
|
||||
}
|
||||
|
||||
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
|
||||
condition, args := adminChatStageCondition(stage, principal)
|
||||
db.Where(condition, args...)
|
||||
}
|
||||
|
||||
func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) {
|
||||
switch filter {
|
||||
case adminChatFilterAll:
|
||||
return "1 = 1", nil
|
||||
case adminChatFilterUnassigned:
|
||||
return `NOT EXISTS (
|
||||
SELECT 1 FROM chat_participants AS cp_support
|
||||
WHERE cp_support.conversation_id = c.id
|
||||
AND cp_support.participant_type = 'admin'
|
||||
AND cp_support.role = 'support'
|
||||
)`, nil
|
||||
case adminChatFilterMine:
|
||||
fallthrough
|
||||
default:
|
||||
return "cp_me.id IS NOT NULL", nil
|
||||
}
|
||||
}
|
||||
|
||||
func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) {
|
||||
latestID := "COALESCE(explicit_lo.id, c.latest_order_id)"
|
||||
latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)"
|
||||
latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)"
|
||||
switch stage {
|
||||
case adminChatStagePending:
|
||||
db.Where(`(
|
||||
lm.sender_type = ?
|
||||
OR (
|
||||
cp_me.id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_messages AS cm_pending
|
||||
WHERE cm_pending.conversation_id = c.id
|
||||
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
|
||||
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
|
||||
)
|
||||
)
|
||||
)`, "user", principal.Type, principal.ID)
|
||||
return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil
|
||||
case adminChatStageUnjoined:
|
||||
db.Where("lo.id IS NULL")
|
||||
return latestID + " IS NULL", nil
|
||||
case adminChatStageHandoff:
|
||||
db.Where("lo.status = ?", "pending_handoff")
|
||||
return latestStatus + " = 'pending_handoff'", nil
|
||||
case adminChatStageRenting:
|
||||
db.Where("lo.status IN ?", []string{"renting", "overdue"})
|
||||
return latestStatus + " IN ('renting', 'overdue')", nil
|
||||
case adminChatStageAfterSale:
|
||||
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
|
||||
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
|
||||
return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil
|
||||
case adminChatStageEnded:
|
||||
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
|
||||
return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil
|
||||
case adminChatStageAll:
|
||||
return
|
||||
fallthrough
|
||||
default:
|
||||
return "1 = 1", nil
|
||||
}
|
||||
}
|
||||
|
||||
func adminNeedsReplyExpression() string {
|
||||
return "c.last_attention_message_id > c.last_admin_message_id"
|
||||
}
|
||||
|
||||
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
|
||||
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
|
||||
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chathub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -8,16 +9,21 @@ import (
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const heartbeatInterval = 30 * time.Second
|
||||
|
||||
type Handler struct {
|
||||
hub *Hub
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewHandler(hub *Hub) *Handler {
|
||||
return &Handler{hub: hub}
|
||||
func NewHandler(hub *Hub, logger *zap.Logger) *Handler {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Handler{hub: hub, logger: logger}
|
||||
}
|
||||
|
||||
// UserEvents 处理用户端 SSE 连接: GET /api/chats/events
|
||||
@@ -51,8 +57,23 @@ func (h *Handler) AdminEvents(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
|
||||
startedAt := time.Now()
|
||||
disconnectReason := "handler_completed"
|
||||
var disconnectErr error
|
||||
ch := h.hub.Subscribe(pType, pID)
|
||||
defer h.hub.Unsubscribe(pType, pID, ch)
|
||||
h.logger.Info("SSE 连接建立", h.connectionLogFields(c, pType, pID)...)
|
||||
defer func() {
|
||||
h.hub.Unsubscribe(pType, pID, ch)
|
||||
fields := h.connectionLogFields(c, pType, pID)
|
||||
fields = append(fields,
|
||||
zap.String("disconnect_reason", disconnectReason),
|
||||
zap.Float64("lifetime_ms", float64(time.Since(startedAt).Microseconds())/1000),
|
||||
)
|
||||
if disconnectErr != nil {
|
||||
fields = append(fields, zap.Error(disconnectErr))
|
||||
}
|
||||
h.logger.Info("SSE 连接断开", fields...)
|
||||
}()
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -60,7 +81,11 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
// 发送初始连接确认
|
||||
fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n")
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n"); err != nil {
|
||||
disconnectReason = "initial_write_error"
|
||||
disconnectErr = err
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
|
||||
heartbeat := time.NewTicker(heartbeatInterval)
|
||||
@@ -71,17 +96,55 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
|
||||
for {
|
||||
select {
|
||||
case <-clientGone:
|
||||
disconnectReason = contextDisconnectReason(c.Request.Context().Err())
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprintf(c.Writer, ":heartbeat\n\n")
|
||||
if _, err := fmt.Fprintf(c.Writer, ":heartbeat\n\n"); err != nil {
|
||||
disconnectReason = "heartbeat_write_error"
|
||||
disconnectErr = err
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
case event, ok := <-ch:
|
||||
if !ok {
|
||||
disconnectReason = "subscription_closed"
|
||||
return
|
||||
}
|
||||
data := MarshalEvent(event)
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data)
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data); err != nil {
|
||||
disconnectReason = "event_write_error"
|
||||
disconnectErr = err
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) connectionLogFields(c *gin.Context, pType string, pID uint64) []zap.Field {
|
||||
fields := []zap.Field{
|
||||
zap.String("request_id", middleware.GetRequestID(c)),
|
||||
zap.String("principal_type", pType),
|
||||
zap.Uint64("principal_id", pID),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
zap.Int("active_sse_connections", h.hub.OnlineCount()),
|
||||
zap.Int("active_principal_type_connections", h.hub.OnlineCountByType(pType)),
|
||||
}
|
||||
if pType == "admin" {
|
||||
fields = append(fields, zap.Uint64("admin_id", pID))
|
||||
} else {
|
||||
fields = append(fields, zap.Uint64("user_id", pID))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func contextDisconnectReason(err error) string {
|
||||
switch err {
|
||||
case context.Canceled:
|
||||
return "context_canceled"
|
||||
case context.DeadlineExceeded:
|
||||
return "context_deadline_exceeded"
|
||||
default:
|
||||
return "context_closed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package chathub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestSSELifecycleLogsConnectionCountReasonAndLifetime(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
core, observed := observer.New(zap.InfoLevel)
|
||||
hub := NewHub(nil)
|
||||
handler := NewHandler(hub, zap.New(core))
|
||||
|
||||
requestContext, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/admin/chats/events", nil).WithContext(requestContext)
|
||||
request.RemoteAddr = "42.49.131.69:34567"
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = request
|
||||
ginContext.Set(middleware.ContextAdminID, uint64(88))
|
||||
ginContext.Set(middleware.ContextRequestID, "sse-test-request")
|
||||
|
||||
handler.AdminEvents(ginContext)
|
||||
|
||||
connected := observed.FilterMessage("SSE 连接建立").All()
|
||||
if len(connected) != 1 {
|
||||
t.Fatalf("connection logs = %d, want 1", len(connected))
|
||||
}
|
||||
connectedFields := connected[0].ContextMap()
|
||||
if connectedFields["admin_id"] != uint64(88) || connectedFields["active_sse_connections"] != int64(1) {
|
||||
t.Fatalf("unexpected connection fields: %v", connectedFields)
|
||||
}
|
||||
|
||||
disconnected := observed.FilterMessage("SSE 连接断开").All()
|
||||
if len(disconnected) != 1 {
|
||||
t.Fatalf("disconnection logs = %d, want 1", len(disconnected))
|
||||
}
|
||||
disconnectedFields := disconnected[0].ContextMap()
|
||||
if disconnectedFields["disconnect_reason"] != "context_canceled" {
|
||||
t.Fatalf("disconnect reason = %v, want context_canceled", disconnectedFields["disconnect_reason"])
|
||||
}
|
||||
if disconnectedFields["active_sse_connections"] != int64(0) {
|
||||
t.Fatalf("active connections after disconnect = %v, want 0", disconnectedFields["active_sse_connections"])
|
||||
}
|
||||
if _, ok := disconnectedFields["lifetime_ms"]; !ok {
|
||||
t.Fatalf("missing lifetime_ms field: %v", disconnectedFields)
|
||||
}
|
||||
if got := hub.OnlineCount(); got != 0 {
|
||||
t.Fatalf("online connection count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type MessageData struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -86,6 +87,22 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// DisconnectUser 关闭指定用户当前进程内的全部实时连接。
|
||||
func (h *Hub) DisconnectUser(userID uint64) {
|
||||
h.disconnect("user", userID)
|
||||
}
|
||||
|
||||
func (h *Hub) disconnect(pType string, pID uint64) {
|
||||
key := principal{Type: pType, ID: pID}
|
||||
h.mu.Lock()
|
||||
clients := h.clients[key]
|
||||
delete(h.clients, key)
|
||||
for ch := range clients {
|
||||
close(ch)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// NotifyConversation 查询会话参与者,向所有在线参与者推送事件。
|
||||
func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) {
|
||||
if h.db == nil {
|
||||
@@ -174,3 +191,16 @@ func (h *Hub) OnlineCount() int {
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// OnlineCountByType 返回当前进程内指定主体类型的 SSE 连接数。
|
||||
func (h *Hub) OnlineCountByType(pType string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
count := 0
|
||||
for key, clients := range h.clients {
|
||||
if key.Type == pType {
|
||||
count += len(clients)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package chathub
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisconnectUserClosesAllConnections(t *testing.T) {
|
||||
hub := NewHub(nil)
|
||||
first := hub.Subscribe("user", 7)
|
||||
second := hub.Subscribe("user", 7)
|
||||
hub.Subscribe("user", 8)
|
||||
hub.Subscribe("admin", 9)
|
||||
|
||||
hub.DisconnectUser(7)
|
||||
for name, ch := range map[string]<-chan *ChatEvent{"first": first, "second": second} {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if ok {
|
||||
t.Fatalf("%s connection was not closed", name)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("%s connection did not close immediately", name)
|
||||
}
|
||||
}
|
||||
if got := hub.OnlineCount(); got != 2 {
|
||||
t.Fatalf("online connection count = %d, want 2", got)
|
||||
}
|
||||
if got := hub.OnlineCountByType("user"); got != 1 {
|
||||
t.Fatalf("online user connection count = %d, want 1", got)
|
||||
}
|
||||
if got := hub.OnlineCountByType("admin"); got != 1 {
|
||||
t.Fatalf("online admin connection count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
"order_no": order.OrderNo,
|
||||
"result": req.Result,
|
||||
"remark": req.Remark,
|
||||
"input_amount_cent": req.AmountCent,
|
||||
"input_amount_cent": arbitrationInputAmountCent(req),
|
||||
"renter_refund_amount_cent": settlement.RenterRefundAmountCent,
|
||||
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
|
||||
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
|
||||
@@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or
|
||||
if err != nil {
|
||||
return settlement, nil, linkedCheckout, 0, err
|
||||
}
|
||||
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req)
|
||||
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req)
|
||||
if err != nil {
|
||||
return settlement, nil, linkedCheckout, 0, err
|
||||
}
|
||||
@@ -299,21 +299,24 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or
|
||||
return 0, ErrInvalidDispute
|
||||
}
|
||||
|
||||
func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) {
|
||||
func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) {
|
||||
switch req.Result {
|
||||
case "actual_settlement":
|
||||
if req.AmountCent <= 0 {
|
||||
if req.AmountCent == nil {
|
||||
if checkout != nil {
|
||||
return arbitrationCheckoutDepositDeductCent(*checkout), nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if req.AmountCent > order.DepositAmountCent {
|
||||
if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent {
|
||||
return 0, ErrInvalidDispute
|
||||
}
|
||||
return req.AmountCent, nil
|
||||
return *req.AmountCent, nil
|
||||
case "release_deposit":
|
||||
return 0, nil
|
||||
case "deduct_deposit", "compensate_owner":
|
||||
deductAmountCent := req.AmountCent
|
||||
if deductAmountCent <= 0 {
|
||||
deductAmountCent := arbitrationInputAmountCent(req)
|
||||
if req.AmountCent == nil || deductAmountCent <= 0 {
|
||||
deductAmountCent = order.DepositAmountCent
|
||||
}
|
||||
if deductAmountCent > order.DepositAmountCent {
|
||||
@@ -325,6 +328,23 @@ func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRe
|
||||
}
|
||||
}
|
||||
|
||||
func arbitrationInputAmountCent(req ArbitrateRequest) int64 {
|
||||
if req.AmountCent == nil {
|
||||
return 0
|
||||
}
|
||||
return *req.AmountCent
|
||||
}
|
||||
|
||||
func arbitrationCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 {
|
||||
if checkout.DepositDeductAmountCent > 0 {
|
||||
return checkout.DepositDeductAmountCent
|
||||
}
|
||||
if checkout.OtherAmountCent > 0 {
|
||||
return checkout.OtherAmountCent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) {
|
||||
order.ActualCoinConsumedM = settlement.CoinConsumedM
|
||||
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
@@ -350,7 +370,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
|
||||
}
|
||||
|
||||
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
|
||||
return order.SettlementMode == "platform_managed"
|
||||
}
|
||||
|
||||
func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
|
||||
@@ -459,15 +479,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
addRenterRefund(totalCent, "仲裁全额退款")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "partial_refund":
|
||||
if req.AmountCent <= 0 || req.AmountCent > totalCent {
|
||||
if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent {
|
||||
return settlement, ErrInvalidDispute
|
||||
}
|
||||
addRenterRefund(req.AmountCent, "仲裁部分退款")
|
||||
addRenterRefund(*req.AmountCent, "仲裁部分退款")
|
||||
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
|
||||
settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent)
|
||||
settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent)
|
||||
// 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。
|
||||
addOwnerIncome(
|
||||
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent),
|
||||
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent),
|
||||
"仲裁剩余金额结算给号主",
|
||||
)
|
||||
case "release_deposit":
|
||||
@@ -475,8 +495,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "deduct_deposit", "compensate_owner":
|
||||
deductAmountCent := req.AmountCent
|
||||
if deductAmountCent <= 0 {
|
||||
deductAmountCent := arbitrationInputAmountCent(req)
|
||||
if req.AmountCent == nil || deductAmountCent <= 0 {
|
||||
deductAmountCent = depositAmountCent
|
||||
}
|
||||
if deductAmountCent > depositAmountCent {
|
||||
|
||||
@@ -36,6 +36,7 @@ type DisputeDTO struct {
|
||||
CheckoutID *uint64 `json:"checkout_id"`
|
||||
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
|
||||
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
|
||||
CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"`
|
||||
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
@@ -61,7 +62,7 @@ type AdminCreateRequest struct {
|
||||
type ArbitrateRequest struct {
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
AmountCent *int64 `json:"amount_cent"`
|
||||
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
|
||||
ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type disputeRow struct {
|
||||
RenterPhone string
|
||||
CheckoutCoinConsumedM *float64
|
||||
CheckoutConsumableAmountCent *int64
|
||||
CheckoutDepositDeductAmountCent *int64
|
||||
}
|
||||
|
||||
func (row disputeRow) toDTO() DisputeDTO {
|
||||
@@ -54,6 +55,7 @@ func (row disputeRow) toDTO() DisputeDTO {
|
||||
CheckoutID: row.CheckoutID,
|
||||
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
|
||||
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
|
||||
CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent,
|
||||
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
|
||||
@@ -94,7 +94,8 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
o.owner_id, o.renter_id, o.extra_item_original_amount_cent,
|
||||
owner.phone AS owner_phone, renter.phone AS renter_phone,
|
||||
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m,
|
||||
c.consumable_amount_cent AS checkout_consumable_amount_cent`)
|
||||
c.consumable_amount_cent AS checkout_consumable_amount_cent,
|
||||
CASE WHEN c.deposit_deduct_amount_cent > 0 THEN c.deposit_deduct_amount_cent ELSE c.other_amount_cent END AS checkout_deposit_deduct_amount_cent`)
|
||||
}
|
||||
|
||||
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func int64Ptr(value int64) *int64 { return &value }
|
||||
|
||||
func disputePureCoinOrder() model.RentalOrder {
|
||||
return model.RentalOrder{
|
||||
Status: "renting",
|
||||
@@ -322,6 +324,64 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformManagedCheckoutArbitrationKeepsExistingDepositCompensation(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunded", nil
|
||||
})})
|
||||
adminID := uint64(78)
|
||||
_, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||
Status: "pending_checkout_confirm",
|
||||
HandoffStatus: "pending_owner_checkout",
|
||||
SettlementStatus: "pending",
|
||||
HandoffMode: "platform",
|
||||
SettlementMode: "platform_managed",
|
||||
ManagedAdminID: &adminID,
|
||||
RentAmountCent: 10000,
|
||||
OwnerRentAmountCent: 8000,
|
||||
PlatformFeeCent: 2000,
|
||||
DepositAmountCent: 5000,
|
||||
})
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
Status: "submitted",
|
||||
CoinConsumedM: 0,
|
||||
DepositDeductAmountCent: 3000,
|
||||
OtherAmountCent: 3000,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("创建结账记录失败: %v", err)
|
||||
}
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "checkout_amount",
|
||||
Description: "结账押金损耗争议",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建结账争议失败: %v", err)
|
||||
}
|
||||
if created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
|
||||
t.Fatalf("争议未带出押金赔付金额: %v", created.CheckoutDepositDeductAmountCent)
|
||||
}
|
||||
actualCoinConsumedM := 0.0
|
||||
actualConsumableAmountCent := int64(0)
|
||||
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
|
||||
Result: "actual_settlement",
|
||||
Remark: "沿用原结账押金赔付",
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
ActualConsumableAmountCent: &actualConsumableAmountCent,
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("仲裁失败: %v", err)
|
||||
}
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取仲裁订单失败: %v", err)
|
||||
}
|
||||
if saved.OfflineSettlementAmountCent != 11000 {
|
||||
t.Fatalf("仲裁后待打款 = %d, want 11000", saved.OfflineSettlementAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
@@ -394,7 +454,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
|
||||
settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
|
||||
Result: "actual_settlement",
|
||||
Remark: "赔付号主",
|
||||
AmountCent: compensationCent,
|
||||
AmountCent: &compensationCent,
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
ActualConsumableAmountCent: &actualConsumableAmountCent,
|
||||
}, 0)
|
||||
@@ -406,6 +466,21 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestActualSettlementArbitrationInheritsCheckoutDepositCompensation(t *testing.T) {
|
||||
order := disputePureCoinOrder()
|
||||
checkout := &model.OrderCheckout{DepositDeductAmountCent: 3000}
|
||||
amount, err := arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement"})
|
||||
if err != nil || amount != 3000 {
|
||||
t.Fatalf("omitted arbitration compensation = %d/%v, want 3000/nil", amount, err)
|
||||
}
|
||||
|
||||
zero := int64(0)
|
||||
amount, err = arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement", AmountCent: &zero})
|
||||
if err != nil || amount != 0 {
|
||||
t.Fatalf("explicit zero arbitration compensation = %d/%v, want 0/nil", amount, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
@@ -422,6 +497,7 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
||||
Status: "submitted",
|
||||
ConsumableAmountCent: 7000,
|
||||
CoinConsumedM: 25,
|
||||
DepositDeductAmountCent: 3000,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("创建结账记录失败: %v", err)
|
||||
@@ -433,8 +509,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建结账争议失败: %v", err)
|
||||
}
|
||||
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 {
|
||||
t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID)
|
||||
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 || created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
|
||||
t.Fatalf("关联结账/M/额外物品/押金赔付 = %v/%v/%v/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID)
|
||||
}
|
||||
|
||||
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
@@ -566,7 +642,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) {
|
||||
|
||||
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
|
||||
Result: "partial_refund",
|
||||
AmountCent: 5000,
|
||||
AmountCent: int64Ptr(5000),
|
||||
Remark: "没打完 号主登录不上",
|
||||
}, 0)
|
||||
if err != nil {
|
||||
@@ -599,7 +675,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) {
|
||||
}
|
||||
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
|
||||
Result: "partial_refund",
|
||||
AmountCent: 20000,
|
||||
AmountCent: int64Ptr(20000),
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("buildArbitrationSettlement() error = %v", err)
|
||||
|
||||
@@ -93,6 +93,9 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
|
||||
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
if req.AmountCent != nil && (*req.AmountCent < 0 || *req.AmountCent > 100_000_000_000) {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
|
||||
@@ -8,13 +8,4 @@ type UploadDTO struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
objectKeys []string
|
||||
}
|
||||
|
||||
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
|
||||
func (d *UploadDTO) ObjectKeys() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), d.objectKeys...)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
storage *Storage
|
||||
objectAuthorizer ObjectAuthorizer
|
||||
uploadOwnerRecorder UploadOwnerRecorder
|
||||
}
|
||||
|
||||
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
|
||||
type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error)
|
||||
|
||||
// UploadOwnerRecorder 保存用户上传私有文件的归属。
|
||||
type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error
|
||||
|
||||
func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler {
|
||||
return &Handler{
|
||||
service: service,
|
||||
storage: storage,
|
||||
objectAuthorizer: objectAuthorizer,
|
||||
uploadOwnerRecorder: uploadOwnerRecorder,
|
||||
}
|
||||
func NewHandler(service *Service, storage *Storage) *Handler {
|
||||
return &Handler{service: service, storage: storage}
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
@@ -60,19 +44,6 @@ func (h *Handler) Upload(c *gin.Context) {
|
||||
writeFileError(c, err)
|
||||
return
|
||||
}
|
||||
if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil {
|
||||
id, valid := userID.(uint64)
|
||||
if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil {
|
||||
// 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。
|
||||
if h.storage != nil {
|
||||
if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil {
|
||||
logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr))
|
||||
}
|
||||
}
|
||||
response.ServiceUnavailable(c, "文件归属记录失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
@@ -97,6 +68,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
if publicOnly &&
|
||||
!strings.HasPrefix(key, "home-banner/") &&
|
||||
!strings.HasPrefix(key, "avatar/") &&
|
||||
!strings.HasPrefix(key, "payment-cert/") &&
|
||||
!strings.HasPrefix(key, "announcement/") &&
|
||||
!strings.HasPrefix(key, "mohong/") &&
|
||||
!strings.HasPrefix(key, "crash/") &&
|
||||
@@ -105,29 +77,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||
return
|
||||
}
|
||||
if !publicOnly {
|
||||
if _, isAdmin := c.Get("admin_id"); !isAdmin {
|
||||
userID, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
id, ok := userID.(uint64)
|
||||
if !ok || h.objectAuthorizer == nil {
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
||||
return
|
||||
}
|
||||
allowed, err := h.objectAuthorizer(c.Request.Context(), id, key)
|
||||
if err != nil {
|
||||
response.ServiceUnavailable(c, "文件权限校验失败")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
object, err := h.storage.Get(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||
|
||||
@@ -61,7 +61,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
}
|
||||
var thumbnailURL string
|
||||
var mediumURL string
|
||||
objectKeys := []string{key}
|
||||
for _, variant := range generateImageVariants(key, data, contentType) {
|
||||
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
|
||||
"source-object": key,
|
||||
@@ -69,7 +68,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
objectKeys = append(objectKeys, variant.Key)
|
||||
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
||||
thumbnailURL = fileURLForScene(scene, variant.Key)
|
||||
}
|
||||
@@ -86,7 +84,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
Filename: req.Header.Filename,
|
||||
ContentType: contentType,
|
||||
Size: int64(len(data)),
|
||||
objectKeys: objectKeys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -109,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
||||
|
||||
func fileURLForScene(scene string, key string) string {
|
||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||
}
|
||||
return fileURL
|
||||
|
||||
@@ -157,36 +157,6 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。
|
||||
func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error {
|
||||
var firstErr error
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.removeObject(ctx, key); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if s.mirror != nil {
|
||||
if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Storage) removeObject(ctx context.Context, key string) error {
|
||||
if err := s.ensureBucketReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
|
||||
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
|
||||
@@ -43,33 +43,6 @@ type ListingDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PublicListingListItemDTO 是首页商品卡片的最小公开数据。
|
||||
// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。
|
||||
type PublicListingListItemDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
Title string `json:"title"`
|
||||
GameName string `json:"game_name"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary,omitempty"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsAccelerated bool `json:"is_accelerated_sale"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。
|
||||
type PublicListingDetailDTO struct {
|
||||
PublicListingListItemDTO
|
||||
Description string `json:"description"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
@@ -186,7 +159,7 @@ type NumberRange struct {
|
||||
}
|
||||
|
||||
type PublicListResult struct {
|
||||
Items []PublicListingListItemDTO `json:"items"`
|
||||
Items []ListingDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
@@ -275,6 +275,8 @@ func externalAccountToCreateRequest(
|
||||
"resources": resources,
|
||||
"skin_groups": externalSkinGroups(skins),
|
||||
"online_time_text": onlineTimeText,
|
||||
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
|
||||
"early_morning_response": "否",
|
||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||
"common_regions": commonRegions(item.CommonRegion),
|
||||
"remark": remark,
|
||||
@@ -382,10 +384,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64,
|
||||
|
||||
func serverRegionFromLoginMethod(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
normalized := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.Contains(value, "微信"):
|
||||
case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"):
|
||||
return "微信"
|
||||
case strings.Contains(strings.ToLower(value), "steam"):
|
||||
case strings.Contains(normalized, "steam"):
|
||||
return "Steam"
|
||||
default:
|
||||
return "QQ"
|
||||
|
||||
@@ -71,100 +71,9 @@ func applyPublicListingURLs(item *ListingDTO) {
|
||||
if item.AssetSummary != nil {
|
||||
delete(item.AssetSummary, "import_meta")
|
||||
delete(item.AssetSummary, "price_breakdown")
|
||||
// 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。
|
||||
delete(item.AssetSummary, "screenshot_groups")
|
||||
}
|
||||
}
|
||||
|
||||
func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO {
|
||||
result := make([]PublicListingListItemDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.toPublicListItem())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (item ListingDTO) toPublicListItem() PublicListingListItemDTO {
|
||||
return PublicListingListItemDTO{
|
||||
ID: item.ID,
|
||||
ListingNo: item.ListingNo,
|
||||
Title: item.Title,
|
||||
GameName: item.GameName,
|
||||
ServerRegion: item.ServerRegion,
|
||||
LoginPlatform: item.LoginPlatform,
|
||||
RankLevel: item.RankLevel,
|
||||
HafCoinAmount: item.HafCoinAmount,
|
||||
AssetSummary: publicListAssetSummary(item.AssetSummary),
|
||||
CoverURL: item.CoverURL,
|
||||
PriceCent: item.PriceCent,
|
||||
DepositAmountCent: item.DepositAmountCent,
|
||||
IsAccelerated: item.IsAccelerated,
|
||||
PublishedAt: item.PublishedAt,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (item ListingDTO) toPublicDetail() PublicListingDetailDTO {
|
||||
listItem := item.toPublicListItem()
|
||||
listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary)
|
||||
return PublicListingDetailDTO{
|
||||
PublicListingListItemDTO: listItem,
|
||||
Description: item.Description,
|
||||
ScreenshotURLS: item.ScreenshotURLS,
|
||||
}
|
||||
}
|
||||
|
||||
// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。
|
||||
func publicListAssetSummary(summary map[string]any) map[string]any {
|
||||
return selectPublicAssetSummary(summary, []string{
|
||||
"season_insurance",
|
||||
"stamina_level",
|
||||
"load_level",
|
||||
"resources",
|
||||
"skin_groups",
|
||||
"total_asset_wan",
|
||||
"online_time",
|
||||
"can_change_game_name",
|
||||
"all_hero",
|
||||
"daily_loss_m",
|
||||
"publish_ratio",
|
||||
})
|
||||
}
|
||||
|
||||
// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。
|
||||
func publicDetailAssetSummary(summary map[string]any) map[string]any {
|
||||
return selectPublicAssetSummary(summary, []string{
|
||||
"season_insurance",
|
||||
"stamina_level",
|
||||
"load_level",
|
||||
"resources",
|
||||
"skin_groups",
|
||||
"total_asset_wan",
|
||||
"online_time",
|
||||
"can_change_game_name",
|
||||
"all_hero",
|
||||
"daily_loss_m",
|
||||
"publish_ratio",
|
||||
"secret_kd",
|
||||
"fire_level",
|
||||
"common_regions",
|
||||
"ban_record",
|
||||
})
|
||||
}
|
||||
|
||||
func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any {
|
||||
if len(summary) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := summary[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applySellerListingPrice(item *ListingDTO) {
|
||||
if item == nil {
|
||||
return
|
||||
|
||||
@@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool {
|
||||
if summary == nil {
|
||||
return false
|
||||
}
|
||||
onlineTime, ok := summary["online_time"].(map[string]any)
|
||||
if !ok {
|
||||
// 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。
|
||||
switch value := summary["early_morning_response"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value) == "是"
|
||||
case bool:
|
||||
return value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
start, okStart := parseTimeMinuteValue(onlineTime["start"])
|
||||
end, okEnd := parseTimeMinuteValue(onlineTime["end"])
|
||||
if !okStart || !okEnd {
|
||||
return false
|
||||
}
|
||||
return timeRangeOverlapsMinutes(start, end, 0, 8*60)
|
||||
}
|
||||
|
||||
func parseTimeMinuteValue(value any) (int, bool) {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.Split(text, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, false
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return hour*60 + minute, true
|
||||
}
|
||||
|
||||
func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool {
|
||||
if start == end {
|
||||
return true
|
||||
}
|
||||
for _, segment := range splitMinuteRange(start, end) {
|
||||
if segment[0] <= targetEnd && segment[1] >= targetStart {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitMinuteRange(start int, end int) [][2]int {
|
||||
if start < end {
|
||||
return [][2]int{{start, end}}
|
||||
}
|
||||
return [][2]int{{start, 23*60 + 59}, {0, end}}
|
||||
}
|
||||
|
||||
@@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) {
|
||||
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
response any
|
||||
want bool
|
||||
}{
|
||||
{name: "全天可上号", start: "00:00", end: "23:59", want: true},
|
||||
{name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true},
|
||||
{name: "刚好八点开始", start: "08:00", end: "12:00", want: true},
|
||||
{name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false},
|
||||
{name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true},
|
||||
{name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false},
|
||||
{name: "明确响应", response: "是", want: true},
|
||||
{name: "明确不响应", response: "否", want: false},
|
||||
{name: "兼容布尔值", response: true, want: true},
|
||||
{name: "缺失字段", response: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isNightAvailableSummary(map[string]any{
|
||||
"online_time": map[string]any{
|
||||
"start": tt.start,
|
||||
"end": tt.end,
|
||||
},
|
||||
})
|
||||
summary := map[string]any{}
|
||||
if tt.response != nil {
|
||||
summary["early_morning_response"] = tt.response
|
||||
}
|
||||
got := isNightAvailableSummary(summary)
|
||||
if got != tt.want {
|
||||
t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want)
|
||||
t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
|
||||
items = items[start:end]
|
||||
}
|
||||
return &PublicListResult{
|
||||
Items: publicListingListItems(items),
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
|
||||
return nil, err
|
||||
}
|
||||
return &PublicListResult{
|
||||
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
|
||||
Items: publicListings(rowsToDTO(rows)),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@@ -219,14 +219,13 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
|
||||
return copied
|
||||
}
|
||||
|
||||
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
||||
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyPublicListingURLs(dto)
|
||||
item := dto.toPublicDetail()
|
||||
return &item, nil
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
||||
|
||||
@@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
||||
like := "%" + keyword + "%"
|
||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||
db = db.Where(
|
||||
`(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
|
||||
like,
|
||||
like,
|
||||
`(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
|
||||
keyword,
|
||||
keyword,
|
||||
id,
|
||||
id,
|
||||
id,
|
||||
@@ -204,22 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
|
||||
}
|
||||
|
||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
|
||||
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Group("listing_id")
|
||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||
CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE
|
||||
WHEN lu.upload_id IS NULL THEN ?
|
||||
WHEN lu.id IS NULL THEN ?
|
||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||
ELSE lu.source_channel
|
||||
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
||||
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
|
||||
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
|
||||
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
||||
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
|
||||
)`)
|
||||
}
|
||||
|
||||
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||
|
||||
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
|
||||
return s.repo.ListMine(ctx, ownerID)
|
||||
}
|
||||
|
||||
func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
||||
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -332,7 +331,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
||||
AssetSummary: map[string]any{
|
||||
"import_meta": map[string]any{"uploader_name": "客服1"},
|
||||
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
||||
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -347,78 +345,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
||||
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||
t.Fatal("expected price_breakdown hidden from public listing")
|
||||
}
|
||||
if _, ok := item.AssetSummary["screenshot_groups"]; ok {
|
||||
t.Fatal("expected screenshot_groups hidden from public listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) {
|
||||
item := ListingDTO{
|
||||
ID: 1,
|
||||
ListingNo: "SP000001",
|
||||
AccountID: 2,
|
||||
OwnerID: 3,
|
||||
OwnerPhone: "13800001234",
|
||||
OwnerNickname: "号主",
|
||||
SourceChannel: "外部渠道",
|
||||
IsExternalUpload: true,
|
||||
Title: "测试账号",
|
||||
Description: "公开描述",
|
||||
ScreenshotURLS: []string{"/api/listings/1/screenshots/0"},
|
||||
Status: "published",
|
||||
ReviewStatus: "approved",
|
||||
HandoffMode: "platform",
|
||||
SettlementMode: "platform_managed",
|
||||
ManagedAdminID: uint64Pointer(4),
|
||||
ReviewReason: "内部审核备注",
|
||||
ListingGroupConversationID: 5,
|
||||
AssetSummary: map[string]any{
|
||||
"season_insurance": "3*3",
|
||||
"contact_phone": "13900005678",
|
||||
"remark": "首页不应携带",
|
||||
},
|
||||
}
|
||||
|
||||
listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal public list error = %v", err)
|
||||
}
|
||||
detailRaw, err := json.Marshal(item.toPublicDetail())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal public detail error = %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"account_id",
|
||||
"owner_id",
|
||||
"owner_phone",
|
||||
"owner_nickname",
|
||||
"source_channel",
|
||||
"is_external_upload",
|
||||
"status",
|
||||
"review_status",
|
||||
"handoff_mode",
|
||||
"settlement_mode",
|
||||
"managed_admin_id",
|
||||
"review_reason",
|
||||
"listing_group_conversation_id",
|
||||
} {
|
||||
if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) {
|
||||
t.Fatalf("public response contains sensitive field %q", field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") {
|
||||
t.Fatalf("public list contains detail-only fields: %s", listRaw)
|
||||
}
|
||||
if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") {
|
||||
t.Fatalf("public list contains non-display asset fields: %s", listRaw)
|
||||
}
|
||||
if strings.Contains(string(detailRaw), "contact_phone") {
|
||||
t.Fatalf("public detail contains non-public asset field: %s", detailRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func uint64Pointer(value uint64) *uint64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||
@@ -604,6 +530,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
|
||||
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
|
||||
}
|
||||
if req.AssetSummary["early_morning_response"] != "否" {
|
||||
t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"])
|
||||
}
|
||||
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
|
||||
@@ -640,6 +569,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRegionFromLoginMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loginMethod string
|
||||
want string
|
||||
}{
|
||||
{name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"},
|
||||
{name: "微信扫码", loginMethod: "微信扫码", want: "微信"},
|
||||
{name: "VX扫码", loginMethod: "VX扫码", want: "微信"},
|
||||
{name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"},
|
||||
{name: "Steam", loginMethod: "Steam令牌", want: "Steam"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want {
|
||||
t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
|
||||
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
||||
LoginMethod: "QQ账号密码",
|
||||
|
||||
@@ -391,10 +391,13 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
|
||||
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
|
||||
var refund *refundAction
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
assets, err := r.lockOrderAssets(tx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order := assets.Order
|
||||
listing := assets.Listing
|
||||
account := assets.Account
|
||||
if order.RefundStatus != refundStatusPendingReview {
|
||||
return errors.New("订单不处于待审核退款状态")
|
||||
}
|
||||
@@ -407,14 +410,23 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
|
||||
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
|
||||
order.RefundStatus = "none"
|
||||
order.RefundAmountCent = 0
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
} else {
|
||||
action, err := r.prepareRefund(order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refund = action
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
if err := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(account).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/processlog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
||||
if hasOpen {
|
||||
return ErrCheckoutCannotSubmit
|
||||
}
|
||||
before := orderState(order)
|
||||
checkoutToUserID := order.OwnerID
|
||||
checkoutTargetType := processlog.ActorUser
|
||||
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
|
||||
checkoutToUserID = platformManagedAdminID(order)
|
||||
checkoutTargetType = processlog.ActorAdmin
|
||||
}
|
||||
record := model.HandoffRecord{
|
||||
OrderID: order.ID,
|
||||
@@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
||||
order.SettlementStatus = settlementStatusPending
|
||||
now := time.Now()
|
||||
order.HandoffStartedAt = &now
|
||||
if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID := order.ID
|
||||
if isPlatformSettlementOrder(order) {
|
||||
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
|
||||
@@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
before := orderState(order)
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
checkout.OwnerAdjustedAt = &now
|
||||
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
|
||||
refund = action
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
before := orderState(order)
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
targetID := order.OwnerID
|
||||
if isOwner {
|
||||
targetID = order.RenterID
|
||||
}
|
||||
if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(checkout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
before := orderState(order)
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
|
||||
checkout.RenterConfirmedAt = &now
|
||||
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
|
||||
refund = action
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
settlementStatusSettled = "settled"
|
||||
settlementStatusClosed = "closed"
|
||||
settlementStatusDisputed = "disputed"
|
||||
settlementStatusArbitrated = "arbitrated"
|
||||
|
||||
handoffModeOwner = "owner"
|
||||
handoffModePlatform = "platform"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/processlog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
|
||||
if !canHoldDeposit(order) {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
before := orderState(order)
|
||||
beforeStatus := order.DepositHoldStatus
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusHeld
|
||||
@@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
|
||||
order.DepositHeldBy = &adminID
|
||||
order.DepositHeldAt = &now
|
||||
order.DepositHoldReleasedAt = nil
|
||||
if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
@@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or
|
||||
if holdAmountCent <= 0 {
|
||||
return ErrDepositHoldAmountEmpty
|
||||
}
|
||||
before := orderState(order)
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusReleased
|
||||
order.DepositHoldReleasedAt = &now
|
||||
if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -51,6 +51,8 @@ type OrderDTO struct {
|
||||
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
AccountSource string `json:"account_source"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
HandoffMode string `json:"handoff_mode"`
|
||||
@@ -77,46 +79,6 @@ type OrderDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
|
||||
type UserOrderListItemDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminOrderListItemDTO 是后台订单表格的最小展示数据,敏感详情仅由详情接口按权限获取。
|
||||
type AdminOrderListItemDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RentAmountCent int64 `json:"rent_amount_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
HandoffMode string `json:"handoff_mode"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AdminActionsDTO struct {
|
||||
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
||||
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
||||
@@ -172,6 +134,7 @@ type CounterCheckoutRequest struct {
|
||||
|
||||
type AdminActionRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"`
|
||||
}
|
||||
|
||||
type PlatformHandoffRequest struct {
|
||||
@@ -179,7 +142,7 @@ type PlatformHandoffRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。
|
||||
// ForceHandoffRequest 客服确认订单已完成交接。
|
||||
type ForceHandoffRequest struct {
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
@@ -209,45 +172,6 @@ type PaginatedResult struct {
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// UserPaginatedResult 为用户订单列表提供受限分页,避免一次导出全部历史订单。
|
||||
type UserPaginatedResult struct {
|
||||
Items []UserOrderListItemDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// SellerHandoffQuery 是号主待办列表的受限查询条件。
|
||||
type SellerHandoffQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
}
|
||||
|
||||
// SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。
|
||||
type SellerHandoffMetrics struct {
|
||||
PendingHandoff int64 `json:"pending_handoff"`
|
||||
PendingCheckout int64 `json:"pending_checkout"`
|
||||
Abnormal int64 `json:"abnormal"`
|
||||
}
|
||||
|
||||
// SellerHandoffPaginatedResult 只包含当前用户作为号主时需要处理的订单。
|
||||
type SellerHandoffPaginatedResult struct {
|
||||
Items []UserOrderListItemDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Metrics SellerHandoffMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
// AdminOrderListPaginatedResult 使用列表专用 DTO,避免后台首页携带订单敏感详情。
|
||||
type AdminOrderListPaginatedResult struct {
|
||||
Items []AdminOrderListItemDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type RefundStatusDTO struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
@@ -269,6 +193,28 @@ type HandoffRecordDTO struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。
|
||||
type ProcessEventDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
BusinessType string `json:"business_type"`
|
||||
BusinessID uint64 `json:"business_id"`
|
||||
Stage string `json:"stage"`
|
||||
Action string `json:"action"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID uint64 `json:"actor_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID *uint64 `json:"target_id,omitempty"`
|
||||
TargetName string `json:"target_name"`
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason"`
|
||||
Payload datatypes.JSON `json:"payload"`
|
||||
AttachmentURLs []string `json:"attachment_urls"`
|
||||
StateBefore datatypes.JSON `json:"state_before"`
|
||||
StateAfter datatypes.JSON `json:"state_after"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CheckoutDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
|
||||
@@ -7,13 +7,20 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/processlog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func canAdminForceHandoff(order model.RentalOrder) bool {
|
||||
if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) {
|
||||
if isPlatformHandoffOrder(order) {
|
||||
if order.Status == orderStatusPendingHandoff {
|
||||
return order.HandoffStatus == handoffStatusPendingRenterConfirm
|
||||
}
|
||||
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
}
|
||||
if isPlatformSettlementOrder(order) {
|
||||
return false
|
||||
}
|
||||
if order.Status == orderStatusPendingHandoff {
|
||||
@@ -24,11 +31,18 @@ func canAdminForceHandoff(order model.RentalOrder) bool {
|
||||
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
}
|
||||
|
||||
func forceHandoffRecordType(order model.RentalOrder) string {
|
||||
if isPlatformHandoffOrder(order) {
|
||||
return "platform_handoff"
|
||||
}
|
||||
return "owner_handoff"
|
||||
}
|
||||
|
||||
func forceHandoffNeedsContent(order model.RentalOrder) bool {
|
||||
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout
|
||||
}
|
||||
|
||||
// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。
|
||||
// AdminForceHandoff 由客服确认订单已完成交接,并立即开始租期。
|
||||
func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
@@ -38,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
|
||||
if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
|
||||
return ErrOrderCannotForceHandoff
|
||||
}
|
||||
before := orderState(order)
|
||||
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
|
||||
return ErrOrderCannotForceHandoff
|
||||
}
|
||||
@@ -60,7 +75,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
|
||||
if !needsContent {
|
||||
var handoffCount int64
|
||||
if err := tx.Model(&model.HandoffRecord{}).
|
||||
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
|
||||
Where("order_id = ? AND type = ?", order.ID, forceHandoffRecordType(order)).
|
||||
Count(&handoffCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
|
||||
order.HandoffStatus = handoffStatusReceived
|
||||
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
|
||||
order.RentedAt = &now
|
||||
if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
|
||||
@@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminProcessEvents(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeOrderError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminClose(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminClose, gin.H{"closed": true})
|
||||
}
|
||||
@@ -248,6 +261,15 @@ func (h *Handler) ListPendingRefund(c *gin.Context) {
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) PendingRefundCount(c *gin.Context) {
|
||||
count, err := h.service.PendingRefundCount(c.Request.Context())
|
||||
if err != nil {
|
||||
writeOrderError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"pending_refund_count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user