Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad279049a0 | ||
|
|
1ee7d17712 | ||
|
|
cfabfa8f65 | ||
|
|
6561f925e0 | ||
|
|
327a03672a | ||
|
|
21fbc69c7a | ||
|
|
2337395d8f | ||
|
|
98f6ee9451 | ||
|
|
0aef80fd11 | ||
|
|
2487a2d83f | ||
|
|
527e5ed228 | ||
|
|
c156c478d8 | ||
|
|
c87883cc65 | ||
|
|
a9a9127076 | ||
|
|
41a5b49450 | ||
|
|
a53b9dc617 | ||
|
|
74d1518b66 | ||
|
|
006f93a0e7 | ||
|
|
cdab2d8157 | ||
|
|
cc91979b34 | ||
|
|
16237dcefc | ||
|
|
52dcb18c96 | ||
|
|
d95b95211c | ||
|
|
8f6356a3d7 | ||
|
|
417f7398e4 | ||
|
|
87327fbd91 | ||
|
|
8660d5d4d1 | ||
|
|
7b274d4ab8 | ||
|
|
266abb4aaa | ||
|
|
cc974f954f | ||
|
|
b0d4d5bfef | ||
|
|
47ba202517 | ||
|
|
6e4689edc4 | ||
|
|
9a52b51c89 | ||
|
|
80e329a604 | ||
|
|
0b235d078d | ||
|
|
f1b48f7921 | ||
|
|
c4507caf37 | ||
|
|
1f204f17ad | ||
|
|
c91423a19b | ||
|
|
0bc6cf9f46 | ||
|
|
79ced2d121 | ||
|
|
50b458e5c2 | ||
|
|
76528076a7 | ||
|
|
2265c48a53 | ||
|
|
79eebdc8e5 | ||
|
|
a0bf47f2ed | ||
|
|
f67ee12d72 | ||
|
|
66cb03ebce | ||
|
|
ca4372271a | ||
|
|
7d7141c055 | ||
|
|
4fb79cb37f | ||
|
|
96221d3c00 | ||
|
|
eb06ec3c5f | ||
|
|
7b7d23c743 | ||
|
|
7438fb6725 |
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
push_lines="$(cat)"
|
||||||
|
has_updates=0
|
||||||
|
has_deletions=0
|
||||||
|
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||||
|
[ -z "${local_ref}" ] && continue
|
||||||
|
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
has_deletions=1
|
||||||
|
else
|
||||||
|
has_updates=1
|
||||||
|
fi
|
||||||
|
done <<< "$push_lines"
|
||||||
|
|
||||||
|
if [ "$has_updates" = "0" ] && [ "$has_deletions" = "1" ]; then
|
||||||
|
echo "[pre-push] 分支删除推送,跳过本地检查"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${SKIP_CHECKS:-0}" = "1" ]; then
|
||||||
|
echo "[pre-push] SKIP_CHECKS=1,跳过本地检查"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pre-push] 运行本地代码门禁..."
|
||||||
|
bash scripts/check.sh
|
||||||
|
echo "[pre-push] 本地检查通过"
|
||||||
@@ -40,3 +40,4 @@ deploy/caddy/conf.d/show.caddy
|
|||||||
|
|
||||||
logs
|
logs
|
||||||
.gocache
|
.gocache
|
||||||
|
artifacts/
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ npm run dev
|
|||||||
|
|
||||||
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。
|
`./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` 调整级别。
|
||||||
|
|||||||
@@ -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
@@ -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 应使用内网 Endpoint:https://oss-cn-hangzhou-internal.aliyuncs.com
|
# 杭州 ECS 应使用内网 Endpoint:https://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
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
|
||||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||||
"hfb_sys/backend/internal/jobs/refundretry"
|
"hfb_sys/backend/internal/jobs/refundretry"
|
||||||
"hfb_sys/backend/internal/logging"
|
"hfb_sys/backend/internal/logging"
|
||||||
@@ -59,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 {
|
||||||
@@ -92,7 +97,6 @@ func main() {
|
|||||||
defer stopJobs()
|
defer stopJobs()
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||||
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
|
||||||
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
||||||
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
||||||
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package fileuploadcleanup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
cleanupLockKey = "hfb:job:file-upload-cleanup:lock"
|
|
||||||
cleanupInterval = 24 * time.Hour
|
|
||||||
cleanupRetention = 30 * 24 * time.Hour
|
|
||||||
cleanupBatchSize = 200
|
|
||||||
)
|
|
||||||
|
|
||||||
// Job 清理长期未关联业务记录的上传归属,避免临时草稿记录无限增长。
|
|
||||||
type Job struct {
|
|
||||||
db *gorm.DB
|
|
||||||
redis *redis.Client
|
|
||||||
logger *zap.Logger
|
|
||||||
instanceID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
return &Job{db: db, redis: redisClient, logger: logger, instanceID: newInstanceID()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) Start(ctx context.Context) {
|
|
||||||
if j == nil || j.db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go j.loop(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) loop(ctx context.Context) {
|
|
||||||
j.run(ctx, time.Now())
|
|
||||||
ticker := time.NewTicker(cleanupInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
j.logger.Debug("临时文件归属清理任务已停止")
|
|
||||||
return
|
|
||||||
case now := <-ticker.C:
|
|
||||||
j.run(ctx, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) run(ctx context.Context, now time.Time) {
|
|
||||||
release, ok := j.acquireLock(ctx)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
deleted, err := j.cleanup(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
j.logger.Warn("临时文件归属清理失败", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if deleted > 0 {
|
|
||||||
j.logger.Info("已清理未关联临时文件归属", zap.Int("count", deleted))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
|
||||||
if j.redis == nil {
|
|
||||||
return func() {}, true
|
|
||||||
}
|
|
||||||
ok, err := j.redis.SetNX(ctx, cleanupLockKey, j.instanceID, 10*time.Minute).Result()
|
|
||||||
if err != nil {
|
|
||||||
j.logger.Warn("临时文件归属清理任务获取锁失败", zap.Error(err))
|
|
||||||
return func() {}, true
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return func() {
|
|
||||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
|
||||||
if err := script.Run(releaseCtx, j.redis, []string{cleanupLockKey}, j.instanceID).Err(); err != nil {
|
|
||||||
j.logger.Warn("临时文件归属清理任务释放锁失败", zap.Error(err))
|
|
||||||
}
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) cleanup(ctx context.Context, now time.Time) (int, error) {
|
|
||||||
cutoff := now.Add(-cleanupRetention)
|
|
||||||
lastID := uint64(0)
|
|
||||||
deleted := 0
|
|
||||||
for {
|
|
||||||
var records []model.FileUploadOwner
|
|
||||||
if err := j.db.WithContext(ctx).
|
|
||||||
Where("id > ? AND created_at < ?", lastID, cutoff).
|
|
||||||
Order("id ASC").
|
|
||||||
Limit(cleanupBatchSize).
|
|
||||||
Find(&records).Error; err != nil {
|
|
||||||
return deleted, err
|
|
||||||
}
|
|
||||||
if len(records) == 0 {
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
lastID = record.ID
|
|
||||||
referenced, err := j.isReferenced(ctx, record.ObjectKey)
|
|
||||||
if err != nil {
|
|
||||||
return deleted, err
|
|
||||||
}
|
|
||||||
if referenced {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result := j.db.WithContext(ctx).
|
|
||||||
Where("id = ? AND created_at < ?", record.ID, cutoff).
|
|
||||||
Delete(&model.FileUploadOwner{})
|
|
||||||
if result.Error != nil {
|
|
||||||
return deleted, result.Error
|
|
||||||
}
|
|
||||||
deleted += int(result.RowsAffected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) isReferenced(ctx context.Context, key string) (bool, error) {
|
|
||||||
encodedKey := url.QueryEscape(key)
|
|
||||||
queries := []struct {
|
|
||||||
sql string
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{sql: "SELECT COUNT(1) FROM game_accounts WHERE INSTR(screenshot_urls, ?) > 0 OR INSTR(screenshot_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
{sql: "SELECT COUNT(1) FROM order_checkouts WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
{sql: "SELECT COUNT(1) FROM disputes WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
{sql: "SELECT COUNT(1) FROM handoff_records WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
{sql: "SELECT COUNT(1) FROM chat_messages WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
{sql: "SELECT COUNT(1) FROM user_payment_accounts WHERE INSTR(certificate_urls, ?) > 0 OR INSTR(certificate_urls, ?) > 0", args: []any{key, encodedKey}},
|
|
||||||
}
|
|
||||||
for _, query := range queries {
|
|
||||||
var count int64
|
|
||||||
if err := j.db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newInstanceID() string {
|
|
||||||
value := make([]byte, 8)
|
|
||||||
if _, err := rand.Read(value); err != nil {
|
|
||||||
return fmt.Sprintf("file-cleanup-%d", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(value)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package fileuploadcleanup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCleanupOnlyDeletesExpiredUnreferencedUploads(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil {
|
|
||||||
t.Fatalf("AutoMigrate() error = %v", err)
|
|
||||||
}
|
|
||||||
createReferenceTables(t, db)
|
|
||||||
now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
|
|
||||||
old := now.Add(-31 * 24 * time.Hour)
|
|
||||||
recent := now.Add(-29 * 24 * time.Hour)
|
|
||||||
records := []model.FileUploadOwner{
|
|
||||||
{UserID: 1, ObjectKey: "listing/orphan.jpg", CreatedAt: old},
|
|
||||||
{UserID: 1, ObjectKey: "listing/referenced.jpg", CreatedAt: old},
|
|
||||||
{UserID: 1, ObjectKey: "listing/recent.jpg", CreatedAt: recent},
|
|
||||||
}
|
|
||||||
if err := db.Create(&records).Error; err != nil {
|
|
||||||
t.Fatalf("create upload owners error = %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Exec("INSERT INTO game_accounts (id, screenshot_urls) VALUES (1, ?)", `["/api/files/object?key=listing%2Freferenced.jpg"]`).Error; err != nil {
|
|
||||||
t.Fatalf("create referenced account error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
job := New(db, nil, nil)
|
|
||||||
deleted, err := job.cleanup(context.Background(), now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cleanup() error = %v", err)
|
|
||||||
}
|
|
||||||
if deleted != 1 {
|
|
||||||
t.Fatalf("cleanup() deleted = %d, want 1", deleted)
|
|
||||||
}
|
|
||||||
var remaining []model.FileUploadOwner
|
|
||||||
if err := db.Order("id ASC").Find(&remaining).Error; err != nil {
|
|
||||||
t.Fatalf("load remaining uploads error = %v", err)
|
|
||||||
}
|
|
||||||
if len(remaining) != 2 || remaining[0].ObjectKey != "listing/referenced.jpg" || remaining[1].ObjectKey != "listing/recent.jpg" {
|
|
||||||
t.Fatalf("unexpected remaining uploads: %#v", remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite error = %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func createReferenceTables(t *testing.T, db *gorm.DB) {
|
|
||||||
t.Helper()
|
|
||||||
statements := []string{
|
|
||||||
"CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, screenshot_urls TEXT)",
|
|
||||||
"CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
|
|
||||||
"CREATE TABLE disputes (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
|
|
||||||
"CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
|
|
||||||
"CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
|
|
||||||
"CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, certificate_urls TEXT)",
|
|
||||||
}
|
|
||||||
for _, statement := range statements {
|
|
||||||
if err := db.Exec(statement).Error; err != nil {
|
|
||||||
t.Fatalf("create reference table error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
|
|||||||
if err != nil {
|
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
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const (
|
|||||||
ContextAuthCurrentVersion = "auth_current_token_version"
|
ContextAuthCurrentVersion = "auth_current_token_version"
|
||||||
ContextAuthFailureDetail = "auth_failure_detail"
|
ContextAuthFailureDetail = "auth_failure_detail"
|
||||||
AdminAccessCookieName = "hfb_admin_access"
|
AdminAccessCookieName = "hfb_admin_access"
|
||||||
UserAccessCookieName = "hfb_user_access"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminTokenContext struct {
|
type AdminTokenContext struct {
|
||||||
@@ -35,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 "))
|
||||||
@@ -44,20 +45,28 @@ func extractBearerToken(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
|
func extractToken(c *gin.Context) string {
|
||||||
|
if tokenText := extractBearerToken(c); tokenText != "" {
|
||||||
|
return tokenText
|
||||||
|
}
|
||||||
|
return c.Query("token")
|
||||||
|
}
|
||||||
|
|
||||||
func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
|
func extractAdminToken(c *gin.Context) (string, string) {
|
||||||
var validate UserTokenValidatorFunc
|
if tokenText := extractBearerToken(c); tokenText != "" {
|
||||||
if len(validators) > 0 {
|
return tokenText, "bearer"
|
||||||
validate = validators[0]
|
|
||||||
}
|
}
|
||||||
|
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||||
|
if tokenText := strings.TrimSpace(cookieToken); tokenText != "" {
|
||||||
|
return tokenText, "cookie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenText := extractBearerToken(c)
|
tokenText := extractToken(c)
|
||||||
if tokenText == "" {
|
|
||||||
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
|
|
||||||
tokenText = strings.TrimSpace(cookieToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tokenText == "" {
|
if tokenText == "" {
|
||||||
response.Unauthorized(c, "缺少访问令牌")
|
response.Unauthorized(c, "缺少访问令牌")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -72,7 +81,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
|
|||||||
}
|
}
|
||||||
if validate != nil {
|
if validate != nil {
|
||||||
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
|
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
|
||||||
response.Unauthorized(c, "访问令牌无效或已过期")
|
if errors.Is(err, auth.ErrDependencyUnavailable) {
|
||||||
|
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
|
||||||
|
} else {
|
||||||
|
response.Unauthorized(c, "登录状态已失效,请重新登录")
|
||||||
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,16 +99,9 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -10,31 +11,26 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserAuthRejectsQueryToken(t *testing.T) {
|
func TestAuthRejectsRevokedUserToken(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
|
manager := auth.NewJWTManager("test-secret")
|
||||||
pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
|
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
|
t.Fatalf("生成令牌失败:%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.GET("/protected", Auth(manager), func(c *gin.Context) {
|
engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error {
|
||||||
|
return auth.ErrTokenVersionMismatch
|
||||||
|
}), func(c *gin.Context) {
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
|
||||||
queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
|
if response.Code != http.StatusUnauthorized {
|
||||||
queryResponse := httptest.NewRecorder()
|
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized)
|
||||||
engine.ServeHTTP(queryResponse, queryRequest)
|
|
||||||
if queryResponse.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
||||||
bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
|
||||||
bearerResponse := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(bearerResponse, bearerRequest)
|
|
||||||
if bearerResponse.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +62,15 @@ 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 {
|
||||||
|
return func(c *gin.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
key := c.ClientIP()
|
key := keyFunc(c)
|
||||||
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
|
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
allowed, resetAt = l.fallback.allow(key, now)
|
allowed, resetAt = l.fallback.allow(key, now)
|
||||||
@@ -65,17 +80,44 @@ func (l *redisRateLimiter) handle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *rateLimiter) handle(c *gin.Context) {
|
func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
key := c.ClientIP()
|
key := keyFunc(c)
|
||||||
allowed, resetAt := l.allow(key, now)
|
allowed, resetAt := l.allow(key, now)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
writeRateLimited(c, now, resetAt)
|
writeRateLimited(c, now, resetAt)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipRateLimitKey(c *gin.Context) string {
|
||||||
|
return "ip:" + c.ClientIP()
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminOrIPRateLimitKey(jwtManager *auth.JWTManager) rateLimitKeyFunc {
|
||||||
|
return func(c *gin.Context) string {
|
||||||
|
if value, ok := c.Get(ContextAdminID); ok {
|
||||||
|
if adminID, ok := value.(uint64); ok && adminID != 0 {
|
||||||
|
return "admin:" + strconv.FormatUint(adminID, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jwtManager != nil {
|
||||||
|
tokenText, _ := extractAdminToken(c)
|
||||||
|
if tokenText != "" {
|
||||||
|
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
||||||
|
if err == nil && claims.UserID != 0 {
|
||||||
|
return "admin:" + strconv.FormatUint(claims.UserID, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ipRateLimitKey(c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
|
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runRateLimitedRequest(router *gin.Engine, bearerToken string) int {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
if bearerToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
return recorder.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimitRouter(limit int, rdb interface{}, handler gin.HandlerFunc) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(handler)
|
||||||
|
router.GET("/ping", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminAccessToken(t *testing.T, jwtManager *auth.JWTManager, adminID uint64, phone string) string {
|
||||||
|
t.Helper()
|
||||||
|
pair, err := jwtManager.GenerateSubjectPairWithVersion(adminID, phone, "admin", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成后台令牌失败: %v", err)
|
||||||
|
}
|
||||||
|
return pair.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAwareRateLimitIsolatesAdminsBehindSharedIP(t *testing.T) {
|
||||||
|
jwtManager := auth.NewJWTManager("test-secret")
|
||||||
|
adminA := adminAccessToken(t, jwtManager, 101, "13800000001")
|
||||||
|
adminB := adminAccessToken(t, jwtManager, 102, "13800000002")
|
||||||
|
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if code := runRateLimitedRequest(router, adminA); code != http.StatusOK {
|
||||||
|
t.Fatalf("客服A 第 %d 次请求 = %d, want 200", i+1, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := runRateLimitedRequest(router, adminA); code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("客服A 超额请求 = %d, want 429", code)
|
||||||
|
}
|
||||||
|
if code := runRateLimitedRequest(router, adminB); code != http.StatusOK {
|
||||||
|
t.Fatalf("同出口IP的客服B 应不受客服A影响, got %d, want 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAwareRateLimitFallsBackToIP(t *testing.T) {
|
||||||
|
jwtManager := auth.NewJWTManager("test-secret")
|
||||||
|
userPair, err := jwtManager.GenerateSubjectPairWithVersion(201, "13900000001", "user", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成用户令牌失败: %v", err)
|
||||||
|
}
|
||||||
|
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("匿名请求 第 %d 次 = %d, want 200", i+1, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("匿名请求超额 = %d, want 429", code)
|
||||||
|
}
|
||||||
|
// 用户令牌无法按 admin 身份解析,应与匿名共享同一个 IP 桶。
|
||||||
|
if code := runRateLimitedRequest(router, userPair.AccessToken); code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("用户令牌应回退到IP维度, got %d, want 429", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyRateLimitPerMinuteKeepsIPKey(t *testing.T) {
|
||||||
|
router := newRateLimitRouter(1, nil, RateLimitPerMinute(1, nil))
|
||||||
|
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("首次匿名请求 = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("第二次匿名请求 = %d, want 429", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,16 +108,25 @@ func isPaymentNotifyPath(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
|
// 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) {
|
||||||
|
|||||||
@@ -4,12 +4,23 @@ 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"`
|
||||||
|
// 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。
|
||||||
|
// 这样读取会话列表不必每次扫描全部订单再按商品分组。
|
||||||
|
LatestOrderID *uint64 `gorm:"index" json:"-"`
|
||||||
|
LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"`
|
||||||
|
LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"`
|
||||||
|
LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||||
|
LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||||
|
// 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。
|
||||||
|
LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
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"`
|
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
|
||||||
Title string `gorm:"size:128;not null" json:"title"`
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
@@ -42,6 +53,23 @@ 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"`
|
||||||
@@ -51,6 +79,7 @@ type ChatMessage struct {
|
|||||||
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +87,24 @@ 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"`
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// FileUploadOwner 记录用户上传的私有文件归属,用于业务关联创建前的临时访问授权。
|
|
||||||
type FileUploadOwner struct {
|
|
||||||
ID uint64 `gorm:"primaryKey"`
|
|
||||||
UserID uint64 `gorm:"not null;index"`
|
|
||||||
ObjectKey string `gorm:"size:512;not null;uniqueIndex"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (FileUploadOwner) TableName() string {
|
|
||||||
return "file_upload_owners"
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"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 (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。
|
||||||
|
// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。
|
||||||
|
type ProcessEvent struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"`
|
||||||
|
BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"`
|
||||||
|
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
|
||||||
|
Action string `gorm:"size:64;not null" json:"action"`
|
||||||
|
ActorType string `gorm:"size:16;not null" json:"actor_type"`
|
||||||
|
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
|
||||||
|
ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"`
|
||||||
|
TargetType string `gorm:"size:16;not null;default:''" json:"target_type"`
|
||||||
|
TargetID *uint64 `json:"target_id,omitempty"`
|
||||||
|
TargetName string `gorm:"size:128;not null;default:''" json:"target_name"`
|
||||||
|
Content string `gorm:"type:text" json:"content"`
|
||||||
|
Reason string `gorm:"type:text" json:"reason"`
|
||||||
|
Payload datatypes.JSON `gorm:"not null" json:"payload"`
|
||||||
|
AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"`
|
||||||
|
StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"`
|
||||||
|
StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ProcessEvent) TableName() string {
|
||||||
|
return "process_events"
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ type User struct {
|
|||||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
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:1" json:"-"`
|
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"`
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type ListingDailyOverviewDTO struct {
|
|||||||
Trend []ListingDayStatsDTO `json:"trend"`
|
Trend []ListingDayStatsDTO `json:"trend"`
|
||||||
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||||
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||||
|
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
|
||||||
|
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
|
||||||
Days int `json:"days"`
|
Days int `json:"days"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
TodayUploaders: todayUploaders,
|
||||||
|
UploaderTrend: uploaderTrend,
|
||||||
Days: days,
|
Days: days,
|
||||||
Timezone: "Asia/Shanghai",
|
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 {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
|
||||||
|
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
|
||||||
|
var (
|
||||||
|
dailyItems []FinanceDailyDTO
|
||||||
|
pickup *PickupSummaryDTO
|
||||||
|
mohong *MohongSummaryDTO
|
||||||
|
disbursement *DisbursementSummaryDTO
|
||||||
|
operatingExpense *OperatingExpenseSummaryDTO
|
||||||
|
)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, 5)
|
||||||
|
run := func(fn func() error) {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
run(func() error {
|
||||||
|
var err error
|
||||||
|
dailyItems, err = r.dailyItems(ctx, query)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
run(func() error {
|
||||||
|
var err error
|
||||||
|
pickup, err = r.pickupSummary(ctx, query)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
run(func() error {
|
||||||
|
var err error
|
||||||
|
mohong, err = r.mohongSummary(ctx, query)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
run(func() error {
|
||||||
|
var err error
|
||||||
|
disbursement, err = r.disbursementSummary(ctx, query)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
run(func() error {
|
||||||
|
var err error
|
||||||
|
operatingExpense, err = r.operatingExpenseSummary(ctx, query)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pickup, err := r.pickupSummary(ctx, query)
|
|
||||||
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{
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
disbursement, err := r.disbursementSummary(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &DashboardDTO{
|
|
||||||
Summary: *summary,
|
Summary: *summary,
|
||||||
DailyItems: dailyItems,
|
DailyItems: dailyItems,
|
||||||
PickupSummary: *pickup,
|
PickupSummary: *pickup,
|
||||||
MohongSummary: *mohong,
|
MohongSummary: *mohong,
|
||||||
DisbursementSummary: *disbursement,
|
DisbursementSummary: *disbursement,
|
||||||
|
OperatingExpenseSummary: *operatingExpense,
|
||||||
GeneratedAt: timeutil.ShanghaiNow(),
|
GeneratedAt: timeutil.ShanghaiNow(),
|
||||||
}, nil
|
}
|
||||||
|
r.storeDashboardCache(ctx, query, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) {
|
||||||
|
var summary OperatingExpenseSummaryDTO
|
||||||
|
err := r.db.WithContext(ctx).Table("operating_expenses").
|
||||||
|
Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count").
|
||||||
|
Where("status = ?", "paid").
|
||||||
|
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
|
||||||
|
Scan(&summary).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
|
// 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
|
||||||
@@ -147,26 +200,25 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *
|
|||||||
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,
|
||||||
@@ -227,6 +230,7 @@ type disbursementItemRow struct {
|
|||||||
OperatorName string
|
OperatorName string
|
||||||
Remark string
|
Remark string
|
||||||
Category string
|
Category string
|
||||||
|
CustomCategoryName string
|
||||||
VoucherURL string
|
VoucherURL string
|
||||||
VoidedAt *time.Time
|
VoidedAt *time.Time
|
||||||
VoidedBy *uint64
|
VoidedBy *uint64
|
||||||
@@ -271,6 +275,7 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
|
|||||||
OperatorName: r.OperatorName,
|
OperatorName: r.OperatorName,
|
||||||
Remark: r.Remark,
|
Remark: r.Remark,
|
||||||
Category: r.Category,
|
Category: r.Category,
|
||||||
|
CustomCategoryName: r.CustomCategoryName,
|
||||||
VoucherURL: r.VoucherURL,
|
VoucherURL: r.VoucherURL,
|
||||||
VoidedAt: r.VoidedAt,
|
VoidedAt: r.VoidedAt,
|
||||||
VoidedBy: r.VoidedBy,
|
VoidedBy: r.VoidedBy,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,24 @@ type DisbursementQuery struct {
|
|||||||
PageSize int
|
PageSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OperatingExpenseQuery struct {
|
||||||
|
Status string
|
||||||
|
Category string
|
||||||
|
Keyword string
|
||||||
|
DateType string
|
||||||
|
StartDate time.Time
|
||||||
|
EndDate time.Time
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardDTO struct {
|
type DashboardDTO struct {
|
||||||
Summary FinanceSummaryDTO `json:"summary"`
|
Summary FinanceSummaryDTO `json:"summary"`
|
||||||
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
||||||
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
||||||
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
|
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
|
||||||
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
|
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
|
||||||
|
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"`
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +113,25 @@ 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"`
|
||||||
@@ -127,6 +158,7 @@ type DisbursementItemDTO struct {
|
|||||||
OperatorName string `json:"operator_name"`
|
OperatorName string `json:"operator_name"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
CustomCategoryName string `json:"custom_category_name"`
|
||||||
VoucherURL string `json:"voucher_url"`
|
VoucherURL string `json:"voucher_url"`
|
||||||
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||||
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||||
@@ -136,6 +168,7 @@ type DisbursementItemDTO struct {
|
|||||||
|
|
||||||
type CreateManualDisbursementRequest struct {
|
type CreateManualDisbursementRequest struct {
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
CustomCategoryName string `json:"custom_category_name"`
|
||||||
PayeeName string `json:"payee_name"`
|
PayeeName string `json:"payee_name"`
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
PaidAt time.Time `json:"paid_at"`
|
PaidAt time.Time `json:"paid_at"`
|
||||||
@@ -151,6 +184,7 @@ type ManualDisbursementDTO struct {
|
|||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
DisbursementNo string `json:"disbursement_no"`
|
DisbursementNo string `json:"disbursement_no"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
CustomCategoryName string `json:"custom_category_name"`
|
||||||
PayeeName string `json:"payee_name"`
|
PayeeName string `json:"payee_name"`
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
PaidAt time.Time `json:"paid_at"`
|
PaidAt time.Time `json:"paid_at"`
|
||||||
@@ -166,6 +200,38 @@ type ManualDisbursementDTO struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateOperatingExpenseRequest struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
PayeeName string `json:"payee_name"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
VoucherURL string `json:"voucher_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoidOperatingExpenseRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperatingExpenseDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
ExpenseNo string `json:"expense_no"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
PayeeName string `json:"payee_name"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
VoucherURL string `json:"voucher_url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedBy uint64 `json:"created_by"`
|
||||||
|
CreatedByName string `json:"created_by_name"`
|
||||||
|
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||||
|
VoidedByName string `json:"voided_by_name"`
|
||||||
|
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||||
|
VoidReason string `json:"void_reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type FinanceSummaryDTO struct {
|
type FinanceSummaryDTO struct {
|
||||||
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
|
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
|
||||||
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
|
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type manualDisbursementRecord struct {
|
|||||||
ID uint64
|
ID uint64
|
||||||
DisbursementNo string
|
DisbursementNo string
|
||||||
Category string
|
Category string
|
||||||
|
CustomCategoryName string
|
||||||
PayeeName string
|
PayeeName string
|
||||||
AmountCent int64
|
AmountCent int64
|
||||||
PaidAt time.Time
|
PaidAt time.Time
|
||||||
@@ -50,6 +51,7 @@ func (r *Repository) CreateManualDisbursement(
|
|||||||
record := manualDisbursementRecord{
|
record := manualDisbursementRecord{
|
||||||
DisbursementNo: disbursementNo,
|
DisbursementNo: disbursementNo,
|
||||||
Category: req.Category,
|
Category: req.Category,
|
||||||
|
CustomCategoryName: req.CustomCategoryName,
|
||||||
PayeeName: req.PayeeName,
|
PayeeName: req.PayeeName,
|
||||||
AmountCent: req.AmountCent,
|
AmountCent: req.AmountCent,
|
||||||
PaidAt: req.PaidAt,
|
PaidAt: req.PaidAt,
|
||||||
@@ -73,6 +75,7 @@ func (r *Repository) CreateManualDisbursement(
|
|||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"disbursement_no": disbursementNo,
|
"disbursement_no": disbursementNo,
|
||||||
"category": req.Category,
|
"category": req.Category,
|
||||||
|
"custom_category_name": req.CustomCategoryName,
|
||||||
"payee_name": req.PayeeName,
|
"payee_name": req.PayeeName,
|
||||||
"amount_cent": req.AmountCent,
|
"amount_cent": req.AmountCent,
|
||||||
"paid_at": req.PaidAt,
|
"paid_at": req.PaidAt,
|
||||||
@@ -156,6 +159,7 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
|
|||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
DisbursementNo: row.DisbursementNo,
|
DisbursementNo: row.DisbursementNo,
|
||||||
Category: row.Category,
|
Category: row.Category,
|
||||||
|
CustomCategoryName: row.CustomCategoryName,
|
||||||
PayeeName: row.PayeeName,
|
PayeeName: row.PayeeName,
|
||||||
AmountCent: row.AmountCent,
|
AmountCent: row.AmountCent,
|
||||||
PaidAt: row.PaidAt,
|
PaidAt: row.PaidAt,
|
||||||
@@ -176,6 +180,7 @@ type manualDisbursementDetailRow struct {
|
|||||||
ID uint64
|
ID uint64
|
||||||
DisbursementNo string
|
DisbursementNo string
|
||||||
Category string
|
Category string
|
||||||
|
CustomCategoryName string
|
||||||
PayeeName string
|
PayeeName string
|
||||||
AmountCent int64
|
AmountCent int64
|
||||||
PaidAt time.Time
|
PaidAt time.Time
|
||||||
|
|||||||
@@ -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,7 +47,8 @@ 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",
|
||||||
|
CustomCategoryName: "临时活动支出",
|
||||||
PayeeName: "测试供应商",
|
PayeeName: "测试供应商",
|
||||||
AmountCent: 8800,
|
AmountCent: 8800,
|
||||||
PaidAt: paidAt,
|
PaidAt: paidAt,
|
||||||
@@ -57,7 +58,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
|||||||
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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,12 +221,13 @@ 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 user.TokenVersion < 1 {
|
if beforeStatus != status {
|
||||||
user.TokenVersion = 1
|
|
||||||
}
|
|
||||||
user.TokenVersion++
|
user.TokenVersion++
|
||||||
|
statusChanged = true
|
||||||
|
}
|
||||||
if err := tx.Save(&user).Error; err != nil {
|
if err := tx.Save(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -230,11 +238,16 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
|||||||
"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
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAccessCookie(c, result.Tokens.AccessToken)
|
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +133,13 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
|
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDependencyUnavailable) {
|
||||||
|
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Unauthorized(c, "刷新令牌无效或已过期")
|
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAccessCookie(c, tokens.AccessToken)
|
|
||||||
response.OK(c, tokens)
|
response.OK(c, tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,16 +153,6 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /auth/logout [post]
|
// @Router /auth/logout [post]
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
func (h *Handler) Logout(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
|
|
||||||
writeAuthError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearAccessCookie(c)
|
|
||||||
response.OK(c, gin.H{"logged_out": true})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +167,6 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAccessCookie(c, result.Tokens.AccessToken)
|
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +181,6 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAccessCookie(c, result.Tokens.AccessToken)
|
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,17 +257,3 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
userID, ok := val.(uint64)
|
userID, ok := val.(uint64)
|
||||||
return userID, ok
|
return userID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAccessCookie(c *gin.Context, token string) {
|
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
|
||||||
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearAccessCookie(c *gin.Context) {
|
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
|
||||||
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestUsesHTTPS(c *gin.Context) bool {
|
|
||||||
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,14 +32,19 @@ func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, toke
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
user, err := r.FindByID(ctx, id)
|
var user model.User
|
||||||
if err != nil {
|
if err := r.db.WithContext(ctx).
|
||||||
|
Select("id, phone, status, token_version").
|
||||||
|
First(&user, id).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion {
|
if user.Status != "active" {
|
||||||
return nil, ErrUserDisabled
|
return nil, ErrUserDisabled
|
||||||
}
|
}
|
||||||
return user, nil
|
if user.TokenVersion != tokenVersion {
|
||||||
|
return nil, ErrTokenVersionMismatch
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
|
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
|
||||||
@@ -61,7 +66,6 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
|
|||||||
RiskStatus: "normal",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
TokenVersion: 1,
|
|
||||||
LastLoginAt: &now,
|
LastLoginAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,16 +93,7 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
|
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
|
||||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
|
||||||
"password_hash": hash,
|
|
||||||
"token_version": gorm.Expr("token_version + 1"),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。
|
|
||||||
func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error {
|
|
||||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
|
|
||||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
|
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
|
||||||
@@ -111,7 +106,6 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
|
|||||||
RiskStatus: "normal",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
TokenVersion: 1,
|
|
||||||
LastLoginAt: &now,
|
LastLoginAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.issueTokenPair(user)
|
tokens, err := s.generateUserTokenPair(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
|
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
|
||||||
if s.users == nil {
|
if s.jwt == nil || s.users == nil {
|
||||||
return TokenPair{}, ErrDependencyUnavailable
|
return TokenPair{}, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
|
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
|
||||||
@@ -201,18 +202,14 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
return s.issueTokenPair(user)
|
return s.generateUserTokenPair(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) {
|
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) {
|
||||||
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
|
if s.jwt == nil {
|
||||||
}
|
return TokenPair{}, ErrDependencyUnavailable
|
||||||
|
|
||||||
func (s *Service) Logout(ctx context.Context, userID uint64) error {
|
|
||||||
if s.users == nil {
|
|
||||||
return ErrDependencyUnavailable
|
|
||||||
}
|
}
|
||||||
return s.users.RevokeTokens(ctx, userID)
|
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func codeKey(phone string) string {
|
func codeKey(phone string) string {
|
||||||
@@ -282,7 +279,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
|
|||||||
|
|
||||||
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
||||||
|
|
||||||
tokens, err := s.issueTokenPair(user)
|
tokens, err := s.generateUserTokenPair(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -387,7 +384,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
|
|||||||
return LoginResult{}, ErrUserDisabled
|
return LoginResult{}, ErrUserDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := s.issueTokenPair(user)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,7 @@ type MessageDTO struct {
|
|||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +248,10 @@ 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",
|
||||||
@@ -255,6 +259,7 @@ func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -152,6 +164,7 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
|||||||
ContentType: msg.ContentType,
|
ContentType: msg.ContentType,
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
AttachmentURLS: msg.AttachmentURLS,
|
AttachmentURLS: msg.AttachmentURLS,
|
||||||
|
AdminAttentionType: msg.AdminAttentionType,
|
||||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -166,22 +179,45 @@ 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 记录,更新已读时间
|
|
||||||
updated = true
|
|
||||||
return tx.Model(&participant).Update("last_read_at", now).Error
|
|
||||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
lastMessageID := uint64(0)
|
||||||
|
if conversation.LastMessageID != nil {
|
||||||
|
lastMessageID = *conversation.LastMessageID
|
||||||
|
}
|
||||||
|
var existing model.ChatAdminConversationState
|
||||||
|
err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID).
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil && existing.LastReadMessageID >= lastMessageID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := model.ChatAdminConversationState{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AdminUserID: principal.ID,
|
||||||
|
LastReadMessageID: lastMessageID,
|
||||||
|
LastReadAt: &now,
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"last_read_message_id": lastMessageID,
|
||||||
|
"last_read_at": now,
|
||||||
|
}),
|
||||||
|
}).Create(&state).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 普通用户必须是 participant
|
// 普通用户必须是 participant
|
||||||
participant, err := r.findParticipant(tx, principal, conversationID, true)
|
participant, err := r.findParticipant(tx, principal, conversationID, true)
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +46,7 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
|
|||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM chat_messages AS cm
|
FROM chat_messages AS cm
|
||||||
WHERE cm.conversation_id = c.id
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND cm.sender_type <> 'system'
|
||||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||||
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
|
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
|
||||||
) AS unread_count`, principal.Type, principal.ID).
|
) AS unread_count`, principal.Type, principal.ID).
|
||||||
@@ -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)
|
||||||
@@ -170,6 +189,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
ContentType: row.ContentType,
|
ContentType: row.ContentType,
|
||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||||
|
AdminAttentionType: row.AdminAttentionType,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,8 +64,86 @@ var (
|
|||||||
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
|
||||||
|
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "bearer "+config.Token)
|
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))
|
return req, err
|
||||||
resp.Body.Close()
|
})
|
||||||
|
if err != nil {
|
||||||
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()...)
|
||||||
if err != nil {
|
formContentType := writer.FormDataContentType()
|
||||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
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("Authorization", "bearer "+config.Token)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", formContentType)
|
||||||
|
|
||||||
resp, err := ocrHTTPClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
return req, err
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
})
|
||||||
if resp.StatusCode != http.StatusOK {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
|
return "", err
|
||||||
}
|
}
|
||||||
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
|
||||||
if err != nil {
|
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
|
||||||
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||||
}
|
if err == nil {
|
||||||
req.Header.Set("Authorization", "bearer "+config.Token)
|
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))
|
return req, err
|
||||||
_ = resp.Body.Close()
|
})
|
||||||
if resp.StatusCode != http.StatusOK {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
|
return "", err
|
||||||
}
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t).Error
|
||||||
Updates(map[string]interface{}{
|
if currentErr == nil {
|
||||||
|
if current.Role != "support" {
|
||||||
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
|
// 只转接当前客服本人,避免误删同群里的其他客服。
|
||||||
|
if err := tx.Model(¤t).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) {
|
||||||
message := model.ChatMessage{
|
// 未分配会话允许从“全部/未分配”直接指派给目标客服。
|
||||||
|
if err := tx.Create(&model.ChatParticipant{
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
SenderType: "system",
|
ParticipantType: "admin",
|
||||||
SenderRole: "system",
|
ParticipantID: toAdminID,
|
||||||
ContentType: "system",
|
Role: "support",
|
||||||
Content: "会话已转接给其他客服",
|
JoinedAt: time.Now(),
|
||||||
AttachmentURLS: emptyJSONList(),
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Create(&message).Error; err != nil {
|
} else {
|
||||||
|
return currentErr
|
||||||
|
}
|
||||||
|
if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil {
|
||||||
return err
|
return 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, row.ID)
|
||||||
|
}
|
||||||
|
participantsByConversation, err := r.participantsForConversations(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items := make([]ConversationDTO, 0, len(rows))
|
items := make([]ConversationDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
participants, err := r.participants(ctx, row.ID)
|
items = append(items, row.toDTO(participantsByConversation[row.ID]))
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, row.toDTO(participants))
|
|
||||||
}
|
}
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||||
|
filter = normalizeAdminChatFilter(filter)
|
||||||
|
stage = normalizeAdminChatStage(stage)
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
|
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO)
|
||||||
|
return result.DTO, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
|
||||||
|
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
|
||||||
|
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
|
||||||
|
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
|
||||||
|
var total int64
|
||||||
|
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
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)
|
|
||||||
var count int64
|
|
||||||
if err := db.Count(&count).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
counts.Ownership[item] = count
|
stages := make(map[string]string, len(adminChatStages))
|
||||||
|
stageArgs := make(map[string][]interface{}, len(adminChatStages))
|
||||||
|
for _, item := range adminChatStages {
|
||||||
|
stages[item], stageArgs[item] = adminChatStageCondition(item, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages))
|
||||||
|
args := make([]interface{}, 0)
|
||||||
|
addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) {
|
||||||
|
parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias)
|
||||||
|
args = append(args, leftArgs...)
|
||||||
|
args = append(args, rightArgs...)
|
||||||
|
}
|
||||||
|
addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage])
|
||||||
|
for _, item := range adminChatFilters {
|
||||||
|
addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage])
|
||||||
}
|
}
|
||||||
for _, item := range adminChatStages {
|
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
|
type row struct {
|
||||||
if err := db.Count(&count).Error; err != nil {
|
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 nil, err
|
||||||
}
|
}
|
||||||
counts.Stages[item] = count
|
return &adminConversationCountsResult{
|
||||||
}
|
Total: result.Total,
|
||||||
return counts, nil
|
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 校验会阻断所有成员继续发言。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type MessageData struct {
|
|||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +36,7 @@ type DisputeDTO struct {
|
|||||||
CheckoutID *uint64 `json:"checkout_id"`
|
CheckoutID *uint64 `json:"checkout_id"`
|
||||||
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
|
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
|
||||||
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
|
CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
|
||||||
|
CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"`
|
||||||
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
||||||
ArbitrationResult string `json:"arbitration_result"`
|
ArbitrationResult string `json:"arbitration_result"`
|
||||||
ArbitrationRemark string `json:"arbitration_remark"`
|
ArbitrationRemark string `json:"arbitration_remark"`
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type disputeRow struct {
|
|||||||
RenterPhone string
|
RenterPhone string
|
||||||
CheckoutCoinConsumedM *float64
|
CheckoutCoinConsumedM *float64
|
||||||
CheckoutConsumableAmountCent *int64
|
CheckoutConsumableAmountCent *int64
|
||||||
|
CheckoutDepositDeductAmountCent *int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row disputeRow) toDTO() DisputeDTO {
|
func (row disputeRow) toDTO() DisputeDTO {
|
||||||
@@ -54,6 +55,7 @@ func (row disputeRow) toDTO() DisputeDTO {
|
|||||||
CheckoutID: row.CheckoutID,
|
CheckoutID: row.CheckoutID,
|
||||||
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
|
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
|
||||||
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
|
CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
|
||||||
|
CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent,
|
||||||
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
||||||
ArbitrationResult: row.ArbitrationResult,
|
ArbitrationResult: row.ArbitrationResult,
|
||||||
ArbitrationRemark: row.ArbitrationRemark,
|
ArbitrationRemark: row.ArbitrationRemark,
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -422,6 +497,7 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,4 @@ type UploadDTO struct {
|
|||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
objectKeys []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
|
|
||||||
func (d *UploadDTO) ObjectKeys() []string {
|
|
||||||
if d == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return append([]string(nil), d.objectKeys...)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
package file
|
package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/logging"
|
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
storage *Storage
|
storage *Storage
|
||||||
objectAuthorizer ObjectAuthorizer
|
|
||||||
uploadOwnerRecorder UploadOwnerRecorder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
|
func NewHandler(service *Service, storage *Storage) *Handler {
|
||||||
type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error)
|
return &Handler{service: service, storage: storage}
|
||||||
|
|
||||||
// UploadOwnerRecorder 保存用户上传私有文件的归属。
|
|
||||||
type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error
|
|
||||||
|
|
||||||
func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler {
|
|
||||||
return &Handler{
|
|
||||||
service: service,
|
|
||||||
storage: storage,
|
|
||||||
objectAuthorizer: objectAuthorizer,
|
|
||||||
uploadOwnerRecorder: uploadOwnerRecorder,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Upload(c *gin.Context) {
|
func (h *Handler) Upload(c *gin.Context) {
|
||||||
@@ -60,19 +44,6 @@ func (h *Handler) Upload(c *gin.Context) {
|
|||||||
writeFileError(c, err)
|
writeFileError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil {
|
|
||||||
id, valid := userID.(uint64)
|
|
||||||
if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil {
|
|
||||||
// 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。
|
|
||||||
if h.storage != nil {
|
|
||||||
if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil {
|
|
||||||
logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response.ServiceUnavailable(c, "文件归属记录失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response.Created(c, item)
|
response.Created(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +68,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
if publicOnly &&
|
if publicOnly &&
|
||||||
!strings.HasPrefix(key, "home-banner/") &&
|
!strings.HasPrefix(key, "home-banner/") &&
|
||||||
!strings.HasPrefix(key, "avatar/") &&
|
!strings.HasPrefix(key, "avatar/") &&
|
||||||
|
!strings.HasPrefix(key, "payment-cert/") &&
|
||||||
!strings.HasPrefix(key, "announcement/") &&
|
!strings.HasPrefix(key, "announcement/") &&
|
||||||
!strings.HasPrefix(key, "mohong/") &&
|
!strings.HasPrefix(key, "mohong/") &&
|
||||||
!strings.HasPrefix(key, "crash/") &&
|
!strings.HasPrefix(key, "crash/") &&
|
||||||
@@ -105,29 +77,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !publicOnly {
|
|
||||||
if _, isAdmin := c.Get("admin_id"); !isAdmin {
|
|
||||||
userID, ok := c.Get("user_id")
|
|
||||||
if !ok {
|
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, ok := userID.(uint64)
|
|
||||||
if !ok || h.objectAuthorizer == nil {
|
|
||||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
allowed, err := h.objectAuthorizer(c.Request.Context(), id, key)
|
|
||||||
if err != nil {
|
|
||||||
response.ServiceUnavailable(c, "文件权限校验失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
object, err := h.storage.Get(c.Request.Context(), key)
|
object, err := h.storage.Get(c.Request.Context(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
}
|
}
|
||||||
var thumbnailURL string
|
var thumbnailURL string
|
||||||
var mediumURL string
|
var mediumURL string
|
||||||
objectKeys := []string{key}
|
|
||||||
for _, variant := range generateImageVariants(key, data, contentType) {
|
for _, variant := range generateImageVariants(key, data, contentType) {
|
||||||
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
|
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
|
||||||
"source-object": key,
|
"source-object": key,
|
||||||
@@ -69,7 +68,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
objectKeys = append(objectKeys, variant.Key)
|
|
||||||
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
||||||
thumbnailURL = fileURLForScene(scene, variant.Key)
|
thumbnailURL = fileURLForScene(scene, variant.Key)
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,6 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
Filename: req.Header.Filename,
|
Filename: req.Header.Filename,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
objectKeys: objectKeys,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
|||||||
|
|
||||||
func fileURLForScene(scene string, key string) string {
|
func fileURLForScene(scene string, key string) string {
|
||||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
||||||
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||||
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||||
}
|
}
|
||||||
return fileURL
|
return fileURL
|
||||||
|
|||||||
@@ -157,36 +157,6 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。
|
|
||||||
func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error {
|
|
||||||
var firstErr error
|
|
||||||
for _, key := range keys {
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := s.removeObject(ctx, key); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
if s.mirror != nil {
|
|
||||||
if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) removeObject(ctx context.Context, key string) error {
|
|
||||||
if err := s.ensureBucketReady(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
|
|
||||||
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) ensureBucket(ctx context.Context) error {
|
func (s *Storage) ensureBucket(ctx context.Context) error {
|
||||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -43,33 +43,6 @@ type ListingDTO struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicListingListItemDTO 是首页商品卡片的最小公开数据。
|
|
||||||
// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。
|
|
||||||
type PublicListingListItemDTO struct {
|
|
||||||
ID uint64 `json:"id"`
|
|
||||||
ListingNo string `json:"listing_no"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
GameName string `json:"game_name"`
|
|
||||||
ServerRegion string `json:"server_region"`
|
|
||||||
LoginPlatform string `json:"login_platform"`
|
|
||||||
RankLevel string `json:"rank_level"`
|
|
||||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
|
||||||
AssetSummary map[string]any `json:"asset_summary,omitempty"`
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
PriceCent int64 `json:"price_cent"`
|
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
|
||||||
IsAccelerated bool `json:"is_accelerated_sale"`
|
|
||||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。
|
|
||||||
type PublicListingDetailDTO struct {
|
|
||||||
PublicListingListItemDTO
|
|
||||||
Description string `json:"description"`
|
|
||||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -186,7 +159,7 @@ type NumberRange struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicListResult struct {
|
type PublicListResult struct {
|
||||||
Items []PublicListingListItemDTO `json:"items"`
|
Items []ListingDTO `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ func externalAccountToCreateRequest(
|
|||||||
"resources": resources,
|
"resources": resources,
|
||||||
"skin_groups": externalSkinGroups(skins),
|
"skin_groups": externalSkinGroups(skins),
|
||||||
"online_time_text": onlineTimeText,
|
"online_time_text": onlineTimeText,
|
||||||
|
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
|
||||||
|
"early_morning_response": "否",
|
||||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||||
"common_regions": commonRegions(item.CommonRegion),
|
"common_regions": commonRegions(item.CommonRegion),
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -71,100 +71,9 @@ func applyPublicListingURLs(item *ListingDTO) {
|
|||||||
if item.AssetSummary != nil {
|
if item.AssetSummary != nil {
|
||||||
delete(item.AssetSummary, "import_meta")
|
delete(item.AssetSummary, "import_meta")
|
||||||
delete(item.AssetSummary, "price_breakdown")
|
delete(item.AssetSummary, "price_breakdown")
|
||||||
// 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。
|
|
||||||
delete(item.AssetSummary, "screenshot_groups")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO {
|
|
||||||
result := make([]PublicListingListItemDTO, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
result = append(result, item.toPublicListItem())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (item ListingDTO) toPublicListItem() PublicListingListItemDTO {
|
|
||||||
return PublicListingListItemDTO{
|
|
||||||
ID: item.ID,
|
|
||||||
ListingNo: item.ListingNo,
|
|
||||||
Title: item.Title,
|
|
||||||
GameName: item.GameName,
|
|
||||||
ServerRegion: item.ServerRegion,
|
|
||||||
LoginPlatform: item.LoginPlatform,
|
|
||||||
RankLevel: item.RankLevel,
|
|
||||||
HafCoinAmount: item.HafCoinAmount,
|
|
||||||
AssetSummary: publicListAssetSummary(item.AssetSummary),
|
|
||||||
CoverURL: item.CoverURL,
|
|
||||||
PriceCent: item.PriceCent,
|
|
||||||
DepositAmountCent: item.DepositAmountCent,
|
|
||||||
IsAccelerated: item.IsAccelerated,
|
|
||||||
PublishedAt: item.PublishedAt,
|
|
||||||
CreatedAt: item.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (item ListingDTO) toPublicDetail() PublicListingDetailDTO {
|
|
||||||
listItem := item.toPublicListItem()
|
|
||||||
listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary)
|
|
||||||
return PublicListingDetailDTO{
|
|
||||||
PublicListingListItemDTO: listItem,
|
|
||||||
Description: item.Description,
|
|
||||||
ScreenshotURLS: item.ScreenshotURLS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。
|
|
||||||
func publicListAssetSummary(summary map[string]any) map[string]any {
|
|
||||||
return selectPublicAssetSummary(summary, []string{
|
|
||||||
"season_insurance",
|
|
||||||
"stamina_level",
|
|
||||||
"load_level",
|
|
||||||
"resources",
|
|
||||||
"skin_groups",
|
|
||||||
"total_asset_wan",
|
|
||||||
"online_time",
|
|
||||||
"can_change_game_name",
|
|
||||||
"all_hero",
|
|
||||||
"daily_loss_m",
|
|
||||||
"publish_ratio",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。
|
|
||||||
func publicDetailAssetSummary(summary map[string]any) map[string]any {
|
|
||||||
return selectPublicAssetSummary(summary, []string{
|
|
||||||
"season_insurance",
|
|
||||||
"stamina_level",
|
|
||||||
"load_level",
|
|
||||||
"resources",
|
|
||||||
"skin_groups",
|
|
||||||
"total_asset_wan",
|
|
||||||
"online_time",
|
|
||||||
"can_change_game_name",
|
|
||||||
"all_hero",
|
|
||||||
"daily_loss_m",
|
|
||||||
"publish_ratio",
|
|
||||||
"secret_kd",
|
|
||||||
"fire_level",
|
|
||||||
"common_regions",
|
|
||||||
"ban_record",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any {
|
|
||||||
if len(summary) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make(map[string]any, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
if value, ok := summary[key]; ok {
|
|
||||||
result[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func applySellerListingPrice(item *ListingDTO) {
|
func applySellerListingPrice(item *ListingDTO) {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -403,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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
|
|||||||
items = items[start:end]
|
items = items[start:end]
|
||||||
}
|
}
|
||||||
return &PublicListResult{
|
return &PublicListResult{
|
||||||
Items: publicListingListItems(items),
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PublicListResult{
|
return &PublicListResult{
|
||||||
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
|
Items: publicListings(rowsToDTO(rows)),
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
@@ -219,14 +219,13 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
|
|||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||||
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyPublicListingURLs(dto)
|
applyPublicListingURLs(dto)
|
||||||
item := dto.toPublicDetail()
|
return dto, nil
|
||||||
return &item, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
|
|||||||
return s.repo.ListMine(ctx, ownerID)
|
return s.repo.ListMine(ctx, ownerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -332,7 +331,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
|||||||
AssetSummary: map[string]any{
|
AssetSummary: map[string]any{
|
||||||
"import_meta": map[string]any{"uploader_name": "客服1"},
|
"import_meta": map[string]any{"uploader_name": "客服1"},
|
||||||
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
||||||
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,78 +345,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
|||||||
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||||
t.Fatal("expected price_breakdown hidden from public listing")
|
t.Fatal("expected price_breakdown hidden from public listing")
|
||||||
}
|
}
|
||||||
if _, ok := item.AssetSummary["screenshot_groups"]; ok {
|
|
||||||
t.Fatal("expected screenshot_groups hidden from public listing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) {
|
|
||||||
item := ListingDTO{
|
|
||||||
ID: 1,
|
|
||||||
ListingNo: "SP000001",
|
|
||||||
AccountID: 2,
|
|
||||||
OwnerID: 3,
|
|
||||||
OwnerPhone: "13800001234",
|
|
||||||
OwnerNickname: "号主",
|
|
||||||
SourceChannel: "外部渠道",
|
|
||||||
IsExternalUpload: true,
|
|
||||||
Title: "测试账号",
|
|
||||||
Description: "公开描述",
|
|
||||||
ScreenshotURLS: []string{"/api/listings/1/screenshots/0"},
|
|
||||||
Status: "published",
|
|
||||||
ReviewStatus: "approved",
|
|
||||||
HandoffMode: "platform",
|
|
||||||
SettlementMode: "platform_managed",
|
|
||||||
ManagedAdminID: uint64Pointer(4),
|
|
||||||
ReviewReason: "内部审核备注",
|
|
||||||
ListingGroupConversationID: 5,
|
|
||||||
AssetSummary: map[string]any{
|
|
||||||
"season_insurance": "3*3",
|
|
||||||
"contact_phone": "13900005678",
|
|
||||||
"remark": "首页不应携带",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal public list error = %v", err)
|
|
||||||
}
|
|
||||||
detailRaw, err := json.Marshal(item.toPublicDetail())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal public detail error = %v", err)
|
|
||||||
}
|
|
||||||
for _, field := range []string{
|
|
||||||
"account_id",
|
|
||||||
"owner_id",
|
|
||||||
"owner_phone",
|
|
||||||
"owner_nickname",
|
|
||||||
"source_channel",
|
|
||||||
"is_external_upload",
|
|
||||||
"status",
|
|
||||||
"review_status",
|
|
||||||
"handoff_mode",
|
|
||||||
"settlement_mode",
|
|
||||||
"managed_admin_id",
|
|
||||||
"review_reason",
|
|
||||||
"listing_group_conversation_id",
|
|
||||||
} {
|
|
||||||
if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) {
|
|
||||||
t.Fatalf("public response contains sensitive field %q", field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") {
|
|
||||||
t.Fatalf("public list contains detail-only fields: %s", listRaw)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") {
|
|
||||||
t.Fatalf("public list contains non-display asset fields: %s", listRaw)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(detailRaw), "contact_phone") {
|
|
||||||
t.Fatalf("public detail contains non-public asset field: %s", detailRaw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uint64Pointer(value uint64) *uint64 {
|
|
||||||
return &value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||||
@@ -604,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"])
|
||||||
@@ -640,6 +569,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerRegionFromLoginMethod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
loginMethod string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"},
|
||||||
|
{name: "微信扫码", loginMethod: "微信扫码", want: "微信"},
|
||||||
|
{name: "VX扫码", loginMethod: "VX扫码", want: "微信"},
|
||||||
|
{name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"},
|
||||||
|
{name: "Steam", loginMethod: "Steam令牌", want: "Steam"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want {
|
||||||
|
t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
|
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, "取消订单原路退款(客服审核通过)")
|
||||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
refund = action
|
refund = action
|
||||||
return tx.Save(&order).Error
|
}
|
||||||
|
if err := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(account).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -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
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const (
|
|||||||
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
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -77,46 +79,6 @@ type OrderDTO struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
|
|
||||||
type UserOrderListItemDTO struct {
|
|
||||||
ID uint64 `json:"id"`
|
|
||||||
OrderNo string `json:"order_no"`
|
|
||||||
ListingID uint64 `json:"listing_id"`
|
|
||||||
ListingNo string `json:"listing_no"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ServerRegion string `json:"server_region"`
|
|
||||||
LoginPlatform string `json:"login_platform"`
|
|
||||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
|
||||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
HandoffStatus string `json:"handoff_status"`
|
|
||||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminOrderListItemDTO 是后台订单表格的最小展示数据,敏感详情仅由详情接口按权限获取。
|
|
||||||
type AdminOrderListItemDTO struct {
|
|
||||||
ID uint64 `json:"id"`
|
|
||||||
OrderNo string `json:"order_no"`
|
|
||||||
ListingID uint64 `json:"listing_id"`
|
|
||||||
ListingNo string `json:"listing_no"`
|
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
|
||||||
RenterPhone string `json:"renter_phone,omitempty"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ServerRegion string `json:"server_region"`
|
|
||||||
LoginPlatform string `json:"login_platform"`
|
|
||||||
RentAmountCent int64 `json:"rent_amount_cent"`
|
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
HandoffStatus string `json:"handoff_status"`
|
|
||||||
HandoffMode string `json:"handoff_mode"`
|
|
||||||
SettlementMode string `json:"settlement_mode"`
|
|
||||||
SettlementStatus string `json:"settlement_status"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminActionsDTO struct {
|
type AdminActionsDTO struct {
|
||||||
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
||||||
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
||||||
@@ -172,6 +134,7 @@ type CounterCheckoutRequest struct {
|
|||||||
|
|
||||||
type AdminActionRequest struct {
|
type AdminActionRequest struct {
|
||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlatformHandoffRequest struct {
|
type PlatformHandoffRequest struct {
|
||||||
@@ -179,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"`
|
||||||
@@ -209,45 +172,6 @@ type PaginatedResult struct {
|
|||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserPaginatedResult 为用户订单列表提供受限分页,避免一次导出全部历史订单。
|
|
||||||
type UserPaginatedResult struct {
|
|
||||||
Items []UserOrderListItemDTO `json:"items"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SellerHandoffQuery 是号主待办列表的受限查询条件。
|
|
||||||
type SellerHandoffQuery struct {
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。
|
|
||||||
type SellerHandoffMetrics struct {
|
|
||||||
PendingHandoff int64 `json:"pending_handoff"`
|
|
||||||
PendingCheckout int64 `json:"pending_checkout"`
|
|
||||||
Abnormal int64 `json:"abnormal"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SellerHandoffPaginatedResult 只包含当前用户作为号主时需要处理的订单。
|
|
||||||
type SellerHandoffPaginatedResult struct {
|
|
||||||
Items []UserOrderListItemDTO `json:"items"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
Metrics SellerHandoffMetrics `json:"metrics"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminOrderListPaginatedResult 使用列表专用 DTO,避免后台首页携带订单敏感详情。
|
|
||||||
type AdminOrderListPaginatedResult struct {
|
|
||||||
Items []AdminOrderListItemDTO `json:"items"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefundStatusDTO struct {
|
type RefundStatusDTO struct {
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
@@ -269,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 {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user