56 Commits
Author SHA1 Message Date
yml2213 ad279049a0 完善数据库 SQL 约束与运维治理 2026-08-31 12:58:28 +08:00
yml2213 1ee7d17712 增加本地推送前代码检查钩子 2026-08-31 12:41:07 +08:00
yml2213 cfabfa8f65 修复生产部署污染选号网占位文件 2026-08-30 22:00:52 +08:00
yml2213 6561f925e0 修复后台分页刷新并优化财务查询 2026-08-30 21:56:22 +08:00
yml2213 327a03672a 修复冻结用户即时下线 2026-08-30 21:19:47 +08:00
yml2213 21fbc69c7a 补充结算后订单利润调整方案 2026-08-30 21:05:28 +08:00
yml2213 2337395d8f 修复代管订单押金赔付待打款金额 2026-08-30 20:37:41 +08:00
yml2213 98f6ee9451 增加客服上传账号统计 2026-08-30 19:44:08 +08:00
yml2213 0aef80fd11 修正后台订单号主实际收入 2026-08-30 19:27:11 +08:00
yml2213 2487a2d83f 增加零号主收入结算确认 2026-08-29 01:31:08 +08:00
yml2213 527e5ed228 优化财务仪表盘加载与利润展示 2026-08-29 00:55:55 +08:00
yml2213 c156c478d8 优化后台高频查询性能 2026-08-29 00:16:16 +08:00
yml2213 c87883cc65 完善订单交接结账流程留痕 2026-08-28 23:48:32 +08:00
yml2213 a9a9127076 新增退款待审核角标提醒 2026-08-28 23:10:07 +08:00
yml2213 41a5b49450 修复订单列表返回状态丢失 2026-08-28 23:03:40 +08:00
yml2213 a53b9dc617 新增订单号主收入列 2026-08-28 22:38:40 +08:00
yml2213 74d1518b66 feat: show next season pass on listings 2026-08-28 18:28:59 +08:00
yml2213 006f93a0e7 feat: add next season pass publish option 2026-08-28 18:23:14 +08:00
yml2213 cdab2d8157 fix(chat): 区分咨询与正常会话待处理规则 2026-08-25 21:11:52 +08:00
yml2213 cc91979b34 fix(chat): 回复后再移出待处理 2026-08-25 20:27:26 +08:00
yml2213 16237dcefc fix(chat): 修复一键清理未读失败 2026-08-25 19:32:04 +08:00
yml2213 52dcb18c96 perf(chat): 分页客服会话并拆分统计查询 2026-08-25 19:21:38 +08:00
yml2213 d95b95211c fix(chat): 提升二维码 OCR 稳定性 2026-08-25 16:59:05 +08:00
yml2213 8f6356a3d7 fix(chat): 优化后台限流与 SSE 日志 2026-08-25 16:20:58 +08:00
yml2213 417f7398e4 perf(chat): 优化后台客服会话查询与状态 2026-08-25 15:53:10 +08:00
yml2213 87327fbd91 Revert "feat(admin): modernize business views including listings, review, users, orders, pickups, and mohong"
This reverts commit 7b274d4ab8.
2026-08-20 17:59:24 +08:00
yml2213 8660d5d4d1 修复仲裁订单线下结算 2026-08-20 17:44:52 +08:00
yml2213 7b274d4ab8 feat(admin): modernize business views including listings, review, users, orders, pickups, and mohong 2026-08-20 17:37:54 +08:00
yml2213 266abb4aaa 修复退款审核前账号提前释放 2026-08-20 16:50:32 +08:00
yml2213 cc974f954f 支持平台代管客服一键交接 2026-08-20 16:43:45 +08:00
yml2213 b0d4d5bfef 优化后台仪表盘界面 2026-08-20 16:42:46 +08:00
yml2213 47ba202517 优化财务管理整个ui 2026-08-20 16:32:57 +08:00
yml2213 6e4689edc4 修复53数据库异常 2026-08-20 16:23:47 +08:00
yml2213 9a52b51c89 优化财务仪表盘UI与快捷时间筛选 2026-08-20 16:10:55 +08:00
yml2213 80e329a604 增加 运营开支 2026-08-20 16:06:05 +08:00
yml2213 0b235d078d 支持自定义线下出款分类 2026-08-20 15:48:25 +08:00
yml2213 f1b48f7921 移除首页筛选中的三个低档位 2026-08-18 20:45:09 +08:00
yml2213 c4507caf37 增加账号凌晨响应标记 2026-08-18 20:39:13 +08:00
yml2213 1f204f17ad 修复线下提号财务调整审计日志 2026-08-18 20:15:06 +08:00
yml2213 c91423a19b 修复冻结用户强制下线 2026-08-18 20:02:21 +08:00
yml2213 0bc6cf9f46 修复微信扫码区服识别 2026-08-17 20:30:15 +08:00
yml2213 79ced2d121 优化备份后台布局 2026-08-17 10:36:15 +08:00
yml2213 50b458e5c2 适配MySQL8.4位点查询 2026-08-17 00:23:33 +08:00
yml2213 76528076a7 补充binlog查询错误信息 2026-08-17 00:19:39 +08:00
yml2213 2265c48a53 修复备份任务退出码 2026-08-17 00:17:42 +08:00
yml2213 79eebdc8e5 修正备份失败健康状态 2026-08-17 00:08:09 +08:00
yml2213 a0bf47f2ed 优化空闲binlog轮转 2026-08-16 23:57:19 +08:00
yml2213 f67ee12d72 新增备份监控与定时配置 2026-08-16 23:53:50 +08:00
yml2213 66cb03ebce 修复备份完成状态时机 2026-08-16 23:40:35 +08:00
yml2213 ca4372271a 修复备份文件属主处理 2026-08-16 23:37:15 +08:00
yml2213 7d7141c055 修复XtraBackup数据卷权限 2026-08-16 23:34:53 +08:00
yml2213 4fb79cb37f 补齐XtraBackup只读权限 2026-08-16 23:32:21 +08:00
yml2213 96221d3c00 修复XtraBackup凭据读取 2026-08-16 23:31:17 +08:00
yml2213 eb06ec3c5f 支持OSS托管加密 2026-08-16 22:38:31 +08:00
yml2213 7b7d23c743 修复备份状态查询 2026-08-16 22:32:57 +08:00
yml2213 7438fb6725 重写在线备份链路 2026-08-16 22:32:07 +08:00
251 changed files with 16594 additions and 5528 deletions
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
push_lines="$(cat)"
has_updates=0
has_deletions=0
while read -r local_ref local_sha remote_ref remote_sha; do
[ -z "${local_ref}" ] && continue
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
has_deletions=1
else
has_updates=1
fi
done <<< "$push_lines"
if [ "$has_updates" = "0" ] && [ "$has_deletions" = "1" ]; then
echo "[pre-push] 分支删除推送,跳过本地检查"
exit 0
fi
if [ "${SKIP_CHECKS:-0}" = "1" ]; then
echo "[pre-push] SKIP_CHECKS=1,跳过本地检查"
exit 0
fi
echo "[pre-push] 运行本地代码门禁..."
bash scripts/check.sh
echo "[pre-push] 本地检查通过"
+1
View File
@@ -40,3 +40,4 @@ deploy/caddy/conf.d/show.caddy
logs
.gocache
artifacts/
+14
View File
@@ -50,6 +50,20 @@ npm run dev
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go``npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend``--skip-frontend``--skip-tests` 做局部调试。
数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。
需要在本地推送前自动执行同一套检查时,启用 Git hook:
```bash
bash scripts/install-git-hooks.sh
```
启用后,`git push` 会执行 `scripts/check.sh`;仅临时跳过时使用:
```bash
SKIP_CHECKS=1 git push
```
## 开发态短信与实名
- 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
+7
View File
@@ -8,6 +8,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=secret
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。
DATABASE_MAX_OPEN_CONNS=50
DATABASE_MAX_IDLE_CONNS=10
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
REDIS_ADDR=127.0.0.1:16379
REDIS_PASSWORD=
REDIS_DB=0
+45 -15
View File
@@ -19,6 +19,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=change-hfb-password
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。
DATABASE_MAX_OPEN_CONNS=50
DATABASE_MAX_IDLE_CONNS=10
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
REDIS_ADDR=redis:6379
REDIS_PASSWORD=
REDIS_DB=0
@@ -48,26 +55,23 @@ LOG_ENABLE_CONSOLE=false
LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14
# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验
MINIO_ROOT_USER=change-minio-user
MINIO_ROOT_PASSWORD=change-minio-password
# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
STORAGE_ENDPOINT=http://minio:9000
STORAGE_BUCKET=hfb-sys
STORAGE_ACCESS_KEY_ID=change-minio-user
STORAGE_SECRET_ACCESS_KEY=change-minio-password
STORAGE_REGION=
STORAGE_BUCKET_LOOKUP=auto
# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。
# 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除
# 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com
# OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。
STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
STORAGE_BUCKET=hfb-sys-assets
STORAGE_ACCESS_KEY_ID=change-oss-access-key-id
STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret
STORAGE_REGION=cn-hangzhou
STORAGE_BUCKET_LOOKUP=dns
# 历史镜像写入配置,迁移完成后保持为空。
STORAGE_MIRROR_ENDPOINT=
STORAGE_MIRROR_BUCKET=hfb-sys-assets
STORAGE_MIRROR_BUCKET=
STORAGE_MIRROR_ACCESS_KEY_ID=
STORAGE_MIRROR_SECRET_ACCESS_KEY=
STORAGE_MIRROR_REGION=cn-hangzhou
STORAGE_MIRROR_BUCKET_LOOKUP=dns
STORAGE_MIRROR_REGION=
STORAGE_MIRROR_BUCKET_LOOKUP=
# 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。
SMS_PROVIDER=aliyun
@@ -102,3 +106,29 @@ FIELD_ENCRYPTION_LEGACY_KEY=
EXTERNAL_UPLOAD_SECRET=
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
EXTERNAL_UPLOAD_ALLOWED_IPS=
# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。
# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。
BACKUP_DIR=/data/backups
# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。
BACKUP_STATUS_DIR=/data/backups/status
# backend 容器内的只读状态文件路径,通常保持默认。
BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json
# 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。
BACKUP_RESTORE_DIR=/data/restore
# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。
# 生成方式:ossutil config 交互配置,或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET。
BACKUP_OSS_URI=oss://hfb-backup
# 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。
# 生成方式:openssl rand -hex 32
BACKUP_PASSPHRASE=change-to-random-backup-passphrase
# 可选:自有 KMS Key ID。留空时 Bucket 必须开启“OSS 完全托管”服务器端加密。
BACKUP_KMS_KEY_ID=
# XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。
BACKUP_MYSQL_USER=hfb_backup
# 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。
BACKUP_MYSQL_PASSWORD=change-backup-mysql-password
# 本地保留策略(OSS 侧用 Bucket 生命周期规则管理,见脚本头部注释)。
BACKUP_KEEP_DAILY=14
BACKUP_KEEP_WEEKLY=8
BACKUP_KEEP_MONTHLY=12
+7 -3
View File
@@ -12,7 +12,6 @@ import (
"hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
"hfb_sys/backend/internal/jobs/ordertimeout"
"hfb_sys/backend/internal/jobs/refundretry"
"hfb_sys/backend/internal/logging"
@@ -59,7 +58,13 @@ func main() {
logAuthRuntimeIdentity(logger, cfg)
var deps router.Dependencies
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime,
SlowQueryThreshold: cfg.Database.SlowQueryThreshold,
})
if err != nil {
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
} else {
@@ -92,7 +97,6 @@ func main() {
defer stopJobs()
if deps.DB != nil {
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
+27
View File
@@ -5,6 +5,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
type Config struct {
@@ -29,6 +30,8 @@ type Config struct {
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
Database DatabaseConfig
BackupStatusFile string
}
type StorageConfig struct {
@@ -69,6 +72,14 @@ type RateLimitConfig struct {
RequestsPerMinute int
}
type DatabaseConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
@@ -130,6 +141,14 @@ func Load() Config {
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
},
Database: DatabaseConfig{
MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1),
MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0),
ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute,
ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute,
SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond,
},
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
}
}
@@ -206,6 +225,14 @@ func getEnvInt(key string, fallback int) int {
return parsed
}
func getEnvIntMin(key string, fallback, minimum int) int {
value := getEnvInt(key, fallback)
if value < minimum {
return fallback
}
return value
}
func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key)
if value == "" {
+35
View File
@@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) {
}
}
func TestLoadDatabaseConfigFromEnvironment(t *testing.T) {
t.Setenv("DATABASE_MAX_OPEN_CONNS", "80")
t.Setenv("DATABASE_MAX_IDLE_CONNS", "16")
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45")
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7")
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750")
cfg := Load()
if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 {
t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database)
}
if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 {
t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime)
}
if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 {
t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold)
}
}
func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) {
t.Setenv("DATABASE_MAX_OPEN_CONNS", "0")
t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1")
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0")
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1")
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0")
cfg := Load()
if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 {
t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database)
}
if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 {
t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database)
}
}
// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
+8 -1
View File
@@ -21,6 +21,10 @@ type structuredGormLogger struct {
}
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond)
}
func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface {
level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info
@@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
if logger == nil {
logger = zap.L()
}
if slowThreshold <= 0 {
slowThreshold = 500 * time.Millisecond
}
return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")),
level: level,
slowThreshold: 500 * time.Millisecond,
slowThreshold: slowThreshold,
}
}
+47 -6
View File
@@ -8,9 +8,50 @@ import (
"gorm.io/gorm"
)
type MySQLOptions struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func DefaultMySQLOptions() MySQLOptions {
return MySQLOptions{
MaxOpenConns: 50,
MaxIdleConns: 10,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
SlowQueryThreshold: 500 * time.Millisecond,
}
}
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions())
}
func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) {
defaults := DefaultMySQLOptions()
if options.MaxOpenConns < 1 {
options.MaxOpenConns = defaults.MaxOpenConns
}
if options.MaxIdleConns < 0 {
options.MaxIdleConns = defaults.MaxIdleConns
}
if options.ConnMaxLifetime <= 0 {
options.ConnMaxLifetime = defaults.ConnMaxLifetime
}
if options.ConnMaxIdleTime < 0 {
options.ConnMaxIdleTime = defaults.ConnMaxIdleTime
}
if options.SlowQueryThreshold <= 0 {
options.SlowQueryThreshold = defaults.SlowQueryThreshold
}
if options.MaxIdleConns > options.MaxOpenConns {
options.MaxIdleConns = options.MaxOpenConns
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newGormLogger(logLevel, appLogger),
Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
})
if err != nil {
return nil, err
@@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er
if err != nil {
return nil, err
}
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
// 限制连接池,避免单个实例耗尽 MySQL max_connections。
sqlDB.SetMaxOpenConns(options.MaxOpenConns)
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
return db, nil
}
+4
View File
@@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB {
func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{},
&model.AdminUser{},
&model.GameAccount{},
&model.RentalListing{},
&model.ListingUpload{},
&model.ListingStatusEvent{},
)
}
@@ -61,8 +63,10 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
&model.WalletLedger{},
&model.RenterGrowthLedger{},
&model.AuditLog{},
&model.ProcessEvent{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
)
}
+9 -1
View File
@@ -313,7 +313,15 @@ func applyMigrations(t *testing.T, db *sql.DB) {
t.Fatal("无法定位当前测试文件")
}
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
entries, err := os.ReadDir(migrationDir)
if err != nil {
t.Fatalf("读取迁移目录失败: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
name := entry.Name()
migrationPath := filepath.Join(migrationDir, name)
raw, err := os.ReadFile(migrationPath)
if err != nil {
@@ -1,167 +0,0 @@
package fileuploadcleanup
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"time"
"hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
cleanupLockKey = "hfb:job:file-upload-cleanup:lock"
cleanupInterval = 24 * time.Hour
cleanupRetention = 30 * 24 * time.Hour
cleanupBatchSize = 200
)
// Job 清理长期未关联业务记录的上传归属,避免临时草稿记录无限增长。
type Job struct {
db *gorm.DB
redis *redis.Client
logger *zap.Logger
instanceID string
}
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
if logger == nil {
logger = zap.NewNop()
}
return &Job{db: db, redis: redisClient, logger: logger, instanceID: newInstanceID()}
}
func (j *Job) Start(ctx context.Context) {
if j == nil || j.db == nil {
return
}
go j.loop(ctx)
}
func (j *Job) loop(ctx context.Context) {
j.run(ctx, time.Now())
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
j.logger.Debug("临时文件归属清理任务已停止")
return
case now := <-ticker.C:
j.run(ctx, now)
}
}
}
func (j *Job) run(ctx context.Context, now time.Time) {
release, ok := j.acquireLock(ctx)
if !ok {
return
}
defer release()
deleted, err := j.cleanup(ctx, now)
if err != nil {
j.logger.Warn("临时文件归属清理失败", zap.Error(err))
return
}
if deleted > 0 {
j.logger.Info("已清理未关联临时文件归属", zap.Int("count", deleted))
}
}
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
if j.redis == nil {
return func() {}, true
}
ok, err := j.redis.SetNX(ctx, cleanupLockKey, j.instanceID, 10*time.Minute).Result()
if err != nil {
j.logger.Warn("临时文件归属清理任务获取锁失败", zap.Error(err))
return func() {}, true
}
if !ok {
return nil, false
}
return func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
if err := script.Run(releaseCtx, j.redis, []string{cleanupLockKey}, j.instanceID).Err(); err != nil {
j.logger.Warn("临时文件归属清理任务释放锁失败", zap.Error(err))
}
}, true
}
func (j *Job) cleanup(ctx context.Context, now time.Time) (int, error) {
cutoff := now.Add(-cleanupRetention)
lastID := uint64(0)
deleted := 0
for {
var records []model.FileUploadOwner
if err := j.db.WithContext(ctx).
Where("id > ? AND created_at < ?", lastID, cutoff).
Order("id ASC").
Limit(cleanupBatchSize).
Find(&records).Error; err != nil {
return deleted, err
}
if len(records) == 0 {
return deleted, nil
}
for _, record := range records {
lastID = record.ID
referenced, err := j.isReferenced(ctx, record.ObjectKey)
if err != nil {
return deleted, err
}
if referenced {
continue
}
result := j.db.WithContext(ctx).
Where("id = ? AND created_at < ?", record.ID, cutoff).
Delete(&model.FileUploadOwner{})
if result.Error != nil {
return deleted, result.Error
}
deleted += int(result.RowsAffected)
}
}
}
func (j *Job) isReferenced(ctx context.Context, key string) (bool, error) {
encodedKey := url.QueryEscape(key)
queries := []struct {
sql string
args []any
}{
{sql: "SELECT COUNT(1) FROM game_accounts WHERE INSTR(screenshot_urls, ?) > 0 OR INSTR(screenshot_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM order_checkouts WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM disputes WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM handoff_records WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM chat_messages WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM user_payment_accounts WHERE INSTR(certificate_urls, ?) > 0 OR INSTR(certificate_urls, ?) > 0", args: []any{key, encodedKey}},
}
for _, query := range queries {
var count int64
if err := j.db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func newInstanceID() string {
value := make([]byte, 8)
if _, err := rand.Read(value); err != nil {
return fmt.Sprintf("file-cleanup-%d", time.Now().UnixNano())
}
return hex.EncodeToString(value)
}
@@ -1,77 +0,0 @@
package fileuploadcleanup
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestCleanupOnlyDeletesExpiredUnreferencedUploads(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
createReferenceTables(t, db)
now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
old := now.Add(-31 * 24 * time.Hour)
recent := now.Add(-29 * 24 * time.Hour)
records := []model.FileUploadOwner{
{UserID: 1, ObjectKey: "listing/orphan.jpg", CreatedAt: old},
{UserID: 1, ObjectKey: "listing/referenced.jpg", CreatedAt: old},
{UserID: 1, ObjectKey: "listing/recent.jpg", CreatedAt: recent},
}
if err := db.Create(&records).Error; err != nil {
t.Fatalf("create upload owners error = %v", err)
}
if err := db.Exec("INSERT INTO game_accounts (id, screenshot_urls) VALUES (1, ?)", `["/api/files/object?key=listing%2Freferenced.jpg"]`).Error; err != nil {
t.Fatalf("create referenced account error = %v", err)
}
job := New(db, nil, nil)
deleted, err := job.cleanup(context.Background(), now)
if err != nil {
t.Fatalf("cleanup() error = %v", err)
}
if deleted != 1 {
t.Fatalf("cleanup() deleted = %d, want 1", deleted)
}
var remaining []model.FileUploadOwner
if err := db.Order("id ASC").Find(&remaining).Error; err != nil {
t.Fatalf("load remaining uploads error = %v", err)
}
if len(remaining) != 2 || remaining[0].ObjectKey != "listing/referenced.jpg" || remaining[1].ObjectKey != "listing/recent.jpg" {
t.Fatalf("unexpected remaining uploads: %#v", remaining)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
return db
}
func createReferenceTables(t *testing.T, db *gorm.DB) {
t.Helper()
statements := []string{
"CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, screenshot_urls TEXT)",
"CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
"CREATE TABLE disputes (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
"CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
"CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
"CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, certificate_urls TEXT)",
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create reference table error = %v", err)
}
}
}
+14 -14
View File
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("创建测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil {
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
@@ -165,19 +165,19 @@ func TestResetRetryClearsRetryFields(t *testing.T) {
func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder {
t.Helper()
order := model.RentalOrder{
OrderNo: "ORD" + suffix,
ListingID: 1,
AccountID: 1,
OwnerID: 1,
RenterID: 2,
RentAmountCent: 1000,
DepositAmountCent: 500,
Status: "closed",
HandoffStatus: "arbitrated",
SettlementStatus: settlementStatus,
RefundStatus: "pending",
RefundAmountCent: 800,
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
OrderNo: "ORD" + suffix,
ListingID: 1,
AccountID: 1,
OwnerID: 1,
RenterID: 2,
RentAmountCent: 1000,
DepositAmountCent: 500,
Status: "closed",
HandoffStatus: "arbitrated",
SettlementStatus: settlementStatus,
RefundStatus: "pending",
RefundAmountCent: 800,
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
+29 -23
View File
@@ -25,7 +25,6 @@ const (
ContextAuthCurrentVersion = "auth_current_token_version"
ContextAuthFailureDetail = "auth_failure_detail"
AdminAccessCookieName = "hfb_admin_access"
UserAccessCookieName = "hfb_user_access"
)
type AdminTokenContext struct {
@@ -35,6 +34,8 @@ type AdminTokenContext struct {
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
@@ -44,20 +45,28 @@ func extractBearerToken(c *gin.Context) string {
return ""
}
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
var validate UserTokenValidatorFunc
if len(validators) > 0 {
validate = validators[0]
func extractToken(c *gin.Context) string {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText
}
return func(c *gin.Context) {
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
return c.Query("token")
}
func extractAdminToken(c *gin.Context) (string, string) {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText, "bearer"
}
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
if tokenText := strings.TrimSpace(cookieToken); tokenText != "" {
return tokenText, "cookie"
}
}
return "", "none"
}
func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractToken(c)
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
@@ -72,7 +81,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
}
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
@@ -86,16 +99,9 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractBearerToken(c)
tokenSource := "bearer"
tokenText, tokenSource := extractAdminToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
tokenSource = "cookie"
}
}
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
response.Unauthorized(c, "缺少后台访问令牌")
c.Abort()
return
+14 -18
View File
@@ -1,6 +1,7 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
@@ -10,31 +11,26 @@ import (
"github.com/gin-gonic/gin"
)
func TestUserAuthRejectsQueryToken(t *testing.T) {
func TestAuthRejectsRevokedUserToken(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
manager := auth.NewJWTManager("test-secret")
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0)
if err != nil {
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
t.Fatalf("生成令牌失败:%v", err)
}
engine := gin.New()
engine.GET("/protected", Auth(manager), func(c *gin.Context) {
engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error {
return auth.ErrTokenVersionMismatch
}), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+pair.AccessToken)
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
queryResponse := httptest.NewRecorder()
engine.ServeHTTP(queryResponse, queryRequest)
if queryResponse.Code != http.StatusUnauthorized {
t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized)
}
bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil)
bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken)
bearerResponse := httptest.NewRecorder()
engine.ServeHTTP(bearerResponse, bearerRequest)
if bearerResponse.Code != http.StatusNoContent {
t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent)
if response.Code != http.StatusUnauthorized {
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized)
}
}
+63 -21
View File
@@ -7,10 +7,14 @@ import (
"sync"
"time"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type rateLimitKeyFunc func(c *gin.Context) string
type rateLimitBucket struct {
count int
resetAt time.Time
@@ -32,6 +36,16 @@ type redisRateLimiter struct {
}
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, ipRateLimitKey)
}
// AdminAwareRateLimitPerMinute isolates authenticated admin traffic by admin ID.
// Login and other requests without a valid admin access token fall back to IP.
func AdminAwareRateLimitPerMinute(limit int, rdb *redis.Client, jwtManager *auth.JWTManager) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, adminOrIPRateLimitKey(jwtManager))
}
func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc) gin.HandlerFunc {
if limit <= 0 {
return func(c *gin.Context) {
c.Next()
@@ -48,34 +62,62 @@ func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
fallback: limiter,
limit: limit,
window: time.Minute,
}).handle
}).handle(keyFunc)
}
return limiter.handle
return limiter.handle(keyFunc)
}
func (l *redisRateLimiter) handle(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
if err != nil {
allowed, resetAt = l.fallback.allow(key, now)
func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
return func(c *gin.Context) {
now := time.Now()
key := keyFunc(c)
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
if err != nil {
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(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt := l.allow(key, now)
if !allowed {
writeRateLimited(c, now, resetAt)
return
func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
return func(c *gin.Context) {
now := time.Now()
key := keyFunc(c)
allowed, resetAt := l.allow(key, now)
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
}
func ipRateLimitKey(c *gin.Context) string {
return "ip:" + c.ClientIP()
}
func adminOrIPRateLimitKey(jwtManager *auth.JWTManager) rateLimitKeyFunc {
return func(c *gin.Context) string {
if value, ok := c.Get(ContextAdminID); ok {
if adminID, ok := value.(uint64); ok && adminID != 0 {
return "admin:" + strconv.FormatUint(adminID, 10)
}
}
if jwtManager != nil {
tokenText, _ := extractAdminToken(c)
if tokenText != "" {
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
if err == nil && claims.UserID != 0 {
return "admin:" + strconv.FormatUint(claims.UserID, 10)
}
}
}
return ipRateLimitKey(c)
}
c.Next()
}
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
@@ -0,0 +1,91 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
)
func runRateLimitedRequest(router *gin.Engine, bearerToken string) int {
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder.Code
}
func newRateLimitRouter(limit int, rdb interface{}, handler gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(handler)
router.GET("/ping", func(c *gin.Context) {
c.Status(http.StatusOK)
})
return router
}
func adminAccessToken(t *testing.T, jwtManager *auth.JWTManager, adminID uint64, phone string) string {
t.Helper()
pair, err := jwtManager.GenerateSubjectPairWithVersion(adminID, phone, "admin", 0)
if err != nil {
t.Fatalf("生成后台令牌失败: %v", err)
}
return pair.AccessToken
}
func TestAdminAwareRateLimitIsolatesAdminsBehindSharedIP(t *testing.T) {
jwtManager := auth.NewJWTManager("test-secret")
adminA := adminAccessToken(t, jwtManager, 101, "13800000001")
adminB := adminAccessToken(t, jwtManager, 102, "13800000002")
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
for i := 0; i < 2; i++ {
if code := runRateLimitedRequest(router, adminA); code != http.StatusOK {
t.Fatalf("客服A 第 %d 次请求 = %d, want 200", i+1, code)
}
}
if code := runRateLimitedRequest(router, adminA); code != http.StatusTooManyRequests {
t.Fatalf("客服A 超额请求 = %d, want 429", code)
}
if code := runRateLimitedRequest(router, adminB); code != http.StatusOK {
t.Fatalf("同出口IP的客服B 应不受客服A影响, got %d, want 200", code)
}
}
func TestAdminAwareRateLimitFallsBackToIP(t *testing.T) {
jwtManager := auth.NewJWTManager("test-secret")
userPair, err := jwtManager.GenerateSubjectPairWithVersion(201, "13900000001", "user", 0)
if err != nil {
t.Fatalf("生成用户令牌失败: %v", err)
}
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
for i := 0; i < 2; i++ {
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
t.Fatalf("匿名请求 第 %d 次 = %d, want 200", i+1, code)
}
}
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
t.Fatalf("匿名请求超额 = %d, want 429", code)
}
// 用户令牌无法按 admin 身份解析,应与匿名共享同一个 IP 桶。
if code := runRateLimitedRequest(router, userPair.AccessToken); code != http.StatusTooManyRequests {
t.Fatalf("用户令牌应回退到IP维度, got %d, want 429", code)
}
}
func TestLegacyRateLimitPerMinuteKeepsIPKey(t *testing.T) {
router := newRateLimitRouter(1, nil, RateLimitPerMinute(1, nil))
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
t.Fatalf("首次匿名请求 = %d, want 200", code)
}
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
t.Fatalf("第二次匿名请求 = %d, want 429", code)
}
}
+10 -1
View File
@@ -108,16 +108,25 @@ func isPaymentNotifyPath(path string) bool {
}
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests {
return false
}
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
if isChatSSEPath(path, route) {
return true
}
if latencyMs >= slowRequestThresholdMs {
return false
}
return true
}
func isChatSSEPath(path, route string) bool {
return path == "/api/chats/events" || path == "/api/admin/chats/events" ||
route == "/api/chats/events" || route == "/api/admin/chats/events"
}
func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason)
if !ok {
@@ -19,6 +19,10 @@ func TestShouldSkipHTTPLog(t *testing.T) {
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
{name: "SSE 限流不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 429, latencyMs: 1, wantSkip: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+68 -21
View File
@@ -4,21 +4,32 @@ import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"`
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"`
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"`
// 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。
// 这样读取会话列表不必每次扫描全部订单再按商品分组。
LatestOrderID *uint64 `gorm:"index" json:"-"`
LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"`
LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"`
LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"`
LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"`
// 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。
LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"`
LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"`
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
Title string `gorm:"size:128;not null" json:"title"`
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 {
@@ -42,22 +53,58 @@ func (ChatParticipant) TableName() string {
return "chat_participants"
}
// ChatAdminConversationState stores per-admin state even when the admin is not
// an assigned participant of the conversation.
type ChatAdminConversationState struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"`
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"`
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"`
LastReadAt *time.Time `json:"last_read_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatAdminConversationState) TableName() string {
return "chat_admin_conversation_states"
}
type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatMessage) TableName() string {
return "chat_messages"
}
// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。
func (m *ChatMessage) AfterCreate(tx *gorm.DB) error {
if m.ID == 0 || m.ConversationID == 0 {
return nil
}
updates := map[string]any{}
if m.AdminAttentionType != "" || m.SenderType == "user" {
updates["last_attention_message_id"] = m.ID
}
if m.SenderType == "admin" {
updates["last_admin_message_id"] = m.ID
}
if len(updates) == 0 {
return nil
}
return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error
}
type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"`
-15
View File
@@ -1,15 +0,0 @@
package model
import "time"
// FileUploadOwner 记录用户上传的私有文件归属,用于业务关联创建前的临时访问授权。
type FileUploadOwner struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"not null;index"`
ObjectKey string `gorm:"size:512;not null;uniqueIndex"`
CreatedAt time.Time `json:"created_at"`
}
func (FileUploadOwner) TableName() string {
return "file_upload_owners"
}
+21
View File
@@ -4,6 +4,7 @@ import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type RentalOrder struct {
@@ -41,6 +42,8 @@ type RentalOrder struct {
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
@@ -71,6 +74,24 @@ func (RentalOrder) TableName() string {
return "rental_orders"
}
// AfterSave 将商品关联发布群的最新订单快照一并更新。
// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。
func (o *RentalOrder) AfterSave(tx *gorm.DB) error {
if o.ID == 0 || o.ListingID == 0 {
return nil
}
return tx.Model(&ChatConversation{}).
Where("listing_id = ? AND order_id IS NULL", o.ListingID).
Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID).
Updates(map[string]any{
"latest_order_id": o.ID,
"latest_order_no": o.OrderNo,
"latest_order_status": o.Status,
"latest_order_handoff_status": o.HandoffStatus,
"latest_order_refund_status": o.RefundStatus,
}).Error
}
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
const (
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。
// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。
type ProcessEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"`
BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"`
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
Action string `gorm:"size:64;not null" json:"action"`
ActorType string `gorm:"size:16;not null" json:"actor_type"`
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"`
TargetType string `gorm:"size:16;not null;default:''" json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `gorm:"size:128;not null;default:''" json:"target_name"`
Content string `gorm:"type:text" json:"content"`
Reason string `gorm:"type:text" json:"reason"`
Payload datatypes.JSON `gorm:"not null" json:"payload"`
AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"`
StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"`
StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (ProcessEvent) TableName() string {
return "process_events"
}
+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"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+15 -6
View File
@@ -24,12 +24,14 @@ type MetricsDTO struct {
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
type ListingDailyOverviewDTO struct {
Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
}
type ListingDayStatsDTO struct {
@@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct {
TradeLeaveCount int64 `json:"trade_leave_count"`
}
type ListingUploaderDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
UploaderID uint64 `json:"uploader_id"`
UploaderName string `json:"uploader_name"`
UploadCount int64 `json:"upload_count"`
}
type PendingDTO struct {
ListingReviews int64 `json:"listing_reviews"`
Disputes int64 `json:"disputes"`
@@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
uploads := make([]listingUploaderRow, 0)
if err := db.Table("listing_uploads").
Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at").
Where("listing_id IS NOT NULL").
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&uploads).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket)
byUploader := make(map[listingUploaderKey]int64)
for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key]
@@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
}
addListingDailyEvent(cb, ev.EventType, ev.Source)
}
for _, upload := range uploads {
key := upload.CreatedAt.In(loc).Format("2006-01-02")
uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName}
byUploader[uploaderKey]++
}
trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
@@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
todayStats = trend[len(trend)-1]
}
channelTrend := listingChannelTrend(byChannel)
uploaderTrend := listingUploaderTrend(byUploader)
todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend {
if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item)
}
}
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
for _, item := range uploaderTrend {
if item.Date == todayStats.Date {
todayUploaders = append(todayUploaders, item)
}
}
return ListingDailyOverviewDTO{
Today: todayStats,
Trend: trend,
TodayChannels: todayChannels,
ChannelTrend: channelTrend,
Days: days,
Timezone: "Asia/Shanghai",
Today: todayStats,
Trend: trend,
TodayChannels: todayChannels,
ChannelTrend: channelTrend,
TodayUploaders: todayUploaders,
UploaderTrend: uploaderTrend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
type listingUploaderRow struct {
UploaderID uint64
UploaderName string
CreatedAt time.Time
}
type listingChannelKey struct {
date string
sourceChannel string
}
type listingUploaderKey struct {
date string
uploaderID uint64
uploaderName string
}
type listingDailyBucket struct {
published int64
activeOffline int64
@@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi
return items
}
func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO {
keys := make([]listingUploaderKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
if rows[keys[i]] != rows[keys[j]] {
return rows[keys[i]] > rows[keys[j]]
}
if keys[i].uploaderName != keys[j].uploaderName {
return keys[i].uploaderName < keys[j].uploaderName
}
return keys[i].uploaderID < keys[j].uploaderID
})
items := make([]ListingUploaderDayStatsDTO, 0, len(keys))
for _, key := range keys {
items = append(items, ListingUploaderDayStatsDTO{
Date: key.date,
UploaderID: key.uploaderID,
UploaderName: key.uploaderName,
UploadCount: rows[key],
})
}
return items
}
func listingChannelRank(channel string) int {
switch channel {
case "咸鱼":
@@ -65,3 +65,44 @@ func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published,
t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave)
}
}
func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) {
db := database.NewTestDB()
if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
loc := timeutil.ShanghaiLocation()
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
uploaderX := uint64(11)
uploaderY := uint64(12)
listingOne := uint64(101)
listingTwo := uint64(102)
listingThree := uint64(103)
uploads := []model.ListingUpload{
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now},
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)},
{UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)},
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)},
}
if err := db.Create(&uploads).Error; err != nil {
t.Fatalf("create uploads error = %v", err)
}
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2)
if err != nil {
t.Fatalf("listingDailyOverview() error = %v", err)
}
if len(overview.TodayUploaders) != 2 {
t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders)
}
counts := make(map[string]int64)
for _, item := range overview.TodayUploaders {
counts[item.UploaderName] = item.UploadCount
}
if counts["小晴"] != 2 || counts["小美"] != 1 {
t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts)
}
if len(overview.UploaderTrend) != 3 {
t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend)
}
}
@@ -2,39 +2,96 @@ package adminfinance
import (
"context"
"sync"
"hfb_sys/backend/internal/timeutil"
)
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
dailyItems, err := r.dailyItems(ctx, query)
if err != nil {
return nil, err
if cached := r.loadDashboardCache(ctx, query); cached != nil {
return cached, nil
}
pickup, err := r.pickupSummary(ctx, query)
if err != nil {
return nil, err
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
var (
dailyItems []FinanceDailyDTO
pickup *PickupSummaryDTO
mohong *MohongSummaryDTO
disbursement *DisbursementSummaryDTO
operatingExpense *OperatingExpenseSummaryDTO
)
var wg sync.WaitGroup
errCh := make(chan error, 5)
run := func(fn func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
errCh <- err
}
}()
}
run(func() error {
var err error
dailyItems, err = r.dailyItems(ctx, query)
return err
})
run(func() error {
var err error
pickup, err = r.pickupSummary(ctx, query)
return err
})
run(func() error {
var err error
mohong, err = r.mohongSummary(ctx, query)
return err
})
run(func() error {
var err error
disbursement, err = r.disbursementSummary(ctx, query)
return err
})
run(func() error {
var err error
operatingExpense, err = r.operatingExpenseSummary(ctx, query)
return err
})
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return nil, err
}
}
summary, err := r.summary(ctx, query, pickup)
if err != nil {
return nil, err
}
mohong, err := r.mohongSummary(ctx, query)
result := &DashboardDTO{
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 {
return nil, err
}
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
return &summary, nil
}
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
@@ -103,6 +160,7 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*
func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) {
db := r.db.WithContext(ctx)
var payment paymentSummaryRow
originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query)
if err := db.Table("payment_orders AS po").
Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
@@ -112,12 +170,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
Joins(`LEFT JOIN (
SELECT order_id, MAX(amount_cent) AS amount_cent
FROM payment_orders
WHERE biz_type IN ? AND status = 'paid'
GROUP BY order_id
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Scan(&payment).Error; err != nil {
return nil, err
@@ -141,32 +194,31 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE 0
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(ro.offline_settlement_amount_cent, 0) > 0
THEN 1
ELSE 0
END), 0) AS offline_settlement_pending_count,
COUNT(ro.id) AS settled_order_count`).
COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0
OR COALESCE(p.refunding_amount_cent, 0) > 0
OR ro.refund_status = 'refunding'
OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed'
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
END) >= ?
THEN 1
ELSE 0
END), 0) AS financial_exception_count,
COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent).
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)).
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Scan(&settlement).Error; err != nil {
return nil, err
}
var exceptionCount int64
if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{
DateType: "settled",
StartDate: query.StartDate,
EndDate: query.EndDate,
})).
Where("finance_status <> ?", financeStatusNormal).
Where("finance_status <> ?", financeStatusOfflineSettlementPending).
Count(&exceptionCount).Error; err != nil {
return nil, err
}
// 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。
var estimated estimatedIncomeRow
if err := db.Table("rental_orders").
@@ -209,7 +261,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
SettledOrderCount: settlement.SettledOrderCount,
PendingSettleOrderCount: estimated.PendingSettleOrderCount,
OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount,
FinancialExceptionCount: exceptionCount,
FinancialExceptionCount: settlement.FinancialExceptionCount,
}, nil
}
@@ -251,7 +303,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
END), 0) AS offline_settlement_pending_count,
COUNT(ro.id) AS settled_order_count`).
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)).
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(ro.settled_at)").
Scan(&settlements).Error; err != nil {
@@ -303,6 +355,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
if err != nil {
return nil, err
}
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
return nil, err
}
itemsByDate := make(map[string]FinanceDailyDTO)
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -392,6 +448,14 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
itemsByDate[date] = item
}
for _, row := range operatingExpenses {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.OperatingExpenseAmountCent = row.AmountCent
item.OperatingExpenseCount = row.Count
itemsByDate[date] = item
}
items := make([]FinanceDailyDTO, 0, len(itemsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -404,7 +468,8 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。
func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) {
rows := make([]dailyPaymentRow, 0)
err := r.db.WithContext(ctx).Table("payment_orders AS po").
db := r.db.WithContext(ctx)
err := db.Table("payment_orders AS po").
Select(`DATE(po.created_at) AS date,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
@@ -415,12 +480,7 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
Joins(`LEFT JOIN (
SELECT order_id, MAX(amount_cent) AS amount_cent
FROM payment_orders
WHERE biz_type IN ? AND status = 'paid'
GROUP BY order_id
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(po.created_at)").
Scan(&rows).Error
@@ -477,6 +537,7 @@ type settlementSummaryRow struct {
OfflineSettlementAmountCent int64
OfflineSettlementPendingAmountCent int64
OfflineSettlementPendingCount int64
FinancialExceptionCount int64
SettledOrderCount int64
}
@@ -0,0 +1,41 @@
package adminfinance
import (
"context"
"encoding/json"
"fmt"
"time"
)
const dashboardCacheTTL = time.Minute
func (r *Repository) dashboardCacheKey(query DashboardQuery) string {
return fmt.Sprintf("admin-finance:dashboard:v1:%d:%d", query.StartDate.Unix(), query.EndDate.Unix())
}
func (r *Repository) loadDashboardCache(ctx context.Context, query DashboardQuery) *DashboardDTO {
if r.redis == nil {
return nil
}
raw, err := r.redis.Get(ctx, r.dashboardCacheKey(query)).Bytes()
if err != nil {
return nil
}
var value DashboardDTO
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
return &value
}
func (r *Repository) storeDashboardCache(ctx context.Context, query DashboardQuery, value *DashboardDTO) {
if r.redis == nil || value == nil {
return
}
raw, err := json.Marshal(value)
if err != nil {
return
}
// 缓存不可用时静默降级到实时统计,不能影响财务页面可用性。
_ = r.redis.Set(ctx, r.dashboardCacheKey(query), raw, dashboardCacheTTL).Err()
}
@@ -240,3 +240,87 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) {
t.Fatalf("调整日汇总 = %+v", adjusted)
}
}
func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
for _, statement := range []string{
`CREATE TABLE payment_orders (
id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, biz_type TEXT NOT NULL,
status TEXT NOT NULL, amount_cent INTEGER NOT NULL, created_at DATETIME NOT NULL
)`,
`CREATE TABLE rental_orders (
id INTEGER PRIMARY KEY, settled_at DATETIME NULL, settlement_mode TEXT NOT NULL,
offline_settlement_amount_cent INTEGER NOT NULL, offline_settlement_status TEXT NOT NULL,
refund_status TEXT NOT NULL, settlement_status TEXT NOT NULL, status TEXT NOT NULL,
created_at DATETIME NOT NULL, platform_fee_cent INTEGER NOT NULL
)`,
`CREATE TABLE order_checkouts (
id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, status TEXT NOT NULL,
platform_fee_cent INTEGER NOT NULL, owner_income_amount_cent INTEGER NOT NULL
)`,
`CREATE TABLE wallet_ledger (
id INTEGER PRIMARY KEY, order_id INTEGER NULL, direction TEXT NOT NULL,
biz_type TEXT NOT NULL, amount_cent INTEGER NOT NULL
)`,
`CREATE TABLE admin_pickups (
id INTEGER PRIMARY KEY, status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL,
profit_amount_cent INTEGER NOT NULL, completed_at DATETIME NULL
)`,
`CREATE TABLE admin_pickup_financial_adjustments (
id INTEGER PRIMARY KEY, pickup_id INTEGER NOT NULL, settle_delta_cent INTEGER NOT NULL,
profit_delta_cent INTEGER NOT NULL, created_at DATETIME NOT NULL
)`,
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("创建统计测试表失败: %v", err)
}
}
loc := timeutil.ShanghaiLocation()
settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc)
for _, row := range [][]any{
{1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300},
{2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200},
} {
if err := db.Exec(`INSERT INTO rental_orders
(id, settled_at, settlement_mode, offline_settlement_amount_cent, offline_settlement_status,
refund_status, settlement_status, status, created_at, platform_fee_cent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入订单失败: %v", err)
}
}
for _, row := range [][]any{{1, 1, "accepted", 300, 700}, {2, 2, "accepted", 200, 600}} {
if err := db.Exec(`INSERT INTO order_checkouts
(id, order_id, status, platform_fee_cent, owner_income_amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入结账单失败: %v", err)
}
}
for _, row := range [][]any{{1, 1, "in", "owner_income", 700}, {2, 2, "in", "owner_income", 500}} {
if err := db.Exec(`INSERT INTO wallet_ledger (id, order_id, direction, biz_type, amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入钱包流水失败: %v", err)
}
}
query := DashboardQuery{
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc),
}
repo := NewRepository(db)
pickup, err := repo.pickupSummary(t.Context(), query)
if err != nil {
t.Fatalf("读取提号汇总失败: %v", err)
}
summary, err := repo.summary(t.Context(), query, pickup)
if err != nil {
t.Fatalf("读取财务汇总失败: %v", err)
}
if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 {
t.Fatalf("普通订单数量或利润错误: %+v", summary)
}
if summary.FinancialExceptionCount != 1 {
t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount)
}
}
@@ -2,6 +2,23 @@ package adminfinance
import "context"
type dailyOperatingExpenseRow struct {
Date string
AmountCent int64
Count int64
}
func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) {
rows := make([]dailyOperatingExpenseRow, 0)
err := r.db.WithContext(ctx).Table("operating_expenses").
Select(`DATE(occurred_at) AS date, COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("status = ?", "paid").
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
Group("DATE(occurred_at)").
Scan(&rows).Error
return rows, err
}
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
db := r.db.WithContext(ctx)
@@ -81,42 +98,29 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
return nil, err
}
var withdrawalPaid struct {
AmountCent int64
FeeAmountCent int64
ActualAmountCent int64
Count int64
// 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。
var withdrawal struct {
PaidAmountCent int64
PaidFeeAmountCent int64
PaidActualAmountCent int64
PaidCount int64
PendingAmountCent int64
PendingCount int64
ReviewAmountCent int64
ReviewCount int64
}
if err := db.Table("withdrawal_requests").
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent,
COALESCE(SUM(fee_cent), 0) AS fee_amount_cent,
COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent,
COUNT(id) AS count`).
Where("status = ?", "completed").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Scan(&withdrawalPaid).Error; err != nil {
return nil, err
}
var withdrawalPending struct {
ActualAmountCent int64
Count int64
}
if err := db.Table("withdrawal_requests").
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
Where("status = ?", "processing").
Scan(&withdrawalPending).Error; err != nil {
return nil, err
}
var withdrawalReview struct {
ActualAmountCent int64
Count int64
}
if err := db.Table("withdrawal_requests").
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
Where("status = ?", "pending").
Scan(&withdrawalReview).Error; err != nil {
if err := db.Table("withdrawal_requests").Select(`
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN fee_cent ELSE 0 END), 0) AS paid_fee_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN actual_amount_cent ELSE 0 END), 0) AS paid_actual_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count,
COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent,
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`,
query.StartDate, query.EndDate, query.StartDate, query.EndDate,
query.StartDate, query.EndDate, query.StartDate, query.EndDate).
Scan(&withdrawal).Error; err != nil {
return nil, err
}
@@ -133,22 +137,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
}
return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count,
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
OfflineSettlementPendingAmountCent: offlinePending.AmountCent,
OfflineSettlementPendingCount: offlinePending.Count,
WithdrawalAmountCent: withdrawalPaid.AmountCent,
WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent,
WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent,
WithdrawalPaidCount: withdrawalPaid.Count,
WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent,
WithdrawalPendingCount: withdrawalPending.Count,
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
WithdrawalReviewCount: withdrawalReview.Count,
WithdrawalAmountCent: withdrawal.PaidAmountCent,
WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent,
WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent,
WithdrawalPaidCount: withdrawal.PaidCount,
WithdrawalPendingAmountCent: withdrawal.PendingAmountCent,
WithdrawalPendingCount: withdrawal.PendingCount,
WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent,
WithdrawalReviewCount: withdrawal.ReviewCount,
ManualPaidAmountCent: manualPaid.AmountCent,
ManualPaidCount: manualPaid.Count,
}, nil
@@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
@@ -125,6 +126,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
@@ -160,6 +162,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark,
md.category,
md.custom_category_name,
md.voucher_url,
md.voided_at,
md.voided_by,
@@ -202,37 +205,38 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
}
type disbursementItemRow struct {
SourceType string
SourceID uint64
BusinessNo string
OrderID uint64
WithdrawalID uint64
UserID uint64
PayeeName string
PayeePhone string
UploaderName string
SourceChannel string
AccountType string
AccountName string
AccountNo string
BankName string
AmountCent int64
FeeCent int64
ActualAmountCent int64
Status string
RawStatus string
BusinessCreatedAt *time.Time
PaidAt *time.Time
OperatorID *uint64
OperatorName string
Remark string
Category string
VoucherURL string
VoidedAt *time.Time
VoidedBy *uint64
VoidedByName string
VoidReason string
SourcePayload []byte
SourceType string
SourceID uint64
BusinessNo string
OrderID uint64
WithdrawalID uint64
UserID uint64
PayeeName string
PayeePhone string
UploaderName string
SourceChannel string
AccountType string
AccountName string
AccountNo string
BankName string
AmountCent int64
FeeCent int64
ActualAmountCent int64
Status string
RawStatus string
BusinessCreatedAt *time.Time
PaidAt *time.Time
OperatorID *uint64
OperatorName string
Remark string
Category string
CustomCategoryName string
VoucherURL string
VoidedAt *time.Time
VoidedBy *uint64
VoidedByName string
VoidReason string
SourcePayload []byte
}
func (r disbursementItemRow) toDTO() DisbursementItemDTO {
@@ -246,35 +250,36 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
}
}
return DisbursementItemDTO{
SourceType: r.SourceType,
SourceID: r.SourceID,
BusinessNo: r.BusinessNo,
OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID,
UserID: r.UserID,
PayeeName: r.PayeeName,
PayeePhone: phone,
UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel,
AccountType: r.AccountType,
AccountName: r.AccountName,
AccountNo: r.AccountNo,
BankName: r.BankName,
AmountCent: r.AmountCent,
FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent,
Status: r.Status,
RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt,
OperatorID: r.OperatorID,
OperatorName: r.OperatorName,
Remark: r.Remark,
Category: r.Category,
VoucherURL: r.VoucherURL,
VoidedAt: r.VoidedAt,
VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName,
VoidReason: r.VoidReason,
SourceType: r.SourceType,
SourceID: r.SourceID,
BusinessNo: r.BusinessNo,
OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID,
UserID: r.UserID,
PayeeName: r.PayeeName,
PayeePhone: phone,
UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel,
AccountType: r.AccountType,
AccountName: r.AccountName,
AccountNo: r.AccountNo,
BankName: r.BankName,
AmountCent: r.AmountCent,
FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent,
Status: r.Status,
RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt,
OperatorID: r.OperatorID,
OperatorName: r.OperatorName,
Remark: r.Remark,
Category: r.Category,
CustomCategoryName: r.CustomCategoryName,
VoucherURL: r.VoucherURL,
VoidedAt: r.VoidedAt,
VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName,
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 admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT,
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT,
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, payee_name, amount_cent, paid_at, remark,
(id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark,
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
(20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err)
}
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
break
}
}
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" {
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
}
+126 -58
View File
@@ -34,13 +34,25 @@ type DisbursementQuery struct {
PageSize int
}
type OperatingExpenseQuery struct {
Status string
Category string
Keyword string
DateType string
StartDate time.Time
EndDate time.Time
Page int
PageSize int
}
type DashboardDTO struct {
Summary FinanceSummaryDTO `json:"summary"`
DailyItems []FinanceDailyDTO `json:"daily_items"`
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
GeneratedAt time.Time `json:"generated_at"`
Summary FinanceSummaryDTO `json:"summary"`
DailyItems []FinanceDailyDTO `json:"daily_items"`
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"`
GeneratedAt time.Time `json:"generated_at"`
}
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
@@ -101,46 +113,67 @@ type DisbursementListSummaryDTO struct {
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
}
type OperatingExpenseSummaryDTO struct {
AmountCent int64 `json:"amount_cent"`
Count int64 `json:"count"`
}
type OperatingExpenseListSummaryDTO struct {
RecordCount int64 `json:"record_count"`
PaidAmountCent int64 `json:"paid_amount_cent"`
PaidCount int64 `json:"paid_count"`
}
type OperatingExpenseListDTO struct {
Items []OperatingExpenseDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Summary OperatingExpenseListSummaryDTO `json:"summary"`
}
type DisbursementItemDTO struct {
SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"`
AccountName string `json:"account_name"`
AccountNo string `json:"account_no"`
BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"`
RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"`
Remark string `json:"remark"`
Category string `json:"category"`
VoucherURL string `json:"voucher_url"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"`
AccountName string `json:"account_name"`
AccountNo string `json:"account_no"`
BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"`
RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"`
Remark string `json:"remark"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
VoucherURL string `json:"voucher_url"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
}
type CreateManualDisbursementRequest struct {
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
}
type VoidManualDisbursementRequest struct {
@@ -148,22 +181,55 @@ type VoidManualDisbursementRequest struct {
}
type ManualDisbursementDTO struct {
ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_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"`
ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
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 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 {
@@ -230,6 +296,8 @@ type FinanceDailyDTO struct {
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"`
OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"`
OperatingExpenseCount int64 `json:"operating_expense_count"`
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
}
@@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) OperatingExpenses(c *gin.Context) {
query, ok := parseOperatingExpenseQuery(c)
if !ok {
return
}
result, err := h.service.OperatingExpenses(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
@@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) CreateOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "运营开支信息不正确")
return
}
item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) VoidOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "运营开支记录 ID 不正确")
return
}
var req VoidOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写作废原因")
return
}
item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
start, end, ok := parseDateRange(c, 6)
if !ok {
@@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
return query, true
}
func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
start, end, ok := parseDateRange(c, 29)
if !ok {
return OperatingExpenseQuery{}, false
}
query := OperatingExpenseQuery{
Status: strings.TrimSpace(c.Query("status")),
Category: strings.TrimSpace(c.Query("category")),
Keyword: strings.TrimSpace(c.Query("keyword")),
DateType: c.DefaultQuery("date_type", "created"),
StartDate: start,
EndDate: end,
}
if query.Status != "" && query.Status != "paid" && query.Status != "voided" {
response.BadRequest(c, "运营开支状态不正确")
return query, false
}
if query.DateType != "created" && query.DateType != "occurred" {
response.BadRequest(c, "日期类型不正确")
return query, false
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
loc := timeutil.ShanghaiLocation()
today := timeutil.ShanghaiNow()
@@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) {
response.NotFound(c, "线下出款记录不存在")
case errors.Is(err, ErrManualDisbursementNotPaid):
response.BadRequest(c, "该线下出款记录已作废")
case errors.Is(err, ErrInvalidOperatingExpense):
response.BadRequest(c, "运营开支信息不正确")
case errors.Is(err, ErrOperatingExpenseNotFound):
response.NotFound(c, "运营开支记录不存在")
case errors.Is(err, ErrOperatingExpenseNotPaid):
response.BadRequest(c, "该运营开支记录已作废")
default:
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
}
@@ -53,7 +53,7 @@ func normalRefundBizTypes() []string {
// settledSettlementStatuses 是视为"已完成结算"的结算状态集合。
// 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。
func settledSettlementStatuses() []string {
return []string{"settled", "closed"}
return []string{"settled", "closed", "arbitrated"}
}
// nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。
@@ -45,3 +45,15 @@ func TestRefundBizTypesIncludeMohongRefund(t *testing.T) {
t.Fatal("refundBizTypes missing mohong_refund")
}
}
func TestSettledSettlementStatusesIncludeArbitrated(t *testing.T) {
values := map[string]bool{}
for _, item := range settledSettlementStatuses() {
values[item] = true
}
for _, want := range []string{"settled", "closed", "arbitrated"} {
if !values[want] {
t.Fatalf("settledSettlementStatuses missing %s", want)
}
}
}
@@ -16,21 +16,22 @@ import (
)
type manualDisbursementRecord struct {
ID uint64
DisbursementNo string
Category string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
}
func (manualDisbursementRecord) TableName() string {
@@ -48,15 +49,16 @@ func (r *Repository) CreateManualDisbursement(
return nil, err
}
record := manualDisbursementRecord{
DisbursementNo: disbursementNo,
Category: req.Category,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
PaidAt: req.PaidAt,
Remark: req.Remark,
VoucherURL: req.VoucherURL,
Status: "paid",
CreatedBy: adminID,
DisbursementNo: disbursementNo,
Category: req.Category,
CustomCategoryName: req.CustomCategoryName,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
PaidAt: req.PaidAt,
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 {
@@ -71,12 +73,13 @@ func (r *Repository) CreateManualDisbursement(
BizID: &id,
Meta: meta,
Detail: map[string]any{
"disbursement_no": disbursementNo,
"category": req.Category,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"paid_at": req.PaidAt,
"has_voucher": req.VoucherURL != "",
"disbursement_no": disbursementNo,
"category": req.Category,
"custom_category_name": req.CustomCategoryName,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"paid_at": req.PaidAt,
"has_voucher": req.VoucherURL != "",
},
})
})
@@ -153,42 +156,44 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
return nil, err
}
return &ManualDisbursementDTO{
ID: row.ID,
DisbursementNo: row.DisbursementNo,
Category: row.Category,
PayeeName: row.PayeeName,
AmountCent: row.AmountCent,
PaidAt: row.PaidAt,
Remark: row.Remark,
VoucherURL: row.VoucherURL,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedByName: row.CreatedByName,
VoidedBy: row.VoidedBy,
VoidedByName: row.VoidedByName,
VoidedAt: row.VoidedAt,
VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
ID: row.ID,
DisbursementNo: row.DisbursementNo,
Category: row.Category,
CustomCategoryName: row.CustomCategoryName,
PayeeName: row.PayeeName,
AmountCent: row.AmountCent,
PaidAt: row.PaidAt,
Remark: row.Remark,
VoucherURL: row.VoucherURL,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedByName: row.CreatedByName,
VoidedBy: row.VoidedBy,
VoidedByName: row.VoidedByName,
VoidedAt: row.VoidedAt,
VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
}, nil
}
type manualDisbursementDetailRow struct {
ID uint64
DisbursementNo string
Category string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
CreatedByName string
VoidedBy *uint64
VoidedByName string
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt 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 newManualDisbursementNo() (string, error) {
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
statements := []string{
`CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
@@ -47,17 +47,18 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "operating_expense",
PayeeName: "测试供应商",
AmountCent: 8800,
PaidAt: paidAt,
Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
Category: "custom",
CustomCategoryName: "临时活动支出",
PayeeName: "测试供应商",
AmountCent: 8800,
PaidAt: paidAt,
Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err)
}
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" {
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" {
t.Fatalf("创建结果不正确: %+v", created)
}
@@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
}
func TestManualDisbursementValidation(t *testing.T) {
if !validManualDisbursementCategory("duoduo_deposit_refund") {
t.Fatal("多多退押金分类应为有效分类")
}
if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") {
t.Fatal("自定义分类名称校验不正确")
}
service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown",
@@ -0,0 +1,234 @@
package adminfinance
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type operatingExpenseRecord struct {
ID uint64
ExpenseNo string
Category string
PayeeName string
AmountCent int64
OccurredAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
}
func (operatingExpenseRecord) TableName() string {
return "operating_expenses"
}
func (r *Repository) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
expenseNo, err := newOperatingExpenseNo()
if err != nil {
return nil, err
}
record := operatingExpenseRecord{
ExpenseNo: expenseNo,
Category: req.Category,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
OccurredAt: req.OccurredAt,
Remark: req.Remark,
VoucherURL: req.VoucherURL,
Status: "paid",
CreatedBy: adminID,
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil {
return err
}
id := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "operating_expense_create",
BizType: "operating_expense",
BizID: &id,
Meta: meta,
Detail: map[string]any{
"expense_no": expenseNo,
"category": req.Category,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"occurred_at": req.OccurredAt,
"has_voucher": req.VoucherURL != "",
},
})
})
if err != nil {
return nil, err
}
return r.findOperatingExpense(ctx, record.ID)
}
func (r *Repository) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record operatingExpenseRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrOperatingExpenseNotFound
}
return err
}
if record.Status != "paid" {
return ErrOperatingExpenseNotPaid
}
now := timeutil.ShanghaiNow()
if err := tx.Model(&record).Updates(map[string]any{
"status": "voided",
"voided_by": adminID,
"voided_at": now,
"void_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
bizID := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "operating_expense_void",
BizType: "operating_expense",
BizID: &bizID,
Meta: meta,
Detail: map[string]any{
"expense_no": record.ExpenseNo,
"amount_cent": record.AmountCent,
"reason": reason,
},
})
})
if err != nil {
return nil, err
}
return r.findOperatingExpense(ctx, id)
}
func (r *Repository) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
var summary OperatingExpenseListSummaryDTO
if err := r.applyOperatingExpenseFilters(r.db.WithContext(ctx).Table("operating_expenses AS oe"), query).
Select(`COUNT(*) AS record_count,
COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count`).
Scan(&summary).Error; err != nil {
return nil, err
}
rows := make([]operatingExpenseDetailRow, 0, query.PageSize)
orderColumn := "oe.created_at"
if query.DateType == "occurred" {
orderColumn = "oe.occurred_at"
}
if err := r.applyOperatingExpenseFilters(r.operatingExpenseDetailQuery(ctx), query).
Order(orderColumn + " DESC").
Order("oe.id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]OperatingExpenseDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &OperatingExpenseListDTO{Items: items, Total: summary.RecordCount, Page: query.Page, PageSize: query.PageSize, Summary: summary}, nil
}
func (r *Repository) findOperatingExpense(ctx context.Context, id uint64) (*OperatingExpenseDTO, error) {
var row operatingExpenseDetailRow
err := r.operatingExpenseDetailQuery(ctx).Where("oe.id = ?", id).Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOperatingExpenseNotFound
}
if err != nil {
return nil, err
}
item := row.toDTO()
return &item, nil
}
func (r *Repository) operatingExpenseDetailQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("operating_expenses AS oe").
Select(`oe.*, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
Joins("LEFT JOIN admin_users AS creator ON creator.id = oe.created_by").
Joins("LEFT JOIN admin_users AS voider ON voider.id = oe.voided_by")
}
func (r *Repository) applyOperatingExpenseFilters(db *gorm.DB, query OperatingExpenseQuery) *gorm.DB {
if query.Status != "" {
db = db.Where("oe.status = ?", query.Status)
}
if query.Category != "" {
db = db.Where("oe.category = ?", query.Category)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
db = db.Where("oe.expense_no LIKE ? OR oe.payee_name LIKE ? OR oe.remark LIKE ?", like, like, like)
}
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
column := "oe.created_at"
if query.DateType == "occurred" {
column = "oe.occurred_at"
}
db = db.Where(column+" >= ? AND "+column+" <= ?", query.StartDate, query.EndDate)
}
return db
}
type operatingExpenseDetailRow struct {
ID uint64
ExpenseNo string
Category string
PayeeName string
AmountCent int64
OccurredAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
CreatedByName string
VoidedBy *uint64
VoidedByName string
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
}
func (r operatingExpenseDetailRow) toDTO() OperatingExpenseDTO {
return OperatingExpenseDTO{
ID: r.ID, ExpenseNo: r.ExpenseNo, Category: r.Category, PayeeName: r.PayeeName,
AmountCent: r.AmountCent, OccurredAt: r.OccurredAt, Remark: r.Remark, VoucherURL: r.VoucherURL,
Status: r.Status, CreatedBy: r.CreatedBy, CreatedByName: r.CreatedByName, VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName, VoidedAt: r.VoidedAt, VoidReason: r.VoidReason, CreatedAt: r.CreatedAt,
}
}
func newOperatingExpenseNo() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("OE%s%s", timeutil.ShanghaiNow().Format("20060102150405"), strings.ToUpper(hex.EncodeToString(buf))), nil
}
@@ -0,0 +1,117 @@
package adminfinance
import (
"errors"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestOperatingExpenseCreateListAndVoid(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
for _, statement := range []string{
`CREATE TABLE operating_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT, expense_no TEXT NOT NULL UNIQUE, category TEXT NOT NULL,
payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL,
remark TEXT NOT NULL, voucher_url TEXT NOT NULL, status TEXT NOT NULL, created_by INTEGER NOT NULL,
voided_by INTEGER, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, updated_at DATETIME
)`,
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER, action TEXT, biz_type TEXT,
biz_id INTEGER, ip TEXT, user_agent TEXT, detail BLOB, created_at DATETIME
)`,
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("创建测试表失败: %v", err)
}
}
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES (7, '运营甲', 'ops_a'), (8, '运营乙', 'ops_b')`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db)
occurredAt := time.Date(2026, 8, 20, 10, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateOperatingExpense(t.Context(), CreateOperatingExpenseRequest{
Category: "推广投放",
PayeeName: "测试媒体",
AmountCent: 12800,
OccurredAt: occurredAt,
Remark: "八月推广费用",
VoucherURL: "/api/files/object?key=operating-expense%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil {
t.Fatalf("创建运营开支失败: %v", err)
}
if created.ID == 0 || created.ExpenseNo == "" || created.Status != "paid" || created.CreatedByName != "运营甲" {
t.Fatalf("创建结果不正确: %+v", created)
}
list, err := repo.OperatingExpenses(t.Context(), OperatingExpenseQuery{
DateType: "occurred", StartDate: occurredAt.Add(-time.Hour), EndDate: occurredAt.Add(time.Hour), Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("查询运营开支失败: %v", err)
}
if list.Total != 1 || list.Summary.PaidAmountCent != 12800 || list.Items[0].Category != "推广投放" {
t.Fatalf("运营开支列表不正确: %+v", list)
}
voided, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复录入", 8, auditlog.Meta{RequestID: "req-void"})
if err != nil {
t.Fatalf("作废运营开支失败: %v", err)
}
if voided.Status != "voided" || voided.VoidedByName != "运营乙" || voided.VoidReason != "重复录入" {
t.Fatalf("作废结果不正确: %+v", voided)
}
if _, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrOperatingExpenseNotPaid) {
t.Fatalf("重复作废错误 = %v, want ErrOperatingExpenseNotPaid", err)
}
}
func TestOperatingExpenseDashboardSummaryExcludesVoided(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.Exec(`CREATE TABLE operating_expenses (
id INTEGER PRIMARY KEY, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL, status TEXT NOT NULL
)`).Error; err != nil {
t.Fatalf("创建运营开支表失败: %v", err)
}
loc := timeutil.ShanghaiLocation()
inRange := time.Date(2026, 8, 20, 10, 0, 0, 0, loc)
if err := db.Exec(`INSERT INTO operating_expenses (id, amount_cent, occurred_at, status) VALUES
(1, 12000, ?, 'paid'), (2, 8000, ?, 'voided'), (3, 5000, ?, 'paid')`, inRange, inRange, inRange.AddDate(0, 0, -1)).Error; err != nil {
t.Fatal(err)
}
summary, err := NewRepository(db).operatingExpenseSummary(t.Context(), DashboardQuery{
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
})
if err != nil {
t.Fatalf("运营开支仪表盘统计失败: %v", err)
}
if summary.AmountCent != 12000 || summary.Count != 1 {
t.Fatalf("运营开支统计 = %+v, want 12000/1", summary)
}
daily, err := NewRepository(db).dailyOperatingExpenses(t.Context(), DashboardQuery{
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
})
if err != nil {
t.Fatalf("每日运营开支统计失败: %v", err)
}
if len(daily) != 1 || daily[0].Date != "2026-08-20" || daily[0].AmountCent != 12000 || daily[0].Count != 1 {
t.Fatalf("每日运营开支统计 = %+v, want 2026-08-20/12000/1", daily)
}
}
@@ -1,13 +1,19 @@
package adminfinance
import (
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
db *gorm.DB
redis *redis.Client
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository {
repo := &Repository{db: db}
if len(redisClient) > 0 {
repo.redis = redisClient[0]
}
return repo
}
@@ -15,6 +15,9 @@ var (
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
ErrInvalidOperatingExpense = errors.New("invalid operating expense")
ErrOperatingExpenseNotFound = errors.New("operating expense not found")
ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid")
)
type Service struct {
@@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
return s.repo.Disbursements(ctx, query)
}
func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.OperatingExpenses(ctx, query)
}
func (s *Service) CreateManualDisbursement(
ctx context.Context,
req CreateManualDisbursementRequest,
@@ -74,10 +93,12 @@ func (s *Service) CreateManualDisbursement(
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
@@ -104,15 +125,51 @@ func (s *Service) VoidManualDisbursement(
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
}
func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.OccurredAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
!validManualVoucherURL(req.VoucherURL) {
return nil, ErrInvalidOperatingExpense
}
return s.repo.CreateOperatingExpense(ctx, req, adminID, meta)
}
func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reason = strings.TrimSpace(reason)
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
return nil, ErrInvalidOperatingExpense
}
return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta)
}
func validManualDisbursementCategory(category string) bool {
switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
return true
default:
return false
}
}
func validManualDisbursementCustomCategoryName(category, name string) bool {
if category != "custom" {
return name == ""
}
return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50
}
func validManualVoucherURL(raw string) bool {
if raw == "" {
return true
@@ -28,3 +28,44 @@ func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB {
Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}).
Group("order_id")
}
// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。
// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合,
// 再使用 wallet_ledger.order_id 索引关联对应流水。
func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
return db.Table("rental_orders AS scoped_order").
Select(`scoped_order.id AS order_id,
COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`).
Joins(`LEFT JOIN wallet_ledger AS wallet
ON wallet.order_id = scoped_order.id
AND wallet.direction = ?
AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation"}).
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
Group("scoped_order.id")
}
// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态,
// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。
func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
return db.Table("rental_orders AS scoped_order").
Select(`scoped_order.id AS order_id,
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent,
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()).
Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()).
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
Group("scoped_order.id")
}
// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。
// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。
func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
refundOrders := db.Table("payment_orders AS refund").
Select("DISTINCT refund.order_id").
Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded").
Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate)
return db.Table("payment_orders AS paid").
Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent").
Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders).
Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid").
Group("paid.order_id")
}
@@ -19,8 +19,9 @@ import (
)
type Repository struct {
db *gorm.DB
encryptor crypto.Encryptor
db *gorm.DB
encryptor crypto.Encryptor
statusChangeNotifier func(userID uint64)
}
type AuditMeta = auditlog.Meta
@@ -33,6 +34,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
return &Repository{db: db, encryptor: encryptor}
}
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
r.statusChangeNotifier = notifier
}
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil {
@@ -207,6 +213,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
}
func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
statusChanged := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
@@ -214,27 +221,33 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
}
beforeStatus := user.Status
beforeRisk := user.RiskStatus
beforeTokenVersion := user.TokenVersion
user.Status = status
user.RiskStatus = riskStatus
if user.TokenVersion < 1 {
user.TokenVersion = 1
if beforeStatus != status {
user.TokenVersion++
statusChanged = true
}
user.TokenVersion++
if err := tx.Save(&user).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": reason,
"before_status": beforeStatus,
"after_status": status,
"before_risk_status": beforeRisk,
"after_risk_status": riskStatus,
"user_id": user.ID,
"reason": reason,
"before_status": beforeStatus,
"after_status": status,
"before_risk_status": beforeRisk,
"after_risk_status": riskStatus,
"before_token_version": beforeTokenVersion,
"after_token_version": user.TokenVersion,
})
})
if err != nil {
return nil, err
}
if statusChanged && r.statusChangeNotifier != nil {
r.statusChangeNotifier(userID)
}
return r.Find(ctx, userID)
}
@@ -293,6 +293,38 @@ func TestAdjustWalletInsufficientBalance(t *testing.T) {
}
}
func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 0}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{})
if err != nil {
t.Fatalf("冻结用户失败:%v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询冻结用户失败:%v", err)
}
if got.Status != "frozen" || saved.TokenVersion != 1 {
t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/1", got.Status, saved.TokenVersion)
}
got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{})
if err != nil {
t.Fatalf("解冻用户失败:%v", err)
}
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询解冻用户失败:%v", err)
}
if got.Status != "active" || saved.TokenVersion != 2 {
t.Fatalf("解冻后状态/版本 = %s/%d, want active/2", got.Status, saved.TokenVersion)
}
}
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。
func (s *Service) SetSessionRevoker(revoker func(userID uint64)) {
if s.repo != nil {
s.repo.SetStatusChangeNotifier(revoker)
}
}
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+4 -28
View File
@@ -111,7 +111,6 @@ func (h *Handler) Login(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -134,10 +133,13 @@ func (h *Handler) Refresh(c *gin.Context) {
}
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
return
}
response.Unauthorized(c, "刷新令牌无效或已过期")
return
}
setAccessCookie(c, tokens.AccessToken)
response.OK(c, tokens)
}
@@ -151,16 +153,6 @@ func (h *Handler) Refresh(c *gin.Context) {
// @Security BearerAuth
// @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
writeAuthError(c, err)
return
}
clearAccessCookie(c)
response.OK(c, gin.H{"logged_out": true})
}
@@ -175,7 +167,6 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -190,7 +181,6 @@ func (h *Handler) Register(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -267,17 +257,3 @@ func currentUserID(c *gin.Context) (uint64, bool) {
userID, ok := val.(uint64)
return userID, ok
}
func setAccessCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
}
func clearAccessCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
}
func requestUsesHTTPS(c *gin.Context) bool {
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
+10 -16
View File
@@ -32,14 +32,19 @@ func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, toke
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
user, err := r.FindByID(ctx, id)
if err != nil {
var user model.User
if err := r.db.WithContext(ctx).
Select("id, phone, status, token_version").
First(&user, id).Error; err != nil {
return nil, err
}
if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion {
if user.Status != "active" {
return nil, ErrUserDisabled
}
return user, nil
if user.TokenVersion != tokenVersion {
return nil, ErrTokenVersionMismatch
}
return &user, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
@@ -61,7 +66,6 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
TokenVersion: 1,
LastLoginAt: &now,
}
@@ -89,16 +93,7 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.
}
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"password_hash": hash,
"token_version": gorm.Expr("token_version + 1"),
}).Error
}
// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。
func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
}
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
@@ -111,7 +106,6 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
TokenVersion: 1,
LastLoginAt: &now,
}
+10 -13
View File
@@ -33,6 +33,7 @@ var (
ErrPasswordTooWeak = errors.New("password too weak")
ErrLoginLocked = errors.New("login locked")
ErrUserAlreadyExists = errors.New("user already exists")
ErrTokenVersionMismatch = errors.New("user token version mismatch")
)
const (
@@ -180,7 +181,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.issueTokenPair(user)
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
@@ -190,7 +191,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
}
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
if s.users == nil {
if s.jwt == nil || s.users == nil {
return TokenPair{}, ErrDependencyUnavailable
}
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
@@ -201,18 +202,14 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenP
if err != nil {
return TokenPair{}, err
}
return s.issueTokenPair(user)
return s.generateUserTokenPair(user)
}
func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) {
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
}
func (s *Service) Logout(ctx context.Context, userID uint64) error {
if s.users == nil {
return ErrDependencyUnavailable
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) {
if s.jwt == nil {
return TokenPair{}, ErrDependencyUnavailable
}
return s.users.RevokeTokens(ctx, userID)
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
}
func codeKey(phone string) string {
@@ -282,7 +279,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
tokens, err := s.issueTokenPair(user)
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
@@ -387,7 +384,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.issueTokenPair(user)
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
@@ -0,0 +1,71 @@
package auth
import (
"errors"
"testing"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestUserTokenVersionRevokesAccessAndRefreshTokens(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("数据库迁移失败:%v", err)
}
user := model.User{Phone: "13900000001", Status: "active", TokenVersion: 0}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewUserRepository(db)
manager := NewJWTManager("test-secret")
pair, err := manager.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
if err != nil {
t.Fatalf("生成令牌失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
if err != nil {
t.Fatalf("有效令牌校验失败:%v", err)
}
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"status": "frozen",
"token_version": 1,
}).Error; err != nil {
t.Fatalf("冻结用户失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("冻结用户校验错误 = %v, want %v", err, ErrUserDisabled)
}
service := NewService(repo, nil, manager, nil, nil)
_, err = service.RefreshToken(t.Context(), pair.RefreshToken)
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("冻结用户刷新错误 = %v, want %v", err, ErrUserDisabled)
}
// 解冻后版本仍然不同,冻结前签发的 token 不能恢复使用。
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"status": "active",
"token_version": 2,
}).Error; err != nil {
t.Fatalf("解冻用户失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
if !errors.Is(err, ErrTokenVersionMismatch) {
t.Fatalf("旧令牌版本校验错误 = %v, want %v", err, ErrTokenVersionMismatch)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 2)
if err != nil {
t.Fatalf("新令牌版本校验失败:%v", err)
}
}
@@ -0,0 +1,59 @@
package backupmonitor
import (
"encoding/json"
"errors"
"net/http"
"os"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Status struct {
GeneratedAt string `json:"generated_at"`
Health string `json:"health"`
Schedule struct {
Enabled string `json:"enabled"`
FullHour string `json:"full_hour"`
FullMinute string `json:"full_minute"`
BinlogIntervalMinutes string `json:"binlog_interval_minutes"`
} `json:"schedule"`
LastFull struct {
LocalComplete string `json:"local_complete"`
RemoteComplete string `json:"remote_complete"`
} `json:"last_full"`
LastBinlog struct {
File string `json:"file"`
RemoteDir string `json:"remote_dir"`
} `json:"last_binlog"`
LastJob struct {
Type string `json:"type"`
Status string `json:"status"`
At string `json:"at"`
Message string `json:"message"`
} `json:"last_job"`
}
type Handler struct{ path string }
func NewHandler(path string) *Handler { return &Handler{path: path} }
func (h *Handler) Status(c *gin.Context) {
content, err := os.ReadFile(h.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
response.Error(c, http.StatusServiceUnavailable, "backup_status_unavailable", "备份状态尚未生成")
return
}
response.Error(c, http.StatusInternalServerError, "backup_status_unavailable", "无法读取备份状态")
return
}
var status Status
if err := json.Unmarshal(content, &status); err != nil {
response.Error(c, http.StatusServiceUnavailable, "backup_status_invalid", "备份状态文件格式无效")
return
}
response.OK(c, status)
}
@@ -0,0 +1,43 @@
package chat
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
const adminChatCountsCacheTTL = 5 * time.Second
func (r *Repository) adminChatCountsCacheKey(principal Principal, filter, stage, keyword string) string {
sum := sha256.Sum256([]byte(keyword))
return fmt.Sprintf("admin-chat:counts:v2:%s:%d:%s:%s:%s", principal.Type, principal.ID, filter, stage, hex.EncodeToString(sum[:8]))
}
func (r *Repository) loadAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string) *AdminConversationCountsDTO {
if r.redis == nil {
return nil
}
raw, err := r.redis.Get(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword)).Bytes()
if err != nil {
return nil
}
var value AdminConversationCountsDTO
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
return &value
}
func (r *Repository) storeAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string, value *AdminConversationCountsDTO) {
if r.redis == nil || value == nil {
return
}
raw, err := json.Marshal(value)
if err != nil {
return
}
_ = r.redis.Set(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword), raw, adminChatCountsCacheTTL).Err()
}
@@ -0,0 +1,199 @@
package chat
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupAdminListTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(
&model.User{},
&model.AdminUser{},
&model.RentalListing{},
&model.RentalOrder{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
); err != nil {
t.Fatalf("迁移测试数据库失败: %v", err)
}
return db
}
func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
db := setupAdminListTestDB(t)
repo := NewRepository(db, nil, nil)
admin := model.AdminUser{Username: "cs-1", Nickname: "客服一", Status: "active"}
user := model.User{Phone: "13800000001", Nickname: "租客"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
listing := model.RentalListing{ListingNo: "L202608250001", OwnerID: user.ID, AccountID: 1, Status: "active"}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
order := model.RentalOrder{OrderNo: "RO-CHAT-1", ListingID: listing.ID, AccountID: 1, OwnerID: user.ID, RenterID: user.ID, Status: "renting"}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
conversation := model.ChatConversation{OrderID: &order.ID, Type: ConversationTypeOrderGroup, Title: "订单群", Status: "active"}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建会话失败: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.ChatParticipant{
{ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: user.ID, Role: "renter", JoinedAt: now},
{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: now},
}).Error; err != nil {
t.Fatalf("创建参与人失败: %v", err)
}
system := model.ChatMessage{ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "欢迎", CreatedAt: now}
userMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "user", SenderID: user.ID, SenderRole: "renter", ContentType: "text", Content: "请问?", CreatedAt: now.Add(time.Second)}
if err := db.Create(&[]model.ChatMessage{system, userMessage}).Error; err != nil {
t.Fatalf("创建消息失败: %v", err)
}
if err := db.Model(&conversation).Updates(map[string]interface{}{
"last_message_id": userMessage.ID,
"last_message_preview": userMessage.Content,
"last_message_at": userMessage.CreatedAt,
}).Error; err != nil {
t.Fatalf("更新会话摘要失败: %v", err)
}
if err := db.Create(&model.ChatAdminConversationState{
ConversationID: conversation.ID,
AdminUserID: admin.ID,
Remark: "客户一",
LastReadMessageID: system.ID,
LastReadAt: &system.CreatedAt,
}).Error; err != nil {
t.Fatalf("创建管理员状态失败: %v", err)
}
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, "")
if err != nil {
t.Fatalf("查询后台会话失败: %v", err)
}
if result.Total != 1 || len(result.Items.([]ConversationDTO)) != 1 {
t.Fatalf("会话数量 = total %d/items %d, want 1/1", result.Total, len(result.Items.([]ConversationDTO)))
}
item := result.Items.([]ConversationDTO)[0]
if item.AdminRemark != "客户一" {
t.Fatalf("管理员备注 = %q, want 客户一", item.AdminRemark)
}
if item.UnreadCount != 1 {
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
}
if item.NeedsReply {
t.Fatal("订单群用户消息不应因未回复停留在待处理")
}
counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "")
if err != nil {
t.Fatalf("查询客服统计失败: %v", err)
}
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
}
if err := db.Model(&conversation).Update("type", ConversationTypeGeneralSupport).Error; err != nil {
t.Fatalf("切换咨询会话类型失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
if err != nil {
t.Fatalf("查询咨询待处理会话失败: %v", err)
}
if result.Total != 1 {
t.Fatalf("咨询未回复待处理数量 = %d, want 1", result.Total)
}
adminMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "admin", SenderID: admin.ID, SenderRole: "support", ContentType: "text", Content: "客服回复", CreatedAt: now.Add(2 * time.Second)}
if err := db.Create(&adminMessage).Error; err != nil {
t.Fatalf("创建客服回复失败: %v", err)
}
if err := db.Model(&conversation).Updates(map[string]interface{}{
"last_message_id": adminMessage.ID,
"last_message_preview": adminMessage.Content,
"last_message_at": adminMessage.CreatedAt,
}).Error; err != nil {
t.Fatalf("更新客服回复摘要失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
if err != nil {
t.Fatalf("查询已回复待处理会话失败: %v", err)
}
if result.Total != 0 {
t.Fatalf("客服回复后待处理数量 = %d, want 0", result.Total)
}
}
func TestListAdminConversationsUsesListingOrderSnapshot(t *testing.T) {
db := setupAdminListTestDB(t)
repo := NewRepository(db, nil, nil)
admin := model.AdminUser{Username: "cs-snapshot", Nickname: "客服快照", Status: "active"}
owner := model.User{Phone: "13800000011", Nickname: "号主"}
renter := model.User{Phone: "13800000012", Nickname: "租客"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建客服失败: %v", err)
}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("创建租客失败: %v", err)
}
listing := model.RentalListing{ListingNo: "L202608250002", OwnerID: owner.ID, AccountID: 2, Status: "active"}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
conversation := model.ChatConversation{ListingID: &listing.ID, Type: ConversationTypeListingGroup, Title: "发布群", Status: "active"}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建发布群失败: %v", err)
}
if err := db.Create(&model.ChatParticipant{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: time.Now()}).Error; err != nil {
t.Fatalf("创建客服成员失败: %v", err)
}
order := model.RentalOrder{
OrderNo: "RO-SNAPSHOT-1", ListingID: listing.ID, AccountID: listing.AccountID,
OwnerID: owner.ID, RenterID: renter.ID, Status: "pending_handoff", HandoffStatus: "pending_owner", RefundStatus: "none",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageHandoff, "")
if err != nil {
t.Fatalf("按交接阶段查询发布群失败: %v", err)
}
items := result.Items.([]ConversationDTO)
if result.Total != 1 || len(items) != 1 {
t.Fatalf("发布群数量 = total %d/items %d, want 1/1", result.Total, len(items))
}
if items[0].LatestOrderID == nil || *items[0].LatestOrderID != order.ID || items[0].LatestOrderStatus != "pending_handoff" {
t.Fatalf("最新订单快照未生效: %+v", items[0])
}
order.Status = "renting"
if err := db.Save(&order).Error; err != nil {
t.Fatalf("更新订单状态失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageRenting, "")
if err != nil || result.Total != 1 {
t.Fatalf("更新后使用中筛选失败: total=%d err=%v", result.Total, err)
}
}
+14 -1
View File
@@ -53,6 +53,18 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
if err != nil {
return nil, err
}
var state model.ChatAdminConversationState
if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var unreadCount int64
if err := db.Table("chat_messages AS cm").
Where("cm.conversation_id = ?", conversation.ID).
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
Where("cm.id > ?", state.LastReadMessageID).
Count(&unreadCount).Error; err != nil {
return nil, err
}
dto := ConversationDTO{
ID: conversation.ID,
OrderID: conversation.OrderID,
@@ -62,11 +74,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
Title: conversation.Title,
Status: conversation.Status,
Role: "admin", // 管理员角色
AdminRemark: state.Remark,
Participants: participants,
LastMessageID: conversation.LastMessageID,
LastMessagePreview: conversation.LastMessagePreview,
LastMessageAt: conversation.LastMessageAt,
UnreadCount: 0, // 管理员不计未读
UnreadCount: unreadCount,
CreatedAt: conversation.CreatedAt,
UpdatedAt: conversation.UpdatedAt,
}
@@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
&model.RentalOrder{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
+16 -13
View File
@@ -21,6 +21,7 @@ type ConversationDTO struct {
Title string `json:"title"`
Status string `json:"status"`
Role string `json:"role"`
AdminRemark string `json:"admin_remark,omitempty"`
Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"`
@@ -29,6 +30,7 @@ type ConversationDTO struct {
LastSenderID uint64 `json:"last_sender_id,omitempty"`
LastSenderRole string `json:"last_sender_role,omitempty"`
UnreadCount int64 `json:"unread_count"`
NeedsReply bool `json:"needs_reply"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -53,19 +55,20 @@ type ParticipantDTO struct {
}
type MessageDTO struct {
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
AdminAttentionType string `json:"admin_attention_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type UnreadCountDTO struct {
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) AdminCounts(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
principal := Principal{Type: "admin", ID: adminID}
result, err := h.service.AdminConversationCounts(
c.Request.Context(),
principal,
c.DefaultQuery("filter", "all"),
c.DefaultQuery("stage", "all"),
c.Query("keyword"),
)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) AdminDetail(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -61,6 +82,19 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
h.markRead(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) AdminMarkAllRead(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
if err := h.service.MarkAllAdminConversationsRead(c.Request.Context(), adminID); err != nil {
writeChatError(c, err)
return
}
response.OK(c, gin.H{"read": true})
}
func (h *Handler) AdminTransfer(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -128,6 +128,7 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
return
}
if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return
}
@@ -164,6 +165,7 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
return
}
if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return
}
+12 -7
View File
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
if handoffSupportID > 0 {
message += ",卖号组客服已接入"
}
return sendSystemMessage(tx, conv.ID, message)
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
}
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
@@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
}
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
return sendSystemMessageWithAttention(tx, conversationID, content, "")
}
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: content,
AttachmentURLS: emptyJSONList(),
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: content,
AttachmentURLS: emptyJSONList(),
AdminAttentionType: attentionType,
}
if err := tx.Create(&message).Error; err != nil {
+87 -23
View File
@@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
offset := (page - 1) * pageSize
var rows []model.ChatMessage
if err := query.Order("id ASC").
if err := query.Order("id DESC").
Offset(offset).
Limit(pageSize).
Find(&rows).Error; err != nil {
return nil, err
}
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
items, err := r.toMessageDTOs(ctx, principal, rows)
if err != nil {
return nil, err
@@ -101,12 +104,21 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Content: req.Content,
AttachmentURLS: encodeStringList(req.AttachmentURLS),
}
if principal.Type == "user" {
message.AdminAttentionType = "user_inquiry"
}
if err := tx.Create(&message).Error; err != nil {
return err
}
conversation.LastMessageID = &message.ID
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
conversation.LastMessageAt = &message.CreatedAt
if message.AdminAttentionType != "" || message.SenderType == "user" {
conversation.LastAttentionMessageID = message.ID
}
if message.SenderType == "admin" {
conversation.LastAdminMessageID = message.ID
}
if err := tx.Save(&conversation).Error; err != nil {
return err
}
@@ -143,16 +155,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Type: "new_message",
ConversationID: conversationID,
Message: &chathub.MessageData{
ID: msg.ID,
ConversationID: msg.ConversationID,
SenderType: msg.SenderType,
SenderID: msg.SenderID,
SenderRole: msg.SenderRole,
SenderName: msg.SenderName,
ContentType: msg.ContentType,
Content: msg.Content,
AttachmentURLS: msg.AttachmentURLS,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
ID: msg.ID,
ConversationID: msg.ConversationID,
SenderType: msg.SenderType,
SenderID: msg.SenderID,
SenderRole: msg.SenderRole,
SenderName: msg.SenderName,
ContentType: msg.ContentType,
Content: msg.Content,
AttachmentURLS: msg.AttachmentURLS,
AdminAttentionType: msg.AdminAttentionType,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
},
}
r.hub.NotifyConversation(conversationID, event)
@@ -166,21 +179,44 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
now := time.Now()
updated := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 管理员可以不是 participant,直接返回成功
if principal.Type == "admin" {
// 尝试查找 participant 记录,如果有就更新
var participant model.ChatParticipant
err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
conversationID, principal.Type, principal.ID).First(&participant).Error
if err == nil {
// 有 participant 记录,更新已读时间
updated = true
return tx.Model(&participant).Update("last_read_at", now).Error
} else if errors.Is(err, gorm.ErrRecordNotFound) {
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
var conversation model.ChatConversation
if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
lastMessageID := uint64(0)
if conversation.LastMessageID != nil {
lastMessageID = *conversation.LastMessageID
}
var existing model.ChatAdminConversationState
err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID).
First(&existing).Error
if err == nil && existing.LastReadMessageID >= lastMessageID {
return nil
}
return err
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
state := model.ChatAdminConversationState{
ConversationID: conversationID,
AdminUserID: principal.ID,
LastReadMessageID: lastMessageID,
LastReadAt: &now,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"last_read_message_id": lastMessageID,
"last_read_at": now,
}),
}).Create(&state).Error; err != nil {
return err
}
updated = true
return nil
}
// 普通用户必须是 participant
@@ -209,3 +245,31 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
}
return nil
}
// MarkAllAdminConversationsRead advances one admin's read cursor for every conversation in one transaction.
func (r *Repository) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
now := time.Now()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先补齐状态行,再更新游标。拆成两步可兼容本地 MySQL/MariaDB 对
// INSERT ... SELECT 同表读取并 ON DUPLICATE KEY UPDATE 的限制。
if err := tx.Exec(`
INSERT INTO chat_admin_conversation_states
(conversation_id, admin_user_id, remark, last_read_message_id, last_read_at)
SELECT c.id, ?, COALESCE(existing.remark, ''), COALESCE(c.last_message_id, 0), ?
FROM chat_conversations AS c
LEFT JOIN chat_admin_conversation_states AS existing
ON existing.conversation_id = c.id AND existing.admin_user_id = ?
WHERE existing.id IS NULL`, adminID, now, adminID).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE chat_admin_conversation_states AS cas
JOIN chat_conversations AS c ON c.id = cas.conversation_id
SET cas.last_read_message_id = c.last_message_id,
cas.last_read_at = ?,
cas.updated_at = CURRENT_TIMESTAMP
WHERE cas.admin_user_id = ?
AND c.last_message_id IS NOT NULL
AND cas.last_read_message_id < c.last_message_id`, now, adminID).Error
})
}
+33 -11
View File
@@ -25,6 +25,7 @@ type conversationRow struct {
Title string
Status string
Role string
AdminRemark string
LastMessageID *uint64
LastMessagePreview string
LastMessageAt *time.Time
@@ -32,6 +33,7 @@ type conversationRow struct {
LastSenderID uint64
LastSenderRole string
UnreadCount int64
NeedsReply int64
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -44,8 +46,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
SELECT COUNT(1)
FROM chat_messages AS cm
WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
AND cm.sender_type <> 'system'
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
) AS unread_count`, principal.Type, principal.ID).
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
@@ -59,6 +62,7 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
Where("c.status = ?", "active").
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID).
Where("cm.sender_type <> ?", "system").
Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
Count(&total).Error
return total, err
@@ -81,15 +85,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat
return &participant, nil
}
func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) {
grouped, err := r.participantsForConversations(ctx, []uint64{conversationID})
if err != nil {
return nil, err
}
return grouped[conversationID], nil
}
func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) {
result := make(map[uint64][]ParticipantDTO, len(conversationIDs))
if len(conversationIDs) == 0 {
return result, nil
}
var rows []model.ChatParticipant
if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil {
if err := r.db.WithContext(ctx).
Where("conversation_id IN ?", uniqueIDs(conversationIDs)).
Order("conversation_id ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows)
if err != nil {
return nil, err
}
items := make([]ParticipantDTO, 0, len(rows))
for _, row := range rows {
name := "系统"
avatar := ""
@@ -100,19 +118,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([
if row.ParticipantType == "admin" {
name = adminNames[row.ParticipantID]
}
items = append(items, ParticipantDTO{
result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{
ID: row.ID,
ConversationID: row.ConversationID,
ParticipantType: row.ParticipantType,
ParticipantID: row.ParticipantID,
Role: row.Role,
Remark: row.Remark,
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
AvatarURL: avatar,
LastReadAt: row.LastReadAt,
JoinedAt: row.JoinedAt,
})
}
return items, nil
return result, nil
}
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
userIDs := make([]uint64, 0)
@@ -166,11 +185,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
SenderAvatar: avatar,
IsSelf: isSelf,
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType,
Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS),
CreatedAt: row.CreatedAt,
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType,
Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS),
AdminAttentionType: row.AdminAttentionType,
CreatedAt: row.CreatedAt,
})
}
return items, nil
@@ -305,6 +325,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
Title: row.Title,
Status: row.Status,
Role: row.Role,
AdminRemark: row.AdminRemark,
Participants: participants,
LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview,
@@ -313,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
LastSenderID: row.LastSenderID,
LastSenderRole: row.LastSenderRole,
UnreadCount: row.UnreadCount,
NeedsReply: row.NeedsReply > 0,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
+452 -79
View File
@@ -6,21 +6,46 @@ import (
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"math"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"hfb_sys/backend/internal/logging"
"github.com/google/uuid"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024
maxQrCodeOCRPixels = 40_000_000
maxQrCodeOCRSide = 2560
qrCodeOCRJPEGQuality = 92
ocrRequestConcurrency = 2
ocrRequestMinInterval = 250 * time.Millisecond
ocrRequestMaxAttempts = 3
ocrRetryBaseDelay = 500 * time.Millisecond
ocrResponseBodyLimit = 2 * 1024 * 1024
ocrResultBodyLimit = 10 * 1024 * 1024
ocrLogBodyLimit = 2048
ocrJobRedisKeyPrefix = "ocr:job:"
ocrJobRedisTTL = 10 * time.Minute
@@ -38,9 +63,87 @@ var (
ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期")
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 {
GroupName string `json:"group_name"`
Candidates []string `json:"candidates"`
@@ -79,13 +182,46 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile
}
if contentType == "" {
contentType = http.DetectContentType(data)
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if contentType == "" || !strings.HasPrefix(contentType, "image/") {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
}
if !strings.HasPrefix(contentType, "image/") {
return nil, "", ErrQrCodeOCRInvalidFile
}
return data, contentType, nil
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxQrCodeOCRPixels {
return nil, "", ErrQrCodeOCRInvalidFile
}
source, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", ErrQrCodeOCRInvalidFile
}
normalized := resizeOCRImage(source, maxQrCodeOCRSide)
var buffer bytes.Buffer
if err := jpeg.Encode(&buffer, normalized, &jpeg.Options{Quality: qrCodeOCRJPEGQuality}); err != nil {
return nil, "", ErrQrCodeOCRInvalidFile
}
if buffer.Len() == 0 || buffer.Len() > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile
}
return buffer.Bytes(), "image/jpeg", nil
}
func resizeOCRImage(source image.Image, maxSide int) image.Image {
bounds := source.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return source
}
scale := math.Min(1, float64(maxSide)/float64(max(width, height)))
targetWidth := max(1, int(math.Round(float64(width)*scale)))
targetHeight := max(1, int(math.Round(float64(height)*scale)))
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func ocrJobKey(jobID string) string {
@@ -132,15 +268,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
return nil, err
}
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return nil, err
}
jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID)
jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID)
if err != nil {
return nil, err
}
rawText, err := fetchPaddleOCRText(ctx, jsonURL)
rawText, err := r.fetchPaddleOCRText(ctx, jsonURL)
if err != nil {
return nil, err
}
@@ -173,7 +309,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str
return "", err
}
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return "", err
}
@@ -206,47 +342,48 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
}
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, 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 {
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
}
var statusPayload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &statusPayload); err != nil {
upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return record, nil
}
switch statusPayload.Data.State {
case "pending", "running":
record.Status = ocrJobStatusRunning
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
case "done":
if statusPayload.Data.ResultURL.JSONURL == "" {
record.Status = ocrJobStatusFailed
record.Error = "未返回识别结果地址"
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址",
zap.String("operation", "status"),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("upstream_state", statusPayload.Data.State),
)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
}
rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
if err != nil {
record.Status = ocrJobStatusFailed
record.Error = err.Error()
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
}
candidates := parseQrCodeGroupNameCandidates(rawText)
@@ -260,7 +397,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
Candidates: candidates,
RawText: rawText,
}
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
case "failed":
@@ -270,14 +407,36 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} else {
record.Error = "PaddleOCR 识别失败"
}
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.ocrLogger(ctx).Error("PaddleOCR 任务执行失败",
zap.String("operation", "status"),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("upstream_state", statusPayload.Data.State),
zap.String("upstream_message", truncateOCRLogValue(record.Error, 512)),
)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
default:
upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state"))
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
}
return record, nil
}
func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) {
if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil {
r.ocrLogger(ctx).Error("OCR 任务状态保存失败",
zap.String("ocr_job_id", jobID),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("ocr_status", record.Status),
zap.Error(err),
)
}
}
func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
filename = normalizeOCRFilename(filename)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("model", config.Model)
@@ -301,51 +460,69 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename,
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body)
requestBody := append([]byte(nil), body.Bytes()...)
formContentType := writer.FormDataContentType()
respBody, err := r.doPaddleRequest(ctx, "submit", func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, bytes.NewReader(requestBody))
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", formContentType)
}
return req, err
})
if err != nil {
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))
return "", err
}
var payload paddleOCRJobResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
if payload.Data.JobID == "" {
return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable)
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId"))
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
r.ocrLogger(ctx).Info("PaddleOCR 任务提交成功",
zap.String("operation", "submit"),
zap.String("paddle_job_id", payload.Data.JobID),
zap.String("model", config.Model),
zap.String("file_content_type", contentType),
zap.Int("file_size", len(data)),
)
return payload.Data.JobID, nil
}
func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
func normalizeOCRFilename(filename string) string {
filename = strings.TrimSpace(filename)
if filename == "" {
return "qrcode.jpg"
}
if dot := strings.LastIndex(filename, "."); dot > 0 {
filename = filename[:dot]
}
return filename + ".jpg"
}
func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
for i := 0; i < 12; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
}
return req, err
})
if err != nil {
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))
return "", err
}
var payload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
switch payload.Data.State {
case "done":
@@ -359,36 +536,232 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string
}
return "", ErrQrCodeOCRUnavailable
}
select {
case <-ctx.Done():
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(2 * time.Second):
if err := r.ocrSleep(ctx, 2*time.Second); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
}
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
}
func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
})
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
return "", err
}
return collectPaddleOCRJSONLText(string(body)), nil
}
type ocrRequestFactory func(context.Context) (*http.Request, error)
func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) {
var lastErr *ocrUpstreamError
for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ {
release, err := r.ocrGate.acquire(ctx, r.ocrSleep)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := factory(ctx)
if err != nil {
release()
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
startedAt := time.Now()
resp, requestErr := r.ocrHTTPClient.Do(req)
duration := time.Since(startedAt)
if requestErr != nil {
release()
lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr)
lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr)
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
} else {
bodyLimit := int64(ocrResponseBodyLimit)
if operation == "result" {
bodyLimit = ocrResultBodyLimit
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
_ = resp.Body.Close()
release()
if int64(len(body)) > bodyLimit {
body = body[:bodyLimit]
readErr = errors.New("upstream response exceeded limit")
}
if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return body, nil
}
lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr)
lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
}
if !lastErr.Retriable || attempt == ocrRequestMaxAttempts {
return nil, lastErr
}
delay := ocrRetryDelay(attempt, lastErr.RetryAfter)
if err := r.ocrSleep(ctx, delay); err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
}
return nil, lastErr
}
func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError {
upstreamErr := &ocrUpstreamError{
Operation: operation,
StatusCode: statusCode,
BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit),
Cause: cause,
}
if headers != nil {
upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now())
upstreamErr.TraceID = firstNonEmpty(
headers.Get("X-Request-ID"),
headers.Get("X-Trace-ID"),
headers.Get("Trace-ID"),
)
}
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code")
upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message")
if upstreamErr.TraceID == "" {
upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id")
}
}
return upstreamErr
}
func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) {
if upstreamErr == nil {
return
}
fields := []zap.Field{
zap.String("operation", upstreamErr.Operation),
zap.Int("attempt", attempt),
zap.Bool("final", final),
zap.Bool("retriable", upstreamErr.Retriable),
zap.Int("upstream_status", upstreamErr.StatusCode),
zap.String("upstream_code", upstreamErr.Code),
zap.String("upstream_message", upstreamErr.Message),
zap.String("upstream_trace_id", upstreamErr.TraceID),
zap.String("upstream_body", upstreamErr.BodySnippet),
zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
}
if upstreamErr.Cause != nil {
fields = append(fields, zap.Error(upstreamErr.Cause))
}
if final {
r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...)
} else {
r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...)
}
}
func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger {
logger := r.logger
if logger == nil {
logger = zap.NewNop()
}
fields := []zap.Field{zap.String("module", "chat_ocr")}
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := logging.AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
return logger.With(fields...)
}
func isRetriableOCRStatus(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func isRetriableOCRNetworkError(ctx context.Context, err error) bool {
return err != nil && ctx.Err() == nil
}
func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return min(retryAfter, 10*time.Second)
}
delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1))
// Deterministic jitter avoids synchronized retries without relying on global randomness.
jitter := time.Duration((attempt*137)%250) * time.Millisecond
return delay + jitter
}
func parseOCRRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
return max(0, time.Duration(seconds)*time.Second)
}
if when, err := http.ParseTime(value); err == nil {
return max(0, when.Sub(now))
}
return 0
}
func sleepWithContext(ctx context.Context, duration time.Duration) error {
if duration <= 0 {
return nil
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func firstJSONText(payload map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := payload[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return truncateOCRLogValue(text, 512)
}
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return truncateOCRLogValue(value, 256)
}
}
return ""
}
func truncateOCRLogValue(value string, limit int) string {
value = strings.Join(strings.Fields(value), " ")
if len(value) <= limit {
return value
}
return value[:limit] + "..."
}
func collectPaddleOCRJSONLText(raw string) string {
var texts []string
for _, line := range strings.Split(raw, "\n") {
@@ -0,0 +1,224 @@
package chat
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func newOCRTestRepository(client *http.Client, logger *zap.Logger, sleep func(context.Context, time.Duration) error) *Repository {
return NewRepository(nil, nil, nil,
withOCRHTTPClient(client),
withOCRGate(newOCRRequestGate(ocrRequestConcurrency, 0)),
WithLogger(logger),
withOCRSleep(sleep),
)
}
func TestDoPaddleRequestRetries429AndHonorsRetryAfter(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) == 1 {
w.Header().Set("Retry-After", "2")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"rate_limit","message":"busy"}`))
return
}
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
var delays []time.Duration
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(_ context.Context, delay time.Duration) error {
delays = append(delays, delay)
return nil
})
body, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
})
if err != nil {
t.Fatalf("Paddle 请求失败: %v", err)
}
if string(body) != "ok" || requests.Load() != 2 {
t.Fatalf("body = %q, requests = %d", body, requests.Load())
}
if len(delays) != 1 || delays[0] != 2*time.Second {
t.Fatalf("retry delays = %v, want [2s]", delays)
}
}
func TestDoPaddleRequestDoesNotRetryDeterministic4xx(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`{"code":"invalid_format","message":"unsupported image"}`))
}))
t.Cleanup(server.Close)
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(context.Context, time.Duration) error { return nil })
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
})
var upstreamErr *ocrUpstreamError
if !errors.As(err, &upstreamErr) {
t.Fatalf("error = %v, want ocrUpstreamError", err)
}
if requests.Load() != 1 || upstreamErr.Retriable {
t.Fatalf("requests = %d, retriable = %v", requests.Load(), upstreamErr.Retriable)
}
if upstreamErr.StatusCode != http.StatusUnsupportedMediaType || upstreamErr.Code != "invalid_format" {
t.Fatalf("upstream error = %+v", upstreamErr)
}
}
func TestDoPaddleRequestLogsStructured502Failure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Trace-ID", "trace-502")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"code":"capacity","message":"upstream busy"}`))
}))
t.Cleanup(server.Close)
core, observed := observer.New(zap.DebugLevel)
repo := newOCRTestRepository(server.Client(), zap.New(core), func(context.Context, time.Duration) error { return nil })
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
req, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
if requestErr == nil {
req.Header.Set("Authorization", "bearer secret-token")
}
return req, requestErr
})
var upstreamErr *ocrUpstreamError
if !errors.As(err, &upstreamErr) {
t.Fatalf("error = %v, want ocrUpstreamError", err)
}
if upstreamErr.StatusCode != http.StatusBadGateway || upstreamErr.TraceID != "trace-502" || upstreamErr.Code != "capacity" {
t.Fatalf("upstream error = %+v", upstreamErr)
}
entries := observed.AllUntimed()
if len(entries) != ocrRequestMaxAttempts {
t.Fatalf("log entries = %d, want %d", len(entries), ocrRequestMaxAttempts)
}
fields := entries[len(entries)-1].ContextMap()
if fields["final"] != true || fields["upstream_status"] != int64(http.StatusBadGateway) || fields["upstream_trace_id"] != "trace-502" {
t.Fatalf("final log fields = %#v", fields)
}
for _, entry := range entries {
if strings.Contains(entry.Message, "secret-token") || strings.Contains(fmt.Sprint(entry.Context), "secret-token") {
t.Fatal("OCR 日志泄露了 Authorization Token")
}
}
}
func TestReadAndValidateOCRFileNormalizesJPEGAndSize(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 3000, 1200))
draw.Draw(source, source.Bounds(), &image.Uniform{C: color.NRGBA{R: 25, G: 80, B: 160, A: 255}}, image.Point{}, draw.Src)
var input bytes.Buffer
if err := png.Encode(&input, source); err != nil {
t.Fatalf("编码测试图片失败: %v", err)
}
data, contentType, err := readAndValidateOCRFile(&input, "image/png")
if err != nil {
t.Fatalf("归一化图片失败: %v", err)
}
if contentType != "image/jpeg" || len(data) < 2 || data[0] != 0xff || data[1] != 0xd8 {
t.Fatalf("content_type = %q, signature = %x", contentType, data[:min(2, len(data))])
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
t.Fatalf("读取归一化图片失败: %v", err)
}
if format != "jpeg" || config.Width != 2560 || config.Height != 1024 {
t.Fatalf("format = %q, size = %dx%d", format, config.Width, config.Height)
}
}
func TestReadAndValidateOCRFileRejectsExcessiveDecodedPixels(t *testing.T) {
data := pngHeader(8001, 5000)
config, _, decodeErr := image.DecodeConfig(bytes.NewReader(data))
if decodeErr != nil || config.Width != 8001 || config.Height != 5000 {
t.Fatalf("测试 PNG 头无效: config=%+v, error=%v", config, decodeErr)
}
_, _, err := readAndValidateOCRFile(bytes.NewReader(data), "image/png")
if !errors.Is(err, ErrQrCodeOCRInvalidFile) {
t.Fatalf("error = %v, want ErrQrCodeOCRInvalidFile", err)
}
}
func TestOCRRequestGateLimitsConcurrency(t *testing.T) {
gate := newOCRRequestGate(2, 0)
sleep := func(context.Context, time.Duration) error { return nil }
releaseFirst, err := gate.acquire(t.Context(), sleep)
if err != nil {
t.Fatal(err)
}
releaseSecond, err := gate.acquire(t.Context(), sleep)
if err != nil {
t.Fatal(err)
}
third := make(chan func(), 1)
go func() {
release, acquireErr := gate.acquire(t.Context(), sleep)
if acquireErr == nil {
third <- release
}
}()
select {
case release := <-third:
release()
t.Fatal("第三个请求在并发槽释放前进入")
case <-time.After(30 * time.Millisecond):
}
releaseFirst()
select {
case release := <-third:
release()
case <-time.After(time.Second):
t.Fatal("并发槽释放后第三个请求仍未进入")
}
releaseSecond()
}
func TestNormalizeOCRFilenameUsesJPEGExtension(t *testing.T) {
if got := normalizeOCRFilename("group.qrcode.webp"); got != "group.qrcode.jpg" {
t.Fatalf("filename = %q", got)
}
if got := normalizeOCRFilename(""); got != "qrcode.jpg" {
t.Fatalf("empty filename = %q", got)
}
}
func pngHeader(width, height uint32) []byte {
var result bytes.Buffer
result.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
data := make([]byte, 13)
binary.BigEndian.PutUint32(data[0:4], width)
binary.BigEndian.PutUint32(data[4:8], height)
data[8] = 8
data[9] = 2
binary.Write(&result, binary.BigEndian, uint32(len(data)))
result.WriteString("IHDR")
result.Write(data)
checksum := crc32.ChecksumIEEE(append([]byte("IHDR"), data...))
binary.Write(&result, binary.BigEndian, checksum)
return result.Bytes()
}
+25 -3
View File
@@ -2,7 +2,12 @@ package chat
import (
"context"
"errors"
"hfb_sys/backend/internal/model"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -13,9 +18,26 @@ const (
)
func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error {
return r.db.WithContext(ctx).Model(&model.ChatParticipant{}).
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).
Update("remark", remark).Error
if principal.Type != "admin" {
return ErrPermissionDenied
}
db := r.db.WithContext(ctx)
var conversation model.ChatConversation
if err := db.Select("id").First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
state := model.ChatAdminConversationState{
ConversationID: conversationID,
AdminUserID: principal.ID,
Remark: strings.TrimSpace(remark),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}),
}).Create(&state).Error
}
func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) {
var replies []model.ChatQuickReply
+60 -6
View File
@@ -1,27 +1,81 @@
package chat
import (
"context"
"errors"
"net/http"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"time"
)
type Repository struct {
db *gorm.DB
hub *chathub.Hub
redis *redis.Client
db *gorm.DB
hub *chathub.Hub
redis *redis.Client
logger *zap.Logger
ocrHTTPClient *http.Client
ocrGate *ocrRequestGate
ocrSleep func(context.Context, time.Duration) error
}
const (
defaultSupportRoleCode = "cs"
)
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository {
return &Repository{db: db, hub: hub, redis: redis}
type RepositoryOption func(*Repository)
func WithLogger(logger *zap.Logger) RepositoryOption {
return func(repo *Repository) {
if logger != nil {
repo.logger = logger
}
}
}
func withOCRHTTPClient(client *http.Client) RepositoryOption {
return func(repo *Repository) {
if client != nil {
repo.ocrHTTPClient = client
}
}
}
func withOCRGate(gate *ocrRequestGate) RepositoryOption {
return func(repo *Repository) {
if gate != nil {
repo.ocrGate = gate
}
}
}
func withOCRSleep(sleep func(context.Context, time.Duration) error) RepositoryOption {
return func(repo *Repository) {
if sleep != nil {
repo.ocrSleep = sleep
}
}
}
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client, options ...RepositoryOption) *Repository {
repo := &Repository{
db: db,
hub: hub,
redis: redis,
logger: zap.NewNop(),
ocrHTTPClient: ocrHTTPClient,
ocrGate: sharedOCRRequestGate,
ocrSleep: sleepWithContext,
}
for _, option := range options {
option(repo)
}
return repo
}
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation
+21
View File
@@ -131,6 +131,16 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio
return s.repo.MarkRead(ctx, principal, conversationID)
}
func (s *Service) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if adminID == 0 {
return ErrPermissionDenied
}
return s.repo.MarkAllAdminConversationsRead(ctx, adminID)
}
func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
@@ -155,10 +165,21 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
}
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword)
}
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
req.Remark = strings.TrimSpace(req.Remark)
if len([]rune(req.Remark)) > 128 {
return ErrInvalidMessage
}
return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark)
}
+179 -104
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"gorm.io/gorm"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"strings"
"time"
)
@@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct {
}
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 验证当前操作者是会话参与者
current, err := r.findParticipant(tx, principal, conversationID, false)
if err != nil {
return err
}
if current.Role != "support" || current.ParticipantType != "admin" {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if principal.Type != "admin" {
return ErrPermissionDenied
}
var conversation model.ChatConversation
if err := tx.First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
if !adminIsSupport(tx, toAdminID) {
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
@@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
if count > 0 {
return fmt.Errorf("该客服已在会话中")
}
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
if err := tx.Model(&model.ChatParticipant{}).
Where("id = ?", current.ID).
Updates(map[string]interface{}{
var current model.ChatParticipant
currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID).
First(&current).Error
if currentErr == nil {
if current.Role != "support" {
return ErrPermissionDenied
}
// 只转接当前客服本人,避免误删同群里的其他客服。
if err := tx.Model(&current).Updates(map[string]interface{}{
"participant_id": toAdminID,
"joined_at": time.Now(),
"last_read_at": 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
}
// 添加系统消息记录转接
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "会话已转接给其他客服",
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil {
return err
}
return nil
})
if err == nil && r.hub != nil {
r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
}
return err
}
func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
db := r.db.WithContext(ctx)
@@ -202,11 +221,8 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
}
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
var total int64
countDB := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(countDB, filter, principal)
applyAdminChatStageFilter(countDB, stage, principal)
if err := countDB.Count(&total).Error; err != nil {
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
if err != nil {
return nil, err
}
@@ -216,16 +232,21 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
COALESCE(cp_me.role, 'admin') AS role,
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
COALESCE(cas.remark, '') AS admin_remark,
COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id,
COALESCE(explicit_lo.order_no, c.latest_order_no) AS latest_order_no,
COALESCE(explicit_lo.status, c.latest_order_status) AS latest_order_status,
COALESCE(explicit_lo.handoff_status, c.latest_order_handoff_status) AS latest_handoff_status,
COALESCE(explicit_lo.refund_status, c.latest_order_refund_status) AS latest_refund_status,
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply,
(
SELECT COUNT(1)
FROM chat_messages AS cm
WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
) END AS unread_count`, principal.Type, principal.ID)
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
AND cm.id > COALESCE(cas.last_read_message_id, 0)
) AS unread_count`)
applyAdminChatOwnershipFilter(queryDB, filter, principal)
applyAdminChatStageFilter(queryDB, stage, principal)
if err := queryDB.
@@ -236,33 +257,59 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
return nil, err
}
items := make([]ConversationDTO, 0, len(rows))
ids := make([]uint64, 0, len(rows))
for _, row := range rows {
participants, err := r.participants(ctx, row.ID)
if err != nil {
return nil, err
}
items = append(items, row.toDTO(participants))
ids = append(ids, row.ID)
}
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
participantsByConversation, err := r.participantsForConversations(ctx, ids)
if err != nil {
return nil, err
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
items := make([]ConversationDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO(participantsByConversation[row.ID]))
}
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) {
filter = normalizeAdminChatFilter(filter)
stage = normalizeAdminChatStage(stage)
keyword = strings.TrimSpace(keyword)
if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil {
return cached, nil
}
result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
if err != nil {
return nil, err
}
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO)
return result.DTO, nil
}
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
var total int64
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
if keyword != "" {
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
like := "%" + keyword + "%"
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ?
OR renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw
@@ -272,32 +319,54 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
return db
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
counts := &AdminConversationCountsDTO{
Ownership: make(map[string]int64, len(adminChatFilters)),
Stages: make(map[string]int64, len(adminChatStages)),
}
type adminConversationCountsResult struct {
Total int64
DTO *AdminConversationCountsDTO
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) {
ownership := make(map[string]string, len(adminChatFilters))
ownershipArgs := make(map[string][]interface{}, len(adminChatFilters))
for _, item := range adminChatFilters {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, item, principal)
applyAdminChatStageFilter(db, stage, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Ownership[item] = count
ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal)
}
stages := make(map[string]string, len(adminChatStages))
stageArgs := make(map[string][]interface{}, len(adminChatStages))
for _, item := range adminChatStages {
stages[item], stageArgs[item] = adminChatStageCondition(item, principal)
}
parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages))
args := make([]interface{}, 0)
addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) {
parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias)
args = append(args, leftArgs...)
args = append(args, rightArgs...)
}
addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage])
for _, item := range adminChatFilters {
addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage])
}
for _, item := range adminChatStages {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, filter, principal)
applyAdminChatStageFilter(db, item, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Stages[item] = count
addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item])
}
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 {
@@ -319,55 +388,61 @@ func normalizeAdminChatStage(stage string) string {
}
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
switch filter {
case adminChatFilterMine:
db.Where("cp_me.id IS NOT NULL")
case adminChatFilterUnassigned:
db.Where(`NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = ?
AND cp_support.role = ?
)`, "admin", "support")
case adminChatFilterAll:
return
default:
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
}
condition, args := adminChatOwnershipCondition(filter, principal)
db.Where(condition, args...)
}
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
condition, args := adminChatStageCondition(stage, principal)
db.Where(condition, args...)
}
func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) {
switch filter {
case adminChatFilterAll:
return "1 = 1", nil
case adminChatFilterUnassigned:
return `NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = 'admin'
AND cp_support.role = 'support'
)`, nil
case adminChatFilterMine:
fallthrough
default:
return "cp_me.id IS NOT NULL", nil
}
}
func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) {
latestID := "COALESCE(explicit_lo.id, c.latest_order_id)"
latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)"
latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)"
switch stage {
case adminChatStagePending:
db.Where(`(
lm.sender_type = ?
OR (
cp_me.id IS NOT NULL
AND EXISTS (
SELECT 1
FROM chat_messages AS cm_pending
WHERE cm_pending.conversation_id = c.id
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
)
)
)`, "user", principal.Type, principal.ID)
return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil
case adminChatStageUnjoined:
db.Where("lo.id IS NULL")
return latestID + " IS NULL", nil
case adminChatStageHandoff:
db.Where("lo.status = ?", "pending_handoff")
return latestStatus + " = 'pending_handoff'", nil
case adminChatStageRenting:
db.Where("lo.status IN ?", []string{"renting", "overdue"})
return latestStatus + " IN ('renting', 'overdue')", nil
case adminChatStageAfterSale:
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil
case adminChatStageEnded:
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil
case adminChatStageAll:
return
fallthrough
default:
return "1 = 1", nil
}
}
func adminNeedsReplyExpression() string {
return "c.last_attention_message_id > c.last_admin_message_id"
}
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
+70 -7
View File
@@ -1,6 +1,7 @@
package chathub
import (
"context"
"fmt"
"time"
@@ -8,16 +9,21 @@ import (
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const heartbeatInterval = 30 * time.Second
type Handler struct {
hub *Hub
hub *Hub
logger *zap.Logger
}
func NewHandler(hub *Hub) *Handler {
return &Handler{hub: hub}
func NewHandler(hub *Hub, logger *zap.Logger) *Handler {
if logger == nil {
logger = zap.NewNop()
}
return &Handler{hub: hub, logger: logger}
}
// UserEvents 处理用户端 SSE 连接: GET /api/chats/events
@@ -51,8 +57,23 @@ func (h *Handler) AdminEvents(c *gin.Context) {
}
func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
startedAt := time.Now()
disconnectReason := "handler_completed"
var disconnectErr error
ch := h.hub.Subscribe(pType, pID)
defer h.hub.Unsubscribe(pType, pID, ch)
h.logger.Info("SSE 连接建立", h.connectionLogFields(c, pType, pID)...)
defer func() {
h.hub.Unsubscribe(pType, pID, ch)
fields := h.connectionLogFields(c, pType, pID)
fields = append(fields,
zap.String("disconnect_reason", disconnectReason),
zap.Float64("lifetime_ms", float64(time.Since(startedAt).Microseconds())/1000),
)
if disconnectErr != nil {
fields = append(fields, zap.Error(disconnectErr))
}
h.logger.Info("SSE 连接断开", fields...)
}()
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
@@ -60,7 +81,11 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
c.Header("X-Accel-Buffering", "no")
// 发送初始连接确认
fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n")
if _, err := fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n"); err != nil {
disconnectReason = "initial_write_error"
disconnectErr = err
return
}
c.Writer.Flush()
heartbeat := time.NewTicker(heartbeatInterval)
@@ -71,17 +96,55 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
for {
select {
case <-clientGone:
disconnectReason = contextDisconnectReason(c.Request.Context().Err())
return
case <-heartbeat.C:
fmt.Fprintf(c.Writer, ":heartbeat\n\n")
if _, err := fmt.Fprintf(c.Writer, ":heartbeat\n\n"); err != nil {
disconnectReason = "heartbeat_write_error"
disconnectErr = err
return
}
c.Writer.Flush()
case event, ok := <-ch:
if !ok {
disconnectReason = "subscription_closed"
return
}
data := MarshalEvent(event)
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data)
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data); err != nil {
disconnectReason = "event_write_error"
disconnectErr = err
return
}
c.Writer.Flush()
}
}
}
func (h *Handler) connectionLogFields(c *gin.Context, pType string, pID uint64) []zap.Field {
fields := []zap.Field{
zap.String("request_id", middleware.GetRequestID(c)),
zap.String("principal_type", pType),
zap.Uint64("principal_id", pID),
zap.String("client_ip", c.ClientIP()),
zap.Int("active_sse_connections", h.hub.OnlineCount()),
zap.Int("active_principal_type_connections", h.hub.OnlineCountByType(pType)),
}
if pType == "admin" {
fields = append(fields, zap.Uint64("admin_id", pID))
} else {
fields = append(fields, zap.Uint64("user_id", pID))
}
return fields
}
func contextDisconnectReason(err error) string {
switch err {
case context.Canceled:
return "context_canceled"
case context.DeadlineExceeded:
return "context_deadline_exceeded"
default:
return "context_closed"
}
}
@@ -0,0 +1,60 @@
package chathub
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/middleware"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestSSELifecycleLogsConnectionCountReasonAndLifetime(t *testing.T) {
gin.SetMode(gin.TestMode)
core, observed := observer.New(zap.InfoLevel)
hub := NewHub(nil)
handler := NewHandler(hub, zap.New(core))
requestContext, cancel := context.WithCancel(context.Background())
cancel()
request := httptest.NewRequest(http.MethodGet, "/api/admin/chats/events", nil).WithContext(requestContext)
request.RemoteAddr = "42.49.131.69:34567"
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = request
ginContext.Set(middleware.ContextAdminID, uint64(88))
ginContext.Set(middleware.ContextRequestID, "sse-test-request")
handler.AdminEvents(ginContext)
connected := observed.FilterMessage("SSE 连接建立").All()
if len(connected) != 1 {
t.Fatalf("connection logs = %d, want 1", len(connected))
}
connectedFields := connected[0].ContextMap()
if connectedFields["admin_id"] != uint64(88) || connectedFields["active_sse_connections"] != int64(1) {
t.Fatalf("unexpected connection fields: %v", connectedFields)
}
disconnected := observed.FilterMessage("SSE 连接断开").All()
if len(disconnected) != 1 {
t.Fatalf("disconnection logs = %d, want 1", len(disconnected))
}
disconnectedFields := disconnected[0].ContextMap()
if disconnectedFields["disconnect_reason"] != "context_canceled" {
t.Fatalf("disconnect reason = %v, want context_canceled", disconnectedFields["disconnect_reason"])
}
if disconnectedFields["active_sse_connections"] != int64(0) {
t.Fatalf("active connections after disconnect = %v, want 0", disconnectedFields["active_sse_connections"])
}
if _, ok := disconnectedFields["lifetime_ms"]; !ok {
t.Fatalf("missing lifetime_ms field: %v", disconnectedFields)
}
if got := hub.OnlineCount(); got != 0 {
t.Fatalf("online connection count = %d, want 0", got)
}
}
+40 -10
View File
@@ -20,16 +20,17 @@ type ChatEvent struct {
// MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
type MessageData struct {
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
CreatedAt string `json:"created_at"`
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
AdminAttentionType string `json:"admin_attention_type,omitempty"`
CreatedAt string `json:"created_at"`
}
// principal 标识一个连接方。
@@ -86,6 +87,22 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) {
h.mu.Unlock()
}
// DisconnectUser 关闭指定用户当前进程内的全部实时连接。
func (h *Hub) DisconnectUser(userID uint64) {
h.disconnect("user", userID)
}
func (h *Hub) disconnect(pType string, pID uint64) {
key := principal{Type: pType, ID: pID}
h.mu.Lock()
clients := h.clients[key]
delete(h.clients, key)
for ch := range clients {
close(ch)
}
h.mu.Unlock()
}
// NotifyConversation 查询会话参与者,向所有在线参与者推送事件。
func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) {
if h.db == nil {
@@ -174,3 +191,16 @@ func (h *Hub) OnlineCount() int {
}
return count
}
// OnlineCountByType 返回当前进程内指定主体类型的 SSE 连接数。
func (h *Hub) OnlineCountByType(pType string) int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for key, clients := range h.clients {
if key.Type == pType {
count += len(clients)
}
}
return count
}
@@ -0,0 +1,32 @@
package chathub
import "testing"
func TestDisconnectUserClosesAllConnections(t *testing.T) {
hub := NewHub(nil)
first := hub.Subscribe("user", 7)
second := hub.Subscribe("user", 7)
hub.Subscribe("user", 8)
hub.Subscribe("admin", 9)
hub.DisconnectUser(7)
for name, ch := range map[string]<-chan *ChatEvent{"first": first, "second": second} {
select {
case _, ok := <-ch:
if ok {
t.Fatalf("%s connection was not closed", name)
}
default:
t.Fatalf("%s connection did not close immediately", name)
}
}
if got := hub.OnlineCount(); got != 2 {
t.Fatalf("online connection count = %d, want 2", got)
}
if got := hub.OnlineCountByType("user"); got != 1 {
t.Fatalf("online user connection count = %d, want 1", got)
}
if got := hub.OnlineCountByType("admin"); got != 1 {
t.Fatalf("online admin connection count = %d, want 1", got)
}
}
+35 -15
View File
@@ -153,7 +153,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
"order_no": order.OrderNo,
"result": req.Result,
"remark": req.Remark,
"input_amount_cent": req.AmountCent,
"input_amount_cent": arbitrationInputAmountCent(req),
"renter_refund_amount_cent": settlement.RenterRefundAmountCent,
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
@@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or
if err != nil {
return settlement, nil, linkedCheckout, 0, err
}
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req)
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req)
if err != nil {
return settlement, nil, linkedCheckout, 0, err
}
@@ -299,21 +299,24 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or
return 0, ErrInvalidDispute
}
func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) {
func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) {
switch req.Result {
case "actual_settlement":
if req.AmountCent <= 0 {
if req.AmountCent == nil {
if checkout != nil {
return arbitrationCheckoutDepositDeductCent(*checkout), nil
}
return 0, nil
}
if req.AmountCent > order.DepositAmountCent {
if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent {
return 0, ErrInvalidDispute
}
return req.AmountCent, nil
return *req.AmountCent, nil
case "release_deposit":
return 0, nil
case "deduct_deposit", "compensate_owner":
deductAmountCent := req.AmountCent
if deductAmountCent <= 0 {
deductAmountCent := arbitrationInputAmountCent(req)
if req.AmountCent == nil || deductAmountCent <= 0 {
deductAmountCent = order.DepositAmountCent
}
if deductAmountCent > order.DepositAmountCent {
@@ -325,6 +328,23 @@ func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRe
}
}
func arbitrationInputAmountCent(req ArbitrateRequest) int64 {
if req.AmountCent == nil {
return 0
}
return *req.AmountCent
}
func arbitrationCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 {
if checkout.DepositDeductAmountCent > 0 {
return checkout.DepositDeductAmountCent
}
if checkout.OtherAmountCent > 0 {
return checkout.OtherAmountCent
}
return 0
}
func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) {
order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
@@ -350,7 +370,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
}
func isPlatformManagedOrder(order model.RentalOrder) bool {
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
return order.SettlementMode == "platform_managed"
}
func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
@@ -459,15 +479,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(totalCent, "仲裁全额退款")
settlement.RenterDepositRefundCent = depositAmountCent
case "partial_refund":
if req.AmountCent <= 0 || req.AmountCent > totalCent {
if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent {
return settlement, ErrInvalidDispute
}
addRenterRefund(req.AmountCent, "仲裁部分退款")
addRenterRefund(*req.AmountCent, "仲裁部分退款")
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent)
settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent)
// 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。
addOwnerIncome(
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent),
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent),
"仲裁剩余金额结算给号主",
)
case "release_deposit":
@@ -475,8 +495,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
settlement.RenterDepositRefundCent = depositAmountCent
case "deduct_deposit", "compensate_owner":
deductAmountCent := req.AmountCent
if deductAmountCent <= 0 {
deductAmountCent := arbitrationInputAmountCent(req)
if req.AmountCent == nil || deductAmountCent <= 0 {
deductAmountCent = depositAmountCent
}
if deductAmountCent > depositAmountCent {
+36 -35
View File
@@ -9,40 +9,41 @@ import (
)
type DisputeDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"`
HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"`
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
ListingNo string `json:"listing_no"`
Title string `json:"title"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone"`
RenterPhone string `json:"renter_phone"`
InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
TargetUserID uint64 `json:"target_user_id"`
Type string `json:"type"`
Status string `json:"status"`
Description string `json:"description"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
PreviousOrderStatus string `json:"previous_order_status"`
PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
PreviousCheckoutStatus string `json:"previous_checkout_status"`
ArbitrationResult string `json:"arbitration_result"`
ArbitrationRemark string `json:"arbitration_remark"`
HandledBy *uint64 `json:"handled_by"`
HandledAt *time.Time `json:"handled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"`
HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"`
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
ListingNo string `json:"listing_no"`
Title string `json:"title"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone"`
RenterPhone string `json:"renter_phone"`
InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
TargetUserID uint64 `json:"target_user_id"`
Type string `json:"type"`
Status string `json:"status"`
Description string `json:"description"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
PreviousOrderStatus string `json:"previous_order_status"`
PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"`
PreviousCheckoutStatus string `json:"previous_checkout_status"`
ArbitrationResult string `json:"arbitration_result"`
ArbitrationRemark string `json:"arbitration_remark"`
HandledBy *uint64 `json:"handled_by"`
HandledAt *time.Time `json:"handled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateRequest struct {
@@ -61,7 +62,7 @@ type AdminCreateRequest struct {
type ArbitrateRequest struct {
Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"`
AmountCent int64 `json:"amount_cent"`
AmountCent *int64 `json:"amount_cent"`
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"`
}
+49 -47
View File
@@ -10,57 +10,59 @@ import (
type disputeRow struct {
model.Dispute
OrderNo string
OrderStatus string
HandoffStatus string
SettlementStatus string
ExtraItemOriginalAmountCent int64
ListingNo string
Title string
OwnerID uint64
RenterID uint64
OwnerPhone string
RenterPhone string
CheckoutCoinConsumedM *float64
CheckoutConsumableAmountCent *int64
OrderNo string
OrderStatus string
HandoffStatus string
SettlementStatus string
ExtraItemOriginalAmountCent int64
ListingNo string
Title string
OwnerID uint64
RenterID uint64
OwnerPhone string
RenterPhone string
CheckoutCoinConsumedM *float64
CheckoutConsumableAmountCent *int64
CheckoutDepositDeductAmountCent *int64
}
func (row disputeRow) toDTO() DisputeDTO {
return DisputeDTO{
ID: row.ID,
OrderID: row.OrderID,
OrderNo: row.OrderNo,
OrderStatus: row.OrderStatus,
HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus,
ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent,
ListingNo: row.ListingNo,
Title: row.Title,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
InitiatorID: row.InitiatorID,
InitiatorType: effectiveInitiatorType(row.Dispute),
InitiatorAdminID: row.InitiatorAdminID,
TargetUserID: row.TargetUserID,
Type: row.Type,
Status: row.Status,
Description: row.Description,
EvidenceURLS: row.EvidenceURLS,
PreviousOrderStatus: row.PreviousOrderStatus,
PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
ArbitrationResult: row.ArbitrationResult,
ArbitrationRemark: row.ArbitrationRemark,
HandledBy: row.HandledBy,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
OrderID: row.OrderID,
OrderNo: row.OrderNo,
OrderStatus: row.OrderStatus,
HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus,
ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent,
ListingNo: row.ListingNo,
Title: row.Title,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
InitiatorID: row.InitiatorID,
InitiatorType: effectiveInitiatorType(row.Dispute),
InitiatorAdminID: row.InitiatorAdminID,
TargetUserID: row.TargetUserID,
Type: row.Type,
Status: row.Status,
Description: row.Description,
EvidenceURLS: row.EvidenceURLS,
PreviousOrderStatus: row.PreviousOrderStatus,
PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent,
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
ArbitrationResult: row.ArbitrationResult,
ArbitrationRemark: row.ArbitrationRemark,
HandledBy: row.HandledBy,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
+2 -1
View File
@@ -94,7 +94,8 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
o.owner_id, o.renter_id, o.extra_item_original_amount_cent,
owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m,
c.consumable_amount_cent AS checkout_consumable_amount_cent`)
c.consumable_amount_cent AS checkout_consumable_amount_cent,
CASE WHEN c.deposit_deduct_amount_cent > 0 THEN c.deposit_deduct_amount_cent ELSE c.other_amount_cent END AS checkout_deposit_deduct_amount_cent`)
}
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -14,6 +14,8 @@ import (
"gorm.io/gorm"
)
func int64Ptr(value int64) *int64 { return &value }
func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{
Status: "renting",
@@ -322,6 +324,64 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
}
}
func TestPlatformManagedCheckoutArbitrationKeepsExistingDepositCompensation(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
adminID := uint64(78)
_, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
Status: "pending_checkout_confirm",
HandoffStatus: "pending_owner_checkout",
SettlementStatus: "pending",
HandoffMode: "platform",
SettlementMode: "platform_managed",
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 5000,
})
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
CoinConsumedM: 0,
DepositDeductAmountCent: 3000,
OtherAmountCent: 3000,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "checkout_amount",
Description: "结账押金损耗争议",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
t.Fatalf("争议未带出押金赔付金额: %v", created.CheckoutDepositDeductAmountCent)
}
actualCoinConsumedM := 0.0
actualConsumableAmountCent := int64(0)
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "沿用原结账押金赔付",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取仲裁订单失败: %v", err)
}
if saved.OfflineSettlementAmountCent != 11000 {
t.Fatalf("仲裁后待打款 = %d, want 11000", saved.OfflineSettlementAmountCent)
}
}
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -394,7 +454,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
Result: "actual_settlement",
Remark: "赔付号主",
AmountCent: compensationCent,
AmountCent: &compensationCent,
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 0)
@@ -406,6 +466,21 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
}
}
func TestActualSettlementArbitrationInheritsCheckoutDepositCompensation(t *testing.T) {
order := disputePureCoinOrder()
checkout := &model.OrderCheckout{DepositDeductAmountCent: 3000}
amount, err := arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement"})
if err != nil || amount != 3000 {
t.Fatalf("omitted arbitration compensation = %d/%v, want 3000/nil", amount, err)
}
zero := int64(0)
amount, err = arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement", AmountCent: &zero})
if err != nil || amount != 0 {
t.Fatalf("explicit zero arbitration compensation = %d/%v, want 0/nil", amount, err)
}
}
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -417,11 +492,12 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
ConsumableAmountCent: 7000,
CoinConsumedM: 25,
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
ConsumableAmountCent: 7000,
CoinConsumedM: 25,
DepositDeductAmountCent: 3000,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
@@ -433,8 +509,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 {
t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID)
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 || created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
t.Fatalf("关联结账/M/额外物品/押金赔付 = %v/%v/%v/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
@@ -566,7 +642,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) {
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund",
AmountCent: 5000,
AmountCent: int64Ptr(5000),
Remark: "没打完 号主登录不上",
}, 0)
if err != nil {
@@ -599,7 +675,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) {
}
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund",
AmountCent: 20000,
AmountCent: int64Ptr(20000),
}, 0)
if err != nil {
t.Fatalf("buildArbitrationSettlement() error = %v", err)
@@ -93,6 +93,9 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute
}
if req.AmountCent != nil && (*req.AmountCent < 0 || *req.AmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute
}
if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute
}
-9
View File
@@ -8,13 +8,4 @@ type UploadDTO struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
objectKeys []string
}
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
func (d *UploadDTO) ObjectKeys() []string {
if d == nil {
return nil
}
return append([]string(nil), d.objectKeys...)
}
+5 -56
View File
@@ -1,38 +1,22 @@
package file
import (
"context"
"errors"
"net/http"
"strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Handler struct {
service *Service
storage *Storage
objectAuthorizer ObjectAuthorizer
uploadOwnerRecorder UploadOwnerRecorder
service *Service
storage *Storage
}
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error)
// UploadOwnerRecorder 保存用户上传私有文件的归属。
type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error
func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler {
return &Handler{
service: service,
storage: storage,
objectAuthorizer: objectAuthorizer,
uploadOwnerRecorder: uploadOwnerRecorder,
}
func NewHandler(service *Service, storage *Storage) *Handler {
return &Handler{service: service, storage: storage}
}
func (h *Handler) Upload(c *gin.Context) {
@@ -60,19 +44,6 @@ func (h *Handler) Upload(c *gin.Context) {
writeFileError(c, err)
return
}
if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil {
id, valid := userID.(uint64)
if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil {
// 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。
if h.storage != nil {
if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil {
logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr))
}
}
response.ServiceUnavailable(c, "文件归属记录失败")
return
}
}
response.Created(c, item)
}
@@ -97,6 +68,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
if publicOnly &&
!strings.HasPrefix(key, "home-banner/") &&
!strings.HasPrefix(key, "avatar/") &&
!strings.HasPrefix(key, "payment-cert/") &&
!strings.HasPrefix(key, "announcement/") &&
!strings.HasPrefix(key, "mohong/") &&
!strings.HasPrefix(key, "crash/") &&
@@ -105,29 +77,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
if !publicOnly {
if _, isAdmin := c.Get("admin_id"); !isAdmin {
userID, ok := c.Get("user_id")
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := userID.(uint64)
if !ok || h.objectAuthorizer == nil {
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
return
}
allowed, err := h.objectAuthorizer(c.Request.Context(), id, key)
if err != nil {
response.ServiceUnavailable(c, "文件权限校验失败")
return
}
if !allowed {
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
return
}
}
}
object, err := h.storage.Get(c.Request.Context(), key)
if err != nil {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
+1 -4
View File
@@ -61,7 +61,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
}
var thumbnailURL string
var mediumURL string
objectKeys := []string{key}
for _, variant := range generateImageVariants(key, data, contentType) {
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
"source-object": key,
@@ -69,7 +68,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
if err != nil {
continue
}
objectKeys = append(objectKeys, variant.Key)
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
thumbnailURL = fileURLForScene(scene, variant.Key)
}
@@ -86,7 +84,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
Filename: req.Header.Filename,
ContentType: contentType,
Size: int64(len(data)),
objectKeys: objectKeys,
}, nil
}
@@ -109,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return fileURL
-30
View File
@@ -157,36 +157,6 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
}, nil
}
// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。
func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error {
var firstErr error
for _, key := range keys {
if key == "" {
continue
}
if err := s.removeObject(ctx, key); err != nil && firstErr == nil {
firstErr = err
}
if s.mirror != nil {
if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func (s *Storage) removeObject(ctx context.Context, key string) error {
if err := s.ensureBucketReady(ctx); err != nil {
return err
}
err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
return nil
}
return err
}
func (s *Storage) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
+5 -32
View File
@@ -43,33 +43,6 @@ type ListingDTO struct {
UpdatedAt time.Time `json:"updated_at"`
}
// PublicListingListItemDTO 是首页商品卡片的最小公开数据。
// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。
type PublicListingListItemDTO struct {
ID uint64 `json:"id"`
ListingNo string `json:"listing_no"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
AssetSummary map[string]any `json:"asset_summary,omitempty"`
CoverURL string `json:"cover_url"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
IsAccelerated bool `json:"is_accelerated_sale"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。
type PublicListingDetailDTO struct {
PublicListingListItemDTO
Description string `json:"description"`
ScreenshotURLS []string `json:"screenshot_urls"`
}
type CreateRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
@@ -186,11 +159,11 @@ type NumberRange struct {
}
type PublicListResult struct {
Items []PublicListingListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
ZoneCounts map[string]int64 `json:"zone_counts"`
Items []ListingDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
ZoneCounts map[string]int64 `json:"zone_counts"`
}
type AdminActionRequest struct {
@@ -275,9 +275,11 @@ func externalAccountToCreateRequest(
"resources": resources,
"skin_groups": externalSkinGroups(skins),
"online_time_text": onlineTimeText,
"ban_record": normalizeBanRecord(item.BanRecord),
"common_regions": commonRegions(item.CommonRegion),
"remark": remark,
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
"early_morning_response": "否",
"ban_record": normalizeBanRecord(item.BanRecord),
"common_regions": commonRegions(item.CommonRegion),
"remark": remark,
"import_meta": map[string]any{
"uploader_name": uploaderName,
"client_upload_time": uploadTime,
@@ -382,10 +384,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64,
func serverRegionFromLoginMethod(value string) string {
value = strings.TrimSpace(value)
normalized := strings.ToLower(value)
switch {
case strings.Contains(value, "微信"):
case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"):
return "微信"
case strings.Contains(strings.ToLower(value), "steam"):
case strings.Contains(normalized, "steam"):
return "Steam"
default:
return "QQ"
@@ -71,100 +71,9 @@ func applyPublicListingURLs(item *ListingDTO) {
if item.AssetSummary != nil {
delete(item.AssetSummary, "import_meta")
delete(item.AssetSummary, "price_breakdown")
// 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。
delete(item.AssetSummary, "screenshot_groups")
}
}
func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO {
result := make([]PublicListingListItemDTO, 0, len(items))
for _, item := range items {
result = append(result, item.toPublicListItem())
}
return result
}
func (item ListingDTO) toPublicListItem() PublicListingListItemDTO {
return PublicListingListItemDTO{
ID: item.ID,
ListingNo: item.ListingNo,
Title: item.Title,
GameName: item.GameName,
ServerRegion: item.ServerRegion,
LoginPlatform: item.LoginPlatform,
RankLevel: item.RankLevel,
HafCoinAmount: item.HafCoinAmount,
AssetSummary: publicListAssetSummary(item.AssetSummary),
CoverURL: item.CoverURL,
PriceCent: item.PriceCent,
DepositAmountCent: item.DepositAmountCent,
IsAccelerated: item.IsAccelerated,
PublishedAt: item.PublishedAt,
CreatedAt: item.CreatedAt,
}
}
func (item ListingDTO) toPublicDetail() PublicListingDetailDTO {
listItem := item.toPublicListItem()
listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary)
return PublicListingDetailDTO{
PublicListingListItemDTO: listItem,
Description: item.Description,
ScreenshotURLS: item.ScreenshotURLS,
}
}
// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。
func publicListAssetSummary(summary map[string]any) map[string]any {
return selectPublicAssetSummary(summary, []string{
"season_insurance",
"stamina_level",
"load_level",
"resources",
"skin_groups",
"total_asset_wan",
"online_time",
"can_change_game_name",
"all_hero",
"daily_loss_m",
"publish_ratio",
})
}
// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。
func publicDetailAssetSummary(summary map[string]any) map[string]any {
return selectPublicAssetSummary(summary, []string{
"season_insurance",
"stamina_level",
"load_level",
"resources",
"skin_groups",
"total_asset_wan",
"online_time",
"can_change_game_name",
"all_hero",
"daily_loss_m",
"publish_ratio",
"secret_kd",
"fire_level",
"common_regions",
"ban_record",
})
}
func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any {
if len(summary) == 0 {
return nil
}
result := make(map[string]any, len(keys))
for _, key := range keys {
if value, ok := summary[key]; ok {
result[key] = value
}
}
return result
}
func applySellerListingPrice(item *ListingDTO) {
if item == nil {
return
@@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool {
if summary == nil {
return false
}
onlineTime, ok := summary["online_time"].(map[string]any)
if !ok {
// 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。
switch value := summary["early_morning_response"].(type) {
case string:
return strings.TrimSpace(value) == "是"
case bool:
return value
default:
return false
}
start, okStart := parseTimeMinuteValue(onlineTime["start"])
end, okEnd := parseTimeMinuteValue(onlineTime["end"])
if !okStart || !okEnd {
return false
}
return timeRangeOverlapsMinutes(start, end, 0, 8*60)
}
func parseTimeMinuteValue(value any) (int, bool) {
text, ok := value.(string)
if !ok {
return 0, false
}
parts := strings.Split(text, ":")
if len(parts) != 2 {
return 0, false
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, false
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, false
}
return hour*60 + minute, true
}
func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool {
if start == end {
return true
}
for _, segment := range splitMinuteRange(start, end) {
if segment[0] <= targetEnd && segment[1] >= targetStart {
return true
}
}
return false
}
func splitMinuteRange(start int, end int) [][2]int {
if start < end {
return [][2]int{{start, end}}
}
return [][2]int{{start, 23*60 + 59}, {0, end}}
}
@@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
}
}
func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) {
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
tests := []struct {
name string
start string
end string
want bool
name string
response any
want bool
}{
{name: "全天可上号", start: "00:00", end: "23:59", want: true},
{name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true},
{name: "刚好八点开始", start: "08:00", end: "12:00", want: true},
{name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false},
{name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true},
{name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false},
{name: "明确响应", response: "", want: true},
{name: "明确不响应", response: "", want: false},
{name: "兼容布尔值", response: true, want: true},
{name: "缺失字段", response: nil, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isNightAvailableSummary(map[string]any{
"online_time": map[string]any{
"start": tt.start,
"end": tt.end,
},
})
summary := map[string]any{}
if tt.response != nil {
summary["early_morning_response"] = tt.response
}
got := isNightAvailableSummary(summary)
if got != tt.want {
t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want)
t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want)
}
})
}
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
items = items[start:end]
}
return &PublicListResult{
Items: publicListingListItems(items),
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
return nil, err
}
return &PublicListResult{
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
Items: publicListings(rowsToDTO(rows)),
Total: total,
Page: page,
PageSize: pageSize,
@@ -219,14 +219,13 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
return copied
}
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
if err != nil {
return nil, err
}
applyPublicListingURLs(dto)
item := dto.toPublicDetail()
return &item, nil
return dto, nil
}
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
+9 -10
View File
@@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
like := "%" + keyword + "%"
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
db = db.Where(
`(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
like,
like,
`(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
keyword,
keyword,
id,
id,
id,
@@ -204,22 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
}
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
Where("listing_id IS NOT NULL").
Group("listing_id")
return r.db.WithContext(ctx).Table("rental_listings AS l").
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
CASE
WHEN lu.upload_id IS NULL THEN ?
WHEN lu.id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
)`)
}
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
return s.repo.ListMine(ctx, ownerID)
}
func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -330,9 +329,8 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
"https://example.com/account.png",
},
AssetSummary: map[string]any{
"import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100},
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
"import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100},
},
}
@@ -347,78 +345,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
if _, ok := item.AssetSummary["price_breakdown"]; ok {
t.Fatal("expected price_breakdown hidden from public listing")
}
if _, ok := item.AssetSummary["screenshot_groups"]; ok {
t.Fatal("expected screenshot_groups hidden from public listing")
}
}
func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) {
item := ListingDTO{
ID: 1,
ListingNo: "SP000001",
AccountID: 2,
OwnerID: 3,
OwnerPhone: "13800001234",
OwnerNickname: "号主",
SourceChannel: "外部渠道",
IsExternalUpload: true,
Title: "测试账号",
Description: "公开描述",
ScreenshotURLS: []string{"/api/listings/1/screenshots/0"},
Status: "published",
ReviewStatus: "approved",
HandoffMode: "platform",
SettlementMode: "platform_managed",
ManagedAdminID: uint64Pointer(4),
ReviewReason: "内部审核备注",
ListingGroupConversationID: 5,
AssetSummary: map[string]any{
"season_insurance": "3*3",
"contact_phone": "13900005678",
"remark": "首页不应携带",
},
}
listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})})
if err != nil {
t.Fatalf("marshal public list error = %v", err)
}
detailRaw, err := json.Marshal(item.toPublicDetail())
if err != nil {
t.Fatalf("marshal public detail error = %v", err)
}
for _, field := range []string{
"account_id",
"owner_id",
"owner_phone",
"owner_nickname",
"source_channel",
"is_external_upload",
"status",
"review_status",
"handoff_mode",
"settlement_mode",
"managed_admin_id",
"review_reason",
"listing_group_conversation_id",
} {
if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) {
t.Fatalf("public response contains sensitive field %q", field)
}
}
if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") {
t.Fatalf("public list contains detail-only fields: %s", listRaw)
}
if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") {
t.Fatalf("public list contains non-display asset fields: %s", listRaw)
}
if strings.Contains(string(detailRaw), "contact_phone") {
t.Fatalf("public detail contains non-public asset field: %s", detailRaw)
}
}
func uint64Pointer(value uint64) *uint64 {
return &value
}
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
@@ -604,6 +530,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
}
if req.AssetSummary["early_morning_response"] != "否" {
t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"])
}
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
if !ok {
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
@@ -640,6 +569,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
}
}
func TestServerRegionFromLoginMethod(t *testing.T) {
tests := []struct {
name string
loginMethod string
want string
}{
{name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"},
{name: "微信扫码", loginMethod: "微信扫码", want: "微信"},
{name: "VX扫码", loginMethod: "VX扫码", want: "微信"},
{name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"},
{name: "Steam", loginMethod: "Steam令牌", want: "Steam"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want {
t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want)
}
})
}
}
func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
LoginMethod: "QQ账号密码",
@@ -391,10 +391,13 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态")
}
@@ -407,14 +410,23 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
order.RefundStatus = "none"
order.RefundAmountCent = 0
return tx.Save(&order).Error
} else {
action, err := r.prepareRefund(order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
if err != nil {
return err
}
refund = action
}
action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
if err != nil {
if err := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil {
return err
}
refund = action
return tx.Save(&order).Error
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
if err != nil {
return err
+25 -2
View File
@@ -6,6 +6,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
if hasOpen {
return ErrCheckoutCannotSubmit
}
before := orderState(order)
checkoutToUserID := order.OwnerID
checkoutTargetType := processlog.ActorUser
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
checkoutToUserID = platformManagedAdminID(order)
checkoutTargetType = processlog.ActorAdmin
}
record := model.HandoffRecord{
OrderID: order.ID,
@@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
order.SettlementStatus = settlementStatusPending
now := time.Now()
order.HandoffStartedAt = &now
if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil {
return err
}
orderID := order.ID
if isPlatformSettlementOrder(order) {
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
@@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
return ErrCheckoutCannotConfirm
}
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
@@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
refund = action
return err
if err != nil {
return err
}
return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
})
if err != nil {
return err
@@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
return ErrCheckoutCannotCounter
}
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
@@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if err := tx.Save(&order).Error; err != nil {
return err
}
targetID := order.OwnerID
if isOwner {
targetID = order.RenterID
}
if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil {
return err
}
@@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
return ErrCheckoutCannotConfirm
}
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
@@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
checkout.RenterConfirmedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
refund = action
return err
if err != nil {
return err
}
return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
})
if err != nil {
return err
+6 -5
View File
@@ -32,11 +32,12 @@ const (
handoffStatusAdminClosed = "admin_closed"
handoffStatusAdminAbnormal = "admin_abnormal"
settlementStatusUnsettled = "unsettled"
settlementStatusPending = "pending"
settlementStatusSettled = "settled"
settlementStatusClosed = "closed"
settlementStatusDisputed = "disputed"
settlementStatusUnsettled = "unsettled"
settlementStatusPending = "pending"
settlementStatusSettled = "settled"
settlementStatusClosed = "closed"
settlementStatusDisputed = "disputed"
settlementStatusArbitrated = "arbitrated"
handoffModeOwner = "owner"
handoffModePlatform = "platform"
@@ -5,6 +5,7 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
if !canHoldDeposit(order) {
return ErrDepositCannotHold
}
before := orderState(order)
beforeStatus := order.DepositHoldStatus
now := time.Now()
order.DepositHoldStatus = depositHoldStatusHeld
@@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
order.DepositHeldBy = &adminID
order.DepositHeldAt = &now
order.DepositHoldReleasedAt = nil
if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
@@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or
if holdAmountCent <= 0 {
return ErrDepositHoldAmountEmpty
}
before := orderState(order)
now := time.Now()
order.DepositHoldStatus = depositHoldStatusReleased
order.DepositHoldReleasedAt = &now
if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil {
return err
}
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
if err != nil {
return err
+27 -81
View File
@@ -51,6 +51,8 @@ type OrderDTO struct {
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel,omitempty"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"`
@@ -77,46 +79,6 @@ type OrderDTO struct {
UpdatedAt time.Time `json:"updated_at"`
}
// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
type UserOrderListItemDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
Role string `json:"role"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
DisplayAmountCent int64 `json:"display_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminOrderListItemDTO 是后台订单表格的最小展示数据,敏感详情仅由详情接口按权限获取。
type AdminOrderListItemDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
OwnerPhone string `json:"owner_phone,omitempty"`
RenterPhone string `json:"renter_phone,omitempty"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RentAmountCent int64 `json:"rent_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
SettlementStatus string `json:"settlement_status"`
CreatedAt time.Time `json:"created_at"`
}
type AdminActionsDTO struct {
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
@@ -171,7 +133,8 @@ type CounterCheckoutRequest 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 {
@@ -179,7 +142,7 @@ type PlatformHandoffRequest struct {
Reason string `json:"reason" binding:"required"`
}
// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。
// ForceHandoffRequest 客服确认订单已完成交接。
type ForceHandoffRequest struct {
Content string `json:"content"`
Reason string `json:"reason" binding:"required"`
@@ -209,45 +172,6 @@ type PaginatedResult struct {
PageSize int `json:"page_size"`
}
// UserPaginatedResult 为用户订单列表提供受限分页,避免一次导出全部历史订单。
type UserPaginatedResult struct {
Items []UserOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// SellerHandoffQuery 是号主待办列表的受限查询条件。
type SellerHandoffQuery struct {
Page int
PageSize int
Status string
}
// SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。
type SellerHandoffMetrics struct {
PendingHandoff int64 `json:"pending_handoff"`
PendingCheckout int64 `json:"pending_checkout"`
Abnormal int64 `json:"abnormal"`
}
// SellerHandoffPaginatedResult 只包含当前用户作为号主时需要处理的订单。
type SellerHandoffPaginatedResult struct {
Items []UserOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Metrics SellerHandoffMetrics `json:"metrics"`
}
// AdminOrderListPaginatedResult 使用列表专用 DTO,避免后台首页携带订单敏感详情。
type AdminOrderListPaginatedResult struct {
Items []AdminOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type RefundStatusDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
@@ -269,6 +193,28 @@ type HandoffRecordDTO struct {
CreatedAt time.Time `json:"created_at"`
}
// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。
type ProcessEventDTO struct {
ID uint64 `json:"id"`
BusinessType string `json:"business_type"`
BusinessID uint64 `json:"business_id"`
Stage string `json:"stage"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID uint64 `json:"actor_id"`
ActorName string `json:"actor_name"`
TargetType string `json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `json:"target_name"`
Content string `json:"content"`
Reason string `json:"reason"`
Payload datatypes.JSON `json:"payload"`
AttachmentURLs []string `json:"attachment_urls"`
StateBefore datatypes.JSON `json:"state_before"`
StateAfter datatypes.JSON `json:"state_after"`
CreatedAt time.Time `json:"created_at"`
}
type CheckoutDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
@@ -7,13 +7,20 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func canAdminForceHandoff(order model.RentalOrder) bool {
if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) {
if isPlatformHandoffOrder(order) {
if order.Status == orderStatusPendingHandoff {
return order.HandoffStatus == handoffStatusPendingRenterConfirm
}
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
}
if isPlatformSettlementOrder(order) {
return false
}
if order.Status == orderStatusPendingHandoff {
@@ -24,11 +31,18 @@ func canAdminForceHandoff(order model.RentalOrder) bool {
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
}
func forceHandoffRecordType(order model.RentalOrder) string {
if isPlatformHandoffOrder(order) {
return "platform_handoff"
}
return "owner_handoff"
}
func forceHandoffNeedsContent(order model.RentalOrder) bool {
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout
}
// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。
// AdminForceHandoff 由客服确认订单已完成交接,并立即开始租期。
func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
@@ -38,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
return ErrOrderCannotForceHandoff
}
before := orderState(order)
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
return ErrOrderCannotForceHandoff
}
@@ -60,7 +75,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !needsContent {
var handoffCount int64
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
Where("order_id = ? AND type = ?", order.ID, forceHandoffRecordType(order)).
Count(&handoffCount).Error; err != nil {
return err
}
@@ -87,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil {
return err
}
orderID := order.ID
if err := notification.Append(tx,
notification.Entry{
@@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
response.OK(c, gin.H{"items": items})
}
func (h *Handler) AdminProcessEvents(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) AdminClose(c *gin.Context) {
h.adminAction(c, h.service.AdminClose, gin.H{"closed": true})
}
@@ -248,6 +261,15 @@ func (h *Handler) ListPendingRefund(c *gin.Context) {
response.OK(c, gin.H{"items": items})
}
func (h *Handler) PendingRefundCount(c *gin.Context) {
count, err := h.service.PendingRefundCount(c.Request.Context())
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"pending_refund_count": count})
}
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
adminID, ok := currentAdminID(c)
if !ok {

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