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
211 changed files with 16387 additions and 3784 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 logs
.gocache .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` 做局部调试。 `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go``npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend``--skip-frontend``--skip-tests` 做局部调试。
数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。
需要在本地推送前自动执行同一套检查时,启用 Git hook:
```bash
bash scripts/install-git-hooks.sh
```
启用后,`git push` 会执行 `scripts/check.sh`;仅临时跳过时使用:
```bash
SKIP_CHECKS=1 git push
```
## 开发态短信与实名 ## 开发态短信与实名
- 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 - 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
+7
View File
@@ -8,6 +8,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=secret MYSQL_PASSWORD=secret
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。
DATABASE_MAX_OPEN_CONNS=50
DATABASE_MAX_IDLE_CONNS=10
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
REDIS_ADDR=127.0.0.1:16379 REDIS_ADDR=127.0.0.1:16379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
+45 -15
View File
@@ -19,6 +19,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=change-hfb-password MYSQL_PASSWORD=change-hfb-password
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。
DATABASE_MAX_OPEN_CONNS=50
DATABASE_MAX_IDLE_CONNS=10
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
REDIS_ADDR=redis:6379 REDIS_ADDR=redis:6379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
@@ -48,26 +55,23 @@ LOG_ENABLE_CONSOLE=false
LOG_ENABLE_FILE=true LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14 LOG_RETAIN_DAYS=14
# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验 # 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除
MINIO_ROOT_USER=change-minio-user
MINIO_ROOT_PASSWORD=change-minio-password
# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
STORAGE_ENDPOINT=http://minio:9000
STORAGE_BUCKET=hfb-sys
STORAGE_ACCESS_KEY_ID=change-minio-user
STORAGE_SECRET_ACCESS_KEY=change-minio-password
STORAGE_REGION=
STORAGE_BUCKET_LOOKUP=auto
# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。
# 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com # 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com
# OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。 # OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。
STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
STORAGE_BUCKET=hfb-sys-assets
STORAGE_ACCESS_KEY_ID=change-oss-access-key-id
STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret
STORAGE_REGION=cn-hangzhou
STORAGE_BUCKET_LOOKUP=dns
# 历史镜像写入配置,迁移完成后保持为空。
STORAGE_MIRROR_ENDPOINT= STORAGE_MIRROR_ENDPOINT=
STORAGE_MIRROR_BUCKET=hfb-sys-assets STORAGE_MIRROR_BUCKET=
STORAGE_MIRROR_ACCESS_KEY_ID= STORAGE_MIRROR_ACCESS_KEY_ID=
STORAGE_MIRROR_SECRET_ACCESS_KEY= STORAGE_MIRROR_SECRET_ACCESS_KEY=
STORAGE_MIRROR_REGION=cn-hangzhou STORAGE_MIRROR_REGION=
STORAGE_MIRROR_BUCKET_LOOKUP=dns STORAGE_MIRROR_BUCKET_LOOKUP=
# 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。 # 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。
SMS_PROVIDER=aliyun SMS_PROVIDER=aliyun
@@ -102,3 +106,29 @@ FIELD_ENCRYPTION_LEGACY_KEY=
EXTERNAL_UPLOAD_SECRET= EXTERNAL_UPLOAD_SECRET=
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。 # 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
EXTERNAL_UPLOAD_ALLOWED_IPS= EXTERNAL_UPLOAD_ALLOWED_IPS=
# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。
# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。
BACKUP_DIR=/data/backups
# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。
BACKUP_STATUS_DIR=/data/backups/status
# backend 容器内的只读状态文件路径,通常保持默认。
BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json
# 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。
BACKUP_RESTORE_DIR=/data/restore
# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。
# 生成方式:ossutil config 交互配置,或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET。
BACKUP_OSS_URI=oss://hfb-backup
# 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。
# 生成方式:openssl rand -hex 32
BACKUP_PASSPHRASE=change-to-random-backup-passphrase
# 可选:自有 KMS Key ID。留空时 Bucket 必须开启“OSS 完全托管”服务器端加密。
BACKUP_KMS_KEY_ID=
# XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。
BACKUP_MYSQL_USER=hfb_backup
# 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。
BACKUP_MYSQL_PASSWORD=change-backup-mysql-password
# 本地保留策略(OSS 侧用 Bucket 生命周期规则管理,见脚本头部注释)。
BACKUP_KEEP_DAILY=14
BACKUP_KEEP_WEEKLY=8
BACKUP_KEEP_MONTHLY=12
+7 -1
View File
@@ -58,7 +58,13 @@ func main() {
logAuthRuntimeIdentity(logger, cfg) logAuthRuntimeIdentity(logger, cfg)
var deps router.Dependencies 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 { if err != nil {
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
} else { } else {
+27
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -29,6 +30,8 @@ type Config struct {
Realname RealnameConfig Realname RealnameConfig
Log LogConfig Log LogConfig
RateLimit RateLimitConfig RateLimit RateLimitConfig
Database DatabaseConfig
BackupStatusFile string
} }
type StorageConfig struct { type StorageConfig struct {
@@ -69,6 +72,14 @@ type RateLimitConfig struct {
RequestsPerMinute int RequestsPerMinute int
} }
type DatabaseConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "development"), AppEnv: getEnv("APP_ENV", "development"),
@@ -130,6 +141,14 @@ func Load() Config {
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
}, },
Database: DatabaseConfig{
MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1),
MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0),
ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute,
ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute,
SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond,
},
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
} }
} }
@@ -206,6 +225,14 @@ func getEnvInt(key string, fallback int) int {
return parsed return parsed
} }
func getEnvIntMin(key string, fallback, minimum int) int {
value := getEnvInt(key, fallback)
if value < minimum {
return fallback
}
return value
}
func getEnvBool(key string, fallback bool) bool { func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
+35
View File
@@ -26,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 时 // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { 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 { func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond)
}
func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface {
level := gormLogger.Warn level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info level = gormLogger.Info
@@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
if logger == nil { if logger == nil {
logger = zap.L() logger = zap.L()
} }
if slowThreshold <= 0 {
slowThreshold = 500 * time.Millisecond
}
return &structuredGormLogger{ return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")), logger: logger.With(zap.String("module", "database")),
level: level, level: level,
slowThreshold: 500 * time.Millisecond, slowThreshold: slowThreshold,
} }
} }
+47 -6
View File
@@ -8,9 +8,50 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type MySQLOptions struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func DefaultMySQLOptions() MySQLOptions {
return MySQLOptions{
MaxOpenConns: 50,
MaxIdleConns: 10,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
SlowQueryThreshold: 500 * time.Millisecond,
}
}
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) { func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions())
}
func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) {
defaults := DefaultMySQLOptions()
if options.MaxOpenConns < 1 {
options.MaxOpenConns = defaults.MaxOpenConns
}
if options.MaxIdleConns < 0 {
options.MaxIdleConns = defaults.MaxIdleConns
}
if options.ConnMaxLifetime <= 0 {
options.ConnMaxLifetime = defaults.ConnMaxLifetime
}
if options.ConnMaxIdleTime < 0 {
options.ConnMaxIdleTime = defaults.ConnMaxIdleTime
}
if options.SlowQueryThreshold <= 0 {
options.SlowQueryThreshold = defaults.SlowQueryThreshold
}
if options.MaxIdleConns > options.MaxOpenConns {
options.MaxIdleConns = options.MaxOpenConns
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newGormLogger(logLevel, appLogger), Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。 // 限制连接池,避免单个实例耗尽 MySQL max_connections。
sqlDB.SetMaxOpenConns(50) sqlDB.SetMaxOpenConns(options.MaxOpenConns)
sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxIdleConns(options.MaxIdleConns)
sqlDB.SetConnMaxLifetime(30 * time.Minute) sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(5 * time.Minute) sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
return db, nil return db, nil
} }
+4
View File
@@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB {
func MigrateListingLifecycleTestSchema(db *gorm.DB) error { func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return db.AutoMigrate( return db.AutoMigrate(
&model.User{}, &model.User{},
&model.AdminUser{},
&model.GameAccount{}, &model.GameAccount{},
&model.RentalListing{}, &model.RentalListing{},
&model.ListingUpload{},
&model.ListingStatusEvent{}, &model.ListingStatusEvent{},
) )
} }
@@ -61,8 +63,10 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
&model.WalletLedger{}, &model.WalletLedger{},
&model.RenterGrowthLedger{}, &model.RenterGrowthLedger{},
&model.AuditLog{}, &model.AuditLog{},
&model.ProcessEvent{},
&model.ChatConversation{}, &model.ChatConversation{},
&model.ChatParticipant{}, &model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{}, &model.ChatMessage{},
) )
} }
+9 -1
View File
@@ -313,7 +313,15 @@ func applyMigrations(t *testing.T, db *sql.DB) {
t.Fatal("无法定位当前测试文件") t.Fatal("无法定位当前测试文件")
} }
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
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) migrationPath := filepath.Join(migrationDir, name)
raw, err := os.ReadFile(migrationPath) raw, err := os.ReadFile(migrationPath)
if err != nil { if err != nil {
+14 -14
View File
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatalf("创建测试数据库失败: %v", err) t.Fatalf("创建测试数据库失败: %v", err)
} }
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil { if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
} }
return db return db
@@ -165,19 +165,19 @@ func TestResetRetryClearsRetryFields(t *testing.T) {
func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder {
t.Helper() t.Helper()
order := model.RentalOrder{ order := model.RentalOrder{
OrderNo: "ORD" + suffix, OrderNo: "ORD" + suffix,
ListingID: 1, ListingID: 1,
AccountID: 1, AccountID: 1,
OwnerID: 1, OwnerID: 1,
RenterID: 2, RenterID: 2,
RentAmountCent: 1000, RentAmountCent: 1000,
DepositAmountCent: 500, DepositAmountCent: 500,
Status: "closed", Status: "closed",
HandoffStatus: "arbitrated", HandoffStatus: "arbitrated",
SettlementStatus: settlementStatus, SettlementStatus: settlementStatus,
RefundStatus: "pending", RefundStatus: "pending",
RefundAmountCent: 800, RefundAmountCent: 800,
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
} }
if err := db.Create(&order).Error; err != nil { if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err) t.Fatalf("create order failed: %v", err)
+28 -10
View File
@@ -34,6 +34,8 @@ type AdminTokenContext struct {
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error) type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func extractBearerToken(c *gin.Context) string { func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization") header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
@@ -50,7 +52,19 @@ func extractToken(c *gin.Context) string {
return c.Query("token") return c.Query("token")
} }
func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { 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) { return func(c *gin.Context) {
tokenText := extractToken(c) tokenText := extractToken(c)
if tokenText == "" { if tokenText == "" {
@@ -65,6 +79,17 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID) c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone) c.Set(ContextPhone, claims.Phone)
@@ -74,16 +99,9 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenText := extractBearerToken(c) tokenText, tokenSource := extractAdminToken(c)
tokenSource := "bearer"
if tokenText == "" { if tokenText == "" {
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil { RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
tokenText = strings.TrimSpace(cookieToken)
tokenSource = "cookie"
}
}
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
response.Unauthorized(c, "缺少后台访问令牌") response.Unauthorized(c, "缺少后台访问令牌")
c.Abort() c.Abort()
return return
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
)
func TestAuthRejectsRevokedUserToken(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-secret")
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0)
if err != nil {
t.Fatalf("生成令牌失败:%v", err)
}
engine := gin.New()
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)
if response.Code != http.StatusUnauthorized {
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized)
}
}
+63 -21
View File
@@ -7,10 +7,14 @@ import (
"sync" "sync"
"time" "time"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
type rateLimitKeyFunc func(c *gin.Context) string
type rateLimitBucket struct { type rateLimitBucket struct {
count int count int
resetAt time.Time resetAt time.Time
@@ -32,6 +36,16 @@ type redisRateLimiter struct {
} }
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc { func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, ipRateLimitKey)
}
// AdminAwareRateLimitPerMinute isolates authenticated admin traffic by admin ID.
// Login and other requests without a valid admin access token fall back to IP.
func AdminAwareRateLimitPerMinute(limit int, rdb *redis.Client, jwtManager *auth.JWTManager) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, adminOrIPRateLimitKey(jwtManager))
}
func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc) gin.HandlerFunc {
if limit <= 0 { if limit <= 0 {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Next() c.Next()
@@ -48,34 +62,62 @@ func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
fallback: limiter, fallback: limiter,
limit: limit, limit: limit,
window: time.Minute, window: time.Minute,
}).handle }).handle(keyFunc)
} }
return limiter.handle return limiter.handle(keyFunc)
} }
func (l *redisRateLimiter) handle(c *gin.Context) { func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
now := time.Now() return func(c *gin.Context) {
key := c.ClientIP() now := time.Now()
allowed, resetAt, err := l.allow(c.Request.Context(), key, now) key := keyFunc(c)
if err != nil { allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
allowed, resetAt = l.fallback.allow(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) { func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
now := time.Now() return func(c *gin.Context) {
key := c.ClientIP() now := time.Now()
allowed, resetAt := l.allow(key, now) key := keyFunc(c)
if !allowed { allowed, resetAt := l.allow(key, now)
writeRateLimited(c, now, resetAt) if !allowed {
return 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) { 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 判断是否为无需记录的普通请求。 // 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 { if status >= 500 || status == http.StatusTooManyRequests {
return false return false
} }
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
if isChatSSEPath(path, route) {
return true
}
if latencyMs >= slowRequestThresholdMs { if latencyMs >= slowRequestThresholdMs {
return false return false
} }
return true return true
} }
func isChatSSEPath(path, route string) bool {
return path == "/api/chats/events" || path == "/api/admin/chats/events" ||
route == "/api/chats/events" || route == "/api/admin/chats/events"
}
func meaningfulAuthFailure(c *gin.Context) bool { func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason) value, ok := c.Get(ContextAuthFailureReason)
if !ok { if !ok {
@@ -19,6 +19,10 @@ func TestShouldSkipHTTPLog(t *testing.T) {
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false}, {name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false}, {name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false}, {name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
{name: "SSE 限流不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 429, latencyMs: 1, wantSkip: false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
+68 -21
View File
@@ -4,21 +4,32 @@ import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm"
) )
type ChatConversation struct { type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"` ListingID *uint64 `gorm:"index" json:"listing_id"`
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` // 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` // 这样读取会话列表不必每次扫描全部订单再按商品分组。
Title string `gorm:"size:128;not null" json:"title"` LatestOrderID *uint64 `gorm:"index" json:"-"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"`
LastMessageID *uint64 `json:"last_message_id"` LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"`
LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"`
LastMessageAt *time.Time `json:"last_message_at"` LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"`
CreatedAt time.Time `json:"created_at"` // 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。
UpdatedAt time.Time `json:"updated_at"` 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 { func (ChatConversation) TableName() string {
@@ -42,22 +53,58 @@ func (ChatParticipant) TableName() string {
return "chat_participants" return "chat_participants"
} }
// ChatAdminConversationState stores per-admin state even when the admin is not
// an assigned participant of the conversation.
type ChatAdminConversationState struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"`
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"`
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"`
LastReadAt *time.Time `json:"last_read_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatAdminConversationState) TableName() string {
return "chat_admin_conversation_states"
}
type ChatMessage struct { type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"` SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"` 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 { func (ChatMessage) TableName() string {
return "chat_messages" return "chat_messages"
} }
// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。
func (m *ChatMessage) AfterCreate(tx *gorm.DB) error {
if m.ID == 0 || m.ConversationID == 0 {
return nil
}
updates := map[string]any{}
if m.AdminAttentionType != "" || m.SenderType == "user" {
updates["last_attention_message_id"] = m.ID
}
if m.SenderType == "admin" {
updates["last_admin_message_id"] = m.ID
}
if len(updates) == 0 {
return nil
}
return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error
}
type ChatQrCode struct { type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"` ImageURL string `gorm:"size:512;not null" json:"image_url"`
+21
View File
@@ -4,6 +4,7 @@ import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm"
) )
type RentalOrder struct { type RentalOrder struct {
@@ -41,6 +42,8 @@ type RentalOrder struct {
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"` HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"` HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
@@ -71,6 +74,24 @@ func (RentalOrder) TableName() string {
return "rental_orders" return "rental_orders"
} }
// AfterSave 将商品关联发布群的最新订单快照一并更新。
// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。
func (o *RentalOrder) AfterSave(tx *gorm.DB) error {
if o.ID == 0 || o.ListingID == 0 {
return nil
}
return tx.Model(&ChatConversation{}).
Where("listing_id = ? AND order_id IS NULL", o.ListingID).
Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID).
Updates(map[string]any{
"latest_order_id": o.ID,
"latest_order_no": o.OrderNo,
"latest_order_status": o.Status,
"latest_order_handoff_status": o.HandoffStatus,
"latest_order_refund_status": o.RefundStatus,
}).Error
}
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算, // 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。 // 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
const ( const (
+34
View File
@@ -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
View File
@@ -15,6 +15,7 @@ type User struct {
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
+15 -6
View File
@@ -24,12 +24,14 @@ type MetricsDTO struct {
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 // ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
type ListingDailyOverviewDTO struct { type ListingDailyOverviewDTO struct {
Today ListingDayStatsDTO `json:"today"` Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"` Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
Days int `json:"days"` TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
Timezone string `json:"timezone"` UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
} }
type ListingDayStatsDTO struct { type ListingDayStatsDTO struct {
@@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct {
TradeLeaveCount int64 `json:"trade_leave_count"` TradeLeaveCount int64 `json:"trade_leave_count"`
} }
type ListingUploaderDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
UploaderID uint64 `json:"uploader_id"`
UploaderName string `json:"uploader_name"`
UploadCount int64 `json:"upload_count"`
}
type PendingDTO struct { type PendingDTO struct {
ListingReviews int64 `json:"listing_reviews"` ListingReviews int64 `json:"listing_reviews"`
Disputes int64 `json:"disputes"` Disputes int64 `json:"disputes"`
@@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
Scan(&events).Error; err != nil { Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err return ListingDailyOverviewDTO{}, err
} }
uploads := make([]listingUploaderRow, 0)
if err := db.Table("listing_uploads").
Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at").
Where("listing_id IS NOT NULL").
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&uploads).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
byDay := make(map[string]*listingDailyBucket, days) byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket) byChannel := make(map[listingChannelKey]*listingDailyBucket)
byUploader := make(map[listingUploaderKey]int64)
for _, ev := range events { for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02") key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key] b := byDay[key]
@@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
} }
addListingDailyEvent(cb, ev.EventType, ev.Source) addListingDailyEvent(cb, ev.EventType, ev.Source)
} }
for _, upload := range uploads {
key := upload.CreatedAt.In(loc).Format("2006-01-02")
uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName}
byUploader[uploaderKey]++
}
trend := make([]ListingDayStatsDTO, 0, days) trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- { for i := days - 1; i >= 0; i-- {
@@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
todayStats = trend[len(trend)-1] todayStats = trend[len(trend)-1]
} }
channelTrend := listingChannelTrend(byChannel) channelTrend := listingChannelTrend(byChannel)
uploaderTrend := listingUploaderTrend(byUploader)
todayChannels := make([]ListingChannelDayStatsDTO, 0) todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend { for _, item := range channelTrend {
if item.Date == todayStats.Date { if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item) todayChannels = append(todayChannels, item)
} }
} }
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
for _, item := range uploaderTrend {
if item.Date == todayStats.Date {
todayUploaders = append(todayUploaders, item)
}
}
return ListingDailyOverviewDTO{ return ListingDailyOverviewDTO{
Today: todayStats, Today: todayStats,
Trend: trend, Trend: trend,
TodayChannels: todayChannels, TodayChannels: todayChannels,
ChannelTrend: channelTrend, ChannelTrend: channelTrend,
Days: days, TodayUploaders: todayUploaders,
Timezone: "Asia/Shanghai", UploaderTrend: uploaderTrend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil }, nil
} }
type listingUploaderRow struct {
UploaderID uint64
UploaderName string
CreatedAt time.Time
}
type listingChannelKey struct { type listingChannelKey struct {
date string date string
sourceChannel string sourceChannel string
} }
type listingUploaderKey struct {
date string
uploaderID uint64
uploaderName string
}
type listingDailyBucket struct { type listingDailyBucket struct {
published int64 published int64
activeOffline int64 activeOffline int64
@@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi
return items return items
} }
func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO {
keys := make([]listingUploaderKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
if rows[keys[i]] != rows[keys[j]] {
return rows[keys[i]] > rows[keys[j]]
}
if keys[i].uploaderName != keys[j].uploaderName {
return keys[i].uploaderName < keys[j].uploaderName
}
return keys[i].uploaderID < keys[j].uploaderID
})
items := make([]ListingUploaderDayStatsDTO, 0, len(keys))
for _, key := range keys {
items = append(items, ListingUploaderDayStatsDTO{
Date: key.date,
UploaderID: key.uploaderID,
UploaderName: key.uploaderName,
UploadCount: rows[key],
})
}
return items
}
func listingChannelRank(channel string) int { func listingChannelRank(channel string) int {
switch channel { switch channel {
case "咸鱼": case "咸鱼":
@@ -65,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) 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 ( import (
"context" "context"
"sync"
"hfb_sys/backend/internal/timeutil" "hfb_sys/backend/internal/timeutil"
) )
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
dailyItems, err := r.dailyItems(ctx, query) if cached := r.loadDashboardCache(ctx, query); cached != nil {
if err != nil { return cached, nil
return nil, err
} }
pickup, err := r.pickupSummary(ctx, query) // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
if err != nil { // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
return nil, err 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) summary, err := r.summary(ctx, query, pickup)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
disbursement, err := r.disbursementSummary(ctx, query) return &summary, nil
if err != nil {
return nil, err
}
return &DashboardDTO{
Summary: *summary,
DailyItems: dailyItems,
PickupSummary: *pickup,
MohongSummary: *mohong,
DisbursementSummary: *disbursement,
GeneratedAt: timeutil.ShanghaiNow(),
}, nil
} }
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 // pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
@@ -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) { func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
var payment paymentSummaryRow var payment paymentSummaryRow
originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query)
if err := db.Table("payment_orders AS po"). if err := db.Table("payment_orders AS po").
Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
@@ -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 = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
Joins(`LEFT JOIN ( Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments).
SELECT order_id, MAX(amount_cent) AS amount_cent
FROM payment_orders
WHERE biz_type IN ? AND status = 'paid'
GROUP BY order_id
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Scan(&payment).Error; err != nil { Scan(&payment).Error; err != nil {
return nil, err return nil, err
@@ -141,32 +194,31 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
THEN COALESCE(ro.offline_settlement_amount_cent, 0) THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE 0 ELSE 0
END), 0) AS offline_settlement_pending_amount_cent, END), 0) AS offline_settlement_pending_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
THEN 1 THEN 1
ELSE 0 ELSE 0
END), 0) AS offline_settlement_pending_count, END), 0) AS offline_settlement_pending_count,
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("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). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Scan(&settlement).Error; err != nil { Scan(&settlement).Error; err != nil {
return nil, err return nil, err
} }
var exceptionCount int64
if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{
DateType: "settled",
StartDate: query.StartDate,
EndDate: query.EndDate,
})).
Where("finance_status <> ?", financeStatusNormal).
Where("finance_status <> ?", financeStatusOfflineSettlementPending).
Count(&exceptionCount).Error; err != nil {
return nil, err
}
// 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。 // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。
var estimated estimatedIncomeRow var estimated estimatedIncomeRow
if err := db.Table("rental_orders"). if err := db.Table("rental_orders").
@@ -209,7 +261,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
SettledOrderCount: settlement.SettledOrderCount, SettledOrderCount: settlement.SettledOrderCount,
PendingSettleOrderCount: estimated.PendingSettleOrderCount, PendingSettleOrderCount: estimated.PendingSettleOrderCount,
OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount, OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount,
FinancialExceptionCount: exceptionCount, FinancialExceptionCount: settlement.FinancialExceptionCount,
}, nil }, nil
} }
@@ -251,7 +303,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
END), 0) AS offline_settlement_pending_count, END), 0) AS offline_settlement_pending_count,
COUNT(ro.id) AS settled_order_count`). COUNT(ro.id) AS settled_order_count`).
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", 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). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(ro.settled_at)"). Group("DATE(ro.settled_at)").
Scan(&settlements).Error; err != nil { Scan(&settlements).Error; err != nil {
@@ -303,6 +355,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
if err != nil { if err != nil {
return nil, err return nil, err
} }
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
return nil, err
}
itemsByDate := make(map[string]FinanceDailyDTO) itemsByDate := make(map[string]FinanceDailyDTO)
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -392,6 +448,14 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
itemsByDate[date] = item itemsByDate[date] = item
} }
for _, row := range operatingExpenses {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.OperatingExpenseAmountCent = row.AmountCent
item.OperatingExpenseCount = row.Count
itemsByDate[date] = item
}
items := make([]FinanceDailyDTO, 0, len(itemsByDate)) items := make([]FinanceDailyDTO, 0, len(itemsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -404,7 +468,8 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 // normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。
func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) {
rows := make([]dailyPaymentRow, 0) rows := make([]dailyPaymentRow, 0)
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, Select(`DATE(po.created_at) AS date,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
@@ -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 = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
Joins(`LEFT JOIN ( Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)).
SELECT order_id, MAX(amount_cent) AS amount_cent
FROM payment_orders
WHERE biz_type IN ? AND status = 'paid'
GROUP BY order_id
) AS orig ON orig.order_id = po.order_id`, payBizTypes()).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(po.created_at)"). Group("DATE(po.created_at)").
Scan(&rows).Error Scan(&rows).Error
@@ -477,6 +537,7 @@ type settlementSummaryRow struct {
OfflineSettlementAmountCent int64 OfflineSettlementAmountCent int64
OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingAmountCent int64
OfflineSettlementPendingCount int64 OfflineSettlementPendingCount int64
FinancialExceptionCount int64
SettledOrderCount 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) 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" import "context"
type dailyOperatingExpenseRow struct {
Date string
AmountCent int64
Count int64
}
func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) {
rows := make([]dailyOperatingExpenseRow, 0)
err := r.db.WithContext(ctx).Table("operating_expenses").
Select(`DATE(occurred_at) AS date, COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("status = ?", "paid").
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
Group("DATE(occurred_at)").
Scan(&rows).Error
return rows, err
}
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。 // disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) { func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -81,42 +98,29 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
return nil, err return nil, err
} }
var withdrawalPaid struct { // 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。
AmountCent int64 var withdrawal struct {
FeeAmountCent int64 PaidAmountCent int64
ActualAmountCent int64 PaidFeeAmountCent int64
Count int64 PaidActualAmountCent int64
PaidCount int64
PendingAmountCent int64
PendingCount int64
ReviewAmountCent int64
ReviewCount int64
} }
if err := db.Table("withdrawal_requests"). if err := db.Table("withdrawal_requests").Select(`
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, 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(fee_cent), 0) AS fee_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(actual_amount_cent), 0) AS actual_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,
COUNT(id) AS count`). COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count,
Where("status = ?", "completed"). COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent,
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate). COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count,
Scan(&withdrawalPaid).Error; err != nil { COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent,
return nil, err 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).
var withdrawalPending struct { Scan(&withdrawal).Error; err != nil {
ActualAmountCent int64
Count int64
}
if err := db.Table("withdrawal_requests").
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
Where("status = ?", "processing").
Scan(&withdrawalPending).Error; err != nil {
return nil, err
}
var withdrawalReview struct {
ActualAmountCent int64
Count int64
}
if err := db.Table("withdrawal_requests").
Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`).
Where("status = ?", "pending").
Scan(&withdrawalReview).Error; err != nil {
return nil, err return nil, err
} }
@@ -133,22 +137,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
} }
return &DisbursementSummaryDTO{ return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent, PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count, PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent, PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count, PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent, OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count, OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
OfflineSettlementPendingAmountCent: offlinePending.AmountCent, OfflineSettlementPendingAmountCent: offlinePending.AmountCent,
OfflineSettlementPendingCount: offlinePending.Count, OfflineSettlementPendingCount: offlinePending.Count,
WithdrawalAmountCent: withdrawalPaid.AmountCent, WithdrawalAmountCent: withdrawal.PaidAmountCent,
WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent, WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent,
WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent, WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent,
WithdrawalPaidCount: withdrawalPaid.Count, WithdrawalPaidCount: withdrawal.PaidCount,
WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent, WithdrawalPendingAmountCent: withdrawal.PendingAmountCent,
WithdrawalPendingCount: withdrawalPending.Count, WithdrawalPendingCount: withdrawal.PendingCount,
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent, WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent,
WithdrawalReviewCount: withdrawalReview.Count, WithdrawalReviewCount: withdrawal.ReviewCount,
ManualPaidAmountCent: manualPaid.AmountCent, ManualPaidAmountCent: manualPaid.AmountCent,
ManualPaidCount: manualPaid.Count, ManualPaidCount: manualPaid.Count,
}, nil }, nil
@@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark, ro.offline_settlement_remark AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -125,6 +126,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -160,6 +162,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark, md.remark,
md.category, md.category,
md.custom_category_name,
md.voucher_url, md.voucher_url,
md.voided_at, md.voided_at,
md.voided_by, md.voided_by,
@@ -202,37 +205,38 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
} }
type disbursementItemRow struct { type disbursementItemRow struct {
SourceType string SourceType string
SourceID uint64 SourceID uint64
BusinessNo string BusinessNo string
OrderID uint64 OrderID uint64
WithdrawalID uint64 WithdrawalID uint64
UserID uint64 UserID uint64
PayeeName string PayeeName string
PayeePhone string PayeePhone string
UploaderName string UploaderName string
SourceChannel string SourceChannel string
AccountType string AccountType string
AccountName string AccountName string
AccountNo string AccountNo string
BankName string BankName string
AmountCent int64 AmountCent int64
FeeCent int64 FeeCent int64
ActualAmountCent int64 ActualAmountCent int64
Status string Status string
RawStatus string RawStatus string
BusinessCreatedAt *time.Time BusinessCreatedAt *time.Time
PaidAt *time.Time PaidAt *time.Time
OperatorID *uint64 OperatorID *uint64
OperatorName string OperatorName string
Remark string Remark string
Category string Category string
VoucherURL string CustomCategoryName string
VoidedAt *time.Time VoucherURL string
VoidedBy *uint64 VoidedAt *time.Time
VoidedByName string VoidedBy *uint64
VoidReason string VoidedByName string
SourcePayload []byte VoidReason string
SourcePayload []byte
} }
func (r disbursementItemRow) toDTO() DisbursementItemDTO { func (r disbursementItemRow) toDTO() DisbursementItemDTO {
@@ -246,35 +250,36 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
} }
} }
return DisbursementItemDTO{ return DisbursementItemDTO{
SourceType: r.SourceType, SourceType: r.SourceType,
SourceID: r.SourceID, SourceID: r.SourceID,
BusinessNo: r.BusinessNo, BusinessNo: r.BusinessNo,
OrderID: r.OrderID, OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID, WithdrawalID: r.WithdrawalID,
UserID: r.UserID, UserID: r.UserID,
PayeeName: r.PayeeName, PayeeName: r.PayeeName,
PayeePhone: phone, PayeePhone: phone,
UploaderName: r.UploaderName, UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel, SourceChannel: r.SourceChannel,
AccountType: r.AccountType, AccountType: r.AccountType,
AccountName: r.AccountName, AccountName: r.AccountName,
AccountNo: r.AccountNo, AccountNo: r.AccountNo,
BankName: r.BankName, BankName: r.BankName,
AmountCent: r.AmountCent, AmountCent: r.AmountCent,
FeeCent: r.FeeCent, FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent, ActualAmountCent: r.ActualAmountCent,
Status: r.Status, Status: r.Status,
RawStatus: r.RawStatus, RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt, BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt, PaidAt: r.PaidAt,
OperatorID: r.OperatorID, OperatorID: r.OperatorID,
OperatorName: r.OperatorName, OperatorName: r.OperatorName,
Remark: r.Remark, Remark: r.Remark,
Category: r.Category, Category: r.Category,
VoucherURL: r.VoucherURL, CustomCategoryName: r.CustomCategoryName,
VoidedAt: r.VoidedAt, VoucherURL: r.VoucherURL,
VoidedBy: r.VoidedBy, VoidedAt: r.VoidedAt,
VoidedByName: r.VoidedByName, VoidedBy: r.VoidedBy,
VoidReason: r.VoidReason, 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 users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`,
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, 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, amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME, status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME void_reason TEXT, created_at DATETIME
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO manual_disbursements if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, 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) voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES VALUES
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), (20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, (21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil { at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
break break
} }
} }
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" { if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem) t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
} }
+126 -58
View File
@@ -34,13 +34,25 @@ type DisbursementQuery struct {
PageSize int PageSize int
} }
type OperatingExpenseQuery struct {
Status string
Category string
Keyword string
DateType string
StartDate time.Time
EndDate time.Time
Page int
PageSize int
}
type DashboardDTO struct { type DashboardDTO struct {
Summary FinanceSummaryDTO `json:"summary"` Summary FinanceSummaryDTO `json:"summary"`
DailyItems []FinanceDailyDTO `json:"daily_items"` DailyItems []FinanceDailyDTO `json:"daily_items"`
PickupSummary PickupSummaryDTO `json:"pickup_summary"` PickupSummary PickupSummaryDTO `json:"pickup_summary"`
MohongSummary MohongSummaryDTO `json:"mohong_summary"` MohongSummary MohongSummaryDTO `json:"mohong_summary"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
GeneratedAt time.Time `json:"generated_at"` OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"`
GeneratedAt time.Time `json:"generated_at"`
} }
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
@@ -101,46 +113,67 @@ type DisbursementListSummaryDTO struct {
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
} }
type OperatingExpenseSummaryDTO struct {
AmountCent int64 `json:"amount_cent"`
Count int64 `json:"count"`
}
type OperatingExpenseListSummaryDTO struct {
RecordCount int64 `json:"record_count"`
PaidAmountCent int64 `json:"paid_amount_cent"`
PaidCount int64 `json:"paid_count"`
}
type OperatingExpenseListDTO struct {
Items []OperatingExpenseDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Summary OperatingExpenseListSummaryDTO `json:"summary"`
}
type DisbursementItemDTO struct { type DisbursementItemDTO struct {
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"` SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"` BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"` OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"` WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"` UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"` PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"` PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"` UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"` SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"` AccountType string `json:"account_type"`
AccountName string `json:"account_name"` AccountName string `json:"account_name"`
AccountNo string `json:"account_no"` AccountNo string `json:"account_no"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"` AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"` FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"` ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"` Status string `json:"status"`
RawStatus string `json:"raw_status"` RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"` PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"` OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Category string `json:"category"` Category string `json:"category"`
VoucherURL string `json:"voucher_url"` CustomCategoryName string `json:"custom_category_name"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoucherURL string `json:"voucher_url"`
VoidedBy *uint64 `json:"voided_by,omitempty"` VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedByName string `json:"voided_by_name"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidReason string `json:"void_reason"` VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
} }
type CreateManualDisbursementRequest struct { type CreateManualDisbursementRequest struct {
Category string `json:"category"` Category string `json:"category"`
PayeeName string `json:"payee_name"` CustomCategoryName string `json:"custom_category_name"`
AmountCent int64 `json:"amount_cent"` PayeeName string `json:"payee_name"`
PaidAt time.Time `json:"paid_at"` AmountCent int64 `json:"amount_cent"`
Remark string `json:"remark"` PaidAt time.Time `json:"paid_at"`
VoucherURL string `json:"voucher_url"` Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
} }
type VoidManualDisbursementRequest struct { type VoidManualDisbursementRequest struct {
@@ -148,22 +181,55 @@ type VoidManualDisbursementRequest struct {
} }
type ManualDisbursementDTO struct { type ManualDisbursementDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"` DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"` Category string `json:"category"`
PayeeName string `json:"payee_name"` CustomCategoryName string `json:"custom_category_name"`
AmountCent int64 `json:"amount_cent"` PayeeName string `json:"payee_name"`
PaidAt time.Time `json:"paid_at"` AmountCent int64 `json:"amount_cent"`
Remark string `json:"remark"` PaidAt time.Time `json:"paid_at"`
VoucherURL string `json:"voucher_url"` Remark string `json:"remark"`
Status string `json:"status"` VoucherURL string `json:"voucher_url"`
CreatedBy uint64 `json:"created_by"` Status string `json:"status"`
CreatedByName string `json:"created_by_name"` CreatedBy uint64 `json:"created_by"`
VoidedBy *uint64 `json:"voided_by,omitempty"` CreatedByName string `json:"created_by_name"`
VoidedByName string `json:"voided_by_name"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"` VoidedAt *time.Time `json:"voided_at,omitempty"`
CreatedAt time.Time `json:"created_at"` 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 { type FinanceSummaryDTO struct {
@@ -230,6 +296,8 @@ type FinanceDailyDTO struct {
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"` ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"` ManualPaidCount int64 `json:"manual_paid_count"`
OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"`
OperatingExpenseCount int64 `json:"operating_expense_count"`
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
DisbursementPaidCount int64 `json:"disbursement_paid_count"` DisbursementPaidCount int64 `json:"disbursement_paid_count"`
} }
@@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) {
response.OK(c, result) response.OK(c, result)
} }
func (h *Handler) OperatingExpenses(c *gin.Context) {
query, ok := parseOperatingExpenseQuery(c)
if !ok {
return
}
result, err := h.service.OperatingExpenses(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) CreateManualDisbursement(c *gin.Context) { func (h *Handler) CreateManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c) adminID, ok := financeAdminID(c)
if !ok { if !ok {
@@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
response.OK(c, item) response.OK(c, item)
} }
func (h *Handler) CreateOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "运营开支信息不正确")
return
}
item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) VoidOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "运营开支记录 ID 不正确")
return
}
var req VoidOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写作废原因")
return
}
item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
start, end, ok := parseDateRange(c, 6) start, end, ok := parseDateRange(c, 6)
if !ok { if !ok {
@@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
return query, true return query, true
} }
func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
start, end, ok := parseDateRange(c, 29)
if !ok {
return OperatingExpenseQuery{}, false
}
query := OperatingExpenseQuery{
Status: strings.TrimSpace(c.Query("status")),
Category: strings.TrimSpace(c.Query("category")),
Keyword: strings.TrimSpace(c.Query("keyword")),
DateType: c.DefaultQuery("date_type", "created"),
StartDate: start,
EndDate: end,
}
if query.Status != "" && query.Status != "paid" && query.Status != "voided" {
response.BadRequest(c, "运营开支状态不正确")
return query, false
}
if query.DateType != "created" && query.DateType != "occurred" {
response.BadRequest(c, "日期类型不正确")
return query, false
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) { func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
loc := timeutil.ShanghaiLocation() loc := timeutil.ShanghaiLocation()
today := timeutil.ShanghaiNow() today := timeutil.ShanghaiNow()
@@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) {
response.NotFound(c, "线下出款记录不存在") response.NotFound(c, "线下出款记录不存在")
case errors.Is(err, ErrManualDisbursementNotPaid): case errors.Is(err, ErrManualDisbursementNotPaid):
response.BadRequest(c, "该线下出款记录已作废") response.BadRequest(c, "该线下出款记录已作废")
case errors.Is(err, ErrInvalidOperatingExpense):
response.BadRequest(c, "运营开支信息不正确")
case errors.Is(err, ErrOperatingExpenseNotFound):
response.NotFound(c, "运营开支记录不存在")
case errors.Is(err, ErrOperatingExpenseNotPaid):
response.BadRequest(c, "该运营开支记录已作废")
default: default:
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
} }
@@ -53,7 +53,7 @@ func normalRefundBizTypes() []string {
// settledSettlementStatuses 是视为"已完成结算"的结算状态集合。 // settledSettlementStatuses 是视为"已完成结算"的结算状态集合。
// 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。 // 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。
func settledSettlementStatuses() []string { func settledSettlementStatuses() []string {
return []string{"settled", "closed"} return []string{"settled", "closed", "arbitrated"}
} }
// nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。 // nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。
@@ -45,3 +45,15 @@ func TestRefundBizTypesIncludeMohongRefund(t *testing.T) {
t.Fatal("refundBizTypes missing mohong_refund") t.Fatal("refundBizTypes missing mohong_refund")
} }
} }
func TestSettledSettlementStatusesIncludeArbitrated(t *testing.T) {
values := map[string]bool{}
for _, item := range settledSettlementStatuses() {
values[item] = true
}
for _, want := range []string{"settled", "closed", "arbitrated"} {
if !values[want] {
t.Fatalf("settledSettlementStatuses missing %s", want)
}
}
}
@@ -16,21 +16,22 @@ import (
) )
type manualDisbursementRecord struct { type manualDisbursementRecord struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
PayeeName string CustomCategoryName string
AmountCent int64 PayeeName string
PaidAt time.Time AmountCent int64
Remark string PaidAt time.Time
VoucherURL string Remark string
Status string VoucherURL string
CreatedBy uint64 Status string
VoidedBy *uint64 CreatedBy uint64
VoidedAt *time.Time VoidedBy *uint64
VoidReason string VoidedAt *time.Time
CreatedAt time.Time VoidReason string
UpdatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
func (manualDisbursementRecord) TableName() string { func (manualDisbursementRecord) TableName() string {
@@ -48,15 +49,16 @@ func (r *Repository) CreateManualDisbursement(
return nil, err return nil, err
} }
record := manualDisbursementRecord{ record := manualDisbursementRecord{
DisbursementNo: disbursementNo, DisbursementNo: disbursementNo,
Category: req.Category, Category: req.Category,
PayeeName: req.PayeeName, CustomCategoryName: req.CustomCategoryName,
AmountCent: req.AmountCent, PayeeName: req.PayeeName,
PaidAt: req.PaidAt, AmountCent: req.AmountCent,
Remark: req.Remark, PaidAt: req.PaidAt,
VoucherURL: req.VoucherURL, Remark: req.Remark,
Status: "paid", VoucherURL: req.VoucherURL,
CreatedBy: adminID, Status: "paid",
CreatedBy: adminID,
} }
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
@@ -71,12 +73,13 @@ func (r *Repository) CreateManualDisbursement(
BizID: &id, BizID: &id,
Meta: meta, Meta: meta,
Detail: map[string]any{ Detail: map[string]any{
"disbursement_no": disbursementNo, "disbursement_no": disbursementNo,
"category": req.Category, "category": req.Category,
"payee_name": req.PayeeName, "custom_category_name": req.CustomCategoryName,
"amount_cent": req.AmountCent, "payee_name": req.PayeeName,
"paid_at": req.PaidAt, "amount_cent": req.AmountCent,
"has_voucher": req.VoucherURL != "", "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 nil, err
} }
return &ManualDisbursementDTO{ return &ManualDisbursementDTO{
ID: row.ID, ID: row.ID,
DisbursementNo: row.DisbursementNo, DisbursementNo: row.DisbursementNo,
Category: row.Category, Category: row.Category,
PayeeName: row.PayeeName, CustomCategoryName: row.CustomCategoryName,
AmountCent: row.AmountCent, PayeeName: row.PayeeName,
PaidAt: row.PaidAt, AmountCent: row.AmountCent,
Remark: row.Remark, PaidAt: row.PaidAt,
VoucherURL: row.VoucherURL, Remark: row.Remark,
Status: row.Status, VoucherURL: row.VoucherURL,
CreatedBy: row.CreatedBy, Status: row.Status,
CreatedByName: row.CreatedByName, CreatedBy: row.CreatedBy,
VoidedBy: row.VoidedBy, CreatedByName: row.CreatedByName,
VoidedByName: row.VoidedByName, VoidedBy: row.VoidedBy,
VoidedAt: row.VoidedAt, VoidedByName: row.VoidedByName,
VoidReason: row.VoidReason, VoidedAt: row.VoidedAt,
CreatedAt: row.CreatedAt, VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
}, nil }, nil
} }
type manualDisbursementDetailRow struct { type manualDisbursementDetailRow struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
PayeeName string CustomCategoryName string
AmountCent int64 PayeeName string
PaidAt time.Time AmountCent int64
Remark string PaidAt time.Time
VoucherURL string Remark string
Status string VoucherURL string
CreatedBy uint64 Status string
CreatedByName string CreatedBy uint64
VoidedBy *uint64 CreatedByName string
VoidedByName string VoidedBy *uint64
VoidedAt *time.Time VoidedByName string
VoidReason string VoidedAt *time.Time
CreatedAt time.Time VoidReason string
CreatedAt time.Time
} }
func newManualDisbursementNo() (string, error) { func newManualDisbursementNo() (string, error) {
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
statements := []string{ statements := []string{
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE, id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
category TEXT NOT NULL, 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, paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER, status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
@@ -47,17 +47,18 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
repo := NewRepository(db) repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation()) paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "operating_expense", Category: "custom",
PayeeName: "测试供应商", CustomCategoryName: "临时活动支出",
AmountCent: 8800, PayeeName: "测试供应商",
PaidAt: paidAt, AmountCent: 8800,
Remark: "测试运营支出", PaidAt: paidAt,
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"}) }, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil { if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err) t.Fatalf("创建其他线下出款失败: %v", err)
} }
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" { if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" {
t.Fatalf("创建结果不正确: %+v", created) t.Fatalf("创建结果不正确: %+v", created)
} }
@@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
} }
func TestManualDisbursementValidation(t *testing.T) { func TestManualDisbursementValidation(t *testing.T) {
if !validManualDisbursementCategory("duoduo_deposit_refund") {
t.Fatal("多多退押金分类应为有效分类")
}
if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") {
t.Fatal("自定义分类名称校验不正确")
}
service := NewService(&Repository{}) service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ _, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown", Category: "unknown",
@@ -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 package adminfinance
import ( import (
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
redis *redis.Client
} }
func NewRepository(db *gorm.DB) *Repository { func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository {
return &Repository{db: db} 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") ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
ErrManualDisbursementNotFound = errors.New("manual disbursement not found") ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid") ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
ErrInvalidOperatingExpense = errors.New("invalid operating expense")
ErrOperatingExpenseNotFound = errors.New("operating expense not found")
ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid")
) )
type Service struct { type Service struct {
@@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
return s.repo.Disbursements(ctx, query) return s.repo.Disbursements(ctx, query)
} }
func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.OperatingExpenses(ctx, query)
}
func (s *Service) CreateManualDisbursement( func (s *Service) CreateManualDisbursement(
ctx context.Context, ctx context.Context,
req CreateManualDisbursementRequest, req CreateManualDisbursementRequest,
@@ -74,10 +93,12 @@ func (s *Service) CreateManualDisbursement(
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
req.Category = strings.TrimSpace(req.Category) req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName) req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark) req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL) req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) || if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() || req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
@@ -104,15 +125,51 @@ func (s *Service) VoidManualDisbursement(
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta) return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
} }
func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.OccurredAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
!validManualVoucherURL(req.VoucherURL) {
return nil, ErrInvalidOperatingExpense
}
return s.repo.CreateOperatingExpense(ctx, req, adminID, meta)
}
func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reason = strings.TrimSpace(reason)
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
return nil, ErrInvalidOperatingExpense
}
return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta)
}
func validManualDisbursementCategory(category string) bool { func validManualDisbursementCategory(category string) bool {
switch category { switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other": case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
return true return true
default: default:
return false return false
} }
} }
func validManualDisbursementCustomCategoryName(category, name string) bool {
if category != "custom" {
return name == ""
}
return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50
}
func validManualVoucherURL(raw string) bool { func validManualVoucherURL(raw string) bool {
if raw == "" { if raw == "" {
return true return true
@@ -28,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"}). Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}).
Group("order_id") Group("order_id")
} }
// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。
// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合,
// 再使用 wallet_ledger.order_id 索引关联对应流水。
func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
return db.Table("rental_orders AS scoped_order").
Select(`scoped_order.id AS order_id,
COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`).
Joins(`LEFT JOIN wallet_ledger AS wallet
ON wallet.order_id = scoped_order.id
AND wallet.direction = ?
AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation"}).
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
Group("scoped_order.id")
}
// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态,
// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。
func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
return db.Table("rental_orders AS scoped_order").
Select(`scoped_order.id AS order_id,
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent,
COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()).
Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()).
Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
Group("scoped_order.id")
}
// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。
// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。
func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
refundOrders := db.Table("payment_orders AS refund").
Select("DISTINCT refund.order_id").
Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded").
Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate)
return db.Table("payment_orders AS paid").
Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent").
Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders).
Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid").
Group("paid.order_id")
}
@@ -19,8 +19,9 @@ import (
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
encryptor crypto.Encryptor encryptor crypto.Encryptor
statusChangeNotifier func(userID uint64)
} }
type AuditMeta = auditlog.Meta type AuditMeta = auditlog.Meta
@@ -33,6 +34,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
return &Repository{db: db, encryptor: encryptor} return &Repository{db: db, encryptor: encryptor}
} }
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
r.statusChangeNotifier = notifier
}
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil { if err != nil {
@@ -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) { func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
statusChanged := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
@@ -214,23 +221,33 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
} }
beforeStatus := user.Status beforeStatus := user.Status
beforeRisk := user.RiskStatus beforeRisk := user.RiskStatus
beforeTokenVersion := user.TokenVersion
user.Status = status user.Status = status
user.RiskStatus = riskStatus user.RiskStatus = riskStatus
if beforeStatus != status {
user.TokenVersion++
statusChanged = true
}
if err := tx.Save(&user).Error; err != nil { if err := tx.Save(&user).Error; err != nil {
return err return err
} }
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{ return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
"user_id": user.ID, "user_id": user.ID,
"reason": reason, "reason": reason,
"before_status": beforeStatus, "before_status": beforeStatus,
"after_status": status, "after_status": status,
"before_risk_status": beforeRisk, "before_risk_status": beforeRisk,
"after_risk_status": riskStatus, "after_risk_status": riskStatus,
"before_token_version": beforeTokenVersion,
"after_token_version": user.TokenVersion,
}) })
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
if statusChanged && r.statusChangeNotifier != nil {
r.statusChangeNotifier(userID)
}
return r.Find(ctx, userID) return r.Find(ctx, userID)
} }
@@ -293,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 { func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo} return &Service{repo: repo}
} }
// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。
func (s *Service) SetSessionRevoker(revoker func(userID uint64)) {
if s.repo != nil {
s.repo.SetStatusChangeNotifier(revoker)
}
}
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+5 -1
View File
@@ -131,8 +131,12 @@ func (h *Handler) Refresh(c *gin.Context) {
response.BadRequest(c, "refresh_token 不能为空") response.BadRequest(c, "refresh_token 不能为空")
return return
} }
tokens, err := h.service.RefreshToken(req.RefreshToken) tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil { if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
return
}
response.Unauthorized(c, "刷新令牌无效或已过期") response.Unauthorized(c, "刷新令牌无效或已过期")
return return
} }
@@ -27,6 +27,26 @@ func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User,
return &user, nil return &user, nil
} }
// FindActiveForToken 校验用户仍可用且令牌版本未被撤销。
func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.User, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
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" {
return nil, ErrUserDisabled
}
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) { func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{ if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
"nickname": nickname, "nickname": nickname,
+20 -5
View File
@@ -33,6 +33,7 @@ var (
ErrPasswordTooWeak = errors.New("password too weak") ErrPasswordTooWeak = errors.New("password too weak")
ErrLoginLocked = errors.New("login locked") ErrLoginLocked = errors.New("login locked")
ErrUserAlreadyExists = errors.New("user already exists") ErrUserAlreadyExists = errors.New("user already exists")
ErrTokenVersionMismatch = errors.New("user token version mismatch")
) )
const ( const (
@@ -180,7 +181,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{}, ErrUserDisabled return LoginResult{}, ErrUserDisabled
} }
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) tokens, err := s.generateUserTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
@@ -189,12 +190,26 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{User: user, Tokens: tokens}, nil return LoginResult{User: user, Tokens: tokens}, nil
} }
func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) { func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
if s.jwt == nil || s.users == nil {
return TokenPair{}, ErrDependencyUnavailable
}
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
if err != nil { if err != nil {
return TokenPair{}, err return TokenPair{}, err
} }
return s.jwt.GeneratePair(claims.UserID, claims.Phone) user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
if err != nil {
return TokenPair{}, err
}
return s.generateUserTokenPair(user)
}
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) {
if s.jwt == nil {
return TokenPair{}, ErrDependencyUnavailable
}
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
} }
func codeKey(phone string) string { func codeKey(phone string) string {
@@ -264,7 +279,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
_ = clearLoginFailure(ctx, s.redis, phone, clientIP) _ = clearLoginFailure(ctx, s.redis, phone, clientIP)
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) tokens, err := s.generateUserTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
@@ -369,7 +384,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
return LoginResult{}, ErrUserDisabled return LoginResult{}, ErrUserDisabled
} }
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) tokens, err := s.generateUserTokenPair(user)
if err != nil { if err != nil {
return LoginResult{}, err 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 { if err != nil {
return nil, err return nil, err
} }
var state model.ChatAdminConversationState
if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var unreadCount int64
if err := db.Table("chat_messages AS cm").
Where("cm.conversation_id = ?", conversation.ID).
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
Where("cm.id > ?", state.LastReadMessageID).
Count(&unreadCount).Error; err != nil {
return nil, err
}
dto := ConversationDTO{ dto := ConversationDTO{
ID: conversation.ID, ID: conversation.ID,
OrderID: conversation.OrderID, OrderID: conversation.OrderID,
@@ -62,11 +74,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
Title: conversation.Title, Title: conversation.Title,
Status: conversation.Status, Status: conversation.Status,
Role: "admin", // 管理员角色 Role: "admin", // 管理员角色
AdminRemark: state.Remark,
Participants: participants, Participants: participants,
LastMessageID: conversation.LastMessageID, LastMessageID: conversation.LastMessageID,
LastMessagePreview: conversation.LastMessagePreview, LastMessagePreview: conversation.LastMessagePreview,
LastMessageAt: conversation.LastMessageAt, LastMessageAt: conversation.LastMessageAt,
UnreadCount: 0, // 管理员不计未读 UnreadCount: unreadCount,
CreatedAt: conversation.CreatedAt, CreatedAt: conversation.CreatedAt,
UpdatedAt: conversation.UpdatedAt, UpdatedAt: conversation.UpdatedAt,
} }
@@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
&model.RentalOrder{}, &model.RentalOrder{},
&model.ChatConversation{}, &model.ChatConversation{},
&model.ChatParticipant{}, &model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{}, &model.ChatMessage{},
); err != nil { ); err != nil {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
+16 -13
View File
@@ -21,6 +21,7 @@ type ConversationDTO struct {
Title string `json:"title"` Title string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
Role string `json:"role"` Role string `json:"role"`
AdminRemark string `json:"admin_remark,omitempty"`
Participants []ParticipantDTO `json:"participants,omitempty"` Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"` LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"` LastMessagePreview string `json:"last_message_preview"`
@@ -29,6 +30,7 @@ type ConversationDTO struct {
LastSenderID uint64 `json:"last_sender_id,omitempty"` LastSenderID uint64 `json:"last_sender_id,omitempty"`
LastSenderRole string `json:"last_sender_role,omitempty"` LastSenderRole string `json:"last_sender_role,omitempty"`
UnreadCount int64 `json:"unread_count"` UnreadCount int64 `json:"unread_count"`
NeedsReply bool `json:"needs_reply"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -53,19 +55,20 @@ type ParticipantDTO struct {
} }
type MessageDTO struct { type MessageDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"` ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"` SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"` SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"` SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"` SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"` SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"` IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"` IsRead bool `json:"is_read"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"` AttachmentURLS []string `json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"` AdminAttentionType string `json:"admin_attention_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
type UnreadCountDTO struct { type UnreadCountDTO struct {
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
response.OK(c, result) response.OK(c, result)
} }
func (h *Handler) AdminCounts(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
principal := Principal{Type: "admin", ID: adminID}
result, err := h.service.AdminConversationCounts(
c.Request.Context(),
principal,
c.DefaultQuery("filter", "all"),
c.DefaultQuery("stage", "all"),
c.Query("keyword"),
)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) AdminDetail(c *gin.Context) { func (h *Handler) AdminDetail(c *gin.Context) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {
@@ -61,6 +82,19 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
h.markRead(c, Principal{Type: "admin", ID: adminID}) h.markRead(c, Principal{Type: "admin", ID: adminID})
} }
func (h *Handler) AdminMarkAllRead(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
if err := h.service.MarkAllAdminConversationsRead(c.Request.Context(), adminID); err != nil {
writeChatError(c, err)
return
}
response.OK(c, gin.H{"read": true})
}
func (h *Handler) AdminTransfer(c *gin.Context) { func (h *Handler) AdminTransfer(c *gin.Context) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {
@@ -128,6 +128,7 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
return return
} }
if errors.Is(err, ErrQrCodeOCRUnavailable) { if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return return
} }
@@ -164,6 +165,7 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
return return
} }
if errors.Is(err, ErrQrCodeOCRUnavailable) { if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return return
} }
+12 -7
View File
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
if handoffSupportID > 0 { if handoffSupportID > 0 {
message += ",卖号组客服已接入" message += ",卖号组客服已接入"
} }
return sendSystemMessage(tx, conv.ID, message) return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
} }
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 // RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
@@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
} }
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
return sendSystemMessageWithAttention(tx, conversationID, content, "")
}
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
message := model.ChatMessage{ message := model.ChatMessage{
ConversationID: conversationID, ConversationID: conversationID,
SenderType: "system", SenderType: "system",
SenderRole: "system", SenderRole: "system",
ContentType: "system", ContentType: "system",
Content: content, Content: content,
AttachmentURLS: emptyJSONList(), AttachmentURLS: emptyJSONList(),
AdminAttentionType: attentionType,
} }
if err := tx.Create(&message).Error; err != nil { if err := tx.Create(&message).Error; err != nil {
+87 -23
View File
@@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
var rows []model.ChatMessage var rows []model.ChatMessage
if err := query.Order("id ASC"). if err := query.Order("id DESC").
Offset(offset). Offset(offset).
Limit(pageSize). Limit(pageSize).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
items, err := r.toMessageDTOs(ctx, principal, rows) items, err := r.toMessageDTOs(ctx, principal, rows)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -101,12 +104,21 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Content: req.Content, Content: req.Content,
AttachmentURLS: encodeStringList(req.AttachmentURLS), AttachmentURLS: encodeStringList(req.AttachmentURLS),
} }
if principal.Type == "user" {
message.AdminAttentionType = "user_inquiry"
}
if err := tx.Create(&message).Error; err != nil { if err := tx.Create(&message).Error; err != nil {
return err return err
} }
conversation.LastMessageID = &message.ID conversation.LastMessageID = &message.ID
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
conversation.LastMessageAt = &message.CreatedAt conversation.LastMessageAt = &message.CreatedAt
if message.AdminAttentionType != "" || message.SenderType == "user" {
conversation.LastAttentionMessageID = message.ID
}
if message.SenderType == "admin" {
conversation.LastAdminMessageID = message.ID
}
if err := tx.Save(&conversation).Error; err != nil { if err := tx.Save(&conversation).Error; err != nil {
return err return err
} }
@@ -143,16 +155,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Type: "new_message", Type: "new_message",
ConversationID: conversationID, ConversationID: conversationID,
Message: &chathub.MessageData{ Message: &chathub.MessageData{
ID: msg.ID, ID: msg.ID,
ConversationID: msg.ConversationID, ConversationID: msg.ConversationID,
SenderType: msg.SenderType, SenderType: msg.SenderType,
SenderID: msg.SenderID, SenderID: msg.SenderID,
SenderRole: msg.SenderRole, SenderRole: msg.SenderRole,
SenderName: msg.SenderName, SenderName: msg.SenderName,
ContentType: msg.ContentType, ContentType: msg.ContentType,
Content: msg.Content, Content: msg.Content,
AttachmentURLS: msg.AttachmentURLS, AttachmentURLS: msg.AttachmentURLS,
CreatedAt: msg.CreatedAt.Format(time.RFC3339), AdminAttentionType: msg.AdminAttentionType,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
}, },
} }
r.hub.NotifyConversation(conversationID, event) r.hub.NotifyConversation(conversationID, event)
@@ -166,21 +179,44 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
now := time.Now() now := time.Now()
updated := false updated := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 管理员可以不是 participant,直接返回成功
if principal.Type == "admin" { if principal.Type == "admin" {
// 尝试查找 participant 记录,如果有就更新 var conversation model.ChatConversation
var participant model.ChatParticipant if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil {
err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", if errors.Is(err, gorm.ErrRecordNotFound) {
conversationID, principal.Type, principal.ID).First(&participant).Error return ErrConversationNotFound
if err == nil { }
// 有 participant 记录,更新已读时间 return err
updated = true }
return tx.Model(&participant).Update("last_read_at", now).Error lastMessageID := uint64(0)
} else if errors.Is(err, gorm.ErrRecordNotFound) { if conversation.LastMessageID != nil {
// 没有 participant 记录,直接返回成功(管理员无需记录已读) 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 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 // 普通用户必须是 participant
@@ -209,3 +245,31 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
} }
return nil return nil
} }
// MarkAllAdminConversationsRead advances one admin's read cursor for every conversation in one transaction.
func (r *Repository) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
now := time.Now()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先补齐状态行,再更新游标。拆成两步可兼容本地 MySQL/MariaDB 对
// INSERT ... SELECT 同表读取并 ON DUPLICATE KEY UPDATE 的限制。
if err := tx.Exec(`
INSERT INTO chat_admin_conversation_states
(conversation_id, admin_user_id, remark, last_read_message_id, last_read_at)
SELECT c.id, ?, COALESCE(existing.remark, ''), COALESCE(c.last_message_id, 0), ?
FROM chat_conversations AS c
LEFT JOIN chat_admin_conversation_states AS existing
ON existing.conversation_id = c.id AND existing.admin_user_id = ?
WHERE existing.id IS NULL`, adminID, now, adminID).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE chat_admin_conversation_states AS cas
JOIN chat_conversations AS c ON c.id = cas.conversation_id
SET cas.last_read_message_id = c.last_message_id,
cas.last_read_at = ?,
cas.updated_at = CURRENT_TIMESTAMP
WHERE cas.admin_user_id = ?
AND c.last_message_id IS NOT NULL
AND cas.last_read_message_id < c.last_message_id`, now, adminID).Error
})
}
+33 -11
View File
@@ -25,6 +25,7 @@ type conversationRow struct {
Title string Title string
Status string Status string
Role string Role string
AdminRemark string
LastMessageID *uint64 LastMessageID *uint64
LastMessagePreview string LastMessagePreview string
LastMessageAt *time.Time LastMessageAt *time.Time
@@ -32,6 +33,7 @@ type conversationRow struct {
LastSenderID uint64 LastSenderID uint64
LastSenderRole string LastSenderRole string
UnreadCount int64 UnreadCount int64
NeedsReply int64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -44,8 +46,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
SELECT COUNT(1) SELECT COUNT(1)
FROM chat_messages AS cm FROM chat_messages AS cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?) AND cm.sender_type <> 'system'
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) 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). ) AS unread_count`, principal.Type, principal.ID).
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
@@ -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("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
Where("c.status = ?", "active"). Where("c.status = ?", "active").
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID). Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID).
Where("cm.sender_type <> ?", "system").
Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)"). Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
Count(&total).Error Count(&total).Error
return total, err return total, err
@@ -81,15 +85,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat
return &participant, nil return &participant, nil
} }
func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) {
grouped, err := r.participantsForConversations(ctx, []uint64{conversationID})
if err != nil {
return nil, err
}
return grouped[conversationID], nil
}
func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) {
result := make(map[uint64][]ParticipantDTO, len(conversationIDs))
if len(conversationIDs) == 0 {
return result, nil
}
var rows []model.ChatParticipant var rows []model.ChatParticipant
if err := r.db.WithContext(ctx).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 return nil, err
} }
userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := make([]ParticipantDTO, 0, len(rows))
for _, row := range rows { for _, row := range rows {
name := "系统" name := "系统"
avatar := "" avatar := ""
@@ -100,19 +118,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([
if row.ParticipantType == "admin" { if row.ParticipantType == "admin" {
name = adminNames[row.ParticipantID] name = adminNames[row.ParticipantID]
} }
items = append(items, ParticipantDTO{ result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{
ID: row.ID, ID: row.ID,
ConversationID: row.ConversationID, ConversationID: row.ConversationID,
ParticipantType: row.ParticipantType, ParticipantType: row.ParticipantType,
ParticipantID: row.ParticipantID, ParticipantID: row.ParticipantID,
Role: row.Role, Role: row.Role,
Remark: row.Remark,
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
AvatarURL: avatar, AvatarURL: avatar,
LastReadAt: row.LastReadAt, LastReadAt: row.LastReadAt,
JoinedAt: row.JoinedAt, JoinedAt: row.JoinedAt,
}) })
} }
return items, nil return result, nil
} }
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
userIDs := make([]uint64, 0) userIDs := make([]uint64, 0)
@@ -166,11 +185,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
SenderAvatar: avatar, SenderAvatar: avatar,
IsSelf: isSelf, IsSelf: isSelf,
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType, ContentType: row.ContentType,
Content: row.Content, Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS), AttachmentURLS: decodeStringList(row.AttachmentURLS),
CreatedAt: row.CreatedAt, AdminAttentionType: row.AdminAttentionType,
CreatedAt: row.CreatedAt,
}) })
} }
return items, nil return items, nil
@@ -305,6 +325,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
Title: row.Title, Title: row.Title,
Status: row.Status, Status: row.Status,
Role: row.Role, Role: row.Role,
AdminRemark: row.AdminRemark,
Participants: participants, Participants: participants,
LastMessageID: row.LastMessageID, LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview, LastMessagePreview: row.LastMessagePreview,
@@ -313,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
LastSenderID: row.LastSenderID, LastSenderID: row.LastSenderID,
LastSenderRole: row.LastSenderRole, LastSenderRole: row.LastSenderRole,
UnreadCount: row.UnreadCount, UnreadCount: row.UnreadCount,
NeedsReply: row.NeedsReply > 0,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
} }
+452 -79
View File
@@ -6,21 +6,46 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io" "io"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/textproto" "net/textproto"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
"hfb_sys/backend/internal/logging"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
) )
const ( const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024 maxQrCodeOCRFileSize = 10 * 1024 * 1024
maxQrCodeOCRPixels = 40_000_000
maxQrCodeOCRSide = 2560
qrCodeOCRJPEGQuality = 92
ocrRequestConcurrency = 2
ocrRequestMinInterval = 250 * time.Millisecond
ocrRequestMaxAttempts = 3
ocrRetryBaseDelay = 500 * time.Millisecond
ocrResponseBodyLimit = 2 * 1024 * 1024
ocrResultBodyLimit = 10 * 1024 * 1024
ocrLogBodyLimit = 2048
ocrJobRedisKeyPrefix = "ocr:job:" ocrJobRedisKeyPrefix = "ocr:job:"
ocrJobRedisTTL = 10 * time.Minute ocrJobRedisTTL = 10 * time.Minute
@@ -38,9 +63,87 @@ var (
ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期") ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期")
ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis") ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis")
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
sharedOCRRequestGate = newOCRRequestGate(ocrRequestConcurrency, ocrRequestMinInterval)
) )
type ocrRequestGate struct {
slots chan struct{}
mu sync.Mutex
nextAllowed time.Time
minInterval time.Duration
}
func newOCRRequestGate(concurrency int, minInterval time.Duration) *ocrRequestGate {
if concurrency < 1 {
concurrency = 1
}
return &ocrRequestGate{
slots: make(chan struct{}, concurrency),
minInterval: max(0, minInterval),
}
}
func (g *ocrRequestGate) acquire(ctx context.Context, sleep func(context.Context, time.Duration) error) (func(), error) {
select {
case g.slots <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
release := func() { <-g.slots }
g.mu.Lock()
now := time.Now()
wait := max(time.Duration(0), g.nextAllowed.Sub(now))
startAt := now.Add(wait)
g.nextAllowed = startAt.Add(g.minInterval)
g.mu.Unlock()
if wait > 0 {
if err := sleep(ctx, wait); err != nil {
release()
return nil, err
}
}
return release, nil
}
type ocrUpstreamError struct {
Operation string
StatusCode int
Code string
Message string
TraceID string
RetryAfter time.Duration
BodySnippet string
Cause error
Retriable bool
}
func (e *ocrUpstreamError) Error() string {
parts := []string{ErrQrCodeOCRUnavailable.Error(), "operation=" + e.Operation}
if e.StatusCode != 0 {
parts = append(parts, "status="+strconv.Itoa(e.StatusCode))
}
if e.Code != "" {
parts = append(parts, "code="+e.Code)
}
if e.Message != "" {
parts = append(parts, "message="+e.Message)
}
if e.TraceID != "" {
parts = append(parts, "trace_id="+e.TraceID)
}
if e.Cause != nil {
parts = append(parts, "cause="+e.Cause.Error())
}
return strings.Join(parts, " ")
}
func (e *ocrUpstreamError) Unwrap() error {
return ErrQrCodeOCRUnavailable
}
type QrCodeOCRResult struct { type QrCodeOCRResult struct {
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
Candidates []string `json:"candidates"` Candidates []string `json:"candidates"`
@@ -79,13 +182,46 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize { if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile return nil, "", ErrQrCodeOCRInvalidFile
} }
if contentType == "" { contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
contentType = http.DetectContentType(data) if contentType == "" || !strings.HasPrefix(contentType, "image/") {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
} }
if !strings.HasPrefix(contentType, "image/") { if !strings.HasPrefix(contentType, "image/") {
return nil, "", ErrQrCodeOCRInvalidFile return nil, "", ErrQrCodeOCRInvalidFile
} }
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 { func ocrJobKey(jobID string) string {
@@ -132,15 +268,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
return nil, err return nil, err
} }
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID) jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rawText, err := fetchPaddleOCRText(ctx, jsonURL) rawText, err := r.fetchPaddleOCRText(ctx, jsonURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -173,7 +309,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str
return "", err return "", err
} }
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -206,47 +342,48 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} }
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID 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 { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) // 保留当前状态,让下一次前端轮询继续尝试。
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := ocrHTTPClient.Do(req)
if err != nil {
// 网络错误不更新 Redis,保留当前状态让前端重试
return record, nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return record, nil return record, nil
} }
var statusPayload paddleOCRJobStatusResponse var statusPayload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &statusPayload); err != nil { if err := json.Unmarshal(respBody, &statusPayload); err != nil {
upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return record, nil return record, nil
} }
switch statusPayload.Data.State { switch statusPayload.Data.State {
case "pending", "running": case "pending", "running":
record.Status = ocrJobStatusRunning record.Status = ocrJobStatusRunning
_ = r.saveOCRJobRecord(ctx, jobID, record) r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil return record, nil
case "done": case "done":
if statusPayload.Data.ResultURL.JSONURL == "" { if statusPayload.Data.ResultURL.JSONURL == "" {
record.Status = ocrJobStatusFailed record.Status = ocrJobStatusFailed
record.Error = "未返回识别结果地址" record.Error = "未返回识别结果地址"
_ = r.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 return record, nil
} }
rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
if err != nil { if err != nil {
record.Status = ocrJobStatusFailed record.Status = ocrJobStatusFailed
record.Error = err.Error() record.Error = err.Error()
_ = r.saveOCRJobRecord(ctx, jobID, record) r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil return record, nil
} }
candidates := parseQrCodeGroupNameCandidates(rawText) candidates := parseQrCodeGroupNameCandidates(rawText)
@@ -260,7 +397,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
Candidates: candidates, Candidates: candidates,
RawText: rawText, RawText: rawText,
} }
_ = r.saveOCRJobRecord(ctx, jobID, record) r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil return record, nil
case "failed": case "failed":
@@ -270,14 +407,36 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} else { } else {
record.Error = "PaddleOCR 识别失败" 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 return record, nil
default:
upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state"))
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
} }
return record, nil return record, nil
} }
func 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{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
_ = writer.WriteField("model", config.Model) _ = writer.WriteField("model", config.Model)
@@ -301,51 +460,69 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename,
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) 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 { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", err
}
req.Header.Set("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
} }
var payload paddleOCRJobResponse var payload paddleOCRJobResponse
if err := json.Unmarshal(respBody, &payload); err != nil { if err := json.Unmarshal(respBody, &payload); err != nil {
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 == "" { 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 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++ { 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 { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", err
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
} }
var payload paddleOCRJobStatusResponse var payload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &payload); err != nil { if err := json.Unmarshal(respBody, &payload); err != nil {
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 { switch payload.Data.State {
case "done": case "done":
@@ -359,36 +536,232 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string
} }
return "", ErrQrCodeOCRUnavailable return "", ErrQrCodeOCRUnavailable
} }
select { if err := r.ocrSleep(ctx, 2*time.Second); err != nil {
case <-ctx.Done(): return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(2 * time.Second):
} }
} }
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
} }
func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil) body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
})
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", err
}
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
return collectPaddleOCRJSONLText(string(body)), nil return collectPaddleOCRJSONLText(string(body)), nil
} }
type ocrRequestFactory func(context.Context) (*http.Request, error)
func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) {
var lastErr *ocrUpstreamError
for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ {
release, err := r.ocrGate.acquire(ctx, r.ocrSleep)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := factory(ctx)
if err != nil {
release()
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
startedAt := time.Now()
resp, requestErr := r.ocrHTTPClient.Do(req)
duration := time.Since(startedAt)
if requestErr != nil {
release()
lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr)
lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr)
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
} else {
bodyLimit := int64(ocrResponseBodyLimit)
if operation == "result" {
bodyLimit = ocrResultBodyLimit
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
_ = resp.Body.Close()
release()
if int64(len(body)) > bodyLimit {
body = body[:bodyLimit]
readErr = errors.New("upstream response exceeded limit")
}
if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return body, nil
}
lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr)
lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
}
if !lastErr.Retriable || attempt == ocrRequestMaxAttempts {
return nil, lastErr
}
delay := ocrRetryDelay(attempt, lastErr.RetryAfter)
if err := r.ocrSleep(ctx, delay); err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
}
return nil, lastErr
}
func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError {
upstreamErr := &ocrUpstreamError{
Operation: operation,
StatusCode: statusCode,
BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit),
Cause: cause,
}
if headers != nil {
upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now())
upstreamErr.TraceID = firstNonEmpty(
headers.Get("X-Request-ID"),
headers.Get("X-Trace-ID"),
headers.Get("Trace-ID"),
)
}
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code")
upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message")
if upstreamErr.TraceID == "" {
upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id")
}
}
return upstreamErr
}
func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) {
if upstreamErr == nil {
return
}
fields := []zap.Field{
zap.String("operation", upstreamErr.Operation),
zap.Int("attempt", attempt),
zap.Bool("final", final),
zap.Bool("retriable", upstreamErr.Retriable),
zap.Int("upstream_status", upstreamErr.StatusCode),
zap.String("upstream_code", upstreamErr.Code),
zap.String("upstream_message", upstreamErr.Message),
zap.String("upstream_trace_id", upstreamErr.TraceID),
zap.String("upstream_body", upstreamErr.BodySnippet),
zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
}
if upstreamErr.Cause != nil {
fields = append(fields, zap.Error(upstreamErr.Cause))
}
if final {
r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...)
} else {
r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...)
}
}
func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger {
logger := r.logger
if logger == nil {
logger = zap.NewNop()
}
fields := []zap.Field{zap.String("module", "chat_ocr")}
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := logging.AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
return logger.With(fields...)
}
func isRetriableOCRStatus(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func isRetriableOCRNetworkError(ctx context.Context, err error) bool {
return err != nil && ctx.Err() == nil
}
func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return min(retryAfter, 10*time.Second)
}
delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1))
// Deterministic jitter avoids synchronized retries without relying on global randomness.
jitter := time.Duration((attempt*137)%250) * time.Millisecond
return delay + jitter
}
func parseOCRRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
return max(0, time.Duration(seconds)*time.Second)
}
if when, err := http.ParseTime(value); err == nil {
return max(0, when.Sub(now))
}
return 0
}
func sleepWithContext(ctx context.Context, duration time.Duration) error {
if duration <= 0 {
return nil
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func firstJSONText(payload map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := payload[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return truncateOCRLogValue(text, 512)
}
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return truncateOCRLogValue(value, 256)
}
}
return ""
}
func truncateOCRLogValue(value string, limit int) string {
value = strings.Join(strings.Fields(value), " ")
if len(value) <= limit {
return value
}
return value[:limit] + "..."
}
func collectPaddleOCRJSONLText(raw string) string { func collectPaddleOCRJSONLText(raw string) string {
var texts []string var texts []string
for _, line := range strings.Split(raw, "\n") { for _, line := range strings.Split(raw, "\n") {
@@ -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 ( import (
"context" "context"
"errors"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const ( const (
@@ -13,9 +18,26 @@ const (
) )
func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error {
return r.db.WithContext(ctx).Model(&model.ChatParticipant{}). if principal.Type != "admin" {
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). return ErrPermissionDenied
Update("remark", remark).Error }
db := r.db.WithContext(ctx)
var conversation model.ChatConversation
if err := db.Select("id").First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
state := model.ChatAdminConversationState{
ConversationID: conversationID,
AdminUserID: principal.ID,
Remark: strings.TrimSpace(remark),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}),
}).Create(&state).Error
} }
func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) {
var replies []model.ChatQuickReply var replies []model.ChatQuickReply
+60 -6
View File
@@ -1,27 +1,81 @@
package chat package chat
import ( import (
"context"
"errors" "errors"
"net/http"
"time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub" "hfb_sys/backend/internal/modules/chathub"
"time"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
hub *chathub.Hub hub *chathub.Hub
redis *redis.Client redis *redis.Client
logger *zap.Logger
ocrHTTPClient *http.Client
ocrGate *ocrRequestGate
ocrSleep func(context.Context, time.Duration) error
} }
const ( const (
defaultSupportRoleCode = "cs" defaultSupportRoleCode = "cs"
) )
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository { type RepositoryOption func(*Repository)
return &Repository{db: db, hub: hub, redis: redis}
func WithLogger(logger *zap.Logger) RepositoryOption {
return func(repo *Repository) {
if logger != nil {
repo.logger = logger
}
}
}
func withOCRHTTPClient(client *http.Client) RepositoryOption {
return func(repo *Repository) {
if client != nil {
repo.ocrHTTPClient = client
}
}
}
func withOCRGate(gate *ocrRequestGate) RepositoryOption {
return func(repo *Repository) {
if gate != nil {
repo.ocrGate = gate
}
}
}
func withOCRSleep(sleep func(context.Context, time.Duration) error) RepositoryOption {
return func(repo *Repository) {
if sleep != nil {
repo.ocrSleep = sleep
}
}
}
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client, options ...RepositoryOption) *Repository {
repo := &Repository{
db: db,
hub: hub,
redis: redis,
logger: zap.NewNop(),
ocrHTTPClient: ocrHTTPClient,
ocrGate: sharedOCRRequestGate,
ocrSleep: sleepWithContext,
}
for _, option := range options {
option(repo)
}
return repo
} }
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation var existing model.ChatConversation
+21
View File
@@ -131,6 +131,16 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio
return s.repo.MarkRead(ctx, principal, conversationID) return s.repo.MarkRead(ctx, principal, conversationID)
} }
func (s *Service) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if adminID == 0 {
return ErrPermissionDenied
}
return s.repo.MarkAllAdminConversationsRead(ctx, adminID)
}
func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error { func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -155,10 +165,21 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword) return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
} }
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword)
}
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error { func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
} }
req.Remark = strings.TrimSpace(req.Remark)
if len([]rune(req.Remark)) > 128 {
return ErrInvalidMessage
}
return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark) return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark)
} }
+179 -104
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"strings" "strings"
"time" "time"
) )
@@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct {
} }
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error { func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 验证当前操作者是会话参与者 if principal.Type != "admin" {
current, err := r.findParticipant(tx, principal, conversationID, false)
if err != nil {
return err
}
if current.Role != "support" || current.ParticipantType != "admin" {
return ErrPermissionDenied 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) { if !adminIsSupport(tx, toAdminID) {
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
@@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
if count > 0 { if count > 0 {
return fmt.Errorf("该客服已在会话中") return fmt.Errorf("该客服已在会话中")
} }
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。 var current model.ChatParticipant
if err := tx.Model(&model.ChatParticipant{}). currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID).
Where("id = ?", current.ID). First(&current).Error
Updates(map[string]interface{}{ if currentErr == nil {
if current.Role != "support" {
return ErrPermissionDenied
}
// 只转接当前客服本人,避免误删同群里的其他客服。
if err := tx.Model(&current).Updates(map[string]interface{}{
"participant_id": toAdminID, "participant_id": toAdminID,
"joined_at": time.Now(), "joined_at": time.Now(),
"last_read_at": nil,
}).Error; err != nil { }).Error; err != nil {
return err return err
}
} else if errors.Is(currentErr, gorm.ErrRecordNotFound) {
// 未分配会话允许从“全部/未分配”直接指派给目标客服。
if err := tx.Create(&model.ChatParticipant{
ConversationID: conversationID,
ParticipantType: "admin",
ParticipantID: toAdminID,
Role: "support",
JoinedAt: time.Now(),
}).Error; err != nil {
return err
}
} else {
return currentErr
} }
// 添加系统消息记录转接 if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil {
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "会话已转接给其他客服",
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
return err return err
} }
return nil return nil
}) })
if err == nil && r.hub != nil {
r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
}
return err
} }
func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -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) { func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
var total int64 total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
countDB := r.adminConversationBase(ctx, principal, keyword) if err != nil {
applyAdminChatOwnershipFilter(countDB, filter, principal)
applyAdminChatStageFilter(countDB, stage, principal)
if err := countDB.Count(&total).Error; err != nil {
return nil, err 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, Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
COALESCE(cp_me.role, 'admin') AS role, COALESCE(cp_me.role, 'admin') AS role,
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status, COALESCE(cas.remark, '') AS admin_remark,
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status, 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, 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) SELECT COUNT(1)
FROM chat_messages AS cm FROM chat_messages AS cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?) AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at) AND cm.id > COALESCE(cas.last_read_message_id, 0)
) END AS unread_count`, principal.Type, principal.ID) ) AS unread_count`)
applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatOwnershipFilter(queryDB, filter, principal)
applyAdminChatStageFilter(queryDB, stage, principal) applyAdminChatStageFilter(queryDB, stage, principal)
if err := queryDB. if err := queryDB.
@@ -236,33 +257,59 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
return nil, err return nil, err
} }
items := make([]ConversationDTO, 0, len(rows)) ids := make([]uint64, 0, len(rows))
for _, row := range rows { for _, row := range rows {
participants, err := r.participants(ctx, row.ID) ids = append(ids, row.ID)
if err != nil {
return nil, err
}
items = append(items, row.toDTO(participants))
} }
participantsByConversation, err := r.participantsForConversations(ctx, ids)
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
if err != nil { if err != nil {
return nil, err 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 { func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
db := r.db.WithContext(ctx).Table("chat_conversations AS c"). db := r.db.WithContext(ctx).Table("chat_conversations AS c").
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID). Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id"). Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
Joins("LEFT JOIN rental_orders AS 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_orders AS explicit_lo ON explicit_lo.id = c.order_id").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)"). Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
if keyword != "" { if keyword != "" {
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
like := "%" + keyword + "%" like := "%" + keyword + "%"
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR 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 renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw SELECT 1 FROM chat_participants AS cp_kw
@@ -272,32 +319,54 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
return db return db
} }
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { type adminConversationCountsResult struct {
counts := &AdminConversationCountsDTO{ Total int64
Ownership: make(map[string]int64, len(adminChatFilters)), DTO *AdminConversationCountsDTO
Stages: make(map[string]int64, len(adminChatStages)), }
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) {
ownership := make(map[string]string, len(adminChatFilters))
ownershipArgs := make(map[string][]interface{}, len(adminChatFilters))
for _, item := range adminChatFilters { for _, item := range adminChatFilters {
db := r.adminConversationBase(ctx, principal, keyword) ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal)
applyAdminChatOwnershipFilter(db, item, principal) }
applyAdminChatStageFilter(db, stage, principal) stages := make(map[string]string, len(adminChatStages))
var count int64 stageArgs := make(map[string][]interface{}, len(adminChatStages))
if err := db.Count(&count).Error; err != nil { for _, item := range adminChatStages {
return nil, err stages[item], stageArgs[item] = adminChatStageCondition(item, principal)
} }
counts.Ownership[item] = count
parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages))
args := make([]interface{}, 0)
addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) {
parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias)
args = append(args, leftArgs...)
args = append(args, rightArgs...)
}
addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage])
for _, item := range adminChatFilters {
addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage])
} }
for _, item := range adminChatStages { for _, item := range adminChatStages {
db := r.adminConversationBase(ctx, principal, keyword) addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item])
applyAdminChatOwnershipFilter(db, filter, principal)
applyAdminChatStageFilter(db, item, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Stages[item] = count
} }
return counts, nil
type row struct {
Total int64
OwnershipMine, OwnershipAll, OwnershipUnassigned int64
StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64
}
var result row
if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil {
return nil, err
}
return &adminConversationCountsResult{
Total: result.Total,
DTO: &AdminConversationCountsDTO{
Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned},
Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded},
},
}, nil
} }
func normalizeAdminChatFilter(filter string) string { func normalizeAdminChatFilter(filter string) string {
@@ -319,55 +388,61 @@ func normalizeAdminChatStage(stage string) string {
} }
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) { func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
switch filter { condition, args := adminChatOwnershipCondition(filter, principal)
case adminChatFilterMine: db.Where(condition, args...)
db.Where("cp_me.id IS NOT NULL")
case adminChatFilterUnassigned:
db.Where(`NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = ?
AND cp_support.role = ?
)`, "admin", "support")
case adminChatFilterAll:
return
default:
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
}
} }
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
condition, args := adminChatStageCondition(stage, principal)
db.Where(condition, args...)
}
func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) {
switch filter {
case adminChatFilterAll:
return "1 = 1", nil
case adminChatFilterUnassigned:
return `NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = 'admin'
AND cp_support.role = 'support'
)`, nil
case adminChatFilterMine:
fallthrough
default:
return "cp_me.id IS NOT NULL", nil
}
}
func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) {
latestID := "COALESCE(explicit_lo.id, c.latest_order_id)"
latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)"
latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)"
switch stage { switch stage {
case adminChatStagePending: case adminChatStagePending:
db.Where(`( return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil
lm.sender_type = ?
OR (
cp_me.id IS NOT NULL
AND EXISTS (
SELECT 1
FROM chat_messages AS cm_pending
WHERE cm_pending.conversation_id = c.id
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
)
)
)`, "user", principal.Type, principal.ID)
case adminChatStageUnjoined: case adminChatStageUnjoined:
db.Where("lo.id IS NULL") return latestID + " IS NULL", nil
case adminChatStageHandoff: case adminChatStageHandoff:
db.Where("lo.status = ?", "pending_handoff") return latestStatus + " = 'pending_handoff'", nil
case adminChatStageRenting: case adminChatStageRenting:
db.Where("lo.status IN ?", []string{"renting", "overdue"}) return latestStatus + " IN ('renting', 'overdue')", nil
case adminChatStageAfterSale: case adminChatStageAfterSale:
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))", return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
case adminChatStageEnded: 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: case adminChatStageAll:
return fallthrough
default:
return "1 = 1", nil
} }
} }
func adminNeedsReplyExpression() string {
return "c.last_attention_message_id > c.last_admin_message_id"
}
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 // 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 // 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
+70 -7
View File
@@ -1,6 +1,7 @@
package chathub package chathub
import ( import (
"context"
"fmt" "fmt"
"time" "time"
@@ -8,16 +9,21 @@ import (
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
const heartbeatInterval = 30 * time.Second const heartbeatInterval = 30 * time.Second
type Handler struct { type Handler struct {
hub *Hub hub *Hub
logger *zap.Logger
} }
func NewHandler(hub *Hub) *Handler { func NewHandler(hub *Hub, logger *zap.Logger) *Handler {
return &Handler{hub: hub} if logger == nil {
logger = zap.NewNop()
}
return &Handler{hub: hub, logger: logger}
} }
// UserEvents 处理用户端 SSE 连接: GET /api/chats/events // 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) { func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
startedAt := time.Now()
disconnectReason := "handler_completed"
var disconnectErr error
ch := h.hub.Subscribe(pType, pID) ch := h.hub.Subscribe(pType, pID)
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("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") 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") 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() c.Writer.Flush()
heartbeat := time.NewTicker(heartbeatInterval) heartbeat := time.NewTicker(heartbeatInterval)
@@ -71,17 +96,55 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) {
for { for {
select { select {
case <-clientGone: case <-clientGone:
disconnectReason = contextDisconnectReason(c.Request.Context().Err())
return return
case <-heartbeat.C: case <-heartbeat.C:
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() c.Writer.Flush()
case event, ok := <-ch: case event, ok := <-ch:
if !ok { if !ok {
disconnectReason = "subscription_closed"
return return
} }
data := MarshalEvent(event) data := MarshalEvent(event)
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() 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 对齐。 // MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
type MessageData struct { type MessageData struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"` ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"` SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"` SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"` SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"` SenderName string `json:"sender_name"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Content string `json:"content"` Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"` AttachmentURLS []string `json:"attachment_urls"`
CreatedAt string `json:"created_at"` AdminAttentionType string `json:"admin_attention_type,omitempty"`
CreatedAt string `json:"created_at"`
} }
// principal 标识一个连接方。 // principal 标识一个连接方。
@@ -86,6 +87,22 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) {
h.mu.Unlock() h.mu.Unlock()
} }
// DisconnectUser 关闭指定用户当前进程内的全部实时连接。
func (h *Hub) DisconnectUser(userID uint64) {
h.disconnect("user", userID)
}
func (h *Hub) disconnect(pType string, pID uint64) {
key := principal{Type: pType, ID: pID}
h.mu.Lock()
clients := h.clients[key]
delete(h.clients, key)
for ch := range clients {
close(ch)
}
h.mu.Unlock()
}
// NotifyConversation 查询会话参与者,向所有在线参与者推送事件。 // NotifyConversation 查询会话参与者,向所有在线参与者推送事件。
func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) { func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) {
if h.db == nil { if h.db == nil {
@@ -174,3 +191,16 @@ func (h *Hub) OnlineCount() int {
} }
return count return count
} }
// OnlineCountByType 返回当前进程内指定主体类型的 SSE 连接数。
func (h *Hub) OnlineCountByType(pType string) int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for key, clients := range h.clients {
if key.Type == pType {
count += len(clients)
}
}
return count
}
@@ -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, "order_no": order.OrderNo,
"result": req.Result, "result": req.Result,
"remark": req.Remark, "remark": req.Remark,
"input_amount_cent": req.AmountCent, "input_amount_cent": arbitrationInputAmountCent(req),
"renter_refund_amount_cent": settlement.RenterRefundAmountCent, "renter_refund_amount_cent": settlement.RenterRefundAmountCent,
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
@@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or
if err != nil { if err != nil {
return settlement, nil, linkedCheckout, 0, err return settlement, nil, linkedCheckout, 0, err
} }
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req) depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req)
if err != nil { if err != nil {
return settlement, nil, linkedCheckout, 0, err return settlement, nil, linkedCheckout, 0, err
} }
@@ -299,21 +299,24 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or
return 0, ErrInvalidDispute 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 { switch req.Result {
case "actual_settlement": case "actual_settlement":
if req.AmountCent <= 0 { if req.AmountCent == nil {
if checkout != nil {
return arbitrationCheckoutDepositDeductCent(*checkout), nil
}
return 0, nil return 0, nil
} }
if req.AmountCent > order.DepositAmountCent { if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent {
return 0, ErrInvalidDispute return 0, ErrInvalidDispute
} }
return req.AmountCent, nil return *req.AmountCent, nil
case "release_deposit": case "release_deposit":
return 0, nil return 0, nil
case "deduct_deposit", "compensate_owner": case "deduct_deposit", "compensate_owner":
deductAmountCent := req.AmountCent deductAmountCent := arbitrationInputAmountCent(req)
if deductAmountCent <= 0 { if req.AmountCent == nil || deductAmountCent <= 0 {
deductAmountCent = order.DepositAmountCent deductAmountCent = order.DepositAmountCent
} }
if 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) { func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) {
order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
@@ -350,7 +370,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
} }
func isPlatformManagedOrder(order model.RentalOrder) bool { func isPlatformManagedOrder(order model.RentalOrder) bool {
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform" return order.SettlementMode == "platform_managed"
} }
func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error { func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
@@ -459,15 +479,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(totalCent, "仲裁全额退款") addRenterRefund(totalCent, "仲裁全额退款")
settlement.RenterDepositRefundCent = depositAmountCent settlement.RenterDepositRefundCent = depositAmountCent
case "partial_refund": case "partial_refund":
if req.AmountCent <= 0 || req.AmountCent > totalCent { if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent {
return settlement, ErrInvalidDispute return settlement, ErrInvalidDispute
} }
addRenterRefund(req.AmountCent, "仲裁部分退款") addRenterRefund(*req.AmountCent, "仲裁部分退款")
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent) settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent)
// 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。 // 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。
addOwnerIncome( addOwnerIncome(
partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent), partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent),
"仲裁剩余金额结算给号主", "仲裁剩余金额结算给号主",
) )
case "release_deposit": case "release_deposit":
@@ -475,8 +495,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
addRenterRefund(depositAmountCent, "仲裁释放押金给租客") addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
settlement.RenterDepositRefundCent = depositAmountCent settlement.RenterDepositRefundCent = depositAmountCent
case "deduct_deposit", "compensate_owner": case "deduct_deposit", "compensate_owner":
deductAmountCent := req.AmountCent deductAmountCent := arbitrationInputAmountCent(req)
if deductAmountCent <= 0 { if req.AmountCent == nil || deductAmountCent <= 0 {
deductAmountCent = depositAmountCent deductAmountCent = depositAmountCent
} }
if deductAmountCent > depositAmountCent { if deductAmountCent > depositAmountCent {
+36 -35
View File
@@ -9,40 +9,41 @@ import (
) )
type DisputeDTO struct { type DisputeDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"` OrderStatus string `json:"order_status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"` SettlementStatus string `json:"settlement_status"`
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
ListingNo string `json:"listing_no"` ListingNo string `json:"listing_no"`
Title string `json:"title"` Title string `json:"title"`
OwnerID uint64 `json:"owner_id"` OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"` RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone"` OwnerPhone string `json:"owner_phone"`
RenterPhone string `json:"renter_phone"` RenterPhone string `json:"renter_phone"`
InitiatorID uint64 `json:"initiator_id"` InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"` InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
TargetUserID uint64 `json:"target_user_id"` TargetUserID uint64 `json:"target_user_id"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Description string `json:"description"` Description string `json:"description"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"` EvidenceURLS datatypes.JSON `json:"evidence_urls"`
PreviousOrderStatus string `json:"previous_order_status"` PreviousOrderStatus string `json:"previous_order_status"`
PreviousHandoffStatus string `json:"previous_handoff_status"` PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"` PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"` CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
PreviousCheckoutStatus string `json:"previous_checkout_status"` CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"`
ArbitrationResult string `json:"arbitration_result"` PreviousCheckoutStatus string `json:"previous_checkout_status"`
ArbitrationRemark string `json:"arbitration_remark"` ArbitrationResult string `json:"arbitration_result"`
HandledBy *uint64 `json:"handled_by"` ArbitrationRemark string `json:"arbitration_remark"`
HandledAt *time.Time `json:"handled_at"` HandledBy *uint64 `json:"handled_by"`
CreatedAt time.Time `json:"created_at"` HandledAt *time.Time `json:"handled_at"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type CreateRequest struct { type CreateRequest struct {
@@ -61,7 +62,7 @@ type AdminCreateRequest struct {
type ArbitrateRequest struct { type ArbitrateRequest struct {
Result string `json:"result" binding:"required"` Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"` Remark string `json:"remark" binding:"required"`
AmountCent int64 `json:"amount_cent"` AmountCent *int64 `json:"amount_cent"`
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"` ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"`
} }
+49 -47
View File
@@ -10,57 +10,59 @@ import (
type disputeRow struct { type disputeRow struct {
model.Dispute model.Dispute
OrderNo string OrderNo string
OrderStatus string OrderStatus string
HandoffStatus string HandoffStatus string
SettlementStatus string SettlementStatus string
ExtraItemOriginalAmountCent int64 ExtraItemOriginalAmountCent int64
ListingNo string ListingNo string
Title string Title string
OwnerID uint64 OwnerID uint64
RenterID uint64 RenterID uint64
OwnerPhone string OwnerPhone string
RenterPhone string RenterPhone string
CheckoutCoinConsumedM *float64 CheckoutCoinConsumedM *float64
CheckoutConsumableAmountCent *int64 CheckoutConsumableAmountCent *int64
CheckoutDepositDeductAmountCent *int64
} }
func (row disputeRow) toDTO() DisputeDTO { func (row disputeRow) toDTO() DisputeDTO {
return DisputeDTO{ return DisputeDTO{
ID: row.ID, ID: row.ID,
OrderID: row.OrderID, OrderID: row.OrderID,
OrderNo: row.OrderNo, OrderNo: row.OrderNo,
OrderStatus: row.OrderStatus, OrderStatus: row.OrderStatus,
HandoffStatus: row.HandoffStatus, HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus, SettlementStatus: row.SettlementStatus,
ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent,
ListingNo: row.ListingNo, ListingNo: row.ListingNo,
Title: row.Title, Title: row.Title,
OwnerID: row.OwnerID, OwnerID: row.OwnerID,
RenterID: row.RenterID, RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone, OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone, RenterPhone: row.RenterPhone,
InitiatorID: row.InitiatorID, InitiatorID: row.InitiatorID,
InitiatorType: effectiveInitiatorType(row.Dispute), InitiatorType: effectiveInitiatorType(row.Dispute),
InitiatorAdminID: row.InitiatorAdminID, InitiatorAdminID: row.InitiatorAdminID,
TargetUserID: row.TargetUserID, TargetUserID: row.TargetUserID,
Type: row.Type, Type: row.Type,
Status: row.Status, Status: row.Status,
Description: row.Description, Description: row.Description,
EvidenceURLS: row.EvidenceURLS, EvidenceURLS: row.EvidenceURLS,
PreviousOrderStatus: row.PreviousOrderStatus, PreviousOrderStatus: row.PreviousOrderStatus,
PreviousHandoffStatus: row.PreviousHandoffStatus, PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus, PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID, CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
PreviousCheckoutStatus: row.PreviousCheckoutStatus, CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent,
ArbitrationResult: row.ArbitrationResult, PreviousCheckoutStatus: row.PreviousCheckoutStatus,
ArbitrationRemark: row.ArbitrationRemark, ArbitrationResult: row.ArbitrationResult,
HandledBy: row.HandledBy, ArbitrationRemark: row.ArbitrationRemark,
HandledAt: row.HandledAt, HandledBy: row.HandledBy,
CreatedAt: row.CreatedAt, HandledAt: row.HandledAt,
UpdatedAt: row.UpdatedAt, 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, o.owner_id, o.renter_id, o.extra_item_original_amount_cent,
owner.phone AS owner_phone, renter.phone AS renter_phone, owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m, l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m,
c.consumable_amount_cent AS checkout_consumable_amount_cent`) c.consumable_amount_cent AS checkout_consumable_amount_cent,
CASE WHEN c.deposit_deduct_amount_cent > 0 THEN c.deposit_deduct_amount_cent ELSE c.other_amount_cent END AS checkout_deposit_deduct_amount_cent`)
} }
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -14,6 +14,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func int64Ptr(value int64) *int64 { return &value }
func disputePureCoinOrder() model.RentalOrder { func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{ return model.RentalOrder{
Status: "renting", Status: "renting",
@@ -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) { func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t) db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -394,7 +454,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{ settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
Result: "actual_settlement", Result: "actual_settlement",
Remark: "赔付号主", Remark: "赔付号主",
AmountCent: compensationCent, AmountCent: &compensationCent,
ActualCoinConsumedM: &actualCoinConsumedM, ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent, ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 0) }, 0)
@@ -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) { func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t) db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
@@ -417,11 +492,12 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
inputOrder.SettlementStatus = "pending" inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder) _, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{ checkout := model.OrderCheckout{
OrderID: order.ID, OrderID: order.ID,
InitiatedBy: renter.ID, InitiatedBy: renter.ID,
Status: "submitted", Status: "submitted",
ConsumableAmountCent: 7000, ConsumableAmountCent: 7000,
CoinConsumedM: 25, CoinConsumedM: 25,
DepositDeductAmountCent: 3000,
} }
if err := db.Create(&checkout).Error; err != nil { if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err) t.Fatalf("创建结账记录失败: %v", err)
@@ -433,8 +509,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("创建结账争议失败: %v", err) t.Fatalf("创建结账争议失败: %v", err)
} }
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 { 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, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID) 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{ if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
@@ -566,7 +642,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) {
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund", Result: "partial_refund",
AmountCent: 5000, AmountCent: int64Ptr(5000),
Remark: "没打完 号主登录不上", Remark: "没打完 号主登录不上",
}, 0) }, 0)
if err != nil { if err != nil {
@@ -599,7 +675,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) {
} }
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "partial_refund", Result: "partial_refund",
AmountCent: 20000, AmountCent: int64Ptr(20000),
}, 0) }, 0)
if err != nil { if err != nil {
t.Fatalf("buildArbitrationSettlement() error = %v", err) 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) { if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute return nil, ErrInvalidDispute
} }
if req.AmountCent != nil && (*req.AmountCent < 0 || *req.AmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute
}
if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) { if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute return nil, ErrInvalidDispute
} }
@@ -275,9 +275,11 @@ func externalAccountToCreateRequest(
"resources": resources, "resources": resources,
"skin_groups": externalSkinGroups(skins), "skin_groups": externalSkinGroups(skins),
"online_time_text": onlineTimeText, "online_time_text": onlineTimeText,
"ban_record": normalizeBanRecord(item.BanRecord), // 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
"common_regions": commonRegions(item.CommonRegion), "early_morning_response": "否",
"remark": remark, "ban_record": normalizeBanRecord(item.BanRecord),
"common_regions": commonRegions(item.CommonRegion),
"remark": remark,
"import_meta": map[string]any{ "import_meta": map[string]any{
"uploader_name": uploaderName, "uploader_name": uploaderName,
"client_upload_time": uploadTime, "client_upload_time": uploadTime,
@@ -382,10 +384,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64,
func serverRegionFromLoginMethod(value string) string { func serverRegionFromLoginMethod(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
normalized := strings.ToLower(value)
switch { switch {
case strings.Contains(value, "微信"): case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"):
return "微信" return "微信"
case strings.Contains(strings.ToLower(value), "steam"): case strings.Contains(normalized, "steam"):
return "Steam" return "Steam"
default: default:
return "QQ" return "QQ"
@@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool {
if summary == nil { if summary == nil {
return false return false
} }
onlineTime, ok := summary["online_time"].(map[string]any) // 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。
if !ok { switch value := summary["early_morning_response"].(type) {
case string:
return strings.TrimSpace(value) == "是"
case bool:
return value
default:
return false return false
} }
start, okStart := parseTimeMinuteValue(onlineTime["start"])
end, okEnd := parseTimeMinuteValue(onlineTime["end"])
if !okStart || !okEnd {
return false
}
return timeRangeOverlapsMinutes(start, end, 0, 8*60)
}
func parseTimeMinuteValue(value any) (int, bool) {
text, ok := value.(string)
if !ok {
return 0, false
}
parts := strings.Split(text, ":")
if len(parts) != 2 {
return 0, false
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, false
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, false
}
return hour*60 + minute, true
}
func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool {
if start == end {
return true
}
for _, segment := range splitMinuteRange(start, end) {
if segment[0] <= targetEnd && segment[1] >= targetStart {
return true
}
}
return false
}
func splitMinuteRange(start int, end int) [][2]int {
if start < end {
return [][2]int{{start, end}}
}
return [][2]int{{start, 23*60 + 59}, {0, end}}
} }
@@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
} }
} }
func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) { func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
start string response any
end string want bool
want bool
}{ }{
{name: "全天可上号", start: "00:00", end: "23:59", want: true}, {name: "明确响应", response: "", want: true},
{name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true}, {name: "明确不响应", response: "", want: false},
{name: "刚好八点开始", start: "08:00", end: "12:00", want: true}, {name: "兼容布尔值", response: true, want: true},
{name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false}, {name: "缺失字段", response: nil, want: false},
{name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true},
{name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := isNightAvailableSummary(map[string]any{ summary := map[string]any{}
"online_time": map[string]any{ if tt.response != nil {
"start": tt.start, summary["early_morning_response"] = tt.response
"end": tt.end, }
}, got := isNightAvailableSummary(summary)
})
if got != tt.want { 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)
} }
}) })
} }
+9 -10
View File
@@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
like := "%" + keyword + "%" like := "%" + keyword + "%"
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
db = db.Where( db = db.Where(
`(l.listing_no 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 = ?)`, `(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 = ?)`,
like, keyword,
like, keyword,
id, id,
id, 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 { func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
Where("listing_id IS NOT NULL").
Group("listing_id")
return r.db.WithContext(ctx).Table("rental_listings AS l"). return r.db.WithContext(ctx).Table("rental_listings AS l").
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname, a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
CASE WHEN lu.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 CASE
WHEN lu.upload_id IS NULL THEN ? WHEN lu.id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ? WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel ELSE lu.source_channel
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown). END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Joins("JOIN game_accounts AS a ON a.id = l.account_id").
Joins("LEFT JOIN users AS u ON u.id = l.owner_id"). Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
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) { func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
@@ -530,6 +530,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" { if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"]) t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
} }
if req.AssetSummary["early_morning_response"] != "否" {
t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"])
}
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any) onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
if !ok { if !ok {
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"]) t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
@@ -566,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) { func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
LoginMethod: "QQ账号密码", LoginMethod: "QQ账号密码",
@@ -391,10 +391,13 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error { func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
var refund *refundAction var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder assets, err := r.lockOrderAssets(tx, orderID)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { if err != nil {
return err return err
} }
order := assets.Order
listing := assets.Listing
account := assets.Account
if order.RefundStatus != refundStatusPendingReview { if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态") return errors.New("订单不处于待审核退款状态")
} }
@@ -407,14 +410,23 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。 // 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
order.RefundStatus = "none" order.RefundStatus = "none"
order.RefundAmountCent = 0 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 := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil {
if err != nil {
return err return err
} }
refund = action if err := tx.Save(order).Error; err != nil {
return tx.Save(&order).Error return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
}) })
if err != nil { if err != nil {
return err return err
+25 -2
View File
@@ -6,6 +6,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
if hasOpen { if hasOpen {
return ErrCheckoutCannotSubmit return ErrCheckoutCannotSubmit
} }
before := orderState(order)
checkoutToUserID := order.OwnerID checkoutToUserID := order.OwnerID
checkoutTargetType := processlog.ActorUser
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
checkoutToUserID = platformManagedAdminID(order) checkoutToUserID = platformManagedAdminID(order)
checkoutTargetType = processlog.ActorAdmin
} }
record := model.HandoffRecord{ record := model.HandoffRecord{
OrderID: order.ID, OrderID: order.ID,
@@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
order.SettlementStatus = settlementStatusPending order.SettlementStatus = settlementStatusPending
now := time.Now() now := time.Now()
order.HandoffStartedAt = &now order.HandoffStartedAt = &now
if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if isPlatformSettlementOrder(order) { if isPlatformSettlementOrder(order) {
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
@@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
checkout.OwnerAdjustedAt = &now checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
refund = action refund = action
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 { if err != nil {
return err return err
@@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err return err
} }
targetID := order.OwnerID
if isOwner {
targetID = order.RenterID
}
if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil { if err := tx.Save(checkout).Error; err != nil {
return err return err
} }
@@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout { if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
checkout.RenterConfirmedAt = &now checkout.RenterConfirmedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
refund = action refund = action
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 { if err != nil {
return err return err
+6 -5
View File
@@ -32,11 +32,12 @@ const (
handoffStatusAdminClosed = "admin_closed" handoffStatusAdminClosed = "admin_closed"
handoffStatusAdminAbnormal = "admin_abnormal" handoffStatusAdminAbnormal = "admin_abnormal"
settlementStatusUnsettled = "unsettled" settlementStatusUnsettled = "unsettled"
settlementStatusPending = "pending" settlementStatusPending = "pending"
settlementStatusSettled = "settled" settlementStatusSettled = "settled"
settlementStatusClosed = "closed" settlementStatusClosed = "closed"
settlementStatusDisputed = "disputed" settlementStatusDisputed = "disputed"
settlementStatusArbitrated = "arbitrated"
handoffModeOwner = "owner" handoffModeOwner = "owner"
handoffModePlatform = "platform" handoffModePlatform = "platform"
@@ -5,6 +5,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
if !canHoldDeposit(order) { if !canHoldDeposit(order) {
return ErrDepositCannotHold return ErrDepositCannotHold
} }
before := orderState(order)
beforeStatus := order.DepositHoldStatus beforeStatus := order.DepositHoldStatus
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusHeld order.DepositHoldStatus = depositHoldStatusHeld
@@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
order.DepositHeldBy = &adminID order.DepositHeldBy = &adminID
order.DepositHeldAt = &now order.DepositHeldAt = &now
order.DepositHoldReleasedAt = nil order.DepositHoldReleasedAt = nil
if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or
if holdAmountCent <= 0 { if holdAmountCent <= 0 {
return ErrDepositHoldAmountEmpty return ErrDepositHoldAmountEmpty
} }
before := orderState(order)
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusReleased order.DepositHoldStatus = depositHoldStatusReleased
order.DepositHoldReleasedAt = &now order.DepositHoldReleasedAt = &now
if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil {
return err
}
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回") action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
if err != nil { if err != nil {
return err return err
+27 -2
View File
@@ -51,6 +51,8 @@ type OrderDTO struct {
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel,omitempty"`
Status string `json:"status"` Status string `json:"status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"` HandoffMode string `json:"handoff_mode"`
@@ -131,7 +133,8 @@ type CounterCheckoutRequest struct {
} }
type AdminActionRequest struct { type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"`
} }
type PlatformHandoffRequest struct { type PlatformHandoffRequest struct {
@@ -139,7 +142,7 @@ type PlatformHandoffRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
} }
// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。 // ForceHandoffRequest 客服确认订单已完成交接。
type ForceHandoffRequest struct { type ForceHandoffRequest struct {
Content string `json:"content"` Content string `json:"content"`
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
@@ -190,6 +193,28 @@ type HandoffRecordDTO struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。
type ProcessEventDTO struct {
ID uint64 `json:"id"`
BusinessType string `json:"business_type"`
BusinessID uint64 `json:"business_id"`
Stage string `json:"stage"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID uint64 `json:"actor_id"`
ActorName string `json:"actor_name"`
TargetType string `json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `json:"target_name"`
Content string `json:"content"`
Reason string `json:"reason"`
Payload datatypes.JSON `json:"payload"`
AttachmentURLs []string `json:"attachment_urls"`
StateBefore datatypes.JSON `json:"state_before"`
StateAfter datatypes.JSON `json:"state_after"`
CreatedAt time.Time `json:"created_at"`
}
type CheckoutDTO struct { type CheckoutDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
@@ -7,13 +7,20 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
func canAdminForceHandoff(order model.RentalOrder) bool { func canAdminForceHandoff(order model.RentalOrder) bool {
if isPlatformHandoffOrder(order) || 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 return false
} }
if order.Status == orderStatusPendingHandoff { if order.Status == orderStatusPendingHandoff {
@@ -24,11 +31,18 @@ func canAdminForceHandoff(order model.RentalOrder) bool {
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
} }
func forceHandoffRecordType(order model.RentalOrder) string {
if isPlatformHandoffOrder(order) {
return "platform_handoff"
}
return "owner_handoff"
}
func forceHandoffNeedsContent(order model.RentalOrder) bool { func forceHandoffNeedsContent(order model.RentalOrder) bool {
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout
} }
// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。 // AdminForceHandoff 由客服确认订单已完成交接,并立即开始租期。
func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error { func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder var order model.RentalOrder
@@ -38,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
before := orderState(order)
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
@@ -60,7 +75,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !needsContent { if !needsContent {
var handoffCount int64 var handoffCount int64
if err := tx.Model(&model.HandoffRecord{}). if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "owner_handoff"). Where("order_id = ? AND type = ?", order.ID, forceHandoffRecordType(order)).
Count(&handoffCount).Error; err != nil { Count(&handoffCount).Error; err != nil {
return err return err
} }
@@ -87,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
order.HandoffStatus = handoffStatusReceived order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now order.RentedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
@@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) AdminProcessEvents(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) AdminClose(c *gin.Context) { func (h *Handler) AdminClose(c *gin.Context) {
h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) h.adminAction(c, h.service.AdminClose, gin.H{"closed": true})
} }
@@ -248,6 +261,15 @@ func (h *Handler) ListPendingRefund(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) PendingRefundCount(c *gin.Context) {
count, err := h.service.PendingRefundCount(c.Request.Context())
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"pending_refund_count": count})
}
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) { func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
if !ok { if !ok {
@@ -44,6 +44,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账") response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账")
case errors.Is(err, ErrCheckoutCannotConfirm): case errors.Is(err, ErrCheckoutCannotConfirm):
response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认") response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认")
case errors.Is(err, ErrZeroOwnerIncomeConfirmation):
response.Error(c, http.StatusConflict, "zero_owner_income_confirmation_required", "号主收入为 0,请确认后再结算")
case errors.Is(err, ErrCheckoutCannotCounter): case errors.Is(err, ErrCheckoutCannotCounter):
response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改") response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改")
case errors.Is(err, ErrCheckoutMaxRounds): case errors.Is(err, ErrCheckoutMaxRounds):
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -20,6 +21,7 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
if order.OwnerID != userID { if order.OwnerID != userID {
return ErrPermissionDenied return ErrPermissionDenied
} }
before := orderState(order)
// 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。 // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。
if order.Status != orderStatusPendingHandoff || if order.Status != orderStatusPendingHandoff ||
(order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) { (order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) {
@@ -37,6 +39,9 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
return err return err
} }
order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStatus = handoffStatusPendingRenterConfirm
if err := appendOrderEvent(tx, order, "handoff", "owner_handoff_submitted", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, req.Content, "", nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。" renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。"
if lateSubmit { if lateSubmit {
+28 -2
View File
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/processlog"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -44,6 +45,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
if err != nil { if err != nil {
return err return err
} }
accountSource, sourceChannel, err := orderAccountSourceSnapshot(tx, listing.ID)
if err != nil {
return err
}
orderNo, err := newOrderNo() orderNo, err := newOrderNo()
if err != nil { if err != nil {
return err return err
@@ -89,6 +94,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
RenterGrowthLevelName: growthSnapshot.LevelName, RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps, RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot, AccountSnapshot: snapshot,
AccountSource: accountSource,
SourceChannel: sourceChannel,
Status: orderStatusPendingPayment, Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone, HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing), HandoffMode: listingHandoffMode(listing),
@@ -126,6 +133,18 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
return r.FindForUser(ctx, renterID, createdID) return r.FindForUser(ctx, renterID, createdID)
} }
func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) {
var upload model.ListingUpload
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
if err == gorm.ErrRecordNotFound {
return "internal", "", nil
}
if err != nil {
return "", "", err
}
return "external", upload.SourceChannel, nil
}
func listingHandoffMode(listing model.RentalListing) string { func listingHandoffMode(listing model.RentalListing) string {
if listing.HandoffMode != "" { if listing.HandoffMode != "" {
return listing.HandoffMode return listing.HandoffMode
@@ -368,8 +387,11 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
return err return err
} }
} }
if err := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil { // 已付款订单取消后需等待客服审核退款,审核通过前继续占用账号。
return err if beforeStatus == orderStatusPendingPayment {
if err := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil {
return err
}
} }
if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil {
return err return err
@@ -418,6 +440,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm { if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm {
return ErrOrderCannotReceive return ErrOrderCannotReceive
} }
before := orderState(order)
now := time.Now() now := time.Now()
if err := tx.Model(&model.HandoffRecord{}). if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}). Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}).
@@ -428,6 +451,9 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
order.HandoffStatus = handoffStatusReceived order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now order.RentedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "renter_received_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客确认已收号,订单开始租用。", "", nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if isPlatformHandoffOrder(order) { if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil { if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil {
@@ -7,6 +7,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -66,7 +67,7 @@ func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool {
func canAdminMarkOfflineSettlement(order model.RentalOrder) bool { func canAdminMarkOfflineSettlement(order model.RentalOrder) bool {
return isPlatformSettlementOrder(order) && return isPlatformSettlementOrder(order) &&
order.Status == orderStatusCompleted && order.Status == orderStatusCompleted &&
order.SettlementStatus == settlementStatusSettled && (order.SettlementStatus == settlementStatusSettled || order.SettlementStatus == settlementStatusArbitrated) &&
effectiveOfflineSettlementStatus(order) == offlineSettlementStatusPending && effectiveOfflineSettlementStatus(order) == offlineSettlementStatusPending &&
order.OfflineSettlementAmountCent > 0 order.OfflineSettlementAmountCent > 0
} }
@@ -81,6 +82,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o
if !canAdminPlatformHandoff(order) { if !canAdminPlatformHandoff(order) {
return ErrOrderCannotHandoff return ErrOrderCannotHandoff
} }
before := orderState(order)
beforeHandoffStatus := order.HandoffStatus beforeHandoffStatus := order.HandoffStatus
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
@@ -98,6 +100,9 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o
now := time.Now() now := time.Now()
order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStatus = handoffStatusPendingRenterConfirm
order.HandoffStartedAt = &now order.HandoffStartedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。" content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
if beforeHandoffStatus == handoffStatusOwnerTimeout { if beforeHandoffStatus == handoffStatusOwnerTimeout {
@@ -146,6 +151,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -153,6 +159,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner { if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
if checkout.OwnerIncomeAmountCent == 0 && !req.ConfirmZeroOwnerIncome {
return ErrZeroOwnerIncomeConfirmation
}
if err := ensureCheckoutCompletable(order, checkout); err != nil { if err := ensureCheckoutCompletable(order, checkout); err != nil {
return err return err
} }
@@ -175,6 +184,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
if err != nil { if err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服确认当前结账方案,订单完成。", req.Reason, checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)); err != nil {
return err
}
return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{ return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -187,6 +199,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
"after_settlement_status": order.SettlementStatus, "after_settlement_status": order.SettlementStatus,
"offline_settlement_status": order.OfflineSettlementStatus, "offline_settlement_status": order.OfflineSettlementStatus,
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent, "offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
"confirm_zero_owner_income": req.ConfirmZeroOwnerIncome,
}) })
}) })
if err != nil { if err != nil {
@@ -207,6 +220,7 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
} }
@@ -259,6 +273,9 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_countered", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -308,6 +325,7 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
} }
@@ -369,6 +387,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "dispute", "platform_checkout_dispute_opened", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服发起结账争议。", req.Reason, map[string]any{"checkout_id": checkout.ID, "dispute_id": row.ID}, before, nil); err != nil {
return err
}
disputeID := row.ID disputeID := row.ID
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, notification.Entry{ if err := notification.Append(tx, notification.Entry{
@@ -415,6 +436,7 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin
if !canAdminMarkOfflineSettlement(order) { if !canAdminMarkOfflineSettlement(order) {
return ErrOfflineSettlementCannotMark return ErrOfflineSettlementCannotMark
} }
before := orderState(order)
beforeStatus := order.OfflineSettlementStatus beforeStatus := order.OfflineSettlementStatus
now := time.Now() now := time.Now()
order.OfflineSettlementStatus = offlineSettlementStatusSettled order.OfflineSettlementStatus = offlineSettlementStatusSettled
@@ -422,6 +444,9 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin
order.OfflineSettledBy = &adminID order.OfflineSettledBy = &adminID
order.OfflineSettledAt = &now order.OfflineSettledAt = &now
order.OwnerSettledAt = &now order.OwnerSettledAt = &now
if err := appendOrderEvent(tx, order, "settlement", "offline_settlement_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.OwnerID, "确认已完成线下结算。", req.Remark, map[string]any{"offline_settlement_amount_cent": order.OfflineSettlementAmountCent}, before, nil); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
+13 -1
View File
@@ -107,6 +107,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwarded: row.GrowthPointsAwarded,
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
AccountSnapshot: row.AccountSnapshot, AccountSnapshot: row.AccountSnapshot,
AccountSource: effectiveAccountSource(row.RentalOrder),
SourceChannel: row.SourceChannel,
Status: row.Status, Status: row.Status,
HandoffStatus: row.HandoffStatus, HandoffStatus: row.HandoffStatus,
HandoffMode: effectiveHandoffMode(row.RentalOrder), HandoffMode: effectiveHandoffMode(row.RentalOrder),
@@ -131,6 +133,13 @@ func (row orderRow) toAdminDTO() OrderDTO {
} }
} }
func effectiveAccountSource(order model.RentalOrder) string {
if order.AccountSource != "" {
return order.AccountSource
}
return "internal"
}
func (row orderRow) toDTOForUser(userID uint64) OrderDTO { func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto := row.toAdminDTO() dto := row.toAdminDTO()
dto.AdminActions = nil dto.AdminActions = nil
@@ -146,6 +155,9 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto.OfflineSettlementRemark = "" dto.OfflineSettlementRemark = ""
dto.OfflineSettledBy = nil dto.OfflineSettledBy = nil
dto.OfflineSettledAt = nil dto.OfflineSettledAt = nil
// 外部来源渠道属于后台经营信息,仅在管理员订单详情展示。
dto.AccountSource = ""
dto.SourceChannel = ""
applyOrderPriceView(&dto, row.RentalOrder, userID) applyOrderPriceView(&dto, row.RentalOrder, userID)
return dto return dto
} }
@@ -277,7 +289,7 @@ func refreshCheckoutSettlementDTO(dto *CheckoutDTO, order model.RentalOrder, che
if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered { if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered {
return return
} }
settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(checkout))
rent := settlement.ActualRentAmountCent rent := settlement.ActualRentAmountCent
ownerRent := settlement.OwnerRentIncomeCent ownerRent := settlement.OwnerRentIncomeCent
platform := settlement.PlatformFeeCent platform := settlement.PlatformFeeCent
+10 -1
View File
@@ -278,7 +278,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
} }
func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement {
return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(*checkout))
}
// effectiveCheckoutDepositDeductCent keeps legacy checkouts that only populated
// other_amount_cent from losing their deposit compensation at finalization.
func effectiveCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 {
if checkout.DepositDeductAmountCent > 0 {
return checkout.DepositDeductAmountCent
}
return maxCent(checkout.OtherAmountCent, 0)
} }
func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement {
@@ -0,0 +1,64 @@
package order
import (
"context"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm"
)
const processBusinessOrder = "order"
func orderState(order model.RentalOrder) map[string]any {
return map[string]any{
"order_status": order.Status, "handoff_status": order.HandoffStatus,
"settlement_status": order.SettlementStatus,
"offline_settlement_status": effectiveOfflineSettlementStatus(order),
}
}
func checkoutEventPayload(checkout model.OrderCheckout) map[string]any {
return map[string]any{
"checkout_id": checkout.ID, "round": checkout.RoundCount, "turn": normalizeCheckoutTurn(&checkout),
"proposed_by": checkout.ProposedBy, "rent_amount_cent": checkout.RentAmountCent,
"owner_rent_amount_cent": checkout.OwnerRentAmountCent, "platform_fee_cent": checkout.PlatformFeeCent,
"deposit_amount_cent": checkout.DepositAmountCent, "consumable_amount_cent": checkout.ConsumableAmountCent,
"coin_consumed_m": checkout.CoinConsumedM, "deposit_deduct_amount_cent": checkout.DepositDeductAmountCent,
"renter_refund_amount_cent": checkout.RenterRefundAmountCent, "owner_income_amount_cent": checkout.OwnerIncomeAmountCent,
"shortfall_cent": checkout.ShortfallCent, "overshoot_amount_cent": checkout.OvershootAmountCent,
}
}
func appendOrderEvent(tx *gorm.DB, order model.RentalOrder, stage, action, actorType string, actorID uint64, targetType string, targetID *uint64, content, reason string, payload, before map[string]any, attachments []string) error {
return processlog.Append(tx, processlog.Entry{
BusinessType: processBusinessOrder, BusinessID: order.ID, Stage: stage, Action: action,
ActorType: actorType, ActorID: actorID, TargetType: targetType, TargetID: targetID,
Content: content, Reason: reason, Payload: payload, Attachments: attachments,
StateBefore: before, StateAfter: orderState(order),
})
}
func (r *Repository) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
return nil, err
}
rows, err := processlog.List(r.db.WithContext(ctx), processBusinessOrder, orderID)
if err != nil {
return nil, err
}
items := make([]ProcessEventDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ProcessEventDTO{
ID: row.ID, BusinessType: row.BusinessType, BusinessID: row.BusinessID, Stage: row.Stage,
Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, ActorName: row.ActorName,
TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName,
Content: row.Content, Reason: row.Reason, Payload: row.Payload,
AttachmentURLs: decodeStringList(row.AttachmentURLs), StateBefore: row.StateBefore,
StateAfter: row.StateAfter, CreatedAt: row.CreatedAt,
})
}
return items, nil
}
+49 -6
View File
@@ -61,11 +61,20 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
return nil, err return nil, err
} }
checkouts, err := r.latestAdminCheckouts(ctx, rows)
if err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows)) items := make([]OrderDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db) paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
for _, row := range rows { for _, row := range rows {
dto := row.toAdminDTO() dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
if checkout, ok := checkouts[row.ID]; ok && shouldAttachAdminCheckout(row) {
checkoutDTO := toCheckoutAdminDTO(checkout)
refreshCheckoutSettlementDTO(&checkoutDTO, row.RentalOrder, checkout)
dto.Checkout = &checkoutDTO
}
items = append(items, dto) items = append(items, dto)
} }
@@ -77,6 +86,41 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
}, nil }, nil
} }
// latestAdminCheckouts loads one latest checkout per order so the admin order
// list can display finalized income without issuing one query per row.
func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) {
result := make(map[uint64]model.OrderCheckout, len(rows))
if len(rows) == 0 {
return result, nil
}
orderIDs := make([]uint64, 0, len(rows))
for _, row := range rows {
orderIDs = append(orderIDs, row.ID)
}
latest := r.db.WithContext(ctx).
Table("order_checkouts").
Select("MAX(id) AS id").
Where("order_id IN ?", orderIDs).
Group("order_id")
var checkouts []model.OrderCheckout
if err := r.db.WithContext(ctx).
Table("order_checkouts AS oc").
Joins("JOIN (?) AS latest ON latest.id = oc.id", latest).
Find(&checkouts).Error; err != nil {
return nil, err
}
for _, checkout := range checkouts {
result[checkout.OrderID] = checkout
}
return result, nil
}
func shouldAttachAdminCheckout(row orderRow) bool {
return shouldAttachCheckout(row.Status) ||
row.SettlementStatus == settlementStatusSettled ||
row.SettlementStatus == settlementStatusArbitrated
}
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
if query.Status != "" { if query.Status != "" {
db = db.Where("o.status = ?", query.Status) db = db.Where("o.status = ?", query.Status)
@@ -100,12 +144,11 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
like := "%" + keyword + "%" like := "%" + keyword + "%"
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
db = db.Where( db = db.Where(
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, `(o.order_no = ? OR l.listing_no = ? OR owner.phone = ? OR renter.phone = ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`,
like, keyword,
like, keyword,
like, keyword,
like, keyword,
like,
id, id,
id, id,
id, id,
@@ -2,6 +2,8 @@ package order
import ( import (
"context" "context"
"hfb_sys/backend/internal/model"
) )
// ListPendingRefund 查询所有待客服审核退款的订单。 // ListPendingRefund 查询所有待客服审核退款的订单。
@@ -26,3 +28,13 @@ func (r *Repository) ListPendingRefund(ctx context.Context) ([]OrderDTO, error)
} }
return items, nil return items, nil
} }
// PendingRefundCount 返回待客服审核退款数量,用于后台导航角标。
func (r *Repository) PendingRefundCount(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&model.RentalOrder{}).
Where("refund_status = ?", refundStatusPendingReview).
Count(&count).Error
return count, err
}
@@ -1,6 +1,8 @@
package order package order
import ( import (
"context"
"errors"
"testing" "testing"
"time" "time"
@@ -379,6 +381,20 @@ func TestBuildCheckoutSettlementWithDepositDeduct(t *testing.T) {
} }
} }
func TestBuildCheckoutSettlementFallsBackToLegacyOtherAmount(t *testing.T) {
order := model.RentalOrder{
RentAmountCent: 24000,
OwnerRentAmountCent: 21600,
DepositAmountCent: 5000,
}
checkout := &model.OrderCheckout{OtherAmountCent: 2000}
settlement := buildCheckoutSettlement(order, checkout)
if settlement.DepositCompensationCent != 2000 || settlement.OwnerIncomeCent != 23600 {
t.Fatalf("legacy other amount settlement = compensation %d, owner income %d, want 2000/23600", settlement.DepositCompensationCent, settlement.OwnerIncomeCent)
}
}
// TestBuildCheckoutSettlementWithConsumable 测试额外消耗 // TestBuildCheckoutSettlementWithConsumable 测试额外消耗
func TestBuildCheckoutSettlementWithConsumable(t *testing.T) { func TestBuildCheckoutSettlementWithConsumable(t *testing.T) {
order := model.RentalOrder{ order := model.RentalOrder{
@@ -721,6 +737,105 @@ func TestCancelClosesPendingPaymentOrder(t *testing.T) {
} }
} }
func TestCancelPendingHandoffKeepsAssetsUntilRefundApproved(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db, Dependencies{
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunding", nil
}),
})
owner := model.User{Phone: "13800001002"}
renter := model.User{Phone: "13900001002"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
Status: accountStatusRented,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "待退款审核账号",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
}
listing := model.RentalListing{
ListingNo: "LST-REFUND-REVIEW-001",
OwnerID: owner.ID,
AccountID: account.ID,
Status: listingStatusRented,
ReviewStatus: listingReviewStatusApproved,
InTransaction: true,
PriceCent: 1000,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("create listing failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-REFUND-REVIEW-001",
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
RentAmountCent: 1000,
Status: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingOwner,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := repo.Cancel(t.Context(), renter.ID, order.ID); err != nil {
t.Fatalf("Cancel() error = %v", err)
}
assertAssetsRented := func() {
var savedListing model.RentalListing
if err := db.First(&savedListing, listing.ID).Error; err != nil {
t.Fatalf("find listing failed: %v", err)
}
if savedListing.Status != listingStatusRented || !savedListing.InTransaction {
t.Fatalf("listing = %s/in_transaction:%t, want rented/true", savedListing.Status, savedListing.InTransaction)
}
var savedAccount model.GameAccount
if err := db.First(&savedAccount, account.ID).Error; err != nil {
t.Fatalf("find account failed: %v", err)
}
if savedAccount.Status != accountStatusRented {
t.Fatalf("account status = %q, want %q", savedAccount.Status, accountStatusRented)
}
}
assertAssetsRented()
var pendingReviewOrder model.RentalOrder
if err := db.First(&pendingReviewOrder, order.ID).Error; err != nil {
t.Fatalf("find cancelled order failed: %v", err)
}
if pendingReviewOrder.RefundStatus != refundStatusPendingReview {
t.Fatalf("refund status = %q, want %q", pendingReviewOrder.RefundStatus, refundStatusPendingReview)
}
if err := repo.AdminApproveRefund(t.Context(), order.ID); err != nil {
t.Fatalf("AdminApproveRefund() error = %v", err)
}
var releasedListing model.RentalListing
if err := db.First(&releasedListing, listing.ID).Error; err != nil {
t.Fatalf("find released listing failed: %v", err)
}
if releasedListing.Status != listingStatusPublished || releasedListing.InTransaction {
t.Fatalf("listing = %s/in_transaction:%t, want published/false", releasedListing.Status, releasedListing.InTransaction)
}
var releasedAccount model.GameAccount
if err := db.First(&releasedAccount, account.ID).Error; err != nil {
t.Fatalf("find released account failed: %v", err)
}
if releasedAccount.Status != accountStatusPublished {
t.Fatalf("account status = %q, want %q", releasedAccount.Status, accountStatusPublished)
}
}
func TestCancelDoesNotReleaseAssetsWhenSiblingOrderActive(t *testing.T) { func TestCancelDoesNotReleaseAssetsWhenSiblingOrderActive(t *testing.T) {
db := setupOrderTestDB(t) db := setupOrderTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
@@ -1079,7 +1194,7 @@ func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) {
} }
} }
func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *testing.T) { func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrderBeforeHandoff(t *testing.T) {
db := setupOrderTestDB(t) db := setupOrderTestDB(t)
if err := db.AutoMigrate(&model.Dispute{}); err != nil { if err := db.AutoMigrate(&model.Dispute{}); err != nil {
t.Fatalf("migrate disputes failed: %v", err) t.Fatalf("migrate disputes failed: %v", err)
@@ -1160,6 +1275,72 @@ func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *test
} }
} }
func TestAdminForceHandoffStartsPlatformManagedOrderAfterPlatformHandoff(t *testing.T) {
db := setupOrderTestDB(t)
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
t.Fatalf("migrate disputes failed: %v", err)
}
repo := NewRepository(db)
owner := model.User{Phone: "admin:force-handoff"}
renter := model.User{Phone: "13900003008"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-FORCE-HANDOFF-PLATFORM-001",
ListingID: 4,
AccountID: 4,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingRenterConfirm,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"daily_loss_m": 50
}`)),
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := db.Create(&model.HandoffRecord{
OrderID: order.ID,
FromUserID: 99,
ToUserID: renter.ID,
Type: "platform_handoff",
Content: "客服已提交账号和登录说明",
}).Error; err != nil {
t.Fatalf("create platform handoff record failed: %v", err)
}
if err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{
Reason: "租客未确认,客服已核实账号可正常使用",
}, AuditMeta{}); err != nil {
t.Fatalf("AdminForceHandoff() error = %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.Status != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived {
t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus)
}
var record model.HandoffRecord
if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_force_handoff").First(&record).Error; err != nil {
t.Fatalf("find force handoff record failed: %v", err)
}
if record.Content != "客服已确认双方完成交接,订单已进入使用中。" {
t.Fatalf("record content = %q", record.Content)
}
}
func TestSubmitCheckoutRefreshesStageTime(t *testing.T) { func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
db := setupOrderTestDB(t) db := setupOrderTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
@@ -1414,6 +1595,46 @@ func TestAdminPlatformCheckoutCounterCanBeAcceptedByRenter(t *testing.T) {
} }
} }
func TestAdminMarkOfflineSettlementAllowsArbitratedPlatformOrder(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(79)
order := model.RentalOrder{
OrderNo: "ORD-OFFLINE-ARBITRATED-001",
ListingID: 1,
AccountID: 1,
OwnerID: 1,
RenterID: 2,
Status: orderStatusCompleted,
SettlementStatus: settlementStatusArbitrated,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
OfflineSettlementStatus: offlineSettlementStatusPending,
OfflineSettlementAmountCent: 8000,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if !canAdminMarkOfflineSettlement(order) {
t.Fatal("arbitrated platform order should allow offline settlement")
}
if err := repo.AdminMarkOfflineSettlement(t.Context(), adminID, order.ID, OfflineSettlementRequest{Remark: "仲裁后线下转账"}, AuditMeta{}); err != nil {
t.Fatalf("AdminMarkOfflineSettlement() error = %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.OfflineSettlementStatus != offlineSettlementStatusSettled {
t.Fatalf("offline settlement status = %q, want %q", saved.OfflineSettlementStatus, offlineSettlementStatusSettled)
}
if saved.OfflineSettledBy == nil || *saved.OfflineSettledBy != adminID {
t.Fatalf("offline settled by = %#v, want %d", saved.OfflineSettledBy, adminID)
}
}
func TestPlatformManagedCheckoutIncludesConsumableAndDepositCompensation(t *testing.T) { func TestPlatformManagedCheckoutIncludesConsumableAndDepositCompensation(t *testing.T) {
db := setupOrderTestDB(t) db := setupOrderTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
@@ -1588,3 +1809,80 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd
} }
return checkout return checkout
} }
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(7002)
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
order.Status = orderStatusCompleted
order.SettlementStatus = settlementStatusSettled
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save completed order failed: %v", err)
}
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
checkout.Status = checkoutStatusAccepted
checkout.OwnerIncomeAmountCent = 6000
if err := db.Save(&checkout).Error; err != nil {
t.Fatalf("save finalized checkout failed: %v", err)
}
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("ListAdmin() error = %v", err)
}
if len(result.Items) != 1 {
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
}
if result.Items[0].Checkout == nil || result.Items[0].Checkout.OwnerIncomeAmountCent == nil {
t.Fatal("ListAdmin() did not include finalized checkout owner income")
}
if *result.Items[0].Checkout.OwnerIncomeAmountCent != 6000 {
t.Fatalf("owner income = %d, want 6000", *result.Items[0].Checkout.OwnerIncomeAmountCent)
}
}
func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db, Dependencies{
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "pending", nil
}),
})
const adminID uint64 = 7001
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
checkout.OwnerIncomeAmountCent = 0
if err := db.Save(&checkout).Error; err != nil {
t.Fatalf("将号主收入调整为零失败: %v", err)
}
err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{
Reason: "客服核对结算",
}, AuditMeta{})
if !errors.Is(err, ErrZeroOwnerIncomeConfirmation) {
t.Fatalf("未确认零收入时 error = %v, want %v", err, ErrZeroOwnerIncomeConfirmation)
}
var pending model.RentalOrder
if err := db.First(&pending, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if pending.Status != orderStatusPendingCheckoutConfirm {
t.Fatalf("未确认零收入后订单状态 = %s, want %s", pending.Status, orderStatusPendingCheckoutConfirm)
}
if err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{
Reason: "已确认号主收入为零",
ConfirmZeroOwnerIncome: true,
}, AuditMeta{}); err != nil {
t.Fatalf("确认零收入后结算失败: %v", err)
}
var completed model.RentalOrder
if err := db.First(&completed, order.ID).Error; err != nil {
t.Fatalf("读取完成订单失败: %v", err)
}
if completed.Status != orderStatusCompleted {
t.Fatalf("确认零收入后订单状态 = %s, want %s", completed.Status, orderStatusCompleted)
}
}
+15
View File
@@ -22,6 +22,7 @@ var (
ErrOrderCannotComplete = errors.New("order cannot complete") ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrZeroOwnerIncomeConfirmation = errors.New("zero owner income confirmation required")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter") ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached") ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall") ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
@@ -184,6 +185,13 @@ func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]Ha
return s.repo.HandoffRecordsAdmin(ctx, orderID) return s.repo.HandoffRecordsAdmin(ctx, orderID)
} }
func (s *Service) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ProcessEventsAdmin(ctx, orderID)
}
func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -351,6 +359,13 @@ func (s *Service) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) {
return s.repo.ListPendingRefund(ctx) return s.repo.ListPendingRefund(ctx)
} }
func (s *Service) PendingRefundCount(ctx context.Context) (int64, error) {
if s.repo == nil {
return 0, ErrDependencyUnavailable
}
return s.repo.PendingRefundCount(ctx)
}
func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) { func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable

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