1 Commits
Author SHA1 Message Date
yml2213 85332df2bd 订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计
- 用户 token 增加版本控制,冻结/改密/退出即时撤销会话
- 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie
- 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属
- 公开商品接口返回最小字段,隐藏号主身份与内部状态
- 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
2026-08-16 21:47:46 +08:00
251 changed files with 5527 additions and 16593 deletions
-30
View File
@@ -1,30 +0,0 @@
#!/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] 本地检查通过"
-1
View File
@@ -40,4 +40,3 @@ deploy/caddy/conf.d/show.caddy
logs logs
.gocache .gocache
artifacts/
-14
View File
@@ -50,20 +50,6 @@ npm run dev
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go``npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend``--skip-frontend``--skip-tests` 做局部调试。 `./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` 调整级别。 - 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
-7
View File
@@ -8,13 +8,6 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=secret MYSQL_PASSWORD=secret
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local 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_ADDR=127.0.0.1:16379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
+15 -45
View File
@@ -19,13 +19,6 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=change-hfb-password MYSQL_PASSWORD=change-hfb-password
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local 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_ADDR=redis:6379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
@@ -55,23 +48,26 @@ LOG_ENABLE_CONSOLE=false
LOG_ENABLE_FILE=true LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14 LOG_RETAIN_DAYS=14
# 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除 # 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。
# 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com # 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com
# OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。 # 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_ENDPOINT=
STORAGE_MIRROR_BUCKET= STORAGE_MIRROR_BUCKET=hfb-sys-assets
STORAGE_MIRROR_ACCESS_KEY_ID= STORAGE_MIRROR_ACCESS_KEY_ID=
STORAGE_MIRROR_SECRET_ACCESS_KEY= STORAGE_MIRROR_SECRET_ACCESS_KEY=
STORAGE_MIRROR_REGION= STORAGE_MIRROR_REGION=cn-hangzhou
STORAGE_MIRROR_BUCKET_LOOKUP= STORAGE_MIRROR_BUCKET_LOOKUP=dns
# 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。 # 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。
SMS_PROVIDER=aliyun SMS_PROVIDER=aliyun
@@ -106,29 +102,3 @@ FIELD_ENCRYPTION_LEGACY_KEY=
EXTERNAL_UPLOAD_SECRET= EXTERNAL_UPLOAD_SECRET=
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。 # 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
EXTERNAL_UPLOAD_ALLOWED_IPS= 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
+3 -7
View File
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/config" "hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/database" "hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
"hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/ordertimeout"
"hfb_sys/backend/internal/jobs/refundretry" "hfb_sys/backend/internal/jobs/refundretry"
"hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/logging"
@@ -58,13 +59,7 @@ func main() {
logAuthRuntimeIdentity(logger, cfg) logAuthRuntimeIdentity(logger, cfg)
var deps router.Dependencies var deps router.Dependencies
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{ db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime,
SlowQueryThreshold: cfg.Database.SlowQueryThreshold,
})
if err != nil { if err != nil {
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
} else { } else {
@@ -97,6 +92,7 @@ func main() {
defer stopJobs() defer stopJobs()
if deps.DB != nil { if deps.DB != nil {
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx) 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 { if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger)) paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx) refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
-27
View File
@@ -5,7 +5,6 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -30,8 +29,6 @@ type Config struct {
Realname RealnameConfig Realname RealnameConfig
Log LogConfig Log LogConfig
RateLimit RateLimitConfig RateLimit RateLimitConfig
Database DatabaseConfig
BackupStatusFile string
} }
type StorageConfig struct { type StorageConfig struct {
@@ -72,14 +69,6 @@ type RateLimitConfig struct {
RequestsPerMinute int RequestsPerMinute int
} }
type DatabaseConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "development"), AppEnv: getEnv("APP_ENV", "development"),
@@ -141,14 +130,6 @@ func Load() Config {
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), 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"),
} }
} }
@@ -225,14 +206,6 @@ func getEnvInt(key string, fallback int) int {
return parsed 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 { func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
-35
View File
@@ -26,41 +26,6 @@ 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 时 // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
+1 -8
View File
@@ -21,10 +21,6 @@ type structuredGormLogger struct {
} }
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { 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 level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info level = gormLogger.Info
@@ -32,13 +28,10 @@ func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThresho
if logger == nil { if logger == nil {
logger = zap.L() logger = zap.L()
} }
if slowThreshold <= 0 {
slowThreshold = 500 * time.Millisecond
}
return &structuredGormLogger{ return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")), logger: logger.With(zap.String("module", "database")),
level: level, level: level,
slowThreshold: slowThreshold, slowThreshold: 500 * time.Millisecond,
} }
} }
+6 -47
View File
@@ -8,50 +8,9 @@ import (
"gorm.io/gorm" "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) { 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{ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold), Logger: newGormLogger(logLevel, appLogger),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -61,11 +20,11 @@ func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, op
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 限制连接池,避免单个实例耗尽 MySQL max_connections。 // 限制连接池,避免本地压测瞬间打满 MySQL max_connections。
sqlDB.SetMaxOpenConns(options.MaxOpenConns) sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(options.MaxIdleConns) sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime) sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime) sqlDB.SetConnMaxIdleTime(5 * time.Minute)
return db, nil return db, nil
} }
-4
View File
@@ -38,10 +38,8 @@ func NewTestDBWithName(name string) *gorm.DB {
func MigrateListingLifecycleTestSchema(db *gorm.DB) error { func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return db.AutoMigrate( return db.AutoMigrate(
&model.User{}, &model.User{},
&model.AdminUser{},
&model.GameAccount{}, &model.GameAccount{},
&model.RentalListing{}, &model.RentalListing{},
&model.ListingUpload{},
&model.ListingStatusEvent{}, &model.ListingStatusEvent{},
) )
} }
@@ -63,10 +61,8 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
&model.WalletLedger{}, &model.WalletLedger{},
&model.RenterGrowthLedger{}, &model.RenterGrowthLedger{},
&model.AuditLog{}, &model.AuditLog{},
&model.ProcessEvent{},
&model.ChatConversation{}, &model.ChatConversation{},
&model.ChatParticipant{}, &model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{}, &model.ChatMessage{},
) )
} }
+1 -9
View File
@@ -313,15 +313,7 @@ func applyMigrations(t *testing.T, db *sql.DB) {
t.Fatal("无法定位当前测试文件") t.Fatal("无法定位当前测试文件")
} }
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
entries, err := os.ReadDir(migrationDir) for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
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) migrationPath := filepath.Join(migrationDir, name)
raw, err := os.ReadFile(migrationPath) raw, err := os.ReadFile(migrationPath)
if err != nil { if err != nil {
@@ -0,0 +1,167 @@
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)
}
@@ -0,0 +1,77 @@
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)
}
}
}
+14 -14
View File
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatalf("创建测试数据库失败: %v", err) t.Fatalf("创建测试数据库失败: %v", err)
} }
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil { if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
} }
return db return db
@@ -165,19 +165,19 @@ func TestResetRetryClearsRetryFields(t *testing.T) {
func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder {
t.Helper() t.Helper()
order := model.RentalOrder{ order := model.RentalOrder{
OrderNo: "ORD" + suffix, OrderNo: "ORD" + suffix,
ListingID: 1, ListingID: 1,
AccountID: 1, AccountID: 1,
OwnerID: 1, OwnerID: 1,
RenterID: 2, RenterID: 2,
RentAmountCent: 1000, RentAmountCent: 1000,
DepositAmountCent: 500, DepositAmountCent: 500,
Status: "closed", Status: "closed",
HandoffStatus: "arbitrated", HandoffStatus: "arbitrated",
SettlementStatus: settlementStatus, SettlementStatus: settlementStatus,
RefundStatus: "pending", RefundStatus: "pending",
RefundAmountCent: 800, RefundAmountCent: 800,
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
} }
if err := db.Create(&order).Error; err != nil { if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err) t.Fatalf("create order failed: %v", err)
+22 -28
View File
@@ -25,6 +25,7 @@ const (
ContextAuthCurrentVersion = "auth_current_token_version" ContextAuthCurrentVersion = "auth_current_token_version"
ContextAuthFailureDetail = "auth_failure_detail" ContextAuthFailureDetail = "auth_failure_detail"
AdminAccessCookieName = "hfb_admin_access" AdminAccessCookieName = "hfb_admin_access"
UserAccessCookieName = "hfb_user_access"
) )
type AdminTokenContext struct { type AdminTokenContext struct {
@@ -34,8 +35,6 @@ type AdminTokenContext struct {
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error) 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 { func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization") header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
@@ -45,28 +44,20 @@ func extractBearerToken(c *gin.Context) string {
return "" return ""
} }
func extractToken(c *gin.Context) string { type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText
}
return c.Query("token")
}
func extractAdminToken(c *gin.Context) (string, string) { func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
if tokenText := extractBearerToken(c); tokenText != "" { var validate UserTokenValidatorFunc
return tokenText, "bearer" if len(validators) > 0 {
validate = validators[0]
} }
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) { return func(c *gin.Context) {
tokenText := extractToken(c) tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" { if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌") response.Unauthorized(c, "缺少访问令牌")
c.Abort() c.Abort()
@@ -81,11 +72,7 @@ func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.Hand
} }
if validate != nil { if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil { if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) { response.Unauthorized(c, "访问令牌无效或已过期")
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort() c.Abort()
return return
} }
@@ -99,9 +86,16 @@ func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.Hand
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenText, tokenSource := extractAdminToken(c) tokenText := extractBearerToken(c)
tokenSource := "bearer"
if tokenText == "" { if tokenText == "" {
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0) if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
tokenSource = "cookie"
}
}
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
response.Unauthorized(c, "缺少后台访问令牌") response.Unauthorized(c, "缺少后台访问令牌")
c.Abort() c.Abort()
return return
+18 -14
View File
@@ -1,7 +1,6 @@
package middleware package middleware
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -11,26 +10,31 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func TestAuthRejectsRevokedUserToken(t *testing.T) { func TestUserAuthRejectsQueryToken(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-secret") manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0) pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
if err != nil { if err != nil {
t.Fatalf("生成令牌失败:%v", err) t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
} }
engine := gin.New() engine := gin.New()
engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error { engine.GET("/protected", Auth(manager), func(c *gin.Context) {
return auth.ErrTokenVersionMismatch
}), func(c *gin.Context) {
c.Status(http.StatusNoContent) 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)
if response.Code != http.StatusUnauthorized { queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized) 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)
} }
} }
+21 -63
View File
@@ -7,14 +7,10 @@ import (
"sync" "sync"
"time" "time"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
type rateLimitKeyFunc func(c *gin.Context) string
type rateLimitBucket struct { type rateLimitBucket struct {
count int count int
resetAt time.Time resetAt time.Time
@@ -36,16 +32,6 @@ type redisRateLimiter struct {
} }
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc { 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 { if limit <= 0 {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Next() c.Next()
@@ -62,62 +48,34 @@ func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc)
fallback: limiter, fallback: limiter,
limit: limit, limit: limit,
window: time.Minute, window: time.Minute,
}).handle(keyFunc) }).handle
} }
return limiter.handle(keyFunc) return limiter.handle
} }
func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc { func (l *redisRateLimiter) handle(c *gin.Context) {
return func(c *gin.Context) { now := time.Now()
now := time.Now() key := c.ClientIP()
key := keyFunc(c) allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
allowed, resetAt, err := l.allow(c.Request.Context(), key, now) if err != nil {
if err != nil { allowed, resetAt = l.fallback.allow(key, now)
allowed, resetAt = l.fallback.allow(key, now)
}
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
} }
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
} }
func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc { func (l *rateLimiter) handle(c *gin.Context) {
return func(c *gin.Context) { now := time.Now()
now := time.Now() key := c.ClientIP()
key := keyFunc(c) allowed, resetAt := l.allow(key, now)
allowed, resetAt := l.allow(key, now) if !allowed {
if !allowed { writeRateLimited(c, now, resetAt)
writeRateLimited(c, now, resetAt) return
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)
} }
c.Next()
} }
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) { func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
@@ -1,91 +0,0 @@
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)
}
}
+1 -10
View File
@@ -108,25 +108,16 @@ func isPaymentNotifyPath(path string) bool {
} }
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。 // shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool { func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests { if status >= 500 || status == http.StatusTooManyRequests {
return false return false
} }
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
if isChatSSEPath(path, route) {
return true
}
if latencyMs >= slowRequestThresholdMs { if latencyMs >= slowRequestThresholdMs {
return false return false
} }
return true 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 { func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason) value, ok := c.Get(ContextAuthFailureReason)
if !ok { if !ok {
@@ -19,10 +19,6 @@ func TestShouldSkipHTTPLog(t *testing.T) {
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false}, {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/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
+21 -68
View File
@@ -4,32 +4,21 @@ import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm"
) )
type ChatConversation struct { type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"` ListingID *uint64 `gorm:"index" json:"listing_id"`
// 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。 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"`
LatestOrderID *uint64 `gorm:"index" json:"-"` Title string `gorm:"size:128;not null" json:"title"`
LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"` LastMessageID *uint64 `json:"last_message_id"`
LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"` LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"`
LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"` LastMessageAt *time.Time `json:"last_message_at"`
// 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。 CreatedAt time.Time `json:"created_at"`
LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"` UpdatedAt time.Time `json:"updated_at"`
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"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"`
LastMessageAt *time.Time `json:"last_message_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (ChatConversation) TableName() string { func (ChatConversation) TableName() string {
@@ -53,58 +42,22 @@ func (ChatParticipant) TableName() string {
return "chat_participants" 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 { type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"` SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` 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"`
CreatedAt time.Time `json:"created_at"`
} }
func (ChatMessage) TableName() string { func (ChatMessage) TableName() string {
return "chat_messages" 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 { type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"` ImageURL string `gorm:"size:512;not null" json:"image_url"`
+15
View File
@@ -0,0 +1,15 @@
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"
}
-21
View File
@@ -4,7 +4,6 @@ import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm"
) )
type RentalOrder struct { type RentalOrder struct {
@@ -42,8 +41,6 @@ type RentalOrder struct {
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` 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"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_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"` HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
@@ -74,24 +71,6 @@ func (RentalOrder) TableName() string {
return "rental_orders" 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 ( const (
-34
View File
@@ -1,34 +0,0 @@
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"
}
+1 -1
View File
@@ -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"` 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"` 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"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
TokenVersion int64 `gorm:"not null;default:0" json:"-"` TokenVersion int64 `gorm:"not null;default:1" json:"-"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
+6 -15
View File
@@ -24,14 +24,12 @@ type MetricsDTO struct {
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 // ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
type ListingDailyOverviewDTO struct { type ListingDailyOverviewDTO struct {
Today ListingDayStatsDTO `json:"today"` Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"` Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"` Days int `json:"days"`
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"` Timezone string `json:"timezone"`
Days int `json:"days"`
Timezone string `json:"timezone"`
} }
type ListingDayStatsDTO struct { type ListingDayStatsDTO struct {
@@ -49,13 +47,6 @@ type ListingChannelDayStatsDTO struct {
TradeLeaveCount int64 `json:"trade_leave_count"` 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 { type PendingDTO struct {
ListingReviews int64 `json:"listing_reviews"` ListingReviews int64 `json:"listing_reviews"`
Disputes int64 `json:"disputes"` Disputes int64 `json:"disputes"`
@@ -130,18 +130,9 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
Scan(&events).Error; err != nil { Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err 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) byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket) byChannel := make(map[listingChannelKey]*listingDailyBucket)
byUploader := make(map[listingUploaderKey]int64)
for _, ev := range events { for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02") key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key] b := byDay[key]
@@ -161,11 +152,6 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
} }
addListingDailyEvent(cb, ev.EventType, ev.Source) 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) trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- { for i := days - 1; i >= 0; i-- {
@@ -185,48 +171,27 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
todayStats = trend[len(trend)-1] todayStats = trend[len(trend)-1]
} }
channelTrend := listingChannelTrend(byChannel) channelTrend := listingChannelTrend(byChannel)
uploaderTrend := listingUploaderTrend(byUploader)
todayChannels := make([]ListingChannelDayStatsDTO, 0) todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend { for _, item := range channelTrend {
if item.Date == todayStats.Date { if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item) todayChannels = append(todayChannels, item)
} }
} }
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
for _, item := range uploaderTrend {
if item.Date == todayStats.Date {
todayUploaders = append(todayUploaders, item)
}
}
return ListingDailyOverviewDTO{ return ListingDailyOverviewDTO{
Today: todayStats, Today: todayStats,
Trend: trend, Trend: trend,
TodayChannels: todayChannels, TodayChannels: todayChannels,
ChannelTrend: channelTrend, ChannelTrend: channelTrend,
TodayUploaders: todayUploaders, Days: days,
UploaderTrend: uploaderTrend, Timezone: "Asia/Shanghai",
Days: days,
Timezone: "Asia/Shanghai",
}, nil }, nil
} }
type listingUploaderRow struct {
UploaderID uint64
UploaderName string
CreatedAt time.Time
}
type listingChannelKey struct { type listingChannelKey struct {
date string date string
sourceChannel string sourceChannel string
} }
type listingUploaderKey struct {
date string
uploaderID uint64
uploaderName string
}
type listingDailyBucket struct { type listingDailyBucket struct {
published int64 published int64
activeOffline int64 activeOffline int64
@@ -279,35 +244,6 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi
return items 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 { func listingChannelRank(channel string) int {
switch channel { switch channel {
case "咸鱼": case "咸鱼":
@@ -65,44 +65,3 @@ 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) 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,96 +2,39 @@ package adminfinance
import ( import (
"context" "context"
"sync"
"hfb_sys/backend/internal/timeutil" "hfb_sys/backend/internal/timeutil"
) )
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
if cached := r.loadDashboardCache(ctx, query); cached != nil { dailyItems, err := r.dailyItems(ctx, query)
return cached, nil if err != nil {
return nil, err
} }
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, pickup, err := r.pickupSummary(ctx, query)
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 if err != nil {
var ( return nil, err
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
}
}
summary, err := r.summary(ctx, query, pickup) summary, err := r.summary(ctx, query, pickup)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := &DashboardDTO{ mohong, err := r.mohongSummary(ctx, query)
Summary: *summary,
DailyItems: dailyItems,
PickupSummary: *pickup,
MohongSummary: *mohong,
DisbursementSummary: *disbursement,
OperatingExpenseSummary: *operatingExpense,
GeneratedAt: timeutil.ShanghaiNow(),
}
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 { if err != nil {
return nil, err return nil, err
} }
return &summary, nil disbursement, err := r.disbursementSummary(ctx, query)
if err != nil {
return nil, err
}
return &DashboardDTO{
Summary: *summary,
DailyItems: dailyItems,
PickupSummary: *pickup,
MohongSummary: *mohong,
DisbursementSummary: *disbursement,
GeneratedAt: timeutil.ShanghaiNow(),
}, nil
} }
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 // pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
@@ -160,7 +103,6 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*
func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) { func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
var payment paymentSummaryRow var payment paymentSummaryRow
originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query)
if err := db.Table("payment_orders AS po"). 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, 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, 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,
@@ -170,7 +112,12 @@ 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 = '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`, 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()). payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments). 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()).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Scan(&payment).Error; err != nil { Scan(&payment).Error; err != nil {
return nil, err return nil, err
@@ -194,31 +141,32 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
THEN COALESCE(ro.offline_settlement_amount_cent, 0) THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE 0 ELSE 0
END), 0) AS offline_settlement_pending_amount_cent, END), 0) AS offline_settlement_pending_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
THEN 1 THEN 1
ELSE 0 ELSE 0
END), 0) AS offline_settlement_pending_count, END), 0) AS offline_settlement_pending_count,
COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 COUNT(ro.id) AS settled_order_count`).
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("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", ownerWalletIncomeForSettledOrdersSubquery(db, query)). Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
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). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Scan(&settlement).Error; err != nil { Scan(&settlement).Error; err != nil {
return nil, err 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 var estimated estimatedIncomeRow
if err := db.Table("rental_orders"). if err := db.Table("rental_orders").
@@ -261,7 +209,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
SettledOrderCount: settlement.SettledOrderCount, SettledOrderCount: settlement.SettledOrderCount,
PendingSettleOrderCount: estimated.PendingSettleOrderCount, PendingSettleOrderCount: estimated.PendingSettleOrderCount,
OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount, OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount,
FinancialExceptionCount: settlement.FinancialExceptionCount, FinancialExceptionCount: exceptionCount,
}, nil }, nil
} }
@@ -303,7 +251,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
END), 0) AS offline_settlement_pending_count, END), 0) AS offline_settlement_pending_count,
COUNT(ro.id) AS settled_order_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("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", ownerWalletIncomeForSettledOrdersSubquery(db, query)). Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(ro.settled_at)"). Group("DATE(ro.settled_at)").
Scan(&settlements).Error; err != nil { Scan(&settlements).Error; err != nil {
@@ -355,10 +303,6 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
if err != nil { if err != nil {
return nil, err return nil, err
} }
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
return nil, err
}
itemsByDate := make(map[string]FinanceDailyDTO) itemsByDate := make(map[string]FinanceDailyDTO)
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -448,14 +392,6 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
itemsByDate[date] = item 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)) items := make([]FinanceDailyDTO, 0, len(itemsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -468,8 +404,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 // normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。
func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) {
rows := make([]dailyPaymentRow, 0) rows := make([]dailyPaymentRow, 0)
db := r.db.WithContext(ctx) err := r.db.WithContext(ctx).Table("payment_orders AS po").
err := db.Table("payment_orders AS po").
Select(`DATE(po.created_at) AS date, 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 = '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, 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,
@@ -480,7 +415,12 @@ 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 = '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`, 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()). payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). 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()).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(po.created_at)"). Group("DATE(po.created_at)").
Scan(&rows).Error Scan(&rows).Error
@@ -537,7 +477,6 @@ type settlementSummaryRow struct {
OfflineSettlementAmountCent int64 OfflineSettlementAmountCent int64
OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingAmountCent int64
OfflineSettlementPendingCount int64 OfflineSettlementPendingCount int64
FinancialExceptionCount int64
SettledOrderCount int64 SettledOrderCount int64
} }
@@ -1,41 +0,0 @@
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,87 +240,3 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) {
t.Fatalf("调整日汇总 = %+v", adjusted) 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,23 +2,6 @@ package adminfinance
import "context" 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 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。 // disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) { func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -98,29 +81,42 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
return nil, err return nil, err
} }
// 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。 var withdrawalPaid struct {
var withdrawal struct { AmountCent int64
PaidAmountCent int64 FeeAmountCent int64
PaidFeeAmountCent int64 ActualAmountCent int64
PaidActualAmountCent int64 Count int64
PaidCount int64
PendingAmountCent int64
PendingCount int64
ReviewAmountCent int64
ReviewCount int64
} }
if err := db.Table("withdrawal_requests").Select(` if err := db.Table("withdrawal_requests").
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, Select(`COALESCE(SUM(amount_cent), 0) AS 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(fee_cent), 0) AS 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(actual_amount_cent), 0) AS actual_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count, COUNT(id) AS count`).
COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent, Where("status = ?", "completed").
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count, Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent, Scan(&withdrawalPaid).Error; err != nil {
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`, return nil, err
query.StartDate, query.EndDate, query.StartDate, query.EndDate, }
query.StartDate, query.EndDate, query.StartDate, query.EndDate).
Scan(&withdrawal).Error; err != nil { 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 {
return nil, err return nil, err
} }
@@ -137,22 +133,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
} }
return &DisbursementSummaryDTO{ return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent, PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count, PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent, PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount, PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent, OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count, OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
OfflineSettlementPendingAmountCent: offlinePending.AmountCent, OfflineSettlementPendingAmountCent: offlinePending.AmountCent,
OfflineSettlementPendingCount: offlinePending.Count, OfflineSettlementPendingCount: offlinePending.Count,
WithdrawalAmountCent: withdrawal.PaidAmountCent, WithdrawalAmountCent: withdrawalPaid.AmountCent,
WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent, WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent,
WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent, WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent,
WithdrawalPaidCount: withdrawal.PaidCount, WithdrawalPaidCount: withdrawalPaid.Count,
WithdrawalPendingAmountCent: withdrawal.PendingAmountCent, WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent,
WithdrawalPendingCount: withdrawal.PendingCount, WithdrawalPendingCount: withdrawalPending.Count,
WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent, WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
WithdrawalReviewCount: withdrawal.ReviewCount, WithdrawalReviewCount: withdrawalReview.Count,
ManualPaidAmountCent: manualPaid.AmountCent, ManualPaidAmountCent: manualPaid.AmountCent,
ManualPaidCount: manualPaid.Count, ManualPaidCount: manualPaid.Count,
}, nil }, nil
@@ -80,7 +80,6 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark, ro.offline_settlement_remark AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -126,7 +125,6 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, 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, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -162,7 +160,6 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark, md.remark,
md.category, md.category,
md.custom_category_name,
md.voucher_url, md.voucher_url,
md.voided_at, md.voided_at,
md.voided_by, md.voided_by,
@@ -205,38 +202,37 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
} }
type disbursementItemRow struct { type disbursementItemRow struct {
SourceType string SourceType string
SourceID uint64 SourceID uint64
BusinessNo string BusinessNo string
OrderID uint64 OrderID uint64
WithdrawalID uint64 WithdrawalID uint64
UserID uint64 UserID uint64
PayeeName string PayeeName string
PayeePhone string PayeePhone string
UploaderName string UploaderName string
SourceChannel string SourceChannel string
AccountType string AccountType string
AccountName string AccountName string
AccountNo string AccountNo string
BankName string BankName string
AmountCent int64 AmountCent int64
FeeCent int64 FeeCent int64
ActualAmountCent int64 ActualAmountCent int64
Status string Status string
RawStatus string RawStatus string
BusinessCreatedAt *time.Time BusinessCreatedAt *time.Time
PaidAt *time.Time PaidAt *time.Time
OperatorID *uint64 OperatorID *uint64
OperatorName string OperatorName string
Remark string Remark string
Category string Category string
CustomCategoryName string VoucherURL string
VoucherURL string VoidedAt *time.Time
VoidedAt *time.Time VoidedBy *uint64
VoidedBy *uint64 VoidedByName string
VoidedByName string VoidReason string
VoidReason string SourcePayload []byte
SourcePayload []byte
} }
func (r disbursementItemRow) toDTO() DisbursementItemDTO { func (r disbursementItemRow) toDTO() DisbursementItemDTO {
@@ -250,36 +246,35 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
} }
} }
return DisbursementItemDTO{ return DisbursementItemDTO{
SourceType: r.SourceType, SourceType: r.SourceType,
SourceID: r.SourceID, SourceID: r.SourceID,
BusinessNo: r.BusinessNo, BusinessNo: r.BusinessNo,
OrderID: r.OrderID, OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID, WithdrawalID: r.WithdrawalID,
UserID: r.UserID, UserID: r.UserID,
PayeeName: r.PayeeName, PayeeName: r.PayeeName,
PayeePhone: phone, PayeePhone: phone,
UploaderName: r.UploaderName, UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel, SourceChannel: r.SourceChannel,
AccountType: r.AccountType, AccountType: r.AccountType,
AccountName: r.AccountName, AccountName: r.AccountName,
AccountNo: r.AccountNo, AccountNo: r.AccountNo,
BankName: r.BankName, BankName: r.BankName,
AmountCent: r.AmountCent, AmountCent: r.AmountCent,
FeeCent: r.FeeCent, FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent, ActualAmountCent: r.ActualAmountCent,
Status: r.Status, Status: r.Status,
RawStatus: r.RawStatus, RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt, BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt, PaidAt: r.PaidAt,
OperatorID: r.OperatorID, OperatorID: r.OperatorID,
OperatorName: r.OperatorName, OperatorName: r.OperatorName,
Remark: r.Remark, Remark: r.Remark,
Category: r.Category, Category: r.Category,
CustomCategoryName: r.CustomCategoryName, VoucherURL: r.VoucherURL,
VoucherURL: r.VoucherURL, VoidedAt: r.VoidedAt,
VoidedAt: r.VoidedAt, VoidedBy: r.VoidedBy,
VoidedBy: r.VoidedBy, VoidedByName: r.VoidedByName,
VoidedByName: r.VoidedByName, VoidReason: r.VoidReason,
VoidReason: r.VoidReason,
} }
} }
@@ -37,7 +37,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
`CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, `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 admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT, id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT,
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT, amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME, status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME void_reason TEXT, created_at DATETIME
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO manual_disbursements if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark, (id, disbursement_no, category, payee_name, amount_cent, paid_at, remark,
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at) voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES VALUES
(20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), (20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, (21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil { at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
break break
} }
} }
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" { if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem) t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
} }
+58 -126
View File
@@ -34,25 +34,13 @@ type DisbursementQuery struct {
PageSize int 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 { type DashboardDTO struct {
Summary FinanceSummaryDTO `json:"summary"` Summary FinanceSummaryDTO `json:"summary"`
DailyItems []FinanceDailyDTO `json:"daily_items"` DailyItems []FinanceDailyDTO `json:"daily_items"`
PickupSummary PickupSummaryDTO `json:"pickup_summary"` PickupSummary PickupSummaryDTO `json:"pickup_summary"`
MohongSummary MohongSummaryDTO `json:"mohong_summary"` MohongSummary MohongSummaryDTO `json:"mohong_summary"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"` GeneratedAt time.Time `json:"generated_at"`
GeneratedAt time.Time `json:"generated_at"`
} }
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
@@ -113,67 +101,46 @@ type DisbursementListSummaryDTO struct {
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` 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 { type DisbursementItemDTO struct {
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"` SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"` BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"` OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"` WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"` UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"` PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"` PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"` UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"` SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"` AccountType string `json:"account_type"`
AccountName string `json:"account_name"` AccountName string `json:"account_name"`
AccountNo string `json:"account_no"` AccountNo string `json:"account_no"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"` AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"` FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"` ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"` Status string `json:"status"`
RawStatus string `json:"raw_status"` RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"` PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"` OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Category string `json:"category"` Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"` VoucherURL string `json:"voucher_url"`
VoucherURL string `json:"voucher_url"` VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"` VoidedByName string `json:"voided_by_name"`
VoidedByName string `json:"voided_by_name"` VoidReason string `json:"void_reason"`
VoidReason string `json:"void_reason"`
} }
type CreateManualDisbursementRequest struct { type CreateManualDisbursementRequest struct {
Category string `json:"category"` Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"` PayeeName string `json:"payee_name"`
PayeeName string `json:"payee_name"` AmountCent int64 `json:"amount_cent"`
AmountCent int64 `json:"amount_cent"` PaidAt time.Time `json:"paid_at"`
PaidAt time.Time `json:"paid_at"` Remark string `json:"remark"`
Remark string `json:"remark"` VoucherURL string `json:"voucher_url"`
VoucherURL string `json:"voucher_url"`
} }
type VoidManualDisbursementRequest struct { type VoidManualDisbursementRequest struct {
@@ -181,55 +148,22 @@ type VoidManualDisbursementRequest struct {
} }
type ManualDisbursementDTO struct { type ManualDisbursementDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"` DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"` Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"` PayeeName string `json:"payee_name"`
PayeeName string `json:"payee_name"` AmountCent int64 `json:"amount_cent"`
AmountCent int64 `json:"amount_cent"` PaidAt time.Time `json:"paid_at"`
PaidAt time.Time `json:"paid_at"` Remark string `json:"remark"`
Remark string `json:"remark"` VoucherURL string `json:"voucher_url"`
VoucherURL string `json:"voucher_url"` Status string `json:"status"`
Status string `json:"status"` CreatedBy uint64 `json:"created_by"`
CreatedBy uint64 `json:"created_by"` CreatedByName string `json:"created_by_name"`
CreatedByName string `json:"created_by_name"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"` VoidedByName string `json:"voided_by_name"`
VoidedByName string `json:"voided_by_name"` VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoidReason string `json:"void_reason"`
VoidReason string `json:"void_reason"` CreatedAt time.Time `json:"created_at"`
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 { type FinanceSummaryDTO struct {
@@ -296,8 +230,6 @@ type FinanceDailyDTO struct {
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"` ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"` 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"` DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
DisbursementPaidCount int64 `json:"disbursement_paid_count"` DisbursementPaidCount int64 `json:"disbursement_paid_count"`
} }
@@ -62,19 +62,6 @@ func (h *Handler) Disbursements(c *gin.Context) {
response.OK(c, result) 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) { func (h *Handler) CreateManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c) adminID, ok := financeAdminID(c)
if !ok { if !ok {
@@ -118,49 +105,6 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
response.OK(c, item) 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) { func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
start, end, ok := parseDateRange(c, 6) start, end, ok := parseDateRange(c, 6)
if !ok { if !ok {
@@ -252,34 +196,6 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
return query, true 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) { func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
loc := timeutil.ShanghaiLocation() loc := timeutil.ShanghaiLocation()
today := timeutil.ShanghaiNow() today := timeutil.ShanghaiNow()
@@ -322,12 +238,6 @@ func writeFinanceError(c *gin.Context, err error) {
response.NotFound(c, "线下出款记录不存在") response.NotFound(c, "线下出款记录不存在")
case errors.Is(err, ErrManualDisbursementNotPaid): case errors.Is(err, ErrManualDisbursementNotPaid):
response.BadRequest(c, "该线下出款记录已作废") 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: default:
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
} }
@@ -53,7 +53,7 @@ func normalRefundBizTypes() []string {
// settledSettlementStatuses 是视为"已完成结算"的结算状态集合。 // settledSettlementStatuses 是视为"已完成结算"的结算状态集合。
// 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。 // 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。
func settledSettlementStatuses() []string { func settledSettlementStatuses() []string {
return []string{"settled", "closed", "arbitrated"} return []string{"settled", "closed"}
} }
// nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。 // nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。
@@ -45,15 +45,3 @@ func TestRefundBizTypesIncludeMohongRefund(t *testing.T) {
t.Fatal("refundBizTypes missing mohong_refund") 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)
}
}
}
@@ -16,22 +16,21 @@ import (
) )
type manualDisbursementRecord struct { type manualDisbursementRecord struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
CustomCategoryName string PayeeName string
PayeeName string AmountCent int64
AmountCent int64 PaidAt time.Time
PaidAt time.Time Remark string
Remark string VoucherURL string
VoucherURL string Status string
Status string CreatedBy uint64
CreatedBy uint64 VoidedBy *uint64
VoidedBy *uint64 VoidedAt *time.Time
VoidedAt *time.Time VoidReason string
VoidReason string CreatedAt time.Time
CreatedAt time.Time UpdatedAt time.Time
UpdatedAt time.Time
} }
func (manualDisbursementRecord) TableName() string { func (manualDisbursementRecord) TableName() string {
@@ -49,16 +48,15 @@ func (r *Repository) CreateManualDisbursement(
return nil, err return nil, err
} }
record := manualDisbursementRecord{ record := manualDisbursementRecord{
DisbursementNo: disbursementNo, DisbursementNo: disbursementNo,
Category: req.Category, Category: req.Category,
CustomCategoryName: req.CustomCategoryName, PayeeName: req.PayeeName,
PayeeName: req.PayeeName, AmountCent: req.AmountCent,
AmountCent: req.AmountCent, PaidAt: req.PaidAt,
PaidAt: req.PaidAt, Remark: req.Remark,
Remark: req.Remark, VoucherURL: req.VoucherURL,
VoucherURL: req.VoucherURL, Status: "paid",
Status: "paid", CreatedBy: adminID,
CreatedBy: adminID,
} }
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
@@ -73,13 +71,12 @@ func (r *Repository) CreateManualDisbursement(
BizID: &id, BizID: &id,
Meta: meta, Meta: meta,
Detail: map[string]any{ Detail: map[string]any{
"disbursement_no": disbursementNo, "disbursement_no": disbursementNo,
"category": req.Category, "category": req.Category,
"custom_category_name": req.CustomCategoryName, "payee_name": req.PayeeName,
"payee_name": req.PayeeName, "amount_cent": req.AmountCent,
"amount_cent": req.AmountCent, "paid_at": req.PaidAt,
"paid_at": req.PaidAt, "has_voucher": req.VoucherURL != "",
"has_voucher": req.VoucherURL != "",
}, },
}) })
}) })
@@ -156,44 +153,42 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
return nil, err return nil, err
} }
return &ManualDisbursementDTO{ return &ManualDisbursementDTO{
ID: row.ID, ID: row.ID,
DisbursementNo: row.DisbursementNo, DisbursementNo: row.DisbursementNo,
Category: row.Category, Category: row.Category,
CustomCategoryName: row.CustomCategoryName, PayeeName: row.PayeeName,
PayeeName: row.PayeeName, AmountCent: row.AmountCent,
AmountCent: row.AmountCent, PaidAt: row.PaidAt,
PaidAt: row.PaidAt, Remark: row.Remark,
Remark: row.Remark, VoucherURL: row.VoucherURL,
VoucherURL: row.VoucherURL, Status: row.Status,
Status: row.Status, CreatedBy: row.CreatedBy,
CreatedBy: row.CreatedBy, CreatedByName: row.CreatedByName,
CreatedByName: row.CreatedByName, VoidedBy: row.VoidedBy,
VoidedBy: row.VoidedBy, VoidedByName: row.VoidedByName,
VoidedByName: row.VoidedByName, VoidedAt: row.VoidedAt,
VoidedAt: row.VoidedAt, VoidReason: row.VoidReason,
VoidReason: row.VoidReason, CreatedAt: row.CreatedAt,
CreatedAt: row.CreatedAt,
}, nil }, nil
} }
type manualDisbursementDetailRow struct { type manualDisbursementDetailRow struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
CustomCategoryName string PayeeName string
PayeeName string AmountCent int64
AmountCent int64 PaidAt time.Time
PaidAt time.Time Remark string
Remark string VoucherURL string
VoucherURL string Status string
Status string CreatedBy uint64
CreatedBy uint64 CreatedByName string
CreatedByName string VoidedBy *uint64
VoidedBy *uint64 VoidedByName string
VoidedByName string VoidedAt *time.Time
VoidedAt *time.Time VoidReason string
VoidReason string CreatedAt time.Time
CreatedAt time.Time
} }
func newManualDisbursementNo() (string, error) { func newManualDisbursementNo() (string, error) {
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
statements := []string{ statements := []string{
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE, id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT 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, status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
@@ -47,18 +47,17 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
repo := NewRepository(db) repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation()) paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "custom", Category: "operating_expense",
CustomCategoryName: "临时活动支出", PayeeName: "测试供应商",
PayeeName: "测试供应商", AmountCent: 8800,
AmountCent: 8800, PaidAt: paidAt,
PaidAt: paidAt, Remark: "测试运营支出",
Remark: "测试运营支出", VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"}) }, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil { if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err) t.Fatalf("创建其他线下出款失败: %v", err)
} }
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" { if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" {
t.Fatalf("创建结果不正确: %+v", created) t.Fatalf("创建结果不正确: %+v", created)
} }
@@ -83,13 +82,6 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
} }
func TestManualDisbursementValidation(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{}) service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ _, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown", Category: "unknown",
@@ -1,234 +0,0 @@
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
}
@@ -1,117 +0,0 @@
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,19 +1,13 @@
package adminfinance package adminfinance
import ( import (
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
redis *redis.Client
} }
func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository { func NewRepository(db *gorm.DB) *Repository {
repo := &Repository{db: db} return &Repository{db: db}
if len(redisClient) > 0 {
repo.redis = redisClient[0]
}
return repo
} }
@@ -15,9 +15,6 @@ var (
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement") ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
ErrManualDisbursementNotFound = errors.New("manual disbursement not found") ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid") 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 { type Service struct {
@@ -67,22 +64,6 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
return s.repo.Disbursements(ctx, query) 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( func (s *Service) CreateManualDisbursement(
ctx context.Context, ctx context.Context,
req CreateManualDisbursementRequest, req CreateManualDisbursementRequest,
@@ -93,12 +74,10 @@ func (s *Service) CreateManualDisbursement(
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
req.Category = strings.TrimSpace(req.Category) req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName) req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark) req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL) req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) || if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() || req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
@@ -125,51 +104,15 @@ func (s *Service) VoidManualDisbursement(
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta) 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 { func validManualDisbursementCategory(category string) bool {
switch category { switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom": case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
return true return true
default: default:
return false 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 { func validManualVoucherURL(raw string) bool {
if raw == "" { if raw == "" {
return true return true
@@ -28,44 +28,3 @@ 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"}). Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}).
Group("order_id") 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")
}
@@ -19,9 +19,8 @@ import (
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
encryptor crypto.Encryptor encryptor crypto.Encryptor
statusChangeNotifier func(userID uint64)
} }
type AuditMeta = auditlog.Meta type AuditMeta = auditlog.Meta
@@ -34,11 +33,6 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
return &Repository{db: db, encryptor: encryptor} 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) { func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil { if err != nil {
@@ -213,7 +207,6 @@ 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) { 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 { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
@@ -221,33 +214,27 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
} }
beforeStatus := user.Status beforeStatus := user.Status
beforeRisk := user.RiskStatus beforeRisk := user.RiskStatus
beforeTokenVersion := user.TokenVersion
user.Status = status user.Status = status
user.RiskStatus = riskStatus user.RiskStatus = riskStatus
if beforeStatus != status { if user.TokenVersion < 1 {
user.TokenVersion++ user.TokenVersion = 1
statusChanged = true
} }
user.TokenVersion++
if err := tx.Save(&user).Error; err != nil { if err := tx.Save(&user).Error; err != nil {
return err return err
} }
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{ return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
"user_id": user.ID, "user_id": user.ID,
"reason": reason, "reason": reason,
"before_status": beforeStatus, "before_status": beforeStatus,
"after_status": status, "after_status": status,
"before_risk_status": beforeRisk, "before_risk_status": beforeRisk,
"after_risk_status": riskStatus, "after_risk_status": riskStatus,
"before_token_version": beforeTokenVersion,
"after_token_version": user.TokenVersion,
}) })
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
if statusChanged && r.statusChangeNotifier != nil {
r.statusChangeNotifier(userID)
}
return r.Find(ctx, userID) return r.Find(ctx, userID)
} }
@@ -293,38 +293,6 @@ 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 { func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -37,13 +37,6 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo} 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) { func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+28 -4
View File
@@ -111,6 +111,7 @@ func (h *Handler) Login(c *gin.Context) {
writeAuthError(c, err) writeAuthError(c, err)
return return
} }
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result) response.OK(c, result)
} }
@@ -133,13 +134,10 @@ func (h *Handler) Refresh(c *gin.Context) {
} }
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken) tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil { if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
return
}
response.Unauthorized(c, "刷新令牌无效或已过期") response.Unauthorized(c, "刷新令牌无效或已过期")
return return
} }
setAccessCookie(c, tokens.AccessToken)
response.OK(c, tokens) response.OK(c, tokens)
} }
@@ -153,6 +151,16 @@ func (h *Handler) Refresh(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) { 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}) response.OK(c, gin.H{"logged_out": true})
} }
@@ -167,6 +175,7 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
writeAuthError(c, err) writeAuthError(c, err)
return return
} }
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result) response.OK(c, result)
} }
@@ -181,6 +190,7 @@ func (h *Handler) Register(c *gin.Context) {
writeAuthError(c, err) writeAuthError(c, err)
return return
} }
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result) response.OK(c, result)
} }
@@ -257,3 +267,17 @@ func currentUserID(c *gin.Context) (uint64, bool) {
userID, ok := val.(uint64) userID, ok := val.(uint64)
return userID, ok 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")
}
+16 -10
View File
@@ -32,19 +32,14 @@ func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, toke
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
var user model.User user, err := r.FindByID(ctx, id)
if err := r.db.WithContext(ctx). if err != nil {
Select("id, phone, status, token_version").
First(&user, id).Error; err != nil {
return nil, err return nil, err
} }
if user.Status != "active" { if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion {
return nil, ErrUserDisabled return nil, ErrUserDisabled
} }
if user.TokenVersion != tokenVersion { return user, nil
return nil, ErrTokenVersionMismatch
}
return &user, nil
} }
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) { func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
@@ -66,6 +61,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
RiskStatus: "normal", RiskStatus: "normal",
CreditScore: 100, CreditScore: 100,
Status: "active", Status: "active",
TokenVersion: 1,
LastLoginAt: &now, LastLoginAt: &now,
} }
@@ -93,7 +89,16 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.
} }
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error { func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).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
} }
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) { func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
@@ -106,6 +111,7 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
RiskStatus: "normal", RiskStatus: "normal",
CreditScore: 100, CreditScore: 100,
Status: "active", Status: "active",
TokenVersion: 1,
LastLoginAt: &now, LastLoginAt: &now,
} }
+13 -10
View File
@@ -33,7 +33,6 @@ var (
ErrPasswordTooWeak = errors.New("password too weak") ErrPasswordTooWeak = errors.New("password too weak")
ErrLoginLocked = errors.New("login locked") ErrLoginLocked = errors.New("login locked")
ErrUserAlreadyExists = errors.New("user already exists") ErrUserAlreadyExists = errors.New("user already exists")
ErrTokenVersionMismatch = errors.New("user token version mismatch")
) )
const ( const (
@@ -181,7 +180,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{}, ErrUserDisabled return LoginResult{}, ErrUserDisabled
} }
tokens, err := s.generateUserTokenPair(user) tokens, err := s.issueTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
@@ -191,7 +190,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
} }
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) { func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
if s.jwt == nil || s.users == nil { if s.users == nil {
return TokenPair{}, ErrDependencyUnavailable return TokenPair{}, ErrDependencyUnavailable
} }
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
@@ -202,16 +201,20 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenP
if err != nil { if err != nil {
return TokenPair{}, err return TokenPair{}, err
} }
return s.generateUserTokenPair(user) return s.issueTokenPair(user)
} }
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) { func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) {
if s.jwt == nil {
return TokenPair{}, ErrDependencyUnavailable
}
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) 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
}
return s.users.RevokeTokens(ctx, userID)
}
func codeKey(phone string) string { func codeKey(phone string) string {
return "sms:code:login:" + phone return "sms:code:login:" + phone
} }
@@ -279,7 +282,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
_ = clearLoginFailure(ctx, s.redis, phone, clientIP) _ = clearLoginFailure(ctx, s.redis, phone, clientIP)
tokens, err := s.generateUserTokenPair(user) tokens, err := s.issueTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
@@ -384,7 +387,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
return LoginResult{}, ErrUserDisabled return LoginResult{}, ErrUserDisabled
} }
tokens, err := s.generateUserTokenPair(user) tokens, err := s.issueTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
@@ -1,71 +0,0 @@
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)
}
}
@@ -1,59 +0,0 @@
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)
}
@@ -1,43 +0,0 @@
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()
}
@@ -1,199 +0,0 @@
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)
}
}
+1 -14
View File
@@ -53,18 +53,6 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
if err != nil { if err != nil {
return nil, err 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{ dto := ConversationDTO{
ID: conversation.ID, ID: conversation.ID,
OrderID: conversation.OrderID, OrderID: conversation.OrderID,
@@ -74,12 +62,11 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
Title: conversation.Title, Title: conversation.Title,
Status: conversation.Status, Status: conversation.Status,
Role: "admin", // 管理员角色 Role: "admin", // 管理员角色
AdminRemark: state.Remark,
Participants: participants, Participants: participants,
LastMessageID: conversation.LastMessageID, LastMessageID: conversation.LastMessageID,
LastMessagePreview: conversation.LastMessagePreview, LastMessagePreview: conversation.LastMessagePreview,
LastMessageAt: conversation.LastMessageAt, LastMessageAt: conversation.LastMessageAt,
UnreadCount: unreadCount, UnreadCount: 0, // 管理员不计未读
CreatedAt: conversation.CreatedAt, CreatedAt: conversation.CreatedAt,
UpdatedAt: conversation.UpdatedAt, UpdatedAt: conversation.UpdatedAt,
} }
@@ -24,7 +24,6 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
&model.RentalOrder{}, &model.RentalOrder{},
&model.ChatConversation{}, &model.ChatConversation{},
&model.ChatParticipant{}, &model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{}, &model.ChatMessage{},
); err != nil { ); err != nil {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
+13 -16
View File
@@ -21,7 +21,6 @@ type ConversationDTO struct {
Title string `json:"title"` Title string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
Role string `json:"role"` Role string `json:"role"`
AdminRemark string `json:"admin_remark,omitempty"`
Participants []ParticipantDTO `json:"participants,omitempty"` Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"` LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"` LastMessagePreview string `json:"last_message_preview"`
@@ -30,7 +29,6 @@ type ConversationDTO struct {
LastSenderID uint64 `json:"last_sender_id,omitempty"` LastSenderID uint64 `json:"last_sender_id,omitempty"`
LastSenderRole string `json:"last_sender_role,omitempty"` LastSenderRole string `json:"last_sender_role,omitempty"`
UnreadCount int64 `json:"unread_count"` UnreadCount int64 `json:"unread_count"`
NeedsReply bool `json:"needs_reply"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -55,20 +53,19 @@ type ParticipantDTO struct {
} }
type MessageDTO struct { type MessageDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"` ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"` SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"` SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"` SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"` SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"` SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"` IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"` IsRead bool `json:"is_read"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"` AttachmentURLS []string `json:"attachment_urls"`
AdminAttentionType string `json:"admin_attention_type,omitempty"` CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
} }
type UnreadCountDTO struct { type UnreadCountDTO struct {
@@ -25,27 +25,6 @@ func (h *Handler) AdminList(c *gin.Context) {
response.OK(c, result) 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) { func (h *Handler) AdminDetail(c *gin.Context) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {
@@ -82,19 +61,6 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
h.markRead(c, Principal{Type: "admin", ID: adminID}) 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) { func (h *Handler) AdminTransfer(c *gin.Context) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {
@@ -128,7 +128,6 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
return return
} }
if errors.Is(err, ErrQrCodeOCRUnavailable) { if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return return
} }
@@ -165,7 +164,6 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
return return
} }
if errors.Is(err, ErrQrCodeOCRUnavailable) { if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return return
} }
+7 -12
View File
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
if handoffSupportID > 0 { if handoffSupportID > 0 {
message += ",卖号组客服已接入" message += ",卖号组客服已接入"
} }
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid") return sendSystemMessage(tx, conv.ID, message)
} }
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 // RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
@@ -248,18 +248,13 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
} }
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { 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{ message := model.ChatMessage{
ConversationID: conversationID, ConversationID: conversationID,
SenderType: "system", SenderType: "system",
SenderRole: "system", SenderRole: "system",
ContentType: "system", ContentType: "system",
Content: content, Content: content,
AttachmentURLS: emptyJSONList(), AttachmentURLS: emptyJSONList(),
AdminAttentionType: attentionType,
} }
if err := tx.Create(&message).Error; err != nil { if err := tx.Create(&message).Error; err != nil {
+23 -87
View File
@@ -40,15 +40,12 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
var rows []model.ChatMessage var rows []model.ChatMessage
if err := query.Order("id DESC"). if err := query.Order("id ASC").
Offset(offset). Offset(offset).
Limit(pageSize). Limit(pageSize).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err 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) items, err := r.toMessageDTOs(ctx, principal, rows)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -104,21 +101,12 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Content: req.Content, Content: req.Content,
AttachmentURLS: encodeStringList(req.AttachmentURLS), AttachmentURLS: encodeStringList(req.AttachmentURLS),
} }
if principal.Type == "user" {
message.AdminAttentionType = "user_inquiry"
}
if err := tx.Create(&message).Error; err != nil { if err := tx.Create(&message).Error; err != nil {
return err return err
} }
conversation.LastMessageID = &message.ID conversation.LastMessageID = &message.ID
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
conversation.LastMessageAt = &message.CreatedAt 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 { if err := tx.Save(&conversation).Error; err != nil {
return err return err
} }
@@ -155,17 +143,16 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Type: "new_message", Type: "new_message",
ConversationID: conversationID, ConversationID: conversationID,
Message: &chathub.MessageData{ Message: &chathub.MessageData{
ID: msg.ID, ID: msg.ID,
ConversationID: msg.ConversationID, ConversationID: msg.ConversationID,
SenderType: msg.SenderType, SenderType: msg.SenderType,
SenderID: msg.SenderID, SenderID: msg.SenderID,
SenderRole: msg.SenderRole, SenderRole: msg.SenderRole,
SenderName: msg.SenderName, SenderName: msg.SenderName,
ContentType: msg.ContentType, ContentType: msg.ContentType,
Content: msg.Content, Content: msg.Content,
AttachmentURLS: msg.AttachmentURLS, AttachmentURLS: msg.AttachmentURLS,
AdminAttentionType: msg.AdminAttentionType, CreatedAt: msg.CreatedAt.Format(time.RFC3339),
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
}, },
} }
r.hub.NotifyConversation(conversationID, event) r.hub.NotifyConversation(conversationID, event)
@@ -179,44 +166,21 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
now := time.Now() now := time.Now()
updated := false updated := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 管理员可以不是 participant,直接返回成功
if principal.Type == "admin" { if principal.Type == "admin" {
var conversation model.ChatConversation // 尝试查找 participant 记录,如果有就更新
if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil { var participant model.ChatParticipant
if errors.Is(err, gorm.ErrRecordNotFound) { err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
return ErrConversationNotFound conversationID, principal.Type, principal.ID).First(&participant).Error
} if err == nil {
return err // 有 participant 记录,更新已读时间
} updated = true
lastMessageID := uint64(0) return tx.Model(&participant).Update("last_read_at", now).Error
if conversation.LastMessageID != nil { } else if errors.Is(err, gorm.ErrRecordNotFound) {
lastMessageID = *conversation.LastMessageID // 没有 participant 记录,直接返回成功(管理员无需记录已读)
}
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 return nil
} }
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err
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
@@ -245,31 +209,3 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
} }
return nil 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
})
}
+11 -33
View File
@@ -25,7 +25,6 @@ type conversationRow struct {
Title string Title string
Status string Status string
Role string Role string
AdminRemark string
LastMessageID *uint64 LastMessageID *uint64
LastMessagePreview string LastMessagePreview string
LastMessageAt *time.Time LastMessageAt *time.Time
@@ -33,7 +32,6 @@ type conversationRow struct {
LastSenderID uint64 LastSenderID uint64
LastSenderRole string LastSenderRole string
UnreadCount int64 UnreadCount int64
NeedsReply int64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -46,9 +44,8 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
SELECT COUNT(1) SELECT COUNT(1)
FROM chat_messages AS cm FROM chat_messages AS cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND cm.sender_type <> 'system' AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND NOT (cm.sender_type = ? AND cm.sender_id = ?) AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
) AS unread_count`, principal.Type, principal.ID). ) AS unread_count`, principal.Type, principal.ID).
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
@@ -62,7 +59,6 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
Where("c.status = ?", "active"). Where("c.status = ?", "active").
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID). 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)"). Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
Count(&total).Error Count(&total).Error
return total, err return total, err
@@ -85,29 +81,15 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat
return &participant, nil return &participant, nil
} }
func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { 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 var rows []model.ChatParticipant
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil {
Where("conversation_id IN ?", uniqueIDs(conversationIDs)).
Order("conversation_id ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := make([]ParticipantDTO, 0, len(rows))
for _, row := range rows { for _, row := range rows {
name := "系统" name := "系统"
avatar := "" avatar := ""
@@ -118,20 +100,19 @@ func (r *Repository) participantsForConversations(ctx context.Context, conversat
if row.ParticipantType == "admin" { if row.ParticipantType == "admin" {
name = adminNames[row.ParticipantID] name = adminNames[row.ParticipantID]
} }
result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{ items = append(items, ParticipantDTO{
ID: row.ID, ID: row.ID,
ConversationID: row.ConversationID, ConversationID: row.ConversationID,
ParticipantType: row.ParticipantType, ParticipantType: row.ParticipantType,
ParticipantID: row.ParticipantID, ParticipantID: row.ParticipantID,
Role: row.Role, Role: row.Role,
Remark: row.Remark,
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
AvatarURL: avatar, AvatarURL: avatar,
LastReadAt: row.LastReadAt, LastReadAt: row.LastReadAt,
JoinedAt: row.JoinedAt, JoinedAt: row.JoinedAt,
}) })
} }
return result, nil return items, nil
} }
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
userIDs := make([]uint64, 0) userIDs := make([]uint64, 0)
@@ -185,12 +166,11 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
SenderAvatar: avatar, SenderAvatar: avatar,
IsSelf: isSelf, IsSelf: isSelf,
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType, ContentType: row.ContentType,
Content: row.Content, Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS), AttachmentURLS: decodeStringList(row.AttachmentURLS),
AdminAttentionType: row.AdminAttentionType, CreatedAt: row.CreatedAt,
CreatedAt: row.CreatedAt,
}) })
} }
return items, nil return items, nil
@@ -325,7 +305,6 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
Title: row.Title, Title: row.Title,
Status: row.Status, Status: row.Status,
Role: row.Role, Role: row.Role,
AdminRemark: row.AdminRemark,
Participants: participants, Participants: participants,
LastMessageID: row.LastMessageID, LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview, LastMessagePreview: row.LastMessagePreview,
@@ -334,7 +313,6 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
LastSenderID: row.LastSenderID, LastSenderID: row.LastSenderID,
LastSenderRole: row.LastSenderRole, LastSenderRole: row.LastSenderRole,
UnreadCount: row.UnreadCount, UnreadCount: row.UnreadCount,
NeedsReply: row.NeedsReply > 0,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
} }
+79 -452
View File
@@ -6,46 +6,21 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io" "io"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/textproto" "net/textproto"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
"hfb_sys/backend/internal/logging"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
) )
const ( const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024 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:" ocrJobRedisKeyPrefix = "ocr:job:"
ocrJobRedisTTL = 10 * time.Minute ocrJobRedisTTL = 10 * time.Minute
@@ -63,87 +38,9 @@ var (
ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期") ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期")
ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis") ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis")
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} 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 { type QrCodeOCRResult struct {
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
Candidates []string `json:"candidates"` Candidates []string `json:"candidates"`
@@ -182,46 +79,13 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize { if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile return nil, "", ErrQrCodeOCRInvalidFile
} }
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) if contentType == "" {
if contentType == "" || !strings.HasPrefix(contentType, "image/") { contentType = http.DetectContentType(data)
contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
} }
if !strings.HasPrefix(contentType, "image/") { if !strings.HasPrefix(contentType, "image/") {
return nil, "", ErrQrCodeOCRInvalidFile return nil, "", ErrQrCodeOCRInvalidFile
} }
config, _, err := image.DecodeConfig(bytes.NewReader(data)) return data, contentType, nil
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 { func ocrJobKey(jobID string) string {
@@ -268,15 +132,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
return nil, err return nil, err
} }
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data) paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID) jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rawText, err := r.fetchPaddleOCRText(ctx, jsonURL) rawText, err := fetchPaddleOCRText(ctx, jsonURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -309,7 +173,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str
return "", err return "", err
} }
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data) paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -342,48 +206,47 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} }
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID 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)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
}
return req, err
})
if err != nil { if err != nil {
// 保留当前状态,让下一次前端轮询继续尝试。 return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
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 record, nil return record, nil
} }
var statusPayload paddleOCRJobStatusResponse var statusPayload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &statusPayload); err != nil { 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 return record, nil
} }
switch statusPayload.Data.State { switch statusPayload.Data.State {
case "pending", "running": case "pending", "running":
record.Status = ocrJobStatusRunning record.Status = ocrJobStatusRunning
r.saveOCRJobRecordWithLog(ctx, jobID, record) _ = r.saveOCRJobRecord(ctx, jobID, record)
return record, nil return record, nil
case "done": case "done":
if statusPayload.Data.ResultURL.JSONURL == "" { if statusPayload.Data.ResultURL.JSONURL == "" {
record.Status = ocrJobStatusFailed record.Status = ocrJobStatusFailed
record.Error = "未返回识别结果地址" record.Error = "未返回识别结果地址"
r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址", _ = r.saveOCRJobRecord(ctx, jobID, record)
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 return record, nil
} }
rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
if err != nil { if err != nil {
record.Status = ocrJobStatusFailed record.Status = ocrJobStatusFailed
record.Error = err.Error() record.Error = err.Error()
r.saveOCRJobRecordWithLog(ctx, jobID, record) _ = r.saveOCRJobRecord(ctx, jobID, record)
return record, nil return record, nil
} }
candidates := parseQrCodeGroupNameCandidates(rawText) candidates := parseQrCodeGroupNameCandidates(rawText)
@@ -397,7 +260,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
Candidates: candidates, Candidates: candidates,
RawText: rawText, RawText: rawText,
} }
r.saveOCRJobRecordWithLog(ctx, jobID, record) _ = r.saveOCRJobRecord(ctx, jobID, record)
return record, nil return record, nil
case "failed": case "failed":
@@ -407,36 +270,14 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} else { } else {
record.Error = "PaddleOCR 识别失败" record.Error = "PaddleOCR 识别失败"
} }
r.ocrLogger(ctx).Error("PaddleOCR 任务执行失败", _ = r.saveOCRJobRecord(ctx, jobID, record)
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 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 return record, nil
} }
func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) { func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
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{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
_ = writer.WriteField("model", config.Model) _ = writer.WriteField("model", config.Model)
@@ -460,69 +301,51 @@ func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRCo
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
requestBody := append([]byte(nil), body.Bytes()...) req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body)
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", formContentType)
}
return req, err
})
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
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)
}
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))
} }
var payload paddleOCRJobResponse var payload paddleOCRJobResponse
if err := json.Unmarshal(respBody, &payload); err != nil { if err := json.Unmarshal(respBody, &payload); err != nil {
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
} }
if payload.Data.JobID == "" { if payload.Data.JobID == "" {
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId")) return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable)
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 return payload.Data.JobID, nil
} }
func normalizeOCRFilename(filename string) string { func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
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++ { for i := 0; i < 12; i++ {
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
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)
}
return req, err
})
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
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))
} }
var payload paddleOCRJobStatusResponse var payload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &payload); err != nil { if err := json.Unmarshal(respBody, &payload); err != nil {
upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
} }
switch payload.Data.State { switch payload.Data.State {
case "done": case "done":
@@ -536,232 +359,36 @@ func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConf
} }
return "", ErrQrCodeOCRUnavailable return "", ErrQrCodeOCRUnavailable
} }
if err := r.ocrSleep(ctx, 2*time.Second); err != nil { select {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) case <-ctx.Done():
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(2 * time.Second):
} }
} }
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
} }
func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
})
if err != nil { if err != nil {
return "", err 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 collectPaddleOCRJSONLText(string(body)), nil 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 { func collectPaddleOCRJSONLText(raw string) string {
var texts []string var texts []string
for _, line := range strings.Split(raw, "\n") { for _, line := range strings.Split(raw, "\n") {
@@ -1,224 +0,0 @@
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()
}
+3 -25
View File
@@ -2,12 +2,7 @@ package chat
import ( import (
"context" "context"
"errors"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const ( const (
@@ -18,26 +13,9 @@ const (
) )
func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error {
if principal.Type != "admin" { return r.db.WithContext(ctx).Model(&model.ChatParticipant{}).
return ErrPermissionDenied Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).
} Update("remark", remark).Error
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) { func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) {
var replies []model.ChatQuickReply var replies []model.ChatQuickReply
+6 -60
View File
@@ -1,81 +1,27 @@
package chat package chat
import ( import (
"context"
"errors" "errors"
"net/http"
"time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub" "hfb_sys/backend/internal/modules/chathub"
"time"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
hub *chathub.Hub hub *chathub.Hub
redis *redis.Client redis *redis.Client
logger *zap.Logger
ocrHTTPClient *http.Client
ocrGate *ocrRequestGate
ocrSleep func(context.Context, time.Duration) error
} }
const ( const (
defaultSupportRoleCode = "cs" defaultSupportRoleCode = "cs"
) )
type RepositoryOption func(*Repository) func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository {
return &Repository{db: db, hub: hub, redis: redis}
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) { func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation var existing model.ChatConversation
-21
View File
@@ -131,16 +131,6 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio
return s.repo.MarkRead(ctx, principal, conversationID) 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 { func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -165,21 +155,10 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword) 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 { func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable 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) return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark)
} }
+102 -177
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"strings" "strings"
"time" "time"
) )
@@ -34,17 +33,15 @@ type AdminConversationCountsDTO struct {
} }
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error { func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if principal.Type != "admin" { // 验证当前操作者是会话参与者
return ErrPermissionDenied current, err := r.findParticipant(tx, principal, conversationID, false)
} if err != nil {
var conversation model.ChatConversation
if err := tx.First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err return err
} }
if current.Role != "support" || current.ParticipantType != "admin" {
return ErrPermissionDenied
}
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
if !adminIsSupport(tx, toAdminID) { if !adminIsSupport(tx, toAdminID) {
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
@@ -59,45 +56,29 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
if count > 0 { if count > 0 {
return fmt.Errorf("该客服已在会话中") return fmt.Errorf("该客服已在会话中")
} }
var current model.ChatParticipant // 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID). if err := tx.Model(&model.ChatParticipant{}).
First(&current).Error Where("id = ?", current.ID).
if currentErr == nil { Updates(map[string]interface{}{
if current.Role != "support" {
return ErrPermissionDenied
}
// 只转接当前客服本人,避免误删同群里的其他客服。
if err := tx.Model(&current).Updates(map[string]interface{}{
"participant_id": toAdminID, "participant_id": toAdminID,
"joined_at": time.Now(), "joined_at": time.Now(),
"last_read_at": nil,
}).Error; err != nil { }).Error; err != nil {
return err return err
}
} else if errors.Is(currentErr, gorm.ErrRecordNotFound) {
// 未分配会话允许从“全部/未分配”直接指派给目标客服。
if err := tx.Create(&model.ChatParticipant{
ConversationID: conversationID,
ParticipantType: "admin",
ParticipantID: toAdminID,
Role: "support",
JoinedAt: time.Now(),
}).Error; err != nil {
return err
}
} else {
return currentErr
} }
if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil { // 添加系统消息记录转接
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "会话已转接给其他客服",
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
return err return err
} }
return nil 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) { func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -221,8 +202,11 @@ 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) { func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword) var total int64
if err != nil { countDB := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(countDB, filter, principal)
applyAdminChatStageFilter(countDB, stage, principal)
if err := countDB.Count(&total).Error; err != nil {
return nil, err return nil, err
} }
@@ -232,21 +216,16 @@ 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, 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, c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
COALESCE(cp_me.role, 'admin') AS role, COALESCE(cp_me.role, 'admin') AS role,
COALESCE(cas.remark, '') AS admin_remark, lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id, lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
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, lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply, CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
(
SELECT COUNT(1) SELECT COUNT(1)
FROM chat_messages AS cm FROM chat_messages AS cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user') AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND cm.id > COALESCE(cas.last_read_message_id, 0) AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
) AS unread_count`) ) END AS unread_count`, principal.Type, principal.ID)
applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatOwnershipFilter(queryDB, filter, principal)
applyAdminChatStageFilter(queryDB, stage, principal) applyAdminChatStageFilter(queryDB, stage, principal)
if err := queryDB. if err := queryDB.
@@ -257,59 +236,33 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
return nil, err 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)) items := make([]ConversationDTO, 0, len(rows))
for _, row := range rows { for _, row := range rows {
items = append(items, row.toDTO(participantsByConversation[row.ID])) participants, err := r.participants(ctx, row.ID)
if err != nil {
return nil, err
}
items = append(items, row.toDTO(participants))
} }
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
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 { if err != nil {
return nil, err return nil, err
} }
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO) return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
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 { func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
db := r.db.WithContext(ctx).Table("chat_conversations AS c"). 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_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 chat_messages AS lm ON lm.id = c.last_message_id").
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_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(explicit_lo.listing_id, c.listing_id)") 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)")
if keyword != "" { 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 + "%" like := "%" + keyword + "%"
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 ? db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
OR renter.phone LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw SELECT 1 FROM chat_participants AS cp_kw
@@ -319,54 +272,32 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
return db return db
} }
type adminConversationCountsResult struct { func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
Total int64 counts := &AdminConversationCountsDTO{
DTO *AdminConversationCountsDTO Ownership: make(map[string]int64, len(adminChatFilters)),
} Stages: make(map[string]int64, len(adminChatStages)),
}
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 { for _, item := range adminChatFilters {
ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal) db := r.adminConversationBase(ctx, principal, keyword)
} applyAdminChatOwnershipFilter(db, item, principal)
stages := make(map[string]string, len(adminChatStages)) applyAdminChatStageFilter(db, stage, principal)
stageArgs := make(map[string][]interface{}, len(adminChatStages)) var count int64
for _, item := range adminChatStages { if err := db.Count(&count).Error; err != nil {
stages[item], stageArgs[item] = adminChatStageCondition(item, principal) return nil, err
} }
counts.Ownership[item] = count
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 { for _, item := range adminChatStages {
addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item]) 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 {
return nil, err
}
counts.Stages[item] = count
} }
return counts, nil
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
}
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 { func normalizeAdminChatFilter(filter string) string {
@@ -388,61 +319,55 @@ func normalizeAdminChatStage(stage string) string {
} }
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) { func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
condition, args := adminChatOwnershipCondition(filter, principal) switch filter {
db.Where(condition, args...) 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)
}
} }
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { 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 { switch stage {
case adminChatStagePending: case adminChatStagePending:
return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil 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)
case adminChatStageUnjoined: case adminChatStageUnjoined:
return latestID + " IS NULL", nil db.Where("lo.id IS NULL")
case adminChatStageHandoff: case adminChatStageHandoff:
return latestStatus + " = 'pending_handoff'", nil db.Where("lo.status = ?", "pending_handoff")
case adminChatStageRenting: case adminChatStageRenting:
return latestStatus + " IN ('renting', 'overdue')", nil db.Where("lo.status IN ?", []string{"renting", "overdue"})
case adminChatStageAfterSale: case adminChatStageAfterSale:
return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil 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")
case adminChatStageEnded: case adminChatStageEnded:
return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
case adminChatStageAll: case adminChatStageAll:
fallthrough return
default:
return "1 = 1", nil
} }
} }
func adminNeedsReplyExpression() string {
return "c.last_attention_message_id > c.last_admin_message_id"
}
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 // 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 // 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
+7 -70
View File
@@ -1,7 +1,6 @@
package chathub package chathub
import ( import (
"context"
"fmt" "fmt"
"time" "time"
@@ -9,21 +8,16 @@ import (
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
const heartbeatInterval = 30 * time.Second const heartbeatInterval = 30 * time.Second
type Handler struct { type Handler struct {
hub *Hub hub *Hub
logger *zap.Logger
} }
func NewHandler(hub *Hub, logger *zap.Logger) *Handler { func NewHandler(hub *Hub) *Handler {
if logger == nil { return &Handler{hub: hub}
logger = zap.NewNop()
}
return &Handler{hub: hub, logger: logger}
} }
// UserEvents 处理用户端 SSE 连接: GET /api/chats/events // UserEvents 处理用户端 SSE 连接: GET /api/chats/events
@@ -57,23 +51,8 @@ func (h *Handler) AdminEvents(c *gin.Context) {
} }
func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) { 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) ch := h.hub.Subscribe(pType, pID)
h.logger.Info("SSE 连接建立", h.connectionLogFields(c, pType, pID)...) defer h.hub.Unsubscribe(pType, pID, ch)
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("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@@ -81,11 +60,7 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
// 发送初始连接确认 // 发送初始连接确认
if _, err := fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n"); err != nil { fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n")
disconnectReason = "initial_write_error"
disconnectErr = err
return
}
c.Writer.Flush() c.Writer.Flush()
heartbeat := time.NewTicker(heartbeatInterval) heartbeat := time.NewTicker(heartbeatInterval)
@@ -96,55 +71,17 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
for { for {
select { select {
case <-clientGone: case <-clientGone:
disconnectReason = contextDisconnectReason(c.Request.Context().Err())
return return
case <-heartbeat.C: case <-heartbeat.C:
if _, err := fmt.Fprintf(c.Writer, ":heartbeat\n\n"); err != nil { fmt.Fprintf(c.Writer, ":heartbeat\n\n")
disconnectReason = "heartbeat_write_error"
disconnectErr = err
return
}
c.Writer.Flush() c.Writer.Flush()
case event, ok := <-ch: case event, ok := <-ch:
if !ok { if !ok {
disconnectReason = "subscription_closed"
return return
} }
data := MarshalEvent(event) data := MarshalEvent(event)
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data); err != nil { fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data)
disconnectReason = "event_write_error"
disconnectErr = err
return
}
c.Writer.Flush() 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"
}
}
@@ -1,60 +0,0 @@
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)
}
}
+10 -40
View File
@@ -20,17 +20,16 @@ type ChatEvent struct {
// MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。 // MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
type MessageData struct { type MessageData struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"` ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"` SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"` SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"` SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"` SenderName string `json:"sender_name"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"` AttachmentURLS []string `json:"attachment_urls"`
AdminAttentionType string `json:"admin_attention_type,omitempty"` CreatedAt string `json:"created_at"`
CreatedAt string `json:"created_at"`
} }
// principal 标识一个连接方。 // principal 标识一个连接方。
@@ -87,22 +86,6 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) {
h.mu.Unlock() 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 查询会话参与者,向所有在线参与者推送事件。 // NotifyConversation 查询会话参与者,向所有在线参与者推送事件。
func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) { func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) {
if h.db == nil { if h.db == nil {
@@ -191,16 +174,3 @@ func (h *Hub) OnlineCount() int {
} }
return count 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
}
@@ -1,32 +0,0 @@
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)
}
}
+15 -35
View File
@@ -153,7 +153,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
"order_no": order.OrderNo, "order_no": order.OrderNo,
"result": req.Result, "result": req.Result,
"remark": req.Remark, "remark": req.Remark,
"input_amount_cent": arbitrationInputAmountCent(req), "input_amount_cent": req.AmountCent,
"renter_refund_amount_cent": settlement.RenterRefundAmountCent, "renter_refund_amount_cent": settlement.RenterRefundAmountCent,
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
@@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or
if err != nil { if err != nil {
return settlement, nil, linkedCheckout, 0, err return settlement, nil, linkedCheckout, 0, err
} }
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req) depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req)
if err != nil { if err != nil {
return settlement, nil, linkedCheckout, 0, err return settlement, nil, linkedCheckout, 0, err
} }
@@ -299,24 +299,21 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or
return 0, ErrInvalidDispute return 0, ErrInvalidDispute
} }
func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) { func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) {
switch req.Result { switch req.Result {
case "actual_settlement": case "actual_settlement":
if req.AmountCent == nil { if req.AmountCent <= 0 {
if checkout != nil {
return arbitrationCheckoutDepositDeductCent(*checkout), nil
}
return 0, nil return 0, nil
} }
if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent { if req.AmountCent > order.DepositAmountCent {
return 0, ErrInvalidDispute return 0, ErrInvalidDispute
} }
return *req.AmountCent, nil return req.AmountCent, nil
case "release_deposit": case "release_deposit":
return 0, nil return 0, nil
case "deduct_deposit", "compensate_owner": case "deduct_deposit", "compensate_owner":
deductAmountCent := arbitrationInputAmountCent(req) deductAmountCent := req.AmountCent
if req.AmountCent == nil || deductAmountCent <= 0 { if deductAmountCent <= 0 {
deductAmountCent = order.DepositAmountCent deductAmountCent = order.DepositAmountCent
} }
if deductAmountCent > order.DepositAmountCent { if deductAmountCent > order.DepositAmountCent {
@@ -328,23 +325,6 @@ func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model
} }
} }
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) { func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) {
order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
@@ -370,7 +350,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
} }
func isPlatformManagedOrder(order model.RentalOrder) bool { func isPlatformManagedOrder(order model.RentalOrder) bool {
return order.SettlementMode == "platform_managed" return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
} }
func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error { func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
@@ -479,15 +459,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(totalCent, "仲裁全额退款") addRenterRefund(totalCent, "仲裁全额退款")
settlement.RenterDepositRefundCent = depositAmountCent settlement.RenterDepositRefundCent = depositAmountCent
case "partial_refund": case "partial_refund":
if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent { if req.AmountCent <= 0 || req.AmountCent > totalCent {
return settlement, ErrInvalidDispute return settlement, ErrInvalidDispute
} }
addRenterRefund(*req.AmountCent, "仲裁部分退款") addRenterRefund(req.AmountCent, "仲裁部分退款")
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent) settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent)
// 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。 // 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。
addOwnerIncome( addOwnerIncome(
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent), partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent),
"仲裁剩余金额结算给号主", "仲裁剩余金额结算给号主",
) )
case "release_deposit": case "release_deposit":
@@ -495,8 +475,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(depositAmountCent, "仲裁释放押金给租客") addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
settlement.RenterDepositRefundCent = depositAmountCent settlement.RenterDepositRefundCent = depositAmountCent
case "deduct_deposit", "compensate_owner": case "deduct_deposit", "compensate_owner":
deductAmountCent := arbitrationInputAmountCent(req) deductAmountCent := req.AmountCent
if req.AmountCent == nil || deductAmountCent <= 0 { if deductAmountCent <= 0 {
deductAmountCent = depositAmountCent deductAmountCent = depositAmountCent
} }
if deductAmountCent > depositAmountCent { if deductAmountCent > depositAmountCent {
+35 -36
View File
@@ -9,41 +9,40 @@ import (
) )
type DisputeDTO struct { type DisputeDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"` OrderStatus string `json:"order_status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"` SettlementStatus string `json:"settlement_status"`
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
ListingNo string `json:"listing_no"` ListingNo string `json:"listing_no"`
Title string `json:"title"` Title string `json:"title"`
OwnerID uint64 `json:"owner_id"` OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"` RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone"` OwnerPhone string `json:"owner_phone"`
RenterPhone string `json:"renter_phone"` RenterPhone string `json:"renter_phone"`
InitiatorID uint64 `json:"initiator_id"` InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"` InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
TargetUserID uint64 `json:"target_user_id"` TargetUserID uint64 `json:"target_user_id"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Description string `json:"description"` Description string `json:"description"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"` EvidenceURLS datatypes.JSON `json:"evidence_urls"`
PreviousOrderStatus string `json:"previous_order_status"` PreviousOrderStatus string `json:"previous_order_status"`
PreviousHandoffStatus string `json:"previous_handoff_status"` PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"` PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"` CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"` PreviousCheckoutStatus string `json:"previous_checkout_status"`
PreviousCheckoutStatus string `json:"previous_checkout_status"` ArbitrationResult string `json:"arbitration_result"`
ArbitrationResult string `json:"arbitration_result"` ArbitrationRemark string `json:"arbitration_remark"`
ArbitrationRemark string `json:"arbitration_remark"` HandledBy *uint64 `json:"handled_by"`
HandledBy *uint64 `json:"handled_by"` HandledAt *time.Time `json:"handled_at"`
HandledAt *time.Time `json:"handled_at"` CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type CreateRequest struct { type CreateRequest struct {
@@ -62,7 +61,7 @@ type AdminCreateRequest struct {
type ArbitrateRequest struct { type ArbitrateRequest struct {
Result string `json:"result" binding:"required"` Result string `json:"result" binding:"required"`
Remark string `json:"remark" 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"` ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"` ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"`
} }
+47 -49
View File
@@ -10,59 +10,57 @@ import (
type disputeRow struct { type disputeRow struct {
model.Dispute model.Dispute
OrderNo string OrderNo string
OrderStatus string OrderStatus string
HandoffStatus string HandoffStatus string
SettlementStatus string SettlementStatus string
ExtraItemOriginalAmountCent int64 ExtraItemOriginalAmountCent int64
ListingNo string ListingNo string
Title string Title string
OwnerID uint64 OwnerID uint64
RenterID uint64 RenterID uint64
OwnerPhone string OwnerPhone string
RenterPhone string RenterPhone string
CheckoutCoinConsumedM *float64 CheckoutCoinConsumedM *float64
CheckoutConsumableAmountCent *int64 CheckoutConsumableAmountCent *int64
CheckoutDepositDeductAmountCent *int64
} }
func (row disputeRow) toDTO() DisputeDTO { func (row disputeRow) toDTO() DisputeDTO {
return DisputeDTO{ return DisputeDTO{
ID: row.ID, ID: row.ID,
OrderID: row.OrderID, OrderID: row.OrderID,
OrderNo: row.OrderNo, OrderNo: row.OrderNo,
OrderStatus: row.OrderStatus, OrderStatus: row.OrderStatus,
HandoffStatus: row.HandoffStatus, HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus, SettlementStatus: row.SettlementStatus,
ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent,
ListingNo: row.ListingNo, ListingNo: row.ListingNo,
Title: row.Title, Title: row.Title,
OwnerID: row.OwnerID, OwnerID: row.OwnerID,
RenterID: row.RenterID, RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone, OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone, RenterPhone: row.RenterPhone,
InitiatorID: row.InitiatorID, InitiatorID: row.InitiatorID,
InitiatorType: effectiveInitiatorType(row.Dispute), InitiatorType: effectiveInitiatorType(row.Dispute),
InitiatorAdminID: row.InitiatorAdminID, InitiatorAdminID: row.InitiatorAdminID,
TargetUserID: row.TargetUserID, TargetUserID: row.TargetUserID,
Type: row.Type, Type: row.Type,
Status: row.Status, Status: row.Status,
Description: row.Description, Description: row.Description,
EvidenceURLS: row.EvidenceURLS, EvidenceURLS: row.EvidenceURLS,
PreviousOrderStatus: row.PreviousOrderStatus, PreviousOrderStatus: row.PreviousOrderStatus,
PreviousHandoffStatus: row.PreviousHandoffStatus, PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus, PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID, CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent, PreviousCheckoutStatus: row.PreviousCheckoutStatus,
PreviousCheckoutStatus: row.PreviousCheckoutStatus, ArbitrationResult: row.ArbitrationResult,
ArbitrationResult: row.ArbitrationResult, ArbitrationRemark: row.ArbitrationRemark,
ArbitrationRemark: row.ArbitrationRemark, HandledBy: row.HandledBy,
HandledBy: row.HandledBy, HandledAt: row.HandledAt,
HandledAt: row.HandledAt, CreatedAt: row.CreatedAt,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
UpdatedAt: row.UpdatedAt,
} }
} }
+1 -2
View File
@@ -94,8 +94,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
o.owner_id, o.renter_id, o.extra_item_original_amount_cent, o.owner_id, o.renter_id, o.extra_item_original_amount_cent,
owner.phone AS owner_phone, renter.phone AS renter_phone, owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m, 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 { func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -14,8 +14,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func int64Ptr(value int64) *int64 { return &value }
func disputePureCoinOrder() model.RentalOrder { func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{ return model.RentalOrder{
Status: "renting", Status: "renting",
@@ -324,64 +322,6 @@ 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) { func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t) db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -454,7 +394,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{ settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
Result: "actual_settlement", Result: "actual_settlement",
Remark: "赔付号主", Remark: "赔付号主",
AmountCent: &compensationCent, AmountCent: compensationCent,
ActualCoinConsumedM: &actualCoinConsumedM, ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent, ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 0) }, 0)
@@ -466,21 +406,6 @@ 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) { func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t) db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -492,12 +417,11 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
inputOrder.SettlementStatus = "pending" inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder) _, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{ checkout := model.OrderCheckout{
OrderID: order.ID, OrderID: order.ID,
InitiatedBy: renter.ID, InitiatedBy: renter.ID,
Status: "submitted", Status: "submitted",
ConsumableAmountCent: 7000, ConsumableAmountCent: 7000,
CoinConsumedM: 25, CoinConsumedM: 25,
DepositDeductAmountCent: 3000,
} }
if err := db.Create(&checkout).Error; err != nil { if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err) t.Fatalf("创建结账记录失败: %v", err)
@@ -509,8 +433,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("创建结账争议失败: %v", err) t.Fatalf("创建结账争议失败: %v", err)
} }
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 { 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/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID) t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID)
} }
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
@@ -642,7 +566,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) {
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund", Result: "partial_refund",
AmountCent: int64Ptr(5000), AmountCent: 5000,
Remark: "没打完 号主登录不上", Remark: "没打完 号主登录不上",
}, 0) }, 0)
if err != nil { if err != nil {
@@ -675,7 +599,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) {
} }
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund", Result: "partial_refund",
AmountCent: int64Ptr(20000), AmountCent: 20000,
}, 0) }, 0)
if err != nil { if err != nil {
t.Fatalf("buildArbitrationSettlement() error = %v", err) t.Fatalf("buildArbitrationSettlement() error = %v", err)
@@ -93,9 +93,6 @@ 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) { if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute 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) { if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute return nil, ErrInvalidDispute
} }
+9
View File
@@ -8,4 +8,13 @@ type UploadDTO struct {
Filename string `json:"filename"` Filename string `json:"filename"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Size int64 `json:"size"` Size int64 `json:"size"`
objectKeys []string
}
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
func (d *UploadDTO) ObjectKeys() []string {
if d == nil {
return nil
}
return append([]string(nil), d.objectKeys...)
} }
+56 -5
View File
@@ -1,22 +1,38 @@
package file package file
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
type Handler struct { type Handler struct {
service *Service service *Service
storage *Storage storage *Storage
objectAuthorizer ObjectAuthorizer
uploadOwnerRecorder UploadOwnerRecorder
} }
func NewHandler(service *Service, storage *Storage) *Handler { // ObjectAuthorizer 校验用户是否可读取指定私有对象。
return &Handler{service: service, storage: storage} 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 (h *Handler) Upload(c *gin.Context) { func (h *Handler) Upload(c *gin.Context) {
@@ -44,6 +60,19 @@ func (h *Handler) Upload(c *gin.Context) {
writeFileError(c, err) writeFileError(c, err)
return 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) response.Created(c, item)
} }
@@ -68,7 +97,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
if publicOnly && if publicOnly &&
!strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "home-banner/") &&
!strings.HasPrefix(key, "avatar/") && !strings.HasPrefix(key, "avatar/") &&
!strings.HasPrefix(key, "payment-cert/") &&
!strings.HasPrefix(key, "announcement/") && !strings.HasPrefix(key, "announcement/") &&
!strings.HasPrefix(key, "mohong/") && !strings.HasPrefix(key, "mohong/") &&
!strings.HasPrefix(key, "crash/") && !strings.HasPrefix(key, "crash/") &&
@@ -77,6 +105,29 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return 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) object, err := h.storage.Get(c.Request.Context(), key)
if err != nil { if err != nil {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
+4 -1
View File
@@ -61,6 +61,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
} }
var thumbnailURL string var thumbnailURL string
var mediumURL string var mediumURL string
objectKeys := []string{key}
for _, variant := range generateImageVariants(key, data, contentType) { 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{ err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
"source-object": key, "source-object": key,
@@ -68,6 +69,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
if err != nil { if err != nil {
continue continue
} }
objectKeys = append(objectKeys, variant.Key)
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") { if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
thumbnailURL = fileURLForScene(scene, variant.Key) thumbnailURL = fileURLForScene(scene, variant.Key)
} }
@@ -84,6 +86,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
Filename: req.Header.Filename, Filename: req.Header.Filename,
ContentType: contentType, ContentType: contentType,
Size: int64(len(data)), Size: int64(len(data)),
objectKeys: objectKeys,
}, nil }, nil
} }
@@ -106,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string { func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key) fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" { if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
} }
return fileURL return fileURL
+30
View File
@@ -157,6 +157,36 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
}, nil }, 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 { func (s *Storage) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket) exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil { if err != nil {
+32 -5
View File
@@ -43,6 +43,33 @@ type ListingDTO struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type CreateRequest struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
@@ -159,11 +186,11 @@ type NumberRange struct {
} }
type PublicListResult struct { type PublicListResult struct {
Items []ListingDTO `json:"items"` Items []PublicListingListItemDTO `json:"items"`
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
ZoneCounts map[string]int64 `json:"zone_counts"` ZoneCounts map[string]int64 `json:"zone_counts"`
} }
type AdminActionRequest struct { type AdminActionRequest struct {
@@ -275,11 +275,9 @@ func externalAccountToCreateRequest(
"resources": resources, "resources": resources,
"skin_groups": externalSkinGroups(skins), "skin_groups": externalSkinGroups(skins),
"online_time_text": onlineTimeText, "online_time_text": onlineTimeText,
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。 "ban_record": normalizeBanRecord(item.BanRecord),
"early_morning_response": "否", "common_regions": commonRegions(item.CommonRegion),
"ban_record": normalizeBanRecord(item.BanRecord), "remark": remark,
"common_regions": commonRegions(item.CommonRegion),
"remark": remark,
"import_meta": map[string]any{ "import_meta": map[string]any{
"uploader_name": uploaderName, "uploader_name": uploaderName,
"client_upload_time": uploadTime, "client_upload_time": uploadTime,
@@ -384,11 +382,10 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64,
func serverRegionFromLoginMethod(value string) string { func serverRegionFromLoginMethod(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
normalized := strings.ToLower(value)
switch { switch {
case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"): case strings.Contains(value, "微信"):
return "微信" return "微信"
case strings.Contains(normalized, "steam"): case strings.Contains(strings.ToLower(value), "steam"):
return "Steam" return "Steam"
default: default:
return "QQ" return "QQ"
@@ -71,9 +71,100 @@ func applyPublicListingURLs(item *ListingDTO) {
if item.AssetSummary != nil { if item.AssetSummary != nil {
delete(item.AssetSummary, "import_meta") delete(item.AssetSummary, "import_meta")
delete(item.AssetSummary, "price_breakdown") 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) { func applySellerListingPrice(item *ListingDTO) {
if item == nil { if item == nil {
return return
@@ -403,13 +403,53 @@ func isNightAvailableSummary(summary map[string]any) bool {
if summary == nil { if summary == nil {
return false return false
} }
// 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。 onlineTime, ok := summary["online_time"].(map[string]any)
switch value := summary["early_morning_response"].(type) { if !ok {
case string:
return strings.TrimSpace(value) == "是"
case bool:
return value
default:
return false 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,27 +178,31 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
} }
} }
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) { func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
response any start string
want bool end string
want bool
}{ }{
{name: "明确响应", response: "", want: true}, {name: "全天可上号", start: "00:00", end: "23:59", want: true},
{name: "明确不响应", response: "", want: false}, {name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true},
{name: "兼容布尔值", response: true, want: true}, {name: "刚好八点开始", start: "08:00", end: "12:00", want: true},
{name: "缺失字段", response: nil, want: false}, {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},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
summary := map[string]any{} got := isNightAvailableSummary(map[string]any{
if tt.response != nil { "online_time": map[string]any{
summary["early_morning_response"] = tt.response "start": tt.start,
} "end": tt.end,
got := isNightAvailableSummary(summary) },
})
if got != tt.want { if got != tt.want {
t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want) t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want)
} }
}) })
} }
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
items = items[start:end] items = items[start:end]
} }
return &PublicListResult{ return &PublicListResult{
Items: items, Items: publicListingListItems(items),
Total: total, Total: total,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
return nil, err return nil, err
} }
return &PublicListResult{ return &PublicListResult{
Items: publicListings(rowsToDTO(rows)), Items: publicListingListItems(publicListings(rowsToDTO(rows))),
Total: total, Total: total,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
@@ -219,13 +219,14 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
return copied return copied
} }
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) 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 { if err != nil {
return nil, err return nil, err
} }
applyPublicListingURLs(dto) applyPublicListingURLs(dto)
return dto, nil item := dto.toPublicDetail()
return &item, nil
} }
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
+10 -9
View File
@@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
like := "%" + keyword + "%" like := "%" + keyword + "%"
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
db = db.Where( db = db.Where(
`(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 = ?)`, `(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 = ?)`,
keyword, like,
keyword, like,
id, id,
id, id,
id, id,
@@ -204,21 +204,22 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
} }
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { 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"). 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, 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, 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.id IS NULL THEN 0 ELSE 1 END AS is_external_upload, CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
CASE CASE
WHEN lu.id IS NULL THEN ? WHEN lu.upload_id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ? WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel ELSE lu.source_channel
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown). END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
Joins("JOIN game_accounts AS a ON a.id = l.account_id"). 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 users AS u ON u.id = l.owner_id").
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。 Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
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) { 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) return s.repo.ListMine(ctx, ownerID)
} }
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -329,8 +330,9 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
"https://example.com/account.png", "https://example.com/account.png",
}, },
AssetSummary: map[string]any{ AssetSummary: map[string]any{
"import_meta": map[string]any{"uploader_name": "客服1"}, "import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100}, "price_breakdown": map[string]any{"buyer_total_price": 100},
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
}, },
} }
@@ -345,6 +347,78 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
if _, ok := item.AssetSummary["price_breakdown"]; ok { if _, ok := item.AssetSummary["price_breakdown"]; ok {
t.Fatal("expected price_breakdown hidden from public listing") 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) { func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
@@ -530,9 +604,6 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" { if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"]) 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) onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
if !ok { if !ok {
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"]) t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
@@ -569,28 +640,6 @@ 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) { func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
LoginMethod: "QQ账号密码", LoginMethod: "QQ账号密码",
@@ -391,13 +391,10 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error { func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
var refund *refundAction var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
assets, err := r.lockOrderAssets(tx, orderID) var order model.RentalOrder
if err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err return err
} }
order := assets.Order
listing := assets.Listing
account := assets.Account
if order.RefundStatus != refundStatusPendingReview { if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态") return errors.New("订单不处于待审核退款状态")
} }
@@ -410,23 +407,14 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。 // 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
order.RefundStatus = "none" order.RefundStatus = "none"
order.RefundAmountCent = 0 order.RefundAmountCent = 0
} else { return tx.Save(&order).Error
action, err := r.prepareRefund(order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
if err != nil {
return err
}
refund = action
} }
if err := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil { action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
if err != nil {
return err return err
} }
if err := tx.Save(order).Error; err != nil { refund = action
return err return tx.Save(&order).Error
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
}) })
if err != nil { if err != nil {
return err return err
+2 -25
View File
@@ -6,7 +6,6 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -32,12 +31,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
if hasOpen { if hasOpen {
return ErrCheckoutCannotSubmit return ErrCheckoutCannotSubmit
} }
before := orderState(order)
checkoutToUserID := order.OwnerID checkoutToUserID := order.OwnerID
checkoutTargetType := processlog.ActorUser
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
checkoutToUserID = platformManagedAdminID(order) checkoutToUserID = platformManagedAdminID(order)
checkoutTargetType = processlog.ActorAdmin
} }
record := model.HandoffRecord{ record := model.HandoffRecord{
OrderID: order.ID, OrderID: order.ID,
@@ -65,9 +61,6 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
order.SettlementStatus = settlementStatusPending order.SettlementStatus = settlementStatusPending
now := time.Now() now := time.Now()
order.HandoffStartedAt = &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 orderID := order.ID
if isPlatformSettlementOrder(order) { if isPlatformSettlementOrder(order) {
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
@@ -115,7 +108,6 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -136,10 +128,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
checkout.OwnerAdjustedAt = &now checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
refund = action refund = action
if err != nil { return err
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 { if err != nil {
return err return err
@@ -165,7 +154,6 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -233,13 +221,6 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err 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 { if err := tx.Save(checkout).Error; err != nil {
return err return err
} }
@@ -304,7 +285,6 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout { if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -320,10 +300,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
checkout.RenterConfirmedAt = &now checkout.RenterConfirmedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
refund = action refund = action
if err != nil { return err
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 { if err != nil {
return err return err
+5 -6
View File
@@ -32,12 +32,11 @@ const (
handoffStatusAdminClosed = "admin_closed" handoffStatusAdminClosed = "admin_closed"
handoffStatusAdminAbnormal = "admin_abnormal" handoffStatusAdminAbnormal = "admin_abnormal"
settlementStatusUnsettled = "unsettled" settlementStatusUnsettled = "unsettled"
settlementStatusPending = "pending" settlementStatusPending = "pending"
settlementStatusSettled = "settled" settlementStatusSettled = "settled"
settlementStatusClosed = "closed" settlementStatusClosed = "closed"
settlementStatusDisputed = "disputed" settlementStatusDisputed = "disputed"
settlementStatusArbitrated = "arbitrated"
handoffModeOwner = "owner" handoffModeOwner = "owner"
handoffModePlatform = "platform" handoffModePlatform = "platform"
@@ -5,7 +5,6 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -24,7 +23,6 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
if !canHoldDeposit(order) { if !canHoldDeposit(order) {
return ErrDepositCannotHold return ErrDepositCannotHold
} }
before := orderState(order)
beforeStatus := order.DepositHoldStatus beforeStatus := order.DepositHoldStatus
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusHeld order.DepositHoldStatus = depositHoldStatusHeld
@@ -32,9 +30,6 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
order.DepositHeldBy = &adminID order.DepositHeldBy = &adminID
order.DepositHeldAt = &now order.DepositHeldAt = &now
order.DepositHoldReleasedAt = nil 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{ if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -66,13 +61,9 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or
if holdAmountCent <= 0 { if holdAmountCent <= 0 {
return ErrDepositHoldAmountEmpty return ErrDepositHoldAmountEmpty
} }
before := orderState(order)
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusReleased order.DepositHoldStatus = depositHoldStatusReleased
order.DepositHoldReleasedAt = &now 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, "暂扣押金归还原路退回") action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
if err != nil { if err != nil {
return err return err
+81 -27
View File
@@ -51,8 +51,6 @@ type OrderDTO struct {
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel,omitempty"`
Status string `json:"status"` Status string `json:"status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"` HandoffMode string `json:"handoff_mode"`
@@ -79,6 +77,46 @@ type OrderDTO struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type AdminActionsDTO struct {
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"` ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"` PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
@@ -133,8 +171,7 @@ type CounterCheckoutRequest struct {
} }
type AdminActionRequest struct { type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"`
} }
type PlatformHandoffRequest struct { type PlatformHandoffRequest struct {
@@ -142,7 +179,7 @@ type PlatformHandoffRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
} }
// ForceHandoffRequest 客服确认订单已完成交接。 // ForceHandoffRequest 客服确认普通号主订单已完成线下交接。
type ForceHandoffRequest struct { type ForceHandoffRequest struct {
Content string `json:"content"` Content string `json:"content"`
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
@@ -172,6 +209,45 @@ type PaginatedResult struct {
PageSize int `json:"page_size"` 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 { type RefundStatusDTO struct {
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
@@ -193,28 +269,6 @@ type HandoffRecordDTO struct {
CreatedAt time.Time `json:"created_at"` 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 { type CheckoutDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
@@ -7,20 +7,13 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
func canAdminForceHandoff(order model.RentalOrder) bool { func canAdminForceHandoff(order model.RentalOrder) bool {
if isPlatformHandoffOrder(order) { if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) {
if order.Status == orderStatusPendingHandoff {
return order.HandoffStatus == handoffStatusPendingRenterConfirm
}
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
}
if isPlatformSettlementOrder(order) {
return false return false
} }
if order.Status == orderStatusPendingHandoff { if order.Status == orderStatusPendingHandoff {
@@ -31,18 +24,11 @@ func canAdminForceHandoff(order model.RentalOrder) bool {
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout 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 { func forceHandoffNeedsContent(order model.RentalOrder) bool {
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout 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 { 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 { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder var order model.RentalOrder
@@ -52,7 +38,6 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
before := orderState(order)
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
@@ -75,7 +60,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !needsContent { if !needsContent {
var handoffCount int64 var handoffCount int64
if err := tx.Model(&model.HandoffRecord{}). if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, forceHandoffRecordType(order)). Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
Count(&handoffCount).Error; err != nil { Count(&handoffCount).Error; err != nil {
return err return err
} }
@@ -102,9 +87,6 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
order.HandoffStatus = handoffStatusReceived order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now 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 orderID := order.ID
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
@@ -60,19 +60,6 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
response.OK(c, gin.H{"items": items}) 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) { func (h *Handler) AdminClose(c *gin.Context) {
h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) h.adminAction(c, h.service.AdminClose, gin.H{"closed": true})
} }
@@ -261,15 +248,6 @@ func (h *Handler) ListPendingRefund(c *gin.Context) {
response.OK(c, gin.H{"items": items}) 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) { func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {

Some files were not shown because too many files have changed in this diff Show More