diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e34f2ac --- /dev/null +++ b/.githooks/pre-push @@ -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] 本地检查通过" diff --git a/.gitignore b/.gitignore index 3e0d8ba..715f96f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ deploy/caddy/conf.d/show.caddy logs .gocache +artifacts/ + +frontend/coverage/ \ No newline at end of file diff --git a/README.md b/README.md index 6cd164d..0f43e07 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 \ go run ./cmd/api cd ../frontend -npm install +npm ci npm run dev ``` @@ -50,6 +50,24 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +本地门禁由 `scripts/check.sh` 统一执行;后端覆盖率基线由 `scripts/check-backend-coverage.sh` 维护。需要验证完整租赁链路时,先启动本地 MySQL 8.4,再设置 `HFB_E2E_DSN` 运行 `go test ./internal/e2e`。 + +服务探针:`GET /health` 仅表示进程存活;`GET /health/ready` 和 `GET /api/health` 会检查 MySQL、Redis,任一依赖不可用时返回 503。 + +数据库 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` 调整级别。 diff --git a/backend/.env.example b/backend/.env.example index 915f436..b68228a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=secret MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=127.0.0.1:16379 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index aace48e..d5707b9 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -19,6 +19,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=change-hfb-password MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=redis:6379 REDIS_PASSWORD= REDIS_DB=0 @@ -48,26 +55,23 @@ LOG_ENABLE_CONSOLE=false LOG_ENABLE_FILE=true LOG_RETAIN_DAYS=14 -# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验。 -MINIO_ROOT_USER=change-minio-user -MINIO_ROOT_PASSWORD=change-minio-password -# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。 -STORAGE_ENDPOINT=http://minio:9000 -STORAGE_BUCKET=hfb-sys -STORAGE_ACCESS_KEY_ID=change-minio-user -STORAGE_SECRET_ACCESS_KEY=change-minio-password -STORAGE_REGION= -STORAGE_BUCKET_LOOKUP=auto - -# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。 +# 主对象存储:图片等数据已迁移到阿里云 OSS,MinIO 已从生产编排移除。 # 杭州 ECS 应使用内网 Endpoint:https://oss-cn-hangzhou-internal.aliyuncs.com # OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。 +STORAGE_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com +STORAGE_BUCKET=hfb-sys-assets +STORAGE_ACCESS_KEY_ID=change-oss-access-key-id +STORAGE_SECRET_ACCESS_KEY=change-oss-access-key-secret +STORAGE_REGION=cn-hangzhou +STORAGE_BUCKET_LOOKUP=dns + +# 历史镜像写入配置,迁移完成后保持为空。 STORAGE_MIRROR_ENDPOINT= -STORAGE_MIRROR_BUCKET=hfb-sys-assets +STORAGE_MIRROR_BUCKET= STORAGE_MIRROR_ACCESS_KEY_ID= STORAGE_MIRROR_SECRET_ACCESS_KEY= -STORAGE_MIRROR_REGION=cn-hangzhou -STORAGE_MIRROR_BUCKET_LOOKUP=dns +STORAGE_MIRROR_REGION= +STORAGE_MIRROR_BUCKET_LOOKUP= # 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。 SMS_PROVIDER=aliyun @@ -102,3 +106,29 @@ FIELD_ENCRYPTION_LEGACY_KEY= EXTERNAL_UPLOAD_SECRET= # 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。 EXTERNAL_UPLOAD_ALLOWED_IPS= + +# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。 +# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。 +BACKUP_DIR=/data/backups +# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。 +BACKUP_STATUS_DIR=/data/backups/status +# backend 容器内的只读状态文件路径,通常保持默认。 +BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json +# 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。 +BACKUP_RESTORE_DIR=/data/restore +# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。 +# 生成方式:ossutil config 交互配置,或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET。 +BACKUP_OSS_URI=oss://hfb-backup +# 客户端加密密码短语(独立保管,与业务密钥一同纳入 KMS/密码管理)。 +# 生成方式:openssl rand -hex 32 +BACKUP_PASSPHRASE=change-to-random-backup-passphrase +# 可选:自有 KMS Key ID。留空时 Bucket 必须开启“OSS 完全托管”服务器端加密。 +BACKUP_KMS_KEY_ID= +# XtraBackup 专用 MySQL 账号,部署脚本会幂等创建并授予最小备份权限。 +BACKUP_MYSQL_USER=hfb_backup +# 生成方式:openssl rand -hex 24。不要和 MySQL 应用账号或 root 密码复用。 +BACKUP_MYSQL_PASSWORD=change-backup-mysql-password +# 本地保留策略(OSS 侧用 Bucket 生命周期规则管理,见脚本头部注释)。 +BACKUP_KEEP_DAILY=14 +BACKUP_KEEP_WEEKLY=8 +BACKUP_KEEP_MONTHLY=12 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f5299d1..c98b4e1 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -59,7 +59,13 @@ func main() { logAuthRuntimeIdentity(logger, cfg) var deps router.Dependencies - db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger) + db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{ + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime, + SlowQueryThreshold: cfg.Database.SlowQueryThreshold, + }) if err != nil { logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) } else { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1938142..ba4fa25 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "time" ) type Config struct { @@ -29,6 +30,8 @@ type Config struct { Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig + Database DatabaseConfig + BackupStatusFile string } type StorageConfig struct { @@ -69,6 +72,14 @@ type RateLimitConfig struct { RequestsPerMinute int } +type DatabaseConfig struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + func Load() Config { return Config{ AppEnv: getEnv("APP_ENV", "development"), @@ -130,6 +141,14 @@ func Load() Config { Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, + Database: DatabaseConfig{ + MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1), + MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0), + ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute, + ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute, + SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond, + }, + BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } @@ -206,6 +225,14 @@ func getEnvInt(key string, fallback int) int { return parsed } +func getEnvIntMin(key string, fallback, minimum int) int { + value := getEnvInt(key, fallback) + if value < minimum { + return fallback + } + return value +} + func getEnvBool(key string, fallback bool) bool { value := os.Getenv(key) if value == "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 53511be..e9ad4a4 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) { } } +func TestLoadDatabaseConfigFromEnvironment(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "80") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "16") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750") + + cfg := Load() + if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 { + t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 { + t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime) + } + if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 { + t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold) + } +} + +func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "0") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0") + + cfg := Load() + if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 { + t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 { + t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database) + } +} + // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { diff --git a/backend/internal/database/gorm_logger.go b/backend/internal/database/gorm_logger.go index e5ca36f..8d60c62 100644 --- a/backend/internal/database/gorm_logger.go +++ b/backend/internal/database/gorm_logger.go @@ -21,6 +21,10 @@ type structuredGormLogger struct { } func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { + return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond) +} + +func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface { level := gormLogger.Warn if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { level = gormLogger.Info @@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { if logger == nil { logger = zap.L() } + if slowThreshold <= 0 { + slowThreshold = 500 * time.Millisecond + } return &structuredGormLogger{ logger: logger.With(zap.String("module", "database")), level: level, - slowThreshold: 500 * time.Millisecond, + slowThreshold: slowThreshold, } } diff --git a/backend/internal/database/mysql.go b/backend/internal/database/mysql.go index 3b62fad..22ccab7 100644 --- a/backend/internal/database/mysql.go +++ b/backend/internal/database/mysql.go @@ -8,9 +8,50 @@ import ( "gorm.io/gorm" ) +type MySQLOptions struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + +func DefaultMySQLOptions() MySQLOptions { + return MySQLOptions{ + MaxOpenConns: 50, + MaxIdleConns: 10, + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 5 * time.Minute, + SlowQueryThreshold: 500 * time.Millisecond, + } +} + func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) { + return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions()) +} + +func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) { + defaults := DefaultMySQLOptions() + if options.MaxOpenConns < 1 { + options.MaxOpenConns = defaults.MaxOpenConns + } + if options.MaxIdleConns < 0 { + options.MaxIdleConns = defaults.MaxIdleConns + } + if options.ConnMaxLifetime <= 0 { + options.ConnMaxLifetime = defaults.ConnMaxLifetime + } + if options.ConnMaxIdleTime < 0 { + options.ConnMaxIdleTime = defaults.ConnMaxIdleTime + } + if options.SlowQueryThreshold <= 0 { + options.SlowQueryThreshold = defaults.SlowQueryThreshold + } + if options.MaxIdleConns > options.MaxOpenConns { + options.MaxIdleConns = options.MaxOpenConns + } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ - Logger: newGormLogger(logLevel, appLogger), + Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold), }) if err != nil { return nil, err @@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er if err != nil { return nil, err } - // 限制连接池,避免本地压测瞬间打满 MySQL max_connections。 - sqlDB.SetMaxOpenConns(50) - sqlDB.SetMaxIdleConns(10) - sqlDB.SetConnMaxLifetime(30 * time.Minute) - sqlDB.SetConnMaxIdleTime(5 * time.Minute) + // 限制连接池,避免单个实例耗尽 MySQL max_connections。 + sqlDB.SetMaxOpenConns(options.MaxOpenConns) + sqlDB.SetMaxIdleConns(options.MaxIdleConns) + sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime) return db, nil } diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index c36a41f..b1c7a2a 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB { func MigrateListingLifecycleTestSchema(db *gorm.DB) error { return db.AutoMigrate( &model.User{}, + &model.AdminUser{}, &model.GameAccount{}, &model.RentalListing{}, + &model.ListingUpload{}, &model.ListingStatusEvent{}, ) } @@ -61,8 +63,10 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error { &model.WalletLedger{}, &model.RenterGrowthLedger{}, &model.AuditLog{}, + &model.ProcessEvent{}, &model.ChatConversation{}, &model.ChatParticipant{}, + &model.ChatAdminConversationState{}, &model.ChatMessage{}, ) } diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index 27ab334..cfc0263 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -170,13 +170,19 @@ func TestArbitrationReleaseDepositDoesNotRepublishListing(t *testing.T) { t.Fatalf("读取仲裁后订单模型失败: %v", err) } assertEqual(t, "仲裁退款状态", orderRow.RefundStatus, "refunded") - assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(15000)) + assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(25800)) walletAccount, err := services.wallet.Account(t.Context(), owner.ID) if err != nil { t.Fatalf("读取号主钱包失败: %v", err) } - assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(35300)) + assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(24500)) + assertEqual( + t, + "仲裁资金守恒", + orderRow.RefundAmountCent+walletAccount.AvailableBalanceCent+3000, + orderRow.RentAmountCent+orderRow.DepositAmountCent, + ) var listingRow model.RentalListing if err := db.First(&listingRow, approved.ID).Error; err != nil { @@ -313,7 +319,15 @@ func applyMigrations(t *testing.T, db *sql.DB) { t.Fatal("无法定位当前测试文件") } migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") - for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} { + entries, err := os.ReadDir(migrationDir) + if err != nil { + t.Fatalf("读取迁移目录失败: %v", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + name := entry.Name() migrationPath := filepath.Join(migrationDir, name) raw, err := os.ReadFile(migrationPath) if err != nil { @@ -424,6 +438,10 @@ func createListingUnderReview(t *testing.T, service *listing.Service, ownerID ui ScreenshotURLS: []string{"https://example.com/e2e-account.png"}, AssetSummary: map[string]any{ "fire_level": 50, + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, "price_breakdown": map[string]any{ "seller_coin_base_price": 233, "seller_total_price": 353, diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go index 256c445..c4a9cb6 100644 --- a/backend/internal/handler/health.go +++ b/backend/internal/handler/health.go @@ -1,7 +1,9 @@ package handler import ( + "context" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -9,16 +11,55 @@ import ( type HealthHandler struct { startedAt time.Time + checks map[string]ReadinessCheck } -func NewHealthHandler() *HealthHandler { - return &HealthHandler{startedAt: time.Now()} +type ReadinessCheck func(context.Context) error + +func NewHealthHandler(checks map[string]ReadinessCheck) *HealthHandler { + return &HealthHandler{startedAt: time.Now(), checks: checks} } -func (h *HealthHandler) Check(c *gin.Context) { +func (h *HealthHandler) Live(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "service": "hfb-api", "started_at": h.startedAt.Format(time.RFC3339), }) } + +func (h *HealthHandler) Ready(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + names := make([]string, 0, len(h.checks)) + for name := range h.checks { + names = append(names, name) + } + sort.Strings(names) + + ready := true + dependencies := make(map[string]string, len(names)) + for _, name := range names { + check := h.checks[name] + if check == nil || check(ctx) != nil { + dependencies[name] = "unavailable" + ready = false + continue + } + dependencies[name] = "ok" + } + + statusCode := http.StatusOK + status := "ok" + if !ready { + statusCode = http.StatusServiceUnavailable + status = "unavailable" + } + c.JSON(statusCode, gin.H{ + "status": status, + "service": "hfb-api", + "started_at": h.startedAt.Format(time.RFC3339), + "dependencies": dependencies, + }) +} diff --git a/backend/internal/handler/health_test.go b/backend/internal/handler/health_test.go new file mode 100644 index 0000000..a5fbac5 --- /dev/null +++ b/backend/internal/handler/health_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHealthLiveDoesNotDependOnReadiness(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHealthHandler(map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + }) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health", nil) + + h.Live(ctx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } +} + +func TestHealthReadyReportsDependencyState(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + checks map[string]ReadinessCheck + wantStatus int + wantBody string + }{ + { + name: "all dependencies ready", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return nil }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusOK, + wantBody: `"status":"ok"`, + }, + { + name: "dependency unavailable", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusServiceUnavailable, + wantBody: `"mysql":"unavailable"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHealthHandler(tt.checks) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health/ready", nil) + + h.Ready(ctx) + + if recorder.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.wantStatus, recorder.Body.String()) + } + if body := recorder.Body.String(); !strings.Contains(body, tt.wantBody) { + t.Fatalf("body = %s, want substring %s", body, tt.wantBody) + } + }) + } +} diff --git a/backend/internal/jobs/refundretry/job_test.go b/backend/internal/jobs/refundretry/job_test.go index a003f33..9bc299d 100644 --- a/backend/internal/jobs/refundretry/job_test.go +++ b/backend/internal/jobs/refundretry/job_test.go @@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("创建测试数据库失败: %v", err) } - if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil { + if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db @@ -161,23 +161,68 @@ func TestResetRetryClearsRetryFields(t *testing.T) { } } +func TestSyncRefundPaymentsMarksFailedAttemptForRetry(t *testing.T) { + db := setupRefundRetryTestDB(t) + repo := payment.NewRepository(db, nil, nil) + job := New(db, nil, zap.NewNop(), repo) + now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + row := model.PaymentOrder{ + PaymentNo: "PAY-RETRY-FAIL", + OrderID: 1, + OrderNo: "ORD-RETRY-FAIL", + UserID: 2, + Provider: "lakala", + MerchantID: "merchant-missing-config", + ThirdOrderID: "REF-RETRY-FAIL", + AmountCent: 1000, + BizType: "admin_refund", + Status: "refunding", + UpdatedAt: now.Add(-10 * time.Minute), + RetryCount: 1, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create refund payment: %v", err) + } + + processed, err := job.syncRefundPayments(t.Context(), now) + if err != nil { + t.Fatalf("syncRefundPayments() error = %v", err) + } + if processed != 0 { + t.Fatalf("processed = %d, want 0 when channel config is unavailable", processed) + } + var latest model.PaymentOrder + if err := db.First(&latest, row.ID).Error; err != nil { + t.Fatalf("find refund payment: %v", err) + } + if latest.RetryCount != 2 { + t.Fatalf("retry_count = %d, want 2", latest.RetryCount) + } + if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) { + t.Fatalf("last_retry_at = %v, want %v", latest.LastRetryAt, now) + } + if latest.NextRetryAt == nil || !latest.NextRetryAt.After(now) { + t.Fatalf("next_retry_at = %v, want a future retry time", latest.NextRetryAt) + } +} + // createArbitrationOrphanFixture 构造一个仲裁孤儿订单:已支付原单 + 仲裁已结案但退款单缺失。 func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { t.Helper() order := model.RentalOrder{ - OrderNo: "ORD" + suffix, - ListingID: 1, - AccountID: 1, - OwnerID: 1, - RenterID: 2, - RentAmountCent: 1000, - DepositAmountCent: 500, - Status: "closed", - HandoffStatus: "arbitrated", - SettlementStatus: settlementStatus, - RefundStatus: "pending", - RefundAmountCent: 800, - UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 + OrderNo: "ORD" + suffix, + ListingID: 1, + AccountID: 1, + OwnerID: 1, + RenterID: 2, + RentAmountCent: 1000, + DepositAmountCent: 500, + Status: "closed", + HandoffStatus: "arbitrated", + SettlementStatus: settlementStatus, + RefundStatus: "pending", + RefundAmountCent: 800, + UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 } if err := db.Create(&order).Error; err != nil { t.Fatalf("create order failed: %v", err) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 0e08995..fd15261 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -35,6 +35,8 @@ type AdminTokenContext struct { type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error) +type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error + func extractBearerToken(c *gin.Context) string { header := c.GetHeader("Authorization") tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) @@ -44,13 +46,19 @@ func extractBearerToken(c *gin.Context) string { return "" } -type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error - -func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc { - var validate UserTokenValidatorFunc - if len(validators) > 0 { - validate = validators[0] +func extractAdminToken(c *gin.Context) (string, string) { + if tokenText := extractBearerToken(c); tokenText != "" { + return tokenText, "bearer" } + if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil { + if tokenText := strings.TrimSpace(cookieToken); tokenText != "" { + return tokenText, "cookie" + } + } + return "", "none" +} + +func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { tokenText := extractBearerToken(c) if tokenText == "" { @@ -72,7 +80,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin } if validate != nil { if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil { - response.Unauthorized(c, "访问令牌无效或已过期") + if errors.Is(err, auth.ErrDependencyUnavailable) { + response.ServiceUnavailable(c, "用户认证服务暂时不可用") + } else { + response.Unauthorized(c, "登录状态已失效,请重新登录") + } c.Abort() return } @@ -86,16 +98,9 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { - tokenText := extractBearerToken(c) - tokenSource := "bearer" + tokenText, tokenSource := extractAdminToken(c) if tokenText == "" { - if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil { - tokenText = strings.TrimSpace(cookieToken) - tokenSource = "cookie" - } - } - if tokenText == "" { - RecordAdminAuthFailure(c, "missing", "none", 0, 0) + RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0) response.Unauthorized(c, "缺少后台访问令牌") c.Abort() return diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go index 3ab3caa..6a0164e 100644 --- a/backend/internal/middleware/auth_test.go +++ b/backend/internal/middleware/auth_test.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "net/http" "net/http/httptest" "testing" @@ -19,7 +20,7 @@ func TestUserAuthRejectsQueryToken(t *testing.T) { } engine := gin.New() - engine.GET("/protected", Auth(manager), func(c *gin.Context) { + engine.GET("/protected", Auth(manager, nil), func(c *gin.Context) { c.Status(http.StatusNoContent) }) @@ -38,3 +39,27 @@ func TestUserAuthRejectsQueryToken(t *testing.T) { t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent) } } + +func TestAuthRejectsRevokedUserToken(t *testing.T) { + gin.SetMode(gin.TestMode) + manager := auth.NewJWTManager("test-secret") + pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0) + if err != nil { + t.Fatalf("生成令牌失败:%v", err) + } + + engine := gin.New() + engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error { + return auth.ErrTokenVersionMismatch + }), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer "+pair.AccessToken) + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized) + } +} diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index a5dfc9f..aec81fe 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -7,10 +7,14 @@ import ( "sync" "time" + "hfb_sys/backend/internal/modules/auth" + "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) +type rateLimitKeyFunc func(c *gin.Context) string + type rateLimitBucket struct { count int resetAt time.Time @@ -32,6 +36,16 @@ type redisRateLimiter struct { } func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc { + return rateLimitPerMinute(limit, rdb, ipRateLimitKey) +} + +// AdminAwareRateLimitPerMinute isolates authenticated admin traffic by admin ID. +// Login and other requests without a valid admin access token fall back to IP. +func AdminAwareRateLimitPerMinute(limit int, rdb *redis.Client, jwtManager *auth.JWTManager) gin.HandlerFunc { + return rateLimitPerMinute(limit, rdb, adminOrIPRateLimitKey(jwtManager)) +} + +func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc) gin.HandlerFunc { if limit <= 0 { return func(c *gin.Context) { c.Next() @@ -48,34 +62,62 @@ func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc { fallback: limiter, limit: limit, window: time.Minute, - }).handle + }).handle(keyFunc) } - return limiter.handle + return limiter.handle(keyFunc) } -func (l *redisRateLimiter) handle(c *gin.Context) { - now := time.Now() - key := c.ClientIP() - allowed, resetAt, err := l.allow(c.Request.Context(), key, now) - if err != nil { - allowed, resetAt = l.fallback.allow(key, now) +func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc { + return func(c *gin.Context) { + now := time.Now() + key := keyFunc(c) + allowed, resetAt, err := l.allow(c.Request.Context(), key, now) + if err != nil { + allowed, resetAt = l.fallback.allow(key, now) + } + if !allowed { + writeRateLimited(c, now, resetAt) + return + } + c.Next() } - if !allowed { - writeRateLimited(c, now, resetAt) - return - } - c.Next() } -func (l *rateLimiter) handle(c *gin.Context) { - now := time.Now() - key := c.ClientIP() - allowed, resetAt := l.allow(key, now) - if !allowed { - writeRateLimited(c, now, resetAt) - return +func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc { + return func(c *gin.Context) { + now := time.Now() + key := keyFunc(c) + allowed, resetAt := l.allow(key, now) + if !allowed { + writeRateLimited(c, now, resetAt) + return + } + c.Next() + } +} + +func ipRateLimitKey(c *gin.Context) string { + return "ip:" + c.ClientIP() +} + +func adminOrIPRateLimitKey(jwtManager *auth.JWTManager) rateLimitKeyFunc { + return func(c *gin.Context) string { + if value, ok := c.Get(ContextAdminID); ok { + if adminID, ok := value.(uint64); ok && adminID != 0 { + return "admin:" + strconv.FormatUint(adminID, 10) + } + } + if jwtManager != nil { + tokenText, _ := extractAdminToken(c) + if tokenText != "" { + claims, err := jwtManager.ParseSubject(tokenText, "access", "admin") + if err == nil && claims.UserID != 0 { + return "admin:" + strconv.FormatUint(claims.UserID, 10) + } + } + } + return ipRateLimitKey(c) } - c.Next() } func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) { diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go new file mode 100644 index 0000000..326ce7b --- /dev/null +++ b/backend/internal/middleware/rate_limit_test.go @@ -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) + } +} diff --git a/backend/internal/middleware/request_logger.go b/backend/internal/middleware/request_logger.go index b1061f7..9a82241 100644 --- a/backend/internal/middleware/request_logger.go +++ b/backend/internal/middleware/request_logger.go @@ -108,16 +108,25 @@ func isPaymentNotifyPath(path string) bool { } // shouldSkipHTTPLog 判断是否为无需记录的普通请求。 -func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool { +func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool { if status >= 500 || status == http.StatusTooManyRequests { return false } + // SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。 + if isChatSSEPath(path, route) { + return true + } if latencyMs >= slowRequestThresholdMs { return false } return true } +func isChatSSEPath(path, route string) bool { + return path == "/api/chats/events" || path == "/api/admin/chats/events" || + route == "/api/chats/events" || route == "/api/admin/chats/events" +} + func meaningfulAuthFailure(c *gin.Context) bool { value, ok := c.Get(ContextAuthFailureReason) if !ok { diff --git a/backend/internal/middleware/request_logger_test.go b/backend/internal/middleware/request_logger_test.go index c368904..05c859b 100644 --- a/backend/internal/middleware/request_logger_test.go +++ b/backend/internal/middleware/request_logger_test.go @@ -19,6 +19,10 @@ func TestShouldSkipHTTPLog(t *testing.T) { {name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false}, {name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false}, {name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false}, + {name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true}, + {name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true}, + {name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false}, + {name: "SSE 限流不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 429, latencyMs: 1, wantSkip: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index ab1d0d4..2fcf3a1 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -4,21 +4,32 @@ import ( "time" "gorm.io/datatypes" + "gorm.io/gorm" ) type ChatConversation struct { - ID uint64 `gorm:"primaryKey" json:"id"` - OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` - ListingID *uint64 `gorm:"index" json:"listing_id"` - Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` - SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` - Title string `gorm:"size:128;not null" json:"title"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` - LastMessageID *uint64 `json:"last_message_id"` - LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` - LastMessageAt *time.Time `json:"last_message_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` + ListingID *uint64 `gorm:"index" json:"listing_id"` + // 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。 + // 这样读取会话列表不必每次扫描全部订单再按商品分组。 + LatestOrderID *uint64 `gorm:"index" json:"-"` + LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"` + LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"` + LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"` + LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"` + // 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。 + LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"` + LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"` + Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` + SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` + Title string `gorm:"size:128;not null" json:"title"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + LastMessageID *uint64 `json:"last_message_id"` + LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` + LastMessageAt *time.Time `json:"last_message_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (ChatConversation) TableName() string { @@ -42,22 +53,58 @@ func (ChatParticipant) TableName() string { return "chat_participants" } +// ChatAdminConversationState stores per-admin state even when the admin is not +// an assigned participant of the conversation. +type ChatAdminConversationState struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"` + AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"` + Remark string `gorm:"size:128;not null;default:''" json:"remark"` + LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"` + LastReadAt *time.Time `json:"last_read_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChatAdminConversationState) TableName() string { + return "chat_admin_conversation_states" +} + type ChatMessage struct { - ID uint64 `gorm:"primaryKey" json:"id"` - ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` - SenderType string `gorm:"size:16;not null" json:"sender_type"` - SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` - SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` - ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` - Content string `json:"content"` - AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` + SenderType string `gorm:"size:16;not null" json:"sender_type"` + SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` + SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` + ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` + Content string `json:"content"` + AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` + AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"` + CreatedAt time.Time `json:"created_at"` } func (ChatMessage) TableName() string { return "chat_messages" } +// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。 +func (m *ChatMessage) AfterCreate(tx *gorm.DB) error { + if m.ID == 0 || m.ConversationID == 0 { + return nil + } + updates := map[string]any{} + if m.AdminAttentionType != "" || m.SenderType == "user" { + updates["last_attention_message_id"] = m.ID + } + if m.SenderType == "admin" { + updates["last_admin_message_id"] = m.ID + } + if len(updates) == 0 { + return nil + } + return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error +} + type ChatQrCode struct { ID uint64 `gorm:"primaryKey" json:"id"` ImageURL string `gorm:"size:512;not null" json:"image_url"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 50ff007..1245861 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -4,6 +4,7 @@ import ( "time" "gorm.io/datatypes" + "gorm.io/gorm" ) type RentalOrder struct { @@ -77,6 +78,24 @@ func (RentalOrder) TableName() string { return "rental_orders" } +// AfterSave 将商品关联发布群的最新订单快照一并更新。 +// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。 +func (o *RentalOrder) AfterSave(tx *gorm.DB) error { + if o.ID == 0 || o.ListingID == 0 { + return nil + } + return tx.Model(&ChatConversation{}). + Where("listing_id = ? AND order_id IS NULL", o.ListingID). + Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID). + Updates(map[string]any{ + "latest_order_id": o.ID, + "latest_order_no": o.OrderNo, + "latest_order_status": o.Status, + "latest_order_handoff_status": o.HandoffStatus, + "latest_order_refund_status": o.RefundStatus, + }).Error +} + // 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算, // 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。 const ( diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go index 2446076..5e4f16e 100644 --- a/backend/internal/model/pickup.go +++ b/backend/internal/model/pickup.go @@ -7,7 +7,7 @@ import ( ) // AdminPickup 管理员线下提号记录。 -// 独立于 rental_orders,不进入正常订单状态机与财务统计口径。 +// 独立于 rental_orders 和正常订单状态机;完成后进入线下提号财务统计。 // 站内上传完成时入卖家钱包;平台代管完成后进入线下结算。 type AdminPickup struct { ID uint64 `gorm:"primaryKey" json:"id"` diff --git a/backend/internal/model/process_event.go b/backend/internal/model/process_event.go new file mode 100644 index 0000000..b9bb400 --- /dev/null +++ b/backend/internal/model/process_event.go @@ -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" +} diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index 2bf981d..d49d382 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -24,12 +24,14 @@ type MetricsDTO struct { // ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 type ListingDailyOverviewDTO struct { - Today ListingDayStatsDTO `json:"today"` - Trend []ListingDayStatsDTO `json:"trend"` - TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` - ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` - Days int `json:"days"` - Timezone string `json:"timezone"` + Today ListingDayStatsDTO `json:"today"` + Trend []ListingDayStatsDTO `json:"trend"` + TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` + ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` + TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"` + UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"` + Days int `json:"days"` + Timezone string `json:"timezone"` } type ListingDayStatsDTO struct { @@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct { TradeLeaveCount int64 `json:"trade_leave_count"` } +type ListingUploaderDayStatsDTO struct { + Date string `json:"date"` // YYYY-MM-DD(上海时区) + UploaderID uint64 `json:"uploader_id"` + UploaderName string `json:"uploader_name"` + UploadCount int64 `json:"upload_count"` +} + type PendingDTO struct { ListingReviews int64 `json:"listing_reviews"` Disputes int64 `json:"disputes"` diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index 1623e76..8ea685d 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da Scan(&events).Error; err != nil { return ListingDailyOverviewDTO{}, err } + uploads := make([]listingUploaderRow, 0) + if err := db.Table("listing_uploads"). + Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at"). + Where("listing_id IS NOT NULL"). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&uploads).Error; err != nil { + return ListingDailyOverviewDTO{}, err + } byDay := make(map[string]*listingDailyBucket, days) byChannel := make(map[listingChannelKey]*listingDailyBucket) + byUploader := make(map[listingUploaderKey]int64) for _, ev := range events { key := ev.CreatedAt.In(loc).Format("2006-01-02") b := byDay[key] @@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da } addListingDailyEvent(cb, ev.EventType, ev.Source) } + for _, upload := range uploads { + key := upload.CreatedAt.In(loc).Format("2006-01-02") + uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName} + byUploader[uploaderKey]++ + } trend := make([]ListingDayStatsDTO, 0, days) for i := days - 1; i >= 0; i-- { @@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da todayStats = trend[len(trend)-1] } channelTrend := listingChannelTrend(byChannel) + uploaderTrend := listingUploaderTrend(byUploader) todayChannels := make([]ListingChannelDayStatsDTO, 0) for _, item := range channelTrend { if item.Date == todayStats.Date { todayChannels = append(todayChannels, item) } } + todayUploaders := make([]ListingUploaderDayStatsDTO, 0) + for _, item := range uploaderTrend { + if item.Date == todayStats.Date { + todayUploaders = append(todayUploaders, item) + } + } return ListingDailyOverviewDTO{ - Today: todayStats, - Trend: trend, - TodayChannels: todayChannels, - ChannelTrend: channelTrend, - Days: days, - Timezone: "Asia/Shanghai", + Today: todayStats, + Trend: trend, + TodayChannels: todayChannels, + ChannelTrend: channelTrend, + TodayUploaders: todayUploaders, + UploaderTrend: uploaderTrend, + Days: days, + Timezone: "Asia/Shanghai", }, nil } +type listingUploaderRow struct { + UploaderID uint64 + UploaderName string + CreatedAt time.Time +} + type listingChannelKey struct { date string sourceChannel string } +type listingUploaderKey struct { + date string + uploaderID uint64 + uploaderName string +} + type listingDailyBucket struct { published int64 activeOffline int64 @@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi return items } +func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO { + keys := make([]listingUploaderKey, 0, len(rows)) + for key := range rows { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].date != keys[j].date { + return keys[i].date < keys[j].date + } + if rows[keys[i]] != rows[keys[j]] { + return rows[keys[i]] > rows[keys[j]] + } + if keys[i].uploaderName != keys[j].uploaderName { + return keys[i].uploaderName < keys[j].uploaderName + } + return keys[i].uploaderID < keys[j].uploaderID + }) + items := make([]ListingUploaderDayStatsDTO, 0, len(keys)) + for _, key := range keys { + items = append(items, ListingUploaderDayStatsDTO{ + Date: key.date, + UploaderID: key.uploaderID, + UploaderName: key.uploaderName, + UploadCount: rows[key], + }) + } + return items +} + func listingChannelRank(channel string) int { switch channel { case "咸鱼": diff --git a/backend/internal/modules/admindashboard/repository_test.go b/backend/internal/modules/admindashboard/repository_test.go index 5ae4f82..fe9b0fc 100644 --- a/backend/internal/modules/admindashboard/repository_test.go +++ b/backend/internal/modules/admindashboard/repository_test.go @@ -65,3 +65,44 @@ func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave) } } + +func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + loc := timeutil.ShanghaiLocation() + now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc) + uploaderX := uint64(11) + uploaderY := uint64(12) + listingOne := uint64(101) + listingTwo := uint64(102) + listingThree := uint64(103) + uploads := []model.ListingUpload{ + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)}, + {UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)}, + } + if err := db.Create(&uploads).Error; err != nil { + t.Fatalf("create uploads error = %v", err) + } + + overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2) + if err != nil { + t.Fatalf("listingDailyOverview() error = %v", err) + } + if len(overview.TodayUploaders) != 2 { + t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders) + } + counts := make(map[string]int64) + for _, item := range overview.TodayUploaders { + counts[item.UploaderName] = item.UploadCount + } + if counts["小晴"] != 2 || counts["小美"] != 1 { + t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts) + } + if len(overview.UploaderTrend) != 3 { + t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend) + } +} diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 88d7f41..17da89c 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -2,39 +2,81 @@ package adminfinance import ( "context" + "sync" "hfb_sys/backend/internal/timeutil" ) func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { - dailyItems, err := r.dailyItems(ctx, query) + if cached := r.loadDashboardCache(ctx, query); cached != nil { + return cached, nil + } + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( + daily dashboardDailyResult + pickup *PickupSummaryDTO + disbursement *DisbursementSummaryDTO + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + 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 + daily, 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 + disbursement, err = r.disbursementSummary(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return nil, err + } + } + + summary := summaryWithPickup(daily.Summary, pickup) + result := &DashboardDTO{ + Summary: summary, + DailyItems: daily.Items, + PickupSummary: *pickup, + MohongSummary: daily.Mohong, + DisbursementSummary: *disbursement, + OperatingExpenseSummary: daily.OperatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), + } + r.storeDashboardCache(ctx, query, result) + return result, nil +} + +func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) { + var summary OperatingExpenseSummaryDTO + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count"). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Scan(&summary).Error if err != nil { return nil, err } - pickup, err := r.pickupSummary(ctx, query) - if err != nil { - return nil, err - } - summary, err := r.summary(ctx, query, pickup) - if err != nil { - return nil, err - } - mohong, err := r.mohongSummary(ctx, query) - if err != nil { - return nil, err - } - disbursement, err := r.disbursementSummary(ctx, query) - if err != nil { - return nil, err - } - return &DashboardDTO{ - Summary: *summary, - DailyItems: dailyItems, - PickupSummary: *pickup, - MohongSummary: *mohong, - DisbursementSummary: *disbursement, - GeneratedAt: timeutil.ShanghaiNow(), - }, nil + return &summary, nil } // pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 @@ -68,11 +110,33 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (* if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil { return nil, err } + var pending struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND offline_settlement_status = ? AND settle_amount_cent > 0", "platform_managed", "pending"). + Scan(&pending).Error; err != nil { + return nil, err + } + var pendingAdjustments struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickup_financial_adjustments"). + Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND status = ? AND settle_delta_cent > 0", "platform_managed", "pending"). + Scan(&pendingAdjustments).Error; err != nil { + return nil, err + } return &PickupSummaryDTO{ SettledAmountCent: row.SettledAmountCent + adjustments.SettledAmountCent, ProfitAmountCent: row.ProfitAmountCent + adjustments.ProfitAmountCent, CompletedCount: row.CompletedCount, InProgressCount: inProgress, + PendingAmountCent: pending.AmountCent + pendingAdjustments.AmountCent, + PendingCount: pending.Count + pendingAdjustments.Count, }, nil } @@ -100,162 +164,57 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* }, nil } -func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) { - db := r.db.WithContext(ctx) - var payment paymentSummaryRow - if err := db.Table("payment_orders AS po"). - Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 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 partial_refund_count, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, - payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). - Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). - Scan(&payment).Error; err != nil { - return nil, err - } - - var settlement settlementSummaryRow - if err := db.Table("rental_orders AS ro"). - Select(`COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, - COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, - COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, - COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_pending_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 - THEN 1 - ELSE 0 - END), 0) AS offline_settlement_pending_count, - COUNT(ro.id) AS settled_order_count`). - 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)). - Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). - Scan(&settlement).Error; err != nil { - return nil, err - } - - var exceptionCount int64 - if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{ - DateType: "settled", - StartDate: query.StartDate, - EndDate: query.EndDate, - })). - Where("finance_status <> ?", financeStatusNormal). - Where("finance_status <> ?", financeStatusOfflineSettlementPending). - Count(&exceptionCount).Error; err != nil { - return nil, err - } - - // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。 - var estimated estimatedIncomeRow - if err := db.Table("rental_orders"). - Select(`COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, - COUNT(id) AS pending_settle_order_count`). - Where("settlement_status NOT IN ?", settledSettlementStatuses()). - Where("status NOT IN ?", nonBillableOrderStatuses()). - Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). - Scan(&estimated).Error; err != nil { - return nil, err - } - - pickupProfitAmountCent := int64(0) - pickupCompletedCount := int64(0) - if pickup != nil { - pickupProfitAmountCent = pickup.ProfitAmountCent - pickupCompletedCount = pickup.CompletedCount - } - return &FinanceSummaryDTO{ - TotalFlowAmountCent: payment.TotalFlowAmountCent, - TotalRefundAmountCent: payment.TotalRefundAmountCent, - PendingRefundAmountCent: payment.PendingRefundAmountCent, - ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent, - PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent, - NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent, - PickupProfitAmountCent: pickupProfitAmountCent, - PickupCompletedCount: pickupCompletedCount, - OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent, - OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent, - OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent, - OfflineSettlementAmountCent: settlement.OfflineSettlementAmountCent, - OfflineSettlementPendingAmountCent: settlement.OfflineSettlementPendingAmountCent, - SettlementDiffAmountCent: settlement.OwnerShouldIncomeAmountCent - settlement.OwnerEffectiveSettlementAmountCent, - EstimatedIncomeAmountCent: estimated.EstimatedIncomeAmountCent, - SuccessfulPayCount: payment.SuccessfulPayCount, - SuccessfulRefundCount: payment.FullRefundCount + payment.PartialRefundCount, - FullRefundCount: payment.FullRefundCount, - PartialRefundCount: payment.PartialRefundCount, - PendingRefundCount: payment.PendingRefundCount, - SettledOrderCount: settlement.SettledOrderCount, - PendingSettleOrderCount: estimated.PendingSettleOrderCount, - OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount, - FinancialExceptionCount: exceptionCount, - }, nil +type dashboardDailyResult struct { + Items []FinanceDailyDTO + Summary FinanceSummaryDTO + Mohong MohongSummaryDTO + OperatingExpense OperatingExpenseSummaryDTO } -func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) { +func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { db := r.db.WithContext(ctx) - payments, err := r.dailyPayments(ctx, query) - if err != nil { - return nil, err + var ( + payments []dailyPaymentRow + settlements []dailySettlementRow + estimates []dailyEstimatedRow + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() } - paidOrders, err := r.dailyPaidOrders(ctx, query) - if err != nil { - return nil, err + run(func() error { + var err error + payments, err = r.dailyPayments(ctx, query) + return err + }) + run(func() error { + var err error + settlements, err = r.dailySettlements(ctx, query) + return err + }) + run(func() error { + var err error + estimates, err = r.dailyEstimates(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return dashboardDailyResult{}, err + } } - settlements := make([]dailySettlementRow, 0) - if err := db.Table("rental_orders AS ro"). - Select(`DATE(ro.settled_at) AS date, - COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, - COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, - COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, - COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_pending_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 - THEN 1 - ELSE 0 - END), 0) AS offline_settlement_pending_count, - COUNT(ro.id) AS settled_order_count`). - 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)). - Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). - Group("DATE(ro.settled_at)"). - Scan(&settlements).Error; err != nil { - return nil, err + paidOrders, err := r.dailyPaidOrders(ctx, query) + if err != nil { + return dashboardDailyResult{}, err } pickupProfits := make([]dailyPickupProfitRow, 0) @@ -267,7 +226,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). Group("DATE(completed_at)"). Scan(&pickupProfits).Error; err != nil { - return nil, err + return dashboardDailyResult{}, err } pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) if err := db.Table("admin_pickup_financial_adjustments AS fa"). @@ -277,31 +236,23 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(fa.created_at)"). Scan(&pickupAdjustments).Error; err != nil { - return nil, err + return dashboardDailyResult{}, err } // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 pickupCancelled, err := r.dailyPickupCancelled(ctx, query) if err != nil { - return nil, err + return dashboardDailyResult{}, err } // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 - estimates := make([]dailyEstimatedRow, 0) - if err := db.Table("rental_orders"). - Select(`DATE(created_at) AS date, - COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent`). - Where("settlement_status NOT IN ?", settledSettlementStatuses()). - Where("status NOT IN ?", nonBillableOrderStatuses()). - Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). - Group("DATE(created_at)"). - Scan(&estimates).Error; err != nil { - return nil, err - } - disbursements, err := r.dailyDisbursements(ctx, query) if err != nil { - return nil, err + return dashboardDailyResult{}, err + } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { + return dashboardDailyResult{}, err } itemsByDate := make(map[string]FinanceDailyDTO) @@ -392,19 +343,163 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount itemsByDate[date] = item } + for _, row := range operatingExpenses { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OperatingExpenseAmountCent = row.AmountCent + item.OperatingExpenseCount = row.Count + itemsByDate[date] = item + } items := make([]FinanceDailyDTO, 0, len(itemsByDate)) for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { items = append(items, itemsByDate[day.Format("2006-01-02")]) } - return items, nil + return dashboardDailyResult{ + Items: items, + Summary: summaryFromDailyRows(payments, settlements, estimates), + Mohong: mohongSummaryFromDailyRows(payments), + OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), + }, nil +} + +func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { + rows := make([]dailyEstimatedRow, 0) + err := r.db.WithContext(ctx).Table("rental_orders"). + Select(`DATE(created_at) AS date, + COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, + COUNT(id) AS pending_settle_order_count`). + Where("settlement_status NOT IN ?", settledSettlementStatuses()). + Where("status NOT IN ?", nonBillableOrderStatuses()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(created_at)"). + Scan(&rows).Error + return rows, err +} + +func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { + db := r.db.WithContext(ctx) + rows := make([]dailySettlementRow, 0) + err := db.Table("rental_orders AS ro"). + Select(`DATE(ro.settled_at) AS date, + COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, + COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, + COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_pending_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 + THEN 1 + ELSE 0 + END), 0) AS offline_settlement_pending_count, + COALESCE(SUM(CASE WHEN 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) >= ? + OR EXISTS ( + SELECT 1 FROM payment_orders AS exception_payment + WHERE exception_payment.order_id = ro.id + AND exception_payment.biz_type IN ? + AND exception_payment.status IN ? + AND exception_payment.amount_cent > 0 + ) + THEN 1 + ELSE 0 + END), 0) AS financial_exception_count, + COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}). + Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). + Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(ro.settled_at)"). + Scan(&rows).Error + return rows, err +} + +func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { + var summary FinanceSummaryDTO + for _, row := range payments { + summary.TotalFlowAmountCent += row.TotalFlowAmountCent + summary.TotalRefundAmountCent += row.TotalRefundAmountCent + summary.PendingRefundAmountCent += row.PendingRefundAmountCent + summary.SuccessfulPayCount += row.SuccessfulPayCount + summary.FullRefundCount += row.FullRefundCount + summary.PartialRefundCount += row.PartialRefundCount + summary.PendingRefundCount += row.PendingRefundCount + } + for _, row := range settlements { + summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent + summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent + summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent + summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent + summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent + summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent + summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount + summary.FinancialExceptionCount += row.FinancialExceptionCount + summary.SettledOrderCount += row.SettledOrderCount + } + for _, row := range estimates { + summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent + summary.PendingSettleOrderCount += row.PendingSettleOrderCount + } + summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent + summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent + return summary +} + +func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { + if pickup == nil { + return summary + } + summary.PickupProfitAmountCent = pickup.ProfitAmountCent + summary.PickupCompletedCount = pickup.CompletedCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent + summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent + summary.OfflineSettlementPendingCount += pickup.PendingCount + return summary +} + +func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { + var summary MohongSummaryDTO + for _, row := range rows { + summary.FlowAmountCent += row.MohongFlowAmountCent + summary.RefundAmountCent += row.MohongRefundAmountCent + summary.PaidOrderCount += row.MohongPaidOrderCount + } + summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent + return summary +} + +func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { + var summary OperatingExpenseSummaryDTO + for _, row := range rows { + summary.AmountCent += row.AmountCent + summary.Count += row.Count + } + return summary } // dailyPayments 按支付单创建日统计收付款流水与退款笔数, // normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { rows := make([]dailyPaymentRow, 0) - err := r.db.WithContext(ctx).Table("payment_orders AS po"). + db := r.db.WithContext(ctx) + err := db.Table("payment_orders AS po"). Select(`DATE(po.created_at) AS date, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, @@ -413,14 +508,12 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ 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 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 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 = '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, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(po.created_at)"). Scan(&rows).Error @@ -439,7 +532,7 @@ func (r *Repository) dailyPickupCancelled(ctx context.Context, query DashboardQu return rows, err } -// dailyPaidOrders 按实际付款日统计普通租赁订单,并将创建的线下提号视为已付款成交。 +// dailyPaidOrders 按实际付款日统计普通租赁订单,并将已完成的线下提号按完成日计入成交。 func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) ([]dailyPaidOrderRow, error) { items := make([]dailyPaidOrderRow, 0) err := r.db.WithContext(ctx).Table(`( @@ -448,10 +541,10 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) WHERE biz_type = 'order_pay' AND status = 'paid' AND paid_at >= ? AND paid_at <= ? GROUP BY DATE(paid_at) UNION ALL - SELECT DATE(created_at) AS date, COUNT(id) AS paid_order_count + SELECT DATE(completed_at) AS date, COUNT(id) AS paid_order_count FROM admin_pickups - WHERE created_at >= ? AND created_at <= ? - GROUP BY DATE(created_at) + WHERE status = 'completed' AND completed_at >= ? AND completed_at <= ? + GROUP BY DATE(completed_at) ) AS daily_paid_orders`, query.StartDate, query.EndDate, query.StartDate, query.EndDate). Select("date, SUM(paid_order_count) AS paid_order_count"). Group("date"). @@ -459,32 +552,6 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) return items, err } -type paymentSummaryRow struct { - TotalFlowAmountCent int64 - TotalRefundAmountCent int64 - PendingRefundAmountCent int64 - SuccessfulPayCount int64 - FullRefundCount int64 - PartialRefundCount int64 - PendingRefundCount int64 -} - -type settlementSummaryRow struct { - PlatformIncomeAmountCent int64 - OwnerShouldIncomeAmountCent int64 - OwnerWalletIncomeAmountCent int64 - OwnerEffectiveSettlementAmountCent int64 - OfflineSettlementAmountCent int64 - OfflineSettlementPendingAmountCent int64 - OfflineSettlementPendingCount int64 - SettledOrderCount int64 -} - -type estimatedIncomeRow struct { - EstimatedIncomeAmountCent int64 - PendingSettleOrderCount int64 -} - type dailyPaymentRow struct { Date string TotalFlowAmountCent int64 @@ -495,6 +562,9 @@ type dailyPaymentRow struct { PartialRefundCount int64 PendingRefundCount int64 NormalFullRefundCount int64 + MohongFlowAmountCent int64 + MohongRefundAmountCent int64 + MohongPaidOrderCount int64 } type dailyPickupCancelledRow struct { @@ -516,6 +586,7 @@ type dailySettlementRow struct { OfflineSettlementAmountCent int64 OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 SettledOrderCount int64 } @@ -534,4 +605,5 @@ type dailyPickupAdjustmentRow struct { type dailyEstimatedRow struct { Date string EstimatedIncomeAmountCent int64 + PendingSettleOrderCount int64 } diff --git a/backend/internal/modules/adminfinance/dashboard_cache.go b/backend/internal/modules/adminfinance/dashboard_cache.go new file mode 100644 index 0000000..8ffcdc9 --- /dev/null +++ b/backend/internal/modules/adminfinance/dashboard_cache.go @@ -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() +} diff --git a/backend/internal/modules/adminfinance/dashboard_profile_test.go b/backend/internal/modules/adminfinance/dashboard_profile_test.go new file mode 100644 index 0000000..00d2e3e --- /dev/null +++ b/backend/internal/modules/adminfinance/dashboard_profile_test.go @@ -0,0 +1,45 @@ +package adminfinance + +import ( + "encoding/json" + "os" + "testing" + "time" + + "hfb_sys/backend/internal/timeutil" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestProfileDashboardOnMySQL(t *testing.T) { + dsn := os.Getenv("HFB_ADMIN_FINANCE_TEST_DSN") + if dsn == "" { + t.Skip("profile only") + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) + if err != nil { + t.Fatal(err) + } + now := timeutil.ShanghaiNow() + started := time.Now() + result, err := NewRepository(db).Dashboard(t.Context(), DashboardQuery{ + StartDate: time.Date(now.Year(), now.Month(), now.Day()-6, 0, 0, 0, 0, timeutil.ShanghaiLocation()), + EndDate: time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, timeutil.ShanghaiLocation()), + }) + if err != nil { + t.Fatal(err) + } + t.Logf("dashboard duration=%s daily_items=%d", time.Since(started), len(result.DailyItems)) + if output := os.Getenv("HFB_ADMIN_FINANCE_PROFILE_OUTPUT"); output != "" { + result.GeneratedAt = time.Time{} + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, data, 0o600); err != nil { + t.Fatal(err) + } + } +} diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index 4394252..1ce17f2 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -27,7 +27,9 @@ func TestDailyPaidOrdersUsePaidAtAndIncludeCreatedPickups(t *testing.T) { } if err := db.Exec(`CREATE TABLE admin_pickups ( id INTEGER PRIMARY KEY, - created_at DATETIME NOT NULL + status TEXT NOT NULL, + created_at DATETIME NOT NULL, + completed_at DATETIME NULL )`).Error; err != nil { t.Fatalf("创建提号表失败: %v", err) } @@ -49,12 +51,12 @@ func TestDailyPaidOrdersUsePaidAtAndIncludeCreatedPickups(t *testing.T) { } } for _, row := range [][]any{ - {1, day(2)}, - {2, day(2)}, - {3, day(3)}, - {4, day(1)}, + {1, "completed", day(2), day(2)}, + {2, "picking_up", day(2), nil}, + {3, "completed", day(3), day(3)}, + {4, "cancelled", day(1), nil}, } { - if err := db.Exec(`INSERT INTO admin_pickups (id, created_at) VALUES (?, ?)`, row...).Error; err != nil { + if err := db.Exec(`INSERT INTO admin_pickups (id, status, created_at, completed_at) VALUES (?, ?, ?, ?)`, row...).Error; err != nil { t.Fatalf("写入提号记录失败: %v", err) } } @@ -75,8 +77,8 @@ func TestDailyPaidOrdersUsePaidAtAndIncludeCreatedPickups(t *testing.T) { for _, row := range result { counts[row.Date] = row.PaidOrderCount } - if counts["2026-08-02"] != 3 || counts["2026-08-03"] != 2 { - t.Fatalf("每日已付款订单 = %#v, want 8月2日为3、8月3日为2", counts) + if counts["2026-08-02"] != 2 || counts["2026-08-03"] != 2 { + t.Fatalf("每日已付款订单 = %#v, want 8月2日为2、8月3日为2", counts) } } @@ -194,7 +196,9 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) { status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL, profit_amount_cent INTEGER NOT NULL, - completed_at DATETIME NULL + completed_at DATETIME NULL, + settlement_mode TEXT NOT NULL DEFAULT 'owner_wallet', + offline_settlement_status TEXT NOT NULL DEFAULT 'none' )`).Error; err != nil { t.Fatalf("创建提号表失败: %v", err) } @@ -203,7 +207,9 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) { pickup_id INTEGER NOT NULL, settle_delta_cent INTEGER NOT NULL, profit_delta_cent INTEGER NOT NULL, - created_at DATETIME NOT NULL + created_at DATETIME NOT NULL, + settlement_mode TEXT NOT NULL DEFAULT 'owner_wallet', + status TEXT NOT NULL DEFAULT 'settled' )`).Error; err != nil { t.Fatalf("创建提号调整表失败: %v", err) } @@ -240,3 +246,126 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) { t.Fatalf("调整日汇总 = %+v", adjusted) } } + +func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + for _, statement := range []string{ + `CREATE TABLE payment_orders ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, biz_type TEXT NOT NULL, + status TEXT NOT NULL, amount_cent INTEGER NOT NULL, created_at DATETIME NOT NULL + )`, + `CREATE TABLE rental_orders ( + id INTEGER PRIMARY KEY, settled_at DATETIME NULL, settlement_mode TEXT NOT NULL, + offline_settlement_amount_cent INTEGER NOT NULL, offline_settlement_status TEXT NOT NULL, + refund_status TEXT NOT NULL, settlement_status TEXT NOT NULL, status TEXT NOT NULL, + created_at DATETIME NOT NULL, platform_fee_cent INTEGER NOT NULL + )`, + `CREATE TABLE order_checkouts ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, status TEXT NOT NULL, + platform_fee_cent INTEGER NOT NULL, owner_income_amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE wallet_ledger ( + id INTEGER PRIMARY KEY, order_id INTEGER NULL, direction TEXT NOT NULL, + biz_type TEXT NOT NULL, amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE admin_pickups ( + id INTEGER PRIMARY KEY, status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL, + profit_amount_cent INTEGER NOT NULL, completed_at DATETIME NULL, + settlement_mode TEXT NOT NULL DEFAULT 'owner_wallet', offline_settlement_status TEXT NOT NULL DEFAULT 'none' + )`, + `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, + settlement_mode TEXT NOT NULL DEFAULT 'owner_wallet', status TEXT NOT NULL DEFAULT 'settled' + )`, + } { + 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) + } + settlements, err := repo.dailySettlements(t.Context(), query) + if err != nil { + t.Fatalf("读取每日结算汇总失败: %v", err) + } + summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup) + if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { + t.Fatalf("普通订单数量或利润错误: %+v", summary) + } + if summary.FinancialExceptionCount != 1 { + t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) + } +} + +func TestSummaryFromDailyRowsPreservesDashboardTotals(t *testing.T) { + paymentRows := []dailyPaymentRow{ + {TotalFlowAmountCent: 1000, TotalRefundAmountCent: 100, PendingRefundAmountCent: 30, SuccessfulPayCount: 3, FullRefundCount: 1}, + {TotalFlowAmountCent: 2500, TotalRefundAmountCent: 400, PendingRefundAmountCent: 70, SuccessfulPayCount: 5, PartialRefundCount: 2, PendingRefundCount: 1}, + } + settlementRows := []dailySettlementRow{ + {PlatformIncomeAmountCent: 300, OwnerShouldIncomeAmountCent: 900, OwnerWalletIncomeAmountCent: 850, OwnerEffectiveSettlementAmountCent: 850, OfflineSettlementAmountCent: 100, OfflineSettlementPendingAmountCent: 20, OfflineSettlementPendingCount: 1, FinancialExceptionCount: 2, SettledOrderCount: 3}, + {PlatformIncomeAmountCent: 200, OwnerShouldIncomeAmountCent: 600, OwnerWalletIncomeAmountCent: 500, OwnerEffectiveSettlementAmountCent: 550, OfflineSettlementAmountCent: 200, OfflineSettlementPendingAmountCent: 40, OfflineSettlementPendingCount: 2, FinancialExceptionCount: 1, SettledOrderCount: 2}, + } + estimateRows := []dailyEstimatedRow{ + {EstimatedIncomeAmountCent: 120, PendingSettleOrderCount: 4}, + {EstimatedIncomeAmountCent: 80, PendingSettleOrderCount: 3}, + } + pickup := &PickupSummaryDTO{ProfitAmountCent: 90, CompletedCount: 2, PendingAmountCent: 60, PendingCount: 3} + + summary := summaryWithPickup(summaryFromDailyRows(paymentRows, settlementRows, estimateRows), pickup) + if summary.TotalFlowAmountCent != 3500 || summary.TotalRefundAmountCent != 500 || summary.ChannelNetAmountCent != 3000 { + t.Fatalf("支付汇总错误: %+v", summary) + } + if summary.PendingRefundAmountCent != 100 || summary.SuccessfulPayCount != 8 || summary.SuccessfulRefundCount != 3 || summary.FullRefundCount != 1 || summary.PartialRefundCount != 2 || summary.PendingRefundCount != 1 { + t.Fatalf("支付笔数汇总错误: %+v", summary) + } + if summary.NormalOrderProfitAmountCent != 500 || summary.PickupProfitAmountCent != 90 || summary.PlatformIncomeAmountCent != 590 || summary.PickupCompletedCount != 2 { + t.Fatalf("平台利润汇总错误: %+v", summary) + } + if summary.OwnerShouldIncomeAmountCent != 1500 || summary.OwnerWalletIncomeAmountCent != 1350 || summary.OwnerEffectiveSettlementAmountCent != 1400 || summary.SettlementDiffAmountCent != 100 { + t.Fatalf("货主结算汇总错误: %+v", summary) + } + if summary.OfflineSettlementAmountCent != 300 || summary.OfflineSettlementPendingAmountCent != 120 || summary.OfflineSettlementPendingCount != 6 { + t.Fatalf("线下结算汇总错误: %+v", summary) + } + if summary.EstimatedIncomeAmountCent != 200 || summary.PendingSettleOrderCount != 7 || summary.SettledOrderCount != 5 || summary.FinancialExceptionCount != 3 { + t.Fatalf("订单状态汇总错误: %+v", summary) + } +} diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go index f4d40ee..ce4feca 100644 --- a/backend/internal/modules/adminfinance/detail.go +++ b/backend/internal/modules/adminfinance/detail.go @@ -19,11 +19,23 @@ func (r *Repository) Details(ctx context.Context, query DetailQuery) (*Paginated return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil } - // 明细行查询保留完整 JOIN,仅对当前分页命中的订单做金额聚合。 - db := r.financeDetailBaseQuery(ctx, query) offset := (query.Page - 1) * query.PageSize + orderIDs := make([]uint64, 0, query.PageSize) + if err := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query). + Order("ro.id DESC"). + Offset(offset). + Limit(query.PageSize). + Pluck("ro.id", &orderIDs).Error; err != nil { + return nil, err + } + if len(orderIDs) == 0 { + return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: total, Page: query.Page, PageSize: query.PageSize}, nil + } + + // 先取当前页 ID,再只为这些订单聚合结账、支付和钱包详情。 + db := r.financeDetailBaseQuery(ctx, query, orderIDs).Where("ro.id IN ?", orderIDs) rows := make([]financeDetailRow, 0, query.PageSize) - if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { + if err := db.Order("ro.id DESC").Scan(&rows).Error; err != nil { return nil, err } items := make([]FinanceDetailDTO, 0, len(rows)) @@ -67,7 +79,7 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB return db } -func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery) *gorm.DB { +func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery, orderIDs []uint64) *gorm.DB { db := r.db.WithContext(ctx).Table("rental_orders AS ro"). Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status, ro.handoff_mode, ro.settlement_mode, @@ -113,9 +125,9 @@ func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQue ro.created_at, ro.settled_at, ro.offline_settled_at`, settlementDiffThresholdCent). Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id"). Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id"). - Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db.WithContext(ctx))). - Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db.WithContext(ctx))). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db.WithContext(ctx))) + Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). + Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForOrdersSubquery(r.db.WithContext(ctx), orderIDs)) return r.applyDetailFilters(db, query) } diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 02c40bb..1a133f4 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -2,6 +2,23 @@ package adminfinance import "context" +type dailyOperatingExpenseRow struct { + Date string + AmountCent int64 + Count int64 +} + +func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) { + rows := make([]dailyOperatingExpenseRow, 0) + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select(`DATE(occurred_at) AS date, COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Group("DATE(occurred_at)"). + Scan(&rows).Error + return rows, err +} + // disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。 func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) { db := r.db.WithContext(ctx) @@ -81,42 +98,29 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue return nil, err } - var withdrawalPaid struct { - AmountCent int64 - FeeAmountCent int64 - ActualAmountCent int64 - Count int64 + // 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。 + var withdrawal struct { + PaidAmountCent int64 + PaidFeeAmountCent int64 + PaidActualAmountCent int64 + PaidCount int64 + PendingAmountCent int64 + PendingCount int64 + ReviewAmountCent int64 + ReviewCount int64 } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, - COALESCE(SUM(fee_cent), 0) AS fee_amount_cent, - COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, - COUNT(id) AS count`). - Where("status = ?", "completed"). - Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate). - Scan(&withdrawalPaid).Error; err != nil { - return nil, err - } - - var withdrawalPending struct { - ActualAmountCent int64 - Count int64 - } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). - Where("status = ?", "processing"). - Scan(&withdrawalPending).Error; err != nil { - return nil, err - } - - var withdrawalReview struct { - ActualAmountCent int64 - Count int64 - } - if err := db.Table("withdrawal_requests"). - Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). - Where("status = ?", "pending"). - Scan(&withdrawalReview).Error; err != nil { + if err := db.Table("withdrawal_requests").Select(` + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN fee_cent ELSE 0 END), 0) AS paid_fee_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN actual_amount_cent ELSE 0 END), 0) AS paid_actual_amount_cent, + COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count, + COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent, + COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count, + COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent, + COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`, + query.StartDate, query.EndDate, query.StartDate, query.EndDate, + query.StartDate, query.EndDate, query.StartDate, query.EndDate). + Scan(&withdrawal).Error; err != nil { return nil, err } @@ -133,22 +137,22 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue } return &DisbursementSummaryDTO{ - PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent, - PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count, - PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent, - PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count, + PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent, + PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count, + PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent, + PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount, OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent, OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count, - OfflineSettlementPendingAmountCent: offlinePending.AmountCent, - OfflineSettlementPendingCount: offlinePending.Count, - WithdrawalAmountCent: withdrawalPaid.AmountCent, - WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent, - WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent, - WithdrawalPaidCount: withdrawalPaid.Count, - WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent, - WithdrawalPendingCount: withdrawalPending.Count, - WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent, - WithdrawalReviewCount: withdrawalReview.Count, + OfflineSettlementPendingAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent, + OfflineSettlementPendingCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count, + WithdrawalAmountCent: withdrawal.PaidAmountCent, + WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent, + WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent, + WithdrawalPaidCount: withdrawal.PaidCount, + WithdrawalPendingAmountCent: withdrawal.PendingAmountCent, + WithdrawalPendingCount: withdrawal.PendingCount, + WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent, + WithdrawalReviewCount: withdrawal.ReviewCount, ManualPaidAmountCent: manualPaid.AmountCent, ManualPaidCount: manualPaid.Count, }, nil diff --git a/backend/internal/modules/adminfinance/disbursement_list.go b/backend/internal/modules/adminfinance/disbursement_list.go index 82ab7c9..c42e5b3 100644 --- a/backend/internal/modules/adminfinance/disbursement_list.go +++ b/backend/internal/modules/adminfinance/disbursement_list.go @@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, ro.offline_settlement_remark AS remark, '' AS category, + '' AS custom_category_name, '' AS voucher_url, NULL AS voided_at, NULL AS voided_by, @@ -94,6 +95,83 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { Where("ro.offline_settlement_status IN ?", []string{"pending", "settled"}). Where("ro.offline_settlement_amount_cent > 0") + pickupManaged := db.Table("admin_pickups AS ap"). + Select(`'pickup' AS source_type, + ap.id AS source_id, + ap.pickup_no AS business_no, + 0 AS order_id, + 0 AS withdrawal_id, + ap.owner_id AS user_id, + '外部卖家' AS payee_name, + '' AS payee_phone, + '' AS uploader_name, + ap.source_channel, + '' AS account_type, + '' AS account_name, + '' AS account_no, + '' AS bank_name, + ap.settle_amount_cent AS amount_cent, + 0 AS fee_cent, + ap.settle_amount_cent AS actual_amount_cent, + CASE WHEN ap.offline_settlement_status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status, + ap.offline_settlement_status AS raw_status, + ap.completed_at AS business_created_at, + ap.offline_settled_at AS paid_at, + ap.offline_settled_by AS operator_id, + COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, + ap.offline_settlement_remark AS remark, + '' AS category, + '' AS custom_category_name, + '' AS voucher_url, + NULL AS voided_at, + NULL AS voided_by, + '' AS voided_by_name, + '' AS void_reason, + ap.account_snapshot AS source_payload`). + Joins("LEFT JOIN admin_users AS operator ON operator.id = ap.offline_settled_by"). + Where("ap.settlement_mode = ?", "platform_managed"). + Where("ap.offline_settlement_status IN ?", []string{"pending", "settled"}). + Where("ap.settle_amount_cent > 0") + + pickupAdjustments := db.Table("admin_pickup_financial_adjustments AS fa"). + Select(`'pickup_adjustment' AS source_type, + fa.id AS source_id, + CONCAT(ap.pickup_no, '-ADJ-', fa.id) AS business_no, + 0 AS order_id, + 0 AS withdrawal_id, + ap.owner_id AS user_id, + '外部卖家' AS payee_name, + '' AS payee_phone, + '' AS uploader_name, + ap.source_channel, + '' AS account_type, + '' AS account_name, + '' AS account_no, + '' AS bank_name, + fa.settle_delta_cent AS amount_cent, + 0 AS fee_cent, + fa.settle_delta_cent AS actual_amount_cent, + CASE WHEN fa.status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status, + fa.status AS raw_status, + fa.created_at AS business_created_at, + fa.settled_at AS paid_at, + fa.settled_by AS operator_id, + COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, + fa.reason AS remark, + '' AS category, + '' AS custom_category_name, + '' AS voucher_url, + NULL AS voided_at, + NULL AS voided_by, + '' AS voided_by_name, + '' AS void_reason, + ap.account_snapshot AS source_payload`). + Joins("JOIN admin_pickups AS ap ON ap.id = fa.pickup_id"). + Joins("LEFT JOIN admin_users AS operator ON operator.id = fa.settled_by"). + Where("fa.settlement_mode = ?", "platform_managed"). + Where("fa.status IN ?", []string{"pending", "settled"}). + Where("fa.settle_delta_cent > 0") + withdrawal := db.Table("withdrawal_requests AS wr"). Select(`'withdrawal' AS source_type, wr.id AS source_id, @@ -125,6 +203,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark, '' AS category, + '' AS custom_category_name, '' AS voucher_url, NULL AS voided_at, NULL AS voided_by, @@ -160,6 +239,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name, md.remark, md.category, + md.custom_category_name, md.voucher_url, md.voided_at, md.voided_by, @@ -169,7 +249,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by"). Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by") - union := db.Raw("? UNION ALL ? UNION ALL ?", platformManaged, withdrawal, manualOffline) + union := db.Raw("? UNION ALL ? UNION ALL ? UNION ALL ? UNION ALL ?", platformManaged, pickupManaged, pickupAdjustments, withdrawal, manualOffline) return db.Table("(?) AS d", union) } @@ -202,37 +282,38 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue } type disbursementItemRow struct { - SourceType string - SourceID uint64 - BusinessNo string - OrderID uint64 - WithdrawalID uint64 - UserID uint64 - PayeeName string - PayeePhone string - UploaderName string - SourceChannel string - AccountType string - AccountName string - AccountNo string - BankName string - AmountCent int64 - FeeCent int64 - ActualAmountCent int64 - Status string - RawStatus string - BusinessCreatedAt *time.Time - PaidAt *time.Time - OperatorID *uint64 - OperatorName string - Remark string - Category string - VoucherURL string - VoidedAt *time.Time - VoidedBy *uint64 - VoidedByName string - VoidReason string - SourcePayload []byte + SourceType string + SourceID uint64 + BusinessNo string + OrderID uint64 + WithdrawalID uint64 + UserID uint64 + PayeeName string + PayeePhone string + UploaderName string + SourceChannel string + AccountType string + AccountName string + AccountNo string + BankName string + AmountCent int64 + FeeCent int64 + ActualAmountCent int64 + Status string + RawStatus string + BusinessCreatedAt *time.Time + PaidAt *time.Time + OperatorID *uint64 + OperatorName string + Remark string + Category string + CustomCategoryName string + VoucherURL string + VoidedAt *time.Time + VoidedBy *uint64 + VoidedByName string + VoidReason string + SourcePayload []byte } func (r disbursementItemRow) toDTO() DisbursementItemDTO { @@ -246,35 +327,36 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO { } } return DisbursementItemDTO{ - SourceType: r.SourceType, - SourceID: r.SourceID, - BusinessNo: r.BusinessNo, - OrderID: r.OrderID, - WithdrawalID: r.WithdrawalID, - UserID: r.UserID, - PayeeName: r.PayeeName, - PayeePhone: phone, - UploaderName: r.UploaderName, - SourceChannel: r.SourceChannel, - AccountType: r.AccountType, - AccountName: r.AccountName, - AccountNo: r.AccountNo, - BankName: r.BankName, - AmountCent: r.AmountCent, - FeeCent: r.FeeCent, - ActualAmountCent: r.ActualAmountCent, - Status: r.Status, - RawStatus: r.RawStatus, - BusinessCreatedAt: r.BusinessCreatedAt, - PaidAt: r.PaidAt, - OperatorID: r.OperatorID, - OperatorName: r.OperatorName, - Remark: r.Remark, - Category: r.Category, - VoucherURL: r.VoucherURL, - VoidedAt: r.VoidedAt, - VoidedBy: r.VoidedBy, - VoidedByName: r.VoidedByName, - VoidReason: r.VoidReason, + SourceType: r.SourceType, + SourceID: r.SourceID, + BusinessNo: r.BusinessNo, + OrderID: r.OrderID, + WithdrawalID: r.WithdrawalID, + UserID: r.UserID, + PayeeName: r.PayeeName, + PayeePhone: phone, + UploaderName: r.UploaderName, + SourceChannel: r.SourceChannel, + AccountType: r.AccountType, + AccountName: r.AccountName, + AccountNo: r.AccountNo, + BankName: r.BankName, + AmountCent: r.AmountCent, + FeeCent: r.FeeCent, + ActualAmountCent: r.ActualAmountCent, + Status: r.Status, + RawStatus: r.RawStatus, + BusinessCreatedAt: r.BusinessCreatedAt, + PaidAt: r.PaidAt, + OperatorID: r.OperatorID, + OperatorName: r.OperatorName, + Remark: r.Remark, + Category: r.Category, + CustomCategoryName: r.CustomCategoryName, + VoucherURL: r.VoucherURL, + VoidedAt: r.VoidedAt, + VoidedBy: r.VoidedBy, + VoidedByName: r.VoidedByName, + VoidReason: r.VoidReason, } } diff --git a/backend/internal/modules/adminfinance/disbursement_list_test.go b/backend/internal/modules/adminfinance/disbursement_list_test.go index 842de0f..9b7ac6d 100644 --- a/backend/internal/modules/adminfinance/disbursement_list_test.go +++ b/backend/internal/modules/adminfinance/disbursement_list_test.go @@ -37,11 +37,22 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) `CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, `CREATE TABLE manual_disbursements ( - id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT, + id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT, amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT, status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME, void_reason TEXT, created_at DATETIME )`, + `CREATE TABLE admin_pickups ( + id INTEGER PRIMARY KEY, pickup_no TEXT, owner_id INTEGER, source_channel TEXT, + settlement_mode TEXT, settle_amount_cent INTEGER, offline_settlement_status TEXT, + offline_settlement_remark TEXT, offline_settled_by INTEGER, offline_settled_at DATETIME, + completed_at DATETIME, account_snapshot BLOB + )`, + `CREATE TABLE admin_pickup_financial_adjustments ( + id INTEGER PRIMARY KEY, pickup_id INTEGER, settle_delta_cent INTEGER, + settlement_mode TEXT, status TEXT, reason TEXT, settled_by INTEGER, + settled_at DATETIME, created_at DATETIME + )`, } for _, statement := range statements { if err := db.Exec(statement).Error; err != nil { @@ -75,6 +86,17 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) at(2), at(3), at(4)).Error; err != nil { t.Fatal(err) } + if err := db.Exec(`INSERT INTO admin_pickups + (id, pickup_no, owner_id, source_channel, settlement_mode, settle_amount_cent, offline_settlement_status, + offline_settlement_remark, offline_settled_by, offline_settled_at, completed_at, account_snapshot) + VALUES (30, 'P202607010001', 1, '线下录入', 'platform_managed', 4000, 'settled', '提号结算', 7, ?, ?, '{}')`, at(4), at(3)).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments + (id, pickup_id, settle_delta_cent, settlement_mode, status, reason, settled_by, settled_at, created_at) + VALUES (31, 30, 500, 'platform_managed', 'settled', '补差价', 7, ?, ?)`, at(5), at(5)).Error; err != nil { + t.Fatal(err) + } if err := db.Exec(`INSERT INTO withdrawal_requests (id, withdraw_no, user_id, account_type, account_name, account_no, bank_name, amount_cent, fee_cent, actual_amount_cent, status, created_at, paid_at, @@ -87,11 +109,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) t.Fatal(err) } if err := db.Exec(`INSERT INTO manual_disbursements - (id, disbursement_no, category, payee_name, amount_cent, paid_at, remark, + (id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark, voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at) VALUES - (20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), - (21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, + (20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), + (21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, at(6), at(5), at(7), at(8), at(5)).Error; err != nil { t.Fatal(err) } @@ -108,11 +130,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) if err != nil { t.Fatalf("Disbursements() error = %v", err) } - if result.Total != 7 || len(result.Items) != 7 { - t.Fatalf("记录数 = %d/%d, want 7/7", result.Total, len(result.Items)) + if result.Total != 9 || len(result.Items) != 9 { + t.Fatalf("记录数 = %d/%d, want 9/9", result.Total, len(result.Items)) } - if result.Summary.PaidAmountCent != 24400 || result.Summary.PaidCount != 3 { - t.Fatalf("已出款 = %d/%d, want 24400/3", result.Summary.PaidAmountCent, result.Summary.PaidCount) + if result.Summary.PaidAmountCent != 28900 || result.Summary.PaidCount != 5 { + t.Fatalf("已出款 = %d/%d, want 28900/5", result.Summary.PaidAmountCent, result.Summary.PaidCount) } if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 { t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount) @@ -134,6 +156,26 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) if platformItem.PayeeName != "外部卖家" || platformItem.PayeePhone != "13800000000" || platformItem.UploaderName != "上传客服甲" { t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName) } + var pickupItem *DisbursementItemDTO + for index := range result.Items { + if result.Items[index].BusinessNo == "P202607010001" { + pickupItem = &result.Items[index] + break + } + } + if pickupItem == nil || pickupItem.SourceType != "pickup" || pickupItem.ActualAmountCent != 4000 { + t.Fatalf("线下提号出款信息不正确: %+v", pickupItem) + } + var adjustmentItem *DisbursementItemDTO + for index := range result.Items { + if result.Items[index].SourceType == "pickup_adjustment" { + adjustmentItem = &result.Items[index] + break + } + } + if adjustmentItem == nil || adjustmentItem.BusinessNo != "P202607010001-ADJ-31" || adjustmentItem.ActualAmountCent != 500 { + t.Fatalf("提号调整出款信息不正确: %+v", adjustmentItem) + } var manualItem *DisbursementItemDTO for index := range result.Items { @@ -142,7 +184,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) break } } - if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" { + if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" { t.Fatalf("其他线下出款信息不正确: %+v", manualItem) } diff --git a/backend/internal/modules/adminfinance/disbursement_mysql_test.go b/backend/internal/modules/adminfinance/disbursement_mysql_test.go new file mode 100644 index 0000000..096e4f4 --- /dev/null +++ b/backend/internal/modules/adminfinance/disbursement_mysql_test.go @@ -0,0 +1,35 @@ +package adminfinance + +import ( + "os" + "testing" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestDisbursementQueryOnMySQL(t *testing.T) { + dsn := os.Getenv("HFB_ADMIN_FINANCE_TEST_DSN") + if dsn == "" { + t.Skip("设置 HFB_ADMIN_FINANCE_TEST_DSN 后运行 MySQL 资金出款查询测试") + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("连接 MySQL 失败: %v", err) + } + + result, err := NewRepository(db).Disbursements(t.Context(), DisbursementQuery{ + DateType: "created", + StartDate: time.Now().AddDate(-1, 0, 0), + EndDate: time.Now().AddDate(0, 0, 1), + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("MySQL 资金出款查询失败: %v", err) + } + if result == nil || result.Items == nil { + t.Fatalf("MySQL 资金出款查询返回异常: %+v", result) + } +} diff --git a/backend/internal/modules/adminfinance/disbursement_test.go b/backend/internal/modules/adminfinance/disbursement_test.go index 47e5efb..a3458cd 100644 --- a/backend/internal/modules/adminfinance/disbursement_test.go +++ b/backend/internal/modules/adminfinance/disbursement_test.go @@ -150,6 +150,9 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) { if summary.PendingPaymentAmountCent != 15600 || summary.PendingPaymentCount != 4 { t.Fatalf("待出款 = %d/%d, want 15600/4", summary.PendingPaymentAmountCent, summary.PendingPaymentCount) } + if summary.OfflineSettlementPendingAmountCent != 10800 || summary.OfflineSettlementPendingCount != 3 { + t.Fatalf("线下结算待出款 = %d/%d, want 10800/3", summary.OfflineSettlementPendingAmountCent, summary.OfflineSettlementPendingCount) + } if summary.WithdrawalReviewAmountCent != 2000 || summary.WithdrawalReviewCount != 1 { t.Fatalf("待审核提现 = %d/%d, want 2000/1", summary.WithdrawalReviewAmountCent, summary.WithdrawalReviewCount) } diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index aa3981a..d5440c1 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -34,13 +34,25 @@ type DisbursementQuery struct { PageSize int } +type OperatingExpenseQuery struct { + Status string + Category string + Keyword string + DateType string + StartDate time.Time + EndDate time.Time + Page int + PageSize int +} + type DashboardDTO struct { - Summary FinanceSummaryDTO `json:"summary"` - DailyItems []FinanceDailyDTO `json:"daily_items"` - PickupSummary PickupSummaryDTO `json:"pickup_summary"` - MohongSummary MohongSummaryDTO `json:"mohong_summary"` - DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` - GeneratedAt time.Time `json:"generated_at"` + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + PickupSummary PickupSummaryDTO `json:"pickup_summary"` + MohongSummary MohongSummaryDTO `json:"mohong_summary"` + DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` + OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"` + GeneratedAt time.Time `json:"generated_at"` } // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 @@ -49,6 +61,8 @@ type PickupSummaryDTO struct { ProfitAmountCent int64 `json:"profit_amount_cent"` // 区间内已完成提号的线下利润合计 CompletedCount int64 `json:"completed_count"` // 区间内已完成提号笔数 InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间) + PendingAmountCent int64 `json:"pending_amount_cent"` // 当前平台代管提号待结算金额(含正向调整) + PendingCount int64 `json:"pending_count"` // 当前平台代管提号待结算笔数(含正向调整) } // MohongSummaryDTO 撞车商城的交易流水,单独展示,不计入平台利润。 @@ -101,46 +115,67 @@ type DisbursementListSummaryDTO struct { WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` } +type OperatingExpenseSummaryDTO struct { + AmountCent int64 `json:"amount_cent"` + Count int64 `json:"count"` +} + +type OperatingExpenseListSummaryDTO struct { + RecordCount int64 `json:"record_count"` + PaidAmountCent int64 `json:"paid_amount_cent"` + PaidCount int64 `json:"paid_count"` +} + +type OperatingExpenseListDTO struct { + Items []OperatingExpenseDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Summary OperatingExpenseListSummaryDTO `json:"summary"` +} + type DisbursementItemDTO struct { - SourceType string `json:"source_type"` - SourceID uint64 `json:"source_id"` - BusinessNo string `json:"business_no"` - OrderID uint64 `json:"order_id,omitempty"` - WithdrawalID uint64 `json:"withdrawal_id,omitempty"` - UserID uint64 `json:"user_id"` - PayeeName string `json:"payee_name"` - PayeePhone string `json:"payee_phone"` - UploaderName string `json:"uploader_name"` - SourceChannel string `json:"source_channel"` - AccountType string `json:"account_type"` - AccountName string `json:"account_name"` - AccountNo string `json:"account_no"` - BankName string `json:"bank_name"` - AmountCent int64 `json:"amount_cent"` - FeeCent int64 `json:"fee_cent"` - ActualAmountCent int64 `json:"actual_amount_cent"` - Status string `json:"status"` - RawStatus string `json:"raw_status"` - BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` - PaidAt *time.Time `json:"paid_at,omitempty"` - OperatorID *uint64 `json:"operator_id,omitempty"` - OperatorName string `json:"operator_name"` - Remark string `json:"remark"` - Category string `json:"category"` - VoucherURL string `json:"voucher_url"` - VoidedAt *time.Time `json:"voided_at,omitempty"` - VoidedBy *uint64 `json:"voided_by,omitempty"` - VoidedByName string `json:"voided_by_name"` - VoidReason string `json:"void_reason"` + SourceType string `json:"source_type"` + SourceID uint64 `json:"source_id"` + BusinessNo string `json:"business_no"` + OrderID uint64 `json:"order_id,omitempty"` + WithdrawalID uint64 `json:"withdrawal_id,omitempty"` + UserID uint64 `json:"user_id"` + PayeeName string `json:"payee_name"` + PayeePhone string `json:"payee_phone"` + UploaderName string `json:"uploader_name"` + SourceChannel string `json:"source_channel"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` + BankName string `json:"bank_name"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + Status string `json:"status"` + RawStatus string `json:"raw_status"` + BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` + PaidAt *time.Time `json:"paid_at,omitempty"` + OperatorID *uint64 `json:"operator_id,omitempty"` + OperatorName string `json:"operator_name"` + Remark string `json:"remark"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + VoucherURL string `json:"voucher_url"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidReason string `json:"void_reason"` } type CreateManualDisbursementRequest struct { - Category string `json:"category"` - PayeeName string `json:"payee_name"` - AmountCent int64 `json:"amount_cent"` - PaidAt time.Time `json:"paid_at"` - Remark string `json:"remark"` - VoucherURL string `json:"voucher_url"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + PaidAt time.Time `json:"paid_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` } type VoidManualDisbursementRequest struct { @@ -148,22 +183,55 @@ type VoidManualDisbursementRequest struct { } type ManualDisbursementDTO struct { - ID uint64 `json:"id"` - DisbursementNo string `json:"disbursement_no"` - Category string `json:"category"` - PayeeName string `json:"payee_name"` - AmountCent int64 `json:"amount_cent"` - PaidAt time.Time `json:"paid_at"` - Remark string `json:"remark"` - VoucherURL string `json:"voucher_url"` - Status string `json:"status"` - CreatedBy uint64 `json:"created_by"` - CreatedByName string `json:"created_by_name"` - VoidedBy *uint64 `json:"voided_by,omitempty"` - VoidedByName string `json:"voided_by_name"` - VoidedAt *time.Time `json:"voided_at,omitempty"` - VoidReason string `json:"void_reason"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + DisbursementNo string `json:"disbursement_no"` + Category string `json:"category"` + CustomCategoryName string `json:"custom_category_name"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + PaidAt time.Time `json:"paid_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` + Status string `json:"status"` + CreatedBy uint64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidReason string `json:"void_reason"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateOperatingExpenseRequest struct { + Category string `json:"category"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + OccurredAt time.Time `json:"occurred_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` +} + +type VoidOperatingExpenseRequest struct { + Reason string `json:"reason"` +} + +type OperatingExpenseDTO struct { + ID uint64 `json:"id"` + ExpenseNo string `json:"expense_no"` + Category string `json:"category"` + PayeeName string `json:"payee_name"` + AmountCent int64 `json:"amount_cent"` + OccurredAt time.Time `json:"occurred_at"` + Remark string `json:"remark"` + VoucherURL string `json:"voucher_url"` + Status string `json:"status"` + CreatedBy uint64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + VoidedBy *uint64 `json:"voided_by,omitempty"` + VoidedByName string `json:"voided_by_name"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidReason string `json:"void_reason"` + CreatedAt time.Time `json:"created_at"` } type FinanceSummaryDTO struct { @@ -173,11 +241,11 @@ type FinanceSummaryDTO struct { ChannelNetAmountCent int64 `json:"channel_net_amount_cent"` // PlatformIncomeAmountCent 为正常订单最终结账利润与已完成线下提号利润之和。 // 保留原 JSON 字段名,避免影响已接入的管理端客户端。 - PlatformIncomeAmountCent int64 `json:"platform_income_amount_cent"` - NormalOrderProfitAmountCent int64 `json:"normal_order_profit_amount_cent"` - PickupProfitAmountCent int64 `json:"pickup_profit_amount_cent"` + PlatformIncomeAmountCent int64 `json:"platform_income_amount_cent"` + NormalOrderProfitAmountCent int64 `json:"normal_order_profit_amount_cent"` + PickupProfitAmountCent int64 `json:"pickup_profit_amount_cent"` // PickupCompletedCount 线下提号利润对应的已完成提号笔数。 - PickupCompletedCount int64 `json:"pickup_completed_count"` + PickupCompletedCount int64 `json:"pickup_completed_count"` OwnerShouldIncomeAmountCent int64 `json:"owner_should_income_amount_cent"` OwnerWalletIncomeAmountCent int64 `json:"owner_wallet_income_amount_cent"` OwnerEffectiveSettlementAmountCent int64 `json:"owner_effective_settlement_amount_cent"` @@ -220,18 +288,20 @@ type FinanceDailyDTO struct { // NormalFullRefundCount 正常(租赁)订单已退全款笔数(不含撞车商城退款)。 NormalFullRefundCount int64 `json:"normal_full_refund_count"` // PickupCancelledCount 线下提号取消笔数(按取消日归集)。 - PickupCancelledCount int64 `json:"pickup_cancelled_count"` - SalesCount int64 `json:"sales_count"` - PaidOrderCount int64 `json:"paid_order_count"` - OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"` - OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"` - OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"` - WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"` - WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` - ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"` - ManualPaidCount int64 `json:"manual_paid_count"` - DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` - DisbursementPaidCount int64 `json:"disbursement_paid_count"` + PickupCancelledCount int64 `json:"pickup_cancelled_count"` + SalesCount int64 `json:"sales_count"` + PaidOrderCount int64 `json:"paid_order_count"` + OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"` + OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"` + OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"` + WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"` + WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` + ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"` + ManualPaidCount int64 `json:"manual_paid_count"` + OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"` + OperatingExpenseCount int64 `json:"operating_expense_count"` + DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` + DisbursementPaidCount int64 `json:"disbursement_paid_count"` } type PaginatedResult struct { diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index 99ab947..9841a21 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) { response.OK(c, result) } +func (h *Handler) OperatingExpenses(c *gin.Context) { + query, ok := parseOperatingExpenseQuery(c) + if !ok { + return + } + result, err := h.service.OperatingExpenses(c.Request.Context(), query) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, result) +} + func (h *Handler) CreateManualDisbursement(c *gin.Context) { adminID, ok := financeAdminID(c) if !ok { @@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) { response.OK(c, item) } +func (h *Handler) CreateOperatingExpense(c *gin.Context) { + adminID, ok := financeAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateOperatingExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "运营开支信息不正确") + return + } + item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c)) + if err != nil { + writeFinanceError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) VoidOperatingExpense(c *gin.Context) { + adminID, ok := financeAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "运营开支记录 ID 不正确") + return + } + var req VoidOperatingExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写作废原因") + return + } + item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c)) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, item) +} + func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { start, end, ok := parseDateRange(c, 6) if !ok { @@ -169,7 +225,7 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) { StartDate: start, EndDate: end, } - if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" { + if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "pickup" && query.SourceType != "pickup_adjustment" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" { response.BadRequest(c, "出款类型不正确") return query, false } @@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) { return query, true } +func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) { + start, end, ok := parseDateRange(c, 29) + if !ok { + return OperatingExpenseQuery{}, false + } + query := OperatingExpenseQuery{ + Status: strings.TrimSpace(c.Query("status")), + Category: strings.TrimSpace(c.Query("category")), + Keyword: strings.TrimSpace(c.Query("keyword")), + DateType: c.DefaultQuery("date_type", "created"), + StartDate: start, + EndDate: end, + } + if query.Status != "" && query.Status != "paid" && query.Status != "voided" { + response.BadRequest(c, "运营开支状态不正确") + return query, false + } + if query.DateType != "created" && query.DateType != "occurred" { + response.BadRequest(c, "日期类型不正确") + return query, false + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + query.Page = page + query.PageSize = pageSize + return query, true +} + func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) { loc := timeutil.ShanghaiLocation() today := timeutil.ShanghaiNow() @@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) { response.NotFound(c, "线下出款记录不存在") case errors.Is(err, ErrManualDisbursementNotPaid): response.BadRequest(c, "该线下出款记录已作废") + case errors.Is(err, ErrInvalidOperatingExpense): + response.BadRequest(c, "运营开支信息不正确") + case errors.Is(err, ErrOperatingExpenseNotFound): + response.NotFound(c, "运营开支记录不存在") + case errors.Is(err, ErrOperatingExpenseNotPaid): + response.BadRequest(c, "该运营开支记录已作废") default: response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") } diff --git a/backend/internal/modules/adminfinance/helper.go b/backend/internal/modules/adminfinance/helper.go index c43e012..8c30ac4 100644 --- a/backend/internal/modules/adminfinance/helper.go +++ b/backend/internal/modules/adminfinance/helper.go @@ -53,7 +53,7 @@ func normalRefundBizTypes() []string { // settledSettlementStatuses 是视为"已完成结算"的结算状态集合。 // 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。 func settledSettlementStatuses() []string { - return []string{"settled", "closed"} + return []string{"settled", "closed", "arbitrated"} } // nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。 diff --git a/backend/internal/modules/adminfinance/helper_test.go b/backend/internal/modules/adminfinance/helper_test.go index 3fcedea..adc850f 100644 --- a/backend/internal/modules/adminfinance/helper_test.go +++ b/backend/internal/modules/adminfinance/helper_test.go @@ -45,3 +45,15 @@ func TestRefundBizTypesIncludeMohongRefund(t *testing.T) { t.Fatal("refundBizTypes missing mohong_refund") } } + +func TestSettledSettlementStatusesIncludeArbitrated(t *testing.T) { + values := map[string]bool{} + for _, item := range settledSettlementStatuses() { + values[item] = true + } + for _, want := range []string{"settled", "closed", "arbitrated"} { + if !values[want] { + t.Fatalf("settledSettlementStatuses missing %s", want) + } + } +} diff --git a/backend/internal/modules/adminfinance/manual_disbursement.go b/backend/internal/modules/adminfinance/manual_disbursement.go index d8e954b..1c83c24 100644 --- a/backend/internal/modules/adminfinance/manual_disbursement.go +++ b/backend/internal/modules/adminfinance/manual_disbursement.go @@ -16,21 +16,22 @@ import ( ) type manualDisbursementRecord struct { - ID uint64 - DisbursementNo string - Category string - PayeeName string - AmountCent int64 - PaidAt time.Time - Remark string - VoucherURL string - Status string - CreatedBy uint64 - VoidedBy *uint64 - VoidedAt *time.Time - VoidReason string - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 + DisbursementNo string + Category string + CustomCategoryName string + PayeeName string + AmountCent int64 + PaidAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + VoidedBy *uint64 + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time + UpdatedAt time.Time } func (manualDisbursementRecord) TableName() string { @@ -48,15 +49,16 @@ func (r *Repository) CreateManualDisbursement( return nil, err } record := manualDisbursementRecord{ - DisbursementNo: disbursementNo, - Category: req.Category, - PayeeName: req.PayeeName, - AmountCent: req.AmountCent, - PaidAt: req.PaidAt, - Remark: req.Remark, - VoucherURL: req.VoucherURL, - Status: "paid", - CreatedBy: adminID, + DisbursementNo: disbursementNo, + Category: req.Category, + CustomCategoryName: req.CustomCategoryName, + PayeeName: req.PayeeName, + AmountCent: req.AmountCent, + PaidAt: req.PaidAt, + Remark: req.Remark, + VoucherURL: req.VoucherURL, + Status: "paid", + CreatedBy: adminID, } err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&record).Error; err != nil { @@ -71,12 +73,13 @@ func (r *Repository) CreateManualDisbursement( BizID: &id, Meta: meta, Detail: map[string]any{ - "disbursement_no": disbursementNo, - "category": req.Category, - "payee_name": req.PayeeName, - "amount_cent": req.AmountCent, - "paid_at": req.PaidAt, - "has_voucher": req.VoucherURL != "", + "disbursement_no": disbursementNo, + "category": req.Category, + "custom_category_name": req.CustomCategoryName, + "payee_name": req.PayeeName, + "amount_cent": req.AmountCent, + "paid_at": req.PaidAt, + "has_voucher": req.VoucherURL != "", }, }) }) @@ -153,42 +156,44 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma return nil, err } return &ManualDisbursementDTO{ - ID: row.ID, - DisbursementNo: row.DisbursementNo, - Category: row.Category, - PayeeName: row.PayeeName, - AmountCent: row.AmountCent, - PaidAt: row.PaidAt, - Remark: row.Remark, - VoucherURL: row.VoucherURL, - Status: row.Status, - CreatedBy: row.CreatedBy, - CreatedByName: row.CreatedByName, - VoidedBy: row.VoidedBy, - VoidedByName: row.VoidedByName, - VoidedAt: row.VoidedAt, - VoidReason: row.VoidReason, - CreatedAt: row.CreatedAt, + ID: row.ID, + DisbursementNo: row.DisbursementNo, + Category: row.Category, + CustomCategoryName: row.CustomCategoryName, + PayeeName: row.PayeeName, + AmountCent: row.AmountCent, + PaidAt: row.PaidAt, + Remark: row.Remark, + VoucherURL: row.VoucherURL, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedByName: row.CreatedByName, + VoidedBy: row.VoidedBy, + VoidedByName: row.VoidedByName, + VoidedAt: row.VoidedAt, + VoidReason: row.VoidReason, + CreatedAt: row.CreatedAt, }, nil } type manualDisbursementDetailRow struct { - ID uint64 - DisbursementNo string - Category string - PayeeName string - AmountCent int64 - PaidAt time.Time - Remark string - VoucherURL string - Status string - CreatedBy uint64 - CreatedByName string - VoidedBy *uint64 - VoidedByName string - VoidedAt *time.Time - VoidReason string - CreatedAt time.Time + ID uint64 + DisbursementNo string + Category string + CustomCategoryName string + PayeeName string + AmountCent int64 + PaidAt time.Time + Remark string + VoucherURL string + Status string + CreatedBy uint64 + CreatedByName string + VoidedBy *uint64 + VoidedByName string + VoidedAt *time.Time + VoidReason string + CreatedAt time.Time } func newManualDisbursementNo() (string, error) { diff --git a/backend/internal/modules/adminfinance/manual_disbursement_test.go b/backend/internal/modules/adminfinance/manual_disbursement_test.go index 7f712e1..f9df480 100644 --- a/backend/internal/modules/adminfinance/manual_disbursement_test.go +++ b/backend/internal/modules/adminfinance/manual_disbursement_test.go @@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { statements := []string{ `CREATE TABLE manual_disbursements ( id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE, - category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, + category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL, status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, @@ -47,17 +47,18 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { repo := NewRepository(db) paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation()) created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ - Category: "operating_expense", - PayeeName: "测试供应商", - AmountCent: 8800, - PaidAt: paidAt, - Remark: "测试运营支出", - VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", + Category: "custom", + CustomCategoryName: "临时活动支出", + PayeeName: "测试供应商", + AmountCent: 8800, + PaidAt: paidAt, + Remark: "测试运营支出", + VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", }, 7, auditlog.Meta{RequestID: "req-create"}) if err != nil { t.Fatalf("创建其他线下出款失败: %v", err) } - if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" { + if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" { t.Fatalf("创建结果不正确: %+v", created) } @@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) { } func TestManualDisbursementValidation(t *testing.T) { + if !validManualDisbursementCategory("duoduo_deposit_refund") { + t.Fatal("多多退押金分类应为有效分类") + } + if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") { + t.Fatal("自定义分类名称校验不正确") + } + service := NewService(&Repository{}) _, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ Category: "unknown", diff --git a/backend/internal/modules/adminfinance/operating_expense.go b/backend/internal/modules/adminfinance/operating_expense.go new file mode 100644 index 0000000..41e196c --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense.go @@ -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 +} diff --git a/backend/internal/modules/adminfinance/operating_expense_test.go b/backend/internal/modules/adminfinance/operating_expense_test.go new file mode 100644 index 0000000..9481048 --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense_test.go @@ -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) + } +} diff --git a/backend/internal/modules/adminfinance/repository.go b/backend/internal/modules/adminfinance/repository.go index fa79478..558a399 100644 --- a/backend/internal/modules/adminfinance/repository.go +++ b/backend/internal/modules/adminfinance/repository.go @@ -1,13 +1,19 @@ package adminfinance import ( + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type Repository struct { - db *gorm.DB + db *gorm.DB + redis *redis.Client } -func NewRepository(db *gorm.DB) *Repository { - return &Repository{db: db} +func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository { + repo := &Repository{db: db} + if len(redisClient) > 0 { + repo.redis = redisClient[0] + } + return repo } diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index 888972f..b54179d 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -15,6 +15,9 @@ var ( ErrInvalidManualDisbursement = errors.New("invalid manual disbursement") ErrManualDisbursementNotFound = errors.New("manual disbursement not found") ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid") + ErrInvalidOperatingExpense = errors.New("invalid operating expense") + ErrOperatingExpenseNotFound = errors.New("operating expense not found") + ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid") ) type Service struct { @@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (* return s.repo.Disbursements(ctx, query) } +func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + return s.repo.OperatingExpenses(ctx, query) +} + func (s *Service) CreateManualDisbursement( ctx context.Context, req CreateManualDisbursementRequest, @@ -74,10 +93,12 @@ func (s *Service) CreateManualDisbursement( return nil, ErrDependencyUnavailable } req.Category = strings.TrimSpace(req.Category) + req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName) req.PayeeName = strings.TrimSpace(req.PayeeName) req.Remark = strings.TrimSpace(req.Remark) req.VoucherURL = strings.TrimSpace(req.VoucherURL) if !validManualDisbursementCategory(req.Category) || + !validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) || utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || req.AmountCent <= 0 || req.PaidAt.IsZero() || utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || @@ -104,15 +125,51 @@ func (s *Service) VoidManualDisbursement( return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta) } +func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + req.Category = strings.TrimSpace(req.Category) + req.PayeeName = strings.TrimSpace(req.PayeeName) + req.Remark = strings.TrimSpace(req.Remark) + req.VoucherURL = strings.TrimSpace(req.VoucherURL) + if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 || + utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || + req.AmountCent <= 0 || req.OccurredAt.IsZero() || + utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || + !validManualVoucherURL(req.VoucherURL) { + return nil, ErrInvalidOperatingExpense + } + return s.repo.CreateOperatingExpense(ctx, req, adminID, meta) +} + +func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + reason = strings.TrimSpace(reason) + if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 { + return nil, ErrInvalidOperatingExpense + } + return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta) +} + func validManualDisbursementCategory(category string) bool { switch category { - case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other": + case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom": return true default: return false } } +func validManualDisbursementCustomCategoryName(category, name string) bool { + if category != "custom" { + return name == "" + } + return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50 +} + func validManualVoucherURL(raw string) bool { if raw == "" { return true diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go index 7ef386b..6759d09 100644 --- a/backend/internal/modules/adminfinance/subquery.go +++ b/backend/internal/modules/adminfinance/subquery.go @@ -4,14 +4,16 @@ import ( "gorm.io/gorm" ) -func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB { - latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id") +func acceptedCheckoutForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + latest := db.Table("order_checkouts").Select("MAX(id) AS id"). + Where("status = ? AND order_id IN ?", "accepted", orderIDs). + Group("order_id") return db.Table("order_checkouts AS oc"). Select("oc.*"). Joins("JOIN (?) AS latest ON latest.id = oc.id", latest) } -func orderPaymentSubquery(db *gorm.DB) *gorm.DB { +func orderPaymentForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { return db.Table("payment_orders"). Select(`order_id, COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, @@ -19,12 +21,42 @@ func orderPaymentSubquery(db *gorm.DB) *gorm.DB { COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent, COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes(), refundBizTypes()). + Where("order_id IN ?", orderIDs). Group("order_id") } -func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { +func ownerWalletIncomeForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { return db.Table("wallet_ledger"). Select("order_id, COALESCE(SUM(amount_cent), 0) AS owner_wallet_income_amount_cent"). - 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 IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs). 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", "arbitration_owner_income"}). + 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") +} diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 2ea666d..c4bd6dd 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -19,8 +19,9 @@ import ( ) type Repository struct { - db *gorm.DB - encryptor crypto.Encryptor + db *gorm.DB + encryptor crypto.Encryptor + statusChangeNotifier func(userID uint64) } type AuditMeta = auditlog.Meta @@ -33,16 +34,16 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository { return &Repository{db: db, encryptor: encryptor} } +// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。 +func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) { + r.statusChangeNotifier = notifier +} + func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) if err != nil { return nil, err } - var total int64 - countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) - if err := countTx.Count(&total).Error; err != nil { - return nil, err - } offset := (page - 1) * pageSize var rows []userRow listTx := r.db.WithContext(ctx).Table("users AS u"). @@ -66,6 +67,18 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) + if err := countTx.Count(&total).Error; err != nil { + return nil, err + } + } + items := make([]UserDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO(growthConfig)) @@ -86,11 +99,15 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con } } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - // 关键词为纯数字时一并按用户 ID 精确匹配 - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { + if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { + // 11 位手机号:命中 users(phone) 唯一索引 + tx = tx.Where("u.phone = ?", keyword) + } else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 { + // 纯数字 ID:一并按用户 ID 精确匹配 + like := "%" + keyword + "%" tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like) } else { + like := "%" + keyword + "%" tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like) } } @@ -207,6 +224,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use } func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) { + statusChanged := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { @@ -214,27 +232,33 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui } beforeStatus := user.Status beforeRisk := user.RiskStatus + beforeTokenVersion := user.TokenVersion user.Status = status user.RiskStatus = riskStatus - if user.TokenVersion < 1 { - user.TokenVersion = 1 + if beforeStatus != status { + user.TokenVersion++ + statusChanged = true } - user.TokenVersion++ if err := tx.Save(&user).Error; err != nil { return err } return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{ - "user_id": user.ID, - "reason": reason, - "before_status": beforeStatus, - "after_status": status, - "before_risk_status": beforeRisk, - "after_risk_status": riskStatus, + "user_id": user.ID, + "reason": reason, + "before_status": beforeStatus, + "after_status": status, + "before_risk_status": beforeRisk, + "after_risk_status": riskStatus, + "before_token_version": beforeTokenVersion, + "after_token_version": user.TokenVersion, }) }) if err != nil { return nil, err } + if statusChanged && r.statusChangeNotifier != nil { + r.statusChangeNotifier(userID) + } return r.Find(ctx, userID) } @@ -555,3 +579,15 @@ func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, me func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index 5359fcf..e21097f 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -293,6 +293,39 @@ func TestAdjustWalletInsufficientBalance(t *testing.T) { } } +func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) { + db := setupAdminUserTestDB(t) + // 显式给出版本起点,避免 GORM 对零值字段应用 default:1 造成歧义。 + user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 1} + 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 != 2 { + t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/2", 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 != 3 { + t.Fatalf("解冻后状态/版本 = %s/%d, want active/3", got.Status, saved.TokenVersion) + } +} + func setupAdminUserTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index e7a4ed4..79718b8 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } +// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。 +func (s *Service) SetSessionRevoker(revoker func(userID uint64)) { + if s.repo != nil { + s.repo.SetStatusChangeNotifier(revoker) + } +} + func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 93a1880..1581020 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -134,6 +134,10 @@ func (h *Handler) Refresh(c *gin.Context) { } tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken) if err != nil { + if errors.Is(err, ErrDependencyUnavailable) { + response.ServiceUnavailable(c, "用户认证服务暂时不可用") + return + } response.Unauthorized(c, "刷新令牌无效或已过期") return } diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index 33b3986..c2800a8 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -32,14 +32,19 @@ func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, toke if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } - user, err := r.FindByID(ctx, id) - if err != nil { + var user model.User + if err := r.db.WithContext(ctx). + Select("id, phone, status, token_version"). + First(&user, id).Error; err != nil { return nil, err } - if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion { + if user.Status != "active" { return nil, ErrUserDisabled } - return user, nil + if user.TokenVersion != tokenVersion { + return nil, ErrTokenVersionMismatch + } + return &user, nil } func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) { diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index d0f250c..2081ef0 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -33,6 +33,7 @@ var ( ErrPasswordTooWeak = errors.New("password too weak") ErrLoginLocked = errors.New("login locked") ErrUserAlreadyExists = errors.New("user already exists") + ErrTokenVersionMismatch = errors.New("user token version mismatch") ) const ( @@ -180,7 +181,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{}, ErrUserDisabled } - tokens, err := s.issueTokenPair(user) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } @@ -190,7 +191,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( } func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) { - if s.users == nil { + if s.jwt == nil || s.users == nil { return TokenPair{}, ErrDependencyUnavailable } claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") @@ -201,15 +202,18 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenP if err != nil { return TokenPair{}, err } - return s.issueTokenPair(user) + return s.generateUserTokenPair(user) } -func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) { +func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) { + if s.jwt == nil { + return TokenPair{}, ErrDependencyUnavailable + } return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) } func (s *Service) Logout(ctx context.Context, userID uint64) error { - if s.users == nil { + if s.jwt == nil || s.users == nil { return ErrDependencyUnavailable } return s.users.RevokeTokens(ctx, userID) @@ -282,7 +286,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client _ = clearLoginFailure(ctx, s.redis, phone, clientIP) - tokens, err := s.issueTokenPair(user) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } @@ -387,7 +391,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor return LoginResult{}, ErrUserDisabled } - tokens, err := s.issueTokenPair(user) + tokens, err := s.generateUserTokenPair(user) if err != nil { return LoginResult{}, err } diff --git a/backend/internal/modules/auth/token_version_test.go b/backend/internal/modules/auth/token_version_test.go new file mode 100644 index 0000000..d9bec4c --- /dev/null +++ b/backend/internal/modules/auth/token_version_test.go @@ -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: 1} + 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", 1) + if err != nil { + t.Fatalf("生成令牌失败:%v", err) + } + + _, err = repo.FindActiveForToken(t.Context(), user.ID, 1) + 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) + } +} diff --git a/backend/internal/modules/backupmonitor/handler.go b/backend/internal/modules/backupmonitor/handler.go new file mode 100644 index 0000000..b9517eb --- /dev/null +++ b/backend/internal/modules/backupmonitor/handler.go @@ -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) +} diff --git a/backend/internal/modules/chat/admin_counts_cache.go b/backend/internal/modules/chat/admin_counts_cache.go new file mode 100644 index 0000000..5ed8d12 --- /dev/null +++ b/backend/internal/modules/chat/admin_counts_cache.go @@ -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() +} diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go new file mode 100644 index 0000000..aabb703 --- /dev/null +++ b/backend/internal/modules/chat/admin_list_test.go @@ -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) + } +} diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go index 9568cd9..ed3cfe0 100644 --- a/backend/internal/modules/chat/conversation.go +++ b/backend/internal/modules/chat/conversation.go @@ -53,6 +53,18 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, if err != nil { return nil, err } + var state model.ChatAdminConversationState + if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + var unreadCount int64 + if err := db.Table("chat_messages AS cm"). + Where("cm.conversation_id = ?", conversation.ID). + Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user"). + Where("cm.id > ?", state.LastReadMessageID). + Count(&unreadCount).Error; err != nil { + return nil, err + } dto := ConversationDTO{ ID: conversation.ID, OrderID: conversation.OrderID, @@ -62,11 +74,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, Title: conversation.Title, Status: conversation.Status, Role: "admin", // 管理员角色 + AdminRemark: state.Remark, Participants: participants, LastMessageID: conversation.LastMessageID, LastMessagePreview: conversation.LastMessagePreview, LastMessageAt: conversation.LastMessageAt, - UnreadCount: 0, // 管理员不计未读 + UnreadCount: unreadCount, CreatedAt: conversation.CreatedAt, UpdatedAt: conversation.UpdatedAt, } diff --git a/backend/internal/modules/chat/conversation_test.go b/backend/internal/modules/chat/conversation_test.go index 854adb4..b47fea6 100644 --- a/backend/internal/modules/chat/conversation_test.go +++ b/backend/internal/modules/chat/conversation_test.go @@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB { &model.RentalOrder{}, &model.ChatConversation{}, &model.ChatParticipant{}, + &model.ChatAdminConversationState{}, &model.ChatMessage{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index b5aa8d8..ac02646 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -21,6 +21,7 @@ type ConversationDTO struct { Title string `json:"title"` Status string `json:"status"` Role string `json:"role"` + AdminRemark string `json:"admin_remark,omitempty"` Participants []ParticipantDTO `json:"participants,omitempty"` LastMessageID *uint64 `json:"last_message_id"` LastMessagePreview string `json:"last_message_preview"` @@ -29,6 +30,7 @@ type ConversationDTO struct { LastSenderID uint64 `json:"last_sender_id,omitempty"` LastSenderRole string `json:"last_sender_role,omitempty"` UnreadCount int64 `json:"unread_count"` + NeedsReply bool `json:"needs_reply"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -53,19 +55,20 @@ type ParticipantDTO struct { } type MessageDTO struct { - ID uint64 `json:"id"` - ConversationID uint64 `json:"conversation_id"` - SenderType string `json:"sender_type"` - SenderID uint64 `json:"sender_id"` - SenderRole string `json:"sender_role"` - SenderName string `json:"sender_name"` - SenderAvatar string `json:"sender_avatar"` - IsSelf bool `json:"is_self"` - IsRead bool `json:"is_read"` - ContentType string `json:"content_type"` - Content string `json:"content"` - AttachmentURLS []string `json:"attachment_urls"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + SenderType string `json:"sender_type"` + SenderID uint64 `json:"sender_id"` + SenderRole string `json:"sender_role"` + SenderName string `json:"sender_name"` + SenderAvatar string `json:"sender_avatar"` + IsSelf bool `json:"is_self"` + IsRead bool `json:"is_read"` + ContentType string `json:"content_type"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` + AdminAttentionType string `json:"admin_attention_type,omitempty"` + CreatedAt time.Time `json:"created_at"` } type UnreadCountDTO struct { diff --git a/backend/internal/modules/chat/handler_admin.go b/backend/internal/modules/chat/handler_admin.go index 1c5dfc2..ab3a6a2 100644 --- a/backend/internal/modules/chat/handler_admin.go +++ b/backend/internal/modules/chat/handler_admin.go @@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) { response.OK(c, result) } +func (h *Handler) AdminCounts(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + principal := Principal{Type: "admin", ID: adminID} + result, err := h.service.AdminConversationCounts( + c.Request.Context(), + principal, + c.DefaultQuery("filter", "all"), + c.DefaultQuery("stage", "all"), + c.Query("keyword"), + ) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, result) +} + func (h *Handler) AdminDetail(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { @@ -61,6 +82,19 @@ func (h *Handler) AdminMarkRead(c *gin.Context) { h.markRead(c, Principal{Type: "admin", ID: adminID}) } +func (h *Handler) AdminMarkAllRead(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + if err := h.service.MarkAllAdminConversationsRead(c.Request.Context(), adminID); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"read": true}) +} + func (h *Handler) AdminTransfer(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 262ed86..a21a8ca 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -128,6 +128,7 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) { return } if errors.Is(err, ErrQrCodeOCRUnavailable) { + response.RecordError(c, err) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) return } @@ -164,6 +165,7 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) { return } if errors.Is(err, ErrQrCodeOCRUnavailable) { + response.RecordError(c, err) c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) return } diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index beb4166..623a6be 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint if handoffSupportID > 0 { message += ",卖号组客服已接入" } - return sendSystemMessage(tx, conv.ID, message) + return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid") } // RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 @@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string { } func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { + return sendSystemMessageWithAttention(tx, conversationID, content, "") +} + +func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error { message := model.ChatMessage{ - ConversationID: conversationID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: content, - AttachmentURLS: emptyJSONList(), + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: content, + AttachmentURLS: emptyJSONList(), + AdminAttentionType: attentionType, } if err := tx.Create(&message).Error; err != nil { diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index 94ee54b..44f7b89 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa offset := (page - 1) * pageSize var rows []model.ChatMessage - if err := query.Order("id ASC"). + if err := query.Order("id DESC"). Offset(offset). Limit(pageSize). Find(&rows).Error; err != nil { return nil, err } + for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 { + rows[left], rows[right] = rows[right], rows[left] + } items, err := r.toMessageDTOs(ctx, principal, rows) if err != nil { return nil, err @@ -101,12 +104,21 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve Content: req.Content, AttachmentURLS: encodeStringList(req.AttachmentURLS), } + if principal.Type == "user" { + message.AdminAttentionType = "user_inquiry" + } if err := tx.Create(&message).Error; err != nil { return err } conversation.LastMessageID = &message.ID conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) conversation.LastMessageAt = &message.CreatedAt + if message.AdminAttentionType != "" || message.SenderType == "user" { + conversation.LastAttentionMessageID = message.ID + } + if message.SenderType == "admin" { + conversation.LastAdminMessageID = message.ID + } if err := tx.Save(&conversation).Error; err != nil { return err } @@ -143,16 +155,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve Type: "new_message", ConversationID: conversationID, Message: &chathub.MessageData{ - ID: msg.ID, - ConversationID: msg.ConversationID, - SenderType: msg.SenderType, - SenderID: msg.SenderID, - SenderRole: msg.SenderRole, - SenderName: msg.SenderName, - ContentType: msg.ContentType, - Content: msg.Content, - AttachmentURLS: msg.AttachmentURLS, - CreatedAt: msg.CreatedAt.Format(time.RFC3339), + ID: msg.ID, + ConversationID: msg.ConversationID, + SenderType: msg.SenderType, + SenderID: msg.SenderID, + SenderRole: msg.SenderRole, + SenderName: msg.SenderName, + ContentType: msg.ContentType, + Content: msg.Content, + AttachmentURLS: msg.AttachmentURLS, + AdminAttentionType: msg.AdminAttentionType, + CreatedAt: msg.CreatedAt.Format(time.RFC3339), }, } r.hub.NotifyConversation(conversationID, event) @@ -166,21 +179,44 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa now := time.Now() updated := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 管理员可以不是 participant,直接返回成功 if principal.Type == "admin" { - // 尝试查找 participant 记录,如果有就更新 - var participant model.ChatParticipant - err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", - conversationID, principal.Type, principal.ID).First(&participant).Error - if err == nil { - // 有 participant 记录,更新已读时间 - updated = true - return tx.Model(&participant).Update("last_read_at", now).Error - } else if errors.Is(err, gorm.ErrRecordNotFound) { - // 没有 participant 记录,直接返回成功(管理员无需记录已读) + var conversation model.ChatConversation + if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } + lastMessageID := uint64(0) + if conversation.LastMessageID != nil { + lastMessageID = *conversation.LastMessageID + } + var existing model.ChatAdminConversationState + err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID). + First(&existing).Error + if err == nil && existing.LastReadMessageID >= lastMessageID { return nil } - return err + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + state := model.ChatAdminConversationState{ + ConversationID: conversationID, + AdminUserID: principal.ID, + LastReadMessageID: lastMessageID, + LastReadAt: &now, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "last_read_message_id": lastMessageID, + "last_read_at": now, + }), + }).Create(&state).Error; err != nil { + return err + } + updated = true + return nil } // 普通用户必须是 participant @@ -209,3 +245,31 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa } return nil } + +// MarkAllAdminConversationsRead advances one admin's read cursor for every conversation in one transaction. +func (r *Repository) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error { + now := time.Now() + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 先补齐状态行,再更新游标。拆成两步可兼容本地 MySQL/MariaDB 对 + // INSERT ... SELECT 同表读取并 ON DUPLICATE KEY UPDATE 的限制。 + if err := tx.Exec(` + INSERT INTO chat_admin_conversation_states + (conversation_id, admin_user_id, remark, last_read_message_id, last_read_at) + SELECT c.id, ?, COALESCE(existing.remark, ''), COALESCE(c.last_message_id, 0), ? + FROM chat_conversations AS c + LEFT JOIN chat_admin_conversation_states AS existing + ON existing.conversation_id = c.id AND existing.admin_user_id = ? + WHERE existing.id IS NULL`, adminID, now, adminID).Error; err != nil { + return err + } + return tx.Exec(` + UPDATE chat_admin_conversation_states AS cas + JOIN chat_conversations AS c ON c.id = cas.conversation_id + SET cas.last_read_message_id = c.last_message_id, + cas.last_read_at = ?, + cas.updated_at = CURRENT_TIMESTAMP + WHERE cas.admin_user_id = ? + AND c.last_message_id IS NOT NULL + AND cas.last_read_message_id < c.last_message_id`, now, adminID).Error + }) +} diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index 2267053..7410185 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -25,6 +25,7 @@ type conversationRow struct { Title string Status string Role string + AdminRemark string LastMessageID *uint64 LastMessagePreview string LastMessageAt *time.Time @@ -32,6 +33,7 @@ type conversationRow struct { LastSenderID uint64 LastSenderRole string UnreadCount int64 + NeedsReply int64 CreatedAt time.Time UpdatedAt time.Time } @@ -44,8 +46,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal) SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id - AND NOT (cm.sender_type = ? AND cm.sender_id = ?) - AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + AND cm.sender_type <> 'system' + AND NOT (cm.sender_type = ? AND cm.sender_id = ?) + AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) ) AS unread_count`, principal.Type, principal.ID). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) @@ -59,6 +62,7 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID). Where("c.status = ?", "active"). Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID). + Where("cm.sender_type <> ?", "system"). Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)"). Count(&total).Error return total, err @@ -81,15 +85,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat return &participant, nil } func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { + grouped, err := r.participantsForConversations(ctx, []uint64{conversationID}) + if err != nil { + return nil, err + } + return grouped[conversationID], nil +} + +func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) { + result := make(map[uint64][]ParticipantDTO, len(conversationIDs)) + if len(conversationIDs) == 0 { + return result, nil + } var rows []model.ChatParticipant - if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil { + if err := r.db.WithContext(ctx). + Where("conversation_id IN ?", uniqueIDs(conversationIDs)). + Order("conversation_id ASC, id ASC"). + Find(&rows).Error; err != nil { return nil, err } userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) if err != nil { return nil, err } - items := make([]ParticipantDTO, 0, len(rows)) for _, row := range rows { name := "系统" avatar := "" @@ -100,19 +118,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([ if row.ParticipantType == "admin" { name = adminNames[row.ParticipantID] } - items = append(items, ParticipantDTO{ + result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{ ID: row.ID, ConversationID: row.ConversationID, ParticipantType: row.ParticipantType, ParticipantID: row.ParticipantID, Role: row.Role, + Remark: row.Remark, DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), AvatarURL: avatar, LastReadAt: row.LastReadAt, JoinedAt: row.JoinedAt, }) } - return items, nil + return result, nil } func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { userIDs := make([]uint64, 0) @@ -166,11 +185,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row SenderAvatar: avatar, IsSelf: isSelf, // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 - IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), - ContentType: row.ContentType, - Content: row.Content, - AttachmentURLS: decodeStringList(row.AttachmentURLS), - CreatedAt: row.CreatedAt, + IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), + ContentType: row.ContentType, + Content: row.Content, + AttachmentURLS: decodeStringList(row.AttachmentURLS), + AdminAttentionType: row.AdminAttentionType, + CreatedAt: row.CreatedAt, }) } return items, nil @@ -305,6 +325,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO Title: row.Title, Status: row.Status, Role: row.Role, + AdminRemark: row.AdminRemark, Participants: participants, LastMessageID: row.LastMessageID, LastMessagePreview: row.LastMessagePreview, @@ -313,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO LastSenderID: row.LastSenderID, LastSenderRole: row.LastSenderRole, UnreadCount: row.UnreadCount, + NeedsReply: row.NeedsReply > 0, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/modules/chat/qrcode_ocr.go b/backend/internal/modules/chat/qrcode_ocr.go index 0d5e728..6e4c225 100644 --- a/backend/internal/modules/chat/qrcode_ocr.go +++ b/backend/internal/modules/chat/qrcode_ocr.go @@ -6,21 +6,46 @@ import ( "encoding/json" "errors" "fmt" + "image" + "image/color" + "image/draw" + _ "image/gif" + "image/jpeg" + _ "image/png" "io" + "math" "mime/multipart" "net/http" "net/textproto" "regexp" "sort" + "strconv" "strings" + "sync" "time" "unicode/utf8" + "hfb_sys/backend/internal/logging" + "github.com/google/uuid" + "go.uber.org/zap" + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" ) const ( maxQrCodeOCRFileSize = 10 * 1024 * 1024 + maxQrCodeOCRPixels = 40_000_000 + maxQrCodeOCRSide = 2560 + qrCodeOCRJPEGQuality = 92 + + ocrRequestConcurrency = 2 + ocrRequestMinInterval = 250 * time.Millisecond + ocrRequestMaxAttempts = 3 + ocrRetryBaseDelay = 500 * time.Millisecond + ocrResponseBodyLimit = 2 * 1024 * 1024 + ocrResultBodyLimit = 10 * 1024 * 1024 + ocrLogBodyLimit = 2048 ocrJobRedisKeyPrefix = "ocr:job:" ocrJobRedisTTL = 10 * time.Minute @@ -38,9 +63,87 @@ var ( ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期") ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis") - ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} + ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} + sharedOCRRequestGate = newOCRRequestGate(ocrRequestConcurrency, ocrRequestMinInterval) ) +type ocrRequestGate struct { + slots chan struct{} + mu sync.Mutex + nextAllowed time.Time + minInterval time.Duration +} + +func newOCRRequestGate(concurrency int, minInterval time.Duration) *ocrRequestGate { + if concurrency < 1 { + concurrency = 1 + } + return &ocrRequestGate{ + slots: make(chan struct{}, concurrency), + minInterval: max(0, minInterval), + } +} + +func (g *ocrRequestGate) acquire(ctx context.Context, sleep func(context.Context, time.Duration) error) (func(), error) { + select { + case g.slots <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + + release := func() { <-g.slots } + g.mu.Lock() + now := time.Now() + wait := max(time.Duration(0), g.nextAllowed.Sub(now)) + startAt := now.Add(wait) + g.nextAllowed = startAt.Add(g.minInterval) + g.mu.Unlock() + + if wait > 0 { + if err := sleep(ctx, wait); err != nil { + release() + return nil, err + } + } + return release, nil +} + +type ocrUpstreamError struct { + Operation string + StatusCode int + Code string + Message string + TraceID string + RetryAfter time.Duration + BodySnippet string + Cause error + Retriable bool +} + +func (e *ocrUpstreamError) Error() string { + parts := []string{ErrQrCodeOCRUnavailable.Error(), "operation=" + e.Operation} + if e.StatusCode != 0 { + parts = append(parts, "status="+strconv.Itoa(e.StatusCode)) + } + if e.Code != "" { + parts = append(parts, "code="+e.Code) + } + if e.Message != "" { + parts = append(parts, "message="+e.Message) + } + if e.TraceID != "" { + parts = append(parts, "trace_id="+e.TraceID) + } + if e.Cause != nil { + parts = append(parts, "cause="+e.Cause.Error()) + } + return strings.Join(parts, " ") +} + +func (e *ocrUpstreamError) Unwrap() error { + return ErrQrCodeOCRUnavailable +} + type QrCodeOCRResult struct { GroupName string `json:"group_name"` Candidates []string `json:"candidates"` @@ -79,13 +182,46 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize { return nil, "", ErrQrCodeOCRInvalidFile } - if contentType == "" { - contentType = http.DetectContentType(data) + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + if contentType == "" || !strings.HasPrefix(contentType, "image/") { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) } if !strings.HasPrefix(contentType, "image/") { return nil, "", ErrQrCodeOCRInvalidFile } - return data, contentType, nil + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxQrCodeOCRPixels { + return nil, "", ErrQrCodeOCRInvalidFile + } + source, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", ErrQrCodeOCRInvalidFile + } + normalized := resizeOCRImage(source, maxQrCodeOCRSide) + var buffer bytes.Buffer + if err := jpeg.Encode(&buffer, normalized, &jpeg.Options{Quality: qrCodeOCRJPEGQuality}); err != nil { + return nil, "", ErrQrCodeOCRInvalidFile + } + if buffer.Len() == 0 || buffer.Len() > maxQrCodeOCRFileSize { + return nil, "", ErrQrCodeOCRInvalidFile + } + return buffer.Bytes(), "image/jpeg", nil +} + +func resizeOCRImage(source image.Image, maxSide int) image.Image { + bounds := source.Bounds() + width := bounds.Dx() + height := bounds.Dy() + if width <= 0 || height <= 0 { + return source + } + scale := math.Min(1, float64(maxSide)/float64(max(width, height))) + targetWidth := max(1, int(math.Round(float64(width)*scale))) + targetHeight := max(1, int(math.Round(float64(height)*scale))) + target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight)) + draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) + xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil) + return target } func ocrJobKey(jobID string) string { @@ -132,15 +268,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con return nil, err } - paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) + paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data) if err != nil { return nil, err } - jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID) + jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID) if err != nil { return nil, err } - rawText, err := fetchPaddleOCRText(ctx, jsonURL) + rawText, err := r.fetchPaddleOCRText(ctx, jsonURL) if err != nil { return nil, err } @@ -173,7 +309,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str return "", err } - paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) + paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data) if err != nil { return "", err } @@ -206,47 +342,48 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo } statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID - req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err == nil { + req.Header.Set("Authorization", "bearer "+config.Token) + } + return req, err + }) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - req.Header.Set("Authorization", "bearer "+config.Token) - - resp, err := ocrHTTPClient.Do(req) - if err != nil { - // 网络错误不更新 Redis,保留当前状态让前端重试 - return record, nil - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - resp.Body.Close() - - if resp.StatusCode != http.StatusOK { + // 保留当前状态,让下一次前端轮询继续尝试。 return record, nil } var statusPayload paddleOCRJobStatusResponse if err := json.Unmarshal(respBody, &statusPayload); err != nil { + upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err) + r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) return record, nil } switch statusPayload.Data.State { case "pending", "running": record.Status = ocrJobStatusRunning - _ = r.saveOCRJobRecord(ctx, jobID, record) + r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil case "done": if statusPayload.Data.ResultURL.JSONURL == "" { record.Status = ocrJobStatusFailed record.Error = "未返回识别结果地址" - _ = r.saveOCRJobRecord(ctx, jobID, record) + r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址", + zap.String("operation", "status"), + zap.String("paddle_job_id", record.PaddleJobID), + zap.String("upstream_state", statusPayload.Data.State), + ) + r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil } - rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) + rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) if err != nil { record.Status = ocrJobStatusFailed record.Error = err.Error() - _ = r.saveOCRJobRecord(ctx, jobID, record) + r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil } candidates := parseQrCodeGroupNameCandidates(rawText) @@ -260,7 +397,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo Candidates: candidates, RawText: rawText, } - _ = r.saveOCRJobRecord(ctx, jobID, record) + r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil case "failed": @@ -270,14 +407,36 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo } else { record.Error = "PaddleOCR 识别失败" } - _ = r.saveOCRJobRecord(ctx, jobID, record) + r.ocrLogger(ctx).Error("PaddleOCR 任务执行失败", + zap.String("operation", "status"), + zap.String("paddle_job_id", record.PaddleJobID), + zap.String("upstream_state", statusPayload.Data.State), + zap.String("upstream_message", truncateOCRLogValue(record.Error, 512)), + ) + r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil + + default: + upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state")) + r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) } return record, nil } -func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) { +func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) { + if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil { + r.ocrLogger(ctx).Error("OCR 任务状态保存失败", + zap.String("ocr_job_id", jobID), + zap.String("paddle_job_id", record.PaddleJobID), + zap.String("ocr_status", record.Status), + zap.Error(err), + ) + } +} + +func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) { + filename = normalizeOCRFilename(filename) body := &bytes.Buffer{} writer := multipart.NewWriter(body) _ = writer.WriteField("model", config.Model) @@ -301,51 +460,69 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body) + requestBody := append([]byte(nil), body.Bytes()...) + formContentType := writer.FormDataContentType() + respBody, err := r.doPaddleRequest(ctx, "submit", func(ctx context.Context) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, bytes.NewReader(requestBody)) + if err == nil { + req.Header.Set("Authorization", "bearer "+config.Token) + req.Header.Set("Content-Type", formContentType) + } + return req, err + }) if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - req.Header.Set("Authorization", "bearer "+config.Token) - req.Header.Set("Content-Type", writer.FormDataContentType()) - - resp, err := ocrHTTPClient.Do(req) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) + return "", err } var payload paddleOCRJobResponse if err := json.Unmarshal(respBody, &payload); err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err) + r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) + return "", upstreamErr } if payload.Data.JobID == "" { - return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable) + upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId")) + r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) + return "", upstreamErr } + r.ocrLogger(ctx).Info("PaddleOCR 任务提交成功", + zap.String("operation", "submit"), + zap.String("paddle_job_id", payload.Data.JobID), + zap.String("model", config.Model), + zap.String("file_content_type", contentType), + zap.Int("file_size", len(data)), + ) return payload.Data.JobID, nil } -func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) { +func normalizeOCRFilename(filename string) string { + filename = strings.TrimSpace(filename) + if filename == "" { + return "qrcode.jpg" + } + if dot := strings.LastIndex(filename, "."); dot > 0 { + filename = filename[:dot] + } + return filename + ".jpg" +} + +func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) { for i := 0; i < 12; i++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil) + statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID + respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err == nil { + req.Header.Set("Authorization", "bearer "+config.Token) + } + return req, err + }) if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - req.Header.Set("Authorization", "bearer "+config.Token) - resp, err := ocrHTTPClient.Do(req) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) + return "", err } var payload paddleOCRJobStatusResponse if err := json.Unmarshal(respBody, &payload); err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err) + r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) + return "", upstreamErr } switch payload.Data.State { case "done": @@ -359,36 +536,232 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string } return "", ErrQrCodeOCRUnavailable } - select { - case <-ctx.Done(): - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err()) - case <-time.After(2 * time.Second): + if err := r.ocrSleep(ctx, 2*time.Second); err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } } return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) } -func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil) +func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { + body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil) + }) if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - resp, err := ocrHTTPClient.Do(req) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + return "", err } return collectPaddleOCRJSONLText(string(body)), nil } +type ocrRequestFactory func(context.Context) (*http.Request, error) + +func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) { + var lastErr *ocrUpstreamError + for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ { + release, err := r.ocrGate.acquire(ctx, r.ocrSleep) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + req, err := factory(ctx) + if err != nil { + release() + return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + startedAt := time.Now() + resp, requestErr := r.ocrHTTPClient.Do(req) + duration := time.Since(startedAt) + if requestErr != nil { + release() + lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr) + lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr) + r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration) + } else { + bodyLimit := int64(ocrResponseBodyLimit) + if operation == "result" { + bodyLimit = ocrResultBodyLimit + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) + _ = resp.Body.Close() + release() + if int64(len(body)) > bodyLimit { + body = body[:bodyLimit] + readErr = errors.New("upstream response exceeded limit") + } + if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return body, nil + } + lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr) + lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil + r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration) + } + + if !lastErr.Retriable || attempt == ocrRequestMaxAttempts { + return nil, lastErr + } + delay := ocrRetryDelay(attempt, lastErr.RetryAfter) + if err := r.ocrSleep(ctx, delay); err != nil { + return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + } + return nil, lastErr +} + +func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError { + upstreamErr := &ocrUpstreamError{ + Operation: operation, + StatusCode: statusCode, + BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit), + Cause: cause, + } + if headers != nil { + upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now()) + upstreamErr.TraceID = firstNonEmpty( + headers.Get("X-Request-ID"), + headers.Get("X-Trace-ID"), + headers.Get("Trace-ID"), + ) + } + var payload map[string]any + if json.Unmarshal(body, &payload) == nil { + upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code") + upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message") + if upstreamErr.TraceID == "" { + upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id") + } + } + return upstreamErr +} + +func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) { + if upstreamErr == nil { + return + } + fields := []zap.Field{ + zap.String("operation", upstreamErr.Operation), + zap.Int("attempt", attempt), + zap.Bool("final", final), + zap.Bool("retriable", upstreamErr.Retriable), + zap.Int("upstream_status", upstreamErr.StatusCode), + zap.String("upstream_code", upstreamErr.Code), + zap.String("upstream_message", upstreamErr.Message), + zap.String("upstream_trace_id", upstreamErr.TraceID), + zap.String("upstream_body", upstreamErr.BodySnippet), + zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()), + zap.Float64("duration_ms", float64(duration.Microseconds())/1000), + } + if upstreamErr.Cause != nil { + fields = append(fields, zap.Error(upstreamErr.Cause)) + } + if final { + r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...) + } else { + r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...) + } +} + +func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger { + logger := r.logger + if logger == nil { + logger = zap.NewNop() + } + fields := []zap.Field{zap.String("module", "chat_ocr")} + if requestID := logging.RequestIDFromContext(ctx); requestID != "" { + fields = append(fields, zap.String("request_id", requestID)) + } + if adminID := logging.AdminIDFromContext(ctx); adminID != 0 { + fields = append(fields, zap.Uint64("admin_id", adminID)) + } + return logger.With(fields...) +} + +func isRetriableOCRStatus(status int) bool { + switch status { + case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests, + http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + return true + default: + return false + } +} + +func isRetriableOCRNetworkError(ctx context.Context, err error) bool { + return err != nil && ctx.Err() == nil +} + +func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration { + if retryAfter > 0 { + return min(retryAfter, 10*time.Second) + } + delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1)) + // Deterministic jitter avoids synchronized retries without relying on global randomness. + jitter := time.Duration((attempt*137)%250) * time.Millisecond + return delay + jitter +} + +func parseOCRRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + if seconds, err := strconv.Atoi(value); err == nil { + return max(0, time.Duration(seconds)*time.Second) + } + if when, err := http.ParseTime(value); err == nil { + return max(0, when.Sub(now)) + } + return 0 +} + +func sleepWithContext(ctx context.Context, duration time.Duration) error { + if duration <= 0 { + return nil + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func firstJSONText(payload map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := payload[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return truncateOCRLogValue(text, 512) + } + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return truncateOCRLogValue(value, 256) + } + } + return "" +} + +func truncateOCRLogValue(value string, limit int) string { + value = strings.Join(strings.Fields(value), " ") + if len(value) <= limit { + return value + } + return value[:limit] + "..." +} + func collectPaddleOCRJSONLText(raw string) string { var texts []string for _, line := range strings.Split(raw, "\n") { diff --git a/backend/internal/modules/chat/qrcode_ocr_test.go b/backend/internal/modules/chat/qrcode_ocr_test.go new file mode 100644 index 0000000..75f02f7 --- /dev/null +++ b/backend/internal/modules/chat/qrcode_ocr_test.go @@ -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() +} diff --git a/backend/internal/modules/chat/quick_reply.go b/backend/internal/modules/chat/quick_reply.go index de1abcb..d7bebd7 100644 --- a/backend/internal/modules/chat/quick_reply.go +++ b/backend/internal/modules/chat/quick_reply.go @@ -2,7 +2,12 @@ package chat import ( "context" + "errors" "hfb_sys/backend/internal/model" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) const ( @@ -13,9 +18,26 @@ const ( ) func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { - return r.db.WithContext(ctx).Model(&model.ChatParticipant{}). - Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). - Update("remark", remark).Error + if principal.Type != "admin" { + return ErrPermissionDenied + } + db := r.db.WithContext(ctx) + var conversation model.ChatConversation + if err := db.Select("id").First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } + state := model.ChatAdminConversationState{ + ConversationID: conversationID, + AdminUserID: principal.ID, + Remark: strings.TrimSpace(remark), + } + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}), + }).Create(&state).Error } func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { var replies []model.ChatQuickReply diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 79bff37..d9891df 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -1,27 +1,81 @@ package chat import ( + "context" "errors" + "net/http" + "time" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chathub" - "time" ) type Repository struct { - db *gorm.DB - hub *chathub.Hub - redis *redis.Client + db *gorm.DB + hub *chathub.Hub + redis *redis.Client + logger *zap.Logger + ocrHTTPClient *http.Client + ocrGate *ocrRequestGate + ocrSleep func(context.Context, time.Duration) error } const ( defaultSupportRoleCode = "cs" ) -func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository { - return &Repository{db: db, hub: hub, redis: redis} +type RepositoryOption func(*Repository) + +func WithLogger(logger *zap.Logger) RepositoryOption { + return func(repo *Repository) { + if logger != nil { + repo.logger = logger + } + } +} + +func withOCRHTTPClient(client *http.Client) RepositoryOption { + return func(repo *Repository) { + if client != nil { + repo.ocrHTTPClient = client + } + } +} + +func withOCRGate(gate *ocrRequestGate) RepositoryOption { + return func(repo *Repository) { + if gate != nil { + repo.ocrGate = gate + } + } +} + +func withOCRSleep(sleep func(context.Context, time.Duration) error) RepositoryOption { + return func(repo *Repository) { + if sleep != nil { + repo.ocrSleep = sleep + } + } +} + +func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client, options ...RepositoryOption) *Repository { + repo := &Repository{ + db: db, + hub: hub, + redis: redis, + logger: zap.NewNop(), + ocrHTTPClient: ocrHTTPClient, + ocrGate: sharedOCRRequestGate, + ocrSleep: sleepWithContext, + } + for _, option := range options { + option(repo) + } + return repo } func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { var existing model.ChatConversation diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index 4166b4e..f60ea05 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -131,6 +131,16 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio return s.repo.MarkRead(ctx, principal, conversationID) } +func (s *Service) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if adminID == 0 { + return ErrPermissionDenied + } + return s.repo.MarkAllAdminConversationsRead(ctx, adminID) +} + func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error { if s.repo == nil { return ErrDependencyUnavailable @@ -155,10 +165,21 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword) } +func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword) +} + func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error { if s.repo == nil { return ErrDependencyUnavailable } + req.Remark = strings.TrimSpace(req.Remark) + if len([]rune(req.Remark)) > 128 { + return ErrInvalidMessage + } return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark) } diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 9d5bd36..f6bb0ef 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -6,6 +6,7 @@ import ( "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chathub" "strings" "time" ) @@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct { } func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 验证当前操作者是会话参与者 - current, err := r.findParticipant(tx, principal, conversationID, false) - if err != nil { - return err - } - if current.Role != "support" || current.ParticipantType != "admin" { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if principal.Type != "admin" { return ErrPermissionDenied } + var conversation model.ChatConversation + if err := tx.First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 if !adminIsSupport(tx, toAdminID) { return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") @@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip if count > 0 { return fmt.Errorf("该客服已在会话中") } - // 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。 - if err := tx.Model(&model.ChatParticipant{}). - Where("id = ?", current.ID). - Updates(map[string]interface{}{ + var current model.ChatParticipant + currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID). + First(¤t).Error + if currentErr == nil { + if current.Role != "support" { + return ErrPermissionDenied + } + // 只转接当前客服本人,避免误删同群里的其他客服。 + if err := tx.Model(¤t).Updates(map[string]interface{}{ "participant_id": toAdminID, "joined_at": time.Now(), + "last_read_at": nil, }).Error; err != nil { - return err + return err + } + } else if errors.Is(currentErr, gorm.ErrRecordNotFound) { + // 未分配会话允许从“全部/未分配”直接指派给目标客服。 + if err := tx.Create(&model.ChatParticipant{ + ConversationID: conversationID, + ParticipantType: "admin", + ParticipantID: toAdminID, + Role: "support", + JoinedAt: time.Now(), + }).Error; err != nil { + return err + } + } else { + return currentErr } - // 添加系统消息记录转接 - message := model.ChatMessage{ - ConversationID: conversationID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: "会话已转接给其他客服", - AttachmentURLS: emptyJSONList(), - } - if err := tx.Create(&message).Error; err != nil { + if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil { return err } return nil }) + if err == nil && r.hub != nil { + r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) + r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) + } + return err } func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { db := r.db.WithContext(ctx) @@ -202,11 +221,8 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal } func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) { - var total int64 - countDB := r.adminConversationBase(ctx, principal, keyword) - applyAdminChatOwnershipFilter(countDB, filter, principal) - applyAdminChatStageFilter(countDB, stage, principal) - if err := countDB.Count(&total).Error; err != nil { + total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword) + if err != nil { return nil, err } @@ -216,16 +232,21 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id, c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, COALESCE(cp_me.role, 'admin') AS role, - lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status, - lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status, + COALESCE(cas.remark, '') AS admin_remark, + COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id, + COALESCE(explicit_lo.order_no, c.latest_order_no) AS latest_order_no, + COALESCE(explicit_lo.status, c.latest_order_status) AS latest_order_status, + COALESCE(explicit_lo.handoff_status, c.latest_order_handoff_status) AS latest_handoff_status, + COALESCE(explicit_lo.refund_status, c.latest_order_refund_status) AS latest_refund_status, lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role, - CASE WHEN cp_me.id IS NULL THEN 0 ELSE ( + CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply, + ( SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id - AND NOT (cm.sender_type = ? AND cm.sender_id = ?) - AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at) - ) END AS unread_count`, principal.Type, principal.ID) + AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user') + AND cm.id > COALESCE(cas.last_read_message_id, 0) + ) AS unread_count`) applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatStageFilter(queryDB, stage, principal) if err := queryDB. @@ -236,68 +257,127 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ return nil, err } - items := make([]ConversationDTO, 0, len(rows)) + ids := make([]uint64, 0, len(rows)) for _, row := range rows { - participants, err := r.participants(ctx, row.ID) - if err != nil { - return nil, err - } - items = append(items, row.toDTO(participants)) + ids = append(ids, row.ID) } - - counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) + participantsByConversation, err := r.participantsForConversations(ctx, ids) if err != nil { return nil, err } - return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil + items := make([]ConversationDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO(participantsByConversation[row.ID])) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { + filter = normalizeAdminChatFilter(filter) + stage = normalizeAdminChatStage(stage) + keyword = strings.TrimSpace(keyword) + if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil { + return cached, nil + } + result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) + if err != nil { + return nil, err + } + r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO) + return result.DTO, nil +} + +func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) { + db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword)) + applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal) + applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal) + var total int64 + if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil } func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB { db := r.db.WithContext(ctx).Table("chat_conversations AS c"). Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID). + Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID). Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id"). - Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))"). - Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)"). - Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id"). - Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)") + Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id"). + Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)") if keyword != "" { - like := "%" + keyword + "%" - db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ? - OR renter.phone LIKE ? OR owner.phone LIKE ? - OR EXISTS ( - SELECT 1 FROM chat_participants AS cp_kw - WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ? - )`, like, like, like, like, like, like, like) + upper := strings.ToUpper(keyword) + if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(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)"). + Where("(renter.phone = ? OR owner.phone = ?)", keyword, keyword) + } else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + db = db.Where("(explicit_lo.order_no = ? OR c.latest_order_no = ?)", keyword, keyword) + } else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) { + db = db.Where("l.listing_no = ?", keyword) + } else { + db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id"). + Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)") + like := "%" + keyword + "%" + db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ? + OR renter.phone LIKE ? OR owner.phone LIKE ? + OR EXISTS ( + SELECT 1 FROM chat_participants AS cp_kw + WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ? + )`, like, like, like, like, like, like, like) + } } return db } -func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { - counts := &AdminConversationCountsDTO{ - Ownership: make(map[string]int64, len(adminChatFilters)), - Stages: make(map[string]int64, len(adminChatStages)), - } +type adminConversationCountsResult struct { + Total int64 + DTO *AdminConversationCountsDTO +} + +func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) { + ownership := make(map[string]string, len(adminChatFilters)) + ownershipArgs := make(map[string][]interface{}, len(adminChatFilters)) for _, item := range adminChatFilters { - db := r.adminConversationBase(ctx, principal, keyword) - applyAdminChatOwnershipFilter(db, item, principal) - applyAdminChatStageFilter(db, stage, principal) - var count int64 - if err := db.Count(&count).Error; err != nil { - return nil, err - } - counts.Ownership[item] = count + ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal) + } + stages := make(map[string]string, len(adminChatStages)) + stageArgs := make(map[string][]interface{}, len(adminChatStages)) + for _, item := range adminChatStages { + stages[item], stageArgs[item] = adminChatStageCondition(item, principal) + } + + parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages)) + args := make([]interface{}, 0) + addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) { + parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias) + args = append(args, leftArgs...) + args = append(args, rightArgs...) + } + addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage]) + for _, item := range adminChatFilters { + addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage]) } for _, item := range adminChatStages { - db := r.adminConversationBase(ctx, principal, keyword) - applyAdminChatOwnershipFilter(db, filter, principal) - applyAdminChatStageFilter(db, item, principal) - var count int64 - if err := db.Count(&count).Error; err != nil { - return nil, err - } - counts.Stages[item] = count + addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item]) } - return counts, nil + + type row struct { + Total int64 + OwnershipMine, OwnershipAll, OwnershipUnassigned int64 + StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64 + } + var result row + if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil { + return nil, err + } + return &adminConversationCountsResult{ + Total: result.Total, + DTO: &AdminConversationCountsDTO{ + Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned}, + Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded}, + }, + }, nil } func normalizeAdminChatFilter(filter string) string { @@ -319,55 +399,61 @@ func normalizeAdminChatStage(stage string) string { } func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) { - switch filter { - case adminChatFilterMine: - db.Where("cp_me.id IS NOT NULL") - case adminChatFilterUnassigned: - db.Where(`NOT EXISTS ( - SELECT 1 FROM chat_participants AS cp_support - WHERE cp_support.conversation_id = c.id - AND cp_support.participant_type = ? - AND cp_support.role = ? - )`, "admin", "support") - case adminChatFilterAll: - return - default: - db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID) - } + condition, args := adminChatOwnershipCondition(filter, principal) + db.Where(condition, args...) } func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { + condition, args := adminChatStageCondition(stage, principal) + db.Where(condition, args...) +} + +func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) { + switch filter { + case adminChatFilterAll: + return "1 = 1", nil + case adminChatFilterUnassigned: + return `NOT EXISTS ( + SELECT 1 FROM chat_participants AS cp_support + WHERE cp_support.conversation_id = c.id + AND cp_support.participant_type = 'admin' + AND cp_support.role = 'support' + )`, nil + case adminChatFilterMine: + fallthrough + default: + return "cp_me.id IS NOT NULL", nil + } +} + +func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) { + latestID := "COALESCE(explicit_lo.id, c.latest_order_id)" + latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)" + latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)" switch stage { case adminChatStagePending: - db.Where(`( - lm.sender_type = ? - OR ( - cp_me.id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM chat_messages AS cm_pending - WHERE cm_pending.conversation_id = c.id - AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?) - AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at) - ) - ) - )`, "user", principal.Type, principal.ID) + return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil case adminChatStageUnjoined: - db.Where("lo.id IS NULL") + return latestID + " IS NULL", nil case adminChatStageHandoff: - db.Where("lo.status = ?", "pending_handoff") + return latestStatus + " = 'pending_handoff'", nil case adminChatStageRenting: - db.Where("lo.status IN ?", []string{"renting", "overdue"}) + return latestStatus + " IN ('renting', 'overdue')", nil case adminChatStageAfterSale: - db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))", - []string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none") + return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil case adminChatStageEnded: - db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"}) + return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil case adminChatStageAll: - return + fallthrough + default: + return "1 = 1", nil } } +func adminNeedsReplyExpression() string { + return "c.last_attention_message_id > c.last_admin_message_id" +} + // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 // 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 // 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 @@ -394,3 +480,15 @@ func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) er } return sendSystemMessage(tx, conversation.ID, content) } + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/backend/internal/modules/chathub/handler.go b/backend/internal/modules/chathub/handler.go index 5d9d9a8..242061b 100644 --- a/backend/internal/modules/chathub/handler.go +++ b/backend/internal/modules/chathub/handler.go @@ -1,6 +1,7 @@ package chathub import ( + "context" "fmt" "time" @@ -8,16 +9,21 @@ import ( "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) const heartbeatInterval = 30 * time.Second type Handler struct { - hub *Hub + hub *Hub + logger *zap.Logger } -func NewHandler(hub *Hub) *Handler { - return &Handler{hub: hub} +func NewHandler(hub *Hub, logger *zap.Logger) *Handler { + if logger == nil { + logger = zap.NewNop() + } + return &Handler{hub: hub, logger: logger} } // UserEvents 处理用户端 SSE 连接: GET /api/chats/events @@ -51,8 +57,23 @@ func (h *Handler) AdminEvents(c *gin.Context) { } func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) { + startedAt := time.Now() + disconnectReason := "handler_completed" + var disconnectErr error ch := h.hub.Subscribe(pType, pID) - defer h.hub.Unsubscribe(pType, pID, ch) + h.logger.Info("SSE 连接建立", h.connectionLogFields(c, pType, pID)...) + defer func() { + h.hub.Unsubscribe(pType, pID, ch) + fields := h.connectionLogFields(c, pType, pID) + fields = append(fields, + zap.String("disconnect_reason", disconnectReason), + zap.Float64("lifetime_ms", float64(time.Since(startedAt).Microseconds())/1000), + ) + if disconnectErr != nil { + fields = append(fields, zap.Error(disconnectErr)) + } + h.logger.Info("SSE 连接断开", fields...) + }() c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") @@ -60,7 +81,11 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) { c.Header("X-Accel-Buffering", "no") // 发送初始连接确认 - fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n") + if _, err := fmt.Fprintf(c.Writer, "event: connected\ndata: {\"ok\":true}\n\n"); err != nil { + disconnectReason = "initial_write_error" + disconnectErr = err + return + } c.Writer.Flush() heartbeat := time.NewTicker(heartbeatInterval) @@ -71,17 +96,55 @@ func (h *Handler) serveSSE(c *gin.Context, pType string, pID uint64) { for { select { case <-clientGone: + disconnectReason = contextDisconnectReason(c.Request.Context().Err()) return case <-heartbeat.C: - fmt.Fprintf(c.Writer, ":heartbeat\n\n") + if _, err := fmt.Fprintf(c.Writer, ":heartbeat\n\n"); err != nil { + disconnectReason = "heartbeat_write_error" + disconnectErr = err + return + } c.Writer.Flush() case event, ok := <-ch: if !ok { + disconnectReason = "subscription_closed" return } data := MarshalEvent(event) - fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data) + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, data); err != nil { + disconnectReason = "event_write_error" + disconnectErr = err + return + } c.Writer.Flush() } } } + +func (h *Handler) connectionLogFields(c *gin.Context, pType string, pID uint64) []zap.Field { + fields := []zap.Field{ + zap.String("request_id", middleware.GetRequestID(c)), + zap.String("principal_type", pType), + zap.Uint64("principal_id", pID), + zap.String("client_ip", c.ClientIP()), + zap.Int("active_sse_connections", h.hub.OnlineCount()), + zap.Int("active_principal_type_connections", h.hub.OnlineCountByType(pType)), + } + if pType == "admin" { + fields = append(fields, zap.Uint64("admin_id", pID)) + } else { + fields = append(fields, zap.Uint64("user_id", pID)) + } + return fields +} + +func contextDisconnectReason(err error) string { + switch err { + case context.Canceled: + return "context_canceled" + case context.DeadlineExceeded: + return "context_deadline_exceeded" + default: + return "context_closed" + } +} diff --git a/backend/internal/modules/chathub/handler_test.go b/backend/internal/modules/chathub/handler_test.go new file mode 100644 index 0000000..c1cd039 --- /dev/null +++ b/backend/internal/modules/chathub/handler_test.go @@ -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) + } +} diff --git a/backend/internal/modules/chathub/hub.go b/backend/internal/modules/chathub/hub.go index 22f1d11..a092b99 100644 --- a/backend/internal/modules/chathub/hub.go +++ b/backend/internal/modules/chathub/hub.go @@ -20,16 +20,17 @@ type ChatEvent struct { // MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。 type MessageData struct { - ID uint64 `json:"id"` - ConversationID uint64 `json:"conversation_id"` - SenderType string `json:"sender_type"` - SenderID uint64 `json:"sender_id"` - SenderRole string `json:"sender_role"` - SenderName string `json:"sender_name"` - ContentType string `json:"content_type"` - Content string `json:"content"` - AttachmentURLS []string `json:"attachment_urls"` - CreatedAt string `json:"created_at"` + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + SenderType string `json:"sender_type"` + SenderID uint64 `json:"sender_id"` + SenderRole string `json:"sender_role"` + SenderName string `json:"sender_name"` + ContentType string `json:"content_type"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` + AdminAttentionType string `json:"admin_attention_type,omitempty"` + CreatedAt string `json:"created_at"` } // principal 标识一个连接方。 @@ -86,6 +87,22 @@ func (h *Hub) Unsubscribe(pType string, pID uint64, ch <-chan *ChatEvent) { h.mu.Unlock() } +// DisconnectUser 关闭指定用户当前进程内的全部实时连接。 +func (h *Hub) DisconnectUser(userID uint64) { + h.disconnect("user", userID) +} + +func (h *Hub) disconnect(pType string, pID uint64) { + key := principal{Type: pType, ID: pID} + h.mu.Lock() + clients := h.clients[key] + delete(h.clients, key) + for ch := range clients { + close(ch) + } + h.mu.Unlock() +} + // NotifyConversation 查询会话参与者,向所有在线参与者推送事件。 func (h *Hub) NotifyConversation(conversationID uint64, event *ChatEvent) { if h.db == nil { @@ -174,3 +191,16 @@ func (h *Hub) OnlineCount() int { } return count } + +// OnlineCountByType 返回当前进程内指定主体类型的 SSE 连接数。 +func (h *Hub) OnlineCountByType(pType string) int { + h.mu.RLock() + defer h.mu.RUnlock() + count := 0 + for key, clients := range h.clients { + if key.Type == pType { + count += len(clients) + } + } + return count +} diff --git a/backend/internal/modules/chathub/hub_test.go b/backend/internal/modules/chathub/hub_test.go new file mode 100644 index 0000000..2b0fbfa --- /dev/null +++ b/backend/internal/modules/chathub/hub_test.go @@ -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) + } +} diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 158171e..0f5a6ac 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -153,7 +153,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r "order_no": order.OrderNo, "result": req.Result, "remark": req.Remark, - "input_amount_cent": req.AmountCent, + "input_amount_cent": arbitrationInputAmountCent(req), "renter_refund_amount_cent": settlement.RenterRefundAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, @@ -245,7 +245,7 @@ func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, or if err != nil { return settlement, nil, linkedCheckout, 0, err } - depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req) + depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, linkedCheckout, req) if err != nil { return settlement, nil, linkedCheckout, 0, err } @@ -299,21 +299,24 @@ func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.Or return 0, ErrInvalidDispute } -func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) { +func arbitrationDepositDeductAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) { switch req.Result { case "actual_settlement": - if req.AmountCent <= 0 { + if req.AmountCent == nil { + if checkout != nil { + return arbitrationCheckoutDepositDeductCent(*checkout), nil + } return 0, nil } - if req.AmountCent > order.DepositAmountCent { + if *req.AmountCent < 0 || *req.AmountCent > order.DepositAmountCent { return 0, ErrInvalidDispute } - return req.AmountCent, nil + return *req.AmountCent, nil case "release_deposit": return 0, nil case "deduct_deposit", "compensate_owner": - deductAmountCent := req.AmountCent - if deductAmountCent <= 0 { + deductAmountCent := arbitrationInputAmountCent(req) + if req.AmountCent == nil || deductAmountCent <= 0 { deductAmountCent = order.DepositAmountCent } if deductAmountCent > order.DepositAmountCent { @@ -325,6 +328,23 @@ func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRe } } +func arbitrationInputAmountCent(req ArbitrateRequest) int64 { + if req.AmountCent == nil { + return 0 + } + return *req.AmountCent +} + +func arbitrationCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { + if checkout.DepositDeductAmountCent > 0 { + return checkout.DepositDeductAmountCent + } + if checkout.OtherAmountCent > 0 { + return checkout.OtherAmountCent + } + return 0 +} + func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) { order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent @@ -350,7 +370,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model. } func isPlatformManagedOrder(order model.RentalOrder) bool { - return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform" + return order.SettlementMode == "platform_managed" } func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error { @@ -459,15 +479,15 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r addRenterRefund(totalCent, "仲裁全额退款") settlement.RenterDepositRefundCent = depositAmountCent case "partial_refund": - if req.AmountCent <= 0 || req.AmountCent > totalCent { + if req.AmountCent == nil || *req.AmountCent <= 0 || *req.AmountCent > totalCent { return settlement, ErrInvalidDispute } - addRenterRefund(req.AmountCent, "仲裁部分退款") + addRenterRefund(*req.AmountCent, "仲裁部分退款") // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 - settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent) + settlement.RenterDepositRefundCent = money.MinCent(*req.AmountCent, depositAmountCent) // 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。 addOwnerIncome( - partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent), + partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, *req.AmountCent), "仲裁剩余金额结算给号主", ) case "release_deposit": @@ -475,8 +495,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r addRenterRefund(depositAmountCent, "仲裁释放押金给租客") settlement.RenterDepositRefundCent = depositAmountCent case "deduct_deposit", "compensate_owner": - deductAmountCent := req.AmountCent - if deductAmountCent <= 0 { + deductAmountCent := arbitrationInputAmountCent(req) + if req.AmountCent == nil || deductAmountCent <= 0 { deductAmountCent = depositAmountCent } if deductAmountCent > depositAmountCent { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index d57364c..272ea54 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -9,40 +9,41 @@ import ( ) type DisputeDTO struct { - ID uint64 `json:"id"` - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - OrderStatus string `json:"order_status"` - HandoffStatus string `json:"handoff_status"` - SettlementStatus string `json:"settlement_status"` - ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` - ListingNo string `json:"listing_no"` - Title string `json:"title"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone"` - RenterPhone string `json:"renter_phone"` - InitiatorID uint64 `json:"initiator_id"` - InitiatorType string `json:"initiator_type"` - InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` - TargetUserID uint64 `json:"target_user_id"` - Type string `json:"type"` - Status string `json:"status"` - Description string `json:"description"` - EvidenceURLS datatypes.JSON `json:"evidence_urls"` - PreviousOrderStatus string `json:"previous_order_status"` - PreviousHandoffStatus string `json:"previous_handoff_status"` - PreviousSettlementStatus string `json:"previous_settlement_status"` - CheckoutID *uint64 `json:"checkout_id"` - CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` - CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` - PreviousCheckoutStatus string `json:"previous_checkout_status"` - ArbitrationResult string `json:"arbitration_result"` - ArbitrationRemark string `json:"arbitration_remark"` - HandledBy *uint64 `json:"handled_by"` - HandledAt *time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus string `json:"order_status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` + ListingNo string `json:"listing_no"` + Title string `json:"title"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone"` + RenterPhone string `json:"renter_phone"` + InitiatorID uint64 `json:"initiator_id"` + InitiatorType string `json:"initiator_type"` + InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` + TargetUserID uint64 `json:"target_user_id"` + Type string `json:"type"` + Status string `json:"status"` + Description string `json:"description"` + EvidenceURLS datatypes.JSON `json:"evidence_urls"` + PreviousOrderStatus string `json:"previous_order_status"` + PreviousHandoffStatus string `json:"previous_handoff_status"` + PreviousSettlementStatus string `json:"previous_settlement_status"` + CheckoutID *uint64 `json:"checkout_id"` + CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` + CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` + CheckoutDepositDeductAmountCent *int64 `json:"checkout_deposit_deduct_amount_cent,omitempty"` + PreviousCheckoutStatus string `json:"previous_checkout_status"` + ArbitrationResult string `json:"arbitration_result"` + ArbitrationRemark string `json:"arbitration_remark"` + HandledBy *uint64 `json:"handled_by"` + HandledAt *time.Time `json:"handled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { @@ -61,7 +62,7 @@ type AdminCreateRequest struct { type ArbitrateRequest struct { Result string `json:"result" binding:"required"` Remark string `json:"remark" binding:"required"` - AmountCent int64 `json:"amount_cent"` + AmountCent *int64 `json:"amount_cent"` ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"` } diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index 1740fc5..18069df 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -10,57 +10,59 @@ import ( type disputeRow struct { model.Dispute - OrderNo string - OrderStatus string - HandoffStatus string - SettlementStatus string - ExtraItemOriginalAmountCent int64 - ListingNo string - Title string - OwnerID uint64 - RenterID uint64 - OwnerPhone string - RenterPhone string - CheckoutCoinConsumedM *float64 - CheckoutConsumableAmountCent *int64 + OrderNo string + OrderStatus string + HandoffStatus string + SettlementStatus string + ExtraItemOriginalAmountCent int64 + ListingNo string + Title string + OwnerID uint64 + RenterID uint64 + OwnerPhone string + RenterPhone string + CheckoutCoinConsumedM *float64 + CheckoutConsumableAmountCent *int64 + CheckoutDepositDeductAmountCent *int64 } func (row disputeRow) toDTO() DisputeDTO { return DisputeDTO{ - ID: row.ID, - OrderID: row.OrderID, - OrderNo: row.OrderNo, - OrderStatus: row.OrderStatus, - HandoffStatus: row.HandoffStatus, - SettlementStatus: row.SettlementStatus, - ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, - ListingNo: row.ListingNo, - Title: row.Title, - OwnerID: row.OwnerID, - RenterID: row.RenterID, - OwnerPhone: row.OwnerPhone, - RenterPhone: row.RenterPhone, - InitiatorID: row.InitiatorID, - InitiatorType: effectiveInitiatorType(row.Dispute), - InitiatorAdminID: row.InitiatorAdminID, - TargetUserID: row.TargetUserID, - Type: row.Type, - Status: row.Status, - Description: row.Description, - EvidenceURLS: row.EvidenceURLS, - PreviousOrderStatus: row.PreviousOrderStatus, - PreviousHandoffStatus: row.PreviousHandoffStatus, - PreviousSettlementStatus: row.PreviousSettlementStatus, - CheckoutID: row.CheckoutID, - CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, - CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, - PreviousCheckoutStatus: row.PreviousCheckoutStatus, - ArbitrationResult: row.ArbitrationResult, - ArbitrationRemark: row.ArbitrationRemark, - HandledBy: row.HandledBy, - HandledAt: row.HandledAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + OrderStatus: row.OrderStatus, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, + ListingNo: row.ListingNo, + Title: row.Title, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + InitiatorID: row.InitiatorID, + InitiatorType: effectiveInitiatorType(row.Dispute), + InitiatorAdminID: row.InitiatorAdminID, + TargetUserID: row.TargetUserID, + Type: row.Type, + Status: row.Status, + Description: row.Description, + EvidenceURLS: row.EvidenceURLS, + PreviousOrderStatus: row.PreviousOrderStatus, + PreviousHandoffStatus: row.PreviousHandoffStatus, + PreviousSettlementStatus: row.PreviousSettlementStatus, + CheckoutID: row.CheckoutID, + CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, + CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, + CheckoutDepositDeductAmountCent: row.CheckoutDepositDeductAmountCent, + PreviousCheckoutStatus: row.PreviousCheckoutStatus, + ArbitrationResult: row.ArbitrationResult, + ArbitrationRemark: row.ArbitrationRemark, + HandledBy: row.HandledBy, + HandledAt: row.HandledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index 6d86f91..55e237d 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -37,11 +37,6 @@ func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) } func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*PaginatedResult, error) { - var total int64 - countDB := applyAdminListFilters(r.adminFilterQuery(ctx), query) - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } page := query.Page pageSize := query.PageSize if page < 1 { @@ -51,6 +46,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi pageSize = 20 } offset := (page - 1) * pageSize + var rows []disputeRow err := applyAdminListFilters(r.baseQuery(ctx), query). Order("d.id DESC"). @@ -60,6 +56,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := applyAdminListFilters(r.adminDisputeCountQuery(ctx, query), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil } @@ -94,7 +101,8 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { o.owner_id, o.renter_id, o.extra_item_original_amount_cent, owner.phone AS owner_phone, renter.phone AS renter_phone, l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m, - c.consumable_amount_cent AS checkout_consumable_amount_cent`) + c.consumable_amount_cent AS checkout_consumable_amount_cent, + CASE WHEN c.deposit_deduct_amount_cent > 0 THEN c.deposit_deduct_amount_cent ELSE c.other_amount_cent END AS checkout_deposit_deduct_amount_cent`) } func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { @@ -107,6 +115,34 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id") } +func (r *Repository) adminDisputeCountQuery(ctx context.Context, query AdminListQuery) *gorm.DB { + kw := strings.TrimSpace(query.Keyword) + if kw == "" { + return r.db.WithContext(ctx).Table("disputes AS d") + } + upper := strings.ToUpper(kw) + if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id") + } + if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id") + } + if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id"). + Joins("JOIN rental_listings AS l ON l.id = o.listing_id") + } + if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id"). + Joins("JOIN users AS owner ON owner.id = o.owner_id"). + Joins("JOIN users AS renter ON renter.id = o.renter_id") + } + return r.adminFilterQuery(ctx) +} + func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { if query.Status != "" { db = db.Where("d.status = ?", query.Status) @@ -115,22 +151,21 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { db = db.Where("d.type = ?", query.Type) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { - db = db.Where( - `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, - like, - like, - like, - like, - like, - id, - id, - id, - id, - id, - ) + upper := strings.ToUpper(keyword) + if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { + // 11 位手机号:命中 users(phone) 唯一索引 + db = db.Where("(owner.phone = ? OR renter.phone = ?)", keyword, keyword) + } else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("o.order_no = ?", keyword) + } else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) { + // 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引 + db = db.Where("l.listing_no = ?", keyword) + } else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 { + // 短纯数字 ID(非零开头):命中纠纷 ID、订单 ID 或关联 ID + db = db.Where("(d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)", id, id, id, id, id) } else { + like := "%" + keyword + "%" db = db.Where( `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`, like, @@ -143,3 +178,16 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { } return db } + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 68fad02..f8e7b36 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -11,9 +11,12 @@ import ( "hfb_sys/backend/internal/model" "gorm.io/datatypes" + "gorm.io/driver/sqlite" "gorm.io/gorm" ) +func int64Ptr(value int64) *int64 { return &value } + func disputePureCoinOrder() model.RentalOrder { return model.RentalOrder{ Status: "renting", @@ -322,6 +325,64 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { } } +func TestPlatformManagedCheckoutArbitrationKeepsExistingDepositCompensation(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunded", nil + })}) + adminID := uint64(78) + _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "pending_checkout_confirm", + HandoffStatus: "pending_owner_checkout", + SettlementStatus: "pending", + HandoffMode: "platform", + SettlementMode: "platform_managed", + ManagedAdminID: &adminID, + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + PlatformFeeCent: 2000, + DepositAmountCent: 5000, + }) + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "submitted", + CoinConsumedM: 0, + DepositDeductAmountCent: 3000, + OtherAmountCent: 3000, + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatalf("创建结账记录失败: %v", err) + } + created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ + Type: "checkout_amount", + Description: "结账押金损耗争议", + }) + if err != nil { + t.Fatalf("创建结账争议失败: %v", err) + } + if created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 { + t.Fatalf("争议未带出押金赔付金额: %v", created.CheckoutDepositDeductAmountCent) + } + actualCoinConsumedM := 0.0 + actualConsumableAmountCent := int64(0) + if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{ + Result: "actual_settlement", + Remark: "沿用原结账押金赔付", + ActualCoinConsumedM: &actualCoinConsumedM, + ActualConsumableAmountCent: &actualConsumableAmountCent, + }, AuditMeta{}); err != nil { + t.Fatalf("仲裁失败: %v", err) + } + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取仲裁订单失败: %v", err) + } + if saved.OfflineSettlementAmountCent != 11000 { + t.Fatalf("仲裁后待打款 = %d, want 11000", saved.OfflineSettlementAmountCent) + } +} + func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { @@ -394,7 +455,7 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) { settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{ Result: "actual_settlement", Remark: "赔付号主", - AmountCent: compensationCent, + AmountCent: &compensationCent, ActualCoinConsumedM: &actualCoinConsumedM, ActualConsumableAmountCent: &actualConsumableAmountCent, }, 0) @@ -406,6 +467,21 @@ func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) { } } +func TestActualSettlementArbitrationInheritsCheckoutDepositCompensation(t *testing.T) { + order := disputePureCoinOrder() + checkout := &model.OrderCheckout{DepositDeductAmountCent: 3000} + amount, err := arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement"}) + if err != nil || amount != 3000 { + t.Fatalf("omitted arbitration compensation = %d/%v, want 3000/nil", amount, err) + } + + zero := int64(0) + amount, err = arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement", AmountCent: &zero}) + if err != nil || amount != 0 { + t.Fatalf("explicit zero arbitration compensation = %d/%v, want 0/nil", amount, err) + } +} + func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { @@ -417,11 +493,12 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { inputOrder.SettlementStatus = "pending" _, renter, order := createDisputeOrderFixture(t, db, inputOrder) checkout := model.OrderCheckout{ - OrderID: order.ID, - InitiatedBy: renter.ID, - Status: "submitted", - ConsumableAmountCent: 7000, - CoinConsumedM: 25, + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "submitted", + ConsumableAmountCent: 7000, + CoinConsumedM: 25, + DepositDeductAmountCent: 3000, } if err := db.Create(&checkout).Error; err != nil { t.Fatalf("创建结账记录失败: %v", err) @@ -433,8 +510,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { if err != nil { t.Fatalf("创建结账争议失败: %v", err) } - if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 { - t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID) + if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 || created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 { + t.Fatalf("关联结账/M/额外物品/押金赔付 = %v/%v/%v/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID) } if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ @@ -566,7 +643,7 @@ func TestPartialRefundReservesPlatformFee(t *testing.T) { settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", - AmountCent: 5000, + AmountCent: int64Ptr(5000), Remark: "没打完 号主登录不上", }, 0) if err != nil { @@ -599,7 +676,7 @@ func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) { } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", - AmountCent: 20000, + AmountCent: int64Ptr(20000), }, 0) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) @@ -626,3 +703,58 @@ func TestPartialRefundOwnerIncomeCentUnit(t *testing.T) { t.Fatalf("full refund: got %d, want 0", got) } } + +func TestApplyAdminListFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminListQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminListQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + }, + { + name: "RO前缀订单号路由至rental_orders(order_no)", + query: AdminListQuery{Keyword: "RO202609051000"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminListQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminListQuery{Keyword: "12345"}, + wantSQLFrag: "d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?", + }, + { + name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)", + query: AdminListQuery{Keyword: "202609040184"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminListQuery{Keyword: "测试账号"}, + wantSQLFrag: "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminListFilters(db.Table("disputes AS d"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 099c8d5..819e1a8 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -93,6 +93,9 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) { return nil, ErrInvalidDispute } + if req.AmountCent != nil && (*req.AmountCent < 0 || *req.AmountCent > 100_000_000_000) { + return nil, ErrInvalidDispute + } if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) { return nil, ErrInvalidDispute } diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index c3eeded..2fe6b69 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -81,28 +81,28 @@ type UpdateRuleStatusRequest struct { } type DistributionOrderDTO struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - SiteID uint64 `json:"site_id"` - ListingID uint64 `json:"listing_id"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - Status string `json:"status"` - HandoffStatus string `json:"handoff_status"` - RefundStatus string `json:"refund_status"` - SettlementStatus string `json:"settlement_status"` - DistributionMode string `json:"distribution_mode"` - GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"` - RefundAmountCent int64 `json:"refund_amount_cent"` - NetPaidAmountCent int64 `json:"net_paid_amount_cent"` - PaymentFeeCent int64 `json:"payment_fee_cent"` - ServiceFeeCent int64 `json:"service_fee_cent"` - MarkupAmountCent int64 `json:"markup_amount_cent"` - TaxAmountCent int64 `json:"tax_amount_cent"` - SplitBaseAmountCent int64 `json:"split_base_amount_cent"` - RoundingAdjustmentCent int64 `json:"rounding_adjustment_cent"` - CreatedAt time.Time `json:"created_at"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + SiteID uint64 `json:"site_id"` + ListingID uint64 `json:"listing_id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + RefundStatus string `json:"refund_status"` + SettlementStatus string `json:"settlement_status"` + DistributionMode string `json:"distribution_mode"` + GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"` + RefundAmountCent int64 `json:"refund_amount_cent"` + NetPaidAmountCent int64 `json:"net_paid_amount_cent"` + PaymentFeeCent int64 `json:"payment_fee_cent"` + ServiceFeeCent int64 `json:"service_fee_cent"` + MarkupAmountCent int64 `json:"markup_amount_cent"` + TaxAmountCent int64 `json:"tax_amount_cent"` + SplitBaseAmountCent int64 `json:"split_base_amount_cent"` + RoundingAdjustmentCent int64 `json:"rounding_adjustment_cent"` + CreatedAt time.Time `json:"created_at"` } type DistributionOrderListQuery struct { diff --git a/backend/internal/modules/listing/admin_filter_test.go b/backend/internal/modules/listing/admin_filter_test.go index f9c6e19..fc3098d 100644 --- a/backend/internal/modules/listing/admin_filter_test.go +++ b/backend/internal/modules/listing/admin_filter_test.go @@ -1,6 +1,12 @@ package listing -import "testing" +import ( + "strings" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) func TestFilterAdminListingsByAssetLevels(t *testing.T) { items := []ListingDTO{ @@ -92,3 +98,49 @@ func TestFilterAdminListingsBySkinGroupOnly(t *testing.T) { t.Fatalf("expected only listing 2 by gold skin group, got %#v", filtered) } } + +func TestApplyAdminListFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + repo := &Repository{} + + tests := []struct { + name string + query AdminListQuery + wantSQLFrag string + }{ + { + name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)", + query: AdminListQuery{Keyword: "202609040184"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminListQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "RO前缀订单号路由至EXISTS订单唯一索引", + query: AdminListQuery{Keyword: "RO202609051000"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminListQuery{Keyword: "12345"}, + wantSQLFrag: "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 = ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := repo.applyAdminListFilters(db.Table("rental_listings AS l"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index a812db0..83832ca 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -275,9 +275,11 @@ func externalAccountToCreateRequest( "resources": resources, "skin_groups": externalSkinGroups(skins), "online_time_text": onlineTimeText, - "ban_record": normalizeBanRecord(item.BanRecord), - "common_regions": commonRegions(item.CommonRegion), - "remark": remark, + // 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。 + "early_morning_response": "否", + "ban_record": normalizeBanRecord(item.BanRecord), + "common_regions": commonRegions(item.CommonRegion), + "remark": remark, "import_meta": map[string]any{ "uploader_name": uploaderName, "client_upload_time": uploadTime, @@ -382,10 +384,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64, func serverRegionFromLoginMethod(value string) string { value = strings.TrimSpace(value) + normalized := strings.ToLower(value) switch { - case strings.Contains(value, "微信"): + case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"): return "微信" - case strings.Contains(strings.ToLower(value), "steam"): + case strings.Contains(normalized, "steam"): return "Steam" default: return "QQ" diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go index 701de0e..2a0647a 100644 --- a/backend/internal/modules/listing/public_filter.go +++ b/backend/internal/modules/listing/public_filter.go @@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool { if summary == nil { return false } - onlineTime, ok := summary["online_time"].(map[string]any) - if !ok { + // 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。 + switch value := summary["early_morning_response"].(type) { + case string: + return strings.TrimSpace(value) == "是" + case bool: + return value + default: return false } - start, okStart := parseTimeMinuteValue(onlineTime["start"]) - end, okEnd := parseTimeMinuteValue(onlineTime["end"]) - if !okStart || !okEnd { - return false - } - return timeRangeOverlapsMinutes(start, end, 0, 8*60) -} - -func parseTimeMinuteValue(value any) (int, bool) { - text, ok := value.(string) - if !ok { - return 0, false - } - parts := strings.Split(text, ":") - if len(parts) != 2 { - return 0, false - } - hour, err := strconv.Atoi(parts[0]) - if err != nil || hour < 0 || hour > 23 { - return 0, false - } - minute, err := strconv.Atoi(parts[1]) - if err != nil || minute < 0 || minute > 59 { - return 0, false - } - return hour*60 + minute, true -} - -func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool { - if start == end { - return true - } - for _, segment := range splitMinuteRange(start, end) { - if segment[0] <= targetEnd && segment[1] >= targetStart { - return true - } - } - return false -} - -func splitMinuteRange(start int, end int) [][2]int { - if start < end { - return [][2]int{{start, end}} - } - return [][2]int{{start, 23*60 + 59}, {0, end}} } diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go index 7eb5145..bf8e9bb 100644 --- a/backend/internal/modules/listing/public_filter_test.go +++ b/backend/internal/modules/listing/public_filter_test.go @@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) { } } -func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) { +func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) { tests := []struct { - name string - start string - end string - want bool + name string + response any + want bool }{ - {name: "全天可上号", start: "00:00", end: "23:59", want: true}, - {name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true}, - {name: "刚好八点开始", start: "08:00", end: "12:00", want: true}, - {name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false}, - {name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true}, - {name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false}, + {name: "明确响应", response: "是", want: true}, + {name: "明确不响应", response: "否", want: false}, + {name: "兼容布尔值", response: true, want: true}, + {name: "缺失字段", response: nil, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := isNightAvailableSummary(map[string]any{ - "online_time": map[string]any{ - "start": tt.start, - "end": tt.end, - }, - }) + summary := map[string]any{} + if tt.response != nil { + summary["early_morning_response"] = tt.response + } + got := isNightAvailableSummary(summary) if got != tt.want { - t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want) + t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want) } }) } diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index f064fcc..d656227 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -66,12 +66,6 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi }, nil } - countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) - var total int64 - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - db := r.applyAdminListFilters(r.baseQuery(ctx), query) offset := (page - 1) * pageSize if offset < 0 { @@ -82,6 +76,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } return &AdminListResult{ Items: rowsToDTO(rows), Total: total, @@ -95,17 +100,24 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g db = db.Where("l.owner_id = ?", query.OwnerID) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { + upper := strings.ToUpper(keyword) + if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?)", keyword) + } else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) { + // 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引 + db = db.Where("l.listing_no = ?", keyword) + } else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 { db = db.Where( - `(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`, - like, - like, + `(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`, + keyword, + keyword, id, id, id, ) } else { + like := "%" + keyword + "%" 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 ?))`, like, @@ -204,22 +216,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L } func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { - uploadSource := r.db.WithContext(ctx).Table("listing_uploads"). - Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel"). - Where("listing_id IS NOT NULL"). - Group("listing_id") return r.db.WithContext(ctx).Table("rental_listings AS l"). Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname, - CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload, + CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload, CASE - WHEN lu.upload_id IS NULL THEN ? + WHEN lu.id IS NULL THEN ? WHEN COALESCE(lu.source_channel, '') = '' THEN ? ELSE lu.source_channel END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Joins("LEFT JOIN users AS u ON u.id = l.owner_id"). - Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource) + // 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。 + Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = ( + SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id + )`) } func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { @@ -266,3 +277,16 @@ func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) { } return fmt.Sprintf("%s%04d", bizDate, seq), nil } + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index d26e995..5dcf6e4 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -604,6 +604,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { if req.AssetSummary["online_time_text"] != "08:00 至 01:00" { t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"]) } + if req.AssetSummary["early_morning_response"] != "否" { + t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"]) + } onlineTime, ok := req.AssetSummary["online_time"].(map[string]any) if !ok { t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"]) @@ -640,6 +643,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { } } +func TestServerRegionFromLoginMethod(t *testing.T) { + tests := []struct { + name string + loginMethod string + want string + }{ + {name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"}, + {name: "微信扫码", loginMethod: "微信扫码", want: "微信"}, + {name: "VX扫码", loginMethod: "VX扫码", want: "微信"}, + {name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"}, + {name: "Steam", loginMethod: "Steam令牌", want: "Steam"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want { + t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want) + } + }) + } +} + func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) { req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ LoginMethod: "QQ账号密码", diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 969b077..8227b08 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -391,10 +391,13 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error { var refund *refundAction err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var order model.RentalOrder - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { + assets, err := r.lockOrderAssets(tx, orderID) + if err != nil { return err } + order := assets.Order + listing := assets.Listing + account := assets.Account if order.RefundStatus != refundStatusPendingReview { return errors.New("订单不处于待审核退款状态") } @@ -407,14 +410,23 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err // 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。 order.RefundStatus = "none" order.RefundAmountCent = 0 - return tx.Save(&order).Error + } else { + action, err := r.prepareRefund(order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)") + if err != nil { + return err + } + refund = action } - action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)") - if err != nil { + if err := releaseAssetsForRentalIfIdle(tx, order, listing, account); err != nil { return err } - refund = action - return tx.Save(&order).Error + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + return tx.Save(account).Error }) if err != nil { return err diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 52abe10..acbd364 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -6,6 +6,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID if hasOpen { return ErrCheckoutCannotSubmit } + before := orderState(order) checkoutToUserID := order.OwnerID + checkoutTargetType := processlog.ActorUser if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { checkoutToUserID = platformManagedAdminID(order) + checkoutTargetType = processlog.ActorAdmin } record := model.HandoffRecord{ OrderID: order.ID, @@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID order.SettlementStatus = settlementStatusPending now := time.Now() order.HandoffStartedAt = &now + if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil { + return err + } orderID := order.ID if isPlatformSettlementOrder(order) { if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { @@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID checkout.OwnerAdjustedAt = &now action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。") refund = action - return err + if err != nil { + return err + } + return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)) }) if err != nil { return err @@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { return ErrCheckoutCannotCounter } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID if err := tx.Save(&order).Error; err != nil { return err } + targetID := order.OwnerID + if isOwner { + targetID = order.RenterID + } + if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil { + return err + } if err := tx.Save(checkout).Error; err != nil { return err } @@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID checkout.RenterConfirmedAt = &now action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。") refund = action - return err + if err != nil { + return err + } + return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)) }) if err != nil { return err diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index 29bae02..cab53be 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -32,11 +32,12 @@ const ( handoffStatusAdminClosed = "admin_closed" handoffStatusAdminAbnormal = "admin_abnormal" - settlementStatusUnsettled = "unsettled" - settlementStatusPending = "pending" - settlementStatusSettled = "settled" - settlementStatusClosed = "closed" - settlementStatusDisputed = "disputed" + settlementStatusUnsettled = "unsettled" + settlementStatusPending = "pending" + settlementStatusSettled = "settled" + settlementStatusClosed = "closed" + settlementStatusDisputed = "disputed" + settlementStatusArbitrated = "arbitrated" handoffModeOwner = "owner" handoffModePlatform = "platform" diff --git a/backend/internal/modules/order/deposit_hold.go b/backend/internal/modules/order/deposit_hold.go index 0cb9588..75c3e46 100644 --- a/backend/internal/modules/order/deposit_hold.go +++ b/backend/internal/modules/order/deposit_hold.go @@ -5,6 +5,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order if !canHoldDeposit(order) { return ErrDepositCannotHold } + before := orderState(order) beforeStatus := order.DepositHoldStatus now := time.Now() order.DepositHoldStatus = depositHoldStatusHeld @@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order order.DepositHeldBy = &adminID order.DepositHeldAt = &now order.DepositHoldReleasedAt = nil + if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or if holdAmountCent <= 0 { return ErrDepositHoldAmountEmpty } + before := orderState(order) now := time.Now() order.DepositHoldStatus = depositHoldStatusReleased order.DepositHoldReleasedAt = &now + if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil { + return err + } action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回") if err != nil { return err diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 07fbbaa..7a3b904 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -51,6 +51,8 @@ type OrderDTO struct { GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` + AccountSource string `json:"account_source"` + SourceChannel string `json:"source_channel,omitempty"` Status string `json:"status"` HandoffStatus string `json:"handoff_status"` HandoffMode string `json:"handoff_mode"` @@ -172,7 +174,8 @@ type CounterCheckoutRequest struct { } type AdminActionRequest struct { - Reason string `json:"reason" binding:"required"` + Reason string `json:"reason" binding:"required"` + ConfirmZeroOwnerIncome bool `json:"confirm_zero_owner_income"` } type PlatformHandoffRequest struct { @@ -180,7 +183,7 @@ type PlatformHandoffRequest struct { Reason string `json:"reason" binding:"required"` } -// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。 +// ForceHandoffRequest 客服确认订单已完成交接。 type ForceHandoffRequest struct { Content string `json:"content"` Reason string `json:"reason" binding:"required"` @@ -270,6 +273,28 @@ type HandoffRecordDTO struct { CreatedAt time.Time `json:"created_at"` } +// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。 +type ProcessEventDTO struct { + ID uint64 `json:"id"` + BusinessType string `json:"business_type"` + BusinessID uint64 `json:"business_id"` + Stage string `json:"stage"` + Action string `json:"action"` + ActorType string `json:"actor_type"` + ActorID uint64 `json:"actor_id"` + ActorName string `json:"actor_name"` + TargetType string `json:"target_type"` + TargetID *uint64 `json:"target_id,omitempty"` + TargetName string `json:"target_name"` + Content string `json:"content"` + Reason string `json:"reason"` + Payload datatypes.JSON `json:"payload"` + AttachmentURLs []string `json:"attachment_urls"` + StateBefore datatypes.JSON `json:"state_before"` + StateAfter datatypes.JSON `json:"state_after"` + CreatedAt time.Time `json:"created_at"` +} + type CheckoutDTO struct { ID uint64 `json:"id"` OrderID uint64 `json:"order_id"` diff --git a/backend/internal/modules/order/force_handoff.go b/backend/internal/modules/order/force_handoff.go index 1fb08a5..0c89ddf 100644 --- a/backend/internal/modules/order/force_handoff.go +++ b/backend/internal/modules/order/force_handoff.go @@ -7,13 +7,20 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" ) func canAdminForceHandoff(order model.RentalOrder) bool { - if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) { + if isPlatformHandoffOrder(order) { + if order.Status == orderStatusPendingHandoff { + return order.HandoffStatus == handoffStatusPendingRenterConfirm + } + return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout + } + if isPlatformSettlementOrder(order) { return false } if order.Status == orderStatusPendingHandoff { @@ -24,11 +31,18 @@ func canAdminForceHandoff(order model.RentalOrder) bool { return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout } +func forceHandoffRecordType(order model.RentalOrder) string { + if isPlatformHandoffOrder(order) { + return "platform_handoff" + } + return "owner_handoff" +} + func forceHandoffNeedsContent(order model.RentalOrder) bool { return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout } -// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。 +// AdminForceHandoff 由客服确认订单已完成交接,并立即开始租期。 func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder @@ -38,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { return ErrOrderCannotForceHandoff } + before := orderState(order) if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { return ErrOrderCannotForceHandoff } @@ -60,7 +75,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde if !needsContent { var handoffCount int64 if err := tx.Model(&model.HandoffRecord{}). - Where("order_id = ? AND type = ?", order.ID, "owner_handoff"). + Where("order_id = ? AND type = ?", order.ID, forceHandoffRecordType(order)). Count(&handoffCount).Error; err != nil { return err } @@ -87,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde order.HandoffStatus = handoffStatusReceived order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil { + return err + } orderID := order.ID if err := notification.Append(tx, notification.Entry{ diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 0571af7..3a677f7 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) AdminProcessEvents(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + func (h *Handler) AdminClose(c *gin.Context) { h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) } @@ -248,6 +261,15 @@ func (h *Handler) ListPendingRefund(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) PendingRefundCount(c *gin.Context) { + count, err := h.service.PendingRefundCount(c.Request.Context()) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"pending_refund_count": count}) +} + func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index d6cc0be..24d5229 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -46,6 +46,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账") case errors.Is(err, ErrCheckoutCannotConfirm): response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认") + case errors.Is(err, ErrZeroOwnerIncomeConfirmation): + response.Error(c, http.StatusConflict, "zero_owner_income_confirmation_required", "号主收入为 0,请确认后再结算") case errors.Is(err, ErrCheckoutCannotCounter): response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改") case errors.Is(err, ErrCheckoutMaxRounds): diff --git a/backend/internal/modules/order/handoff.go b/backend/internal/modules/order/handoff.go index f9c7b39..361a31d 100644 --- a/backend/internal/modules/order/handoff.go +++ b/backend/internal/modules/order/handoff.go @@ -5,6 +5,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -20,6 +21,7 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u if order.OwnerID != userID { return ErrPermissionDenied } + before := orderState(order) // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。 if order.Status != orderStatusPendingHandoff || (order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) { @@ -37,6 +39,9 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u return err } order.HandoffStatus = handoffStatusPendingRenterConfirm + if err := appendOrderEvent(tx, order, "handoff", "owner_handoff_submitted", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, req.Content, "", nil, before, nil); err != nil { + return err + } orderID := order.ID renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。" if lateSubmit { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 6b60bc7..dfb0bbc 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -10,6 +10,7 @@ import ( "hfb_sys/backend/internal/modules/distribution" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/rentergrowth" + "hfb_sys/backend/internal/processlog" "gorm.io/datatypes" "gorm.io/gorm" @@ -45,6 +46,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } + accountSource, sourceChannel, err := orderAccountSourceSnapshot(tx, listing.ID) + if err != nil { + return err + } orderNo, err := newOrderNo() if err != nil { return err @@ -94,6 +99,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ RenterGrowthLevelName: growthSnapshot.LevelName, RenterDiscountBps: growthSnapshot.DiscountBps, AccountSnapshot: snapshot, + AccountSource: accountSource, + SourceChannel: sourceChannel, Status: orderStatusPendingPayment, HandoffStatus: handoffStatusNone, HandoffMode: listingHandoffMode(listing), @@ -136,6 +143,18 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return r.FindForUser(ctx, renterID, createdID) } +func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) { + var upload model.ListingUpload + err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error + if err == gorm.ErrRecordNotFound { + return "internal", "", nil + } + if err != nil { + return "", "", err + } + return "external", upload.SourceChannel, nil +} + func listingHandoffMode(listing model.RentalListing) string { if listing.HandoffMode != "" { return listing.HandoffMode @@ -378,8 +397,11 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) return err } } - if err := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil { - return err + // 已付款订单取消后需等待客服审核退款,审核通过前继续占用账号。 + if beforeStatus == orderStatusPendingPayment { + if err := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil { + return err + } } if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { return err @@ -428,6 +450,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm { return ErrOrderCannotReceive } + before := orderState(order) now := time.Now() if err := tx.Model(&model.HandoffRecord{}). Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}). @@ -438,6 +461,9 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID order.HandoffStatus = handoffStatusReceived order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "renter_received_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客确认已收号,订单开始租用。", "", nil, before, nil); err != nil { + return err + } orderID := order.ID if isPlatformHandoffOrder(order) { if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil { diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go index c7bffbb..963876e 100644 --- a/backend/internal/modules/order/platform_managed.go +++ b/backend/internal/modules/order/platform_managed.go @@ -7,6 +7,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -66,7 +67,7 @@ func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool { func canAdminMarkOfflineSettlement(order model.RentalOrder) bool { return isPlatformSettlementOrder(order) && order.Status == orderStatusCompleted && - order.SettlementStatus == settlementStatusSettled && + (order.SettlementStatus == settlementStatusSettled || order.SettlementStatus == settlementStatusArbitrated) && effectiveOfflineSettlementStatus(order) == offlineSettlementStatusPending && order.OfflineSettlementAmountCent > 0 } @@ -81,6 +82,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o if !canAdminPlatformHandoff(order) { return ErrOrderCannotHandoff } + before := orderState(order) beforeHandoffStatus := order.HandoffStatus if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID @@ -98,6 +100,9 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o now := time.Now() order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStartedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil { + return err + } orderID := order.ID content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。" if beforeHandoffStatus == handoffStatusOwnerTimeout { @@ -146,6 +151,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -153,6 +159,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if normalizeCheckoutTurn(checkout) != checkoutTurnOwner { return ErrCheckoutCannotConfirm } + if checkout.OwnerIncomeAmountCent == 0 && !req.ConfirmZeroOwnerIncome { + return ErrZeroOwnerIncomeConfirmation + } if err := ensureCheckoutCompletable(order, checkout); err != nil { return err } @@ -175,6 +184,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if err != nil { return err } + if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服确认当前结账方案,订单完成。", req.Reason, checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)); err != nil { + return err + } return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -187,6 +199,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u "after_settlement_status": order.SettlementStatus, "offline_settlement_status": order.OfflineSettlementStatus, "offline_settlement_amount_cent": order.OfflineSettlementAmountCent, + "confirm_zero_owner_income": req.ConfirmZeroOwnerIncome, }) }) if err != nil { @@ -207,6 +220,7 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotCounter } + before := orderState(order) if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID } @@ -259,6 +273,9 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u if err := tx.Create(&record).Error; err != nil { return err } + if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_countered", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -308,6 +325,7 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotCounter } + before := orderState(order) if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID } @@ -369,6 +387,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u if err := tx.Create(&record).Error; err != nil { return err } + if err := appendOrderEvent(tx, order, "dispute", "platform_checkout_dispute_opened", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服发起结账争议。", req.Reason, map[string]any{"checkout_id": checkout.ID, "dispute_id": row.ID}, before, nil); err != nil { + return err + } disputeID := row.ID orderID := order.ID if err := notification.Append(tx, notification.Entry{ @@ -415,6 +436,7 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin if !canAdminMarkOfflineSettlement(order) { return ErrOfflineSettlementCannotMark } + before := orderState(order) beforeStatus := order.OfflineSettlementStatus now := time.Now() order.OfflineSettlementStatus = offlineSettlementStatusSettled @@ -422,6 +444,9 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin order.OfflineSettledBy = &adminID order.OfflineSettledAt = &now order.OwnerSettledAt = &now + if err := appendOrderEvent(tx, order, "settlement", "offline_settlement_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.OwnerID, "确认已完成线下结算。", req.Remark, map[string]any{"offline_settlement_amount_cent": order.OfflineSettlementAmountCent}, before, nil); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 0cc00f3..03e0a3b 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -108,6 +108,8 @@ func (row orderRow) toAdminDTO() OrderDTO { GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, AccountSnapshot: row.AccountSnapshot, + AccountSource: effectiveAccountSource(row.RentalOrder), + SourceChannel: row.SourceChannel, Status: row.Status, HandoffStatus: row.HandoffStatus, HandoffMode: effectiveHandoffMode(row.RentalOrder), @@ -132,6 +134,13 @@ func (row orderRow) toAdminDTO() OrderDTO { } } +func effectiveAccountSource(order model.RentalOrder) string { + if order.AccountSource != "" { + return order.AccountSource + } + return "internal" +} + func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto := row.toAdminDTO() dto.AdminActions = nil @@ -147,6 +156,9 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto.OfflineSettlementRemark = "" dto.OfflineSettledBy = nil dto.OfflineSettledAt = nil + // 外部来源渠道属于后台经营信息,仅在管理员订单详情展示。 + dto.AccountSource = "" + dto.SourceChannel = "" applyOrderPriceView(&dto, row.RentalOrder, userID) return dto } @@ -335,7 +347,7 @@ func refreshCheckoutSettlementDTO(dto *CheckoutDTO, order model.RentalOrder, che if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered { return } - settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) + settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(checkout)) rent := settlement.ActualRentAmountCent ownerRent := settlement.OwnerRentIncomeCent platform := settlement.PlatformFeeCent diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 47c6569..e7424c2 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -278,7 +278,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c } func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { - return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) + return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, effectiveCheckoutDepositDeductCent(*checkout)) +} + +// effectiveCheckoutDepositDeductCent keeps legacy checkouts that only populated +// other_amount_cent from losing their deposit compensation at finalization. +func effectiveCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { + if checkout.DepositDeductAmountCent > 0 { + return checkout.DepositDeductAmountCent + } + return maxCent(checkout.OtherAmountCent, 0) } func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { diff --git a/backend/internal/modules/order/process_events.go b/backend/internal/modules/order/process_events.go new file mode 100644 index 0000000..97343fd --- /dev/null +++ b/backend/internal/modules/order/process_events.go @@ -0,0 +1,64 @@ +package order + +import ( + "context" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" + + "gorm.io/gorm" +) + +const processBusinessOrder = "order" + +func orderState(order model.RentalOrder) map[string]any { + return map[string]any{ + "order_status": order.Status, "handoff_status": order.HandoffStatus, + "settlement_status": order.SettlementStatus, + "offline_settlement_status": effectiveOfflineSettlementStatus(order), + } +} + +func checkoutEventPayload(checkout model.OrderCheckout) map[string]any { + return map[string]any{ + "checkout_id": checkout.ID, "round": checkout.RoundCount, "turn": normalizeCheckoutTurn(&checkout), + "proposed_by": checkout.ProposedBy, "rent_amount_cent": checkout.RentAmountCent, + "owner_rent_amount_cent": checkout.OwnerRentAmountCent, "platform_fee_cent": checkout.PlatformFeeCent, + "deposit_amount_cent": checkout.DepositAmountCent, "consumable_amount_cent": checkout.ConsumableAmountCent, + "coin_consumed_m": checkout.CoinConsumedM, "deposit_deduct_amount_cent": checkout.DepositDeductAmountCent, + "renter_refund_amount_cent": checkout.RenterRefundAmountCent, "owner_income_amount_cent": checkout.OwnerIncomeAmountCent, + "shortfall_cent": checkout.ShortfallCent, "overshoot_amount_cent": checkout.OvershootAmountCent, + } +} + +func appendOrderEvent(tx *gorm.DB, order model.RentalOrder, stage, action, actorType string, actorID uint64, targetType string, targetID *uint64, content, reason string, payload, before map[string]any, attachments []string) error { + return processlog.Append(tx, processlog.Entry{ + BusinessType: processBusinessOrder, BusinessID: order.ID, Stage: stage, Action: action, + ActorType: actorType, ActorID: actorID, TargetType: targetType, TargetID: targetID, + Content: content, Reason: reason, Payload: payload, Attachments: attachments, + StateBefore: before, StateAfter: orderState(order), + }) +} + +func (r *Repository) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) { + var order model.RentalOrder + if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil { + return nil, err + } + rows, err := processlog.List(r.db.WithContext(ctx), processBusinessOrder, orderID) + if err != nil { + return nil, err + } + items := make([]ProcessEventDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, ProcessEventDTO{ + ID: row.ID, BusinessType: row.BusinessType, BusinessID: row.BusinessID, Stage: row.Stage, + Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, ActorName: row.ActorName, + TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName, + Content: row.Content, Reason: row.Reason, Payload: row.Payload, + AttachmentURLs: decodeStringList(row.AttachmentURLs), StateBefore: row.StateBefore, + StateAfter: row.StateAfter, CreatedAt: row.CreatedAt, + }) + } + return items, nil +} diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index c42ecfb..1672ad8 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -120,12 +120,6 @@ func isSellerHandoffStatus(status string) bool { } func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) { - var total int64 - countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query) - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - page := query.Page pageSize := query.PageSize if page < 1 { @@ -135,6 +129,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm pageSize = 20 } offset := (page - 1) * pageSize + var rows []orderRow err := applyAdminOrderFilters(r.adminQuery(ctx), query). Order("o.id DESC"). @@ -145,6 +140,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm return nil, err } + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := applyAdminOrderFilters(r.adminOrderCountQuery(ctx, query), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } + items := make([]AdminOrderListItemDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toAdminListItem()) @@ -171,6 +177,60 @@ func normalizePagination(page, pageSize int) (int, int) { return page, pageSize } +func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQuery) *gorm.DB { + kw := strings.TrimSpace(query.Keyword) + upper := strings.ToUpper(kw) + needsJoin := false + if kw != "" { + if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + needsJoin = false + } else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && len(kw) < 9 { + needsJoin = false + } else { + needsJoin = true + } + } + if !needsJoin { + return r.db.WithContext(ctx).Table("rental_orders AS o") + } + return r.adminOrderJoinQuery(ctx) +} + +// latestAdminCheckouts loads one latest checkout per order so the admin order +// list can display finalized income without issuing one query per row. +func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) { + result := make(map[uint64]model.OrderCheckout, len(rows)) + if len(rows) == 0 { + return result, nil + } + orderIDs := make([]uint64, 0, len(rows)) + for _, row := range rows { + orderIDs = append(orderIDs, row.ID) + } + latest := r.db.WithContext(ctx). + Table("order_checkouts"). + Select("MAX(id) AS id"). + Where("order_id IN ?", orderIDs). + Group("order_id") + var checkouts []model.OrderCheckout + if err := r.db.WithContext(ctx). + Table("order_checkouts AS oc"). + Joins("JOIN (?) AS latest ON latest.id = oc.id", latest). + Find(&checkouts).Error; err != nil { + return nil, err + } + for _, checkout := range checkouts { + result[checkout.OrderID] = checkout + } + return result, nil +} + +func shouldAttachAdminCheckout(row orderRow) bool { + return shouldAttachCheckout(row.Status) || + row.SettlementStatus == settlementStatusSettled || + row.SettlementStatus == settlementStatusArbitrated +} + func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { if query.Status != "" { db = db.Where("o.status = ?", query.Status) @@ -191,21 +251,21 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { db = db.Where("COALESCE(NULLIF(o.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { - db = db.Where( - `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, - like, - like, - like, - like, - like, - id, - id, - id, - id, - ) + upper := strings.ToUpper(keyword) + if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { + // 11 位手机号:命中 users(phone) 唯一索引 + db = db.Where("(owner.phone = ? OR renter.phone = ?)", keyword, keyword) + } else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("o.order_no = ?", keyword) + } else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) { + // 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引 + db = db.Where("l.listing_no = ?", keyword) + } else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 { + // 短纯数字 ID(非零开头):命中 rental_orders(id) 主键及关联 ID + db = db.Where("(o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)", id, id, id, id) } else { + like := "%" + keyword + "%" db = db.Where( `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`, like, @@ -377,3 +437,12 @@ type orderRow struct { OwnerPhone string RenterPhone string } + +func isAllDigits(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return len(s) > 0 +} diff --git a/backend/internal/modules/order/refund_review.go b/backend/internal/modules/order/refund_review.go index 62709f8..656171a 100644 --- a/backend/internal/modules/order/refund_review.go +++ b/backend/internal/modules/order/refund_review.go @@ -2,6 +2,8 @@ package order import ( "context" + + "hfb_sys/backend/internal/model" ) // ListPendingRefund 查询所有待客服审核退款的订单。 @@ -26,3 +28,13 @@ func (r *Repository) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) } return items, nil } + +// PendingRefundCount 返回待客服审核退款数量,用于后台导航角标。 +func (r *Repository) PendingRefundCount(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.RentalOrder{}). + Where("refund_status = ?", refundStatusPendingReview). + Count(&count).Error + return count, err +} diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 506d14d..987f2b7 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1,6 +1,8 @@ package order import ( + "context" + "errors" "testing" "time" @@ -379,6 +381,20 @@ func TestBuildCheckoutSettlementWithDepositDeduct(t *testing.T) { } } +func TestBuildCheckoutSettlementFallsBackToLegacyOtherAmount(t *testing.T) { + order := model.RentalOrder{ + RentAmountCent: 24000, + OwnerRentAmountCent: 21600, + DepositAmountCent: 5000, + } + checkout := &model.OrderCheckout{OtherAmountCent: 2000} + + settlement := buildCheckoutSettlement(order, checkout) + if settlement.DepositCompensationCent != 2000 || settlement.OwnerIncomeCent != 23600 { + t.Fatalf("legacy other amount settlement = compensation %d, owner income %d, want 2000/23600", settlement.DepositCompensationCent, settlement.OwnerIncomeCent) + } +} + // TestBuildCheckoutSettlementWithConsumable 测试额外消耗 func TestBuildCheckoutSettlementWithConsumable(t *testing.T) { order := model.RentalOrder{ @@ -721,6 +737,105 @@ func TestCancelClosesPendingPaymentOrder(t *testing.T) { } } +func TestCancelPendingHandoffKeepsAssetsUntilRefundApproved(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunding", nil + }), + }) + owner := model.User{Phone: "13800001002"} + renter := model.User{Phone: "13900001002"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + Status: accountStatusRented, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "待退款审核账号", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + listing := model.RentalListing{ + ListingNo: "LST-REFUND-REVIEW-001", + OwnerID: owner.ID, + AccountID: account.ID, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + PriceCent: 1000, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + order := model.RentalOrder{ + OrderNo: "ORD-REFUND-REVIEW-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 1000, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingOwner, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + if err := repo.Cancel(t.Context(), renter.ID, order.ID); err != nil { + t.Fatalf("Cancel() error = %v", err) + } + assertAssetsRented := func() { + var savedListing model.RentalListing + if err := db.First(&savedListing, listing.ID).Error; err != nil { + t.Fatalf("find listing failed: %v", err) + } + if savedListing.Status != listingStatusRented || !savedListing.InTransaction { + t.Fatalf("listing = %s/in_transaction:%t, want rented/true", savedListing.Status, savedListing.InTransaction) + } + var savedAccount model.GameAccount + if err := db.First(&savedAccount, account.ID).Error; err != nil { + t.Fatalf("find account failed: %v", err) + } + if savedAccount.Status != accountStatusRented { + t.Fatalf("account status = %q, want %q", savedAccount.Status, accountStatusRented) + } + } + assertAssetsRented() + + var pendingReviewOrder model.RentalOrder + if err := db.First(&pendingReviewOrder, order.ID).Error; err != nil { + t.Fatalf("find cancelled order failed: %v", err) + } + if pendingReviewOrder.RefundStatus != refundStatusPendingReview { + t.Fatalf("refund status = %q, want %q", pendingReviewOrder.RefundStatus, refundStatusPendingReview) + } + if err := repo.AdminApproveRefund(t.Context(), order.ID); err != nil { + t.Fatalf("AdminApproveRefund() error = %v", err) + } + + var releasedListing model.RentalListing + if err := db.First(&releasedListing, listing.ID).Error; err != nil { + t.Fatalf("find released listing failed: %v", err) + } + if releasedListing.Status != listingStatusPublished || releasedListing.InTransaction { + t.Fatalf("listing = %s/in_transaction:%t, want published/false", releasedListing.Status, releasedListing.InTransaction) + } + var releasedAccount model.GameAccount + if err := db.First(&releasedAccount, account.ID).Error; err != nil { + t.Fatalf("find released account failed: %v", err) + } + if releasedAccount.Status != accountStatusPublished { + t.Fatalf("account status = %q, want %q", releasedAccount.Status, accountStatusPublished) + } +} + func TestCancelDoesNotReleaseAssetsWhenSiblingOrderActive(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) @@ -1079,7 +1194,7 @@ func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) { } } -func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *testing.T) { +func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrderBeforeHandoff(t *testing.T) { db := setupOrderTestDB(t) if err := db.AutoMigrate(&model.Dispute{}); err != nil { t.Fatalf("migrate disputes failed: %v", err) @@ -1160,6 +1275,72 @@ func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *test } } +func TestAdminForceHandoffStartsPlatformManagedOrderAfterPlatformHandoff(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.Dispute{}); err != nil { + t.Fatalf("migrate disputes failed: %v", err) + } + repo := NewRepository(db) + + owner := model.User{Phone: "admin:force-handoff"} + renter := model.User{Phone: "13900003008"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + + order := model.RentalOrder{ + OrderNo: "ORD-FORCE-HANDOFF-PLATFORM-001", + ListingID: 4, + AccountID: 4, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingRenterConfirm, + HandoffMode: handoffModePlatform, + SettlementMode: settlementModePlatformManaged, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "daily_loss_m": 50 + }`)), + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + if err := db.Create(&model.HandoffRecord{ + OrderID: order.ID, + FromUserID: 99, + ToUserID: renter.ID, + Type: "platform_handoff", + Content: "客服已提交账号和登录说明", + }).Error; err != nil { + t.Fatalf("create platform handoff record failed: %v", err) + } + + if err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{ + Reason: "租客未确认,客服已核实账号可正常使用", + }, AuditMeta{}); err != nil { + t.Fatalf("AdminForceHandoff() error = %v", err) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("load order failed: %v", err) + } + if saved.Status != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived { + t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus) + } + var record model.HandoffRecord + if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_force_handoff").First(&record).Error; err != nil { + t.Fatalf("find force handoff record failed: %v", err) + } + if record.Content != "客服已确认双方完成交接,订单已进入使用中。" { + t.Fatalf("record content = %q", record.Content) + } +} + func TestSubmitCheckoutRefreshesStageTime(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) @@ -1414,6 +1595,46 @@ func TestAdminPlatformCheckoutCounterCanBeAcceptedByRenter(t *testing.T) { } } +func TestAdminMarkOfflineSettlementAllowsArbitratedPlatformOrder(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(79) + order := model.RentalOrder{ + OrderNo: "ORD-OFFLINE-ARBITRATED-001", + ListingID: 1, + AccountID: 1, + OwnerID: 1, + RenterID: 2, + Status: orderStatusCompleted, + SettlementStatus: settlementStatusArbitrated, + HandoffMode: handoffModePlatform, + SettlementMode: settlementModePlatformManaged, + OfflineSettlementStatus: offlineSettlementStatusPending, + OfflineSettlementAmountCent: 8000, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + if !canAdminMarkOfflineSettlement(order) { + t.Fatal("arbitrated platform order should allow offline settlement") + } + if err := repo.AdminMarkOfflineSettlement(t.Context(), adminID, order.ID, OfflineSettlementRequest{Remark: "仲裁后线下转账"}, AuditMeta{}); err != nil { + t.Fatalf("AdminMarkOfflineSettlement() error = %v", err) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("load order failed: %v", err) + } + if saved.OfflineSettlementStatus != offlineSettlementStatusSettled { + t.Fatalf("offline settlement status = %q, want %q", saved.OfflineSettlementStatus, offlineSettlementStatusSettled) + } + if saved.OfflineSettledBy == nil || *saved.OfflineSettledBy != adminID { + t.Fatalf("offline settled by = %#v, want %d", saved.OfflineSettledBy, adminID) + } +} + func TestPlatformManagedCheckoutIncludesConsumableAndDepositCompensation(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) @@ -1588,3 +1809,78 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd } return checkout } + +func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(7002) + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + order.Status = orderStatusCompleted + order.SettlementStatus = settlementStatusSettled + if err := db.Save(&order).Error; err != nil { + t.Fatalf("save completed order failed: %v", err) + } + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.Status = checkoutStatusAccepted + checkout.OwnerIncomeAmountCent = 6000 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("save finalized checkout failed: %v", err) + } + + result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListAdmin() error = %v", err) + } + if len(result.Items) != 1 { + t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) + } + // 订单列表已最小化为 AdminOrderListItemDTO,结账明细(含号主到手金额)由订单详情接口返回。 + if result.Items[0].SettlementStatus != settlementStatusSettled { + t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled) + } +} + +func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "pending", nil + }), + }) + const adminID uint64 = 7001 + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.OwnerIncomeAmountCent = 0 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("将号主收入调整为零失败: %v", err) + } + + err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{ + Reason: "客服核对结算", + }, AuditMeta{}) + if !errors.Is(err, ErrZeroOwnerIncomeConfirmation) { + t.Fatalf("未确认零收入时 error = %v, want %v", err, ErrZeroOwnerIncomeConfirmation) + } + + var pending model.RentalOrder + if err := db.First(&pending, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if pending.Status != orderStatusPendingCheckoutConfirm { + t.Fatalf("未确认零收入后订单状态 = %s, want %s", pending.Status, orderStatusPendingCheckoutConfirm) + } + + if err := repo.AdminPlatformCheckoutConfirm(t.Context(), *order.ManagedAdminID, order.ID, AdminActionRequest{ + Reason: "已确认号主收入为零", + ConfirmZeroOwnerIncome: true, + }, AuditMeta{}); err != nil { + t.Fatalf("确认零收入后结算失败: %v", err) + } + var completed model.RentalOrder + if err := db.First(&completed, order.ID).Error; err != nil { + t.Fatalf("读取完成订单失败: %v", err) + } + if completed.Status != orderStatusCompleted { + t.Fatalf("确认零收入后订单状态 = %s, want %s", completed.Status, orderStatusCompleted) + } +} diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 4d9749f..0a6be77 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -1,6 +1,7 @@ package order import ( + "strings" "testing" "time" @@ -9,6 +10,8 @@ import ( "hfb_sys/backend/internal/timeutil" "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func pureCoinDiscountOrder() model.RentalOrder { @@ -470,3 +473,58 @@ func TestNewOrderNoUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) { t.Fatalf("order no time = %s, want between %s and %s", parsed, before, after) } } + +func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminOrderQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminOrderQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + }, + { + name: "RO前缀订单号路由至rental_orders(order_no)", + query: AdminOrderQuery{Keyword: "RO202609051000"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)", + query: AdminOrderQuery{Keyword: "202609040184"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminOrderQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminOrderQuery{Keyword: "12345"}, + wantSQLFrag: "o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminOrderQuery{Keyword: "测试账号"}, + wantSQLFrag: "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminOrderFilters(db.Table("rental_orders AS o"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index cd3ece0..6bed379 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -22,6 +22,7 @@ var ( ErrOrderCannotComplete = errors.New("order cannot complete") ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") + ErrZeroOwnerIncomeConfirmation = errors.New("zero owner income confirmation required") ErrCheckoutCannotCounter = errors.New("checkout cannot counter") ErrCheckoutMaxRounds = errors.New("checkout max rounds reached") ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall") @@ -195,6 +196,13 @@ func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]Ha return s.repo.HandoffRecordsAdmin(ctx, orderID) } +func (s *Service) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ProcessEventsAdmin(ctx, orderID) +} + func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { if s.repo == nil { return ErrDependencyUnavailable @@ -362,6 +370,13 @@ func (s *Service) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) { return s.repo.ListPendingRefund(ctx) } +func (s *Service) PendingRefundCount(ctx context.Context) (int64, error) { + if s.repo == nil { + return 0, ErrDependencyUnavailable + } + return s.repo.PendingRefundCount(ctx) +} + func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 8d803b8..f8fa1da 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "strings" + "sync" "testing" "time" @@ -694,6 +696,75 @@ func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) { } } +func TestConcurrentPaidNotificationsAreIdempotent(t *testing.T) { + db := database.NewTestDBWithName("payment-concurrent") + if err := database.MigrateRentalTransactionTestSchema(db); err != nil { + t.Fatalf("数据库迁移失败: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + sqlDB.SetMaxOpenConns(8) + orderRepo := ordermodule.NewRepository(db) + repo := NewRepository(db, nil, orderRepo) + fixture := createPayableOrderFixture(t, db, "CONCURRENT", true) + params := map[string]string{ + "third_order_id": fixture.Payment.ThirdOrderID, + "status": "paid", + "amount": "1000", + "provider_order_id": "PROVIDER-CONCURRENT", + } + + const callers = 8 + results := make(chan error, callers) + var group sync.WaitGroup + for i := 0; i < callers; i++ { + group.Add(1) + go func() { + defer group.Done() + var callErr error + for attempt := 0; attempt < 50; attempt++ { + _, callErr = repo.HandleNotify(t.Context(), "mock", params, "status=paid", "application/x-www-form-urlencoded", "") + if callErr == nil || !strings.Contains(callErr.Error(), "locked") { + break + } + time.Sleep(5 * time.Millisecond) + } + results <- callErr + }() + } + group.Wait() + close(results) + for callErr := range results { + if callErr != nil { + t.Fatalf("concurrent HandleNotify() error = %v", callErr) + } + } + + var latestPayment model.PaymentOrder + if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil { + t.Fatalf("find payment: %v", err) + } + if latestPayment.Status != "paid" { + t.Fatalf("payment status = %q, want paid", latestPayment.Status) + } + var latestOrder model.RentalOrder + if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil { + t.Fatalf("find order: %v", err) + } + if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" { + t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus) + } + var paidCount int64 + if err := db.Model(&model.PaymentOrder{}).Where("id = ? AND status = 'paid'", fixture.Payment.ID).Count(&paidCount).Error; err != nil { + t.Fatalf("count paid payment: %v", err) + } + if paidCount != 1 { + t.Fatalf("paid payment rows = %d, want 1", paidCount) + } +} + // TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。 func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) { db := setupPaymentTestDB(t) diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 287da59..0d3a51b 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -150,6 +150,19 @@ func (h *Handler) ListFinancialAdjustments(c *gin.Context) { response.OK(c, items) } +func (h *Handler) ProcessEvents(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.ProcessEvents(c.Request.Context(), id) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + // SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。 func (h *Handler) SettleFinancialAdjustment(c *gin.Context) { adminID, ok := currentAdminID(c) diff --git a/backend/internal/modules/pickup/process_events.go b/backend/internal/modules/pickup/process_events.go new file mode 100644 index 0000000..6fddb95 --- /dev/null +++ b/backend/internal/modules/pickup/process_events.go @@ -0,0 +1,76 @@ +package pickup + +import ( + "context" + "encoding/json" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const processBusinessPickup = "pickup" + +type ProcessEventDTO struct { + ID uint64 `json:"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 string `json:"created_at"` +} + +func pickupState(row model.AdminPickup) map[string]any { + return map[string]any{ + "pickup_status": row.Status, "offline_settlement_status": row.OfflineSettlementStatus, + "settlement_mode": row.SettlementMode, + } +} + +func appendPickupEvent(tx *gorm.DB, pickup model.AdminPickup, action string, adminID uint64, content, reason string, payload, before map[string]any) error { + return processlog.Append(tx, processlog.Entry{ + BusinessType: processBusinessPickup, BusinessID: pickup.ID, Stage: "pickup", Action: action, + ActorType: processlog.ActorAdmin, ActorID: adminID, TargetType: processlog.ActorUser, + TargetID: &pickup.OwnerID, Content: content, Reason: reason, Payload: payload, + StateBefore: before, StateAfter: pickupState(pickup), + }) +} + +func (r *Repository) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) { + var pickup model.AdminPickup + if err := r.db.WithContext(ctx).First(&pickup, pickupID).Error; err != nil { + return nil, err + } + rows, err := processlog.List(r.db.WithContext(ctx), processBusinessPickup, pickupID) + if err != nil { + return nil, err + } + items := make([]ProcessEventDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, ProcessEventDTO{ + ID: row.ID, Stage: row.Stage, Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, + ActorName: row.ActorName, TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName, + Content: row.Content, Reason: row.Reason, Payload: row.Payload, AttachmentURLs: decodeStringList(row.AttachmentURLs), + StateBefore: row.StateBefore, StateAfter: row.StateAfter, CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + return items, nil +} + +func decodeStringList(raw datatypes.JSON) []string { + items := make([]string, 0) + _ = json.Unmarshal(raw, &items) + return items +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 4ee045a..4bc1f71 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -105,6 +105,13 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint if err := tx.Create(&pickup).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_created", adminID, "管理员创建线下提号。", pickup.Remark, map[string]any{ + "listing_price_cent": pickup.ListingPriceCent, "profit_amount_cent": pickup.ProfitAmountCent, + "account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel, + "settlement_mode": pickup.SettlementMode, + }, map[string]any{}); err != nil { + return err + } createdID = pickup.ID // 锁定商品:status=rented,从「已上架」列表移除,进入「已锁定」 @@ -188,6 +195,7 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { @@ -273,6 +281,12 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete return err } } + if err := appendPickupEvent(tx, pickup, "pickup_completed", adminID, "管理员完成提号。", pickup.CompleteRemark, map[string]any{ + "settle_amount_cent": pickup.SettleAmountCent, "profit_amount_cent": pickup.ProfitAmountCent, + "settlement_mode": pickup.SettlementMode, "offline_settlement_status": pickup.OfflineSettlementStatus, + }, before); err != nil { + return err + } if err := auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", @@ -316,6 +330,7 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 { return ErrOfflineSettlementCannotMark } + before := pickupState(pickup) pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID) if err != nil { return err @@ -345,6 +360,11 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, return err } } + if err := appendPickupEvent(tx, pickup, "pickup_offline_settlement_confirmed", adminID, "确认已完成提号线下结算。", pickup.OfflineSettlementRemark, map[string]any{ + "settle_amount_cent": effectiveSettleAmountCent, "settled_adjustment_count": pendingTotals.Count, + }, before); err != nil { + return err + } bid := pickup.ID return auditlog.Append(tx, auditlog.Entry{ @@ -386,12 +406,18 @@ func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req Upda if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) oldProfitAmountCent := pickup.ProfitAmountCent pickup.ProfitAmountCent = req.ProfitAmountCent if err := tx.Model(&pickup).Update("profit_amount_cent", pickup.ProfitAmountCent).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_profit_updated", adminID, "修改提号利润。", strings.TrimSpace(req.Reason), map[string]any{ + "old_profit_amount_cent": oldProfitAmountCent, "profit_amount_cent": pickup.ProfitAmountCent, + }, before); err != nil { + return err + } bid := pickup.ID return auditlog.Append(tx, auditlog.Entry{ @@ -431,6 +457,7 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin if pickup.Status != StatusCompleted { return ErrFinancialAdjustmentInvalid } + before := pickupState(pickup) totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID) if err != nil { @@ -471,6 +498,13 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin if err := tx.Create(&adjustment).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_financial_adjusted", adminID, "创建提号财务调整。", adjustment.Reason, map[string]any{ + "adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent, + "settle_delta_cent": adjustment.SettleDeltaCent, "settlement_mode": adjustment.SettlementMode, + "adjustment_status": adjustment.Status, + }, before); err != nil { + return err + } // 站内钱包的补款在创建调整时立即到账;扣款统一转待追回,避免余额被扣成负数。 if pickup.SettlementMode == SettlementModeOwnerWallet && settleDeltaCent > 0 { @@ -492,7 +526,7 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin ActorType: "admin", ActorID: adminID, Action: "pickup_financial_adjustment_create", - BizType: "admin_pickup_financial_adjustment", + BizType: "admin_pickup", BizID: &bid, Meta: meta, Detail: map[string]any{ @@ -559,12 +593,23 @@ func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID if err := tx.Save(&adjustment).Error; err != nil { return err } + var pickup model.AdminPickup + if err := tx.First(&pickup, adjustment.PickupID).Error; err != nil { + return err + } + if err := appendPickupEvent(tx, pickup, "pickup_adjustment_settled", adminID, "确认提号财务调整已处理。", adjustment.SettlementRemark, map[string]any{ + "adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent, + "settle_delta_cent": adjustment.SettleDeltaCent, "before_status": beforeStatus, + "adjustment_status": adjustment.Status, + }, pickupState(pickup)); err != nil { + return err + } bid := adjustment.ID return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_financial_adjustment_settle", - BizType: "admin_pickup_financial_adjustment", + BizType: "admin_pickup", BizID: &bid, Meta: meta, Detail: map[string]any{ @@ -595,6 +640,7 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { return err @@ -609,6 +655,9 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, if err := tx.Save(&pickup).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_cancelled", adminID, "管理员取消提号。", reason, nil, before); err != nil { + return err + } // 取消提号:恢复上架,重新出现在商品管理「已上架」 fromStatus := listing.Status listing.InTransaction = false @@ -673,11 +722,7 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error return &dto, nil } -func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { - if r == nil || r.db == nil { - return nil, ErrDependencyUnavailable - } - db := r.baseQuery(ctx) +func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { if status := strings.TrimSpace(query.Status); status != "" { db = db.Where("p.status = ?", status) } @@ -691,16 +736,90 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa db = db.Where("p.offline_settlement_status = ?", offlineSettlementStatus) } if kw := strings.TrimSpace(query.Keyword); kw != "" { - like := "%" + kw + "%" - db = db.Where( - "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", - like, - like, - like, - like, - ) + upper := strings.ToUpper(kw) + if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { + // 11 位手机号:命中 users(phone) 唯一索引 + db = db.Where("owner.phone = ?", kw) + } else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { + // 提号编号:命中 admin_pickups(pickup_no) 唯一索引 + db = db.Where("p.pickup_no = ?", kw) + } else if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { + // 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引 + db = db.Where("l.listing_no = ?", kw) + } else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { + // 短纯数字 ID(非零开头):命中 admin_pickups 主键及关联 ID + db = db.Where("(p.id = ? OR p.listing_id = ? OR p.account_id = ? OR p.owner_id = ?)", id, id, id, id) + } else { + like := "%" + kw + "%" + db = db.Where( + "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", + like, + like, + like, + like, + ) + } } - return r.paginatePickups(db, query.Page, query.PageSize, false) + return db +} + +func (r *Repository) adminPickupCountQuery(ctx context.Context, query AdminPickupQuery) *gorm.DB { + kw := strings.TrimSpace(query.Keyword) + if kw == "" { + return r.db.WithContext(ctx).Table("admin_pickups AS p") + } + upper := strings.ToUpper(kw) + if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { + return r.db.WithContext(ctx).Table("admin_pickups AS p") + } + if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { + return r.db.WithContext(ctx).Table("admin_pickups AS p") + } + if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Joins("JOIN users AS owner ON owner.id = p.owner_id") + } + if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id") + } + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id"). + Joins("JOIN game_accounts AS a ON a.id = p.account_id"). + Joins("JOIN users AS owner ON owner.id = p.owner_id") +} + +func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + page, pageSize := normalizePaging(query.Page, query.PageSize) + offset := (page - 1) * pageSize + + var rows []pickupRow + db := applyAdminPickupFilters(r.baseQuery(ctx), query) + if err := db.Order("p.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := applyAdminPickupFilters(r.adminPickupCountQuery(ctx, query), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } + + items := make([]PickupDTO, 0, len(rows)) + for _, row := range rows { + item := row.toDTO() + item.AccountSnapshot = nil + items = append(items, item) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // ListShopNames 返回历史提号中已使用的店铺名称,供后台选择和筛选。 @@ -733,14 +852,20 @@ func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query Se func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) { page, pageSize = normalizePaging(page, pageSize) - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, err - } var rows []pickupRow if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } + var total int64 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + // 使用独立干净的 Count 查询,避免触发 baseQuery 中的关联子查询 + countDB := db.Session(&gorm.Session{}).Select("p.id") + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } items := make([]PickupDTO, 0, len(rows)) for _, row := range rows { item := row.toDTO() @@ -780,8 +905,13 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL Joins("LEFT JOIN listing_uploads AS lu ON lu.id = latest_upload.upload_id"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false) if kw := strings.TrimSpace(query.Keyword); kw != "" { - like := "%" + kw + "%" - db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) + upper := strings.ToUpper(kw) + if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { + db = db.Where("l.listing_no = ?", kw) + } else { + like := "%" + kw + "%" + db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) + } } switch strings.TrimSpace(query.SourceType) { case ListingSourceExternal: @@ -790,15 +920,20 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL db = db.Where("lu.id IS NULL") } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, err - } page, pageSize := normalizePaging(query.Page, query.PageSize) var rows []availableListingRow if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } + var total int64 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := db.Session(&gorm.Session{}).Select("l.id") + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } items := make([]AvailableListingDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO()) @@ -1200,3 +1335,16 @@ func newPickupNo() (string, error) { randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000 return "PK" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil } + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 589ebb1..429ffbf 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -3,6 +3,7 @@ package pickup import ( "encoding/json" "errors" + "strings" "testing" "hfb_sys/backend/internal/auditlog" @@ -469,6 +470,13 @@ func TestRepositoryFinancialAdjustmentUsesRecoveryForInternalReduction(t *testin if adjustment.Status != FinancialAdjustmentStatusRecovery || adjustment.SettleDeltaCent != -2000 { t.Fatalf("追回调整 = %+v", adjustment) } + var audit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_create").First(&audit).Error; err != nil { + t.Fatalf("查询财务调整审计日志失败: %v", err) + } + if audit.BizType != "admin_pickup" { + t.Fatalf("财务调整审计业务类型 = %q, want admin_pickup", audit.BizType) + } var ledgerCount int64 if err := db.Model(&model.WalletLedger{}).Where("biz_type = ?", "admin_pickup_adjustment").Count(&ledgerCount).Error; err != nil { t.Fatalf("统计补款流水失败: %v", err) @@ -512,6 +520,13 @@ func TestRepositoryFinancialAdjustmentSettlesPlatformManagedPickupSeparately(t * if adjustments[0].Status != FinancialAdjustmentStatusPending { t.Fatalf("代管补款状态 = %q, want pending", adjustments[0].Status) } + var createAudit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_create").First(&createAudit).Error; err != nil { + t.Fatalf("查询创建财务调整审计日志失败: %v", err) + } + if createAudit.BizType != "admin_pickup" { + t.Fatalf("创建财务调整审计业务类型 = %q, want admin_pickup", createAudit.BizType) + } if err := repo.SettleFinancialAdjustment(t.Context(), adjustments[0].ID, FinancialAdjustmentSettlementRequest{Remark: "已补款"}, 102, auditlog.Meta{}); err != nil { t.Fatalf("SettleFinancialAdjustment() error = %v", err) } @@ -519,6 +534,13 @@ func TestRepositoryFinancialAdjustmentSettlesPlatformManagedPickupSeparately(t * if err != nil || adjustments[0].Status != FinancialAdjustmentStatusSettled || adjustments[0].SettledBy == nil || *adjustments[0].SettledBy != 102 { t.Fatalf("确认后的调整流水 = %+v, %v", adjustments, err) } + var settleAudit model.AuditLog + if err := db.Where("action = ?", "pickup_financial_adjustment_settle").First(&settleAudit).Error; err != nil { + t.Fatalf("查询确认财务调整审计日志失败: %v", err) + } + if settleAudit.BizType != "admin_pickup" { + t.Fatalf("确认财务调整审计业务类型 = %q, want admin_pickup", settleAudit.BizType) + } } func TestRepositoryOfflineSettlementProcessesPendingPickupAdjustments(t *testing.T) { @@ -586,6 +608,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { } if err := db.AutoMigrate( &model.User{}, + &model.AdminUser{}, &model.GameAccount{}, &model.RentalListing{}, &model.ListingUpload{}, @@ -594,6 +617,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { &model.WalletAccount{}, &model.WalletLedger{}, &model.AuditLog{}, + &model.ProcessEvent{}, &model.Notification{}, &model.ListingStatusEvent{}, ); err != nil { @@ -679,3 +703,58 @@ func seedPickup(t *testing.T, db *gorm.DB, status string) model.AdminPickup { } return pickup } + +func TestApplyAdminPickupFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminPickupQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminPickupQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ?", + }, + { + name: "PK前缀提号编号路由至admin_pickups(pickup_no)", + query: AdminPickupQuery{Keyword: "PK202609051000"}, + wantSQLFrag: "p.pickup_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminPickupQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminPickupQuery{Keyword: "12345"}, + wantSQLFrag: "p.id = ? OR p.listing_id = ? OR p.account_id = ? OR p.owner_id = ?", + }, + { + name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)", + query: AdminPickupQuery{Keyword: "202609040184"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminPickupQuery{Keyword: "测试账号"}, + wantSQLFrag: "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminPickupFilters(db.Table("admin_pickups AS p"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 874aff5..45f6c40 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -87,6 +87,13 @@ func (s *Service) ListFinancialAdjustments(ctx context.Context, pickupID uint64) return s.repo.ListFinancialAdjustments(ctx, pickupID) } +func (s *Service) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ProcessEvents(ctx, pickupID) +} + func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 1523fcc..4211b3f 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -17,6 +17,14 @@ type UpdateRequest struct { Description string `json:"description"` } +// BackupScheduleDTO 仅描述调度时间,不包含备份目录、OSS 地址或任何密钥。 +type BackupScheduleDTO struct { + Enabled bool `json:"enabled"` + FullHour int `json:"full_hour"` + FullMinute int `json:"full_minute"` + BinlogIntervalMinutes int `json:"binlog_interval_minutes"` +} + type HomeAnnouncementsDTO struct { Items []string `json:"items"` } @@ -75,22 +83,23 @@ type HomeBannerItem struct { } type PublishOptionsDTO struct { - ServerOptions []string `json:"server_options"` - FaceOptions []string `json:"face_options"` - RankOptions []string `json:"rank_options"` - InsuranceOptions []string `json:"insurance_options"` - LevelOptions []string `json:"level_options"` - LoginMethodOptions []string `json:"login_method_options"` - RegionOptions []string `json:"region_options"` - SkinGroups []PublishOptionGroup `json:"skin_groups"` - QuantityItems []PublishQuantityItem `json:"quantity_items"` - ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"` - BanRecordOptions []string `json:"ban_record_options"` - BanEvidenceOptions []string `json:"ban_evidence_options"` - FireLevelMin int `json:"fire_level_min"` - PriceConfig PublishPriceConfig `json:"price_config"` - DepositRecommend PublishDepositRecommend `json:"deposit_recommend_config"` - RatioConfig PublishRatioConfig `json:"ratio_config"` + NextSeasonPassEnabled bool `json:"next_season_pass_enabled"` + ServerOptions []string `json:"server_options"` + FaceOptions []string `json:"face_options"` + RankOptions []string `json:"rank_options"` + InsuranceOptions []string `json:"insurance_options"` + LevelOptions []string `json:"level_options"` + LoginMethodOptions []string `json:"login_method_options"` + RegionOptions []string `json:"region_options"` + SkinGroups []PublishOptionGroup `json:"skin_groups"` + QuantityItems []PublishQuantityItem `json:"quantity_items"` + ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"` + BanRecordOptions []string `json:"ban_record_options"` + BanEvidenceOptions []string `json:"ban_evidence_options"` + FireLevelMin int `json:"fire_level_min"` + PriceConfig PublishPriceConfig `json:"price_config"` + DepositRecommend PublishDepositRecommend `json:"deposit_recommend_config"` + RatioConfig PublishRatioConfig `json:"ratio_config"` } type PublishOptionGroup struct { diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index fb476ee..c4829fa 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -110,6 +110,34 @@ func (h *Handler) Update(c *gin.Context) { response.OK(c, item) } +func (h *Handler) BackupSchedule(c *gin.Context) { + schedule, err := h.service.BackupSchedule(c.Request.Context()) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + +func (h *Handler) UpdateBackupSchedule(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req BackupScheduleDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "备份定时配置格式无效") + return + } + schedule, err := h.service.UpdateBackupSchedule(c.Request.Context(), adminID, req, auditMeta(c)) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + func auditMeta(c *gin.Context) AuditMeta { return AuditMeta{ IP: c.ClientIP(), diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index fcc862d..4a5809f 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -14,12 +14,13 @@ func defaultPublishOptionsConfigValue() string { func DefaultPublishOptions() PublishOptionsDTO { return PublishOptionsDTO{ - ServerOptions: []string{"QQ", "微信", "Steam"}, - FaceOptions: []string{"是", "否"}, - RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"}, - InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"}, - LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"}, - LoginMethodOptions: []string{"扫码", "账号密码"}, + NextSeasonPassEnabled: false, + ServerOptions: []string{"QQ", "微信", "Steam"}, + FaceOptions: []string{"是", "否"}, + RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"}, + InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"}, + LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"}, + LoginMethodOptions: []string{"扫码", "账号密码"}, RegionOptions: []string{ "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index a9e29ba..fa0ad13 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -51,6 +51,10 @@ var defaultConfigs = []defaultConfig{ {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, + {Key: "backup.schedule_enabled", Value: "true", Description: "在线备份定时任务是否启用"}, + {Key: "backup.full_hour", Value: "4", Description: "每日全量备份小时(Asia/Shanghai)"}, + {Key: "backup.full_minute", Value: "30", Description: "每日全量备份分钟(Asia/Shanghai)"}, + {Key: "backup.binlog_interval_minutes", Value: "1", Description: "binlog 归档间隔分钟(1-5)"}, } var adminVisibleConfigKeys = []string{ diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index 5e2758a..30297bd 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -3,6 +3,7 @@ package systemconfig import ( "context" "errors" + "strconv" ) var ( @@ -24,3 +25,31 @@ func (s *Service) List(ctx context.Context) ([]ConfigDTO, error) { } return s.repo.List(ctx) } + +func (s *Service) BackupSchedule(ctx context.Context) (*BackupScheduleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + values := map[string]string{} + for _, item := range defaultConfigs { + if item.Key == "backup.schedule_enabled" || item.Key == "backup.full_hour" || item.Key == "backup.full_minute" || item.Key == "backup.binlog_interval_minutes" { + values[item.Key] = item.Value + } + } + for key := range values { + value, err := s.repo.FindValue(ctx, key) + if err != nil { + return nil, err + } + values[key] = value + } + hour, _ := strconv.Atoi(values["backup.full_hour"]) + minute, _ := strconv.Atoi(values["backup.full_minute"]) + interval, _ := strconv.Atoi(values["backup.binlog_interval_minutes"]) + return &BackupScheduleDTO{ + Enabled: values["backup.schedule_enabled"] == "true", + FullHour: hour, + FullMinute: minute, + BinlogIntervalMinutes: interval, + }, nil +} diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 6f401f5..d6549f8 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -12,6 +12,10 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up if s.repo == nil { return nil, ErrDependencyUnavailable } + // 备份定时只能通过独立接口修改,避免拥有普通系统配置权限的管理员改变备份策略。 + if strings.HasPrefix(key, "backup.") { + return nil, ErrInvalidConfig + } if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") { return nil, ErrInvalidConfig } @@ -34,3 +38,25 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up } return s.repo.Update(ctx, actorID, key, req, meta) } + +func (s *Service) UpdateBackupSchedule(ctx context.Context, actorID uint64, req BackupScheduleDTO, meta AuditMeta) (*BackupScheduleDTO, error) { + if s.repo == nil || req.FullHour < 0 || req.FullHour > 23 || req.FullMinute < 0 || req.FullMinute > 59 || req.BinlogIntervalMinutes < 1 || req.BinlogIntervalMinutes > 5 { + return nil, ErrInvalidConfig + } + updates := []struct { + key string + value string + description string + }{ + {"backup.schedule_enabled", strconv.FormatBool(req.Enabled), "在线备份定时任务是否启用"}, + {"backup.full_hour", strconv.Itoa(req.FullHour), "每日全量备份小时(Asia/Shanghai)"}, + {"backup.full_minute", strconv.Itoa(req.FullMinute), "每日全量备份分钟(Asia/Shanghai)"}, + {"backup.binlog_interval_minutes", strconv.Itoa(req.BinlogIntervalMinutes), "binlog 归档间隔分钟(1-5)"}, + } + for _, item := range updates { + if _, err := s.repo.Update(ctx, actorID, item.key, UpdateRequest{Value: item.value, Description: item.description}, meta); err != nil { + return nil, err + } + } + return &req, nil +} diff --git a/backend/internal/modules/withdrawal/repository_integration_test.go b/backend/internal/modules/withdrawal/repository_integration_test.go new file mode 100644 index 0000000..98bd2b0 --- /dev/null +++ b/backend/internal/modules/withdrawal/repository_integration_test.go @@ -0,0 +1,176 @@ +package withdrawal + +import ( + "context" + "errors" + "fmt" + "testing" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/pkg/crypto" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type withdrawalFixture struct { + db *gorm.DB + repo *Repository + userID uint64 + adminID uint64 + accountID uint64 +} + +func newWithdrawalFixture(t *testing.T) withdrawalFixture { + t.Helper() + dsn := fmt.Sprintf("file:withdrawal-%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.AdminUser{}, + &model.UserPaymentAccount{}, + &model.WithdrawalRequest{}, + &model.WalletAccount{}, + &model.WalletLedger{}, + ); err != nil { + t.Fatalf("migrate fixture: %v", err) + } + + user := model.User{Phone: "13800000001", Nickname: "test-user", Status: "active"} + admin := model.AdminUser{Username: "reviewer", PasswordHash: "test", Nickname: "reviewer", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("create admin: %v", err) + } + account := model.UserPaymentAccount{ + UserID: user.ID, AccountType: "bank", AccountName: "Test User", + AccountNo: "6222021234567890", BankName: "Test Bank", Status: "active", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create payment account: %v", err) + } + if err := db.Create(&model.WalletAccount{ + UserID: user.ID, AvailableBalanceCent: 100_000, Status: "active", + }).Error; err != nil { + t.Fatalf("create wallet: %v", err) + } + + return withdrawalFixture{ + db: db, repo: NewRepository(db, wallet.NewRepository(db), &crypto.MockEncryptor{}), + userID: user.ID, adminID: admin.ID, accountID: account.ID, + } +} + +func (f withdrawalFixture) create(t *testing.T, amountCent int64) *WithdrawalDTO { + t.Helper() + dto, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: amountCent, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + return dto +} + +func (f withdrawalFixture) assertBalance(t *testing.T, available, frozen int64) { + t.Helper() + var account model.WalletAccount + if err := f.db.Where("user_id = ?", f.userID).First(&account).Error; err != nil { + t.Fatalf("load wallet: %v", err) + } + if account.AvailableBalanceCent != available || account.FrozenBalanceCent != frozen { + t.Fatalf("balance = available:%d frozen:%d, want available:%d frozen:%d", + account.AvailableBalanceCent, account.FrozenBalanceCent, available, frozen) + } +} + +func TestWithdrawalCreateAndCancelPreservesBalance(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 20_000) + if dto.Status != "pending" || dto.FeeCent != 0 || dto.ActualAmountCent != 20_000 { + t.Fatalf("created withdrawal = %+v", dto) + } + f.assertBalance(t, 80_000, 20_000) + + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); err != nil { + t.Fatalf("Cancel: %v", err) + } + f.assertBalance(t, 100_000, 0) + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Cancel error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 100_000, 0) +} + +func TestWithdrawalRejectedReturnsFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 30_000) + approved := false + result, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{ + Approved: &approved, + Remark: "account mismatch", + }) + if err != nil { + t.Fatalf("Review rejected: %v", err) + } + if result.Status != "rejected" || result.ReviewedByName != "reviewer" { + t.Fatalf("review result = %+v", result) + } + f.assertBalance(t, 100_000, 0) + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Review error = %v, want ErrWithdrawalLocked", err) + } +} + +func TestWithdrawalApprovedAndPaidConsumesFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 40_000) + approved := true + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); err != nil { + t.Fatalf("Review approved: %v", err) + } + f.assertBalance(t, 60_000, 40_000) + + result, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{ + PaymentProofURL: "proof/test.png", + Remark: "paid", + }) + if err != nil { + t.Fatalf("ConfirmPayment: %v", err) + } + if result.Status != "completed" || result.PaidByName != "reviewer" { + t.Fatalf("payment result = %+v", result) + } + f.assertBalance(t, 60_000, 0) + if _, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second ConfirmPayment error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 60_000, 0) +} + +func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) { + f := newWithdrawalFixture(t) + _, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: 100_001, + }) + if !errors.Is(err, wallet.ErrInsufficientBalance) { + t.Fatalf("Create error = %v, want ErrInsufficientBalance", err) + } + var count int64 + if err := f.db.Model(&model.WithdrawalRequest{}).Count(&count).Error; err != nil { + t.Fatalf("count withdrawals: %v", err) + } + if count != 0 { + t.Fatalf("withdrawal count = %d, want 0", count) + } + f.assertBalance(t, 100_000, 0) +} diff --git a/backend/internal/processlog/processlog.go b/backend/internal/processlog/processlog.go new file mode 100644 index 0000000..8fc2514 --- /dev/null +++ b/backend/internal/processlog/processlog.go @@ -0,0 +1,128 @@ +// Package processlog 提供交易过程的追加式留痕能力。 +package processlog + +import ( + "encoding/json" + "strconv" + "strings" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const ( + ActorUser = "user" + ActorAdmin = "admin" + ActorSystem = "system" +) + +type Entry struct { + BusinessType string + BusinessID uint64 + Stage string + Action string + ActorType string + ActorID uint64 + TargetType string + TargetID *uint64 + Content string + Reason string + Payload map[string]any + Attachments []string + StateBefore map[string]any + StateAfter map[string]any +} + +func Append(tx *gorm.DB, entry Entry) error { + actorName, err := displayName(tx, entry.ActorType, entry.ActorID) + if err != nil { + return err + } + targetName := "" + if entry.TargetID != nil && *entry.TargetID > 0 { + targetName, err = displayName(tx, entry.TargetType, *entry.TargetID) + if err != nil { + return err + } + } + return tx.Create(&model.ProcessEvent{ + BusinessType: entry.BusinessType, + BusinessID: entry.BusinessID, + Stage: entry.Stage, + Action: entry.Action, + ActorType: entry.ActorType, + ActorID: entry.ActorID, + ActorName: actorName, + TargetType: entry.TargetType, + TargetID: entry.TargetID, + TargetName: targetName, + Content: strings.TrimSpace(entry.Content), + Reason: strings.TrimSpace(entry.Reason), + Payload: jsonValue(entry.Payload), + AttachmentURLs: jsonValue(entry.Attachments), + StateBefore: jsonValue(entry.StateBefore), + StateAfter: jsonValue(entry.StateAfter), + }).Error +} + +func List(tx *gorm.DB, businessType string, businessID uint64) ([]model.ProcessEvent, error) { + var rows []model.ProcessEvent + err := tx.Where("business_type = ? AND business_id = ?", businessType, businessID). + Order("id ASC").Find(&rows).Error + return rows, err +} + +func displayName(tx *gorm.DB, actorType string, id uint64) (string, error) { + if id == 0 || actorType == ActorSystem { + return "系统", nil + } + switch actorType { + case ActorAdmin: + var row model.AdminUser + if err := tx.Select("id", "username", "nickname").First(&row, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "客服 ID " + formatID(id), nil + } + return "", err + } + if strings.TrimSpace(row.Nickname) != "" { + return row.Nickname + "(客服)", nil + } + if strings.TrimSpace(row.Username) != "" { + return row.Username + "(客服)", nil + } + return "客服 ID " + formatID(id), nil + default: + var row model.User + if err := tx.Select("id", "nickname", "phone").First(&row, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "用户 ID " + formatID(id), nil + } + return "", err + } + if strings.TrimSpace(row.Nickname) != "" { + return row.Nickname + "(用户)", nil + } + if strings.TrimSpace(row.Phone) != "" { + return row.Phone + "(用户)", nil + } + return "用户 ID " + formatID(id), nil + } +} + +func jsonValue(value any) datatypes.JSON { + if value == nil { + return datatypes.JSON([]byte("{}")) + } + raw, err := json.Marshal(value) + if err != nil || len(raw) == 0 { + return datatypes.JSON([]byte("{}")) + } + return datatypes.JSON(raw) +} + +func formatID(id uint64) string { + return strconv.FormatUint(id, 10) +} diff --git a/backend/internal/router/admin_routes.go b/backend/internal/router/admin_routes.go new file mode 100644 index 0000000..dea53bd --- /dev/null +++ b/backend/internal/router/admin_routes.go @@ -0,0 +1,97 @@ +package router + +import ( + "hfb_sys/backend/internal/modules/adminmgr" + "hfb_sys/backend/internal/modules/adminrole" + "hfb_sys/backend/internal/modules/announcement" + "hfb_sys/backend/internal/modules/chat" + "hfb_sys/backend/internal/modules/mohong" + "hfb_sys/backend/internal/modules/payment" + "hfb_sys/backend/internal/modules/supportgroup" + + "github.com/gin-gonic/gin" +) + +type permissionMiddleware func(string) gin.HandlerFunc + +func registerCrashAdminRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, mohongHandler *mohong.Handler, paymentHandler *payment.Handler) { + if mohongHandler == nil { + return + } + register := func(prefix string) { + routes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) + routes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) + routes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) + routes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) + routes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) + routes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) + routes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) + routes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) + routes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) + routes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) + routes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) + routes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) + routes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) + routes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) + routes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) + routes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) + routes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) + } + register("/crash") + register("/mohong") +} + +type adminManagementHandlers struct { + chat *chat.Handler + supportGroup *supportgroup.Handler + role *adminrole.Handler + admin *adminmgr.Handler + announcement *announcement.Handler +} + +func registerAdminManagementRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, handlers adminManagementHandlers) { + routes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), handlers.chat.CreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), handlers.chat.BatchCreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), handlers.chat.BatchDeleteQrCodeHandler) + routes.GET("/chats/qrcodes", requirePerm("qrcode:view"), handlers.chat.ListQrCodesHandler) + routes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), handlers.chat.GetQrCodeStatsHandler) + routes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), handlers.chat.RecognizeQrCodeGroupNameHandler) + routes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), handlers.chat.SubmitOCRJobHandler) + routes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), handlers.chat.GetOCRJobResultHandler) + routes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.UpdateQrCodeHandler) + routes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.DeleteQrCodeHandler) + + routes.GET("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.List) + routes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), handlers.supportGroup.ListSupportAdmins) + routes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.FindByID) + routes.POST("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.Create) + routes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Update) + routes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Delete) + routes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), handlers.supportGroup.AssignMembers) + + routes.GET("/roles", requirePerm("role:manage"), handlers.role.List) + routes.GET("/roles/:id", requirePerm("role:manage"), handlers.role.FindByID) + routes.POST("/roles", requirePerm("role:manage"), handlers.role.Create) + routes.PUT("/roles/:id", requirePerm("role:manage"), handlers.role.Update) + routes.DELETE("/roles/:id", requirePerm("role:manage"), handlers.role.Delete) + routes.PUT("/roles/:id/permissions", requirePerm("role:manage"), handlers.role.AssignPermissions) + routes.GET("/permissions", requirePerm("role:manage"), handlers.role.ListPermissions) + + routes.GET("/admin-users", requirePerm("admin_user:manage"), handlers.admin.List) + routes.GET("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.FindByID) + routes.POST("/admin-users", requirePerm("admin_user:manage"), handlers.admin.Create) + routes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Update) + routes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Delete) + routes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), handlers.admin.AssignRoles) + routes.PUT("/admin-users/me/password", handlers.admin.ChangeOwnPassword) + routes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), handlers.admin.ResetPassword) + + routes.GET("/announcements", requirePerm("announcement:view"), handlers.announcement.AdminList) + routes.GET("/announcements/:id", requirePerm("announcement:view"), handlers.announcement.AdminGetByID) + routes.POST("/announcements", requirePerm("announcement:manage"), handlers.announcement.Create) + routes.PUT("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Update) + routes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), handlers.announcement.Publish) + routes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), handlers.announcement.Archive) + routes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), handlers.announcement.Unarchive) + routes.DELETE("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Delete) +} diff --git a/backend/internal/router/health_checks.go b/backend/internal/router/health_checks.go new file mode 100644 index 0000000..b368bf0 --- /dev/null +++ b/backend/internal/router/health_checks.go @@ -0,0 +1,29 @@ +package router + +import ( + "context" + "errors" + + "hfb_sys/backend/internal/handler" +) + +func newReadinessChecks(deps Dependencies) map[string]handler.ReadinessCheck { + return map[string]handler.ReadinessCheck{ + "mysql": func(ctx context.Context) error { + if deps.DB == nil { + return errors.New("mysql unavailable") + } + sqlDB, err := deps.DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) + }, + "redis": func(ctx context.Context) error { + if deps.Redis == nil { + return errors.New("redis unavailable") + } + return deps.Redis.Ping(ctx).Err() + }, + } +} diff --git a/backend/internal/router/providers.go b/backend/internal/router/providers.go new file mode 100644 index 0000000..e81f0f5 --- /dev/null +++ b/backend/internal/router/providers.go @@ -0,0 +1,48 @@ +package router + +import ( + "strings" + + "hfb_sys/backend/internal/config" + smsintegration "hfb_sys/backend/internal/integrations/sms" + "hfb_sys/backend/internal/modules/realname" + "hfb_sys/backend/pkg/crypto" + + "go.uber.org/zap" +) + +func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { + case "aliyun": + provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ + AccessKeyID: cfg.SMS.AliyunAccessKeyID, + AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, + Endpoint: cfg.SMS.AliyunEndpoint, + SignName: cfg.SMS.AliyunSignName, + LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, + }, logger) + if err != nil { + logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) + return smsintegration.NewUnavailableProvider(err) + } + return provider + default: + return smsintegration.NewMockProvider(logger) + } +} + +func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { + case "cloudmarket", "aliyun_cloudmarket": + provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ + URL: cfg.Realname.CloudMarketURL, AppCode: cfg.Realname.CloudMarketAppCode, Encryptor: encryptor, + }) + if err != nil { + logger.Warn("实名认证服务初始化失败", zap.Error(err)) + return realname.NewUnavailableProvider(err) + } + return provider + default: + return realname.NewMockProvider(encryptor) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index ca14100..aa5ad63 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,11 +2,9 @@ package router import ( "context" - "strings" "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" - smsintegration "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" @@ -19,6 +17,7 @@ import ( "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/announcement" "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/backupmonitor" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chathub" "hfb_sys/backend/internal/modules/dispute" @@ -60,16 +59,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.Use(middleware.RequestID()) engine.Use(middleware.RequestLogger(logger)) engine.Use(middleware.Recovery(logger)) + jwtManager := auth.NewJWTManager(cfg.JWTSecret) - health := handler.NewHealthHandler() - engine.GET("/health", health.Check) + health := handler.NewHealthHandler(newReadinessChecks(deps)) + engine.GET("/health", health.Live) + engine.GET("/health/ready", health.Ready) // Swagger 文档路由(仅在非生产环境) if !config.IsProductionEnv(cfg.AppEnv) { engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } if cfg.RateLimit.Enabled { - engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis)) + engine.Use(middleware.AdminAwareRateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis, jwtManager)) } // 业务字段加密器(实名/收款账号):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。 @@ -112,7 +113,6 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { logger.Debug("开发环境使用模拟业务字段加密器") } - jwtManager := auth.NewJWTManager(cfg.JWTSecret) var userRepo *auth.UserRepository if deps.DB != nil { userRepo = auth.NewUserRepository(deps.DB) @@ -134,7 +134,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) var adminFinanceRepo *adminfinance.Repository if deps.DB != nil { - adminFinanceRepo = adminfinance.NewRepository(deps.DB) + adminFinanceRepo = adminfinance.NewRepository(deps.DB, deps.Redis) } adminFinanceService := adminfinance.NewService(adminFinanceRepo) adminFinanceHandler := adminfinance.NewHandler(adminFinanceService) @@ -173,10 +173,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { var chatHub *chathub.Hub if deps.DB != nil { chatHub = chathub.NewHub(deps.DB) + adminUserService.SetSessionRevoker(chatHub.DisconnectUser) } var chatRepo *chat.Repository if deps.DB != nil { - chatRepo = chat.NewRepository(deps.DB, chatHub, deps.Redis) + chatRepo = chat.NewRepository(deps.DB, chatHub, deps.Redis, chat.WithLogger(logger)) } // 创建 chat 适配器用于 listing @@ -284,7 +285,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { chatHandler := chat.NewHandler(chatService) var chatHubHandler *chathub.Handler if chatHub != nil { - chatHubHandler = chathub.NewHandler(chatHub) + chatHubHandler = chathub.NewHandler(chatHub, logger) } var disputeRepo *dispute.Repository if deps.DB != nil { @@ -315,6 +316,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } distributionService := distribution.NewService(distributionRepo) distributionHandler := distribution.NewHandler(distributionService) + backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository if deps.DB != nil { adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis) @@ -374,12 +376,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } announcementService := announcement.NewService(announcementRepo) announcementHandler := announcement.NewHandler(announcementService) - var validateUserToken middleware.UserTokenValidatorFunc - if userRepo != nil { - validateUserToken = func(ctx context.Context, userID uint64, tokenVersion int64) error { - _, err := userRepo.FindActiveForToken(ctx, userID, tokenVersion) - return err + validateUserToken := func(ctx context.Context, userID uint64, tokenVersion int64) error { + if userRepo == nil { + return auth.ErrDependencyUnavailable } + _, err := userRepo.FindActiveForToken(ctx, userID, tokenVersion) + return err } requireAuth := middleware.Auth(jwtManager, validateUserToken) var validateAdminToken middleware.AdminTokenValidatorFunc @@ -403,7 +405,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api := engine.Group("/api") { - api.GET("/health", health.Check) + api.GET("/health", health.Ready) api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) api.GET("/listing-publish-agreements", systemConfigHandler.ListingPublishAgreements) api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig) @@ -431,6 +433,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes.POST("/password/reset", authHandler.ResetPassword) authRoutes.POST("/refresh", authHandler.Refresh) authRoutes.POST("/logout", requireAuth, authHandler.Logout) + if chatHubHandler != nil { + authRoutes.GET("/events", requireAuth, chatHubHandler.UserEvents) + } } api.GET("/me", requireAuth, userHandler.Me) @@ -627,6 +632,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) + adminRoutes.GET("/orders/:id/process-events", requirePerm("order:view"), orderHandler.AdminProcessEvents) adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal) adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) @@ -644,6 +650,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit) adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit) adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund) + adminRoutes.GET("/orders/refund-pending-count", requirePerm("order:close"), orderHandler.PendingRefundCount) adminRoutes.POST("/orders/:id/dispute", requirePerm("dispute:arbitrate"), disputeHandler.AdminCreateByOrder) // 管理员线下提号(独立于正常订单流程) @@ -652,6 +659,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments) + adminRoutes.GET("/pickups/:id/process-events", requirePerm("order:pickup"), pickupHandler.ProcessEvents) adminRoutes.POST("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.CreateFinancialAdjustment) adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) @@ -671,31 +679,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) - // 撞车后台(/admin/crash;兼容 /admin/mohong) - if mohongHandler != nil { - // 商品编辑需要拉分类列表,故 GET 用 product_view - registerCrashAdmin := func(prefix string) { - adminRoutes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) - adminRoutes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) - adminRoutes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) - adminRoutes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) - adminRoutes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) - adminRoutes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) - adminRoutes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) - adminRoutes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) - adminRoutes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) - adminRoutes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) - adminRoutes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) - adminRoutes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) - adminRoutes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) - adminRoutes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) - adminRoutes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) - adminRoutes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) - adminRoutes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) - } - registerCrashAdmin("/crash") - registerCrashAdmin("/mohong") - } + registerCrashAdminRoutes(adminRoutes, requirePerm, mohongHandler, paymentHandler) adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) @@ -704,6 +688,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements) adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement) adminRoutes.POST("/finance/manual-disbursements/:id/void", requirePerm("finance:manual_disbursement"), adminFinanceHandler.VoidManualDisbursement) + adminRoutes.GET("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.OperatingExpenses) + adminRoutes.POST("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.CreateOperatingExpense) + adminRoutes.POST("/finance/operating-expenses/:id/void", requirePerm("finance:operating_expense"), adminFinanceHandler.VoidOperatingExpense) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) @@ -728,6 +715,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update) + adminRoutes.GET("/backups/status", requirePerm("backup:view"), backupMonitorHandler.Status) + adminRoutes.GET("/backups/schedule", requirePerm("backup:view"), systemConfigHandler.BackupSchedule) + adminRoutes.PUT("/backups/schedule", requirePerm("backup:schedule:update"), systemConfigHandler.UpdateBackupSchedule) adminRoutes.GET("/notifications", requirePerm("notification:view"), adminNotificationHandler.List) adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount) adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead) @@ -744,6 +734,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents) } adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList) + adminRoutes.GET("/chats/counts", requirePerm("chat:view"), chatHandler.AdminCounts) + adminRoutes.POST("/chats/read-all", requirePerm("chat:view"), chatHandler.AdminMarkAllRead) adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail) adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages) adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend) @@ -760,95 +752,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/listing-group-welcome", requirePerm("system_config:view"), chatHandler.AdminGetListingGroupWelcome) adminRoutes.PUT("/chats/listing-group-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateListingGroupWelcome) - // 二维码池管理 - adminRoutes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), chatHandler.CreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteQrCodeHandler) - adminRoutes.GET("/chats/qrcodes", requirePerm("qrcode:view"), chatHandler.ListQrCodesHandler) - adminRoutes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetQrCodeStatsHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), chatHandler.RecognizeQrCodeGroupNameHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), chatHandler.SubmitOCRJobHandler) - adminRoutes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), chatHandler.GetOCRJobResultHandler) - adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.UpdateQrCodeHandler) - adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler) - - // 客服分组 - adminRoutes.GET("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.List) - adminRoutes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), supportGroupHandler.ListSupportAdmins) - adminRoutes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.FindByID) - adminRoutes.POST("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.Create) - adminRoutes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Update) - adminRoutes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Delete) - adminRoutes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), supportGroupHandler.AssignMembers) - - // 角色管理 - adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) - adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID) - adminRoutes.POST("/roles", requirePerm("role:manage"), adminRoleHandler.Create) - adminRoutes.PUT("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Update) - adminRoutes.DELETE("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Delete) - adminRoutes.PUT("/roles/:id/permissions", requirePerm("role:manage"), adminRoleHandler.AssignPermissions) - adminRoutes.GET("/permissions", requirePerm("role:manage"), adminRoleHandler.ListPermissions) - - // 管理员管理 - adminRoutes.GET("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.List) - adminRoutes.GET("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.FindByID) - adminRoutes.POST("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.Create) - adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update) - adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) - adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) - adminRoutes.PUT("/admin-users/me/password", adminMgrHandler.ChangeOwnPassword) - adminRoutes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), adminMgrHandler.ResetPassword) - - // 公告管理 - adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList) - adminRoutes.GET("/announcements/:id", requirePerm("announcement:view"), announcementHandler.AdminGetByID) - adminRoutes.POST("/announcements", requirePerm("announcement:manage"), announcementHandler.Create) - adminRoutes.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update) - adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish) - adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive) - adminRoutes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), announcementHandler.Unarchive) - adminRoutes.DELETE("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Delete) + registerAdminManagementRoutes(adminRoutes, requirePerm, adminManagementHandlers{ + chat: chatHandler, supportGroup: supportGroupHandler, role: adminRoleHandler, + admin: adminMgrHandler, announcement: announcementHandler, + }) } } return engine } - -func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { - case "aliyun": - provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ - AccessKeyID: cfg.SMS.AliyunAccessKeyID, - AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, - Endpoint: cfg.SMS.AliyunEndpoint, - SignName: cfg.SMS.AliyunSignName, - LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, - }, logger) - if err != nil { - logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) - return smsintegration.NewUnavailableProvider(err) - } - return provider - default: - return smsintegration.NewMockProvider(logger) - } -} - -func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { - case "cloudmarket", "aliyun_cloudmarket": - provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ - URL: cfg.Realname.CloudMarketURL, - AppCode: cfg.Realname.CloudMarketAppCode, - Encryptor: encryptor, - }) - if err != nil { - logger.Warn("实名认证服务初始化失败", zap.Error(err)) - return realname.NewUnavailableProvider(err) - } - return provider - default: - return realname.NewMockProvider(encryptor) - } -} diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go new file mode 100644 index 0000000..4b6e5f0 --- /dev/null +++ b/backend/internal/router/router_test.go @@ -0,0 +1,68 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/config" + + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func testConfig() config.Config { + return config.Config{AppEnv: "test", JWTSecret: "test-secret"} +} + +func TestHealthRoutesSeparateLivenessAndReadiness(t *testing.T) { + engine := New(testConfig(), Dependencies{}, zap.NewNop()) + + for _, tt := range []struct { + path string + wantStatus int + }{ + {path: "/health", wantStatus: http.StatusOK}, + {path: "/health/ready", wantStatus: http.StatusServiceUnavailable}, + {path: "/api/health", wantStatus: http.StatusServiceUnavailable}, + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, tt.path, nil) + engine.ServeHTTP(recorder, request) + if recorder.Code != tt.wantStatus { + t.Fatalf("GET %s status = %d, want %d; body=%s", tt.path, recorder.Code, tt.wantStatus, recorder.Body.String()) + } + } +} + +func TestExtractedAdminRoutesRemainRegisteredWithoutDuplicates(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:router-routes?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + engine := New(testConfig(), Dependencies{DB: db}, zap.NewNop()) + routes := engine.Routes() + registered := make(map[string]struct{}, len(routes)) + for _, route := range routes { + key := route.Method + " " + route.Path + if _, exists := registered[key]; exists { + t.Fatalf("duplicate route: %s", key) + } + registered[key] = struct{}{} + } + + for _, key := range []string{ + "GET /api/admin/crash/products", + "GET /api/admin/mohong/products", + "POST /api/admin/chats/qrcodes/batch", + "PUT /api/admin/chat-support-groups/:id/members", + "PUT /api/admin/roles/:id/permissions", + "PUT /api/admin/admin-users/:id/password", + "POST /api/admin/announcements/:id/publish", + } { + if _, exists := registered[key]; !exists { + t.Errorf("missing route after extraction: %s", key) + } + } +} diff --git a/backend/migrations/000050_backup_monitor_permissions.sql b/backend/migrations/000050_backup_monitor_permissions.sql new file mode 100644 index 0000000..fe6c166 --- /dev/null +++ b/backend/migrations/000050_backup_monitor_permissions.sql @@ -0,0 +1,35 @@ +-- +goose Up + +INSERT INTO permissions (code, name, resource, action) VALUES +('backup:view', '查看备份状态', 'backup', 'view'), +('backup:schedule:update', '修改备份定时', 'backup', 'schedule_update') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'super_admin' AND p.code IN ('backup:view', 'backup:schedule:update'); + +INSERT INTO system_configs (`key`, `value`, description) VALUES +('backup.schedule_enabled', 'true', '在线备份定时任务是否启用'), +('backup.full_hour', '4', '每日全量备份小时(Asia/Shanghai)'), +('backup.full_minute', '30', '每日全量备份分钟(Asia/Shanghai)'), +('backup.binlog_interval_minutes', '1', 'binlog 归档间隔分钟(1-5)') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM permissions WHERE code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM system_configs WHERE `key` IN ( + 'backup.schedule_enabled', + 'backup.full_hour', + 'backup.full_minute', + 'backup.binlog_interval_minutes' +); diff --git a/backend/migrations/000051_fix_external_wechat_server_region.sql b/backend/migrations/000051_fix_external_wechat_server_region.sql new file mode 100644 index 0000000..4745e27 --- /dev/null +++ b/backend/migrations/000051_fix_external_wechat_server_region.sql @@ -0,0 +1,21 @@ +-- +goose Up + +-- 修正历史外部上传中以 VX/WX 表示微信扫码、但被默认归类为 QQ 的账号。 +UPDATE game_accounts AS a +JOIN rental_listings AS l ON l.account_id = a.id +SET a.server_region = '微信' +WHERE a.server_region = 'QQ' + AND EXISTS ( + SELECT 1 + FROM listing_uploads AS lu + WHERE lu.listing_id = l.id + ) + AND ( + a.login_platform LIKE '%微信%' + OR LOWER(TRIM(a.login_platform)) LIKE 'vx%' + OR LOWER(TRIM(a.login_platform)) LIKE 'wx%' + ); + +-- +goose Down + +-- 此数据修复不可逆,避免将已修正的微信账号错误回退为 QQ。 diff --git a/backend/migrations/000052_user_token_version.sql b/backend/migrations/000052_user_token_version.sql new file mode 100644 index 0000000..3ce5e9e --- /dev/null +++ b/backend/migrations/000052_user_token_version.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE users + ADD COLUMN token_version BIGINT NOT NULL DEFAULT 0 COMMENT '用户会话版本,递增后旧令牌失效'; + +-- +goose Down + +ALTER TABLE users + DROP COLUMN token_version; diff --git a/backend/migrations/000053_manual_disbursement_custom_category.sql b/backend/migrations/000053_manual_disbursement_custom_category.sql new file mode 100644 index 0000000..092fa68 --- /dev/null +++ b/backend/migrations/000053_manual_disbursement_custom_category.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE manual_disbursements + ADD COLUMN custom_category_name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '自定义出款分类名称'; + +-- +goose Down + +ALTER TABLE manual_disbursements + DROP COLUMN custom_category_name; diff --git a/backend/migrations/000054_operating_expenses.sql b/backend/migrations/000054_operating_expenses.sql new file mode 100644 index 0000000..9cd2d48 --- /dev/null +++ b/backend/migrations/000054_operating_expenses.sql @@ -0,0 +1,44 @@ +-- +goose Up + +CREATE TABLE operating_expenses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + expense_no VARCHAR(64) NOT NULL COMMENT '运营开支单号', + category VARCHAR(50) NOT NULL COMMENT '开支类别', + payee_name VARCHAR(100) NOT NULL COMMENT '收款方或单位名称', + amount_cent BIGINT NOT NULL COMMENT '开支金额(分)', + occurred_at DATETIME NOT NULL COMMENT '实际开支时间', + remark VARCHAR(500) NOT NULL COMMENT '开支用途备注', + voucher_url VARCHAR(500) NOT NULL DEFAULT '' COMMENT '开支凭证地址', + status VARCHAR(16) NOT NULL DEFAULT 'paid' COMMENT '状态: paid已记账/voided已作废', + created_by BIGINT UNSIGNED NOT NULL COMMENT '录入管理员ID', + voided_by BIGINT UNSIGNED NULL COMMENT '作废管理员ID', + voided_at DATETIME NULL COMMENT '作废时间', + void_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '作废原因', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_operating_expenses_no (expense_no), + KEY idx_operating_expenses_occurred (status, occurred_at), + KEY idx_operating_expenses_created (created_at), + KEY idx_operating_expenses_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='运营开支记账'; + +INSERT INTO permissions (code, name, resource, action) VALUES +('finance:operating_expense', '管理运营开支', 'finance', 'operating_expense') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'finance') AND p.code = 'finance:operating_expense'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'finance:operating_expense'; + +DELETE FROM permissions WHERE code = 'finance:operating_expense'; +DROP TABLE IF EXISTS operating_expenses; diff --git a/backend/migrations/000055_manual_disbursement_custom_category_repair.sql b/backend/migrations/000055_manual_disbursement_custom_category_repair.sql new file mode 100644 index 0000000..a127d05 --- /dev/null +++ b/backend/migrations/000055_manual_disbursement_custom_category_repair.sql @@ -0,0 +1,24 @@ +-- +goose Up + +-- 本地历史数据库可能已占用 000053 版本但未包含该字段,此迁移负责补齐。 +-- +goose StatementBegin +SET @custom_category_column_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'manual_disbursements' + AND column_name = 'custom_category_name' +); +SET @custom_category_sql = IF( + @custom_category_column_exists = 0, + 'ALTER TABLE manual_disbursements ADD COLUMN custom_category_name VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''自定义出款分类名称''', + 'SELECT 1' +); +PREPARE custom_category_statement FROM @custom_category_sql; +EXECUTE custom_category_statement; +DEALLOCATE PREPARE custom_category_statement; +-- +goose StatementEnd + +-- +goose Down + +-- 补偿迁移不删除字段,避免回滚时误删由 000053 创建的列。 diff --git a/backend/migrations/000056_chat_admin_state_and_indexes.sql b/backend/migrations/000056_chat_admin_state_and_indexes.sql new file mode 100644 index 0000000..f3c24c1 --- /dev/null +++ b/backend/migrations/000056_chat_admin_state_and_indexes.sql @@ -0,0 +1,70 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS chat_admin_conversation_states ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL COMMENT '会话ID', + admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '管理员ID', + remark VARCHAR(128) NOT NULL DEFAULT '' COMMENT '管理员个人备注', + last_read_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后已读消息ID', + last_read_at DATETIME NULL COMMENT '最后已读时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_chat_admin_conversation_state (conversation_id, admin_user_id), + KEY idx_chat_admin_state_admin (admin_user_id, conversation_id), + KEY idx_chat_admin_state_read (admin_user_id, last_read_message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员会话个人状态'; + +INSERT INTO chat_admin_conversation_states ( + conversation_id, admin_user_id, remark, last_read_message_id, last_read_at +) +SELECT + cp.conversation_id, + cp.participant_id, + cp.remark, + COALESCE(( + SELECT MAX(cm.id) + FROM chat_messages cm + WHERE cm.conversation_id = cp.conversation_id + AND cp.last_read_at IS NOT NULL + AND cm.created_at <= cp.last_read_at + ), 0), + cp.last_read_at +FROM chat_participants cp +WHERE cp.participant_type = 'admin' + AND (cp.remark <> '' OR cp.last_read_at IS NOT NULL) +ON DUPLICATE KEY UPDATE + remark = VALUES(remark), + last_read_message_id = GREATEST( + chat_admin_conversation_states.last_read_message_id, + VALUES(last_read_message_id) + ), + last_read_at = CASE + WHEN chat_admin_conversation_states.last_read_at IS NULL THEN VALUES(last_read_at) + WHEN VALUES(last_read_at) IS NULL THEN chat_admin_conversation_states.last_read_at + ELSE GREATEST(chat_admin_conversation_states.last_read_at, VALUES(last_read_at)) + END; + +ALTER TABLE chat_conversations + ADD INDEX idx_chat_conversations_status_activity (status, last_message_at, id), + ADD INDEX idx_chat_conversations_type_scene_activity (type, support_scene, last_message_at, id); + +-- 后台客服列表需要按商品快速定位最新订单,避免为每个会话执行全表排序。 +ALTER TABLE rental_orders + ADD INDEX idx_rental_orders_listing_id_id (listing_id, id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE rental_orders + DROP INDEX idx_rental_orders_listing_id_id; + +ALTER TABLE chat_conversations + DROP INDEX idx_chat_conversations_type_scene_activity, + DROP INDEX idx_chat_conversations_status_activity; + +DROP TABLE IF EXISTS chat_admin_conversation_states; + +-- +goose StatementEnd diff --git a/backend/migrations/000057_chat_attention_rules.sql b/backend/migrations/000057_chat_attention_rules.sql new file mode 100644 index 0000000..a131e3e --- /dev/null +++ b/backend/migrations/000057_chat_attention_rules.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE chat_messages + ADD COLUMN admin_attention_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客服关注类型: user_inquiry用户咨询, order_paid支付成功' AFTER attachment_urls, + ADD KEY idx_chat_messages_attention (conversation_id, admin_attention_type, id); + +UPDATE chat_messages +SET admin_attention_type = 'user_inquiry' +WHERE sender_type = 'user' AND admin_attention_type = ''; + +INSERT INTO chat_admin_conversation_states ( + conversation_id, admin_user_id, remark, last_read_message_id, last_read_at +) +SELECT c.id, au.id, '', COALESCE(c.last_message_id, 0), c.last_message_at +FROM chat_conversations c +JOIN admin_users au ON au.status = 'active' +JOIN admin_user_roles aur ON aur.admin_user_id = au.id +JOIN roles r ON r.id = aur.role_id AND r.code = 'cs' +LEFT JOIN chat_admin_conversation_states cas + ON cas.conversation_id = c.id AND cas.admin_user_id = au.id +WHERE cas.id IS NULL; + +UPDATE chat_admin_conversation_states cas +JOIN chat_conversations c ON c.id = cas.conversation_id +SET cas.last_read_message_id = COALESCE(c.last_message_id, 0), + cas.last_read_at = c.last_message_at +WHERE cas.last_read_message_id = 0; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE chat_messages + DROP KEY idx_chat_messages_attention, + DROP COLUMN admin_attention_type; +-- +goose StatementEnd diff --git a/backend/migrations/000058_process_events_and_order_source.sql b/backend/migrations/000058_process_events_and_order_source.sql new file mode 100644 index 0000000..c5213ee --- /dev/null +++ b/backend/migrations/000058_process_events_and_order_source.sql @@ -0,0 +1,37 @@ +-- +goose Up + +CREATE TABLE process_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + business_type VARCHAR(32) NOT NULL, + business_id BIGINT UNSIGNED NOT NULL, + stage VARCHAR(32) NOT NULL DEFAULT '', + action VARCHAR(64) NOT NULL, + actor_type VARCHAR(16) NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + actor_name VARCHAR(128) NOT NULL DEFAULT '', + target_type VARCHAR(16) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + target_name VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NOT NULL, + reason TEXT NOT NULL, + payload JSON NOT NULL, + attachment_urls JSON NOT NULL, + state_before JSON NOT NULL, + state_after JSON NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_process_event_business (business_type, business_id, id), + KEY idx_process_event_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单与提号不可变过程留痕'; + +ALTER TABLE rental_orders + ADD COLUMN account_source VARCHAR(32) NOT NULL DEFAULT 'internal' COMMENT '订单创建时账号来源快照' AFTER account_snapshot, + ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单创建时外部来源渠道快照' AFTER account_source; + +-- +goose Down + +ALTER TABLE rental_orders + DROP COLUMN source_channel, + DROP COLUMN account_source; + +DROP TABLE IF EXISTS process_events; diff --git a/backend/migrations/000059_query_performance_snapshots.sql b/backend/migrations/000059_query_performance_snapshots.sql new file mode 100644 index 0000000..d39a0ac --- /dev/null +++ b/backend/migrations/000059_query_performance_snapshots.sql @@ -0,0 +1,52 @@ +-- +goose Up +-- 高频客服列表不再实时扫描全部订单与聊天消息;以下字段是可由业务记录重建的读取快照。 + +ALTER TABLE chat_conversations + ADD COLUMN latest_order_id BIGINT UNSIGNED NULL COMMENT '发布群最新订单ID快照' AFTER listing_id, + ADD COLUMN latest_order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布群最新订单号快照' AFTER latest_order_id, + ADD COLUMN latest_order_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新订单状态快照' AFTER latest_order_no, + ADD COLUMN latest_order_handoff_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新交接状态快照' AFTER latest_order_status, + ADD COLUMN latest_order_refund_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新退款状态快照' AFTER latest_order_handoff_status, + ADD COLUMN last_attention_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条需客服关注消息ID' AFTER latest_order_refund_status, + ADD COLUMN last_admin_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条客服消息ID' AFTER last_attention_message_id, + ADD KEY idx_chat_conversations_latest_order_status (latest_order_status, last_message_at, id); + +UPDATE chat_conversations AS c +JOIN ( + SELECT ro.id, ro.listing_id, ro.order_no, ro.status, ro.handoff_status, ro.refund_status + FROM rental_orders AS ro + JOIN ( + SELECT listing_id, MAX(id) AS id + FROM rental_orders + GROUP BY listing_id + ) AS latest ON latest.id = ro.id +) AS o ON o.listing_id = c.listing_id +SET c.latest_order_id = o.id, + c.latest_order_no = o.order_no, + c.latest_order_status = o.status, + c.latest_order_handoff_status = o.handoff_status, + c.latest_order_refund_status = o.refund_status +WHERE c.order_id IS NULL; + +UPDATE chat_conversations AS c +LEFT JOIN ( + SELECT conversation_id, + COALESCE(MAX(CASE WHEN admin_attention_type <> '' OR sender_type = 'user' THEN id END), 0) AS attention_id, + COALESCE(MAX(CASE WHEN sender_type = 'admin' THEN id END), 0) AS admin_id + FROM chat_messages + GROUP BY conversation_id +) AS m ON m.conversation_id = c.id +SET c.last_attention_message_id = COALESCE(m.attention_id, 0), + c.last_admin_message_id = COALESCE(m.admin_id, 0); + +-- +goose Down + +ALTER TABLE chat_conversations + DROP KEY idx_chat_conversations_latest_order_status, + DROP COLUMN last_admin_message_id, + DROP COLUMN last_attention_message_id, + DROP COLUMN latest_order_refund_status, + DROP COLUMN latest_order_handoff_status, + DROP COLUMN latest_order_status, + DROP COLUMN latest_order_no, + DROP COLUMN latest_order_id; diff --git a/backend/migrations/000060_finance_dashboard_indexes.sql b/backend/migrations/000060_finance_dashboard_indexes.sql new file mode 100644 index 0000000..2a1cd14 --- /dev/null +++ b/backend/migrations/000060_finance_dashboard_indexes.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- 财务仪表盘会按完成/取消日期读取提号,并按调整创建日汇总利润。 +-- 补齐复合索引,避免数据增长后统计退化为全表扫描。 +-- 该版本曾在部分开发库中部分执行,以下写法兼容已有同名索引后再次运行。 + +-- +goose StatementBegin +SET @pickup_completed_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_completed_at' +); +SET @pickup_completed_index_sql = IF( + @pickup_completed_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_completed_at (status, completed_at)', + 'SELECT 1' +); +PREPARE pickup_completed_index_statement FROM @pickup_completed_index_sql; +EXECUTE pickup_completed_index_statement; +DEALLOCATE PREPARE pickup_completed_index_statement; + +SET @pickup_cancelled_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_cancelled_at' +); +SET @pickup_cancelled_index_sql = IF( + @pickup_cancelled_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_cancelled_at (status, cancelled_at)', + 'SELECT 1' +); +PREPARE pickup_cancelled_index_statement FROM @pickup_cancelled_index_sql; +EXECUTE pickup_cancelled_index_statement; +DEALLOCATE PREPARE pickup_cancelled_index_statement; + +SET @pickup_adjustment_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickup_financial_adjustments' + AND index_name = 'idx_admin_pickup_financial_adjustments_created_at' +); +SET @pickup_adjustment_index_sql = IF( + @pickup_adjustment_index_exists = 0, + 'ALTER TABLE admin_pickup_financial_adjustments ADD KEY idx_admin_pickup_financial_adjustments_created_at (created_at)', + 'SELECT 1' +); +PREPARE pickup_adjustment_index_statement FROM @pickup_adjustment_index_sql; +EXECUTE pickup_adjustment_index_statement; +DEALLOCATE PREPARE pickup_adjustment_index_statement; +-- +goose StatementEnd + +-- +goose Down + +-- 补偿迁移不删除索引,避免回滚时误删此前已存在的同名索引。 diff --git a/backend/migrations/000061_listing_upload_uploader_date_index.sql b/backend/migrations/000061_listing_upload_uploader_date_index.sql new file mode 100644 index 0000000..aee4363 --- /dev/null +++ b/backend/migrations/000061_listing_upload_uploader_date_index.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE listing_uploads + ADD KEY idx_listing_uploads_created_admin (created_at, matched_admin_id); + +-- +goose Down + +ALTER TABLE listing_uploads + DROP KEY idx_listing_uploads_created_admin; diff --git a/backend/migrations/000062_fix_admin_pickup_collation.sql b/backend/migrations/000062_fix_admin_pickup_collation.sql new file mode 100644 index 0000000..2917fe7 --- /dev/null +++ b/backend/migrations/000062_fix_admin_pickup_collation.sql @@ -0,0 +1,18 @@ +-- +goose Up + +-- 000021/000049 创建表时未显式指定排序规则,在 MySQL 8 中会继承 +-- utf8mb4_0900_ai_ci。财务出款查询会与 utf8mb4_unicode_ci 的订单、 +-- 提现和手工出款表做 UNION,混用排序规则会触发 Error 1271。 +ALTER TABLE admin_pickups + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE admin_pickup_financial_adjustments + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- +goose Down + +ALTER TABLE admin_pickup_financial_adjustments + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +ALTER TABLE admin_pickups + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; diff --git a/backend/migrations/000063_finance_pending_disbursement_index.sql b/backend/migrations/000063_finance_pending_disbursement_index.sql new file mode 100644 index 0000000..3d6190f --- /dev/null +++ b/backend/migrations/000063_finance_pending_disbursement_index.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- 财务仪表盘的待线下出款金额不受日期限制,需要持续读取全部待出款订单。 +-- 覆盖查询条件与汇总金额,避免通过低区分度状态索引回表扫描。 +ALTER TABLE rental_orders + ADD KEY idx_rental_orders_offline_pending_amount ( + settlement_mode, + offline_settlement_status, + offline_settlement_amount_cent + ); + +-- +goose Down +ALTER TABLE rental_orders + DROP KEY idx_rental_orders_offline_pending_amount; diff --git a/deploy/README.md b/deploy/README.md index c97d804..bfdf64f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,7 +8,7 @@ https://选号网域名 # CADDY_SHOW_DOMAIN(可选),hfb_show 静态 https://monitor.主站域名 # Beszel 监控 ``` -生产部署使用 Caddy 作为公网入口,只暴露宿主机 `80`、`443` 端口。Caddy 会自动申请和续签 HTTPS 证书,并直接托管前端静态资源(主站 `dist` 与可选的选号网 `show-dist` 在构建 Caddy 镜像时打入);后端、MySQL、Redis、MinIO 均通过 Docker 内网通信。选号网与主站**共用同一 backend**,浏览器访问选号网域名下的 `/api/*` 由 Caddy 反代到本机 backend,**不会直连数据库**。 +生产部署使用 Caddy 作为公网入口,只暴露宿主机 `80`、`443` 端口。Caddy 会自动申请和续签 HTTPS 证书,并直接托管前端静态资源(主站 `dist` 与可选的选号网 `show-dist` 在构建 Caddy 镜像时打入);后端、MySQL、Redis 均通过 Docker 内网通信,图片对象存储使用阿里云 OSS。选号网与主站**共用同一 backend**,浏览器访问选号网域名下的 `/api/*` 由 Caddy 反代到本机 backend,**不会直连数据库**。 部署前请确认: @@ -32,12 +32,16 @@ MYSQL_ROOT_PASSWORD MYSQL_PASSWORD MYSQL_DSN JWT_SECRET -MINIO_ROOT_USER -MINIO_ROOT_PASSWORD STORAGE_ACCESS_KEY_ID STORAGE_SECRET_ACCESS_KEY BACKEND_UID BACKEND_GID +BACKUP_DIR +BACKUP_STATUS_DIR +BACKUP_RESTORE_DIR +BACKUP_OSS_URI +BACKUP_PASSPHRASE +BACKUP_MYSQL_PASSWORD ``` `BACKEND_UID`、`BACKEND_GID` 填写服务器业务用户的数值 ID,例如: @@ -49,29 +53,76 @@ id -g yml 后端容器会使用该 UID/GID 运行。部署脚本由 root 执行时会自动把已有日志修正为该属主;非 root 执行且发现旧的 root 日志时,会给出需要执行的 `chown` 命令后停止。 -### 迁移对象存储到阿里云 OSS +### 对象存储:阿里云 OSS -创建私有 Bucket 后,先保持 `STORAGE_*` 指向当前 MinIO,在 `backend/.env` 配置 OSS 镜像端: +图片等业务文件存储在私有 OSS Bucket,`backend/.env` 的 `STORAGE_*` 直接指向 OSS: ```text -STORAGE_MIRROR_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com -STORAGE_MIRROR_BUCKET=hfb-sys-assets -STORAGE_MIRROR_ACCESS_KEY_ID=change-oss-access-key-id -STORAGE_MIRROR_SECRET_ACCESS_KEY=change-oss-access-key-secret -STORAGE_MIRROR_REGION=cn-hangzhou -STORAGE_MIRROR_BUCKET_LOOKUP=dns +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 ``` -RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要使用阿里云主账号 AccessKey。镜像端启用后,新上传文件会同步写入 MinIO 和 OSS;任一镜像写入失败时上传会失败,避免数据库引用未同步文件。 +RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要使用阿里云主账号 AccessKey。杭州 ECS 必须使用内网 Endpoint,避免跨公网访问。 -部署支持镜像配置的镜像后,执行以下命令同步历史对象;命令可重复执行,不会删除 MinIO 数据: +历史 MinIO 数据已通过 `scripts/migrate-minio-to-oss.sh` 迁移完成,生产编排不再包含 MinIO;旧 MinIO volume 观察期结束后可删除。 + +### 数据库在线备份与时间点恢复 + +生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,调度器每分钟检查 binlog 位点,仅在有新增已提交事务时轮转并归档。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 + +部署脚本会创建或更新 `BACKUP_MYSQL_USER`,该用户只有 XtraBackup 所需的全局备份权限和业务库只读权限,不开放远程 root。 + +备份 Bucket 必须独立于业务 Bucket,开启“OSS 完全托管”服务器端加密和版本控制。备份 RAM 身份仅授予该 Bucket 的列举、读写对象权限;若填写 `BACKUP_KMS_KEY_ID`,再授予该 Key 的加解密权限。推荐配置不可变保留策略。 + +部署机需安装 `ossutil`、`openssl` 和 `flock`,并使用与 cron 相同的业务用户完成 `ossutil` 凭证配置;部署脚本会在启动服务前检查这些依赖。 ```bash -./scripts/migrate-minio-to-oss.sh -./scripts/migrate-minio-to-oss.sh --verify +# 每日全量热备,本地留存并上传 OSS 备份桶 +./scripts/backup-online.sh full + +# 检查并归档有新增事务的关闭 binlog,配合全量实现时间点恢复 +./scripts/archive-binlog.sh archive + +# 校验本地状态对应的远端完整对象 / 清理过期本地备份 +./scripts/archive-binlog.sh verify +./scripts/backup-online.sh status +./scripts/backup-online.sh prune ``` -校验通过后,将 `STORAGE_*` 改为同一组 OSS 配置,再清空所有 `STORAGE_MIRROR_*` 配置并重新部署。杭州 ECS 必须使用 `oss-cn-hangzhou-internal.aliyuncs.com`,避免跨公网访问。MinIO 保留至少 30 天后再考虑下线。 +后台“数据备份”页面只读取 `${BACKUP_STATUS_DIR}/status.json`;该目录以只读方式挂载给 backend,不包含备份载荷。首次部署后先生成状态摘要: + +```bash +./scripts/backup-status.sh write +``` + +推荐 cron(以部署用户运行,并把日志写到受限目录): + +```cron +# 每分钟读取后台定时配置,按需归档 binlog 和执行全量热备。 +* * * * * cd /srv/hfb_sys && ./scripts/backup-scheduler.sh >> /data/backups/backup-scheduler.log 2>&1 +15 5 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh prune >> /data/backups/prune.log 2>&1 +10 5 * * 0 cd /srv/hfb_sys && ./scripts/archive-binlog.sh verify >> /data/backups/archive-verify.log 2>&1 +``` + +全量备份时间、是否启用定时任务和 binlog 间隔在后台“数据备份”页面配置;宿主机 cron 必须保留每分钟的调度器入口。不要将 Docker socket、备份目录或恢复操作暴露给 backend。 + +OSS 生命周期建议:`mysql/full/daily/` 30 天、`weekly/` 60 天、`monthly/` 365 天、`mysql/binlog/` 至少 45 天。时间点恢复窗口受最早全量备份和 binlog 保留期共同限制。 + +每周必须在隔离目录做一次恢复演练,绝不向生产容器回放: + +```bash +# 从 status 输出选取一个带 complete.env 的全量备份 URI。 +./scripts/restore-online.sh prepare oss://hfb-backup/mysql/full/daily///complete.env +./scripts/restore-online.sh fetch-all /data/restore// +./scripts/restore-online.sh start /data/restore// +./scripts/restore-online.sh replay /data/restore// hfb-restore- '2026-08-16 20:00:00' +``` + +恢复容器使用 `--network none`,只可通过 `docker exec` 检查。演练结束后手动删除名为 `hfb-restore-*` 的恢复容器和 `/data/restore` 对应目录;生产容器不在恢复工具的可选目标中。 ### 同机部署选号网(hfb_show,可选) @@ -108,14 +159,13 @@ HFB_SHOW_DIR=../hfb_show 脚本会自动完成: -- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 -- 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。 +- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost`、占位值或已下线的 MinIO。 - 构建并启动生产容器。 - 使用配置的非 root UID/GID 运行后端,并检查日志目录属主。 - 通过 Caddy 自动申请或续签 HTTPS 证书。 -- 等待 MySQL、Redis、MinIO 就绪。 +- 等待 MySQL、Redis 就绪。 - 按顺序执行尚未应用的数据库迁移。 -- 重启后端并检查 `https://你的域名/api/health`。 +- 重启后端并在容器内检查 `http://127.0.0.1:8080/api/health`。默认不依赖公网 DNS、TLS 或 Caddy;如需检查公网入口,可设置 `PROD_HEALTH_URL=https://你的域名/api/health`。 如需部署完成后直接跟随查看后端日志: @@ -166,6 +216,7 @@ docker compose -f deploy/docker-compose.prod.yml run --rm --no-deps backend \ ```bash curl https://主站域名/health +curl https://主站域名/health/ready curl https://主站域名/api/health # 若启用了选号网: curl -I https://选号网域名/ diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 3c513db..c395395 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -31,7 +31,8 @@ } } - handle /health { + @health path /health /health/ready + handle @health { reverse_proxy backend:8080 { import backend_proxy } diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile index a8dd135..0150458 100644 --- a/deploy/caddy/Dockerfile +++ b/deploy/caddy/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + # 第一阶段:构建主站前端静态资源 FROM node:24-alpine AS build @@ -6,7 +8,8 @@ ENV VITE_ADMIN_BASE_PATH=${VITE_ADMIN_BASE_PATH} WORKDIR /src COPY frontend/package*.json ./ -RUN npm install +RUN --mount=type=cache,target=/root/.npm \ + npm ci --prefer-offline --no-audit --fund=false COPY frontend/ . RUN npm run build diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 7a224b8..ad50a6a 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -75,30 +75,6 @@ services: timeout: 5s retries: 10 - minio: - image: minio/minio:RELEASE.2025-09-07T16-13-09Z - restart: unless-stopped - command: server /data --console-address ":9001" - env_file: - - ../backend/.env - environment: - TZ: Asia/Shanghai - volumes: - - minio_data:/data - deploy: - resources: - limits: - memory: 512M - cpus: '0.5' - reservations: - memory: 256M - cpus: '0.25' - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 10s - timeout: 5s - retries: 10 - backend: build: context: ../backend @@ -111,6 +87,8 @@ services: TZ: Asia/Shanghai volumes: - ../backend/logs:/app/logs + # 仅挂载备份状态摘要,后端无法读取备份文件、Docker socket 或 OSS 凭证文件。 + - ${BACKUP_STATUS_DIR:-/data/backups/status}:/var/run/hfb-backup-status:ro deploy: resources: limits: @@ -124,10 +102,14 @@ services: condition: service_healthy redis: condition: service_healthy - minio: - condition: service_healthy expose: - "8080" + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null -T 5 http://127.0.0.1:8080/api/health"] + interval: 10s + timeout: 6s + retries: 12 + start_period: 10s beszel: image: henrygd/beszel:0.18.7 @@ -189,7 +171,6 @@ volumes: caddy_config: mysql_data: redis_data: - minio_data: beszel_data: beszel_socket: beszel_agent_data: diff --git a/deploy/mysql/my.cnf b/deploy/mysql/my.cnf index 4142561..c462f3d 100644 --- a/deploy/mysql/my.cnf +++ b/deploy/mysql/my.cnf @@ -8,3 +8,19 @@ default-character-set = utf8mb4 character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init-connect = 'SET NAMES utf8mb4' + +# 生产慢查询观测:应用日志记录 SQL 形状,MySQL 原生日志记录超过阈值的语句。 +# performance_schema 默认开启,使用 events_statements_summary_by_digest 做聚合分析。 +slow_query_log = ON +long_query_time = 0.5 +log_slow_admin_statements = ON + +# 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。 +# 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。 +server-id = 1 +log_bin = mysql-bin +binlog_format = ROW +binlog_row_image = FULL +binlog_expire_logs_seconds = 604800 +sync_binlog = 1 +innodb_flush_log_at_trx_commit = 1 diff --git a/docs/DIFF_FILE b/docs/DIFF_FILE new file mode 100644 index 0000000..5a8af76 --- /dev/null +++ b/docs/DIFF_FILE @@ -0,0 +1,733 @@ +diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go +index f868ff7..17da89c 100644 +--- a/backend/internal/modules/adminfinance/dashboard.go ++++ b/backend/internal/modules/adminfinance/dashboard.go +@@ -14,14 +14,12 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( +- dailyItems []FinanceDailyDTO +- pickup *PickupSummaryDTO +- mohong *MohongSummaryDTO +- disbursement *DisbursementSummaryDTO +- operatingExpense *OperatingExpenseSummaryDTO ++ daily dashboardDailyResult ++ pickup *PickupSummaryDTO ++ disbursement *DisbursementSummaryDTO + ) + var wg sync.WaitGroup +- errCh := make(chan error, 5) ++ errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { +@@ -33,7 +31,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + } + run(func() error { + var err error +- dailyItems, err = r.dailyItems(ctx, query) ++ daily, err = r.dailyItems(ctx, query) + return err + }) + run(func() error { +@@ -41,21 +39,11 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + 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 { +@@ -64,17 +52,14 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + } + } + +- summary, err := r.summary(ctx, query, pickup) +- if err != nil { +- return nil, err +- } ++ summary := summaryWithPickup(daily.Summary, pickup) + result := &DashboardDTO{ +- Summary: *summary, +- DailyItems: dailyItems, ++ Summary: summary, ++ DailyItems: daily.Items, + PickupSummary: *pickup, +- MohongSummary: *mohong, ++ MohongSummary: daily.Mohong, + DisbursementSummary: *disbursement, +- OperatingExpenseSummary: *operatingExpense, ++ OperatingExpenseSummary: daily.OperatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), + } + r.storeDashboardCache(ctx, query, result) +@@ -179,157 +164,57 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* + }, nil + } + +-func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) { +- db := r.db.WithContext(ctx) +- var payment paymentSummaryRow +- originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query) +- if err := db.Table("payment_orders AS po"). +- Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 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 partial_refund_count, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, +- payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). +- Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments). +- Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). +- Scan(&payment).Error; err != nil { +- return nil, err +- } +- +- var settlement settlementSummaryRow +- if err := db.Table("rental_orders AS ro"). +- Select(`COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, +- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, +- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, +- COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_pending_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 +- THEN 1 +- ELSE 0 +- END), 0) AS offline_settlement_pending_count, +- COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 +- OR COALESCE(p.refunding_amount_cent, 0) > 0 +- OR ro.refund_status = 'refunding' +- OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE COALESCE(w.owner_wallet_income_amount_cent, 0) +- END) >= ? +- THEN 1 +- ELSE 0 +- END), 0) AS financial_exception_count, +- COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent). +- Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). +- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). +- Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)). +- Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). +- Scan(&settlement).Error; err != nil { +- return nil, err +- } +- +- // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。 +- var estimated estimatedIncomeRow +- if err := db.Table("rental_orders"). +- Select(`COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, +- COUNT(id) AS pending_settle_order_count`). +- Where("settlement_status NOT IN ?", settledSettlementStatuses()). +- Where("status NOT IN ?", nonBillableOrderStatuses()). +- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). +- Scan(&estimated).Error; err != nil { +- return nil, err +- } +- +- pickupProfitAmountCent := int64(0) +- pickupCompletedCount := int64(0) +- if pickup != nil { +- pickupProfitAmountCent = pickup.ProfitAmountCent +- pickupCompletedCount = pickup.CompletedCount +- } +- return &FinanceSummaryDTO{ +- TotalFlowAmountCent: payment.TotalFlowAmountCent, +- TotalRefundAmountCent: payment.TotalRefundAmountCent, +- PendingRefundAmountCent: payment.PendingRefundAmountCent, +- ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent, +- PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent, +- NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent, +- PickupProfitAmountCent: pickupProfitAmountCent, +- PickupCompletedCount: pickupCompletedCount, +- OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent, +- OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent, +- OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent, +- OfflineSettlementAmountCent: settlement.OfflineSettlementAmountCent, +- OfflineSettlementPendingAmountCent: settlement.OfflineSettlementPendingAmountCent + pickup.PendingAmountCent, +- SettlementDiffAmountCent: settlement.OwnerShouldIncomeAmountCent - settlement.OwnerEffectiveSettlementAmountCent, +- EstimatedIncomeAmountCent: estimated.EstimatedIncomeAmountCent, +- SuccessfulPayCount: payment.SuccessfulPayCount, +- SuccessfulRefundCount: payment.FullRefundCount + payment.PartialRefundCount, +- FullRefundCount: payment.FullRefundCount, +- PartialRefundCount: payment.PartialRefundCount, +- PendingRefundCount: payment.PendingRefundCount, +- SettledOrderCount: settlement.SettledOrderCount, +- PendingSettleOrderCount: estimated.PendingSettleOrderCount, +- OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount + pickup.PendingCount, +- FinancialExceptionCount: settlement.FinancialExceptionCount, +- }, nil ++type dashboardDailyResult struct { ++ Items []FinanceDailyDTO ++ Summary FinanceSummaryDTO ++ Mohong MohongSummaryDTO ++ OperatingExpense OperatingExpenseSummaryDTO + } + +-func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) { ++func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { + db := r.db.WithContext(ctx) +- payments, err := r.dailyPayments(ctx, query) +- if err != nil { +- return nil, err ++ var ( ++ payments []dailyPaymentRow ++ settlements []dailySettlementRow ++ estimates []dailyEstimatedRow ++ ) ++ var wg sync.WaitGroup ++ errCh := make(chan error, 3) ++ run := func(fn func() error) { ++ wg.Add(1) ++ go func() { ++ defer wg.Done() ++ if err := fn(); err != nil { ++ errCh <- err ++ } ++ }() + } +- paidOrders, err := r.dailyPaidOrders(ctx, query) +- if err != nil { +- return nil, err ++ run(func() error { ++ var err error ++ payments, err = r.dailyPayments(ctx, query) ++ return err ++ }) ++ run(func() error { ++ var err error ++ settlements, err = r.dailySettlements(ctx, query) ++ return err ++ }) ++ run(func() error { ++ var err error ++ estimates, err = r.dailyEstimates(ctx, query) ++ return err ++ }) ++ wg.Wait() ++ close(errCh) ++ for err := range errCh { ++ if err != nil { ++ return dashboardDailyResult{}, err ++ } + } + +- settlements := make([]dailySettlementRow, 0) +- if err := db.Table("rental_orders AS ro"). +- Select(`DATE(ro.settled_at) AS date, +- COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, +- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, +- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, +- COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_pending_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 +- THEN 1 +- ELSE 0 +- END), 0) AS offline_settlement_pending_count, +- COUNT(ro.id) AS settled_order_count`). +- Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). +- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). +- Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). +- Group("DATE(ro.settled_at)"). +- Scan(&settlements).Error; err != nil { +- return nil, err ++ paidOrders, err := r.dailyPaidOrders(ctx, query) ++ if err != nil { ++ return dashboardDailyResult{}, err + } + + pickupProfits := make([]dailyPickupProfitRow, 0) +@@ -341,7 +226,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Group("DATE(completed_at)"). + Scan(&pickupProfits).Error; err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) + if err := db.Table("admin_pickup_financial_adjustments AS fa"). +@@ -351,35 +236,23 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(fa.created_at)"). + Scan(&pickupAdjustments).Error; err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 + pickupCancelled, err := r.dailyPickupCancelled(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 +- estimates := make([]dailyEstimatedRow, 0) +- if err := db.Table("rental_orders"). +- Select(`DATE(created_at) AS date, +- COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent`). +- Where("settlement_status NOT IN ?", settledSettlementStatuses()). +- Where("status NOT IN ?", nonBillableOrderStatuses()). +- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). +- Group("DATE(created_at)"). +- Scan(&estimates).Error; err != nil { +- return nil, err +- } +- + disbursements, err := r.dailyDisbursements(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + itemsByDate := make(map[string]FinanceDailyDTO) +@@ -483,7 +356,142 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + items = append(items, itemsByDate[day.Format("2006-01-02")]) + } +- return items, nil ++ return dashboardDailyResult{ ++ Items: items, ++ Summary: summaryFromDailyRows(payments, settlements, estimates), ++ Mohong: mohongSummaryFromDailyRows(payments), ++ OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), ++ }, nil ++} ++ ++func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { ++ rows := make([]dailyEstimatedRow, 0) ++ err := r.db.WithContext(ctx).Table("rental_orders"). ++ Select(`DATE(created_at) AS date, ++ COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, ++ COUNT(id) AS pending_settle_order_count`). ++ Where("settlement_status NOT IN ?", settledSettlementStatuses()). ++ Where("status NOT IN ?", nonBillableOrderStatuses()). ++ Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). ++ Group("DATE(created_at)"). ++ Scan(&rows).Error ++ return rows, err ++} ++ ++func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { ++ db := r.db.WithContext(ctx) ++ rows := make([]dailySettlementRow, 0) ++ err := db.Table("rental_orders AS ro"). ++ Select(`DATE(ro.settled_at) AS date, ++ COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, ++ COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, ++ COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, ++ COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, ++ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' ++ THEN COALESCE(ro.offline_settlement_amount_cent, 0) ++ ELSE 0 ++ END), 0) AS offline_settlement_amount_cent, ++ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' ++ AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' ++ THEN COALESCE(ro.offline_settlement_amount_cent, 0) ++ ELSE 0 ++ END), 0) AS offline_settlement_pending_amount_cent, ++ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' ++ AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' ++ AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 ++ THEN 1 ++ ELSE 0 ++ END), 0) AS offline_settlement_pending_count, ++ COALESCE(SUM(CASE WHEN 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) >= ? ++ OR EXISTS ( ++ SELECT 1 FROM payment_orders AS exception_payment ++ WHERE exception_payment.order_id = ro.id ++ AND exception_payment.biz_type IN ? ++ AND exception_payment.status IN ? ++ AND exception_payment.amount_cent > 0 ++ ) ++ THEN 1 ++ ELSE 0 ++ END), 0) AS financial_exception_count, ++ COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}). ++ Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). ++ Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). ++ Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). ++ Group("DATE(ro.settled_at)"). ++ Scan(&rows).Error ++ return rows, err ++} ++ ++func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { ++ var summary FinanceSummaryDTO ++ for _, row := range payments { ++ summary.TotalFlowAmountCent += row.TotalFlowAmountCent ++ summary.TotalRefundAmountCent += row.TotalRefundAmountCent ++ summary.PendingRefundAmountCent += row.PendingRefundAmountCent ++ summary.SuccessfulPayCount += row.SuccessfulPayCount ++ summary.FullRefundCount += row.FullRefundCount ++ summary.PartialRefundCount += row.PartialRefundCount ++ summary.PendingRefundCount += row.PendingRefundCount ++ } ++ for _, row := range settlements { ++ summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent ++ summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent ++ summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent ++ summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent ++ summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent ++ summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent ++ summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount ++ summary.FinancialExceptionCount += row.FinancialExceptionCount ++ summary.SettledOrderCount += row.SettledOrderCount ++ } ++ for _, row := range estimates { ++ summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent ++ summary.PendingSettleOrderCount += row.PendingSettleOrderCount ++ } ++ summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent ++ summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount ++ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent ++ summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent ++ return summary ++} ++ ++func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { ++ if pickup == nil { ++ return summary ++ } ++ summary.PickupProfitAmountCent = pickup.ProfitAmountCent ++ summary.PickupCompletedCount = pickup.CompletedCount ++ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent ++ summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent ++ summary.OfflineSettlementPendingCount += pickup.PendingCount ++ return summary ++} ++ ++func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { ++ var summary MohongSummaryDTO ++ for _, row := range rows { ++ summary.FlowAmountCent += row.MohongFlowAmountCent ++ summary.RefundAmountCent += row.MohongRefundAmountCent ++ summary.PaidOrderCount += row.MohongPaidOrderCount ++ } ++ summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent ++ return summary ++} ++ ++func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { ++ var summary OperatingExpenseSummaryDTO ++ for _, row := range rows { ++ summary.AmountCent += row.AmountCent ++ summary.Count += row.Count ++ } ++ return summary + } + + // dailyPayments 按支付单创建日统计收付款流水与退款笔数, +@@ -500,7 +508,10 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ + 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 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 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 = '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, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). + Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). +@@ -541,33 +552,6 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) + return items, err + } + +-type paymentSummaryRow struct { +- TotalFlowAmountCent int64 +- TotalRefundAmountCent int64 +- PendingRefundAmountCent int64 +- SuccessfulPayCount int64 +- FullRefundCount int64 +- PartialRefundCount int64 +- PendingRefundCount int64 +-} +- +-type settlementSummaryRow struct { +- PlatformIncomeAmountCent int64 +- OwnerShouldIncomeAmountCent int64 +- OwnerWalletIncomeAmountCent int64 +- OwnerEffectiveSettlementAmountCent int64 +- OfflineSettlementAmountCent int64 +- OfflineSettlementPendingAmountCent int64 +- OfflineSettlementPendingCount int64 +- FinancialExceptionCount int64 +- SettledOrderCount int64 +-} +- +-type estimatedIncomeRow struct { +- EstimatedIncomeAmountCent int64 +- PendingSettleOrderCount int64 +-} +- + type dailyPaymentRow struct { + Date string + TotalFlowAmountCent int64 +@@ -578,6 +562,9 @@ type dailyPaymentRow struct { + PartialRefundCount int64 + PendingRefundCount int64 + NormalFullRefundCount int64 ++ MohongFlowAmountCent int64 ++ MohongRefundAmountCent int64 ++ MohongPaidOrderCount int64 + } + + type dailyPickupCancelledRow struct { +@@ -599,6 +586,7 @@ type dailySettlementRow struct { + OfflineSettlementAmountCent int64 + OfflineSettlementPendingAmountCent int64 + OfflineSettlementPendingCount int64 ++ FinancialExceptionCount int64 + SettledOrderCount int64 + } + +@@ -617,4 +605,5 @@ type dailyPickupAdjustmentRow struct { + type dailyEstimatedRow struct { + Date string + EstimatedIncomeAmountCent int64 ++ PendingSettleOrderCount int64 + } +diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go +index a0821c2..1ce17f2 100644 +--- a/backend/internal/modules/adminfinance/dashboard_test.go ++++ b/backend/internal/modules/adminfinance/dashboard_test.go +@@ -321,10 +321,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + if err != nil { + t.Fatalf("读取提号汇总失败: %v", err) + } +- summary, err := repo.summary(t.Context(), query, pickup) ++ settlements, err := repo.dailySettlements(t.Context(), query) + if err != nil { +- t.Fatalf("读取财务汇总失败: %v", err) ++ t.Fatalf("读取每日结算汇总失败: %v", err) + } ++ summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup) + if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { + t.Fatalf("普通订单数量或利润错误: %+v", summary) + } +@@ -332,3 +333,39 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) + } + } ++ ++func TestSummaryFromDailyRowsPreservesDashboardTotals(t *testing.T) { ++ paymentRows := []dailyPaymentRow{ ++ {TotalFlowAmountCent: 1000, TotalRefundAmountCent: 100, PendingRefundAmountCent: 30, SuccessfulPayCount: 3, FullRefundCount: 1}, ++ {TotalFlowAmountCent: 2500, TotalRefundAmountCent: 400, PendingRefundAmountCent: 70, SuccessfulPayCount: 5, PartialRefundCount: 2, PendingRefundCount: 1}, ++ } ++ settlementRows := []dailySettlementRow{ ++ {PlatformIncomeAmountCent: 300, OwnerShouldIncomeAmountCent: 900, OwnerWalletIncomeAmountCent: 850, OwnerEffectiveSettlementAmountCent: 850, OfflineSettlementAmountCent: 100, OfflineSettlementPendingAmountCent: 20, OfflineSettlementPendingCount: 1, FinancialExceptionCount: 2, SettledOrderCount: 3}, ++ {PlatformIncomeAmountCent: 200, OwnerShouldIncomeAmountCent: 600, OwnerWalletIncomeAmountCent: 500, OwnerEffectiveSettlementAmountCent: 550, OfflineSettlementAmountCent: 200, OfflineSettlementPendingAmountCent: 40, OfflineSettlementPendingCount: 2, FinancialExceptionCount: 1, SettledOrderCount: 2}, ++ } ++ estimateRows := []dailyEstimatedRow{ ++ {EstimatedIncomeAmountCent: 120, PendingSettleOrderCount: 4}, ++ {EstimatedIncomeAmountCent: 80, PendingSettleOrderCount: 3}, ++ } ++ pickup := &PickupSummaryDTO{ProfitAmountCent: 90, CompletedCount: 2, PendingAmountCent: 60, PendingCount: 3} ++ ++ summary := summaryWithPickup(summaryFromDailyRows(paymentRows, settlementRows, estimateRows), pickup) ++ if summary.TotalFlowAmountCent != 3500 || summary.TotalRefundAmountCent != 500 || summary.ChannelNetAmountCent != 3000 { ++ t.Fatalf("支付汇总错误: %+v", summary) ++ } ++ if summary.PendingRefundAmountCent != 100 || summary.SuccessfulPayCount != 8 || summary.SuccessfulRefundCount != 3 || summary.FullRefundCount != 1 || summary.PartialRefundCount != 2 || summary.PendingRefundCount != 1 { ++ t.Fatalf("支付笔数汇总错误: %+v", summary) ++ } ++ if summary.NormalOrderProfitAmountCent != 500 || summary.PickupProfitAmountCent != 90 || summary.PlatformIncomeAmountCent != 590 || summary.PickupCompletedCount != 2 { ++ t.Fatalf("平台利润汇总错误: %+v", summary) ++ } ++ if summary.OwnerShouldIncomeAmountCent != 1500 || summary.OwnerWalletIncomeAmountCent != 1350 || summary.OwnerEffectiveSettlementAmountCent != 1400 || summary.SettlementDiffAmountCent != 100 { ++ t.Fatalf("货主结算汇总错误: %+v", summary) ++ } ++ if summary.OfflineSettlementAmountCent != 300 || summary.OfflineSettlementPendingAmountCent != 120 || summary.OfflineSettlementPendingCount != 6 { ++ t.Fatalf("线下结算汇总错误: %+v", summary) ++ } ++ if summary.EstimatedIncomeAmountCent != 200 || summary.PendingSettleOrderCount != 7 || summary.SettledOrderCount != 5 || summary.FinancialExceptionCount != 3 { ++ t.Fatalf("订单状态汇总错误: %+v", summary) ++ } ++} +diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go +index f4d40ee..ce4feca 100644 +--- a/backend/internal/modules/adminfinance/detail.go ++++ b/backend/internal/modules/adminfinance/detail.go +@@ -19,11 +19,23 @@ func (r *Repository) Details(ctx context.Context, query DetailQuery) (*Paginated + return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil + } + +- // 明细行查询保留完整 JOIN,仅对当前分页命中的订单做金额聚合。 +- db := r.financeDetailBaseQuery(ctx, query) + offset := (query.Page - 1) * query.PageSize ++ orderIDs := make([]uint64, 0, query.PageSize) ++ if err := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query). ++ Order("ro.id DESC"). ++ Offset(offset). ++ Limit(query.PageSize). ++ Pluck("ro.id", &orderIDs).Error; err != nil { ++ return nil, err ++ } ++ if len(orderIDs) == 0 { ++ return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: total, Page: query.Page, PageSize: query.PageSize}, nil ++ } ++ ++ // 先取当前页 ID,再只为这些订单聚合结账、支付和钱包详情。 ++ db := r.financeDetailBaseQuery(ctx, query, orderIDs).Where("ro.id IN ?", orderIDs) + rows := make([]financeDetailRow, 0, query.PageSize) +- if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { ++ if err := db.Order("ro.id DESC").Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]FinanceDetailDTO, 0, len(rows)) +@@ -67,7 +79,7 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB + return db + } + +-func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery) *gorm.DB { ++func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery, orderIDs []uint64) *gorm.DB { + db := r.db.WithContext(ctx).Table("rental_orders AS ro"). + Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status, + ro.handoff_mode, ro.settlement_mode, +@@ -113,9 +125,9 @@ func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQue + ro.created_at, ro.settled_at, ro.offline_settled_at`, settlementDiffThresholdCent). + Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id"). + Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id"). +- Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db.WithContext(ctx))). +- Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db.WithContext(ctx))). +- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db.WithContext(ctx))) ++ Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). ++ Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). ++ Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForOrdersSubquery(r.db.WithContext(ctx), orderIDs)) + + return r.applyDetailFilters(db, query) + } +diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go +index 18be7d9..6759d09 100644 +--- a/backend/internal/modules/adminfinance/subquery.go ++++ b/backend/internal/modules/adminfinance/subquery.go +@@ -4,14 +4,16 @@ import ( + "gorm.io/gorm" + ) + +-func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB { +- latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id") ++func acceptedCheckoutForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { ++ latest := db.Table("order_checkouts").Select("MAX(id) AS id"). ++ Where("status = ? AND order_id IN ?", "accepted", orderIDs). ++ Group("order_id") + return db.Table("order_checkouts AS oc"). + Select("oc.*"). + Joins("JOIN (?) AS latest ON latest.id = oc.id", latest) + } + +-func orderPaymentSubquery(db *gorm.DB) *gorm.DB { ++func orderPaymentForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + return db.Table("payment_orders"). + Select(`order_id, + COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, +@@ -19,13 +21,14 @@ func orderPaymentSubquery(db *gorm.DB) *gorm.DB { + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, + refundBizTypes(), refundBizTypes(), refundBizTypes()). ++ Where("order_id IN ?", orderIDs). + Group("order_id") + } + +-func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { ++func ownerWalletIncomeForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + return db.Table("wallet_ledger"). + Select("order_id, COALESCE(SUM(amount_cent), 0) AS owner_wallet_income_amount_cent"). +- Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}). ++ Where("direction = ? AND biz_type IN ? AND order_id IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs). + Group("order_id") + } + +@@ -44,18 +47,6 @@ func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery + 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 { +diff --git a/backend/migrations/000063_finance_pending_disbursement_index.sql b/backend/migrations/000063_finance_pending_disbursement_index.sql +new file mode 100644 +index 0000000..3d6190f +--- /dev/null ++++ b/backend/migrations/000063_finance_pending_disbursement_index.sql +@@ -0,0 +1,13 @@ ++-- +goose Up ++-- 财务仪表盘的待线下出款金额不受日期限制,需要持续读取全部待出款订单。 ++-- 覆盖查询条件与汇总金额,避免通过低区分度状态索引回表扫描。 ++ALTER TABLE rental_orders ++ ADD KEY idx_rental_orders_offline_pending_amount ( ++ settlement_mode, ++ offline_settlement_status, ++ offline_settlement_amount_cent ++ ); ++ ++-- +goose Down ++ALTER TABLE rental_orders ++ DROP KEY idx_rental_orders_offline_pending_amount; diff --git a/docs/MODIFIED_FILE b/docs/MODIFIED_FILE new file mode 100644 index 0000000..17da89c --- /dev/null +++ b/docs/MODIFIED_FILE @@ -0,0 +1,609 @@ +package adminfinance + +import ( + "context" + "sync" + + "hfb_sys/backend/internal/timeutil" +) + +func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { + if cached := r.loadDashboardCache(ctx, query); cached != nil { + return cached, nil + } + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( + daily dashboardDailyResult + pickup *PickupSummaryDTO + disbursement *DisbursementSummaryDTO + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + 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 + daily, 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 + disbursement, err = r.disbursementSummary(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return nil, err + } + } + + summary := summaryWithPickup(daily.Summary, pickup) + result := &DashboardDTO{ + Summary: summary, + DailyItems: daily.Items, + PickupSummary: *pickup, + MohongSummary: daily.Mohong, + DisbursementSummary: *disbursement, + OperatingExpenseSummary: daily.OperatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), + } + r.storeDashboardCache(ctx, query, result) + return result, nil +} + +func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) { + var summary OperatingExpenseSummaryDTO + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count"). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Scan(&summary).Error + if err != nil { + return nil, err + } + return &summary, nil +} + +// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 +func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) { + db := r.db.WithContext(ctx) + var row struct { + SettledAmountCent int64 + ProfitAmountCent int64 + CompletedCount int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS settled_amount_cent, COALESCE(SUM(profit_amount_cent), 0) AS profit_amount_cent, COUNT(id) AS completed_count`). + Where("status = ?", "completed"). + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Scan(&row).Error; err != nil { + return nil, err + } + var adjustments struct { + SettledAmountCent int64 + ProfitAmountCent int64 + } + if err := db.Table("admin_pickup_financial_adjustments AS fa"). + Select(`COALESCE(SUM(fa.settle_delta_cent), 0) AS settled_amount_cent, + COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`). + Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed"). + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Scan(&adjustments).Error; err != nil { + return nil, err + } + var inProgress int64 + if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil { + return nil, err + } + var pending struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND offline_settlement_status = ? AND settle_amount_cent > 0", "platform_managed", "pending"). + Scan(&pending).Error; err != nil { + return nil, err + } + var pendingAdjustments struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickup_financial_adjustments"). + Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND status = ? AND settle_delta_cent > 0", "platform_managed", "pending"). + Scan(&pendingAdjustments).Error; err != nil { + return nil, err + } + return &PickupSummaryDTO{ + SettledAmountCent: row.SettledAmountCent + adjustments.SettledAmountCent, + ProfitAmountCent: row.ProfitAmountCent + adjustments.ProfitAmountCent, + CompletedCount: row.CompletedCount, + InProgressCount: inProgress, + PendingAmountCent: pending.AmountCent + pendingAdjustments.AmountCent, + PendingCount: pending.Count + pendingAdjustments.Count, + }, nil +} + +// mohongSummary 撞车商城按支付订单统计交易流水,不计入平台利润。 +func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*MohongSummaryDTO, error) { + db := r.db.WithContext(ctx) + var row struct { + FlowAmountCent int64 + RefundAmountCent int64 + PaidOrderCount int64 + } + if err := db.Table("payment_orders"). + Select(`COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS flow_amount_cent, + COALESCE(SUM(CASE WHEN biz_type = 'mohong_refund' AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_order_count`). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Scan(&row).Error; err != nil { + return nil, err + } + return &MohongSummaryDTO{ + FlowAmountCent: row.FlowAmountCent, + RefundAmountCent: row.RefundAmountCent, + NetAmountCent: row.FlowAmountCent - row.RefundAmountCent, + PaidOrderCount: row.PaidOrderCount, + }, nil +} + +type dashboardDailyResult struct { + Items []FinanceDailyDTO + Summary FinanceSummaryDTO + Mohong MohongSummaryDTO + OperatingExpense OperatingExpenseSummaryDTO +} + +func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { + db := r.db.WithContext(ctx) + var ( + payments []dailyPaymentRow + settlements []dailySettlementRow + estimates []dailyEstimatedRow + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + 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 + payments, err = r.dailyPayments(ctx, query) + return err + }) + run(func() error { + var err error + settlements, err = r.dailySettlements(ctx, query) + return err + }) + run(func() error { + var err error + estimates, err = r.dailyEstimates(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return dashboardDailyResult{}, err + } + } + + paidOrders, err := r.dailyPaidOrders(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + pickupProfits := make([]dailyPickupProfitRow, 0) + if err := db.Table("admin_pickups"). + Select(`DATE(completed_at) AS date, + COALESCE(SUM(profit_amount_cent), 0) AS profit_amount_cent, + COUNT(id) AS completed_count`). + Where("status = ?", "completed"). + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Group("DATE(completed_at)"). + Scan(&pickupProfits).Error; err != nil { + return dashboardDailyResult{}, err + } + pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) + if err := db.Table("admin_pickup_financial_adjustments AS fa"). + Select(`DATE(fa.created_at) AS date, + COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`). + Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed"). + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(fa.created_at)"). + Scan(&pickupAdjustments).Error; err != nil { + return dashboardDailyResult{}, err + } + + // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 + pickupCancelled, err := r.dailyPickupCancelled(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 + disbursements, err := r.dailyDisbursements(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + itemsByDate := make(map[string]FinanceDailyDTO) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + itemsByDate[date] = FinanceDailyDTO{Date: date} + } + for _, row := range payments { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.TotalFlowAmountCent = row.TotalFlowAmountCent + item.TotalRefundAmountCent = row.TotalRefundAmountCent + item.PendingRefundAmountCent = row.PendingRefundAmountCent + item.ChannelNetAmountCent = row.TotalFlowAmountCent - row.TotalRefundAmountCent + item.SuccessfulPayCount = row.SuccessfulPayCount + item.SuccessfulRefundCount = row.FullRefundCount + row.PartialRefundCount + item.FullRefundCount = row.FullRefundCount + item.PartialRefundCount = row.PartialRefundCount + item.PendingRefundCount = row.PendingRefundCount + item.NormalFullRefundCount = row.NormalFullRefundCount + itemsByDate[date] = item + } + for _, row := range paidOrders { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PaidOrderCount = row.PaidOrderCount + itemsByDate[date] = item + } + for _, row := range settlements { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent = row.PlatformIncomeAmountCent + item.OwnerShouldIncomeAmountCent = row.OwnerShouldIncomeAmountCent + item.OwnerWalletIncomeAmountCent = row.OwnerWalletIncomeAmountCent + item.OwnerEffectiveSettlementAmountCent = row.OwnerEffectiveSettlementAmountCent + item.OfflineSettlementAmountCent = row.OfflineSettlementAmountCent + item.OfflineSettlementPendingAmountCent = row.OfflineSettlementPendingAmountCent + item.SettlementDiffAmountCent = row.OwnerShouldIncomeAmountCent - row.OwnerEffectiveSettlementAmountCent + item.SettledOrderCount = row.SettledOrderCount + item.SalesCount = row.SettledOrderCount + item.OfflineSettlementPendingCount = row.OfflineSettlementPendingCount + itemsByDate[date] = item + } + for _, row := range pickupProfits { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent += row.ProfitAmountCent + item.PickupCompletedCount = row.CompletedCount + item.SalesCount += row.CompletedCount + itemsByDate[date] = item + } + for _, row := range pickupAdjustments { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent += row.ProfitAmountCent + itemsByDate[date] = item + } + for _, row := range pickupCancelled { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PickupCancelledCount = row.Count + itemsByDate[date] = item + } + for _, row := range estimates { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.EstimatedIncomeAmountCent = row.EstimatedIncomeAmountCent + itemsByDate[date] = item + } + for _, row := range disbursements { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OfflineSettlementPaidAmountCent = row.OfflineSettlementPaidAmountCent + item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount + item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent + item.WithdrawalPaidCount = row.WithdrawalPaidCount + item.ManualPaidAmountCent = row.ManualPaidAmountCent + item.ManualPaidCount = row.ManualPaidCount + item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent + item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount + itemsByDate[date] = item + } + for _, row := range operatingExpenses { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OperatingExpenseAmountCent = row.AmountCent + item.OperatingExpenseCount = row.Count + itemsByDate[date] = item + } + + items := make([]FinanceDailyDTO, 0, len(itemsByDate)) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + items = append(items, itemsByDate[day.Format("2006-01-02")]) + } + return dashboardDailyResult{ + Items: items, + Summary: summaryFromDailyRows(payments, settlements, estimates), + Mohong: mohongSummaryFromDailyRows(payments), + OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), + }, nil +} + +func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { + rows := make([]dailyEstimatedRow, 0) + err := r.db.WithContext(ctx).Table("rental_orders"). + Select(`DATE(created_at) AS date, + COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, + COUNT(id) AS pending_settle_order_count`). + Where("settlement_status NOT IN ?", settledSettlementStatuses()). + Where("status NOT IN ?", nonBillableOrderStatuses()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(created_at)"). + Scan(&rows).Error + return rows, err +} + +func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { + db := r.db.WithContext(ctx) + rows := make([]dailySettlementRow, 0) + err := db.Table("rental_orders AS ro"). + Select(`DATE(ro.settled_at) AS date, + COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, + COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, + COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_pending_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 + THEN 1 + ELSE 0 + END), 0) AS offline_settlement_pending_count, + COALESCE(SUM(CASE WHEN 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) >= ? + OR EXISTS ( + SELECT 1 FROM payment_orders AS exception_payment + WHERE exception_payment.order_id = ro.id + AND exception_payment.biz_type IN ? + AND exception_payment.status IN ? + AND exception_payment.amount_cent > 0 + ) + THEN 1 + ELSE 0 + END), 0) AS financial_exception_count, + COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}). + Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). + Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(ro.settled_at)"). + Scan(&rows).Error + return rows, err +} + +func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { + var summary FinanceSummaryDTO + for _, row := range payments { + summary.TotalFlowAmountCent += row.TotalFlowAmountCent + summary.TotalRefundAmountCent += row.TotalRefundAmountCent + summary.PendingRefundAmountCent += row.PendingRefundAmountCent + summary.SuccessfulPayCount += row.SuccessfulPayCount + summary.FullRefundCount += row.FullRefundCount + summary.PartialRefundCount += row.PartialRefundCount + summary.PendingRefundCount += row.PendingRefundCount + } + for _, row := range settlements { + summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent + summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent + summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent + summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent + summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent + summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent + summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount + summary.FinancialExceptionCount += row.FinancialExceptionCount + summary.SettledOrderCount += row.SettledOrderCount + } + for _, row := range estimates { + summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent + summary.PendingSettleOrderCount += row.PendingSettleOrderCount + } + summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent + summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent + return summary +} + +func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { + if pickup == nil { + return summary + } + summary.PickupProfitAmountCent = pickup.ProfitAmountCent + summary.PickupCompletedCount = pickup.CompletedCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent + summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent + summary.OfflineSettlementPendingCount += pickup.PendingCount + return summary +} + +func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { + var summary MohongSummaryDTO + for _, row := range rows { + summary.FlowAmountCent += row.MohongFlowAmountCent + summary.RefundAmountCent += row.MohongRefundAmountCent + summary.PaidOrderCount += row.MohongPaidOrderCount + } + summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent + return summary +} + +func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { + var summary OperatingExpenseSummaryDTO + for _, row := range rows { + summary.AmountCent += row.AmountCent + summary.Count += row.Count + } + return summary +} + +// dailyPayments 按支付单创建日统计收付款流水与退款笔数, +// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 +func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { + rows := make([]dailyPaymentRow, 0) + db := r.db.WithContext(ctx) + err := db.Table("payment_orders AS po"). + Select(`DATE(po.created_at) AS date, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 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 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 = '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 = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). + Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(po.created_at)"). + Scan(&rows).Error + return rows, err +} + +// dailyPickupCancelled 按取消日统计线下提号取消笔数。 +func (r *Repository) dailyPickupCancelled(ctx context.Context, query DashboardQuery) ([]dailyPickupCancelledRow, error) { + rows := make([]dailyPickupCancelledRow, 0) + err := r.db.WithContext(ctx).Table("admin_pickups"). + Select(`DATE(cancelled_at) AS date, COUNT(id) AS count`). + Where("status = ?", "cancelled"). + Where("cancelled_at >= ? AND cancelled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(cancelled_at)"). + Scan(&rows).Error + return rows, err +} + +// dailyPaidOrders 按实际付款日统计普通租赁订单,并将已完成的线下提号按完成日计入成交。 +func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) ([]dailyPaidOrderRow, error) { + items := make([]dailyPaidOrderRow, 0) + err := r.db.WithContext(ctx).Table(`( + SELECT DATE(paid_at) AS date, COUNT(DISTINCT order_id) AS paid_order_count + FROM payment_orders + WHERE biz_type = 'order_pay' AND status = 'paid' AND paid_at >= ? AND paid_at <= ? + GROUP BY DATE(paid_at) + UNION ALL + SELECT DATE(completed_at) AS date, COUNT(id) AS paid_order_count + FROM admin_pickups + WHERE status = 'completed' AND completed_at >= ? AND completed_at <= ? + GROUP BY DATE(completed_at) + ) AS daily_paid_orders`, query.StartDate, query.EndDate, query.StartDate, query.EndDate). + Select("date, SUM(paid_order_count) AS paid_order_count"). + Group("date"). + Scan(&items).Error + return items, err +} + +type dailyPaymentRow struct { + Date string + TotalFlowAmountCent int64 + TotalRefundAmountCent int64 + PendingRefundAmountCent int64 + SuccessfulPayCount int64 + FullRefundCount int64 + PartialRefundCount int64 + PendingRefundCount int64 + NormalFullRefundCount int64 + MohongFlowAmountCent int64 + MohongRefundAmountCent int64 + MohongPaidOrderCount int64 +} + +type dailyPickupCancelledRow struct { + Date string + Count int64 +} + +type dailyPaidOrderRow struct { + Date string + PaidOrderCount int64 +} + +type dailySettlementRow struct { + Date string + PlatformIncomeAmountCent int64 + OwnerShouldIncomeAmountCent int64 + OwnerWalletIncomeAmountCent int64 + OwnerEffectiveSettlementAmountCent int64 + OfflineSettlementAmountCent int64 + OfflineSettlementPendingAmountCent int64 + OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 + SettledOrderCount int64 +} + +type dailyPickupProfitRow struct { + Date string + ProfitAmountCent int64 + CompletedCount int64 +} + +// dailyPickupAdjustmentRow 调整按调整创建日进入当日利润,避免回写历史完成日报。 +type dailyPickupAdjustmentRow struct { + Date string + ProfitAmountCent int64 +} + +type dailyEstimatedRow struct { + Date string + EstimatedIncomeAmountCent int64 + PendingSettleOrderCount int64 +} diff --git a/docs/ROLLBACK.sh b/docs/ROLLBACK.sh new file mode 100755 index 0000000..ce3f262 --- /dev/null +++ b/docs/ROLLBACK.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +target=${1:?用法: ROLLBACK.sh <待恢复文件>} +original=/tmp/hfb-finance-dashboard-optimization/dashboard.go.original +test -f "$original" +cp "$original" "$target" diff --git a/docs/VERIFICATION.txt b/docs/VERIFICATION.txt new file mode 100644 index 0000000..a595f5d --- /dev/null +++ b/docs/VERIFICATION.txt @@ -0,0 +1,34 @@ +性能优化验证记录 + +变更分支/字段:main;财务仪表盘重复聚合、财务明细分页 order_id 两阶段查询、rental_orders 待线下出款覆盖索引。 +原始 dashboard.go SHA-256:ecc1df350998a45778f3cf607a25ed75d67deb23dd6b1d04ec6cef5e5131c752 + +四个验证产物: +/Users/yml/codes/hfb_sys/docs/MODIFIED_FILE +/Users/yml/codes/hfb_sys/docs/DIFF_FILE +/Users/yml/codes/hfb_sys/docs/VERIFICATION.txt +/Users/yml/codes/hfb_sys/docs/ROLLBACK.sh + +BASELINE +命令:HFB_ADMIN_FINANCE_TEST_DSN='hfb:secret@tcp(127.0.0.1:13306)/hfb_finance_perf?charset=utf8mb4&parseTime=True&loc=Local' HFB_ADMIN_FINANCE_PROFILE_OUTPUT='/tmp/hfb-finance-dashboard-optimization/baseline.json' go test ./internal/modules/adminfinance -run '^TestProfileDashboardOnMySQL$' -count=1 -v +输入:50 万 rental_orders、50 万 order_checkouts、60 万 payment_orders、100 万 wallet_ledger,时间分布 70 天,查询 2026-08-29 至 2026-09-04。 +字面输出/结果:dashboard duration=7.936473625s daily_items=7;PASS;退出状态 0。 + +MODIFIED +命令:HFB_ADMIN_FINANCE_TEST_DSN='hfb:secret@tcp(127.0.0.1:13306)/hfb_finance_perf?charset=utf8mb4&parseTime=True&loc=Local' HFB_ADMIN_FINANCE_PROFILE_OUTPUT='/tmp/hfb-finance-dashboard-optimization/modified.json' go test ./internal/modules/adminfinance -run '^TestProfileDashboardOnMySQL$' -count=1 -v +输入:与 BASELINE 相同数据库、相同 7 天区间。 +字面输出/结果:dashboard duration=1.625048875s daily_items=7;PASS;退出状态 0;cmp -s baseline.json modified.json 的退出状态 0;SQL 数 31 -> 26。 + +ROLLBACK +命令:cp docs/MODIFIED_FILE /tmp/hfb-dashboard-rollback-copy.go && ./docs/ROLLBACK.sh /tmp/hfb-dashboard-rollback-copy.go && cmp /tmp/hfb-dashboard-rollback-copy.go /tmp/hfb-finance-dashboard-optimization/dashboard.go.original +输入:优化后的 MODIFIED_FILE 副本。 +字面输出/结果:rollback_cmp_exit=0;退出状态 0;副本恢复为原始 dashboard.go;MODIFIED_FILE 保持优化后行为和内容。 + +TESTS +命令:go test ./...;go vet ./... +字面输出/结果:后端全量测试 PASS;go vet PASS;退出状态均为 0。 + +MIGRATION +命令:goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' up;SHOW INDEX FROM rental_orders WHERE Key_name='idx_rental_orders_offline_pending_amount';goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' down。 +输入:本地 Docker MySQL 的 migration 62 数据库。 +字面输出/结果:goose: successfully migrated database to version: 63;索引三列均出现;Down 输出 OK 000063_finance_pending_disbursement_index.sql;删除后恢复原状态;退出状态 0。 diff --git a/docs/business-rules.md b/docs/business-rules.md new file mode 100644 index 0000000..d4b5a8a --- /dev/null +++ b/docs/business-rules.md @@ -0,0 +1,33 @@ +# 业务规则 + +## 商品 + +- 用户完成登录和实名认证后才能发布租赁商品。 +- 商品提交审核并通过后才可公开上架;处于交易中的商品不能被另一订单锁定。 +- 主动下架、交易离架、异常和封存是不同状态,必须保留状态事件。 + +## 订单 + +- 创建订单后进入待支付;支付成功后进入待交接并锁定商品。 +- 号主提交交接信息,租客确认后开始计算租期。 +- 租客归还后进入结账协商,双方确认或客服仲裁后完成结算。 +- 订单状态、交接状态和结算状态共同描述流程,禁止仅修改其中一个字段绕过状态机。 + +## 支付与退款 + +- 金额均使用整数分,支付单和退款单必须具有唯一业务单号。 +- 渠道通知必须先验签,再按幂等规则推进本地状态。 +- 已进入终态的支付或退款记录不能被迟到通知回退。 +- 人工退款、关闭订单、封存订单和仲裁退款的业务语义不同,均需审计记录。 + +## 钱包与提现 + +- 钱包区分可用余额和冻结余额,每次变更必须产生流水。 +- 创建提现申请时,可用余额转为冻结余额;取消或拒绝时原额返还。 +- 审核通过后只有确认打款才能最终扣减冻结余额,重复审核或重复确认必须被拒绝。 + +## 管理操作 + +- 管理后台使用独立认证和 RBAC 权限。 +- 关闭、退款、封存、异常、仲裁和余额调整等高风险操作必须写入审计日志。 +- 客服只能通过服务端返回的可用动作执行订单状态转换。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..20a822c --- /dev/null +++ b/docs/database.md @@ -0,0 +1,25 @@ +# 数据库设计 + +## 设计原则 + +- MySQL 8.4 是生产事实源,Redis 只保存限流、验证码、会话辅助和缓存数据。 +- 金额统一以 `*_cent` 的整数分存储;数量和比例字段按业务精度单独定义。 +- 核心写入通过 GORM 事务完成,钱包账户更新使用行锁并同步写入不可变流水。 +- 表结构由 `backend/migrations` 下的 Goose 迁移管理,应用启动不自动修改生产表结构。 + +## 核心领域 + +- 用户与权限:`users`、`admin_users`、角色、权限及关联表。 +- 商品:租赁商品、账号、资产截图、审核记录和状态事件。 +- 订单:租赁订单、交接记录、结账协商、过程事件和申诉。 +- 资金:支付单、退款单、钱包账户、钱包流水、收款账号和提现申请。 +- 运营:系统配置、公告、通知、聊天、二维码池、推送规则和撞车商品。 + +## 一致性约束 + +- 下单时在事务内锁定可租商品,避免重复出租。 +- 支付状态、订单状态和商品状态在同一业务事务内推进。 +- 钱包每次余额变化必须生成对应流水,提现在待审核阶段将可用余额转为冻结余额。 +- 支付通知和业务单号使用唯一索引实现幂等。 + +索引、慢查询、连接池和备份恢复细则见[数据库 SQL 约束与运维](数据库SQL约束与运维.md)。 diff --git a/docs/finance-dashboard-performance.md b/docs/finance-dashboard-performance.md new file mode 100644 index 0000000..20fbc8a --- /dev/null +++ b/docs/finance-dashboard-performance.md @@ -0,0 +1,36 @@ +# 财务仪表盘查询性能分析 + +## 结论 + +默认 7 天首页慢的主要原因不是缺少订单 ID 索引,而是同一时间窗口被重复聚合:每日支付、每日结算、每日预计收入完成后,旧实现又串行执行支付汇总、结算汇总、预计收入汇总。结算聚合还需要按订单关联 `order_checkouts` 与 `wallet_ledger`,钱包流水增长后成本随订单数上升。 + +“先取 ID 再关联详情”适合分页明细,不适合首页 SUM/COUNT。首页已经通过 `settled_at` 索引先定位订单;把 ID 传到应用层会增加网络传输、`IN` 参数和内存聚合,不能减少数据库必须扫描的聚合数据。 + +本次已落地两类优化: + +- 每日结果直接派生首页总览,删除三条重复汇总 SQL;支付行同时派生撞车商城总览,费用行同时派生费用总览。 +- 财务明细分页先取当前页订单 ID,再限制结账、支付、钱包聚合子查询只处理这些 ID。 +- 增加 `rental_orders(settlement_mode, offline_settlement_status, offline_settlement_amount_cent)` 覆盖索引,优化不受日期限制的待线下出款统计。 + +## 实测 + +测试库 `hfb_finance_perf` 使用 70 天分布的模拟数据:`rental_orders` 50 万、`order_checkouts` 50 万、`payment_orders` 60 万、`wallet_ledger` 100 万,另有提号、提现和费用数据。 + +旧实现与新实现返回的完整 Dashboard JSON(去除生成时间)字节级一致;SQL 数量由 31 降至 26。 + +| 场景 | 总耗时 | +| --- | ---: | +| 旧实现冷查询 | 7.936s | +| 新实现冷查询 | 1.625s | +| 新实现带覆盖索引的一次实测 | 2.894s | + +机器负载、缓存和并发会造成波动;服务器低配环境出现 10 秒与上述瓶颈相符。稳定性收益来自减少重复扫描,不能只用一次本机耗时外推生产 P95。 + +## 后续治理 + +1. 为所有高频列表记录 `duration_ms`、扫描行数、P95/P99 和调用接口,按总耗时排序治理。 +2. 深分页列表逐步从 OFFSET 改为基于 `(id, created_at)` 的游标分页;保留需要跳页的后台列表作为例外。 +3. 对首页和报表建立按天汇总表(例如 `finance_daily_snapshots`),业务事件增量更新,首页只读取 7 行快照和少量实时待办。 +4. 开启 MySQL slow query log 或 `performance_schema.events_statements_summary_by_digest`,持续检查执行计划是否因数据分布变化回退。 +5. 对每个复合索引验证过滤列顺序、覆盖列和写入成本;不要为低频或已经低于查询预算的提号小表盲目加索引。 + diff --git a/docs/project-plan.md b/docs/project-plan.md new file mode 100644 index 0000000..2567f1d --- /dev/null +++ b/docs/project-plan.md @@ -0,0 +1,23 @@ +# 项目计划 + +## 产品目标 + +HFB Sys 面向《三角洲行动》账号租赁,提供从商品发布、审核、下单、支付、账号交接、租用、归还、结账到申诉仲裁的闭环,并支持平台代管账号和撞车商品交易。 + +## 当前阶段 + +系统已经进入生产运营与稳定性建设阶段。主流程、管理后台、支付渠道、财务统计、消息通知、对象存储和备份恢复均已落地,后续工作以资金正确性、自动化回归和运维可观测性为主。 + +## 迭代顺序 + +1. 资金链路:支付回调幂等、退款、钱包余额守恒、提现审核和结算测试。 +2. 稳定性:真实 MySQL E2E、就绪探针、超时任务和恢复演练。 +3. 可维护性:拆分大型前端页面和后端路由组装文件。 +4. 体验:根据运营数据优化商品筛选、订单操作和客服工作台。 + +## 发布标准 + +- `./scripts/check.sh` 全部通过。 +- 数据库迁移同时具备 Up/Down,并通过 SQL 门禁。 +- 涉及金额或状态机的变更必须包含回归测试。 +- 生产部署后 `/health/ready` 返回 200,关键下单、支付查询和后台登录完成冒烟验证。 diff --git a/docs/代码质量改进计划.md b/docs/代码质量改进计划.md index ca166ae..463b71e 100644 --- a/docs/代码质量改进计划.md +++ b/docs/代码质量改进计划.md @@ -1,643 +1,55 @@ # HFB Sys 代码质量改进计划 -## 文档说明 +## 当前基线 -本文档记录了项目代码审查发现的问题和优化计划。 +更新时间:2026-09-02。 -**生成时间**: 2026-06-10 -**项目版本**: refactor/features-architecture 分支 +- 后端约 374 个 Go 文件、6.6 万行代码,64 个测试文件。 +- 前端约 249 个 Vue/TypeScript 文件、8.4 万行代码,7 个测试文件、32 个测试;大型页面样式已外置,订单详情 setup 已独立为 composable 目录中的脚本模块。 +- 数据库使用 61 个 Goose 迁移文件,SQL 约束由 `scripts/check-sql-guard.sh` 检查。 +- 后端总语句覆盖率基线为 30%;核心模块门禁为 order 40%、payment 20%、wallet 34%、withdrawal 30%。 +- 前端支付、钱包、提现和订单展示目标文件已建立 V8 覆盖率门禁:语句 80%、分支 70%、函数 80%、行 80%。 +- Vue 页面大小门禁为 1800 行,`scripts/check-vue-size.sh` 在本地检查中执行,防止大型 SFC 回涨;订单详情页的指标、支付流水和展示工具已拆出独立模块。 +- Repository 已普遍传递请求 Context;当前约 376 处 GORM `WithContext` 调用。 ---- +## 已落地门禁 -## 📊 当前状态 +本地和部署前统一运行: -### 代码规模 -- **后端**: ~28,000 行 Go 代码,122 个模块文件 -- **前端**: ~46,000 行 Vue/TypeScript 代码 -- **数据库**: 876 行 SQL 初始化脚本,114 个索引 -- **测试覆盖率**: - - 后端约 10%(12 个测试文件) - - 前端接近 0%(仅 2 个测试文件) - -### 测试覆盖率详情 - -| 模块 | 覆盖率 | 状态 | -|------|--------|------| -| realname | 28.2% | ⚠️ 需改进 | -| listing | 11.1% | 🔴 严重不足 | -| wallet | 11.0% | 🔴 严重不足 | -| payment | 8.9% | 🔴 严重不足 | -| dispute | 8.8% | 🔴 严重不足 | -| order | 7.7% | 🔴 严重不足 | -| paymentconfig | 2.2% | 🔴 严重不足 | -| adminuser | 0.7% | 🔴 严重不足 | - ---- - -## 🔴 严重问题(需要尽快处理) - -### 1. 测试覆盖率严重不足 ⚠️ - -**现状**: -- 核心金融模块(payment, wallet, order)测试覆盖率低于 12% -- 复杂的订单状态机、结算逻辑、退款流程缺少测试保护 -- 前端几乎没有单元测试 - -**风险**: -- 金融相关的钱包、支付、退款逻辑出错会导致资金损失 -- 重构时容易引入 bug,没有安全网 -- 订单状态转换错误会导致业务流程异常 - -**改进计划**: -- ✅ **已完成** (2026-06-10 第一批): - - 为 wallet 模块补充 Service 层测试(8 个测试用例) - - 为 order 模块补充 Service 层测试(11 个测试用例) - - 为 payment 模块补充 Service 层测试(10 个测试用例) - -- ✅ **已完成** (2026-06-10 第二批): - - 为 wallet.Repository 补充核心业务逻辑测试(14 个逻辑测试 + 9 个集成测试) - - 为 order.Repository 补充测试(11 个集成测试) - - 为 payment.Repository 补充测试(18 个集成测试) - -- 🔄 **进行中**: - - [ ] 为 wallet.Repository 补充更多集成测试 - - [ ] 并发场景测试(账户锁定) - - [ ] Withdraw 提现测试 - - [ ] AdminLedger 管理员账本查询测试 - - - [ ] 为 order.Repository 补充更多集成测试 - - [ ] Pay 支付状态转换测试 - - [ ] SubmitHandoff/ConfirmReceive 交接流程测试 - - [ ] CounterCheckout 反价逻辑测试 - - - [ ] 为 payment.Repository 补充更多集成测试 - - [ ] Start 支付单创建与复用测试 - - [ ] StartRefund 退款流程测试 - - [ ] HandleNotify 支付回调处理测试 - -- 📅 **计划中**(1-2 周内完成): - - [ ] 前端关键组件测试 - - [ ] 支付组件单元测试 - - [ ] 钱包组件单元测试 - - [ ] 订单流程 E2E 测试 - - - [ ] 集成测试扩展 - - [ ] 完整租号流程测试(发布→下单→支付→交接→归还→结算) - - [ ] 退款流程测试 - - [ ] 仲裁流程测试 - -**目标**: -- 短期(1 个月内):核心模块测试覆盖率达到 **60%** -- 中期(3 个月内):整体测试覆盖率达到 **70%** -- 长期:建立 CI/CD 测试流程,强制测试覆盖率不低于 60% - ---- - -### 2. 缺少 Context 超时控制 🚨 - -**现状**: -- 122 个模块文件中只有 8 个使用 `context.Context` -- 数据库查询、HTTP 调用、外部 API(支付、短信、实名)都没有超时控制 - -**风险**: -- 数据库慢查询会导致 goroutine 泄漏 -- 外部 API 超时会拖垮整个服务 -- 无法实现请求级别的超时控制和取消机制 - -**改进计划**: -- 📅 **Week 1**: - - [ ] 为所有 Repository 方法签名添加 `ctx context.Context` 参数 - - [ ] 更新 Service 层传递 context - - [ ] 在 handler 层从 `c.Request.Context()` 获取 context - -- 📅 **Week 2**: - - [ ] 为数据库操作添加超时控制 - ```go - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return r.db.WithContext(ctx).Create(...) - ``` - - - [ ] 为外部 API 调用添加超时控制 - - 支付渠道调用:10 秒超时 - - 短信服务:5 秒超时 - - 实名认证:10 秒超时 - -**示例代码**: -```go -// 修改前 -func (r *Repository) Create(userID uint64, req CreateRequest) (*OrderDTO, error) { - return r.db.Transaction(func(tx *gorm.DB) error { - // ... - }) -} - -// 修改后 -func (r *Repository) Create(ctx context.Context, userID uint64, req CreateRequest) (*OrderDTO, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // ... - }) -} +```bash +./scripts/check.sh ``` ---- +门禁包含: -### 3. 代码文件过大,职责不清 📦 +1. SQL 迁移约束与完整性检查。 +2. `go vet`、Go 全量构建、测试和覆盖率基线。 +3. Prettier、ESLint、Vue/TypeScript 类型检查。 +4. Vitest 和 Vite 生产构建。 -**现状**: -- `listing/repository.go`: **1731 行** -- `payment/repository.go`: **1282 行** -- `chat/repository.go`: **1155 行** -- 平均文件行数:**184 行**(偏高,建议 150 行以下) +本地门禁通过 `scripts/check.sh` 统一执行;需要完整租赁链路时,在本地 MySQL 8.4 上设置 `HFB_E2E_DSN` 运行 E2E。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。 -**问题**: -- 单个文件包含太多逻辑,难以维护和测试 -- 违反单一职责原则 -- 代码复用困难 +## 后续重点 -**改进计划**: -- 📅 **Week 3-4**: - - **listing 模块拆分**: - ``` - listing/ - ├── repository.go # 核心仓储(保留 Create/Update/Delete) - ├── query.go # 查询逻辑(List/Detail/Search) - ├── review.go # 审核逻辑(SubmitReview/Approve/Reject) - └── cache.go # 缓存逻辑(PublicZoneCount) - ``` - - **payment 模块拆分**: - ``` - payment/ - ├── repository.go # 核心仓储 - ├── refund.go # 退款逻辑(StartRefund/QueryRefund) - ├── channel.go # 渠道适配(lakala/leshua/mock) - └── notify.go # 回调处理(HandleNotify) - ``` - - **chat 模块拆分**: - ``` - chat/ - ├── repository.go # 核心仓储 - ├── message.go # 消息逻辑(Send/List/MarkRead) - └── websocket.go # WebSocket 连接管理 - ``` +### P0:资金正确性 ---- +- 扩充支付通知乱序、重复回调和退款重试测试。 +- 扩充钱包并发入账、提现审核竞争和事务回滚测试。 +- 保持金额使用整数分存储,禁止业务层使用浮点数累计金额。 -## 🟡 重要问题(影响性能和可维护性) +### P1:前端回归保护 -### 4. 缺少接口抽象,模块耦合严重 +- 为支付收银台、订单操作、提现和后台财务组件增加测试。 +- 优先测试状态转换、权限可见性和请求参数,不追求低价值快照数量。 +- 逐步建立前端覆盖率基线。 -**现状**: -```go -// order/repository.go 直接依赖具体实现 -type Repository struct { - db *gorm.DB - chatRepo *chat.Repository // 直接依赖 - refundFunc RefundFunc // 通过函数注入 -} -``` +### P1:结构治理 -**问题**: -- 难以进行单元测试(无法 mock 依赖) -- 模块间耦合紧密,修改一个模块可能影响其他模块 -- 依赖注入通过 `SetXxx` 方法,容易忘记初始化 +- 将大型管理页面拆成展示组件、操作对话框和 composable。 +- 将路由组装按公开、用户、卖家、管理员领域拆分,保持 `router.New` 只负责依赖组装。 +- 单次拆分必须保持接口、路由名称和用户行为不变。 -**改进方案**: -```go -// 定义接口解耦 -type ChatService interface { - CreateOrderChat(ctx context.Context, orderID uint64) error -} +### P2:可观测性 -type RefundService interface { - Refund(ctx context.Context, orderID uint64, amount int64, bizType, remark string) error -} - -type Repository struct { - db *gorm.DB - chatSvc ChatService - refundSvc RefundService -} - -func NewRepository(db *gorm.DB, chatSvc ChatService, refundSvc RefundService) *Repository { - return &Repository{ - db: db, - chatSvc: chatSvc, - refundSvc: refundSvc, - } -} -``` - -**改进计划**: -- 📅 **Week 5-6**: - - [ ] 定义核心接口(ChatService, RefundService, WalletService) - - [ ] 重构 Repository 依赖注入 - - [ ] 更新 router 初始化代码 - ---- - -### 5. 数据库查询存在性能隐患 - -**现状**: -- 数据库索引定义相对较少(114 个索引,20+ 张表) -- 部分复杂查询缺少索引覆盖 -- 缺少慢查询监控和分析 - -**具体问题**: -```sql --- adminfinance/repository.go 中的财务汇总查询 -SELECT ... FROM rental_orders AS ro -JOIN order_checkouts AS oc ON oc.order_id = ro.id -LEFT JOIN (...) AS w ON w.order_id = ro.id -- 子查询可能很慢 -WHERE ro.settled_at >= ? AND ro.settled_at <= ? -``` - -**改进计划**: -- 📅 **Week 7**: - - [ ] 审查所有复杂查询,使用 `EXPLAIN` 分析 - - [ ] 补充缺失的索引: - ```sql - -- 订单结算查询优化 - CREATE INDEX idx_rental_orders_settled_at_status - ON rental_orders(settled_at, settlement_status); - - -- 财务流水查询优化 - CREATE INDEX idx_wallet_ledger_user_created - ON wallet_ledger(user_id, created_at DESC); - - -- 支付订单查询优化 - CREATE INDEX idx_payment_orders_biz_status - ON payment_orders(biz_type, status, created_at DESC); - ``` - - - [ ] 对复杂统计查询考虑预计算方案 - - 每日财务汇总定时任务(凌晨 1 点) - - 用户钱包余额缓存(Redis) - - 订单统计数据缓存(5 分钟 TTL) - -- 📅 **Week 8**: - - [ ] 启用慢查询日志(记录 > 1 秒的查询) - - [ ] 建立慢查询分析流程 - - [ ] 优化 TOP 10 慢查询 - ---- - -### 6. 缺少 API 限流和熔断机制 - -**现状**: -- 只有短信验证码有简单的限流(`ErrCodeRateLimited`) -- **没有全局 API 限流** -- **没有熔断机制**保护外部依赖(支付渠道、短信服务) - -**风险**: -- 恶意攻击会拖垮服务 -- 外部服务故障会级联影响整个系统 -- 无法防止暴力破解 - -**改进计划**: -- 📅 **Week 9**: - - **添加全局限流中间件**: - ```go - import "github.com/ulule/limiter/v3" - - func RateLimitMiddleware() gin.HandlerFunc { - rate := limiter.Rate{ - Period: time.Minute, - Limit: 100, // 每分钟 100 次请求 - } - store := memory.NewStore() - middleware := mgin.NewMiddleware(limiter.New(store, rate)) - return middleware - } - ``` - - **添加熔断器保护外部服务**: - ```go - import "github.com/sony/gobreaker" - - type PaymentChannelWithBreaker struct { - channel channelClient - breaker *gobreaker.CircuitBreaker - } - - func NewPaymentChannel(channel channelClient) *PaymentChannelWithBreaker { - breaker := gobreaker.NewCircuitBreaker(gobreaker.Settings{ - Name: "payment_channel", - MaxRequests: 3, - Timeout: 10 * time.Second, - ReadyToTrip: func(counts gobreaker.Counts) bool { - failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) - return counts.Requests >= 3 && failureRatio >= 0.6 - }, - }) - return &PaymentChannelWithBreaker{channel: channel, breaker: breaker} - } - ``` - -- 📅 **限流策略**: - - 全局:100 req/min per IP - - 登录接口:5 req/min per IP - - 短信发送:1 req/min per phone - - 支付接口:10 req/min per user - - 文件上传:20 req/hour per user - ---- - -### 7. 前端错误处理不统一 - -**现状**: -- 4 个 Vue 文件中仍有 `console.log/error` 调试代码 -- 错误处理分散在各个组件中 -- 缺少全局错误拦截和统一提示 - -**改进计划**: -- 📅 **Week 10**: - - **统一 HTTP 错误处理**: - ```typescript - // shared/utils/http.ts - import { ElMessage } from 'element-plus' - - axios.interceptors.response.use( - response => response, - error => { - const message = error.response?.data?.message || '请求失败' - ElMessage.error(message) - - // 统一错误上报(生产环境) - if (import.meta.env.PROD) { - errorReporter.report({ - message, - stack: error.stack, - url: error.config?.url, - method: error.config?.method, - }) - } - - return Promise.reject(error) - } - ) - ``` - - **清理调试代码**: - - [ ] 移除所有 `console.log` 调试代码 - - [ ] 添加 ESLint 规则禁止 `console.log` - - [ ] 使用统一的日志工具(开发环境) - ---- - -## 🟢 改进建议(提升代码质量) - -### 8. 日志记录不完整 - -**现状**: -- 有基础日志框架(zap)和请求日志中间件 -- 但业务日志记录不充分 -- 缺少关键业务节点的日志追踪 - -**改进建议**: -```go -// 为关键业务操作添加结构化日志 -logger.Info("order paid successfully", - zap.Uint64("order_id", orderID), - zap.Uint64("user_id", userID), - zap.Int64("amount_cent", amount), - zap.String("payment_no", paymentNo), - zap.String("provider", provider), -) - -logger.Warn("refund failed", - zap.Uint64("order_id", orderID), - zap.Int64("amount_cent", amount), - zap.String("reason", reason), - zap.Error(err), -) -``` - -**补充日志点**: -- [ ] 订单支付成功/失败 -- [ ] 退款申请/成功/失败 -- [ ] 账户交接关键节点 -- [ ] 仲裁处理结果 -- [ ] 钱包余额变更(大额) -- [ ] 系统配置修改 - ---- - -### 9. 配置管理可以改进 - -**现状**: -- 配置都通过环境变量管理 -- 缺少配置验证 -- 部分配置硬编码在代码中 - -**改进方案**: -```go -// 添加配置验证 -func (c Config) Validate() error { - if c.JWTSecret == "change-me" { - return errors.New("JWT_SECRET must be set in production") - } - if c.AppEnv == "production" && c.SMS.Provider == "mock" { - return errors.New("SMS provider cannot be mock in production") - } - if c.AppEnv == "production" && c.Realname.Provider == "mock" { - return errors.New("Realname provider cannot be mock in production") - } - return nil -} - -// 在 main.go 中调用 -cfg := config.Load() -if err := cfg.Validate(); err != nil { - log.Fatal("配置验证失败:", err) -} -``` - -**支持配置文件**: -- [ ] 支持 `config.yaml` 作为环境变量补充 -- [ ] 支持多环境配置文件(dev/staging/prod) -- [ ] 敏感配置仍使用环境变量 - ---- - -### 10. 前端共享逻辑可以进一步抽象 - -**现状**: -- `shared/utils` 目录已经做了不错的抽象 -- 但部分工具函数文件过大(`listingDisplay.ts` 11KB、`pricing.ts` 11KB) - -**拆分建议**: -``` -shared/utils/ -├── listing/ -│ ├── display.ts # 展示相关 -│ ├── format.ts # 格式化函数 -│ └── filters.ts # 过滤逻辑 -├── pricing/ -│ ├── pricing.ts # 价格计算 -│ ├── discount.ts # 折扣计算 -│ └── commission.ts # 佣金计算 -└── ... -``` - ---- - -### 11. 缺少文档和注释 - -**现状**: -- 代码注释较少 -- 没有完整的架构文档 -- API 文档依赖 Swagger - -**改进计划**: -- [ ] 补充架构文档 - - [ ] 数据库设计文档(`database.md`) - - [ ] 订单状态机文档 - - [ ] 支付流程文档 - - [ ] 结算规则文档 - -- [ ] 补充开发者指南 - - [ ] 如何添加新功能模块 - - [ ] 如何编写测试 - - [ ] 如何部署到生产环境 - -- [ ] 生成并维护 API 文档 - - [ ] 完善 Swagger 注释 - - [ ] 自动生成 API 文档 - ---- - -### 12. 缺少监控和告警 - -**现状**: -- 有基础日志(按天切分) -- 有审计日志 -- **但没有性能监控、错误追踪、告警机制** - -**改进方案**: - -**集成监控工具**: -- Prometheus + Grafana(应用监控) -- Sentry(错误追踪) -- OpenTelemetry(链路追踪,可选) - -**关键指标**: -- API 响应时间(P95, P99) -- 错误率 -- 订单支付成功率 -- 数据库慢查询 -- 外部 API 调用成功率 - -**告警规则**: -- API 错误率 > 5% -- 支付成功率 < 95% -- 数据库慢查询 > 10 次/分钟 -- 外部服务调用失败率 > 10% - ---- - -## 📊 优先级和时间计划 - -### 🔥 立即处理(已完成) -- ✅ 为核心金融模块补充单元测试(wallet, order, payment Service 层) - -### ⚡ 短期优化(1-2 周) -- **Week 1-2**: 添加 Context 超时控制 -- **Week 3-4**: 拆分大文件(listing/payment/chat repository) -- **Week 5-6**: 引入接口抽象,降低模块耦合 -- **Week 7-8**: 补充数据库索引,优化慢查询 - -### 🎯 中期改进(1-2 个月) -- **Week 9**: 添加 API 限流和熔断机制 -- **Week 10**: 统一前端错误处理 -- **Week 11-12**: 补充 Repository 层集成测试 -- **Week 13-14**: 补充前端组件测试 -- **Week 15-16**: 添加监控和告警系统 - -### 🌟 长期目标(3-6 个月) -- 建立 CI/CD 测试流程 -- 测试覆盖率达到 70%+ -- 完善技术文档 -- 性能优化(P95 响应时间 < 200ms) - ---- - -## 📈 成功指标 - -### 代码质量 -- [ ] 测试覆盖率 ≥ 60%(核心模块 ≥ 70%) -- [ ] 平均文件行数 < 150 行 -- [ ] 代码重复率 < 5% - -### 性能指标 -- [ ] API P95 响应时间 < 200ms -- [ ] API P99 响应时间 < 500ms -- [ ] 数据库慢查询 < 5 次/分钟 - -### 可靠性指标 -- [ ] API 可用性 > 99.9% -- [ ] 错误率 < 0.1% -- [ ] 支付成功率 > 99% - ---- - -## 🎉 已完成的改进 - -### 2026-06-10(第一批) -- ✅ 为 `wallet` 模块补充 Service 层单元测试(8 个测试用例) -- ✅ 为 `order` 模块补充 Service 层单元测试(11 个测试用例) -- ✅ 为 `payment` 模块补充 Service 层单元测试(10 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 逻辑测试(14 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 集成测试(9 个测试用例) -- ✅ 所有新增测试通过验证 -- ✅ 测试覆盖率基线建立: - - wallet: 11.0% → **36.1%**(提升 25%) - - order: 7.7% - - payment: 8.9% - -### 2026-06-10(第二批 - Week 1-2 完成) -- ✅ 为 `order` 模块补充 Repository 集成测试(11 个测试用例) - - 订单创建验证(商品状态、不能租自己的商品) - - 订单定价计算测试 - - 结账结算计算测试(全额租用、扣押金、额外消耗) - - 状态常量验证测试 - - 订单时长计算测试 -- ✅ 为 `payment` 模块补充 Repository 集成测试(18 个测试用例) - - 支付单复用逻辑测试 - - 支付状态转换测试 - - 退款业务类型覆盖测试 - - 支付金额验证测试 - - 支付渠道验证测试 - - Mock 模式判断测试 - - 钱包充值开关测试 -- ✅ 测试覆盖率提升: - - wallet: **36.1%**(保持) - - order: 7.7% → **10.2%**(提升 2.5%) - - payment: **8.9%**(保持) - -### 测试统计(Week 1-2 完成后) -- **测试文件总数**: 16 个 -- **测试用例总数**: 103 个(估算) - - wallet: 34 个 - - order: 28 个(17 + 11) - - payment: 41 个(23 + 18) -- **所有测试通过率**: 100% - ---- - -## 📝 备注 - -- 本文档是活文档,随着项目演进持续更新 -- 每完成一项改进,更新进度标记(✅) -- 定期回顾(每 2 周),调整优先级 - -**文档维护者**: Claude Code -**最后更新**: 2026-06-10 +- 区分 `/health` 存活探针和 `/health/ready` 就绪探针。 +- 持续观察慢查询、退款重试、订单超时任务和备份恢复演练结果。 diff --git a/docs/商家余额分账/0.商家余额分账-介绍.md b/docs/商家余额分账/0.商家余额分账-介绍.md new file mode 100644 index 0000000..95bf890 --- /dev/null +++ b/docs/商家余额分账/0.商家余额分账-介绍.md @@ -0,0 +1,347 @@ +# 一、功能简介 +乐刷的商家分账产品,提供多种方式商家发起分账,简单来看分为两种: + +1. 商家直接使用乐刷提供的商户PC后台发起分账; +2. 服务商接入乐刷的商家分账API,商家使用服务商提供的产品来发起分账。其中乐刷提供给服务商的分账API,有3种分账模式,服务商根据自身的分账业务特性和系统能力选择其一: + 1. 订单分账API。发起分账时,需要指明是对哪笔交易订单号发起分账。 + 2. 余额分账API。发起分账时,无需指明哪笔交易订单号分账,服务商根据自身的业务计算出需要分账的汇总资金,直接发起分账。 + 3. 自动分账API。服务商通过接口配置商家的分账规则,乐刷每日根据已设置的规则自动给商家发起分账。(该模式下,一般面向开发能力较弱的服务商,但服务商又需要商家登录由服务商提供的商家平台上操作场景) + +如服务商需要接入接口,若无特殊情况,建议服务商使用订单分账模式。 + +商家使用分账后,为了不混淆业务,一个商家仅可以使用以上三种分账模式中的一种,不可同时使用,且不能再使用乐刷提供的页面发起分账。 + +比如服务商已经接通余额分账的接口,并且给商家提供相应的分账操作页面,则不可以再让同一个商家直接使用乐刷提供的商家分账管理后台进行分账或者不可以让给同一个商家接通订单分账接口进行分账,该点需要服务商特别谨记。 + +以下介绍商家余额分账API产品的功能和操作步骤。 + +## 1. 余额分账功能简介 +商家余额分账功能,主要用于将商家交易成功后的二维码交易订单资金,按照一定的周期和比例,分账给与分账方(即收款商家)有合作关系的第三方,可以是收款商家的服务商、供货商、物流提供商、平台商等。 + +## 2. 余额分账功能使用场景 +(1) 物业租金抵扣 + +物业给商家提供场地收取商家的租金,为了减少经营商家的资金压力,通过商家的交易款抵扣铺租。由于铺租金额每月收取的金额固定且需要优先结清,无法分拆到具体交易订单订单,该类场景下可使用余额分账。 + +(2)设备押金收取 + +设备提供方给商家提供收款设备等产品,由于该类设备具有相应的成本,商家需要向设备商支付设备采购款,并采用先使用后通过支付的模式,该模式下,可通过余额分账进行分账,将前期的交易结算款用于支付设备款。 + +(3)其他合理的业务场景 + +商家的分账业务只要合理、合规,且可以合理的业务分账依据,并且分账的比例控制在合理范围或者分账金额控制在约定范围,也均可以使用该功能进行实现。 + +# 二、商家订单分账功能 +## 1. 接入准备 +(1)服务商完成服务商入网,取得**服务商编号**。 + +服务商入网流程详见: + +(2)服务商开发者获取到**服务商密钥**。 + +目前暂不支持服务商自行获取进件密钥,服务商密钥统一由乐刷的技术支持同学邮件发送给服务商的技术对接人。可在微信对接群联系乐刷的技术支持同事提供。 + +注: + +1)进件、交易、分账等接口使用的秘钥均为同一个秘钥。 + +2)乐刷当前仅支持向一级代理商提供密钥,不支持向二级及以下的代理商提供密钥。 + +3)商户秘钥不可以调用订单分账的接口,仅服务商秘钥(即1级代理商的秘钥)可调用。 + +(3)服务商、商户已完成订单分账功能的开通。 + +服务商、商户 开通分账功能的开通流程,详见:[三、分账开通流程](https://www.yuque.com/leshuazf/merchant_royalty/ynbk080r037yiicq) + +## 2. 接入指南 +### (1)简介 +完成 [接入准备](#tFfVI) 后,开发者可根据下方的接口调用流程快速接入能力。 + +### (2)分账流程 +#### 流程1:商户入网 +目前乐刷仅支持特定场景的商户使用余额分账,当前开放的特定场景为:医美、加油站、娱乐、教育培训。(若服务商有其他场景,需一事一议,提前与对应的销售沟通确认) + + + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。商户接口进件见:[商户入网指引](https://www.yuque.com/leshua-jhzf/qrcode_pay/gr9eco) + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网。 + + + +商户入网需要提供的资料清单如下: + +1. 分账方: + 1. 小微商户:身份证、银行卡、三合照(门店照、店内照、收银台)。 + 2. 个体商户:营业执照、经营者身份证、银行卡、三合照(门店照、店内照、收银台)。 + 3. 企业商户:营业执照、法人身份证、结算银行卡(对私:结算人身份证、结算人银行卡、非法人结算授权书--非法人结算时提供,对公:开户许可证)、三合照(门店照、店内照、收银台)。 +2. 分账接收方(需要进行收单交易): + 1. 小微商户:身份证、银行卡、三合照(门店照、店内照、收银台)。 + 2. 个体商户:营业执照、经营者身份证、银行卡、三合照(门店照、店内照、收银台)。 + 3. 企业商户:营业执照、法人身份证、结算银行卡(对私:结算人身份证、结算人银行卡、非法人结算授权书--非法人结算时提供,对公:开户许可证)、三合照(门店照、店内照、收银台)。 +3. 分账接收方(不需要进行收单交易): + 1. 分账到对私账户。以小微商户入网至乐刷,生成对应的乐刷商户号。 + 1. 具体所需资料要求:身份证、银行卡、三合照(门店照、店内照、收银台)传以下专用图。 + + + + 2. 分账接收方进件时,简称命名为“商户名+分账”,如“张三分账” + 3. 用此套资料进件会被驳回,乐刷商户号不需要通过,只要生成乐刷商户号即可。此商户号无交易权限,不可发起交易,但不影响商户接收分账权限,不影响分账后的结算。 + 2. 分账到对公账户。以企业商户入网至乐刷。 + 1. 具体所需资料要求:营业执照、法人身份证、结算银行卡(对公:开户许可证)、三合照(门店照、店内照、收银台)。 + + + +#### 流程2:商家余额分账流程 +```mermaid +sequenceDiagram +participant A as 分账方(收款商家) +participant B as 分账接收方 +participant C as 服务商 +participant D as 乐刷 + +#--------以下为具体流程---------# +Note right of A:1.分账方开通商家分账 + A->>C: 申请使用分账功能 + alt 纸质协议开通模式 + C->>C:线下收集收款商家的纸质协议并拍照保存 + C->>D:调用“商户开通分账申请(线下协议)”接口/api/share-merchant/accredit + D-->>C:同步返回接口调用结果 + D->>D:乐刷风控人工审核(1-3工作日内),审核通过即给收款商家开通分账 + C->>D:调用“商户开通分账结果查询”接口/api/share-merchant/accreditQuery + D-->>C:同步返回商户当前的审核结果 + + + else 电子协议开通模式 + C->>D:调用“商户开通分账申请(电子协议)”接口/api/share-merchant/elec-contract-accredit + D-->>C:同步返回调用结果及商家H5电子合同签署链接 + C->>A:将乐刷返回的H5电子合同发送给商家进行签署 + A->>D:商家法人在乐刷提供的H5上完成电子合同的签署 + D->>D:商家完成电子合同签署则自动开通分账 + C->>D:调用“商户开通分账结果查询”接口/api/share-merchant/accreditQuery + D-->>C:同步返回商户当前的分账开通结果 + + end + + +Note right of A:2.分账方和分账接收方绑定分账关系 + A->>C: 将分账接收方的名单提供给服务商 + C->>D:调用“分账关系绑定”接口/api/share-merchant/bind + D-->>C:同步返回绑定结果 + + A->>C: 申请终止某些分账接收方的分账 + C->>D:调用“分账关系解绑”接口/api/share-merchant/unbind + D-->>C:同步返回解绑结果 + + C->>D:调用“分账关系绑定查询”接口/api/share-merchant/queryBind + D-->>C:同步返回分账方已绑定对的分账接收方名单 + +Note right of A:3.收款商家进行交易 + A->>C: 申请发起收款(即正常的收款服务) + C->>D:调用“统一下单”接口get_tdcode或者“条码支付”接口upload_authcode,并传参royalty=1 + D-->>C:同步返回支付请求结果 + + C->>D:调用“交易查询”接口query_status查询支付结果 + D-->>C:同步返回交易订单的支付结果 + C->>A:告知/展示交易订单的支付结果 + + +Note right of A:4.分账发起 + A->>C: 申请发起分账(服务商和商户发起分账的流程可由服务商定义) + C->>D:调用“余额分账申请”接口/api/share-merchant/createSumAccredit + D-->>C:同步返回余额分账结果 + + C->>D:【可选】调用“余额分账结果查询”接口/api/share-merchant/querySumAccredit + D-->>C:同步返回订单分账结果 + + C->>D:【可选】调用“余额分账撤销”接口/api/share-merchant/canceSumAccredit,撤销某笔当日发起的余额分账 + D-->>C:同步返回余额分账撤销结果 + +Note right of A:5.分账资金结算出款 + + D->>D:D1日凌晨获取上一自然日发起的余额分账请求数据进行清分【3点为日切点】 + D->>A:D1日早上将分账给分账方的资金一笔结算给分账方 + D->>B:D1日早上将分账给分账接收方的资金一笔结算给分账接收方 + +Note right of A:6.分账对账 + D->>C:D1日早上将分账打款对账单放置乐刷的FTP,供服务商获取并进行对账 + + +``` + +### (3)流程说明 +#### 1)分账方和分账接收方入网 +分账方和分账接收方,均需要作为乐刷的商户户进行入网,获取到对应的乐刷商户号(乐刷商户号由10位数字组成) + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。商户接口进件见:[商户入网指引](https://www.yuque.com/leshua-jhzf/qrcode_pay/gr9eco) + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网。 + +分账接收方如本身无交易的诉求,只需提交商户入网资料并完成商户入网审核即可,无需进行交易费率配置、微信认证等环节。 + +#### 2)分账方开通分账功能 +**① 分账协议签署和配置分账费率** + +分账方(即收款商家)要使用分账功能,需要先给分账方开通分账功能,目前乐刷提供API接口供服务商接入分账方开通分账功能,分账方要开通分账功能,必须与乐刷签署分账协议,明确分账比例、**分账费率**、服务商授权等内容,以保证分账业务的合规性,避免后续产生不必要的资金纠纷。 + +根据协议签署的方式不同,服务商可选择电子协议或者线下协议两种开通方式: + +1. 电子协议,服务商调用乐刷的“[商户开通分账申请(电子协议)](https://www.yuque.com/leshuazf/merchant_royalty/lkels6)”接口,提交分账方和签约比例、分账费率等信息后,乐刷返回相应的电子合同H5链接,服务商将电子合同链接通过自有平台展示、短信等方式给到商家,由商家法人完成验证和签署。 + +注:电子协议的签署只能由商家法人签署,不可由商家法人之外的其他人签署,在签署前,乐刷会通过校验手机号和银行卡是否为商家法人(即姓法人名、法人身份证号、手机号是否一致,或者法人姓名、法人身份证号、银行卡号是否一致)。 + +电子合同签署验证示例: + +(1)手机号验证模式: + + + + + +(2)银行卡验证模式: + + + +注:获取金额成功后,请商户查看自己的银行APP查看收到的打款金额,并在“回填金额”中输入收到的打款金额,并点击“下一步”完成验证。 + + + +2. 线下协议。服务商下载乐刷的线下协议模板,打印提供给商家,完成信息的填写后,盖上商户的公章(不可用个人按手印的方式,如采用该方式将会风控审核环节被驳回),然后调乐刷的“[商户开通分账申请(纸质协议)](https://www.yuque.com/leshuazf/merchant_royalty/mnslzx)”接口,提交商户开通分账申请。 + +商家分账线下协议模板见: + +[特约商户支付服务合作协议--商户分账补充协议.pdf](https://www.yuque.com/attachments/yuque/0/2022/pdf/529841/1658910656501-17b2fbc7-190b-4e9f-be44-c86afc80706a.pdf) + +服务商提交商家的分账开通申请后,后期可通过“[商户分账开通结果查询](https://www.yuque.com/leshuazf/merchant_royalty/vdbp7t)”接口,查询商家的分账开通结果。 + + + +**特别说明:** + +1. 服务商通过接口给分账方开通分账功能时,分账方的最大分账比例不可以超过服务商的分账比例。 +2. 商户的分账服务费率,需要高于服务商的分账成本,且不可高于5%。 +3. **若商户的分账比例或者分账费率需要变更,需要重新调接口进行协议签署,新的分账比例或者分账服务费率需要签署成功且审核通过后生效。** + + + +**② 余额分账开通报备** + +分账方通过接口的方式,提交分账协议后,会自动进入待审核的环节,进入待审核环节后,需要商家同步提供补充资料供乐刷审核方可完成商家余额分账的开通。资料的提交方式为邮件发送。具体要求如下: + +1. 需要提交的资料内容: + 1. 分账业务协议(商户与其分账接收方) + 2. 商户近一个月内的发票、收据(1-2张) + 3. 经营场景视频(需拍摄到清晰的营业执照) + 4. 特殊行业需额外提供对应的经营资质: + 1. 医疗、美容:《医疗机构执业许可证》 + 2. 医疗器械:《医疗器械经营企业许可证》 + 3. 药品:《药品经营许可证》 + 4. 加油站:《成品油批发经营批准证书》或《成品油仓储经营批准证书》或《成品油零售经营批准证书》或《燃气经营许可证》 + 5. 烟酒类:《烟草零售经营许可证》、《酒类经营许可证》 + 6. 物流/快递:《道路运输许可证》、《快递业务经营许可证》 +2. 邮件发送的要求: + 1. 标题:服务商编号_商户编号_商户全程_余额分账开通申请,如:2888568_0000000018_张三科技有限公司_余额分账开通申请 + 2. 发送人:乐刷商务对接人邮箱(具体请咨询对应的乐刷商务) + 3. 抄送人:carriezheng@yeahka.com;v_yoyodu@yeahka.com;rocketfu@yeahka.com;leshuasaas_spm@yeahka.com;support2@yeahka.com + 4. 邮件正文: + +我司服务商编号+服务商名称,现有“商户编号”+“商户全称”,欲使用乐刷提供的余额分账功能,以下为商家的真实业务场景说明: + +一、商家业务场景说明 + +二、商家分账协议(可作为附件提供) + +三、经营场景视频(可作为附件提供) + +四、特殊行业补充资料(可作为附件提供) + +五、近一个月内开设的发票照片(可作为附件提供) + +3. **乐刷审核** + +乐刷风控审核约需2-3个工作日,审核通过后会给商家开通余额分账的功能,并邮件回复发件人。 + +注:目前仅支持医美、娱乐、加油、教育培训行业的商家申请使用余额分账 + +#### 3)分账方和分账接收方建立绑定关系 +分账前需先通过“[分账关系绑定](https://www.yuque.com/leshuazf/merchant_royalty/ch4u7g)”接口,将分账接收方与分账方(即收款商家)建立绑定关系,一个分账方最多可绑定XXX个分账接收方(乐刷商户号维度)。 + + + +若分账方与某分账接收方终止业务合作,并且后续不需要继续向该分账接收方分账,可调用”[分账关系绑定](https://www.yuque.com/leshuazf/merchant_royalty/ixqa6n)”接口解除绑定关系。解除绑定关系后,后续不可以继续向该分账接收方发起分账。解除绑定关系后,可以继续调用“分账关系绑定”接口重新建立绑定关系。 + +服务商可调用”[分账关系绑定查询](https://www.yuque.com/leshuazf/merchant_royalty/yplfec)”接口查询分账方当前绑定的分账接收方名单。 + +#### 4)分账方进行交易 +当前仅支持对二维码交易的订单标记分账,不可对刷卡交易的订单标记分账。 + +需要分账的订单,交易下单时(统一下单接口和条码支付接口),可在下单接口中传入分账标识(royalty=1),将交易资金预先冻结在商户分账方的待分账账本中。 + +注:扣除交易手续费和分账手续费后,实时计入分账方的待分账账本。 + +#### 5)分账方分账余额分账 +分账交易订单交易成功后,该笔订单扣除交易收费和分账手续费后,剩余资金实时入账至分账方的待分账账本中,服务商可通过调用“[余额分账申请](https://www.yuque.com/leshuazf/merchant_royalty/ypx1g8)”接口发起分账请求。 + +分账请求提交后,乐刷会实时将分账方的待分账账本资金转出到分账接收方的待结算账户,并同步返回分账最终处理结果。 + + + +如某笔分账提交错误,可通过“[余额分账撤销](https://www.yuque.com/leshuazf/merchant_royalty/muqq15)”撤回当日(以0点作为日切点)发起的分账请求,撤销后的资金可用于退款或者继续发起分账。 + + + +服务商可通过调用“[余额分账结果查询](https://www.yuque.com/leshuazf/merchant_royalty/dqep7p)”接口,查询某笔余额分账的分账结果。 + +#### 6)分账方和分账方接收方的账资金结算出款 +服务商发起的订单分账,乐刷D1日凌晨进行统一的清分结算,并于D1早上7点左右出款至分账方和分账接收方。 + +结算出款环节无需服务商接入接口。 + +同一个分账接收方有多个分账方分给他时,会将合并一笔出款给到分账接收方。 + +某分账方既涉及自身给给自身,也涉及其他分账接收方分给自身时,所有的分账资金合并一笔出给该分账方。 + +分账方既涉及分账的交易,也涉及非分账的交易时,非分账的交易合并出款,并且不与分账的资金合并出款。 + +#### 7)商户分账数据对账 +乐刷每日早上8点左右,提供打款对账单给服务商进行对账。 + +[分账交易对账单](https://www.yuque.com/leshuazf/merchant_royalty/af71z3) + +[余额分账对账单](https://www.yuque.com/leshuazf/merchant_royalty/imflfs) + +[商家打款对账单](https://www.yuque.com/leshuazf/merchant_royalty/vi01zl) + +#### 8)服务商分润 +由于商户分账订单的出款具有延迟性,乐刷在交易成功后就对分账订单进行计费,因此分账交易订单成功后的次日,乐刷即计算服务商的分账分润(与交易分润分开入账)。 + +服务商可登录乐刷提供的代理商PC后台,在“分润提取”页面中提取分润。 + +注:分润的提取需要开立相应的发票 + +### (4)分账规则说明 +1. D1结算模式下(默认均采用该模式),分账资金为实付资金,交易手续费和分账手续费在入待分账账本前已经扣除,同一个分账接收方或者分账方,在同一周期内有多笔余额分账时,次日将合并一笔打款给商家。 +2. 一次分账请求,不可以超过50个分账接收方,且分账接收方不允许重复。 +3. 发起余额分账时,分账接收方和分账方的结算状态为正常,如有任一方的结算状态为冻结,则会导致分账失败 +4. 分账比例校验公式: (本次分账给分账接收方的分账金额 + 历史已成功分账给分账接收方的分账金额) ÷ (待分账账本总入账金额 - 分账订单历史成功退款金额 ) ≤ 商户分账比例 +5. 凌晨3点前发起的余额分账,可当日出款。举例:5日早上,对4日3:00-24:00和5日0:00-3:00 间发起的余额分账进行出款。 + +### (5)分账订单退款流程 +如服务商商需要对商户的分账订单进行退款,需要调用专门的“[分账交易退款](https://www.yuque.com/leshuazf/merchant_royalty/lo74w3)”接口发起退款。 + +目前仅支持90天内的交易发起退款,退款时,分账方的待分账账户需有足够的资金,若分账方的待分账账户中无足够资金,则无法进行退款。 + +## 3. 开发常见问题 + + + + + diff --git a/docs/商家余额分账/1.余额分账申请.md b/docs/商家余额分账/1.余额分账申请.md new file mode 100644 index 0000000..2c38ba9 --- /dev/null +++ b/docs/商家余额分账/1.余额分账申请.md @@ -0,0 +1,243 @@ +# 1. 接口用途 +该接口用于发起余额分账请求,交易成功后,交易订单的资金扣除交易手续费和分账手续费后,实时入账至分账方的待分账账本,如待分账账本余额足够,可发起余额分账。 + +接口发起的分账中,订单分账、余额分账、自动分账为不同的分账请求模式,同一个商户只允许选择其中的一种。 + +调用该接口前,商户需要开通分账功能,且商户的结算状态正常。 + +# 2. 接口说明 +## (1) 适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +乐刷不对测试环境的交易订单进行清分、入账和出款,请直接在生产环境进行调试。 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +1. D1结算模式下(默认均采用该模式),分账资金为实付资金,交易手续费和分账手续费在入待分账账本前已经扣除,同一个分账接收方或者分账方,在同一周期内有多笔余额分账时,次日将合并一笔打款给商家。 +2. 一次分账请求,不可以超过50个分账接收方,且分账接收方不允许重复。 +3. 发起余额分账时,分账接收方和分账方的结算状态为正常,如有任一方的结算状态为冻结,则会导致分账失败 +4. 分账比例校验公式: (本次分账给分账接收方的分账金额 + 历史已成功分账给分账接收方的分账金额) ÷ (待分账账本总入账金额 - 分账订单历史成功退款金额 ) ≤ 商户分账比例 +5. 凌晨3点前发起的余额分账,可当日出款。举例:5日早上,对4日3:00-24:00和5日0:00-3:00 间发起的余额分账进行出款。 + +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{\'cmd\': \'balance_allot_create\', \'merchant_id\': \'6763019848\', \'third_id\': \'202207151719300aNoeMhd\', \'amount\': \'1\', \'remark\': \'分账备注\', \'details\': \'[{"user_id":"6763019848","user_type":"2","amount":"1"}]\'}' | + + +### 业务请求参数 +| **参数** | **参数类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| cmd | String | M | 服务名称,本处固定为:balance_allot_create | "balance_allot_create" | +| merchant_id | String | M | 分账方的乐刷商户号 | "8613319257" | +| third_id | String | M | 商户分账单号 | "202207151719300aNoeMhd" | +| amount | String | M | 分账总金额
details中各分账接收方的分账金额汇总需等于本处的金额 | "2" | +| remark | String | O | 分账备注 | "备注说明1" | +| details | String | M | 分账详情,list格式并且需要转化为string,一次请求不可超过50个分账接收方且分账接收方不可重复,列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处固定填2
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805151053', 'sign': 'YWFiMWM5MjU4NDY3MzkwNGFiNjZjZTUwN2UxYjAzNzQ=', 'data': '{\'cmd\': \'balance_allot_create\', \'merchant_id\': \'6763019848\', \'third_id\': \'202207151719300aNoeMhd\', \'amount\': \'1\', \'remark\': \'分账备注\', \'details\': \'[{"user_id":"6763019848","user_type":"2","amount":"1"}]\'}'} + +``` + + + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805151053&sign=YWFiMWM5MjU4NDY3MzkwNGFiNjZjZTUwN2UxYjAzNzQ%3D&data=%7B%27cmd%27%3A+%27balance_allot_create%27%2C+%27merchant_id%27%3A+%276763019848%27%2C+%27third_id%27%3A+%27202207151719300aNoeMhd%27%2C+%27amount%27%3A+%271%27%2C+%27remark%27%3A+%27%E5%88%86%E8%B4%A6%E5%A4%87%E6%B3%A8%27%2C+%27details%27%3A+%27%5B%7B%22user_id%22%3A%226763019848%22%2C%22user_type%22%3A%222%22%2C%22amount%22%3A%221%22%7D%5D%27%7D + +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/createSumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"third_id":"0000000060402350","merchant_id":"9031015604", \ +"amount":"50","details":[{"user_id":"9031015604","user_type":"2","amount":"50"}]}' +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 网关应答码。
000000,代表网关转发成功,具体的成功需要看retcode和status
其他应答码代表网关转发失败 | "000000" | +| respMsg | String | M | 网关应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 业务参数
respCode=000000时返回 | {'third_id': '20220715173529LDxUoE9l', 'amount': '2', 'leshua_id': '2022071563329181703', 'details': '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]', 'remark': '分账备注', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '1'}} | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖third_id | String | O | 商户分账单号 | '20220715173529LDxUoE9l' | +| ┖amount | String | O | 分账总金额,单位为分 | "2" | +| ┖leshua_id | String | O | 乐刷分账单号 | "2022071563329181703" | +| ┖remark | String | O | 分账备注 | '分账备注' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款 | '1' | +| ┖merchant_id | String | O | 分账方的乐刷商户号 | '8613319257' | +| ┖details | String[] | O | 分账详情,list格式并且转化为string,返回请求时送的details.
列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (2)响应示例(正常) +```json +{"version":"1.0","respCode":"000000","respMsg":"成功","reqSerialNo":"20220805151053","data":{"third_id":"202207151719300aNoeMhd","amount":"1","leshua_id":"2022080554653269903","details":"[{\"user_id\":\"6763019848\",\"user_type\":\"2\",\"amount\":\"1\"}]","remark":"分账备注","merchant_id":"6763019848","retmsg":"success","retcode":"0","status":"1"}} + +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000037', 'respMsg': '分账商户未绑定', 'reqSerialNo': '20220715172759'} +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码(respCode)** | **错误描述(****respMsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷聚合 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷聚合 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 2. 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷聚合 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷聚合 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷聚合 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷聚合 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷聚合 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷聚合 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 乐刷底层 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 乐刷底层 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 乐刷底层 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 乐刷底层 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 乐刷底层 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 乐刷底层 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 乐刷底层 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 乐刷底层 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 乐刷底层 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 乐刷底层 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 乐刷底层 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 乐刷底层 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 乐刷底层 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 乐刷底层 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 乐刷底层 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 乐刷底层 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 乐刷底层 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 乐刷底层 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 乐刷底层 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 乐刷底层 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 乐刷底层 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 乐刷底层 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 乐刷底层 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 乐刷底层 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 乐刷底层 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 乐刷底层 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 乐刷底层 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 乐刷底层 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 乐刷底层 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 乐刷底层 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 乐刷底层 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 乐刷底层 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 乐刷底层 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 乐刷底层 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 乐刷底层 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 乐刷底层 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 乐刷底层 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 乐刷底层 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/createSumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_create", + "merchant_id": "6763019848", + "third_id": reqSerialNo(), + "amount": "1", + "remark": "分账备注", + "details": json.dumps([{"user_id":"6763019848","user_type":"2","amount":"1"}]) ##json.dumps() 是把python对象转换成json对象的一个过程,生成的是字符串 +} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.json()) + +``` diff --git a/docs/商家余额分账/2. 余额分账结果查询.md b/docs/商家余额分账/2. 余额分账结果查询.md new file mode 100644 index 0000000..6185e80 --- /dev/null +++ b/docs/商家余额分账/2. 余额分账结果查询.md @@ -0,0 +1,224 @@ +# 1. 接口用途 +该接口可查询发起的余额分账的结果。 + +# 2. 接口说明 +## (1)适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{"cmd": "balance_allot_query", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}' | + + +### 业务请求参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | --- | --- | --- | +| cmd | String | M | 服务名称,本处固定为:balance_allot_query | "balance_allot_query" | +| merchant_id | String | M | 分账方的乐刷商户编号 | | +| third_id | String | M | 商户分账单号 | | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805151947', 'sign': 'MzBlNGUxNGFiMzg5NWYwZTVlNzcyNTQwNzIwNWQwMjc=', 'data': '{"cmd": "balance_allot_query", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}'} + +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/querySumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"cmd":"balance_allot_query","third_id":"5856212145754114","merchant_id":"548957978"}' +``` + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805151947&sign=MzBlNGUxNGFiMzg5NWYwZTVlNzcyNTQwNzIwNWQwMjc%3D&data=%7B%22cmd%22%3A+%22balance_allot_query%22%2C+%22merchant_id%22%3A+%226763019848%22%2C+%22third_id%22%3A+%22202207151719300aNoeMhd%22%7D +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 网关应答码。
000000,代表网关转发成功,具体的成功需要看retcode和status
其他应答码代表网关转发失败 | "000000" | +| respMsg | String | M | 网关应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 分账详情,JSON格式
respCode=000000时返回 | | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖merchant_id | String | O | 分账方的乐刷商户号
retcode=0时返回 | '8613319257' | +| ┖third_id | String | O | 商户分账单号
retcode=0时返回 | '20220715173529LDxUoE9l' | +| ┖leshua_id | String | O | 乐刷分账单号
retcode=0时返回 | "2022071563329181703" | +| ┖amount | String | O | 分账总金额,单位为分
retcode=0时返回 | "2" | +| ┖remark | String | O | 分账备注
retcode=0时返回 | '分账备注' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款
retcode=0时返回 | '1' | +| ┖details | String[] | O | 分账详情,list格式,返回请求时送的details.
retcode=0时返回
列表内字段说明:
1. user_id:分账接收方/分账方的乐刷商户编号
2. user_type:分账接收方类型,此处
3. amount:分账金额,单位为分 | '[{"user_id":"8613319257","user_type":"2","amount":"1"},{"user_id":"8123016461","user_type":"2","amount":"1"}]' | + + +## (2)响应示例(正常) +```json +{'version': '1.0', 'respCode': '000000', 'respMsg': '成功', 'reqSerialNo': '20220715182702', 'data': {'third_id': '20220715173529LDxUoE9l', 'amount': '2', 'leshua_id': '2022071563329181703', 'details': '[{"amount":"1","user_id":"8123016461","user_type":"2"},{"amount":"1","user_id":"8613319257","user_type":"2"}]', 'remark': '分账备注', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '1'}} +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000103', 'respMsg': '汇总分账失败db_not_found', 'reqSerialNo': '20220715184903'} + +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码** | **错误描述** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷聚合 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷聚合 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 2 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷聚合 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷聚合 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷聚合 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷聚合 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷聚合 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷聚合 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | +| 000999 | 系统异常 | 乐刷聚合 | 系统升级或出现未知异常 | 紧急请联系乐刷运营 | +| 000063 | 商户未开通t1结算状态 | 乐刷聚合 | 分账接受放未开通T1结算 | 检查T1结算状态 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 乐刷底层 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 乐刷底层 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 乐刷底层 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 乐刷底层 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 乐刷底层 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 乐刷底层 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 乐刷底层 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 乐刷底层 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 乐刷底层 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 乐刷底层 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 乐刷底层 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 乐刷底层 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 乐刷底层 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 乐刷底层 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 乐刷底层 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 乐刷底层 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 乐刷底层 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 乐刷底层 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 乐刷底层 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 乐刷底层 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 乐刷底层 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 乐刷底层 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 乐刷底层 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 乐刷底层 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 乐刷底层 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 乐刷底层 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 乐刷底层 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 乐刷底层 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 乐刷底层 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 乐刷底层 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 乐刷底层 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 乐刷底层 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 乐刷底层 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 乐刷底层 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 乐刷底层 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 乐刷底层 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 乐刷底层 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 乐刷底层 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 乐刷底层 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 乐刷底层 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/querySumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_query", + "merchant_id": "6763019848", + "third_id": "202207151719300aNoeMhd"} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.text) + +``` + + diff --git a/docs/商家余额分账/3.余额分账撤销.md b/docs/商家余额分账/3.余额分账撤销.md new file mode 100644 index 0000000..73a76a8 --- /dev/null +++ b/docs/商家余额分账/3.余额分账撤销.md @@ -0,0 +1,224 @@ +# 1. 接口用途 +该接口可撤销已发起的余额分账。 + +# 2. 接口说明 +## (1) 适用对象 + 1. 一级代理商 + +## (2)接口地址 +测试环境地址:无 + +生产环境地址:https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit + +## (3)测试参数 +无 + +## (4)特别说明 +1. 分账单号撤销后,不可再继续撤销,可通过查询接口查询该笔订单的当前状态。 +2. 跨日后,由于已经步入结算环节,不支持再进行撤销。 + +## (5)频率限制 +40次/20秒 + +# 3. 接口请求 +## (1)请求方式 +1. 接口以 https的POST方式进行调用。 +2. 请求头部的 content-type 类型:application/x-www-form-urlencoded + +## (2)请求参数 +### 公共请求参数 +| **参数名称** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| **agentId** | String | M | 服务商编号,由乐刷分配的接入方唯一标识,明文传输。 | "2888568" | +| **version** | String | M | 目前固定值1.0 | "1.0" | +| **reqSerialNo** | String | M | 请求流水号(yyyyMMddHHmmssSSSXXXXX,其中 XXXXX为5位顺序号,禁止使用UUID等无意义数据) | "202207142012591234" | +| **sign** | String | M | 签名串,业务数据明文参与签名后经 Base64 编码的字符串。
签名方法详见[乐刷聚合签名](https://www.yuque.com/docs/share/a57e3853-70e6-445d-9193-e1892594b0ea?# 《聚合业务接口常规签名》) | "ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE" | +| **data** | String | M | 业务数据明文字符串(Json格式字符串,参照各个接口中输入参数说明) | '{"cmd": "balance_allot_cancel", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}' | + + +### 业务请求参数 +| **参数** | **参数类型** | **必填** | **长度** | **说明** | **示例** | +| --- | --- | --- | --- | --- | --- | +| cmd | String | M | | 服务名称,本处固定为:balance_allot_cancel | "balance_allot_cancel" | +| merchant_id | String | M | | 分账方的乐刷商户编号 | "8613319257" | +| third_id | String | M | | 商户分账单号 | "20220715173529LDxUoE9l" | + + +## (3)请求示例 +```json +{'agentId': '2653276', 'version': '1.0', 'reqSerialNo': '20220805152236', 'sign': 'ZDE2MzU0NjQ5MDE5MTdkYTYzZTExMTYyYzgxZjdlNzM=', 'data': '{"cmd": "balance_allot_cancel", "merchant_id": "6763019848", "third_id": "202207151719300aNoeMhd"}'} +``` + +```plain +curl --location --request POST 'http://t-saas-mch.lepass.cn/api/share-merchant/canceSumAccredit' \ +--form 'agentId=2888568' \ +--form 'version=1.0' \ +--form 'reqSerialNo=20220715110526' \ +--form 'sign=ZTNlZTUyZTA5MDE5YzQxZWViMWRhYjEwODhhN2EwOTE=' \ +--form 'data={"third_id":"5856212145754114","cmd":"balance_allot_cancel","merchant_id":"0000000017"}' +``` + +```plain +https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit?agentId=2653276&version=1.0&reqSerialNo=20220805152236&sign=ZDE2MzU0NjQ5MDE5MTdkYTYzZTExMTYyYzgxZjdlNzM%3D&data=%7B%22cmd%22%3A+%22balance_allot_cancel%22%2C+%22merchant_id%22%3A+%226763019848%22%2C+%22third_id%22%3A+%22202207151719300aNoeMhd%22%7D +``` + +# 4. 接口响应 +## (1)响应参数 +| **参数** | **类型** | **必填** | **说明** | **示例** | +| --- | --- | :---: | --- | --- | +| respCode | String | M | 应答码。
000000,代表分账关系解绑成功
其他应答码代表分账关系解绑失败 | "000000" | +| respMsg | String | M | 应答说明,成功时返回“成功” | "成功" | +| version | String | M | 版本号 | "1.0" | +| reqSerialNo | String | M | 请求流水号 | "20220715122334" | +| data | Object | O | 业务参数
respCode=000000时返回 | {'third_id': '20220715173529LDxUoE9l', 'leshua_id': '2022071563329181703', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '4'}} | +| ┖retcode | String | M | 业务应答码,
0表示业务受理成功,分账成功需要看status
其他表示业务受理失败 | '0' | +| ┖retmsg | String | M | 业务应答说明
业务受理成功时返回“success” | 'success' | +| ┖third_id | String | O | 商户分账单号
retcode=0时返回 | '20220715173529LDxUoE9l' | +| ┖leshua_id | String | O | 乐刷分账单号
retcode=0时返回 | "2022071563329181703" | +| ┖merchant_id | String | O | 分账方的乐刷商户号
retcode=0时返回 | '8613319257' | +| ┖status | String | O | 分账状态,枚举如下:
0:处理中
1:分账成功
2:分账失败
3:分账撤销中
4:分账撤销成功
5:分账撤销失败
说明:分账成功后,资金次日统一出款
retcode=0时返回 | '1' | + + +## (2)响应示例(正常) +```json +{'version': '1.0', 'respCode': '000000', 'respMsg': '成功', 'reqSerialNo': '20220715185901', 'data': {'third_id': '20220715173529LDxUoE9l', 'leshua_id': '2022071563329181703', 'merchant_id': '8613319257', 'retmsg': 'success', 'retcode': '0', 'status': '4'}} + +``` + +## (3)响应示例(异常) +```json +{'version': '1.0', 'respCode': '000103', 'respMsg': '汇总分账失败status_not_allow', 'reqSerialNo': '20220715190421'} +``` + +# 5. 错误码 +**respCode 错误码** + +| **错误码** | **错误描述** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| 000001 | 必填参数为空 | 乐刷 | 标注为“M”的参数值为空 | 提供为“M”的参数值 | +| 000002 | 签名验证失败 | 乐刷 | 请求方提供的sign参数值为错误的签名 | 1. 请确认服务商编号正确且已生成KEY 1. 请求方参照签名算法,修正sign参数值 | +| 000017 | 渠道商未开通此业务 | 乐刷 | 当前服务商为开通该业务 服务商不属于一级服务商,暂未开通该业务 | 联系运营人员 | +| 000026 | 已暂停代理商进件 | 乐刷 | 当前服务商进件商户权限已被关闭 | 联系运营人员 | +| 000027 | 商户已禁用分账功能 | 乐刷 | 该商户分账权限已被关闭 | 联系运营人员 | +| 000004 | 接口调用频率限制 | 乐刷 | 当前服务商调用接口过于频繁 | 降低接口调用频率 | +| 000005 | json格式或者数据类型不正确 | 乐刷 | data 不是一个合法的json字符串或者字段类型不匹配 | 参照文档修正data值 | +| 000051 | 商户号不正确 | 乐刷 | 商户号在乐刷不存在或者不属于该服务商 | 检查修正商户号 | +| 000999 | 系统异常 | 乐刷 | 系统升级或出现未知异常 | 紧急请联系乐刷运营 | +| 000063 | 商户未开通t1结算状态 | 乐刷 | 分账接受放未开通T1结算 | 检查T1结算状态 | + + +**respCode =000000时,业务错误码retcode如下:** + +| **错误码(retcode)** | **错误描述(****retmsg****)** | **错误来源** | **异常原因** | **解决方案** | +| --- | --- | --- | --- | --- | +| -55001 | 分账请求中转失败 | 结算 | 可能调用上游失败 | 重试失败后联系乐刷运营 | +| -55002 | 不存在商户分账号 | 结算 | 商户号不存在或为空 | 检查商户号 | +| -55003 | 商户分账号重复 | 结算 | 多个分账商户号中有重复的商户号 | 检查商户号 | +| -55004 | 创建乐刷分账号失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55005 | 禁止分账 | 结算 | 商户被禁止分账 | 联系乐刷运营 | +| -55006 | 可分账余额小于等于零 | 结算 | 分账余额不足 | 检查是否已分账过,或重复分账, 或分账金额计算是否有误 | +| -55007 | 分账比例异常 | 结算 | 合同配置的分账比例非法 | 联系乐刷运营 | +| -55008 | 订单已分账或商户分账号重复 | 结算 | 订单已分账或商户分账号重复 | 检查分账订单是否已分账 | +| -55009 | 分账方数量限制 | 结算 | 分账方数量限制 | 检查分账方数量,不能超过50 | +| -55010 | 未查询到该订单,请检查后重试 | 结算 | 订单不存在 | 检查订单号 | +| -55011 | 不是分账订单 | 结算 | 订单不是分账订单,不能分账 | 检查订单号对应的订单类型 | +| -55012 | T0交易禁止分账 | 结算 | T0交易禁止分账 | T0交易禁止分账 | +| -55013 | 订单不支持退款 | 结算 | 订单不支持退款 | 订单不支持退款 | +| -55014 | 数据异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55015 | 获取订单号失败 | 结算 | 获取订单号失败 | 检查订单号是否存在 | +| -55016 | json解析异常 | 结算 | 参数格式异常 | 检查参数格式 | +| -55017 | 更新分账信息失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55018 | 创建分账信息失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55019 | 订单未分账 | 结算 | 订单未分账 | 检查订单分账状态 | +| -55020 | 查询分账信息失败 | 结算 | 内部异常 | 联系乐刷运营 | +| -55021 | 该订单已发起分账,请勿重复发起 | 结算 | 该订单已发起分账,请勿重复发起 | 勿重复发起 | +| -55022 | 分账异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55023 | 手续费承担方异常 | 结算 | 手续费承担方异常 | 检查手续费承担方参数 | +| -55024 | 该订单状态下不允许分账,请检查后重试 | 结算 | 该订单状态下不允许分账, 请检查后重试 | 检查订单状态 | +| -55025 | 分账金额有误,请检查后重试 | 结算 | 分账金额有误,请检查后重试 | 分账金额大于订单金额,或分账金额小于0等非法参数 | +| -55026 | 分账方异常 | 结算 | 内部异常 | 联系乐刷运营 | +| -55027 | 主体未配置分账比例 | 结算 | 主体未配置分账比例 | 检查分账主体的分账比例 | +| -55028 | 代理商或商户未开通分账权限 | 结算 | 代理商或商户未开通分账权限 | 联系乐刷运营 | +| -55029 | t0交易不允许分账 | 结算 | t0交易不允许分账 | t0交易不允许分账 | +| -55030 | 分账退款查询标记错误 | 结算 | 内部异常 | 联系乐刷运营 | +| -55031 | 分账次数异常 | 结算 | 分账次数异常 | 检查分账次数 | +| -55032 | 未分账金额不足以退款 | 结算 | 未分账金额不足以退款 | 检查退款金额 | +| -55033 | 接口不支持该功能 | 结算 | 接口不支持该功能 | 联系乐刷运营 | +| -55034 | 撤销失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55035 | 未查询到该分账订单,请检查后重试 | 结算 | 分账订单不存在 | 检查分账订单号 | +| -55036 | 数据库事务处理失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55037 | 分账订单处理中 | 结算 | 分账订单处理中 | 稍后重试 | +| -55038 | 分账订单号重复 | 结算 | 分账订单号重复 | 检查分账订单号后重试 | +| -55039 | 单次分账请求可分账金额必须等于分账金额 | 结算 | 单次分账请求可分账金额必须等于分账金额 | 检查分账金额 | +| -55040 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 结算 | 多次分账,最后一次分账,分账的金额必须是剩余的所有可分账金额 | 检查分账金额 | +| -55041 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 结算 | 多次分账,最后一次分账,分账对象必须有交易主体商户 | 检查分账商户信息 | +| -55042 | 可分账金额不足或订单已分账 | 结算 | 可分账金额不足或订单已分账 | 检查分账状态和已分账金额 | +| -55043 | 分账订单更新完成状态失败 | 结算 | 分账订单更新完成状态失败 | 重试失败后联系乐刷运营 | +| -55044 | 手续费承担次数异常 | 结算 | 手续费承担次数异常 | 检查商户是否承担了多次手续费 | +| -55045 | 分账查询历史库限流 | 结算 | 内部异常 | 稍后重试 | +| -55046 | 分账状态异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55047 | 生成结算侧分账服务请求异常 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55048 | 请求结算侧分账服务结果未知 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55049 | 结算侧分账服务异常返回失败 | 结算 | 内部异常 | 重试失败后联系乐刷运营 | +| -55050 | 参数不合法 | 结算 | 参数不合法 | 检查参数中的必填、类型(特别是数字字符类型不能混用)等 | +| -55051 | 同一笔订单存在分账中,不允许继续分账 | 结算 | 同一笔订单存在分账中,不允许继续分账 | 有分账正在进行,请稍后重试 | +| -55052 | 不支持该分账退款模式 | 结算 | 不支持该分账退款模式 | 不支持该分账退款模式 | + + + + +# 6. 异步通知触发 +无 + +# 7.Demo示例 +```python +import requests +import hashlib +import base64 +import datetime +import json +##定义签名方法 +def sign_md5_base64(data,key): + signStr = "lepos" + key + data + m2 = hashlib.md5() + m2.update(signStr.encode('utf-8')) + md5str = m2.hexdigest() + base64Str = base64.b64encode(md5str.encode('utf-8')).decode() + return base64Str + +##定义流水号生成方法 +def reqSerialNo(): + now = datetime.datetime.now() + order_id = now.strftime("%Y-%m-%d-%H-%M-%S").replace("-", "") + return order_id + +##服务商参数 +agentId="" ## 这里填写服务商编号 +key="" ## 这里填写服务商密钥,与服务商进件和交易密钥一致 +request_url="https://saas-mch.leshuazf.com/api/share-merchant/canceSumAccredit" ## 这里填写请求的接口地址 + +header={'Content-Type': 'application/x-www-form-urlencoded'} +##业务参数 +data={ + "cmd": "balance_allot_cancel", + "merchant_id": "6763019848", + "third_id": "202207151719300aNoeMhd" +} + +body = { + "agentId":agentId, + "version": "1.0", + "reqSerialNo": reqSerialNo(), + "sign": sign_md5_base64(json.dumps(data), key), + "data": json.dumps(data) +} + +req = requests.post(url=request_url, data=body,headers=header) + + +if req.status_code != 200: + print("Http请求失败,状态码:" + str(req.status_code) + ",错误信息:" + req.text) +else: + print(req.json()) + +``` diff --git a/docs/商家余额分账/产品介绍.md b/docs/商家余额分账/产品介绍.md new file mode 100644 index 0000000..95f3bf4 --- /dev/null +++ b/docs/商家余额分账/产品介绍.md @@ -0,0 +1,163 @@ +# 1. 阅读对象 +服务商(即SaaS 一级代理商) + +# 2. 产品简介 +商家分账产品,是乐刷面向商家提供的商家资金管理产品,用以解决实际收款商家需要将收款资金按照一定的周期、比例、金额等规则分账给收款商家合作方的场景诉求,收款商家的合作方可以是合作伙伴、供应商、服务商、平台方等角色。 + +服务商在完成业务申请后,商家可以直接使用乐刷提供的“商户聚合支付平台”PC页面发起分账,或者 + +由商家的服务商通过调用接口的方式代替商家进行分账。 + +# 3. 名词解释 +| **序号** | **名词** | **解释** | +| :---: | --- | --- | +| 1 | 分账方 | 指商品或者服务的提供方,这里指实际收款商家本身。 | +| 2 | 分账接收方 | 与实际收款商家有合作关系的第三方,比如服务商、软件供应商、供货商、平台方等角色,接收分账资金的商家。 | +| 3 | 服务商 | 为实际收款商家提供各种产品和服务的提供方,这里指乐刷的1级代理商。 | +| 4 | 分账比例 | 指分账方的分账订单交易金额中,最大可分给分账接收方的资金比例。 | +| 5 | 分账关系 | 分账方和分账接收方建立绑定关系,该分账方的分账接收方仅限于建立绑定关系的对象。 | +| 6 | 分账标记 | 分账方发起交易时给交易订单(含二维码支付订单和刷卡支付订单)打上的分账标识,仅打上分账标识的订单方可参与商家分账。 | +| 7 | 待分账账户 | 分账方的分账托管账本,分账方的交易打上分账标记后,交易订单的资金将收到分账方的分账托管账本,后续的分账将从该账本中划拨资金给商户进行出款。 | +| 8 | 订单分账 | 分账模式的一种,服务商替分账方发起分账时,基于某笔的交易订单发起分账的分账模式。 | +| 9 | 余额分账 | 分账模式的一种,服务商替分账方发起分账时,基于分账方待分账账户余额发起分账的分账模式,该分账模式无需强依赖具体的交易订单。
分账方的交易订单打上分账标记后,会实时清分入账至分账方的待分账账户中。 | +| 11 | 自动分账 | 余额分账模式的一种,服务商无需开发,通过商户乐刷后台配置好的分账规则执行分账的分账模式。
手动分账,属于不配置分账规则,商户按需求分账资金给予接受方。 | + + +# 3. 适用场景 +## (1)电商平台场景分润 +某电商平台,消费者完成支付后,订单资金收到分账方的待分账账户内,商户根据与物流服务商、流量推广方的约定,将物流服务费、流量推广费通过分账功能分给物流服务商和流量推广方。 + +## (2)服务商抽佣 +在线下交易中,商家通过扫码枪、码牌、POS机等收款产品收款,消费者完成支付支付后,订单资金收到商家的待分账账户内,在经过商家授权后,线下服务商可通过分账产品收取佣金,比如 POS 推广费、智能收银设备服务费等。 + +## (3)代运营平台抽佣场景 +代运营平台给商家提供商家销售系统、小程序引流、营销发券、会员权益等会员营销服务后,采用基于商家的收款订单抽取一定比例佣金的方式收取服务费 + +## (4)设备服务运维场景 +实际收款商家的收款设备提供方给商家提供收银机器、服务后,收款商家需要按照一定的比例或者金额将维修服务费分账给设备提供方。 + +## (5)分润给合作伙伴 +实际收款商家在与他方合作时,可以用“分账”功能,将交易资金分给合作伙伴。 + +例如: + +1. 商家的供应商给商家提供消费产品供应服务,商家可通过零售交易款将部分资金分账给供应商。 +2. 代理商根据与车场商户之间的约定,将停车费分配给智能停车系统商户等。 + +# 4. 产品流程 +## 4.1 分账业务流程图 +![画板](https://cdn.nlark.com/yuque/0/2024/jpeg/1233785/1704271232345-43016a57-0b91-491c-bfe6-b1c4085182f8.jpeg) + + + +## 4.2 商户分账资金流信息流 + + +## 4.3 商户分账业务全流程 + + +![画板](https://cdn.nlark.com/yuque/0/2022/jpeg/529841/1659337621852-30aaeb15-8973-4c79-bd17-8bb597852f5e.jpeg) + +商户分账业务全流程,具体说明如下: + +### (1)商户入网 +拟参与资金分账的各方,包括分账方(即收款商家)、分账接收方(如供应商、物流方、平台方等收款商家的合作方),均需以商户的身份入网至乐刷,获取乐刷的乐刷商户号,后续的分账业务均基于乐刷商户号进行。 + + + +分账方和分账接收方,均支持对私和对公结算。 + + + +目前乐刷提供两种方式供服务商完成商户入网: + +1) API接口:服务商通过接入乐刷提供的商户入网接口,在自有的产品和系统上增加相应的商户入网资料提交入口,完成分账方和分账接收方的入网。 + +注:分账接收方如无需走交易,则无需进行费率配置等操作。 + +2)代理商平台入网:乐刷为服务商提供服务商业务管理平台,包括服务商PC后台和服务商APP两种,服务商收集到商户的资金后,可登录该平台录入并提交资料完成商户入网 + +### (2 )商户开通分账 +商家要进行资金分账,需要给分账方开通分账的功能,开通分账的功能需要商家签署对应的协议。目前乐刷提供了两种方式供服务商推进商家完成分账功能的开通: + +1)API接口:服务商通过接口乐刷提供的商户分账开通接口,完成商户分账功能的开通。目前乐刷支持通过接口获取电子协议和通过接口上传线下纸质协议的方式,商户完成协议签署后,乐刷进行审核,审核通过即完成分账功能的开通。 + +详细的开通流程见操作指引。 + +2)商户PC开通:服务商不想进行开发的情况下,可以让商家登录乐刷提供的商户PC平台,在乐刷的商户PC平台内完成签署。 + +详细的页面操作流程见操作指引。 + +### (3)商户交易 +商户的分账资金来源于其收款产生的交易,不支持非收单资金进行分账(如外部资金充值等),目前商户可通过以下两种方式完成商户交易: + +1)API接口交易:服务商/商户通过接入乐刷提供的交易接口完成交易。 + +2)乐刷码牌、POS机交易:服务商不开发的情况下,也可以通过使用乐刷提供的码牌、码牌音箱、智能POS、传统POS等收款设备来完成收款。 + +注:目前支持按照订单维度和按照商户维度确定交易是否需要分账,如某部分订单的资金需要作为普通订单不参与分账,则需要通过接口的方式接入乐刷支付并在支付接口中标记哪笔订单是否需要分账。 + +### (4)商户分账 +乐刷的分账为异步分账,即支付成功后方可发起分账。目前乐刷提供多种方式供服务商发起分账,具体如下: + +1)商户PC分账:商户登录乐刷的商户PC平台,在商户PC页面操作和配置分账。目前商户可在商户PC平台选择配置自动分账规则和手动发起分账两种模式,商户可选任一一种发起分账。 + +自动分账:商户配置分账规则,乐刷按照商户配置的分账规则,每日将交易订单的资金分给各分账接收方 + +手动分账:商户在页面上填写分账接收方和分账金额,提交请求发起分账。 + +2)API接口分账:服务商具备自己的业务系统的情况下,可以通过调用乐刷的分账接口向乐刷发起分账请求。根据分账的模式不同,乐刷提供三种方式向乐刷发起分账请求: + ++ 订单分账:请求乐刷发起分账时,需要明确哪笔交易订单需要分账 ++ 余额分账:请求乐刷发起分账时,无需指明具体的订单,只要商户的待分账账户资金足够,并且分账给他人的资金在规定比例内,即可发起分账。 ++ 自动分账:通过接口将商户分账的规则提交给乐刷,乐刷按照提交的规则每日自动给商户发起分账。 + +### (5)资金出款 +商户发起分账请求且请求成功后,乐刷默认D1日将分账资金打款至各分账接收方的银行卡。举例:1号发起的分账,2号早上打款。 + +若商家使用乐刷的“商户聚合支付平台”发起支付,则可以选择是否分账后立即到账。注:如使用分账立即到账功能,后续会收取一定的服务费。 + +若商家使用乐刷提供的接口发起分账,暂时不支持分账后立即到账。 + + + +# 5. 产品特性 +**(1)多种落地实现方式,满足不同程度开发能力的服务商所需** + +乐刷提供分账API和商户分账后台,服务商可根据自身的开发能力,灵活选择不同的业务落地模式。 + + + +**(2)多种分账方式,满足各类业务场景的分账诉求** + +针对不同的业务分账场景,乐刷提供了多样的分账模式,可以基于交易订单发起分账,也可以基于商户余额发起分账,满足不同业务场景的复杂分账需求。 + +**** + +**(3)资金安全可控** + +基于收单资金分账,真实资金留存在乐刷备付金账户,商户的分账资金记名在乐刷的托管账本,分账后方可出金,资金安全得到保证,避免资金混用和乱用。 + + + +**(4)支持多种类型的客户参与分账** + +乐刷的分账产品,支持向个人、供应商、平台方、服务商等多方发起分账,并且可根据情况结算至对公账户和对私账户。 + + + +**(5)分账资金控制合理** + +商户进行分账,需要签署协议并约定分账比例和金额,在约定的资金范围内发起分账,避免商户的交易资金被不合理的挪用。 + + + +**(6)****可基于余额或每笔订单进行分账,可单次或多次分账,每次分给多方** + +支持按余额传入具体的分账接收方和分账金额进行分账,每日无次数限制**,**每次分账最多可以分给**50个接收方**。 + +也支持按订单传入具体的分账接收方和分账金额进行分账,一笔订单最多可以分**20次,**每次分账最多可以分给**50个接收方**。 + +# +# + diff --git a/docs/数据库SQL约束与运维.md b/docs/数据库SQL约束与运维.md new file mode 100644 index 0000000..5ea65ad --- /dev/null +++ b/docs/数据库SQL约束与运维.md @@ -0,0 +1,77 @@ +# 数据库 SQL 约束与运维 + +本项目使用 MySQL 8.4、GORM 和 Goose。本文是提交、上线和故障排查的数据库基线;SQL 静态检查不能替代真实数据量下的执行计划。 + +## 提交前约束 + +新增或修改 SQL、GORM 查询或迁移必须满足: + +1. 列表查询必须有分页、最大数量或明确日期范围,禁止无界读取订单、流水、消息和日志历史。 +2. `GROUP BY`、`COUNT(DISTINCT)`、窗口函数、跨表聚合、`UNION` 和相关子查询必须在接近生产数据量的 MySQL 上保存 `EXPLAIN FORMAT=JSON` 或 `EXPLAIN ANALYZE` 结果。 +3. 高频查询先限定当前时间范围或当前页 ID,再做关联和聚合;禁止读取用户全部历史记录后在应用层过滤。 +4. 新增索引必须通过新的 Goose migration 提交,并在迁移注释中说明覆盖的查询条件、排序和基数;大表索引要评估锁影响和上线窗口。 +5. SQL 值必须使用 GORM 参数绑定或 `?` 占位符,禁止拼接请求参数。动态排序字段只能来自白名单。 +6. 已应用的迁移文件禁止修改或删除,只能新增迁移完成结构修复;除 `000001_init.sql` 外的新迁移必须包含 `-- +goose Up` 和 `-- +goose Down`。 +7. 角色、权限、推送渠道、客服组等小型配置表允许全量读取,但必须保持数量边界或在本文件的白名单中登记;业务数据不得套用该例外。 + +本地 `scripts/check.sh` 会执行 `scripts/check-sql-guard.sh`,保护关键索引、时间范围和迁移完整性。它只防止关键代码被重构移除,不判断执行计划是否优秀。 + +## 执行计划 + +在维护库或只读副本上替换真实参数后执行: + +```sql +EXPLAIN FORMAT=JSON +SELECT ...; + +EXPLAIN ANALYZE +SELECT ...; +``` + +重点检查扫描行数、实际返回行数、索引使用、临时表、文件排序和 join 顺序。财务首页的仪表盘、结算明细和提现合并列表属于必须定期复核的查询。 + +## 生产观测 + +生产 MySQL 应保持 `performance_schema` 开启,并启用慢查询日志。项目的 `deploy/mysql/my.cnf` 默认记录超过 500ms 的语句;应用日志只记录 SQL 形状、耗时和返回行数,不记录绑定参数值。 + +常用排查 SQL: + +```sql +SELECT + DIGEST_TEXT, + COUNT_STAR, + ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds, + ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms, + SUM_ROWS_SENT, + SUM_ROWS_EXAMINED +FROM performance_schema.events_statements_summary_by_digest +ORDER BY SUM_TIMER_WAIT DESC +LIMIT 30; + +SELECT THREAD_ID, EVENT_NAME, TIMER_WAIT, SQL_TEXT, ROWS_EXAMINED +FROM performance_schema.events_statements_current +WHERE SQL_TEXT IS NOT NULL +ORDER BY TIMER_WAIT DESC; +``` + +应用慢查询阈值由 `DATABASE_SLOW_QUERY_THRESHOLD_MS` 配置。阈值调整后要同步检查 MySQL `long_query_time`,避免应用和数据库观测口径长期不一致。 + +## 连接、超时与容量 + +- `DATABASE_MAX_OPEN_CONNS` 是单个 backend 实例的连接上限,总量必须按实例数核算,不能简单随实例扩容同比增加。 +- `DATABASE_MAX_IDLE_CONNS`、`DATABASE_CONN_MAX_LIFETIME_MINUTES` 和 `DATABASE_CONN_MAX_IDLE_TIME_MINUTES` 控制空闲连接和连接轮换。 +- 请求必须设置 Go `context` 截止时间;锁等待由 MySQL `innodb_lock_wait_timeout` 控制。长报表查询应进入异步任务或汇总表。 +- 数据量增长后,排行榜、财务历史统计和审计报表应迁移到日汇总表、缓存或只读副本,不能持续依赖在线全表聚合。 +- 修改连接池或容器资源后,用 `docker compose config` 核对最终插值;资源限额应高于稳定 RSS 峰值并留出数据库连接和备份余量。 + +## 备份与恢复 + +生产使用 XtraBackup 全量备份加 MySQL ROW binlog 归档实现时间点恢复。备份对象先客户端加密,再使用独立 OSS bucket 的服务端加密;恢复必须在隔离目录和隔离 MySQL 容器演练,禁止直接向生产实例回放。 + +```bash +./scripts/backup-online.sh status +./scripts/archive-binlog.sh verify +./scripts/restore-online.sh prepare +``` + +备份完成标识、SHA-256、manifest、server UUID 和 Goose 版本必须一致;恢复演练至少按月验证一次,并记录恢复时间点和结果。 diff --git a/docs/结算后订单利润调整方案.md b/docs/结算后订单利润调整方案.md new file mode 100644 index 0000000..0c25dfe --- /dev/null +++ b/docs/结算后订单利润调整方案.md @@ -0,0 +1,166 @@ +# 结算后订单利润调整方案 + +## 1. 背景与目标 + +订单完成结算后,用户可能继续发生售后补偿、部分退款、号主收入修正等情况。需要在不破坏正式结算快照和历史资金流水的前提下,记录每次调整,并展示订单的最终实际收入和平台利润。 + +本方案针对正常租赁订单,提号订单已有类似实现,可复用其“追加调整流水”的思路。 + +## 2. 当前系统结算口径 + +- 正式结算基准使用最终结算记录 `order_checkouts` 的正式金额,不能使用发布价格、预计价格或订单初始价格。 +- 号主正式收入为 `order_checkouts.owner_income_amount_cent`。 +- 普通订单结算时通过 `wallet_ledger` 给号主入账。 +- 平台代管订单结算时把待付号主金额记录在 `rental_orders.offline_settlement_amount_cent`。 +- 订单过程留痕使用 `process_events`,管理员操作审计使用 `audit_logs`。 +- 财务明细目前通过结算记录、号主钱包入账和平台代管线下结算金额计算平台净额。 + +相关代码: + +- `backend/internal/modules/order/checkout_finalize.go` +- `backend/internal/modules/order/queries.go` +- `backend/internal/modules/adminfinance/detail.go` +- `backend/internal/model/order_checkout.go` +- `backend/internal/modules/pickup/repository.go` + +## 3. 核心设计 + +不要修改原始结算记录、订单线下结算金额或已有钱包流水。新增不可变的订单财务调整流水: + +```text +admin_order_financial_adjustments +``` + +建议字段: + +```text +id +order_id +adjustment_no 唯一调整单号,用于幂等 +settlement_mode owner_wallet / platform_managed +owner_income_delta_cent 号主收入调整,可正可负 +renter_compensation_cent 本次租客补偿金额 +compensation_bearer platform / owner +platform_profit_delta_cent 系统计算的利润变化,不允许前端直接填写 +owner_settlement_status settled / pending / recovery_pending +renter_refund_status none / pending / refunded / failed +reason +reference_no 售后单号或外部凭证号 +created_by +processed_by +processed_at +remark +created_at +``` + +最终结果按流水累计: + +```text +最终号主收入 = 正式结算号主收入 + 所有有效 owner_income_delta_cent +最终租客退款 = 正式结算退款 + 所有已完成 renter_compensation_cent +最终平台利润 = 正式结算利润 + 所有 platform_profit_delta_cent +``` + +`platform_profit_delta_cent` 由服务端根据补偿承担方和号主收入变化自动计算,避免出现“号主收入、租客退款、平台利润”三者不一致。 + +## 4. 后台操作口径 + +后台输入“目标最终金额”,系统自动计算差额,不建议让客服手工输入差额: + +1. 展示正式结算号主收入和当前实际号主收入。 +2. 输入调整后的号主最终收入(可不调整)。 +3. 输入本次售后补偿金额。 +4. 选择补偿承担方:平台或号主。 +5. 填写售后单号、原因和备注。 +6. 提交前预览本次差额、最终号主收入、最终平台利润和待处理资金状态。 + +示例:正式号主收入 100 元,平台利润 20 元,售后补偿 3 元: + +- 平台承担:号主仍为 100 元,平台利润减少 3 元。 +- 号主承担:号主最终收入减少 3 元,并产生 3 元退款;平台利润原则上不变。 + +## 5. 资金处理 + +### 普通号主钱包订单 + +- 增加号主收入:在同一数据库事务中追加钱包入账流水。 +- 减少号主收入:余额足够时追加钱包扣款流水。 +- 余额不足:记录 `recovery_pending`,不能把钱包扣成负数,后续由财务处理追回。 +- 平台承担租客补偿:提交支付退款任务,退款完成后再将调整标记为已处理。 + +### 平台代管订单 + +- 原线下款尚未支付:调整金额并入待打款任务,原始结算金额保持不变。 +- 原线下款已支付:增加新的待补款或待追回任务。 +- 不直接覆盖 `offline_settlement_amount_cent`,否则会丢失首次结算记录。 + +### 退款幂等 + +当前退款逻辑按订单和 `biz_type` 查找已有退款单。多次售后不能共用同一个退款业务键,应使用 `adjustment_no` 作为每次退款的唯一幂等键,并补充退款重试和失败状态。 + +## 6. API、权限与页面 + +建议接口: + +```text +GET /admin/orders/:id/financial-adjustments +POST /admin/orders/:id/financial-adjustments +POST /admin/order-financial-adjustments/:id/settle +``` + +新增权限: + +```text +order:financial_adjust +``` + +不建议复用订单关闭或标记异常权限。 + +订单详情的结算区域增加: + +- 正式结算号主收入 +- 售后调整合计 +- 当前实际号主收入 +- 正式退款与售后补偿 +- 当前实际平台利润 +- 调整流水、退款状态、补款/追回状态 +- “新增售后补偿/修改利润”按钮 + +需要同步修改订单详情、财务明细、财务仪表盘和线下出款待办的聚合查询。 + +## 7. 状态与审计 + +号主资金和租客退款可能分别处于不同状态,因此不能只使用一个总状态。建议分别保存号主结算状态和租客退款状态,再由接口计算调整单的整体状态。 + +创建调整、钱包入账/扣款、退款任务创建、退款完成/失败、线下补款/追回确认,都要写入 `process_events` 和 `audit_logs`,并记录调整前后金额、操作人、原因和凭证号。 + +## 8. 测试范围 + +- 普通订单增加或减少号主收入。 +- 钱包余额不足进入待追回。 +- 平台代管未打款时调整。 +- 平台代管已打款后补款或追回。 +- 平台承担补偿与号主承担补偿。 +- 退款失败、重试和重复请求幂等。 +- 同一订单多次售后调整的累计结果。 +- 正式结算记录和原始钱包流水不可被修改。 +- 财务明细、仪表盘和出款待办金额一致。 + +## 9. 分支核查(2026-08-30) + +- 当前 `main`:`98f6ee9`,包含最近的客服上传账号统计。 +- 本地 `dev`:`85332df`。 +- 远端 `gitea/dev`:`85332df`,与本地 `dev` 一致。 +- `dev` 包含 `80e55ba 支持提号完成后财务调整`,相关实现位于 `admin_pickup_financial_adjustments`、提号仓库/服务和提号管理页面。 +- `dev` 尚未包含正常租赁订单的结算后利润调整。 +- `dev` 与 `main` 的共同基点为 `f48da14`;`dev` 有 1 个独有提交,`main` 有 49 个后续提交。不能直接整体合并 `dev`,如需保留其中的接口最小化和私有文件加固,应单独评审后再移植 `85332df`。 + +## 10. 实施顺序 + +1. 增加迁移和 `AdminOrderFinancialAdjustment` 模型。 +2. 增加订单调整仓库、服务、接口、权限和审计。 +3. 接入钱包、退款幂等键、平台代管补款/追回。 +4. 修改订单详情和财务聚合查询。 +5. 补齐普通订单、平台代管、余额不足、退款失败和重复调整测试。 + +实施前需要确认:补偿承担方规则、退款是否全部原路退回、以及是否统一采用“目标最终金额”输入。推荐采用本文方案。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b784352..691e0c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -92,30 +93,30 @@ "license": "MIT" }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -125,18 +126,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1801,17 +1812,48 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1820,13 +1862,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1857,9 +1899,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1870,13 +1912,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1884,14 +1926,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1900,9 +1942,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1910,13 +1952,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2252,6 +2294,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -3262,6 +3333,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3320,6 +3401,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3411,6 +3499,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3586,6 +3713,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", @@ -4306,6 +4461,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4749,19 +4917,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4789,12 +4957,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 49bf970..c7ac654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check .", "check": "npm run format:check && npm run lint:strict && npm run typecheck", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { @@ -33,6 +34,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e27b114..d3d860b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,10 +5,12 @@ import { computed } from 'vue' import { RouterView, useRoute } from 'vue-router' import InAppBrowserPrompt from './components/InAppBrowserPrompt.vue' +import { useSessionMonitor } from './features/auth/composables/useSessionMonitor' import AdminLayout from './layouts/AdminLayout.vue' import AppLayout from './layouts/AppLayout.vue' const route = useRoute() +useSessionMonitor() const layout = computed(() => { if (route.path === '/m' || route.path.startsWith('/m/')) { return 'blank' diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index 862348d..38c536d 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -36,10 +36,19 @@ export interface ListingDailyOverview { trend: ListingDayStats[] today_channels: ListingChannelDayStats[] channel_trend: ListingChannelDayStats[] + today_uploaders: ListingUploaderDayStats[] + uploader_trend: ListingUploaderDayStats[] days: number timezone: string } +export interface ListingUploaderDayStats { + date: string + uploader_id: number + uploader_name: string + upload_count: number +} + export interface DashboardRecentOrder { id: number order_no: string @@ -90,6 +99,8 @@ export async function fetchAdminDashboard() { trend: listingDaily?.trend ?? [], today_channels: listingDaily?.today_channels ?? [], channel_trend: listingDaily?.channel_trend ?? [], + today_uploaders: listingDaily?.today_uploaders ?? [], + uploader_trend: listingDaily?.uploader_trend ?? [], days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index fb27d32..74145c8 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -68,6 +68,8 @@ export interface FinanceDailyItem { withdrawal_paid_count: number manual_paid_amount_cent: number manual_paid_count: number + operating_expense_amount_cent: number + operating_expense_count: number disbursement_paid_amount_cent: number disbursement_paid_count: number } @@ -107,6 +109,11 @@ export interface FinanceDisbursementSummary { manual_paid_count: number } +export interface OperatingExpenseSummary { + amount_cent: number + count: number +} + export interface FinanceDisbursementListSummary { record_count: number paid_amount_cent: number @@ -119,7 +126,7 @@ export interface FinanceDisbursementListSummary { } export interface FinanceDisbursementItem { - source_type: 'platform_managed' | 'withdrawal' | 'manual_offline' + source_type: 'platform_managed' | 'pickup' | 'pickup_adjustment' | 'withdrawal' | 'manual_offline' source_id: number business_no: string order_id?: number @@ -144,6 +151,7 @@ export interface FinanceDisbursementItem { operator_name: string remark: string category: string + custom_category_name: string voucher_url: string voided_at?: string voided_by?: number @@ -153,6 +161,7 @@ export interface FinanceDisbursementItem { export interface CreateManualDisbursementPayload { category: string + custom_category_name?: string payee_name: string amount_cent: number paid_at: string @@ -164,6 +173,7 @@ export interface ManualDisbursement { id: number disbursement_no: string category: string + custom_category_name: string payee_name: string amount_cent: number paid_at: string @@ -204,9 +214,61 @@ export interface FinanceDashboard { pickup_summary: FinancePickupSummary mohong_summary: FinanceMohongSummary disbursement_summary: FinanceDisbursementSummary + operating_expense_summary: OperatingExpenseSummary generated_at: string } +export interface OperatingExpense { + id: number + expense_no: string + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url: string + status: 'paid' | 'voided' + created_by: number + created_by_name: string + voided_by?: number + voided_by_name: string + voided_at?: string + void_reason: string + created_at: string +} + +export interface OperatingExpenseQuery extends FinanceDateQuery { + status?: string + category?: string + keyword?: string + date_type?: 'created' | 'occurred' + page?: number + page_size?: number +} + +export interface OperatingExpenseListSummary { + record_count: number + paid_amount_cent: number + paid_count: number +} + +export interface OperatingExpenseList { + items: OperatingExpense[] + total: number + page: number + page_size: number + summary: OperatingExpenseListSummary +} + +export interface CreateOperatingExpensePayload { + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url?: string +} + export interface FinanceDetail { order_id: number order_no: string @@ -306,6 +368,10 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { manual_paid_amount_cent: 0, manual_paid_count: 0, }, + operating_expense_summary: data.data?.operating_expense_summary ?? { + amount_cent: 0, + count: 0, + }, } } @@ -347,6 +413,37 @@ export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery } } +export async function fetchOperatingExpenses(query: OperatingExpenseQuery = {}) { + const { data } = await apiClient.get>( + '/admin/finance/operating-expenses', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + summary: result?.summary ?? { record_count: 0, paid_amount_cent: 0, paid_count: 0 }, + } +} + +export async function createOperatingExpense(payload: CreateOperatingExpensePayload) { + const { data } = await apiClient.post>( + '/admin/finance/operating-expenses', + payload + ) + return data.data +} + +export async function voidOperatingExpense(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/finance/operating-expenses/${id}/void`, + { reason } + ) + return data.data +} + export async function createManualDisbursement(payload: CreateManualDisbursementPayload) { const { data } = await apiClient.post>( '/admin/finance/manual-disbursements', diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index daff9f5..d64d3b5 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -63,6 +63,25 @@ export interface AdminPickupFinancialAdjustment { created_at: string } +export interface AdminPickupProcessEvent { + id: number + stage: string + action: string + actor_type: string + actor_id: number + actor_name: string + target_type: string + target_id?: number + target_name: string + content: string + reason: string + payload: Record + attachment_urls: string[] + state_before: Record + state_after: Record + created_at: string +} + export interface AvailableListing { id: number listing_no: string @@ -226,6 +245,13 @@ export async function fetchAdminPickupFinancialAdjustments(id: string | number) return Array.isArray(data.data) ? data.data : [] } +export async function fetchAdminPickupProcessEvents(id: string | number) { + const { data } = await apiClient.get>( + `/admin/pickups/${id}/process-events` + ) + return Array.isArray(data.data?.items) ? data.data.items : [] +} + export async function settleAdminPickupFinancialAdjustment( id: number, req: AdminPickupFinancialAdjustmentSettlementRequest = {} diff --git a/frontend/src/features/admin/api/backup.ts b/frontend/src/features/admin/api/backup.ts new file mode 100644 index 0000000..073a59f --- /dev/null +++ b/frontend/src/features/admin/api/backup.ts @@ -0,0 +1,35 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface BackupSchedule { + enabled: boolean + full_hour: number + full_minute: number + binlog_interval_minutes: number +} + +export interface BackupStatus { + generated_at: string + health: 'healthy' | 'warning' + last_full: { local_complete: string; remote_complete: string } + last_binlog: { file: string; remote_dir: string } + last_job: { type: string; status: string; at: string; message: string } +} + +export async function fetchBackupStatus() { + const { data } = await apiClient.get>('/admin/backups/status') + return data.data +} + +export async function fetchBackupSchedule() { + const { data } = await apiClient.get>('/admin/backups/schedule') + return data.data +} + +export async function updateBackupSchedule(payload: BackupSchedule) { + const { data } = await apiClient.put>( + '/admin/backups/schedule', + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.css b/frontend/src/features/admin/components/AdminOrderMetrics.css new file mode 100644 index 0000000..f82fa52 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.css @@ -0,0 +1,18 @@ +.order-metrics { + margin-bottom: 20px; +} + +.metric-card strong { + overflow-wrap: anywhere; + font-size: 20px; + line-height: 1.25; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.vue b/frontend/src/features/admin/components/AdminOrderMetrics.vue new file mode 100644 index 0000000..b3ce1e6 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.css b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css new file mode 100644 index 0000000..d1751d4 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css @@ -0,0 +1,67 @@ +.panel-heading, +.record-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.panel-heading { + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; +} + +.record-list, +.record-item { + display: grid; + gap: 12px; +} + +.record-item { + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.danger-text { + color: #dc2626; +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue new file mode 100644 index 0000000..826da4a --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/features/admin/components/ProcessTimeline.vue b/frontend/src/features/admin/components/ProcessTimeline.vue new file mode 100644 index 0000000..e9826f7 --- /dev/null +++ b/frontend/src/features/admin/components/ProcessTimeline.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue index 87c1a03..ae08aca 100644 --- a/frontend/src/features/admin/components/PublishOptionsDialog.vue +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -294,6 +294,13 @@ async function handleSave() {
发布规则
+ + + { + key: string + filters: TFilters + currentPage: Ref + pageSize: Ref + scrollSelector?: string +} + +interface StoredAdminListState { + filters?: TFilters + currentPage?: number + pageSize?: number + scrollTop?: number +} + +/** + * 在同一后台标签页内保留列表筛选、分页与滚动位置。 + * 数据列表本身仍由页面按当前筛选重新请求,避免展示过期数据。 + */ +export function useAdminListState( + options: AdminListStateOptions +) { + const storageKey = `hfb.admin.list-state.${options.key}` + const scrollSelector = options.scrollSelector || '.admin-main' + let stateSaveTimer: number | undefined + let scrollSaveTimer: number | undefined + + function readState() { + if (typeof window === 'undefined') return null + try { + const raw = window.sessionStorage.getItem(storageKey) + if (!raw) return null + const state = JSON.parse(raw) as StoredAdminListState + return state && typeof state === 'object' ? state : null + } catch { + return null + } + } + + function scrollContainer() { + return document.querySelector(scrollSelector) + } + + function saveState() { + if (typeof window === 'undefined') return + try { + const state: StoredAdminListState = { + filters: { ...options.filters }, + currentPage: options.currentPage.value, + pageSize: options.pageSize.value, + scrollTop: scrollContainer()?.scrollTop || 0, + } + window.sessionStorage.setItem(storageKey, JSON.stringify(state)) + } catch { + // sessionStorage 不可用时仅不恢复状态,不影响列表使用。 + } + } + + function restoreState() { + const state = readState() + if (!state) return + if (state.filters && typeof state.filters === 'object') { + Object.assign(options.filters, state.filters) + } + if (typeof state.currentPage === 'number' && state.currentPage > 0) { + options.currentPage.value = state.currentPage + } + if (typeof state.pageSize === 'number' && state.pageSize > 0) { + options.pageSize.value = state.pageSize + } + } + + function restoreScroll() { + const scrollTop = Number(readState()?.scrollTop || 0) + if (!Number.isFinite(scrollTop) || scrollTop <= 0) return + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const container = scrollContainer() + if (container) container.scrollTop = scrollTop + }) + }) + } + + function scheduleSave() { + if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer) + stateSaveTimer = window.setTimeout(saveState, 200) + } + + function handleScroll() { + if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer) + scrollSaveTimer = window.setTimeout(saveState, 300) + } + + // 在页面首次请求前恢复筛选和分页。 + restoreState() + + watch([options.filters, options.currentPage, options.pageSize], scheduleSave, { deep: true }) + + onMounted(() => { + scrollContainer()?.addEventListener('scroll', handleScroll, { passive: true }) + }) + + onActivated(() => { + scrollContainer()?.addEventListener('scroll', handleScroll, { passive: true }) + }) + + onDeactivated(() => { + saveState() + scrollContainer()?.removeEventListener('scroll', handleScroll) + }) + + onBeforeRouteLeave(() => { + saveState() + }) + + onBeforeUnmount(() => { + saveState() + scrollContainer()?.removeEventListener('scroll', handleScroll) + if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer) + if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer) + }) + + return { restoreScroll, saveState } +} diff --git a/frontend/src/features/admin/composables/usePendingRefundCount.ts b/frontend/src/features/admin/composables/usePendingRefundCount.ts new file mode 100644 index 0000000..e53ac73 --- /dev/null +++ b/frontend/src/features/admin/composables/usePendingRefundCount.ts @@ -0,0 +1,89 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { fetchPendingRefundCount } from '@/features/orders/api/orders' +import { useAdminSessionStore } from '@/stores/adminSession' + +export const pendingRefundCountChangedEvent = 'pending-refund-count-changed' + +export function usePendingRefundCount(route: RouteLocationNormalizedLoaded) { + const adminSession = useAdminSessionStore() + const count = ref(0) + let timer: number | null = null + let requestID = 0 + + const label = computed(() => (count.value > 99 ? '99+' : String(count.value))) + const canReviewRefund = computed( + () => adminSession.isSuperAdmin || adminSession.hasPermission('order:close') + ) + + function stopPolling() { + if (timer === null) return + window.clearInterval(timer) + timer = null + } + + async function refresh() { + if (!canReviewRefund.value) { + count.value = 0 + return + } + const currentID = ++requestID + try { + const nextCount = await fetchPendingRefundCount() + if (currentID === requestID) count.value = nextCount + } catch { + // 角标加载失败时保持当前数字,避免影响后台其他操作。 + } + } + + function startPolling() { + stopPolling() + void refresh() + timer = window.setInterval(refresh, 30_000) + } + + function handleVisibilityChange() { + if (document.hidden) { + stopPolling() + return + } + startPolling() + } + + function handleCountChanged() { + void refresh() + } + + onMounted(() => { + window.addEventListener(pendingRefundCountChangedEvent, handleCountChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + if (!document.hidden) startPolling() + }) + + onBeforeUnmount(() => { + stopPolling() + window.removeEventListener(pendingRefundCountChangedEvent, handleCountChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch(canReviewRefund, canReview => { + if (canReview && !document.hidden) { + startPolling() + return + } + stopPolling() + count.value = 0 + }) + + watch( + () => route.fullPath, + () => void refresh() + ) + + return { + pendingRefundCount: count, + pendingRefundLabel: label, + refreshPendingRefundCount: refresh, + } +} diff --git a/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts new file mode 100644 index 0000000..82ab90f --- /dev/null +++ b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { + estimateOrderEndAt, + paymentBizTypeLabel, + paymentPaidAt, + paymentStatusLabel, + paymentStatusType, +} from '../adminOrderPresentation' + +describe('admin order presentation', () => { + it('prefers the server-provided estimated end time', () => { + expect(estimateOrderEndAt('2026-09-03T12:00:00Z', '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-03T12:00:00Z' + ) + }) + + it('calculates the end time from rental start and duration', () => { + expect(estimateOrderEndAt(undefined, '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-02T14:00:00.000Z' + ) + expect(estimateOrderEndAt(undefined, 'invalid', 2)).toBeUndefined() + }) + + it('maps known payment states and preserves unknown channel values', () => { + expect(paymentStatusLabel('refunded')).toBe('已退款') + expect(paymentStatusType('failed')).toBe('danger') + expect(paymentBizTypeLabel('order_pay')).toBe('订单支付') + expect(paymentBizTypeLabel('future_type')).toBe('future_type') + }) + + it('selects the most meaningful payment completion time', () => { + expect( + paymentPaidAt({ + paid_at: 'paid', + updated_at: 'updated', + created_at: 'created', + }) + ).toBe('paid') + expect(paymentPaidAt({ updated_at: 'updated', created_at: 'created' })).toBe('updated') + }) +}) diff --git a/frontend/src/features/admin/utils/adminOrderPresentation.ts b/frontend/src/features/admin/utils/adminOrderPresentation.ts new file mode 100644 index 0000000..779dab4 --- /dev/null +++ b/frontend/src/features/admin/utils/adminOrderPresentation.ts @@ -0,0 +1,51 @@ +import type { AdminPayment } from '@/features/admin/api/adminPayments' + +export function estimateOrderEndAt( + estimatedEndAt?: string, + rentedAt?: string, + durationHours?: number +) { + if (estimatedEndAt) return estimatedEndAt + const hours = Number(durationHours || 0) + if (!rentedAt || hours <= 0) return undefined + const startedAt = new Date(rentedAt).getTime() + if (!Number.isFinite(startedAt)) return undefined + return new Date(startedAt + hours * 60 * 60 * 1000).toISOString() +} + +export function paymentStatusLabel(status: string) { + const labels: Record = { + created: '已创建', + paying: '支付中', + paid: '已支付', + refunding: '退款中', + refunded: '已退款', + failed: '失败', + } + return labels[status] || status +} + +export function paymentStatusType(status: string) { + if (['paid', 'refunded'].includes(status)) return 'success' as const + if (status === 'failed') return 'danger' as const + if (['paying', 'refunding'].includes(status)) return 'warning' as const + return 'info' as const +} + +export function paymentBizTypeLabel(type: string) { + const labels: Record = { + order_pay: '订单支付', + checkout_refund: '结账退款', + arbitration_refund: '仲裁退款', + admin_refund: '人工退款', + cancel_refund: '取消退款', + admin_close_refund: '客服关闭退款', + admin_seal_refund: '封存退款', + deposit_refund: '暂扣押金归还', + } + return labels[type] || type +} + +export function paymentPaidAt(record: Pick) { + return record.paid_at || record.updated_at || record.created_at +} diff --git a/frontend/src/features/admin/views/AdminBackupView.vue b/frontend/src/features/admin/views/AdminBackupView.vue new file mode 100644 index 0000000..f97e7ac --- /dev/null +++ b/frontend/src/features/admin/views/AdminBackupView.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminChatsView.css b/frontend/src/features/admin/views/AdminChatsView.css new file mode 100644 index 0000000..9b898cd --- /dev/null +++ b/frontend/src/features/admin/views/AdminChatsView.css @@ -0,0 +1,560 @@ +.admin-page { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + +.page-head { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.page-head h1 { + margin: 0; + font-size: 22px; +} + +.page-head p { + margin: 6px 0 0; + color: #6b7280; +} + +.head-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.chat-workbench { + display: grid; + min-height: 0; + grid-template-columns: 330px minmax(0, 1fr); + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.conversation-pane { + min-height: 0; + overflow-y: auto; + border-right: 1px solid #e5e7eb; + background: #f8fafc; +} + +.filter-tabs { + display: grid; + gap: 10px; + padding: 10px 12px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.filter-tabs :deep(.el-radio-group) { + display: flex; + width: 100%; +} + +.filter-tabs :deep(.el-radio-button) { + flex: 1; +} + +.filter-tabs :deep(.el-radio-button__inner) { + width: 100%; + padding: 7px 8px; +} + +.stage-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.stage-tabs button { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 8px; + border: 1px solid #d8dee8; + border-radius: 999px; + background: #fff; + color: #4b5563; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.stage-tabs button.active { + border-color: #3b82f6; + background: #eff6ff; + color: #2563eb; +} + +.stage-tabs em { + color: #94a3b8; + font-style: normal; + font-weight: 800; +} + +.stage-tabs button.active em { + color: #2563eb; +} + +.conversation-search { + width: 100%; +} + +.conversation-pagination { + display: flex; + justify-content: center; + padding: 12px 8px; + border-top: 1px solid #e5e7eb; + background: #fff; +} + +.conversation-row { + position: relative; + display: block; + width: 100%; + padding: 14px; + border: 0; + border-bottom: 1px solid #e5e7eb; + background: transparent; + text-align: left; +} + +.conversation-row.active { + background: #eef6ff; +} + +.row-title { + display: flex; + gap: 10px; + align-items: center; +} + +.row-title strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-title span { + flex: none; + color: #9ca3af; + font-size: 12px; +} + +.conversation-row p { + margin: 8px 24px 0 0; + overflow: hidden; + color: #6b7280; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags { + display: flex; + min-width: 0; + gap: 6px; + align-items: center; + margin-top: 8px; +} + +.row-tags span { + overflow: hidden; + max-width: 160px; + padding: 2px 7px; + border-radius: 999px; + background: #eef2f7; + color: #64748b; + font-size: 11px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags .stage-tag { + flex: none; + max-width: none; + font-weight: 700; +} + +.stage-tag.pending { + background: #fee2e2; + color: #dc2626; +} + +.stage-tag.unjoined { + background: #fef3c7; + color: #b45309; +} + +.stage-tag.handoff { + background: #dbeafe; + color: #2563eb; +} + +.stage-tag.renting { + background: #dcfce7; + color: #15803d; +} + +.stage-tag.after-sale { + background: #f3e8ff; + color: #7e22ce; +} + +.stage-tag.ended { + background: #e5e7eb; + color: #4b5563; +} + +.row-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.support-name { + color: #8a94a6; + font-size: 12px; +} + +.row-meta em { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ef4444; + color: #fff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.message-pane { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.message-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; +} + +.head-title { + flex: 1; + min-width: 0; +} + +.head-title h2 { + margin: 0; + font-size: 18px; + display: flex; + align-items: center; + gap: 8px; +} + +.head-title p { + margin: 6px 0 0; + color: #6b7280; + font-size: 13px; +} + +.head-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.head-actions a { + text-decoration: none; +} + +.message-body { + display: grid; + min-width: 0; + min-height: 0; + background: #f3f6fa; +} + +.message-body.has-order { + grid-template-columns: minmax(0, 1fr) minmax(330px, 38%); +} + +.message-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + background: #f3f6fa; +} + +.order-side-panel { + min-width: 0; + overflow-y: auto; + padding: 16px; + border-left: 1px solid #e5e7eb; + background: #f8fafc; +} + +.order-side-head, +.order-side-section { + padding: 14px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03); +} + +.order-side-head { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.order-side-head span { + color: #2563eb; + font-size: 12px; + font-weight: 700; +} + +.order-side-head strong { + color: #071947; + font-size: 18px; +} + +.order-side-head p, +.order-side-section p { + margin: 0; + color: #7c8aaa; + font-size: 13px; + line-height: 1.6; +} + +.order-side-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.order-side-metrics div { + display: grid; + gap: 5px; + min-width: 0; + padding: 12px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; +} + +.order-side-metrics span { + color: #8a94a6; + font-size: 12px; +} + +.order-side-metrics strong { + overflow: hidden; + color: #071947; + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.order-side-section { + display: grid; + gap: 8px; +} + +.order-side-section + .order-side-section { + margin-top: 12px; +} + +.order-side-section h3 { + margin: 0; + color: #071947; + font-size: 15px; +} + +.order-side-section strong { + color: #071947; + font-size: 13px; +} + +.message-row { + max-width: 70%; + margin-bottom: 14px; +} + +.message-row.self { + margin-left: auto; + text-align: right; +} + +.message-row.system { + max-width: none; + text-align: center; +} + +.system-block { + display: inline-flex; + max-width: min(100%, 320px); + flex-direction: column; + align-items: center; + gap: 8px; +} + +.system-attachments { + display: grid; + justify-items: center; + gap: 6px; +} + +.message-row small { + display: block; + margin-bottom: 5px; + color: #8a94a6; +} + +.read-status { + margin-top: 4px; + margin-bottom: 0; + font-size: 11px; +} + +.read-status.read { + color: #67c23a; +} + +.message-row p { + display: inline-block; + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: #fff; + color: #111827; + line-height: 1.5; + text-align: left; +} + +.message-row.self p { + background: #dff5eb; +} + +.message-row.admin p { + background: #fff4e6; + border-left: 3px solid #ff9800; +} + +.admin-label { + color: #ff9800 !important; + font-weight: 600; +} + +.message-attachments { + display: grid; + justify-items: start; + gap: 6px; +} + +.message-row.self .message-attachments { + justify-items: end; +} + +.message-row.system span { + display: inline-block; + padding: 5px 10px; + border-radius: 8px; + background: #e5e7eb; + color: #6b7280; + font-size: 12px; +} + +.composer { + border-top: 1px solid #e5e7eb; +} + +.composer-tools { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid #f0f0f0; +} + +.hidden-file { + display: none; +} + +.pending-attachments { + display: flex; + gap: 8px; + padding: 10px 14px 0; + overflow-x: auto; +} + +.pending-item { + position: relative; + flex: none; +} + +.pending-item :deep(.chat-image-button) { + width: 84px; + height: 84px; +} + +.pending-item :deep(.chat-image-button img) { + height: 84px; +} + +.remove-attachment { + position: absolute; + top: 4px; + right: 4px; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(17, 24, 39, 0.72); + color: #fff; + cursor: pointer; +} + +.composer-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + align-items: end; + padding: 14px; +} + +.reply-title { + font-weight: 500; + margin-right: 8px; +} + +.reply-preview { + color: #9ca3af; + font-size: 12px; +} diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index f0db5e9..270c78b 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -1,14 +1,16 @@ - + diff --git a/frontend/src/features/admin/views/AdminDisputesView.css b/frontend/src/features/admin/views/AdminDisputesView.css new file mode 100644 index 0000000..6348763 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDisputesView.css @@ -0,0 +1,263 @@ +.dispute-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-item { + display: grid; + gap: 6px; + padding: 14px 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.summary-item span { + color: #64748b; + font-size: 13px; +} + +.summary-item strong { + color: #0f172a; + font-size: 24px; + line-height: 1; +} + +.filter-panel { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.filter-control { + width: 180px; +} + +.keyword-filter { + width: 280px; +} + +.arbitration-field { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.arbitration-field span { + color: #475569; + font-size: 13px; + font-weight: 700; +} + +.main-cell, +.party-cell { + display: grid; + gap: 4px; +} + +.main-cell strong { + color: #0f172a; + font-weight: 700; +} + +.main-cell span, +.party-cell span { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.dispute-table :deep(.el-table__cell) { + vertical-align: top; +} + +.evidence-dialog { + display: grid; + gap: 14px; +} + +.evidence-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.evidence-header div { + display: grid; + gap: 5px; +} + +.evidence-header strong { + color: #0f172a; + font-size: 17px; +} + +.evidence-header span, +.evidence-meta-grid span, +.evidence-description span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.evidence-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.evidence-meta-grid > div { + display: grid; + gap: 5px; + min-width: 0; + padding: 10px 12px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-meta-grid strong { + min-width: 0; + overflow: hidden; + color: #1f2937; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-description { + display: grid; + gap: 6px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-description p { + margin: 0; + color: #334155; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; +} + +.evidence-list { + display: grid; + gap: 12px; + max-height: 420px; + padding-right: 4px; + overflow: auto; +} + +.evidence-card { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 14px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; +} + +.evidence-preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + overflow: hidden; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-file-icon { + display: flex; + align-items: center; + justify-content: center; + width: 58px; + height: 58px; + border-radius: 8px; + background: #eef2ff; + color: #4f46e5; + font-weight: 800; +} + +.evidence-card :deep(.evidence-image) { + display: block; + width: 100%; + height: 96px; + object-fit: cover; +} + +.evidence-card :deep(.evidence-image-fallback) { + min-height: 96px; + border: 0; +} + +.evidence-info { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.evidence-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.evidence-title-row strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-info p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.evidence-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 960px) { + .dispute-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-panel { + align-items: stretch; + flex-direction: column; + } + + .filter-control { + width: 100%; + } + + .evidence-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .evidence-card { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index e1375e1..f1b71e0 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -184,7 +184,7 @@ function openArbitration(row: Dispute) { activeDispute.value = row result.value = editableArbitrationResult(row.arbitration_result) remark.value = row.arbitration_remark || '' - amount.value = undefined + amount.value = centToYuan(row.checkout_deposit_deduct_amount_cent ?? 0) actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined actualConsumableAmountYuan.value = centToYuan( row.checkout_consumable_amount_cent ?? row.extra_item_original_amount_cent ?? 0 @@ -337,7 +337,9 @@ async function handleArbitrate() { result: result.value, remark: remark.value.trim(), amount_cent: - isCompletedResult.value && amount.value !== undefined ? yuanToCent(amount.value) : undefined, + isCompletedResult.value && amount.value !== undefined + ? yuanToCent(amount.value) + : undefined, actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined, actual_consumable_amount_cent: isCompletedResult.value ? yuanToCent(actualConsumableAmountYuan.value) @@ -674,268 +676,4 @@ async function handleArbitrate() { - + diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.css b/frontend/src/features/admin/views/AdminFinanceDashboardView.css new file mode 100644 index 0000000..b973f55 --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.css @@ -0,0 +1,662 @@ +.finance-dashboard-page { + padding: 0 4px 32px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ================= 头部 Header & 筛选栏 ================= */ +.dashboard-header { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); + display: flex; + flex-direction: column; + gap: 20px; +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.header-meta { + display: flex; + align-items: center; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: #64748b; + background: #f8fafc; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.filter-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} + +.preset-buttons { + display: inline-flex; + background: #f1f5f9; + padding: 3px; + border-radius: 10px; + gap: 2px; +} + +.preset-btn { + border: none; + background: transparent; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: #64748b; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-btn:hover { + color: #1e293b; +} + +.preset-btn.active { + background: #ffffff; + color: #4f46e5; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.date-picker-group { + display: flex; + align-items: center; + gap: 8px; +} + +.date-input { + width: 140px; +} + +.date-separator { + color: #94a3b8; + font-size: 13px; +} + +.action-buttons { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心 Hero KPI 卡片 ================= */ +.hero-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.hero-card { + background: #ffffff; + border-radius: 16px; + padding: 20px 22px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.hero-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.hero-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.hero-card--flow::before { + background: linear-gradient(90deg, #3b82f6, #60a5fa); +} + +.hero-card--profit::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.hero-card--net::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.hero-card--outflow::before { + background: linear-gradient(90deg, #8b5cf6, #a78bfa); +} + +.hero-card--risk::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.hero-card-header { + display: flex; + align-items: center; + gap: 10px; +} + +.hero-icon-box { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.hero-card--flow .hero-icon-box { + background: #eff6ff; + color: #3b82f6; +} + +.hero-card--profit .hero-icon-box { + background: #ecfdf5; + color: #10b981; +} + +.hero-card--net .hero-icon-box { + background: #ecfeff; + color: #06b6d4; +} + +.hero-card--outflow .hero-icon-box { + background: #f5f3ff; + color: #8b5cf6; +} + +.hero-card--risk .hero-icon-box { + background: #fffbeb; + color: #f59e0b; +} + +.hero-label { + font-size: 13.5px; + font-weight: 600; + color: #64748b; +} + +.hero-value { + font-size: 28px; + font-weight: 800; + color: #0f172a; + margin: 12px 0 10px; + letter-spacing: -0.5px; + line-height: 1.2; +} + +.diff-value.danger { + color: #ef4444; +} + +.diff-value.success { + color: #10b981; +} + +.hero-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; +} + +.hero-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + font-size: 11.5px; +} + +.hero-pill.success { + background: #f0fdf4; + color: #15803d; +} + +.hero-pill.emerald { + background: #ecfdf5; + color: #047857; +} + +.hero-pill.teal { + background: #f0fdfa; + color: #0f766e; +} + +.hero-pill.purple { + background: #f5f3ff; + color: #6d28d9; +} + +.hero-pill.warning { + background: #fffbeb; + color: #b45309; +} + +.hero-pill.danger { + background: #fef2f2; + color: #b91c1c; +} + +.hero-pill.neutral { + background: #f1f5f9; + color: #475569; +} + +.hero-note { + color: #94a3b8; + font-size: 11.5px; +} + +/* ================= 模块化 Section 卡片 ================= */ +.dashboard-sections { + display: flex; + flex-direction: column; + gap: 24px; +} + +.section-container { + background: #ffffff; + border-radius: 16px; + padding: 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.section-title-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +.section-badge { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.section-badge.primary { + background: #eff6ff; + color: #3b82f6; +} + +.section-badge.teal { + background: #f0fdfa; + color: #0d9488; +} + +.section-badge.blue { + background: #eff6ff; + color: #2563eb; +} + +.section-badge.purple { + background: #f5f3ff; + color: #7c3aed; +} + +.section-badge.indigo { + background: #eef2ff; + color: #4f46e5; +} + +.section-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: #1e293b; +} + +.section-desc { + margin: 3px 0 0; + font-size: 12.5px; + color: #64748b; +} + +/* 子指标网格 */ +.sub-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +.sub-metric-grid.cols-2 { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.sub-metric-grid.cols-3 { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.sub-card { + background: #f8fafc; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #f1f5f9; + display: flex; + flex-direction: column; + gap: 6px; + transition: all 0.2s ease; +} + +.sub-card:hover { + background: #f1f5f9; + border-color: #e2e8f0; +} + +.sub-card.highlight-pending { + background: #fffbeb; + border-color: #fef3c7; +} + +.sub-card-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.sub-card-value { + font-size: 20px; + font-weight: 700; + color: #0f172a; + letter-spacing: -0.3px; +} + +.sub-card-sub { + font-size: 11.5px; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.dot-sep { + color: #cbd5e1; +} + +.danger-text { + color: #ef4444; +} + +.warning-text { + color: #d97706; +} + +.success-text { + color: #10b981; +} + +.emerald-text { + color: #059669; +} + +.orange-text { + color: #ea580c; +} + +.primary-text { + color: #3b82f6; +} + +/* 出款区域两栏布局 */ +.disbursement-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .disbursement-layout { + grid-template-columns: 1fr; + } +} + +.disbursement-subgroup { + display: flex; + flex-direction: column; + gap: 12px; +} + +.subgroup-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: #334155; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.success-dot { + background: #10b981; +} + +.warning-dot { + background: #f59e0b; +} + +/* 专项分部卡片 */ +.segments-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .segments-row { + grid-template-columns: 1fr; + } +} + +/* ================= 每日明细表格美化 ================= */ +.table-container { + padding-bottom: 16px; +} + +.table-section-header { + margin-bottom: 16px; +} + +.record-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 4px 12px; + border-radius: 20px; +} + +.table-wrapper { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__body-wrapper td) { + padding: 14px 0; + font-size: 13px; + color: #334155; +} + +.date-cell { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #1e293b; +} + +.amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.profit-cell { + color: #059669; + font-weight: 700; +} + +.flow-cell { + color: #1d4ed8; +} + +.refund-cell { + color: #dc2626; +} + +.outflow-cell { + color: #7c3aed; +} + +.expense-cell { + color: #d97706; +} + +.count-pill { + display: inline-block; + min-width: 24px; + padding: 2px 8px; + border-radius: 12px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 600; +} + +.count-pill.primary-pill { + background: #eef2ff; + color: #4f46e5; +} + +.refund-cancel-tag-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.mini-tag { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.mini-tag.refund-tag { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fee2e2; +} + +.mini-tag.cancel-tag { + background: #fffbeb; + color: #b45309; + border: 1px solid #fef3c7; +} + +.mini-tag.muted-tag { + background: #f8fafc; + color: #94a3b8; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 5838d95..488b47b 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -1,5 +1,19 @@ - + diff --git a/frontend/src/features/admin/views/AdminFinanceDetailsView.vue b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue index 52edd06..80241ad 100644 --- a/frontend/src/features/admin/views/AdminFinanceDetailsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css new file mode 100644 index 0000000..a3962ec --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css @@ -0,0 +1,545 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 18px 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--paid::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.metric-card--pending::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--review::before { + background: linear-gradient(90deg, #6366f1, #818cf8); +} + +.metric-card--fee::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.metric-card--total::before { + background: linear-gradient(90deg, #64748b, #94a3b8); +} + +.metric-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.metric-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.metric-icon-wrap.teal { + background: #ecfeff; + color: #06b6d4; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.emerald-val { + color: #059669; +} + +.amber-val { + color: #d97706; +} + +.indigo-val { + color: #4f46e5; +} + +.teal-val { + color: #0891b2; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.success { + background: #f0fdf4; + color: #16a34a; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-pill.info { + background: #eef2ff; + color: #4f46e5; +} + +.metric-sub-text { + font-size: 11.5px; + color: #94a3b8; +} + +/* ================= 筛选面板 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px 16px; +} + +.filter-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.filter-grid :deep(.el-form-item__label) { + padding-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} + +.full-control { + width: 100%; +} + +/* ================= 表格卡片 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #4f46e5; + font-size: 18px; +} + +.count-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.business-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.source-tag { + width: fit-content; + font-weight: 600; + font-size: 11px; +} + +.business-no { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #1e293b; +} + +.link-no { + color: #3b82f6; + text-decoration: none; + font-weight: 600; +} + +.link-no:hover { + text-decoration: underline; +} + +.stack-cell { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.35; +} + +.payee-name { + font-weight: 600; + color: #0f172a; +} + +.phone-text { + font-size: 11.5px; + color: #64748b; +} + +.muted-note { + font-size: 11.5px; + color: #94a3b8; +} + +.channel-note { + font-size: 11px; + color: #64748b; +} + +.account-line { + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; + font-size: 12.5px; +} + +.account-type-badge { + font-size: 10.5px; + padding: 1px 4px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; +} + +.account-num { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11.5px; + color: #64748b; +} + +.bank-name { + font-size: 11.5px; + color: #94a3b8; +} + +.category-tag { + width: fit-content; + margin-bottom: 2px; +} + +.voucher-uploaded { + font-size: 11px; + color: #10b981; +} + +.voucher-missing { + font-size: 11px; + color: #94a3b8; +} + +.amount-stack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.actual-amount { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; +} + +.fee-detail { + font-size: 11px; + color: #94a3b8; +} + +.status-pill { + font-weight: 600; +} + +.time-cell { + font-size: 12px; + color: #475569; +} + +.empty-time { + color: #cbd5e1; +} + +.operator-name { + font-size: 12.5px; + color: #475569; +} + +.remark-text { + font-size: 12px; + color: #64748b; +} + +.action-cell { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ================= 弹窗样式 ================= */ +.voucher-upload-box { + display: flex; + align-items: center; + gap: 16px; +} + +.voucher-preview-wrap { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.voucher-img-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-content-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13.5px; + border-bottom: 1px dashed #f1f5f9; + padding-bottom: 8px; +} + +.detail-row.column-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.detail-label { + color: #64748b; +} + +.detail-val { + color: #0f172a; +} + +.detail-img-container { + max-width: 100%; + max-height: 240px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.detail-voucher-img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} + +.void-info-box { + background: #fef2f2; + border-radius: 8px; + padding: 12px 14px; + border: 1px solid #fee2e2; + color: #b91c1c; + font-size: 12.5px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.void-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: 2px; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 232c913..d5d86f0 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -1,5 +1,20 @@ - + diff --git a/frontend/src/features/admin/views/AdminListingDetailView.css b/frontend/src/features/admin/views/AdminListingDetailView.css new file mode 100644 index 0000000..3ddcc46 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingDetailView.css @@ -0,0 +1,489 @@ +.listing-detail-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 20px; + margin-bottom: 18px; +} + +.listing-title-block { + min-width: 0; +} + +.listing-code-line { + display: inline-flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-code-line strong { + color: #2563eb; + letter-spacing: 0; +} + +.listing-title-block h1 { + margin: 8px 0 10px; + color: #0f172a; + font-size: 30px; + line-height: 1.15; +} + +.listing-subtitle { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.listing-subtitle span { + padding: 4px 9px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 13px; +} + +.listing-summary-bar { + display: grid; + grid-template-columns: repeat(6, minmax(120px, 1fr)); + gap: 1px; + overflow: hidden; + margin-bottom: 16px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #e5e7eb; +} + +.listing-summary-bar > div { + display: grid; + min-height: 74px; + align-content: center; + gap: 8px; + padding: 12px 14px; + background: #fff; +} + +.listing-summary-bar span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-summary-bar strong { + color: #0f172a; + font-size: 21px; + line-height: 1.1; +} + +.listing-workspace { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.7fr); + gap: 16px; + align-items: start; +} + +.detail-panel { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #fff; + padding: 16px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.screenshots-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid #eef2f7; +} + +.panel-heading h2 { + margin: 0; + color: #0f172a; + font-size: 16px; + font-weight: 800; +} + +.panel-heading span { + overflow: hidden; + color: #94a3b8; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 0; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #eef2f7; +} + +.compact-detail-grid.single { + grid-template-columns: 1fr; +} + +.compact-detail-grid div { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 10px; + align-items: start; + min-height: 38px; + padding: 10px 12px; + background: #fff; +} + +.compact-detail-grid dt { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.compact-detail-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; + font-weight: 700; +} + +.resource-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.resource-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.resource-item span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.resource-item strong { + color: #0f172a; + font-size: 18px; +} + +.skin-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 12px; +} + +.screenshot-thumb { + display: grid; + gap: 8px; + padding: 8px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; + cursor: pointer; + text-align: left; +} + +.screenshot-thumb:hover { + border-color: #93c5fd; + background: #eff6ff; +} + +.screenshot-thumb img { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; + background: #f1f5f9; + object-fit: cover; +} + +.screenshot-image { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; +} + +.screenshot-thumb span { + color: #334155; + font-size: 13px; + font-weight: 700; +} + +.empty-line { + margin: 0; + color: #94a3b8; + font-size: 13px; +} + +.transfer-owner-trigger { + min-width: 120px; + font-weight: 700; +} + +.external-adjust-form { + display: grid; + gap: 4px; +} + +:global(.external-adjust-dialog) { + max-width: calc(100vw - 32px); +} + +:global(.external-adjust-dialog .el-dialog__body) { + max-height: calc(100vh - 180px); + overflow-y: auto; +} + +.external-adjust-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +.external-adjust-grid :deep(.el-input-number), +.external-adjust-form :deep(.el-input-number) { + width: 100%; +} + +.external-adjust-preview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 4px 0 18px 112px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #e5e7eb; +} + +.external-adjust-preview > div { + display: grid; + min-height: 70px; + align-content: center; + gap: 7px; + padding: 10px; + background: #f8fafc; +} + +.external-adjust-preview span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.external-adjust-preview strong { + color: #0f172a; + font-size: 16px; +} + +.transfer-dialog { + display: grid; + gap: 18px; +} + +.transfer-route { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); + align-items: stretch; + gap: 12px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.transfer-owner-card { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; +} + +.transfer-owner-card.target { + border-color: #86efac; + background: #f0fdf4; +} + +.transfer-owner-card.target.empty { + border-color: #bfdbfe; + background: #eff6ff; +} + +.transfer-owner-card span { + font-size: 13px; +} + +.transfer-owner-card strong { + overflow: hidden; + color: #0f172a; + font-size: 18px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-owner-card small { + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-arrow { + display: grid; + place-items: center; + color: #2563eb; + font-size: 22px; + font-weight: 800; +} + +.transfer-section { + display: grid; + gap: 8px; +} + +.transfer-section label { + color: #334155; + font-size: 14px; + font-weight: 700; +} + +.transfer-user-list { + min-height: 138px; + max-height: 280px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.transfer-user { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 14px; + border: 0; + border-bottom: 1px solid #eef2f7; + background: #fff; + color: #334155; + text-align: left; + cursor: pointer; +} + +.transfer-user:last-child { + border-bottom: 0; +} + +.transfer-user:hover, +.transfer-user.is-selected { + background: #eff6ff; +} + +.transfer-user.is-selected { + box-shadow: inset 3px 0 0 #2563eb; +} + +.transfer-user-main { + min-width: 0; +} + +.transfer-user strong { + display: block; + overflow: hidden; + color: #0f172a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user small { + display: block; + margin-top: 4px; + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user-meta { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +@media (max-width: 760px) { + .listing-detail-header, + .listing-workspace { + grid-template-columns: 1fr; + } + + .listing-summary-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-detail-grid { + grid-template-columns: 1fr; + } + + .transfer-route { + grid-template-columns: 1fr; + } + + .external-adjust-grid, + .external-adjust-preview { + grid-template-columns: 1fr; + } + + .external-adjust-preview { + margin-left: 0; + } + + .transfer-arrow { + transform: rotate(90deg); + } + + .transfer-user { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index e98b51b..63ca82f 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -1027,494 +1027,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingReviewView.css b/frontend/src/features/admin/views/AdminListingReviewView.css new file mode 100644 index 0000000..20599a7 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingReviewView.css @@ -0,0 +1,590 @@ +.review-page { + min-height: calc(100vh - 128px); +} + +.review-header { + margin-bottom: 18px; +} + +.review-summary { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-card { + min-width: 0; + border: 1px solid #e8edf4; + border-radius: 8px; + background: #ffffff; + padding: 14px 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.summary-card:hover { + border-color: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.15); + transform: translateY(-1px); +} + +.summary-card.active { + border-color: #1477ff; + background: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.25); +} + +.summary-card.active span { + color: #ffffff; +} + +.summary-card.active strong { + color: #ffffff; +} + +.review-summary span, +.queue-footer span, +.panel-title span, +.inventory-grid span, +.resource-table span, +.price-decision-card span, +.adjust-current span, +.adjust-field span { + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.review-summary strong { + display: block; + margin-top: 5px; + color: #1b2559; + font-size: 24px; + font-weight: 800; +} + +.review-workbench { + display: grid; + grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.review-queue, +.review-detail, +.review-panel, +.review-hero-panel { + min-width: 0; +} + +.review-queue { + position: sticky; + top: 16px; + border-radius: 8px; + background: #ffffff; + padding: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.queue-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px; + gap: 10px; + margin-bottom: 12px; +} + +.queue-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 300px); + overflow: auto; + padding-right: 2px; +} + +.queue-item { + width: 100%; + min-height: 112px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #ffffff; + padding: 12px; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s, + background 0.2s; +} + +.queue-item:hover, +.queue-item.active { + border-color: #4f7cff; + background: #f8faff; + box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1); +} + +.queue-title-row, +.queue-footer, +.panel-title, +.hero-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.queue-title-row strong { + display: block; + min-width: 0; + color: #1b2559; + font-size: 14px; + font-weight: 800; + line-height: 1.4; +} + +.queue-title-row span { + flex-shrink: 0; + color: #4f7cff; + font-weight: 800; +} + +.queue-title-row .queue-code { + display: inline-flex; + margin-bottom: 4px; + padding: 2px 7px; + border-radius: 999px; + background: #eff6ff; + font-size: 12px; +} + +.queue-meta, +.risk-strip, +.skin-list, +.reject-reasons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.queue-meta { + margin: 10px 0; +} + +.queue-meta span { + border-radius: 6px; + background: #f0f2f5; + padding: 4px 7px; + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + +.queue-footer { + align-items: center; +} + +.queue-tags, +.panel-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.review-detail { + display: flex; + flex-direction: column; + gap: 14px; +} + +.review-hero-panel, +.review-panel { + border-radius: 8px; + background: #ffffff; + padding: 18px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.hero-title h2, +.panel-title h3 { + margin: 0; + color: #1b2559; + font-size: 18px; + font-weight: 800; + line-height: 1.35; +} + +.hero-title p { + margin: 0 0 6px; + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.risk-strip { + margin-top: 14px; +} + +.review-metrics { + grid-template-columns: repeat(5, minmax(132px, 1fr)); + gap: 12px; + margin-bottom: 0; +} + +.review-metrics .metric-card { + border-radius: 8px; + padding: 16px; +} + +.review-metrics .metric-card strong { + font-size: 20px; +} + +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.panel-title { + align-items: center; + margin-bottom: 14px; +} + +.price-panel-title p { + margin: 5px 0 0; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.price-decision-grid { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 12px; +} + +.price-decision-card { + display: grid; + gap: 7px; + min-width: 0; + min-height: 128px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 14px; +} + +.price-decision-card strong { + color: #1b2559; + font-size: 24px; + font-weight: 900; + line-height: 1.1; +} + +.price-decision-card p, +.price-decision-card small { + margin: 0; + color: #4b5563; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.price-decision-card small { + color: #8f9bba; +} + +.price-decision-card.buyer { + border-color: #bfdbfe; + background: #eff6ff; +} + +.price-decision-card.buyer strong { + color: #2563eb; +} + +.price-decision-card.rule { + border-color: #fed7aa; + background: #fff7ed; +} + +.price-decision-card.rule strong { + color: #ea580c; +} + +.price-decision-card.deposit { + border-color: #e0e7ff; + background: #f5f7ff; +} + +.detail-list { + display: grid; + gap: 10px; + margin: 0; +} + +.detail-list div { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.detail-list dt { + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.detail-list dd { + min-width: 0; + margin: 0; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.inventory-grid { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.inventory-grid div { + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.inventory-grid strong { + display: block; + margin-top: 4px; + color: #1b2559; + font-size: 22px; + font-weight: 900; +} + +.resource-table { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.resource-table div { + display: grid; + grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr); + gap: 10px; + align-items: center; + border-bottom: 1px solid #f0f2f5; + padding-bottom: 8px; +} + +.resource-table strong { + color: #1f2937; + font-size: 13px; + font-weight: 800; +} + +.resource-table em { + color: #8f9bba; + font-size: 12px; + font-style: normal; + font-weight: 600; +} + +.skin-list { + align-items: center; +} + +.skin-list > span { + color: #8f9bba; + font-size: 13px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.screenshot-tile { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 0; + cursor: pointer; +} + +.screenshot-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.screenshot-tile span { + position: absolute; + top: 8px; + left: 8px; + border-radius: 6px; + background: rgba(245, 158, 11, 0.95); + padding: 3px 7px; + color: #ffffff; + font-size: 12px; + font-weight: 800; +} + +.empty-warning { + display: flex; + align-items: center; + gap: 8px; + min-height: 84px; + border-radius: 8px; + background: #fff7ed; + padding: 16px; + color: #c2410c; + font-weight: 700; +} + +.dialog-screenshots { + max-height: 540px; + overflow: auto; +} + +.reject-dialog p { + margin-top: 0; +} + +.reject-reasons { + margin: 12px 0; +} + +.price-adjust-dialog { + display: grid; + gap: 14px; +} + +.adjust-current { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.adjust-current div { + display: grid; + gap: 5px; + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.adjust-current strong { + color: #1b2559; + font-size: 18px; + font-weight: 900; +} + +.adjust-current .preview-value { + color: #2563eb; +} + +.adjust-mode { + width: 100%; +} + +.adjust-mode :deep(.el-radio-button) { + width: 50%; +} + +.adjust-mode :deep(.el-radio-button__inner) { + width: 100%; + border-color: #d7deea; + font-weight: 800; +} + +.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { + border-color: #2563eb; + background: #2563eb; + box-shadow: -1px 0 0 0 #2563eb; +} + +.adjust-field { + display: grid; + gap: 8px; +} + +.adjust-field > span { + color: #52607a; + font-size: 12px; + font-weight: 800; +} + +.adjust-field :deep(.el-input-number) { + width: 100%; +} + +.empty-detail { + min-height: 420px; + display: grid; + place-items: center; + border-radius: 8px; + background: #ffffff; +} + +@media (max-width: 1280px) { + .review-workbench { + grid-template-columns: 1fr; + } + + .review-queue { + position: static; + } + + .queue-list { + max-height: none; + } +} + +@media (max-width: 900px) { + .review-summary, + .price-decision-grid, + .review-grid { + grid-template-columns: 1fr; + } + + .adjust-current { + grid-template-columns: 1fr; + } + + .hero-title { + flex-direction: column; + } + + .hero-actions { + justify-content: flex-start; + } + + .queue-toolbar { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 970dfde..0559b70 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -36,7 +36,7 @@ interface RiskItem { level: RiskLevel } -const defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1' +const _defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1' const rejectReasonOptions = [ '默认截图,需补充真实截图', '账号资产信息不完整', @@ -1053,595 +1053,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue index baf028a..964c40f 100644 --- a/frontend/src/features/admin/views/AdminListingsView.vue +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -209,10 +209,7 @@ function restoreListState() { if (typeof state.page === 'number' && state.page > 0) { currentPage.value = state.page } - if ( - typeof state.pageSize === 'number' && - [10, 20, 50, 100].includes(state.pageSize) - ) { + if (typeof state.pageSize === 'number' && [10, 20, 50, 100].includes(state.pageSize)) { pageSize.value = state.pageSize } } catch { diff --git a/frontend/src/features/admin/views/AdminMohongCategoriesView.vue b/frontend/src/features/admin/views/AdminMohongCategoriesView.vue index 6374f52..6c623b9 100644 --- a/frontend/src/features/admin/views/AdminMohongCategoriesView.vue +++ b/frontend/src/features/admin/views/AdminMohongCategoriesView.vue @@ -78,11 +78,9 @@ async function handleSave() { async function handleDelete(item: MohongCategory) { try { - await ElMessageBox.confirm( - `确认删除分类「${item.name}」?其下商品将变为未分类。`, - '提示', - { type: 'warning' } - ) + await ElMessageBox.confirm(`确认删除分类「${item.name}」?其下商品将变为未分类。`, '提示', { + type: 'warning', + }) await deleteAdminMohongCategory(item.id) ElMessage.success('已删除') await load() diff --git a/frontend/src/features/admin/views/AdminMohongConfigView.vue b/frontend/src/features/admin/views/AdminMohongConfigView.vue index bb62558..47023e6 100644 --- a/frontend/src/features/admin/views/AdminMohongConfigView.vue +++ b/frontend/src/features/admin/views/AdminMohongConfigView.vue @@ -64,7 +64,9 @@ async function uploadQrcode(file: File) {

撞车配置

-

全局默认固定二维码与订单复制模板。商品可覆盖默认二维码;支付后用户在订单详情扫码并复制信息联系客服。

+

+ 全局默认固定二维码与订单复制模板。商品可覆盖默认二维码;支付后用户在订单详情扫码并复制信息联系客服。 +

保存配置
@@ -91,8 +93,9 @@ async function uploadQrcode(file: File) {
可用变量:{{order_no}} {{created_at}} {{items}}(多商品明细) {{product_title}} - {{quantity}} {{amount}} {{buyer_name}} - {{buyer_phone}} {{unit}} + {{quantity}} {{amount}} + {{buyer_name}} {{buyer_phone}} + {{unit}}
diff --git a/frontend/src/features/admin/views/AdminMohongOrdersView.vue b/frontend/src/features/admin/views/AdminMohongOrdersView.vue index 5f33122..5d39446 100644 --- a/frontend/src/features/admin/views/AdminMohongOrdersView.vue +++ b/frontend/src/features/admin/views/AdminMohongOrdersView.vue @@ -128,9 +128,7 @@ function canComplete(row: MohongOrder) { } function canCancel(row: MohongOrder) { - return ( - row.status === 'pending_payment' || row.status === 'paid' || row.status === 'receiving' - ) + return row.status === 'pending_payment' || row.status === 'paid' || row.status === 'receiving' } function canRefund(row: MohongOrder) { @@ -158,7 +156,13 @@ async function copyText(text: string) {
- + @@ -167,7 +171,7 @@ async function copyText(text: string) { - 查询 + 查询
@@ -262,7 +266,11 @@ async function copyText(text: string) {
二维码快照 - +
diff --git a/frontend/src/features/admin/views/AdminMohongProductsView.vue b/frontend/src/features/admin/views/AdminMohongProductsView.vue index d912471..7b9fb92 100644 --- a/frontend/src/features/admin/views/AdminMohongProductsView.vue +++ b/frontend/src/features/admin/views/AdminMohongProductsView.vue @@ -207,7 +207,13 @@ function removeGallery(url: string) {
- + @@ -216,7 +222,7 @@ function removeGallery(url: string) { - 查询 + 查询
@@ -273,7 +279,12 @@ function removeGallery(url: string) { > - + @@ -288,7 +299,11 @@ function removeGallery(url: string) { style="width: 80px; height: 80px; border-radius: 8px" fit="cover" /> - + 上传封面 @@ -299,7 +314,11 @@ function removeGallery(url: string) { - + 添加图片 @@ -331,7 +350,11 @@ function removeGallery(url: string) { - +
+import { + CircleClose, + Money, + Plus, + Refresh, + Search, + Tickets, + UploadFilled, + View, + Wallet, + WarningFilled, +} from '@element-plus/icons-vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { computed, onMounted, reactive, ref } from 'vue' + +import { + createOperatingExpense, + fetchOperatingExpenses, + voidOperatingExpense, + type OperatingExpense, + type OperatingExpenseListSummary, +} from '@/features/admin/api/adminFinance' +import AuthImage from '@/shared/components/business/AuthImage.vue' +import { uploadAdminFile } from '@/shared/api/files' +import { useAdminSessionStore } from '@/stores/adminSession' +import { readError } from '@/shared/utils/error' +import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money' +import { formatDateTime, formatInputDate } from '@/shared/utils/time' +import AdminTablePagination from '../components/AdminTablePagination.vue' + +const adminSession = useAdminSessionStore() +const loading = ref(false) +const createVisible = ref(false) +const createSaving = ref(false) +const voucherUploading = ref(false) +const actionID = ref(0) +const selectedExpense = ref(null) +const items = ref([]) +const total = ref(0) +const currentPage = ref(1) +const currentPageSize = ref(20) +const summary = ref(emptySummary()) +const canManage = computed(() => adminSession.hasPermission('finance:operating_expense')) + +const filters = reactive({ + date_type: 'created' as 'created' | 'occurred', + start_date: defaultStartDate(), + end_date: formatInputDate(new Date()), + status: '', + category: '', + keyword: '', +}) + +const createForm = reactive({ + category: '', + payee_name: '', + amount_yuan: 0, + occurred_at: new Date(), + remark: '', + voucher_url: '', +}) + +onMounted(loadItems) + +async function loadItems() { + loading.value = true + try { + const result = await fetchOperatingExpenses({ + ...filters, + page: currentPage.value, + page_size: currentPageSize.value, + }) + items.value = result.items + total.value = result.total + summary.value = result.summary + } catch (error) { + items.value = [] + total.value = 0 + summary.value = emptySummary() + ElMessage.error(readError(error, '运营开支记录加载失败')) + } finally { + loading.value = false + } +} + +function search() { + currentPage.value = 1 + void loadItems() +} + +function resetFilters() { + filters.date_type = 'created' + filters.start_date = defaultStartDate() + filters.end_date = formatInputDate(new Date()) + filters.status = '' + filters.category = '' + filters.keyword = '' + currentPage.value = 1 + void loadItems() +} + +function openCreate() { + createForm.category = '' + createForm.payee_name = '' + createForm.amount_yuan = 0 + createForm.occurred_at = new Date() + createForm.remark = '' + createForm.voucher_url = '' + createVisible.value = true +} + +async function uploadVoucher(options: { file: File }) { + if (!options.file.type.startsWith('image/')) { + ElMessage.warning('开支凭证仅支持图片') + return + } + voucherUploading.value = true + try { + const uploaded = await uploadAdminFile(options.file, 'operating-expense') + createForm.voucher_url = uploaded.url + ElMessage.success('凭证已上传') + } catch (error) { + ElMessage.error(readError(error, '凭证上传失败')) + } finally { + voucherUploading.value = false + } +} + +async function submitExpense() { + const amountCent = yuanToCent(createForm.amount_yuan) + if ( + !createForm.category.trim() || + !createForm.payee_name.trim() || + amountCent <= 0 || + !createForm.occurred_at || + !createForm.remark.trim() + ) { + ElMessage.warning('请填写完整的开支信息') + return + } + createSaving.value = true + try { + await createOperatingExpense({ + category: createForm.category.trim(), + payee_name: createForm.payee_name.trim(), + amount_cent: amountCent, + occurred_at: createForm.occurred_at.toISOString(), + remark: createForm.remark.trim(), + voucher_url: createForm.voucher_url || undefined, + }) + createVisible.value = false + ElMessage.success('运营开支已记账') + await loadItems() + } catch (error) { + ElMessage.error(readError(error, '运营开支记账失败')) + } finally { + createSaving.value = false + } +} + +async function handleVoid(item: OperatingExpense) { + if (actionID.value) return + try { + const { value } = await ElMessageBox.prompt( + `确认作废开支单 ${item.expense_no}?作废后将从运营开支统计中排除。`, + '作废运营开支', + { + confirmButtonText: '确认作废', + cancelButtonText: '取消', + inputPlaceholder: '请输入作废原因', + inputValidator: value => !!value.trim() || '请输入作废原因', + type: 'warning', + } + ) + actionID.value = item.id + await voidOperatingExpense(item.id, value.trim()) + selectedExpense.value = null + ElMessage.success('运营开支已作废') + await loadItems() + } catch (error) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(readError(error, '运营开支作废失败')) + } finally { + actionID.value = 0 + } +} + +function emptySummary(): OperatingExpenseListSummary { + return { record_count: 0, paid_amount_cent: 0, paid_count: 0 } +} + +function defaultStartDate() { + const date = new Date() + date.setDate(date.getDate() - 29) + return formatInputDate(date) +} + +function money(value: number | undefined | null) { + return formatCentWithSymbol(Number(value || 0)) +} + +function statusLabel(status: string) { + return status === 'paid' ? '已记账' : '已作废' +} + +function statusType(status: string): 'success' | 'danger' { + return status === 'paid' ? 'success' : 'danger' +} + + + + + diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.css b/frontend/src/features/admin/views/AdminOrderDetailView.css new file mode 100644 index 0000000..00c90e9 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOrderDetailView.css @@ -0,0 +1,318 @@ +.order-metrics { + margin-bottom: 20px; +} + +.order-metrics .metric-card strong { + font-size: 20px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.platform-handoff-button { + min-width: 96px; + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + font-weight: 700; + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18); +} + +.platform-handoff-button:hover, +.platform-handoff-button:focus { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.platform-handoff-button:active { + border-color: #1e40af; + background: #1e40af; +} + +.order-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); + gap: 20px; + margin-bottom: 28px; +} + +.detail-panel { + min-width: 0; +} + +.order-wide-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; + text-align: right; +} + +.status-tags, +.snapshot-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.detail-list div { + min-width: 0; + border-top: 1px solid #f0f2f5; + padding-top: 10px; +} + +.detail-list .wide { + grid-column: 1 / -1; +} + +.detail-list dt { + margin-bottom: 5px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.detail-list dd { + margin: 0; + color: #1b2559; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.money-list { + display: grid; + gap: 8px; +} + +.money-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #f0f2f5; + padding-top: 9px; + color: #52616f; + font-size: 13px; +} + +.money-row strong { + color: #1b2559; + font-size: 15px; +} + +.money-row.total { + margin-top: 4px; + border-top-color: #d8e2f0; + color: #1b2559; + font-weight: 700; +} + +.money-row.total strong { + font-size: 18px; +} + +.record-list { + display: grid; + gap: 12px; +} + +.record-item { + display: grid; + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-main span { + color: #8f9bba; + font-size: 13px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.snapshot-detail-list { + margin-bottom: 14px; +} + +.ratio-row { + display: flex; + gap: 24px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; +} + +.ratio-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ratio-label { + font-size: 12px; + color: #8f9bba; + font-weight: 600; +} + +.ratio-value { + font-size: 20px; + font-weight: 700; + color: #1b2559; +} + +.snapshot-tags { + justify-content: flex-start; + margin-bottom: 14px; +} + +.snapshot-resources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.resource-item { + display: grid; + gap: 4px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 10px 12px; +} + +.resource-item strong { + color: #1b2559; + font-size: 13px; +} + +.resource-item span { + color: #52616f; + font-size: 12px; +} + +.snapshot-json { + overflow-x: auto; + max-height: 360px; + margin: 0; + border-radius: 8px; + background: #111827; + color: #dbe4ee; + padding: 14px; + line-height: 1.6; +} + +.dialog-body { + display: grid; + gap: 12px; +} + +.dialog-body p { + margin: 0; + color: #52616f; + line-height: 1.6; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.form-grid label { + display: grid; + gap: 6px; + color: #52616f; + font-size: 13px; + font-weight: 600; +} + +.form-grid :deep(.el-input-number) { + width: 100%; +} + +@media (max-width: 960px) { + .order-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + align-items: flex-start; + flex-direction: column; + } + + .detail-list { + grid-template-columns: 1fr; + } + + .status-tags { + justify-content: flex-start; + } +} diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 9c0a5ce..7fcef82 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -2,7 +2,7 @@ import { readError } from '@/shared/utils/error' import { ElMessage, ElMessageBox } from 'element-plus' import { computed, onMounted, ref } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { adminCloseOrder, @@ -20,9 +20,11 @@ import { adminResetHandoff, adminSealOrder, fetchAdminHandoffRecords, + fetchAdminOrderProcessEvents, fetchAdminOrder, type HandoffRecord, type Order, + type ProcessEvent, type RefundStatus, } from '@/features/orders' import { adminCreateOrderDispute } from '@/features/disputes' @@ -33,7 +35,7 @@ import { readSnapshot, readSnapshotResources, } from '@/features/orders/composables/useOrderSnapshot' -import { adminPath } from '@/shared/utils/adminPath' +import { adminPath, isAdminPath } from '@/shared/utils/adminPath' import { centToYuan, formatCentWithSymbol } from '@/shared/utils/money' import { disputeStatusLabel, @@ -44,12 +46,18 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' +import AdminOrderMetrics, { type AdminOrderMetric } from '../components/AdminOrderMetrics.vue' +import AdminOrderPaymentRecords from '../components/AdminOrderPaymentRecords.vue' +import ProcessTimeline from '../components/ProcessTimeline.vue' +import { estimateOrderEndAt, paymentStatusLabel } from '../utils/adminOrderPresentation' const route = useRoute() +const router = useRouter() const loading = ref(false) const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) +const processEvents = ref([]) const paymentRecords = ref([]) type OrderActionType = | 'close' @@ -63,6 +71,7 @@ type OrderActionType = | '' const actionType = ref('') const reason = ref('') +const zeroOwnerIncomeConfirmed = ref(false) const refundStatus = ref(null) const platformHandoffVisible = ref(false) const platformHandoffContent = ref('') @@ -122,6 +131,15 @@ const returnLabel = computed(() => ? '返回会话' : '返回列表' ) + +function goBackToList() { + const back = (window.history.state as { back?: string | null } | null)?.back + if (back && isAdminPath(back)) { + router.back() + return + } + void router.push(returnTarget.value) +} const refunding = ref(false) const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2)) @@ -240,6 +258,63 @@ const latestPayment = computed(() => { const paidOrderPayment = computed(() => paymentRecords.value.find(item => item.biz_type === 'order_pay' && item.status === 'paid') ) +const orderMetricRows = computed(() => { + const current = order.value + if (!current) return [] + const rows: AdminOrderMetric[] = [ + { + key: 'order-status', + label: '订单状态', + value: orderStatusLabel(current.status), + detail: orderHandoffStatusLabel(current), + }, + { + key: 'settlement-status', + label: '结算状态', + value: settlementStatusLabel(current.settlement_status), + detail: `更新 ${formatDateTime(current.updated_at)}`, + }, + ] + if (isPlatformManaged.value) { + rows.push({ + key: 'offline-settlement', + label: '线下结算', + value: offlineSettlementStatusLabel(offlineSettlementStatus.value), + detail: moneyCent(current.offline_settlement_amount_cent), + }) + } + rows.push( + { + key: 'total', + label: '订单总额', + value: moneyCent(orderTotalCent.value), + detail: `租金 ${moneyCent(current.rent_amount_cent)} / 押金 ${moneyCent(current.deposit_amount_cent)}`, + }, + { + key: 'payment', + label: '支付状态', + value: latestPayment.value ? paymentStatusLabel(latestPayment.value.status) : '暂无流水', + detail: paidOrderPayment.value + ? `支付 ${moneyCent(paidOrderPayment.value.amount_cent)}` + : '尚未支付', + }, + { + key: 'refund', + label: '退款状态', + value: refundStatusLabel(refundStatus.value?.refund_status || ''), + detail: refundStatus.value?.refund_amount_cent + ? moneyCent(refundStatus.value.refund_amount_cent) + : '暂无退款金额', + }, + { + key: 'estimated-end', + label: '预计截止', + value: formatDateTime(orderEstimatedEndAt(), '未设置'), + detail: `${current.estimated_duration_hours} 小时`, + } + ) + return rows +}) const currentSnapshot = computed(() => readSnapshot(order.value)) const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(order.value)) const checkoutRemainingHafCoinM = computed(() => { @@ -345,8 +420,14 @@ async function loadOrder() { loading.value = true try { order.value = await fetchAdminOrder(String(route.params.id)) - handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id)) - paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items + const [handoffs, events, payments] = await Promise.all([ + fetchAdminHandoffRecords(String(route.params.id)), + fetchAdminOrderProcessEvents(String(route.params.id)), + fetchAdminPayments({ order_id: String(route.params.id) }), + ]) + handoffRecords.value = handoffs + processEvents.value = events + paymentRecords.value = payments.items await loadRefundStatus() } finally { loading.value = false @@ -456,7 +537,11 @@ async function submitAction() { await adminResetHandoff(order.value.id, reason.value) ElMessage.success(`${resetActionLabel.value}成功`) } else if (actionType.value === 'platform_checkout_confirm') { - await adminPlatformCheckoutConfirm(order.value.id, reason.value) + await adminPlatformCheckoutConfirm( + order.value.id, + reason.value, + zeroOwnerIncomeConfirmed.value + ) ElMessage.success('结账已确认') } else if (actionType.value === 'platform_checkout_dispute') { await adminPlatformCheckoutDispute(order.value.id, reason.value) @@ -480,6 +565,28 @@ async function submitAction() { } } +async function confirmPlatformCheckout() { + if (!order.value) return + zeroOwnerIncomeConfirmed.value = false + if (Number(order.value.checkout?.owner_income_amount_cent ?? 0) === 0) { + try { + await ElMessageBox.confirm( + '本次结算的号主收入为 ¥0.0。确认后将完成结算,请再次确认是否继续。', + '号主收入为零', + { + confirmButtonText: '仍确认结算', + cancelButtonText: '取消', + type: 'warning', + } + ) + } catch { + return + } + zeroOwnerIncomeConfirmed.value = true + } + openAction('platform_checkout_confirm') +} + function openPlatformHandoff() { platformHandoffContent.value = '' platformHandoffReason.value = '' @@ -648,12 +755,11 @@ function orderRentedAt() { } function orderEstimatedEndAt() { - if (!order.value) return undefined - if (order.value.estimated_end_at) return order.value.estimated_end_at - const rentedAt = orderRentedAt() - const durationHours = Number(order.value.estimated_duration_hours || 0) - if (!rentedAt || durationHours <= 0) return undefined - return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() + return estimateOrderEndAt( + order.value?.estimated_end_at, + orderRentedAt(), + Number(order.value?.estimated_duration_hours || 0) + ) } async function handleRefund() { @@ -692,39 +798,6 @@ async function confirmRefund() { await handleRefund() } -function paymentStatusLabel(status: string) { - const map: Record = { - created: '已创建', - paying: '支付中', - paid: '已支付', - refunding: '退款中', - refunded: '已退款', - failed: '失败', - } - return map[status] || status -} - -function paymentStatusType(status: string) { - if (['paid', 'refunded'].includes(status)) return 'success' - if (status === 'failed') return 'danger' - if (['paying', 'refunding'].includes(status)) return 'warning' - return 'info' -} - -function paymentBizTypeLabel(type: string) { - const map: Record = { - order_pay: '订单支付', - checkout_refund: '结账退款', - arbitration_refund: '仲裁退款', - admin_refund: '人工退款', - cancel_refund: '取消退款', - admin_close_refund: '客服关闭退款', - admin_seal_refund: '封存退款', - deposit_refund: '暂扣押金归还', - } - return map[type] || type -} - function moneyCent(value: number | undefined | null) { return formatCentWithSymbol(Number(value || 0)) } @@ -773,6 +846,10 @@ function settlementModeLabel(mode?: string) { return map[mode || 'owner_wallet'] || mode || '-' } +function accountSourceLabel(source?: string) { + return source === 'external' ? '外部上传' : '站内上传' +} + function offlineSettlementStatusLabel(status?: string) { const map: Record = { none: '无需线下结算', @@ -892,10 +969,6 @@ function formatRatioNumber(value: number) { const rounded = Math.round(value * 100) / 100 return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) } - -function paymentPaidAt(record: AdminPayment) { - return record.paid_at || record.updated_at || record.created_at -} - + diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index 33d2f65..edce1b5 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -13,6 +13,7 @@ import { formatDateTime } from '@/shared/utils/time' import { centToYuan, formatMoney } from '@/shared/utils/money' import { formatListingNo } from '@/shared/utils/listingDisplay' import { adminPath } from '@/shared/utils/adminPath' +import { useAdminListState } from '@/features/admin/composables/useAdminListState' import AdminTablePagination from '../components/AdminTablePagination.vue' const filters = reactive({ @@ -31,6 +32,12 @@ const currentPageSize = ref(20) const route = useRoute() const router = useRouter() const canReturnToWithdrawals = computed(() => route.query.from === 'withdrawals') +const { restoreScroll } = useAdminListState({ + key: 'orders', + filters, + currentPage, + pageSize: currentPageSize, +}) const orderStatusOptions = [ { label: '待支付', value: 'pending_payment' }, @@ -77,13 +84,14 @@ const settlementStatusOptions = [ { label: '已仲裁', value: 'arbitrated' }, ] as const -onMounted(() => { +onMounted(async () => { // 支持从提现审核等页面跳转预填号主/租客搜索 const keyword = route.query.keyword if (typeof keyword === 'string' && keyword.trim()) { filters.keyword = keyword.trim() } - void loadOrders() + await loadOrders() + restoreScroll() }) async function loadOrders() { @@ -156,6 +164,7 @@ function userText(value: string | number | undefined) { function isPlatformManaged(row: AdminOrderListItem) { return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' } + - + diff --git a/frontend/src/features/admin/views/AdminPickupView.css b/frontend/src/features/admin/views/AdminPickupView.css new file mode 100644 index 0000000..639fda6 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupView.css @@ -0,0 +1,120 @@ +.form-hint { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} +.pickup-filter-bar { + margin-bottom: 16px; +} + +.pickup-filter-bar :deep(.el-form) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; +} + +.pickup-filter-bar :deep(.el-form-item) { + margin: 0; +} + +.pickup-filter-bar :deep(.el-form-item__label) { + padding-right: 7px; + color: #52616f; + font-size: 13px; +} + +.pickup-filter-bar :deep(.el-select) { + width: 128px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(3) .el-select) { + width: 150px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(4) .el-select) { + width: 175px; +} + +.pickup-keyword-filter :deep(.el-input) { + width: 280px; +} + +.pickup-filter-actions :deep(.el-form-item__content) { + gap: 8px; +} + +@media (max-width: 900px) { + .pickup-filter-bar :deep(.el-form) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pickup-filter-bar :deep(.el-form-item), + .pickup-filter-bar :deep(.el-select), + .pickup-keyword-filter :deep(.el-input) { + width: 100%; + } + + .pickup-filter-bar :deep(.el-form-item__content) { + min-width: 0; + flex: 1; + } +} + +@media (max-width: 600px) { + .pickup-filter-bar :deep(.el-form) { + grid-template-columns: minmax(0, 1fr); + } +} +.text-muted { + color: #94a3b8; +} +.price-snapshot { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: #475569; + font-size: 13px; + line-height: 1.6; +} +.source-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} +.source-cell small { + color: #64748b; +} +.listing-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +.listing-option-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.listing-option-price { + flex: none; + color: #64748b; + font-variant-numeric: tabular-nums; +} +.selected-source { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + color: #475569; + font-size: 13px; +} +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index ef6d8fd..2ee53db 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -902,125 +902,4 @@ function listingOptionLabel(item: AvailableListing) { - + diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.css b/frontend/src/features/admin/views/AdminQrCodePoolView.css new file mode 100644 index 0000000..4e40066 --- /dev/null +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.css @@ -0,0 +1,448 @@ +.qrcode-pool-view { + display: flex; + flex-direction: column; + gap: 16px; +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.stat-card { + text-align: center; +} + +.toolbar-card, +.table-card { + border-radius: 10px; +} + +.table-card :deep(.el-card__body) { + padding: 20px; +} + +.stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.2; +} + +.stat-value.success { + color: var(--el-color-success); +} +.stat-value.primary { + color: var(--el-color-primary); +} +.stat-value.danger { + color: var(--el-color-danger); +} + +.stat-label { + margin-top: 6px; + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.filter-area, +.action-area { + display: flex; + gap: 8px; + align-items: center; +} + +.filter-area { + flex: 1 1 auto; + flex-wrap: wrap; +} + +.action-area { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.keyword-filter { + width: 240px; +} + +.compact-filter { + width: 128px; +} + +.date-range-filter { + width: 330px; +} + +.qrcode-table { + border-radius: 10px; + box-shadow: none; + padding: 0; +} + +.qrcode-table :deep(.el-table__inner-wrapper) { + min-width: 0; +} + +.qrcode-table :deep(.el-table__header th) { + height: 42px; + background: #f8f9fb; +} + +.qrcode-table :deep(.el-table__cell) { + padding: 10px 0; +} + +.qrcode-table :deep(.cell) { + padding: 0 14px; +} + +.qrcode-table :deep(.el-table__fixed-right) { + box-shadow: -8px 0 12px rgba(31, 45, 61, 0.04); +} + +.qrcode-table :deep(.el-table__header), +.qrcode-table :deep(.el-table__body) { + width: 100% !important; +} + +.qrcode-table :deep(.el-table__header-wrapper), +.qrcode-table :deep(.el-table__body-wrapper) { + overflow-x: auto; +} + +.qrcode-table :deep(.selection-col) { + width: 4%; +} + +.qrcode-table :deep(.qr-col) { + width: 8%; +} + +.qrcode-table :deep(.status-col) { + width: 8%; +} + +.qrcode-table :deep(.name-col) { + width: 17%; +} + +.qrcode-table :deep(.note-col) { + width: 17%; +} + +.qrcode-table :deep(.group-col) { + width: 17%; +} + +.qrcode-table :deep(.rename-col) { + width: 8%; +} + +.qrcode-table :deep(.time-col) { + width: 12%; +} + +.qrcode-table :deep(.action-col) { + width: 10%; +} + +.qrcode-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #ffffff; +} + +.note-cell { + color: #303846; + font-weight: 500; +} + +.time-stack { + display: inline-flex; + flex-direction: column; + gap: 2px; + color: #5f6b7a; + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: nowrap; +} + +.copy-cell-button { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: #303846; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-align: left; +} + +.copy-cell-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copy-cell-button .el-icon { + flex: 0 0 auto; + color: var(--el-color-primary); + font-size: 14px; +} + +.copy-cell-button:disabled { + color: #a8b0bd; + cursor: default; +} + +.table-actions { + display: flex; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin-left: 0; + padding: 4px 6px; +} + +@media (max-width: 1280px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .action-area { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .keyword-filter, + .compact-filter, + .date-range-filter { + width: 100%; + } + + .filter-area, + .action-area { + align-items: stretch; + } +} + +.upload-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.batch-upload-area { + width: 100%; +} + +.qrcode-upload-form :deep(.el-form-item__label) { + align-items: center; + padding-right: 18px; + line-height: 32px; + white-space: nowrap; + word-break: keep-all; +} + +.qrcode-upload-form :deep(.el-form-item) { + margin-bottom: 18px; +} + +.qrcode-upload-form :deep(.el-form-item__content) { + min-width: 0; +} + +.batch-upload-area :deep(.el-upload) { + width: 100%; +} + +.batch-upload-area :deep(.el-upload-dragger) { + width: 100%; + padding: 12px 18px; +} + +.batch-upload-area :deep(.el-icon--upload) { + margin-bottom: 4px; + font-size: 28px; + line-height: 1; +} + +.batch-upload-area :deep(.el-upload__text) { + line-height: 20px; +} + +.upload-tip { + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 18px; +} + +.uploaded-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; + max-height: 320px; + overflow-y: auto; + padding: 10px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #fafbfc; +} + +.uploaded-item { + min-width: 0; + padding: 10px; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-main { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.uploaded-thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + flex: 0 0 auto; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-local-preview { + display: block; + width: 52px; + height: 52px; + border-radius: 6px; + object-fit: cover; +} + +.uploaded-remove { + position: absolute; + top: -7px; + right: -7px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid #ffffff; + border-radius: 50%; + background: var(--el-color-danger); + color: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.uploaded-remove:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.uploaded-name { + overflow: hidden; + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploaded-meta { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.uploaded-meta .el-tag { + height: 20px; +} + +.uploaded-group-editor { + margin-top: 8px; +} + +.expired-tag { + color: var(--el-color-danger); +} + +.edit-image-area { + display: flex; + align-items: center; + gap: 12px; +} + +.edit-image-preview { + flex: 0 0 auto; + width: 88px; + height: 88px; + overflow: hidden; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #fafbfc; +} + +.edit-image-preview :deep(.el-image), +.edit-image-preview :deep(img) { + display: block; + width: 100%; + height: 100%; +} + +.edit-image-empty { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: var(--el-text-color-secondary); + font-size: 12px; +} + +.expire-editor { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index 249340a..ef78aac 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -32,6 +32,7 @@ import { formatDateTime } from '@/shared/utils/time' import AdminTablePagination from '../components/AdminTablePagination.vue' import AuthImage from '@/shared/components/business/AuthImage.vue' import { readError } from '@/shared/utils/error' +import { optimizeImageForUpload } from '@/shared/utils/imageUpload' import { useAdminSessionStore } from '@/stores/adminSession' const adminSession = useAdminSessionStore() @@ -243,34 +244,33 @@ function parseGroupNameFromOcrText(text: string) { ) } -const ocrConcurrencyLimit = 4 +const ocrConcurrencyLimit = 2 let ocrRunningCount = 0 -const ocrWaitQueue: (() => void)[] = [] +class OCRQueueResetError extends Error {} +const ocrWaitQueue: { resolve: () => void; reject: (error: Error) => void }[] = [] function ocrAcquireSlot(): Promise { if (ocrRunningCount < ocrConcurrencyLimit) { ocrRunningCount++ return Promise.resolve() } - return new Promise(resolve => { - ocrWaitQueue.push(() => { - ocrRunningCount++ - resolve() - }) + return new Promise((resolve, reject) => { + ocrWaitQueue.push({ resolve, reject }) }) } function ocrReleaseSlot() { ocrRunningCount = Math.max(0, ocrRunningCount - 1) const next = ocrWaitQueue.shift() - if (next) next() + if (next) { + ocrRunningCount++ + next.resolve() + } } function ocrResetSlots() { - ocrRunningCount = 0 - while (ocrWaitQueue.length > 0) { - const next = ocrWaitQueue.shift() - next?.() + for (const waiter of ocrWaitQueue.splice(0)) { + waiter.reject(new OCRQueueResetError('OCR queue reset')) } } @@ -322,9 +322,11 @@ async function handleFileUpload(options: { file: File }) { } uploadedImages.value.push(item) + const normalizedFile = await optimizeImageForUpload(options.file, 'qrcode') const [uploaded, groupName] = await Promise.all([ - uploadAdminFile(options.file, 'qrcode'), - recognizeGroupNameWithLimit(options.file).catch(error => { + uploadAdminFile(normalizedFile, 'qrcode', { optimize: false }), + recognizeGroupNameWithLimit(normalizedFile).catch(error => { + if (error instanceof OCRQueueResetError) return '' console.warn('二维码群名 OCR 失败', error) ElMessage.warning(readError(error, '二维码群名 OCR 失败')) return '' @@ -977,453 +979,4 @@ onMounted(reloadAll)
- + diff --git a/frontend/src/features/admin/views/AdminRefundReviewView.vue b/frontend/src/features/admin/views/AdminRefundReviewView.vue index 6dcdc97..c77efe6 100644 --- a/frontend/src/features/admin/views/AdminRefundReviewView.vue +++ b/frontend/src/features/admin/views/AdminRefundReviewView.vue @@ -14,6 +14,7 @@ import { formatCentWithSymbol } from '@/shared/utils/money' import { orderStatusLabel, handoffStatusLabel } from '@/shared/utils/statusLabels' import { readError } from '@/shared/utils/error' import { adminPath } from '@/shared/utils/adminPath' +import { pendingRefundCountChangedEvent } from '@/features/admin/composables/usePendingRefundCount' const router = useRouter() const loading = ref(false) @@ -46,6 +47,7 @@ async function handleApprove(row: Order) { submitting.value = true try { await adminApproveRefund(row.id) + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款审核通过,${row.order_no} 已发起退款`) await loadOrders() } catch (error) { @@ -75,6 +77,7 @@ async function handleRejectRestore() { submitting.value = true try { await adminRejectRefund(row.id, 'restore') + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款已驳回,${row.order_no} 已恢复至待交接状态`) rejectVisible.value = false await loadOrders() @@ -100,6 +103,7 @@ async function handleRejectClose() { submitting.value = true try { await adminRejectRefund(row.id, 'close') + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款已驳回,${row.order_no} 已关闭`) rejectVisible.value = false await loadOrders() diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.css b/frontend/src/features/admin/views/AdminSystemConfigsView.css new file mode 100644 index 0000000..99f3d4c --- /dev/null +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.css @@ -0,0 +1,89 @@ +.publish-config-panel { + display: grid; + gap: 18px; + margin-bottom: 18px; + padding: 20px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.publish-config-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.publish-config-main h2 { + margin: 4px 0 6px; + color: #111827; + font-size: 20px; +} + +.publish-config-main span { + color: #6b7280; + font-size: 13px; +} + +.publish-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.welcome-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.home-stat-grid .publish-stat strong { + font-size: 18px; +} + +.publish-stat { + display: grid; + gap: 4px; + padding: 12px; + border-radius: 8px; + background: #f8fafc; +} + +.publish-stat strong { + color: #1477ff; + font-size: 22px; + line-height: 1; +} + +.publish-stat span, +.config-value { + color: #4b5563; + font-size: 13px; + overflow-wrap: anywhere; +} + +/* 仅实体主按钮提升文字清晰度,其他按钮保持组件默认配色。 */ +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link)) { + font-weight: 700; +} + +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) span) { + color: #ffffff !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + font-weight: 700; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (max-width: 1100px) { + .publish-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 31a1021..efcaf4b 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -965,94 +965,4 @@ function formatConfigValue(row: SystemConfig) { - + diff --git a/frontend/src/features/admin/views/AdminUsersView.css b/frontend/src/features/admin/views/AdminUsersView.css new file mode 100644 index 0000000..c51733e --- /dev/null +++ b/frontend/src/features/admin/views/AdminUsersView.css @@ -0,0 +1,149 @@ +.user-filter-bar { + margin-bottom: 16px; + padding: 16px 16px 0; + border-radius: 8px; + background: #fff; +} + +.user-filter-bar :deep(.el-form-item) { + margin-bottom: 16px; +} + +.balance-available { + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.balance-frozen { + color: #94a3b8; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.balance-frozen.is-positive { + color: #c2410c; +} + +.quota-cell { + display: grid; + gap: 2px; +} + +.quota-cell strong { + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +.quota-cell span { + color: #64748b; + font-size: 11px; + line-height: 1.3; +} + +.table-actions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 2px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin: 0; + height: 28px; + padding: 0 8px; + font-weight: 600; +} + +.table-actions :deep(.el-button + .el-button) { + margin-left: 0; +} + +.table-actions :deep(.el-button.is-text) { + border-radius: 6px; +} + +.table-actions :deep(.el-button.is-text:hover) { + background: #f1f5f9; +} + +.wallet-dialog { + display: grid; + gap: 14px; +} + +.quota-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; + font-size: 13px; +} + +.quota-summary strong { + color: #0f172a; +} + +.growth-dialog { + gap: 16px; +} + +.growth-current { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; +} + +.growth-current strong { + color: #0f172a; + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.wallet-balance-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.wallet-balance-cards > div { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.wallet-balance-cards span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.wallet-balance-cards strong { + color: #0f172a; + font-size: 22px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.full-control { + width: 100%; +} + +.dialog-body { + display: grid; + gap: 12px; +} diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index 5674c42..c47740d 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -755,154 +755,4 @@ function moneyCent(value: number | string | undefined) { - + diff --git a/frontend/src/features/admin/views/AdminWalletLedgerView.vue b/frontend/src/features/admin/views/AdminWalletLedgerView.vue index ef6aaa4..7a2314c 100644 --- a/frontend/src/features/admin/views/AdminWalletLedgerView.vue +++ b/frontend/src/features/admin/views/AdminWalletLedgerView.vue @@ -1,5 +1,13 @@ + + diff --git a/frontend/src/features/admin/views/AdminWithdrawalsView.vue b/frontend/src/features/admin/views/AdminWithdrawalsView.vue index c9c427b..32a324f 100644 --- a/frontend/src/features/admin/views/AdminWithdrawalsView.vue +++ b/frontend/src/features/admin/views/AdminWithdrawalsView.vue @@ -1,4 +1,16 @@ diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 123803c..8df0100 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -88,8 +88,8 @@ export async function logoutUser() { }) } -export async function fetchMe() { - const { data } = await apiClient.get>('/me') +export async function fetchMe(options?: { silent?: boolean }) { + const { data } = await apiClient.get>('/me', options) return data.data } diff --git a/frontend/src/features/auth/composables/useSessionMonitor.ts b/frontend/src/features/auth/composables/useSessionMonitor.ts new file mode 100644 index 0000000..1c4676e --- /dev/null +++ b/frontend/src/features/auth/composables/useSessionMonitor.ts @@ -0,0 +1,117 @@ +import { onBeforeUnmount, onMounted, watch } from 'vue' + +import { fetchMe } from '@/features/auth/api/auth' +import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' +import { useSessionStore } from '@/stores/session' + +const scope: AuthScope = 'user' +const reconnectDelay = 3000 +const verificationInterval = 5000 + +// 保持一个轻量级会话通道,使后台冻结能在没有页面请求时立即让用户退出。 +export function useSessionMonitor() { + const session = useSessionStore() + let source: EventSource | null = null + let reconnectTimer: number | null = null + let verificationTimer: number | null = null + let checking = false + let stopped = false + + function closeSource() { + source?.close() + source = null + } + + function scheduleReconnect() { + if (stopped || reconnectTimer !== null || !session.isLoggedIn) return + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + connect() + }, reconnectDelay) + } + + async function verifySession() { + if (stopped || checking || !session.isLoggedIn) return + checking = true + try { + await fetchMe({ silent: true }) + } catch { + // 认证失败由 axios 拦截器清理令牌并跳转;网络错误等待下一次校验。 + } finally { + checking = false + } + } + + function connect() { + if (stopped || source || !session.isLoggedIn) return + const token = getAccessToken(scope) + if (!token) return + + source = new EventSource(`/api/auth/events?token=${encodeURIComponent(token)}`, { + withCredentials: true, + }) + source.addEventListener('connected', () => undefined) + source.onerror = () => { + closeSource() + void verifySession() + scheduleReconnect() + } + } + + function stop() { + closeSource() + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (verificationTimer !== null) { + window.clearInterval(verificationTimer) + verificationTimer = null + } + } + + function start() { + stop() + if (!session.isLoggedIn) return + connect() + void verifySession() + verificationTimer = window.setInterval(verifySession, verificationInterval) + } + + function handleAuthStorageChanged(event: Event) { + const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail + if (detail?.scope !== scope || stopped) return + if (session.isLoggedIn) { + start() + } else { + stop() + } + } + + function handleVisibilityChange() { + if (document.visibilityState === 'visible') void verifySession() + } + + onMounted(() => { + stopped = false + start() + window.addEventListener('auth-storage-changed', handleAuthStorageChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + }) + + onBeforeUnmount(() => { + stopped = true + stop() + window.removeEventListener('auth-storage-changed', handleAuthStorageChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch( + () => session.isLoggedIn, + loggedIn => { + if (stopped) return + if (loggedIn) start() + else stop() + } + ) +} diff --git a/frontend/src/features/auth/views/MobileProfileView.css b/frontend/src/features/auth/views/MobileProfileView.css new file mode 100644 index 0000000..0f2c930 --- /dev/null +++ b/frontend/src/features/auth/views/MobileProfileView.css @@ -0,0 +1,665 @@ +.mobile-profile-shell { + min-height: 100dvh; + background: #f6f8fa; + color: #1a202c; + padding: 0 0 calc(64px + env(safe-area-inset-bottom)); +} + +/* ========== Hero Header ========== */ +.profile-hero { + background: #ffffff; + padding: 20px 16px 20px; + border-bottom: 1px solid rgba(243, 244, 246, 0.8); +} + +.hero-top-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.app-title { + font-size: 16px; + font-weight: 700; + color: #111827; + letter-spacing: 0.5px; +} + +.hero-setting-btn { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + background: #f3f4f6; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s; +} + +.hero-setting-btn:active { + background: #e5e7eb; +} + +.profile-hero-content { + display: flex; + align-items: center; + gap: 16px; +} + +.avatar-circle { + display: grid; + width: 60px; + height: 60px; + place-items: center; + border-radius: 999px; + overflow: hidden; + background: #eaf2ff; + color: #1477ff; + font-size: 24px; + font-weight: 900; + border: 1px solid #e5e7eb; +} + +.avatar-circle img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-user { + flex: 1; + min-width: 0; +} + +.name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.user-nickname { + margin: 0; + font-size: 18px; + font-weight: 800; + color: #111827; +} + +.verified-tag, +.unverified-tag { + font-size: 10px !important; + height: 18px; + padding: 0 6px; + font-weight: 700; + border: none; +} + +.user-meta { + margin: 4px 0 0; + color: #6b7280; + font-size: 12px; +} + +.growth-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 12px 16px 0; +} + +.growth-card > div { + display: grid; + gap: 4px; + min-width: 0; + padding: 14px; + border: 1px solid rgba(243, 244, 246, 0.9); + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); +} + +.growth-card span { + color: #9ca3af; + font-size: 11px; + font-weight: 700; +} + +.growth-card strong { + color: #7c3aed; + font-size: 20px; + line-height: 1.2; + font-weight: 900; +} + +/* ========== Balance Card ========== */ +.balance-card { + margin: 12px 16px 14px; + padding: 16px; + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.balance-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.balance-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.balance-label { + color: #9ca3af; + font-size: 11px; + font-weight: 600; +} + +.balance-value { + font-size: 26px; + font-weight: 800; + font-family: Inter, system-ui, sans-serif; + color: #111827; +} + +.withdraw-btn { + background: transparent; + border: 1px solid #1477ff; + color: #1477ff; + font-weight: 700; + font-size: 12px; + height: 28px; + padding: 0 16px; + border-radius: 999px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.withdraw-btn:active { + background: #1477ff; + color: #ffffff; +} + +.balance-footer { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #f3f4f6; +} + +.detail-btn { + background: transparent; + border: none; + color: #1477ff; + font-weight: 700; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; +} + +/* ========== Menu Cards ========== */ +.menu-card { + margin: 0 16px 14px; + background: #ffffff; + border-radius: 16px; + padding: 16px; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.menu-card.cell-card { + padding: 0; + overflow: hidden; +} + +.menu-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.menu-card .card-header h3 { + margin: 0; + font-size: 14px; + font-weight: 800; + color: #111827; +} + +.see-all { + font-size: 11px; + color: #6b7280; + display: flex; + align-items: center; + gap: 2px; + cursor: pointer; +} + +/* Grid Menu Layout */ +.grid-menu { + display: flex; + justify-content: space-between; + align-items: center; +} + +.grid-menu.col-4 .grid-item { + flex: 0 0 25%; +} + +.grid-menu.col-5 .grid-item { + flex: 0 0 20%; +} + +.grid-menu.col-3 .grid-item { + flex: 0 0 33.33%; +} + +.grid-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.grid-item:active .icon-wrap { + transform: scale(0.92); +} + +.icon-wrap { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 12px; + transition: transform 0.15s ease; +} + +/* Vibrant pastel colors for menu icon wraps */ +.icon-wrap.warning { + color: #f59e0b; + background: rgba(245, 158, 11, 0.08); +} +.icon-wrap.info { + color: #2563eb; + background: rgba(37, 99, 235, 0.08); +} +.icon-wrap.primary { + color: #1477ff; + background: rgba(20, 119, 255, 0.08); +} +.icon-wrap.success { + color: #10b981; + background: rgba(16, 185, 129, 0.08); +} +.icon-wrap.orange { + color: #ff5f00; + background: rgba(255, 95, 0, 0.08); +} +.icon-wrap.purple { + color: #8b5cf6; + background: rgba(139, 92, 246, 0.08); +} +.icon-wrap.teal { + color: #0d9488; + background: rgba(13, 148, 136, 0.08); +} + +.grid-item span { + font-size: 11px; + color: #374151; + font-weight: 600; +} + +/* Vant Cell Adjustments */ +:deep(.van-cell-group) { + background: transparent; +} +:deep(.van-cell) { + padding: 14px 16px; + font-size: 14px; +} +.verified-color { + color: #10b981; + font-weight: 600; +} +.unverified-color { + color: #9ca3af; +} +.unread-cell-value { + color: #ef4444; + font-weight: 700; +} + +/* 收支明细 Popup */ +.ledgers-popup { + display: flex; + flex-direction: column; +} +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #f3f4f6; + flex-shrink: 0; +} +.popup-header h3 { + margin: 0; + font-size: 16px; + font-weight: 800; +} +.popup-close { + border: none; + background: transparent; + font-size: 16px; + color: #9ca3af; + cursor: pointer; +} +.popup-body { + flex: 1; + overflow-y: auto; + padding: 0 16px; +} +.ledger-list { + display: flex; + flex-direction: column; +} +.ledger-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid #f3f4f6; +} +.ledger-item:last-child { + border-bottom: none; +} +.ledger-left { + display: flex; + flex-direction: column; + gap: 4px; +} +.ledger-remark { + font-size: 14px; + color: #1f2937; + font-weight: 600; +} +.ledger-time { + font-size: 11px; + color: #9ca3af; +} +.ledger-right { + font-size: 15px; + font-weight: 800; +} +.in-color { + color: #10b981; +} +.out-color { + color: #ef4444; +} + +/* 租后须知 Popup */ +.notice-popup { + display: flex; + flex-direction: column; +} +.notice-content { + padding: 16px 0; + color: #374151; + font-size: 14px; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; +} +.center-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 0; +} + +/* ========== 设置面板 ========== */ +.settings-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #f5f6f8; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.settings-header h2 { + margin: 0; + font-size: 17px; + font-weight: 700; +} + +.settings-close { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: none; + background: none; + color: #999; + cursor: pointer; +} + +.settings-group { + margin-top: 12px; +} + +.settings-group-title { + margin: 0 0 4px 16px; + font-size: 13px; + font-weight: 600; + color: #999; +} + +.settings-group :deep(.van-cell) { + font-size: 15px; +} + +.settings-logout { + margin: auto 16px 24px; + padding-top: 20px; +} + +/* ========== 资料修改 ========== */ +.profile-editor { + padding: 16px 16px max(18px, env(safe-area-inset-bottom)); + background: #fff; +} + +.profile-editor-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile-editor-header h2 { + margin: 0; + color: #17233d; + font-size: 18px; + font-weight: 800; +} + +.profile-editor-header button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + border-radius: 12px; + background: #f4f6f8; + color: #64748b; +} + +.profile-preview { + display: flex; + align-items: center; + gap: 12px; + margin: 18px 0; + padding: 14px; + border-radius: 14px; + background: #f7faff; +} + +.profile-preview-avatar { + display: grid; + width: 52px; + height: 52px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: 16px; + background: #1477ff; + color: #fff; + font-size: 22px; + font-weight: 900; +} + +.profile-preview-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-preview-avatar.clickable { + cursor: pointer; + position: relative; +} + +.avatar-upload-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + opacity: 0; + transition: opacity 0.2s ease; +} + +.profile-preview-avatar.clickable:hover .avatar-upload-overlay, +.profile-preview-avatar.clickable:active .avatar-upload-overlay { + opacity: 1; +} + +.avatar-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.8); +} + +.preview-info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.preview-info strong { + display: block; + max-width: 230px; + overflow: hidden; + color: #17233d; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-info p { + margin: 0; + color: #7b8798; + font-size: 12px; +} + +.upload-btn { + margin-top: 4px; + font-weight: 600; +} + +.editor-field { + display: block; + margin-bottom: 12px; +} + +.password-hint { + margin: 8px 0 14px; + color: #8b9cb5; + font-size: 12px; + line-height: 1.5; +} + +.editor-field span { + display: block; + margin-bottom: 6px; + color: #697386; + font-size: 13px; + font-weight: 700; +} + +.editor-field input { + box-sizing: border-box; + width: 100%; + height: 46px; + border: 1px solid #e1e7ef; + border-radius: 12px; + background: #fff; + color: #17233d; + font-size: 14px; + outline: none; + padding: 0 12px; +} + +.editor-field input:focus { + border-color: #1477ff; + box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.1); +} + +.profile-save-btn { + height: 44px; + margin-top: 6px; + background: #1477ff; + border-color: transparent; + font-weight: 800; +} + +/* ========== 响应式适配 ========== */ +@media (min-width: 520px) { + .mobile-profile-shell { + max-width: 430px; + margin: 0 auto; + box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08); + } +} diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index 39a37a1..83c8efa 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -691,670 +691,4 @@ function resolveAvatarURL(url: string | undefined | null) { - + diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index c6df45e..0916d85 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -30,6 +30,7 @@ export interface ChatConversation { title: string status: string role: 'renter' | 'owner' | 'support' | 'customer' | 'admin' + admin_remark?: string participants?: ChatParticipant[] last_message_id?: number last_message_preview: string @@ -38,6 +39,7 @@ export interface ChatConversation { last_sender_id?: number last_sender_role?: string unread_count: number + needs_reply: boolean created_at: string updated_at: string } @@ -159,6 +161,21 @@ export async function fetchAdminChats(page = 1, pageSize = 50, query: AdminChatQ return data.data } +export async function fetchAdminChatCounts(query: AdminChatQuery = {}) { + const params = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) + const { data } = await apiClient.get>('/admin/chats/counts', { + params, + }) + return data.data +} + +export async function markAllAdminChatsRead() { + const { data } = await apiClient.post>('/admin/chats/read-all') + return data.data +} + export async function fetchAdminChat(id: number) { const { data } = await apiClient.get>(`/admin/chats/${id}`) return data.data diff --git a/frontend/src/features/chats/composables/useChatSSE.ts b/frontend/src/features/chats/composables/useChatSSE.ts index d69c91a..99f7585 100644 --- a/frontend/src/features/chats/composables/useChatSSE.ts +++ b/frontend/src/features/chats/composables/useChatSSE.ts @@ -1,6 +1,7 @@ import { onBeforeUnmount, ref, type Ref } from 'vue' -import { refreshAccessToken } from '@/shared/api/client' -import type { AuthScope } from '@/shared/utils/authStorage' +import axios from 'axios' +import { redirectToLogin, refreshAccessToken } from '@/shared/api/client' +import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' export interface SSEMessage { id: number @@ -12,6 +13,7 @@ export interface SSEMessage { content_type: string content: string attachment_urls: string[] + admin_attention_type?: string created_at: string } @@ -88,8 +90,11 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { if (!stopped) { reconnectTimer = setTimeout(connect, reconnectDelay) } - } catch { + } catch (error) { closeSource() + if (axios.isAxiosError(error) && error.response?.status === 401) { + redirectToLogin(scope) + } } finally { refreshing = false } diff --git a/frontend/src/features/disputes/api/disputes.ts b/frontend/src/features/disputes/api/disputes.ts index 67913fb..018d11a 100644 --- a/frontend/src/features/disputes/api/disputes.ts +++ b/frontend/src/features/disputes/api/disputes.ts @@ -31,6 +31,7 @@ export interface Dispute { checkout_id?: number checkout_coin_consumed_m?: number checkout_consumable_amount_cent?: number + checkout_deposit_deduct_amount_cent?: number previous_checkout_status?: string arbitration_result: string arbitration_remark: string diff --git a/frontend/src/features/financial/__tests__/financialApi.spec.ts b/frontend/src/features/financial/__tests__/financialApi.spec.ts new file mode 100644 index 0000000..549e9e8 --- /dev/null +++ b/frontend/src/features/financial/__tests__/financialApi.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +const apiClient = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), +})) + +vi.mock('@/shared/api/client', () => ({ apiClient })) + +import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api/wallet' +import { + cancelWithdrawal, + createPaymentAccount, + createWithdrawal, + deletePaymentAccount, + fetchPaymentAccount, + fetchPaymentAccounts, + fetchWithdrawal, + fetchWithdrawals, + setDefaultPaymentAccount, + updatePaymentAccount, +} from '@/features/wallet/api/withdrawal' +import { fetchAdminPayments } from '@/features/admin/api/adminPayments' + +describe('financial API clients', () => { + it('fetches wallet balance and paginated ledger with server data', async () => { + apiClient.get + .mockResolvedValueOnce({ data: { data: { available_balance_cent: 100 } } }) + .mockResolvedValueOnce({ data: { data: { items: [], total: 0, page: 2, page_size: 10 } } }) + + await expect(fetchWalletBalance()).resolves.toEqual({ available_balance_cent: 100 }) + await expect(fetchWalletLedger(2, 10)).resolves.toEqual({ + items: [], + total: 0, + page: 2, + page_size: 10, + }) + expect(apiClient.get).toHaveBeenNthCalledWith(2, '/wallet/ledger', { + params: { page: 2, page_size: 10 }, + }) + }) + + it('supports payment-account CRUD and default selection', async () => { + const account = { id: 7, account_type: 'alipay' } + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [account], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: account } }) + apiClient.post.mockResolvedValueOnce({ data: { data: account } }) + apiClient.put.mockResolvedValueOnce({ data: { data: account } }) + apiClient.delete.mockResolvedValueOnce({ data: { data: { deleted: true } } }) + apiClient.post.mockResolvedValueOnce({ data: { data: { updated: true } } }) + + await expect(fetchPaymentAccounts()).resolves.toMatchObject({ items: [account], total: 1 }) + await expect(fetchPaymentAccount(7)).resolves.toEqual(account) + await expect( + createPaymentAccount({ account_type: 'alipay', account_name: 'A', account_no: 'x' }) + ).resolves.toEqual(account) + await expect(updatePaymentAccount(7, { is_default: true })).resolves.toEqual(account) + await expect(deletePaymentAccount(7)).resolves.toEqual({ deleted: true }) + await expect(setDefaultPaymentAccount(7)).resolves.toEqual({ updated: true }) + }) + + it('supports withdrawal lifecycle API calls', async () => { + const withdrawal = { id: 9, status: 'pending' } + apiClient.post + .mockResolvedValueOnce({ data: { data: withdrawal } }) + .mockResolvedValueOnce({ data: { data: { cancelled: true } } }) + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [withdrawal], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: withdrawal } }) + + await expect(createWithdrawal({ payment_account_id: 7, amount_cent: 1000 })).resolves.toEqual( + withdrawal + ) + await expect(fetchWithdrawals(1, 20)).resolves.toMatchObject({ items: [withdrawal] }) + await expect(fetchWithdrawal(9)).resolves.toEqual(withdrawal) + await expect(cancelWithdrawal(9)).resolves.toEqual({ cancelled: true }) + }) + + it('filters empty admin-payment query values and normalizes pagination', async () => { + apiClient.get.mockResolvedValueOnce({ data: { data: undefined } }) + + await expect( + fetchAdminPayments({ status: '', provider: 'mock', page: 3, silent: true }) + ).resolves.toEqual({ items: [], total: 0, page: 3, page_size: 20 }) + expect(apiClient.get).toHaveBeenLastCalledWith('/admin/payments', { + params: { provider: 'mock', page: 3 }, + silent: true, + }) + }) +}) diff --git a/frontend/src/features/listings/api/listingOptions.ts b/frontend/src/features/listings/api/listingOptions.ts index 33761fc..00465cd 100644 --- a/frontend/src/features/listings/api/listingOptions.ts +++ b/frontend/src/features/listings/api/listingOptions.ts @@ -99,6 +99,7 @@ export interface ListingPublishAgreements { } export interface ListingPublishOptions { + next_season_pass_enabled: boolean server_options: string[] face_options: string[] rank_options: string[] @@ -118,6 +119,7 @@ export interface ListingPublishOptions { } export const emptyListingPublishOptions: ListingPublishOptions = { + next_season_pass_enabled: false, server_options: [], face_options: [], rank_options: [], @@ -206,6 +208,7 @@ export function mergeListingPublishOptions( options?: Partial ): ListingPublishOptions { return { + next_season_pass_enabled: options?.next_season_pass_enabled === true, server_options: normalizeStringList(options?.server_options), face_options: normalizeStringList(options?.face_options), rank_options: normalizeStringList(options?.rank_options), diff --git a/frontend/src/features/listings/components/HomeFilters.vue b/frontend/src/features/listings/components/HomeFilters.vue index 8d407b5..e3ee21b 100644 --- a/frontend/src/features/listings/components/HomeFilters.vue +++ b/frontend/src/features/listings/components/HomeFilters.vue @@ -46,7 +46,10 @@ function updateFilter(key: K, value: HomeFilters[K] emit('update:filters', { [key]: value } as Partial) } -function toggleChip(key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'region' | 'rank', value: string) { +function toggleChip( + key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'region' | 'rank', + value: string +) { const current = props.filters[key] const next = current.includes(value) ? current.filter(item => item !== value) @@ -56,9 +59,7 @@ function toggleChip(key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'reg function toggleSkin(name: string) { const current = props.filters.skinNames - const next = current.includes(name) - ? current.filter(item => item !== name) - : [...current, name] + const next = current.includes(name) ? current.filter(item => item !== name) : [...current, name] updateFilter('skinNames', next) } @@ -121,9 +122,7 @@ function toggleAdvanced() { } /** 刀皮/红皮作为独立左列字段展示(与全宽皮肤区区分) */ -const meleeSkinGroup = computed(() => - props.skinFilterGroups.find(group => group.key === 'melee') -) +const meleeSkinGroup = computed(() => props.skinFilterGroups.find(group => group.key === 'melee')) const redSkinGroup = computed(() => props.skinFilterGroups.find(group => group.key === 'operatorRed') ) diff --git a/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts index 35807e7..0fd9c9e 100644 --- a/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { rangeLabel, isRangeSelected } from '@/features/listings/composables/useFilterOptions' +import { + awmRangeOptions, + isRangeSelected, + moneyRangeOptions, + rangeLabel, + ratioRangeOptions, +} from '@/features/listings/composables/useFilterOptions' describe('useFilterOptions', () => { describe('rangeLabel', () => { @@ -27,4 +33,10 @@ describe('useFilterOptions', () => { expect(isRangeSelected(undefined, undefined, undefined, undefined)).toBe(true) }) }) + + it('应该隐藏数量过小的首页筛选档位', () => { + expect(awmRangeOptions.some(option => option.label === '0-20')).toBe(false) + expect(moneyRangeOptions.some(option => option.label === '0-50')).toBe(false) + expect(ratioRangeOptions.some(option => option.label === '≤30')).toBe(false) + }) }) diff --git a/frontend/src/features/listings/composables/useFilterOptions.ts b/frontend/src/features/listings/composables/useFilterOptions.ts index b9e0478..f9c3ef9 100644 --- a/frontend/src/features/listings/composables/useFilterOptions.ts +++ b/frontend/src/features/listings/composables/useFilterOptions.ts @@ -9,10 +9,9 @@ export const coinRangeOptions = [ { label: '300以上', min: 300, max: undefined }, ] -/** AWM 子弹数量区间(与移动端档位保持一致) */ +/** AWM 子弹数量区间:移除数量过小的 0-20 档位 */ export const awmRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '0-20', min: 0, max: 20 }, { label: '20-50', min: 20, max: 50 }, { label: '50-100', min: 50, max: 100 }, { label: '100-200', min: 100, max: 200 }, @@ -21,7 +20,6 @@ export const awmRangeOptions = [ export const ratioRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '≤30', min: undefined, max: 30 }, { label: '30-35', min: 30, max: 35 }, { label: '35-40', min: 35, max: 40 }, { label: '40-45', min: 40, max: 45 }, @@ -30,7 +28,6 @@ export const ratioRangeOptions = [ export const moneyRangeOptions = [ { label: '全部', min: undefined, max: undefined }, - { label: '0-50', min: 0, max: 50 }, { label: '50-100', min: 50, max: 100 }, { label: '100-150', min: 100, max: 150 }, { label: '150-200', min: 150, max: 200 }, diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 507927d..8856452 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -136,7 +136,7 @@ const zoneOptions = computed(() => [ { key: 'night', label: '夜间专区', - hint: '0-8点可上号', + hint: '0-2点可响应上号', count: zoneCount('night'), }, { diff --git a/frontend/src/features/listings/views/ListingDetailView.css b/frontend/src/features/listings/views/ListingDetailView.css new file mode 100644 index 0000000..acae137 --- /dev/null +++ b/frontend/src/features/listings/views/ListingDetailView.css @@ -0,0 +1,680 @@ +.detail-back-bar { + /* 吸顶固定:始终显示在顶部导航栏(64px)下方,滚动时不跟随页面移走 */ + position: sticky; + top: 78px; + z-index: 20; + display: flex; + align-items: center; + margin-bottom: 14px; +} + +.detail-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border: 1px solid #eef1f5; + border-radius: 10px; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px rgba(23, 35, 61, 0.08); + transition: + border-color 0.2s ease, + color 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; +} + +.detail-back-btn:hover { + border-color: #ff6a00; + background: #fff7ed; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.12); +} + +.pc-detail-layout { + grid-template-columns: minmax(0, 1fr) 420px; + align-items: start; +} + +.pc-detail-main { + overflow: hidden; +} + +.detail-hero-card { + position: relative; + display: grid; + height: 340px; + overflow: hidden; + background: #111827; + color: #ffffff; +} + +.detail-hero-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-hero-card > span { + display: grid; + height: 340px; + place-items: center; + font-size: 32px; + font-weight: 900; +} + +.detail-hero-card::after { + position: absolute; + inset: 0; + content: ''; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)), + linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%); +} + +.detail-hero-overlay { + position: absolute; + inset: auto 0 0; + z-index: 1; + max-width: 860px; + padding: 28px; +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.detail-tags span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + font-size: 13px; + font-weight: 900; +} + +.detail-code-chip { + width: fit-content; + margin-top: 10px; + padding: 5px 10px; + border: 1px solid rgba(147, 197, 253, 0.6); + border-radius: 999px; + background: rgba(239, 246, 255, 0.92); + color: #2563eb; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-favorite-chip { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + margin-top: 8px; + padding: 7px 12px; + border: 1px solid rgba(255, 106, 0, 0.42); + border-radius: 999px; + background: rgba(255, 106, 0, 0.18); + color: #fff7ed; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-hero-overlay h1 { + margin: 0; + color: #ffffff; + font-size: 30px; + line-height: 1.25; +} + +.detail-hero-overlay p { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.82); + font-size: 16px; + font-weight: 800; +} + +.detail-body { + display: grid; + gap: 18px; + padding: 24px; +} + +.detail-summary-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} + +.detail-metric, +.detail-section { + border: 1px solid #edf0f4; + border-radius: 16px; + background: #ffffff; +} + +.detail-metric { + padding: 18px; + background: #f8fafc; +} + +.detail-metric span { + display: block; + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-metric strong { + display: block; + margin-top: 8px; + color: #17233d; + font-size: 28px; + line-height: 1.1; +} + +.detail-metric.is-coin strong { + color: #1477ff; +} + +.detail-metric.is-price strong { + color: #ef4444; +} + +.detail-section { + padding: 22px; +} + +.detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.detail-section-head h2 { + margin: 0; + color: #17233d; + font-size: 20px; +} + +.detail-section-head span { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.detail-chip-row span { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(255, 106, 0, 0.28); + border-radius: 8px; + background: #fff7ed; + color: #ea580c; + font-size: 13px; + font-weight: 900; +} + +.detail-info-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 0; +} + +.detail-info-grid div { + min-width: 0; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-info-grid dt { + color: #8b9cb5; + font-size: 12px; + font-weight: 900; +} + +.detail-info-grid dd { + margin: 8px 0 0; + overflow-wrap: anywhere; + color: #17233d; + font-size: 15px; + font-weight: 900; +} + +.detail-resource-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.detail-resource-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-resource-card span { + min-width: 0; + color: #475569; + font-size: 14px; + font-weight: 900; +} + +.detail-resource-card strong { + color: #17233d; + font-size: 18px; +} + +.detail-resource-card em { + display: flex; + align-items: center; + justify-content: space-between; + grid-column: 1 / -1; + font-size: 12px; + font-style: normal; + font-weight: 900; +} + +.detail-resource-card em b { + color: #ff6a00; +} + +.detail-resource-card em small { + color: #17233d; + font-size: 13px; + font-weight: 900; +} + +.skin-groups { + display: grid; + gap: 16px; +} + +.skin-group h3 { + margin: 0 0 10px; + color: #475569; + font-size: 15px; +} + +.detail-description { + margin: 0; + color: #52616f; + font-size: 15px; + line-height: 1.8; +} + +.detail-screenshot-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-screenshot { + margin: 0; +} + +.detail-screenshot-image { + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 12px; + border: 1px solid #edf0f4; + background: #f8fafc; + cursor: zoom-in; +} + +.detail-screenshot-image :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-screenshot figcaption { + margin-top: 8px; + color: #64748b; + font-size: 13px; + font-weight: 800; + text-align: center; +} + +.pc-order-card { + max-width: none; + border-radius: 20px; + padding: 24px; + overflow: hidden; +} + +.order-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.order-card-head h2 { + margin: 4px 0 0; + color: #17233d; + font-size: 22px; + line-height: 1.2; +} + +.order-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + background: #f1f5f9; + padding: 0 10px; + color: #64748b; + font-size: 12px; + font-weight: 900; +} + +.order-state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + min-height: 28px; + border-radius: 999px; + background: #ecfdf5; + color: #16a34a; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.order-state.is-busy { + background: #fff7ed; + color: #ea580c; +} + +.order-form { + display: grid; + gap: 14px; +} + +.order-safe-text { + margin-bottom: 18px; + color: #7a89a5; + font-size: 15px; + font-weight: 700; +} + +.order-total-box { + position: relative; + display: grid; + gap: 12px; + margin-bottom: 0; + border: 1px solid rgba(245, 158, 11, 0.2); + background: + linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.92)), #fffbeb; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.order-total-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-total-head span { + color: #9a5a16; + font-size: 14px; + font-weight: 900; +} + +.order-total-head strong { + margin: 0; + color: #ef4444; + font-size: 34px; + line-height: 1; + text-align: right; +} + +.order-price-breakdown { + display: grid; + gap: 10px; + padding: 0; +} + +.order-price-breakdown div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.order-price-breakdown span, +.order-deposit-row span { + color: #8a5a12; + font-size: 13px; + font-weight: 900; +} + +.order-price-breakdown strong, +.order-deposit-row strong { + margin: 0; + color: #17233d; + font-size: 16px; + line-height: 1.2; +} + +.order-deposit-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-deposit-row strong { + color: #92400e; + font-size: 18px; +} + +.order-check-list { + display: grid; + gap: 10px; + margin: 0; +} + +.order-check-list div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 44px; + border: 1px solid #eef2f7; + border-radius: 12px; + background: #f8fafc; + padding: 0 14px; +} + +.order-check-list dt { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.order-check-list dd { + margin: 0; + color: #17233d; + font-size: 14px; + font-weight: 900; + text-align: right; + overflow-wrap: anywhere; +} + +.full-control { + width: 100%; +} + +.order-action-group { + display: grid; + gap: 10px; + padding-top: 2px; +} + +.order-primary-btn { + min-height: 48px; + border: 0; + background: #ff8a00; + color: #ffffff; + font-size: 15px; + font-weight: 900; + box-shadow: 0 12px 24px rgba(255, 106, 0, 0.24); +} + +.order-primary-btn:hover, +.order-primary-btn:focus { + background: #ff7a00; + color: #ffffff; +} + +.order-primary-btn.is-disabled, +.order-primary-btn.is-disabled:hover { + background: #f3a94a; + color: rgba(255, 255, 255, 0.86); + box-shadow: none; +} + +.favorite-order-btn { + min-height: 46px; + margin-left: 0; + border-color: #ff6a00; + background: #ffffff; + color: #ff6a00; + font-size: 15px; + font-weight: 900; +} + +.favorite-order-btn:hover, +.favorite-order-btn:focus { + border-color: #ff6a00; + background: #fff7ed; + color: #ea580c; +} + +.agreement-dialog-body { + display: grid; + gap: 14px; +} + +.agreement-tip { + margin: 0; + color: #64748b; + font-size: 14px; + font-weight: 700; +} + +.agreement-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.agreement-panel { + display: grid; + gap: 10px; + min-width: 0; +} + +.agreement-panel h3 { + margin: 0; + color: #17233d; + font-size: 16px; + font-weight: 900; +} + +.agreement-content { + height: 260px; + overflow: auto; + border: 1px solid #d8dee9; + border-radius: 8px; + background: #f8fafc; + padding: 14px; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.75; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #f97316; + font-size: 12px; + font-weight: 800; +} + +@media (max-width: 1180px) { + .pc-detail-layout, + .detail-screenshot-grid { + grid-template-columns: 1fr; + } + + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .detail-hero-card, + .detail-hero-card img, + .detail-hero-card > span { + height: 300px; + } + + .detail-hero-overlay { + padding: 24px; + } + + .detail-hero-overlay h1 { + font-size: 26px; + } +} + +@media (max-width: 720px) { + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: 1fr; + } + + .agreement-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index 1f87ba0..0680828 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -427,6 +427,13 @@ function goBack() { +
+
+

号主备注

+
+

{{ listing.description || '号主暂未填写详细说明。' }}

+
+

额外消耗品

@@ -465,13 +472,6 @@ function goBack() {
-
-
-

号主备注

-
-

{{ listing.description || '号主暂未填写详细说明。' }}

-
-

账号截图

@@ -627,685 +627,4 @@ function goBack() {
- + diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index b53de37..142bb77 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -104,14 +104,13 @@ const zoneOptions = [ { key: 'all', label: '全部', hint: '当前可租' }, { key: 'sale', label: '特惠', hint: '价格更划算' }, { key: 'gift', label: '赠送', hint: '含赠送物品' }, - { key: 'night', label: '夜间', hint: '0-8点可上号' }, + { key: 'night', label: '夜间', hint: '0-2点可响应上号' }, { key: 'password', label: '账密', hint: '交接更快' }, { key: 'highCoin', label: '高币', hint: '100M以上' }, ] const rangePresets: Record> = { price: [ - { label: '0-50', min: '0', max: '50' }, { label: '50-100', min: '50', max: '100' }, { label: '100-200', min: '100', max: '200' }, { label: '200以上', min: '200', max: '' }, @@ -123,8 +122,6 @@ const rangePresets: Record p { + margin: 0; + color: #6b7280; + font-size: 13px; + font-weight: 700; +} + +.agreement-panel { + display: grid; + gap: 8px; +} + +.agreement-panel > strong { + color: #17233d; + font-size: 14px; +} + +.agreement-content { + height: 190px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + padding: 12px; + color: #1f2937; + font-size: 12px; + font-weight: 700; + line-height: 1.7; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #ff6a00; + font-size: 12px; + font-weight: 800; +} + +.agreement-confirm-btn { + margin-top: 4px; + background: #ff6a00 !important; + border: none !important; + font-weight: 800; +} + +/* ========== 空状态 ========== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 80px 0; + color: #999; + font-size: 14px; +} diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index e8bf15c..0bbb676 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -415,6 +415,11 @@ function handleToggleFavorite() { +
+

号主备注

+

{{ listing.description }}

+
+

额外消耗品

@@ -445,11 +450,6 @@ function handleToggleFavorite() {
-
-

备注

-

{{ listing.description }}

-
-

账号截图

@@ -570,490 +570,4 @@ function handleToggleFavorite() { - + diff --git a/frontend/src/features/mohong/api/mohong.ts b/frontend/src/features/mohong/api/mohong.ts index ba487f1..9638861 100644 --- a/frontend/src/features/mohong/api/mohong.ts +++ b/frontend/src/features/mohong/api/mohong.ts @@ -240,8 +240,13 @@ export async function fetchAdminMohongProduct(id: number | string) { return data.data } -export async function createAdminMohongProduct(payload: Partial & { title: string; price_cent: number }) { - const { data } = await apiClient.post>('/admin/crash/products', payload) +export async function createAdminMohongProduct( + payload: Partial & { title: string; price_cent: number } +) { + const { data } = await apiClient.post>( + '/admin/crash/products', + payload + ) return data.data } diff --git a/frontend/src/features/mohong/components/MohongCartPanel.vue b/frontend/src/features/mohong/components/MohongCartPanel.vue index 2bd733f..1eebaa0 100644 --- a/frontend/src/features/mohong/components/MohongCartPanel.vue +++ b/frontend/src/features/mohong/components/MohongCartPanel.vue @@ -87,13 +87,7 @@ async function handleCheckout() { - + diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts index 6ef97e8..42f5089 100644 --- a/frontend/src/features/seller/composables/usePublishDraft.ts +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -17,8 +17,10 @@ export function defaultPublishForm(): PublishForm { login_method: '', online_start: '', online_end: '', + early_morning_response: '否', can_change_game_name: '', all_hero: '', + next_season_pass: '', ban_record: '', common_regions: [], depositAmountYuan: '', diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index cf343cd..6393d1f 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -79,6 +79,7 @@ export function usePublishForm(options: UsePublishFormOptions) { const submitButtonText = computed(() => (isEditMode.value ? '提交修改' : '立即发布')) const submitLoadingText = computed(() => (isEditMode.value ? '提交中...' : '发布中...')) const showBanRecordRiskHint = false + const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled) const pricing = usePricingCalculator({ form, @@ -308,8 +309,10 @@ export function usePublishForm(options: UsePublishFormOptions) { login_method: listing.login_platform || '', online_start: stringValue(onlineTime.start), online_end: stringValue(onlineTime.end), + early_morning_response: stringValue(summary.early_morning_response) || '否', can_change_game_name: stringValue(summary.can_change_game_name), all_hero: stringValue(summary.all_hero), + next_season_pass: stringValue(summary.next_season_pass), ban_record: stringValue(summary.ban_record), common_regions: stringArray(summary.common_regions), depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '', @@ -700,6 +703,9 @@ export function usePublishForm(options: UsePublishFormOptions) { if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗' const onlineTimeError = validateOnlineTime() if (onlineTimeError) return onlineTimeError + if (form.early_morning_response !== '是' && form.early_morning_response !== '否') { + return '请选择凌晨0-2点是否可响应上号' + } if (pricing.hasAcceleratedSaleRatioInput()) { const ratio = Number(form.accelerated_sale_ratio) if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' @@ -719,6 +725,9 @@ export function usePublishForm(options: UsePublishFormOptions) { if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录' if (!form.can_change_game_name) return '请选择是否可以修改游戏名' if (!form.all_hero) return '请选择是否有全英雄' + if (nextSeasonPassEnabled.value && !form.next_season_pass) { + return '请选择下赛季是否购买通行证' + } if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) return '请填写押金' if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确' if ( @@ -818,8 +827,10 @@ export function usePublishForm(options: UsePublishFormOptions) { start: form.online_start, end: form.online_end, }, + early_morning_response: form.early_morning_response, can_change_game_name: form.can_change_game_name, all_hero: form.all_hero, + next_season_pass: form.next_season_pass, ban_record: form.ban_record, common_regions: form.common_regions, remark: form.remark, @@ -911,6 +922,7 @@ export function usePublishForm(options: UsePublishFormOptions) { passwordAndDeviceConfirmed, canPublishAfterAgreements, showBanRecordRiskHint, + nextSeasonPassEnabled, isScanOnlyServer, effectiveLoginMethodOptions, fileInput, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css index d5e03f7..6e6c8e0 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css @@ -88,6 +88,10 @@ font-size: 16px; } +.early-morning-response-field :deep(.van-field__label) { + width: 128px; +} + .hint-label, .backend-hint-target.has-hint, .upload-title.has-hint { diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 120c630..63aac55 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -27,6 +27,7 @@ const { passwordAndDeviceConfirmed, canPublishAfterAgreements, showBanRecordRiskHint, + nextSeasonPassEnabled, fileInput, activeUploadKey, form, @@ -121,6 +122,8 @@ function selectRadio(value: T, setter: (value: T) => void) { const canChangeGameNameOptions = ['是', '否'] const allHeroOptions = ['是', '否'] +const nextSeasonPassOptions = ['购买', '不购买'] +const earlyMorningResponseOptions = ['是', '否'] // 在线时间:通栏自定义块 + 底部时间选择,避免 van-field 窄列截断原生 time const onlineTimePickerVisible = ref(false) @@ -439,6 +442,28 @@ const screenshotGuides: Record = { + + + +
@@ -481,6 +506,27 @@ const screenshotGuides: Record = { 请填写能百分百联系上您的时间。该时段联系不上可能影响上架或扣款,请预留扫码与冻结人脸时间。

+ + + + {{ getListingTitle(listing) }}

{{ getListingSubtitle(listing) }}

- {{ getServerRegion(listing) || '—' }} + {{ + getServerRegion(listing) || '—' + }} {{ getLoginMethod(listing) }} @@ -360,7 +362,9 @@ async function handleOffline() {
- 编辑 + 编辑 +
+ + +
+
@@ -396,6 +408,15 @@ function selectDailyLoss(value: string | number) {
+
+ + +
+
{ { label: '负重等级', value: readAssetString(listing.value, 'load_level') || '--' }, { label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' }, { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)) }, - { label: '日损耗', value: getDailyLoss(listing.value) ? `${getDailyLoss(listing.value)}/天` : '--' }, + { + label: '日损耗', + value: getDailyLoss(listing.value) ? `${getDailyLoss(listing.value)}/天` : '--', + }, { label: 'M单价', value: formatRatio(listing.value) }, { label: '方便上号', value: getOnlineTimeText(listing.value) || '--' }, { label: '预计可租', value: formatEstimatedRentalDuration(listing.value) }, @@ -207,19 +210,37 @@ async function handleOffline() {
- 返回列表 + 返回列表

我的商品详情

仅本人可查看完整发布信息;他人无法通过此入口访问。

复制编号 - + 编辑 - + 提审 - + 下架
diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.css b/frontend/src/features/wallet/views/MobileWithdrawalView.css new file mode 100644 index 0000000..2e36b65 --- /dev/null +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.css @@ -0,0 +1,484 @@ +.mobile-withdrawal { + --van-primary-color: #ff6a00; + --van-button-primary-background: #ff6a00; + --van-button-primary-border-color: #ff6a00; + --van-radio-checked-icon-color: #ff6a00; + --van-checkbox-checked-icon-color: #ff6a00; + --van-tag-warning-color: #ff6a00; + min-height: 100vh; + padding: 12px 14px 28px; + background: #fff7ed; +} + +.page-header { + display: grid; + grid-template-columns: 40px 1fr 40px; + align-items: center; + height: 44px; + margin-bottom: 12px; +} + +.page-header h1 { + margin: 0; + text-align: center; + font-size: 17px; + color: #101828; +} + +.back-btn, +.refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 18px; + background: #fff; + color: #1f2937; +} + +.refresh-btn { + justify-self: end; +} + +.balance-panel { + display: grid; + gap: 6px; + padding: 18px; + border-radius: 8px; + background: linear-gradient(135deg, #ff8a1f 0%, #ff5a00 100%); + color: #fff; + box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22); +} + +.balance-panel span, +.balance-panel small { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; +} + +.balance-panel strong { + font-size: 31px; + line-height: 1.1; +} + +.withdraw-card, +.records-card { + margin-top: 12px; + padding: 14px; + border-radius: 8px; + background: #fff; +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; + color: #101828; +} + +.section-title button { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + color: #ff6a00; + font-size: 13px; +} + +.section-title button:disabled { + color: #98a2b3; +} + +.section-title span { + color: #98a2b3; + font-size: 12px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 28px 0; +} + +.empty-state { + display: grid; + justify-items: center; + gap: 10px; + padding: 24px 0; + color: #98a2b3; +} + +.empty-state p { + margin: 0; +} + +.account-list { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.account-option { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.account-option.active { + border-color: #ff6a00; + background: #fff7ed; +} + +.account-select { + display: grid; + grid-template-columns: 34px 1fr auto 20px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 44px; + padding: 0; + border: 0; + background: transparent; + text-align: left; +} + +.account-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: #fff3e8; + color: #ff6a00; +} + +.account-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.account-main strong { + overflow: hidden; + color: #101828; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-main small { + overflow: hidden; + color: #667085; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #f2f4f7; +} + +.account-actions button { + border: 0; + background: transparent; + color: #ff6a00; + font-size: 12px; +} + +.account-actions button.danger { + color: #b42318; +} + +.account-actions button:disabled { + color: #98a2b3; +} + +:deep(.van-cell) { + padding-right: 0; + padding-left: 0; +} + +.amount-meta, +.amount-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 0; + color: #667085; + font-size: 12px; +} + +.amount-meta button { + border: 0; + background: transparent; + color: #ff6a00; +} + +.amount-preview { + padding: 10px 12px; + border-radius: 8px; + background: #f8fafc; +} + +.amount-preview strong { + color: #ff6a00; + font-size: 18px; +} + +.submit-btn { + margin-top: 14px; +} + +.record-list { + display: grid; + gap: 10px; +} + +.record-item { + padding: 12px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.record-top, +.record-meta { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.record-top strong { + display: block; + color: #101828; + font-size: 18px; +} + +.record-top small, +.record-meta { + color: #98a2b3; + font-size: 12px; +} + +.record-meta { + margin-top: 8px; +} + +.record-remark { + margin: 10px 0 0; + color: #b42318; + font-size: 12px; +} + +.cancel-btn { + margin-top: 10px; + padding: 6px 10px; + border: 1px solid #fecdca; + border-radius: 999px; + background: #fff; + color: #b42318; + font-size: 12px; +} + +.account-popup { + max-height: 86vh; +} + +.popup-body { + padding: 20px 16px 24px; +} + +.popup-body h2 { + margin: 0 0 16px; + font-size: 17px; + color: #101828; +} + +.delete-account-btn { + margin-top: 10px; +} + +.type-tabs { + gap: 8px 12px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.type-tabs :deep(.van-radio) { + margin-right: 0; +} + +.type-tabs :deep(.van-radio__label) { + color: #1f2937; + font-size: 15px; +} + +.type-label { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.recommend-badge { + display: inline-flex; + align-items: center; + height: 17px; + padding: 0 6px; + border-radius: 999px; + background: #fff7ed; + color: #c2410c; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.type-tip { + margin: 0 0 12px; + color: #8a5a13; + font-size: 12px; + line-height: 1.5; +} + +.account-tip { + margin: 12px 0; + padding: 10px 12px; + border-radius: 8px; + background: #fffbeb; + color: #92400e; + font-size: 12px; + line-height: 1.6; +} + +.receive-qr-tip { + display: grid; + grid-template-columns: minmax(0, 1fr) 108px; + gap: 10px; + align-items: center; + margin: 12px 0; + padding: 10px; + border: 1px solid #fed7aa; + border-radius: 10px; + background: #fff7ed; + color: #92400e; +} + +.receive-qr-tip strong { + display: block; + margin-bottom: 5px; + color: #c2410c; + font-size: 13px; +} + +.receive-qr-tip p { + margin: 0; + font-size: 12px; + line-height: 1.55; +} + +.receive-qr-tip img { + display: block; + width: 108px; + border: 1px solid #fde6c8; + border-radius: 8px; + background: #ffffff; +} + +.certificate-section { + margin: 14px 0; +} + +.certificate-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + color: #1f2937; + font-size: 14px; + font-weight: 600; +} + +.certificate-title small { + color: #98a2b3; + font-size: 12px; + font-weight: 400; +} + +.certificate-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.certificate-item, +.certificate-upload { + position: relative; + overflow: hidden; + aspect-ratio: 1; + border-radius: 8px; + background: #fff7ed; +} + +.certificate-item { + border: 1px solid #fed7aa; +} + +.certificate-item img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.certificate-remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: rgba(15, 23, 42, 0.72); + color: #fff; +} + +.certificate-upload { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed #ff9f43; + color: #ff6a00; + font-size: 12px; +} + +.certificate-upload input { + display: none; +} + +.certificate-upload span { + line-height: 1; +} diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.vue b/frontend/src/features/wallet/views/MobileWithdrawalView.vue index fdcda56..d2341a9 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.vue +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.vue @@ -663,489 +663,4 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning - + diff --git a/frontend/src/layouts/AdminLayout.css b/frontend/src/layouts/AdminLayout.css new file mode 100644 index 0000000..7cf5377 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.css @@ -0,0 +1,373 @@ +.admin-shell { + display: flex; + height: 100vh; + min-height: 100vh; + background-color: #f5f7fa; + overflow: hidden; +} + +.admin-sidebar { + width: 220px; + height: 100vh; + flex: none; + background-color: #304156; + transition: width 0.3s ease; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.admin-shell--collapsed .admin-sidebar { + width: 64px; +} + +.admin-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid #3d4e5f; +} + +.admin-brand { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #fff; + overflow: hidden; +} + +.brand-mark { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background-color: #409eff; + border-radius: 4px; + font-size: 18px; + font-weight: 700; + flex-shrink: 0; +} + +.brand-text { + font-size: 16px; + font-weight: 600; + white-space: nowrap; +} + +.collapse-btn { + color: #bfcbd9; + cursor: pointer; + font-size: 20px; + flex-shrink: 0; +} + +.collapse-btn:hover { + color: #fff; +} + +.admin-menu { + --el-menu-bg-color: transparent; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; + + flex: 1; + min-height: 0; + border-right: none; + background-color: transparent; + overflow-y: auto; + overflow-x: hidden; +} + +.admin-sidebar-footer { + display: grid; + flex: none; + gap: 10px; + padding: 12px; + border-top: 1px solid #3d4e5f; + background: rgba(16, 24, 39, 0.18); +} + +.admin-shell--collapsed .admin-sidebar-footer { + padding: 10px 8px; +} + +.sidebar-notification { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(64, 158, 255, 0.35); + border-radius: 8px; + color: #d7dfec; + text-decoration: none; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-notification:hover, +.sidebar-notification.router-link-active { + background-color: rgba(64, 158, 255, 0.14); + border-color: rgba(64, 158, 255, 0.8); + color: #fff; +} + +.admin-shell--collapsed .sidebar-notification { + padding: 0; +} + +.sidebar-notification-icon { + position: relative; + display: grid; + width: 20px; + height: 20px; + place-items: center; +} + +.sidebar-notification-text { + font-size: 13px; + font-weight: 700; +} + +.sidebar-badge { + position: absolute; + top: -8px; + right: -13px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border: 2px solid #304156; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 12px; + text-align: center; + box-sizing: border-box; +} + +.sidebar-status { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 1px solid; + border-radius: 8px; + color: #d7dfec; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-status:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-status { + padding: 0; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + width: 100%; + padding: 8px; + border-radius: 10px; + cursor: pointer; + transition: background-color 0.2s; +} + +.sidebar-user:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-user { + justify-content: center; + padding: 8px 0; +} + +.sidebar-user-text { + display: grid; + min-width: 0; + gap: 2px; +} + +.sidebar-user-text strong { + overflow: hidden; + color: #fff; + font-size: 13px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-user-text small { + overflow: hidden; + color: #8fa0ba; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-menu:not(.el-menu--collapse) { + width: 100%; +} + +:deep(.el-menu) { + background-color: transparent; +} + +:deep(.el-sub-menu__title) { + color: #bfcbd9; +} + +:deep(.el-sub-menu__title:hover) { + background-color: #263445; +} + +:deep(.el-sub-menu.is-active > .el-sub-menu__title) { + color: #409eff; +} + +:deep(.el-menu-item) { + color: #bfcbd9; +} + +:deep(.el-menu-item:hover) { + background-color: #263445; +} + +:deep(.el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +:deep(.el-menu--inline) { + background-color: #263445; +} + +:deep(.el-menu--inline .el-menu-item) { + height: 46px; + background-color: #263445; + font-size: 14px; +} + +:deep(.el-menu--inline .el-menu-item:hover), +:deep(.el-menu--inline .el-menu-item.is-active) { + background-color: #1f2d3d; +} + +.menu-item-label { + min-width: 0; +} + +.menu-item-badge { + margin-left: auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f43f5e; + color: #ffffff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +:deep(.el-menu--collapse .menu-item-badge) { + position: absolute; + top: 6px; + right: 5px; + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + border: 2px solid #304156; + border-radius: 50%; + color: transparent; + font-size: 0; + line-height: 0; +} + +:global(.admin-menu-popper) { + --el-menu-bg-color: #304156; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; +} + +:global(.admin-menu-popper .el-menu) { + border-right: none; + background-color: #304156; +} + +:global(.admin-menu-popper .el-menu-item) { + color: #bfcbd9; +} + +:global(.admin-menu-popper .el-menu-item:hover), +:global(.admin-menu-popper .el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +.admin-workspace { + flex: 1; + min-width: 0; + min-height: 0; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-text { + font-size: 13px; + color: inherit; + font-weight: 500; +} + +:deep(.el-dropdown-menu__item .status-dot) { + margin-right: 8px; +} + +.admin-main { + flex: 1; + min-height: 0; + padding: 18px 24px 24px; + overflow-y: auto; +} + +:global(.force-password-dialog) { + max-width: calc(100vw - 32px); +} + +.force-password-form { + display: grid; + gap: 2px; +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index d659b8d..4019d54 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -36,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router' import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin' import { useAdminNotificationUnreadCount } from '@/features/admin/composables/useAdminNotificationUnreadCount' +import { usePendingRefundCount } from '@/features/admin/composables/usePendingRefundCount' import { useAdminSessionStore } from '@/stores/adminSession' import { adminPath, ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath' import { readError } from '@/shared/utils/error' @@ -169,6 +170,12 @@ const allNavGroups: NavGroup[] = [ icon: Money, permission: 'wallet:view', }, + { + label: '运营开支', + to: adminPath('finance/operating-expenses'), + icon: Money, + permission: 'finance:operating_expense', + }, { label: '资金流水', to: adminPath('wallet-ledger'), @@ -219,6 +226,12 @@ const allNavGroups: NavGroup[] = [ icon: Operation, permission: 'system_config:view', }, + { + label: '数据备份', + to: adminPath('backups'), + icon: DocumentChecked, + permission: 'backup:view', + }, ], }, { @@ -291,6 +304,8 @@ const hasNotificationPermission = computed( ) const { unreadCount: adminNotificationUnreadCount, unreadLabel: adminNotificationUnreadLabel } = useAdminNotificationUnreadCount(route) +const { pendingRefundCount, pendingRefundLabel } = usePendingRefundCount(route) +const refundReviewPath = adminPath('orders/refund-review') const statusLabels: Record = { online: '在线', @@ -398,7 +413,15 @@ async function handleForcedPasswordChange() { - + @@ -519,344 +542,4 @@ async function handleForcedPasswordChange() {
- + diff --git a/frontend/src/layouts/AppLayout.css b/frontend/src/layouts/AppLayout.css new file mode 100644 index 0000000..c983d8e --- /dev/null +++ b/frontend/src/layouts/AppLayout.css @@ -0,0 +1,732 @@ +/* ========== 顶部栏 ========== */ +.pc-app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 100vw; + overflow-x: clip; + background: #f5f6f8; +} + +.pc-topbar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 20px; + min-width: 0; + max-width: 100%; + height: 64px; + padding: 0 24px; + background: #fff; + border-bottom: 1px solid #eef1f5; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +/* ========== 品牌 ========== */ +.pc-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} + +.pc-brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 12px; + background: linear-gradient(135deg, #fff3e8 0%, #ffe4cc 100%); + color: #ff6a00; + font-size: 20px; + font-weight: 900; +} + +.pc-brand-info { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.pc-brand-text { + font-size: 16px; + font-weight: 800; + color: #17233d; +} + +.pc-brand-sub { + font-size: 11px; + color: #8b9cb5; + font-weight: 600; +} + +/* ========== 导航 ========== */ +.pc-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: 10px; + min-width: 0; + height: 100%; +} + +.pc-nav-link { + position: relative; + display: flex; + align-items: center; + gap: 5px; + padding: 8px 14px; + border-radius: 10px; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-nav-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 16px; + text-align: center; +} + +.pc-nav-link:hover, +.pc-nav-link.router-link-active { + background: #fff3e8; + color: #ff6a00; +} + +/* ========== 搜索 ========== */ +.pc-search { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 0 14px; + height: 40px; + border-radius: 12px; + background: #f4f6f8; + border: 1px solid transparent; + max-width: 320px; + min-width: 180px; + flex: 1; + transition: all 0.2s; +} + +.pc-search:focus-within { + background: #fff; + border-color: #ff6a00; + box-shadow: 0 0 0 4px rgba(255, 106, 0, 0.08); +} + +.pc-search input { + border: none; + outline: none; + background: transparent; + color: #17233d; + font-size: 13px; + width: 100%; +} + +.pc-search input::placeholder { + color: #a0aab6; +} + +.pc-search .el-icon { + color: #a0aab6; + font-size: 16px; + flex-shrink: 0; +} + +/* ========== 用户操作区 ========== */ +.pc-user-actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: 12px; + min-width: 0; + flex-shrink: 0; + height: 100%; +} + +.pc-icon-link { + display: flex; + align-items: center; + gap: 10px; + padding: 0 22px; + height: 40px; + border-radius: 12px; + border: 1px solid #eef1f5; + background: #ffffff; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.pc-icon-link:hover { + background: #fff3e8; + border-color: #ff6a00; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.05); +} + +.service-link { + gap: 7px; + min-width: 92px; + justify-content: center; + padding: 0 18px; + border-color: rgba(255, 106, 0, 0.32); + border-radius: 10px; + background: #fff8f2; + color: #ff6a00; + font-size: 14px; + font-weight: 800; +} + +.service-link .el-icon { + width: 18px; + height: 18px; + font-size: 18px; + stroke-width: 2.2; +} + +.service-link:hover { + background: #fff3e8; + border-color: #ff6a00; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.1); + transform: translateY(-1px); +} + +.pc-login-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 16px; + height: 40px; + border-radius: 10px; + background: #f4f6f8; + color: #17233d; + font-size: 13px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-login-link:hover { + background: #eaeff5; +} + +.pc-post-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 18px; + height: 40px; + border-radius: 10px; + background: #ff6a00; + color: #fff; + font-size: 14px; + font-weight: 800; + text-decoration: none; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.2); + transition: all 0.2s; +} + +.pc-post-link:hover { + background: #e55d00; + transform: translateY(-1px); +} + +/* ========== 已登录 - 用户块 ========== */ +.pc-user-block { + position: relative; + height: 40px; + display: flex; + align-items: center; +} + +.pc-user-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + height: 36px; + border: none; + border-radius: 999px; + background: #f4f6f8; + cursor: pointer; + transition: all 0.2s; +} + +.pc-user-trigger:hover, +.pc-user-trigger.active { + background: #eaeff5; +} + +.pc-avatar-chip { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + overflow: hidden; + background: linear-gradient(135deg, #ff6a00 0%, #e55d00 100%); + color: #fff; + font-size: 13px; + font-weight: 800; + flex-shrink: 0; +} + +.pc-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pc-avatar-text { + line-height: 1; +} + +.pc-user-name { + max-width: 100px; + overflow: hidden; + color: #17233d; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc-realname-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge.is-normal { + background: #eef2ff; + color: #4f46e5; +} + +.pc-growth-badge.is-platinum { + background: #eff6ff; + color: #2563eb; +} + +.pc-growth-badge.is-diamond { + background: #ecfeff; + color: #0891b2; +} + +.pc-growth-badge.is-peak { + background: #fff7ed; + color: #ea580c; +} + +.pc-realname-badge.is-verified, +.dropdown-status.is-verified { + background: #ecfdf3; + color: #16a34a; +} + +.pc-realname-badge.is-pending, +.dropdown-status.is-pending { + background: #fff7ed; + color: #f97316; +} + +.pc-realname-badge.is-rejected, +.dropdown-status.is-rejected { + background: #fff1f2; + color: #e11d48; +} + +.pc-realname-badge.is-unverified, +.dropdown-status.is-unverified { + background: #f1f5f9; + color: #64748b; +} + +.arrow-icon { + font-size: 12px; + color: #8b9cb5; + transition: transform 0.3s; +} + +.arrow-icon.rotated { + transform: rotate(180deg); +} + +/* ========== 下拉菜单容器 (Hover Bridge) ========== */ +.pc-user-dropdown-container { + position: absolute; + top: 100%; + right: 0; + z-index: 200; + padding-top: 12px; +} + +.pc-user-dropdown { + width: 388px; + padding: 14px; + border-radius: 14px; + background: #fff; + border: 1px solid #eef1f5; + box-shadow: 0 10px 30px rgba(23, 35, 61, 0.15); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + border: none; + border-radius: 10px; + background: none; + color: #475569; + font-size: 14px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.dropdown-growth-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; +} + +.dropdown-growth-panel > div { + display: grid; + gap: 5px; + min-width: 0; + min-height: 62px; + padding: 11px 12px; + border-radius: 12px; + background: #f8fafc; +} + +.dropdown-growth-panel span { + color: #8b9cb5; + font-size: 12px; + font-weight: 800; +} + +.dropdown-growth-panel strong { + min-width: 0; + overflow: hidden; + color: #7c3aed; + font-size: 18px; + font-weight: 900; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 50px; + height: 20px; + margin-left: auto; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.dropdown-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateX(4px); +} + +.dropdown-item .el-icon { + font-size: 16px; + color: #94a3b8; + transition: color 0.2s; +} + +.dropdown-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-item.logout { + color: #ef4444; +} + +.dropdown-item.logout .el-icon { + color: #ef4444; +} + +.dropdown-item.logout:hover { + background: #fff1f0; +} + +.dropdown-divider { + height: 1px; + margin: 12px 6px; + background: #f1f5f9; +} + +.dropdown-basic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-basic-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 42px; + padding: 0 12px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 13px; + font-weight: 800; + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-basic-item .el-icon { + flex: 0 0 auto; + color: #94a3b8; + font-size: 16px; +} + +.dropdown-basic-item span:not(.dropdown-status) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-basic-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-basic-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-service-group { + padding: 0 2px; +} + +.dropdown-service-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2px 8px; + color: #17233d; + font-size: 14px; + font-weight: 900; +} + +.dropdown-service-more { + color: #64748b; + font-size: 12px; + font-weight: 800; + text-decoration: none; +} + +.dropdown-service-more:hover { + color: #ff6a00; +} + +.dropdown-service-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-service-card, +.dropdown-seller-card { + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-service-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-service-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-service-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-seller-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-seller-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-seller-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-card:hover .el-icon { + color: #ff6a00; +} + +/* 下拉动画 */ +.dropdown-enter-active, +.dropdown-leave-active { + transition: + opacity 0.2s ease, + transform 0.2s ease; +} +.dropdown-enter-from { + opacity: 0; + transform: translateY(10px); +} +.dropdown-leave-to { + opacity: 0; + transform: translateY(5px); +} + +/* ========== 主体 ---------- */ +.pc-main { + flex: 1; + min-width: 0; + padding: 20px 24px; + overflow-x: clip; +} + +/* ========== 页脚 ========== */ +.pc-footer { + flex-shrink: 0; + padding: 16px 24px; + background: #fff; + border-top: 1px solid #eef1f5; +} + +.pc-footer-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + max-width: 1280px; + margin: 0 auto; + color: #8b9cb5; + font-size: 12px; + line-height: 1.6; +} + +.pc-footer-icp { + color: #8b9cb5; + text-decoration: none; + transition: color 0.2s; +} + +.pc-footer-icp:hover { + color: #5a6577; +} + +.pc-footer-divider { + color: #d8dfe8; +} + +.pc-footer-copyright { + color: #8b9cb5; +} + +@media (max-width: 1280px) { + .pc-growth-badge { + display: none; + } + + .pc-realname-badge { + display: none; + } +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 3a7a60f..0ecd1fe 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -359,737 +359,4 @@ async function handleSupportClick() {
- + diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 99da303..3df265a 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -138,6 +138,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'), meta: adminMeta, }, + { + path: adminPath('finance/operating-expenses'), + name: 'admin-finance-operating-expenses', + component: () => import('@/features/admin/views/AdminOperatingExpensesView.vue'), + meta: adminMeta, + }, { path: adminPath('wallet-ledger'), name: 'admin-wallet-ledger', @@ -162,6 +168,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'), meta: adminMeta, }, + { + path: adminPath('backups'), + name: 'admin-backups', + component: () => import('@/features/admin/views/AdminBackupView.vue'), + meta: adminMeta, + }, { path: adminPath('payment-configs'), name: 'admin-payment-configs', diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index f8dc867..66ae921 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -175,7 +175,7 @@ function getRequestScope(url = ''): AuthScope { return url.startsWith('/admin') ? 'admin' : 'user' } -function redirectToLogin(scope: AuthScope) { +export function redirectToLogin(scope: AuthScope) { clearAuthStorage(scope) const currentPath = window.location.pathname + window.location.search const loginPath = getLoginPath(scope, currentPath) diff --git a/frontend/src/shared/api/files.ts b/frontend/src/shared/api/files.ts index 45404dc..71d2f0e 100644 --- a/frontend/src/shared/api/files.ts +++ b/frontend/src/shared/api/files.ts @@ -23,8 +23,12 @@ export async function uploadFile(file: File, scene: string) { return data.data } -export async function uploadAdminFile(file: File, scene: string) { - const uploadTarget = await optimizeImageForUpload(file, scene) +export async function uploadAdminFile( + file: File, + scene: string, + options: { optimize?: boolean } = {} +) { + const uploadTarget = options.optimize === false ? file : await optimizeImageForUpload(file, scene) const form = new FormData() form.append('file', uploadTarget) form.append('scene', scene) diff --git a/frontend/src/shared/types/publish.ts b/frontend/src/shared/types/publish.ts index 49fbf3d..8821cea 100644 --- a/frontend/src/shared/types/publish.ts +++ b/frontend/src/shared/types/publish.ts @@ -15,10 +15,14 @@ export type PublishForm = { login_method: string online_start: string online_end: string + /** 是否可在凌晨 0-2 点响应上号(是/否) */ + early_morning_response: string /** 是否可以修改游戏名(是/否) */ can_change_game_name: string /** 是否有全英雄(是/否) */ all_hero: string + /** 下赛季是否购买通行证(购买/不购买) */ + next_season_pass: string ban_record: string common_regions: string[] depositAmountYuan: number | '' diff --git a/frontend/src/shared/utils/auditLabels.ts b/frontend/src/shared/utils/auditLabels.ts index 87be060..8d31a15 100644 --- a/frontend/src/shared/utils/auditLabels.ts +++ b/frontend/src/shared/utils/auditLabels.ts @@ -53,8 +53,8 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [ { value: 'chat.renter_retention.remove', label: '到期移除租客会话', group: '系统任务' }, // 提号 { value: 'pickup_create', label: '创建提号', group: '提号' }, - { value: 'pickup_complete', label: '完成提号结算', group: '提号', highRisk: true }, - { value: 'pickup_offline_settlement', label: '确认提号线下结算', group: '提号', highRisk: true }, + { value: 'pickup_complete', label: '完成提号结算', group: '提号', highRisk: true }, + { value: 'pickup_offline_settlement', label: '确认提号线下结算', group: '提号', highRisk: true }, { value: 'pickup_profit_update', label: '修改提号利润', group: '提号', highRisk: true }, { value: 'pickup_cancel', label: '取消提号', group: '提号' }, // 申诉 diff --git a/frontend/src/shared/utils/listingDisplay.ts b/frontend/src/shared/utils/listingDisplay.ts index a7fbdad..3832e0c 100644 --- a/frontend/src/shared/utils/listingDisplay.ts +++ b/frontend/src/shared/utils/listingDisplay.ts @@ -158,6 +158,10 @@ export function getListingChips(item: PublicListingItem): ListingDisplayChip[] { if (readAssetString(item, 'all_hero') === '是') { chips.push({ label: '全英雄', value: '是' }) } + const nextSeasonPass = readAssetString(item, 'next_season_pass') + if (nextSeasonPass === '购买' || nextSeasonPass === '不购买') { + chips.push({ label: '下赛季通行证', value: nextSeasonPass }) + } return chips.filter(chip => chip.value) } diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts index dda323f..7c75736 100644 --- a/frontend/src/shared/utils/statusLabels.ts +++ b/frontend/src/shared/utils/statusLabels.ts @@ -160,11 +160,13 @@ export function handoffStatusLabel(status: string) { return readLabel(handoffStatusMap, status) } -export function orderHandoffStatusLabel(order?: { - handoff_status?: string - handoff_mode?: string - settlement_mode?: string -} | null) { +export function orderHandoffStatusLabel( + order?: { + handoff_status?: string + handoff_mode?: string + settlement_mode?: string + } | null +) { const status = order?.handoff_status || '' if (order?.handoff_mode !== 'platform' && order?.settlement_mode !== 'platform_managed') { return handoffStatusLabel(status) diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 1539e2b..78b3395 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -347,30 +347,290 @@ max-width: 720px; } -/* ========== Button Enhancements ========== */ -/* 仅实体主按钮强制白字;浅色、虚线和文字按钮沿用 Element Plus 的主题色。 */ +/* ========== Enterprise Button Design System ========== */ +.el-button { + font-family: inherit; + font-weight: 600; + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + letter-spacing: 0.2px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.el-button .el-icon { + font-size: 1.05em; +} + +/* 默认 / 次级按钮 (White / Slate) */ +.el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--danger):not( + .el-button--warning + ):not(.el-button--info):not(.is-text):not(.is-link) { + background: #ffffff; + color: #334155; + border: 1px solid #cbd5e1; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--danger):not( + .el-button--warning + ):not(.el-button--info):not(.is-text):not(.is-link):hover:not(.is-disabled) { + background: #f8fafc; + color: #0f172a; + border-color: #94a3b8; + box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); +} + +.el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--danger):not( + .el-button--warning + ):not(.el-button--info):not(.is-text):not(.is-link):active:not(.is-disabled) { + background: #f1f5f9; + transform: translateY(0); + box-shadow: none; +} + +/* 主按钮 (Primary - Modern Vibrant Blue/Indigo) */ .el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) { - font-weight: 700 !important; - --el-button-text-color: #ffffff; - --el-button-hover-text-color: #ffffff; - --el-button-active-text-color: #ffffff; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; + border: 1px solid #2563eb !important; + color: #ffffff !important; + box-shadow: + 0 1px 2px rgba(37, 99, 235, 0.12), + 0 3px 8px rgba(37, 99, 235, 0.22); +} + +.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):hover:not( + .is-disabled + ) { + background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%) !important; + border-color: #3b82f6 !important; + color: #ffffff !important; + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.35); + transform: translateY(-1px); +} + +.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):active:not( + .is-disabled + ) { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; + border-color: #1d4ed8 !important; + transform: translateY(0); + box-shadow: 0 1px 3px rgba(37, 99, 235, 0.2); } .el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) span { color: #ffffff !important; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); - font-weight: 700; + font-weight: 600; +} + +/* 成功按钮 (Success - Emerald) */ +.el-button--success:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) { + background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important; + border: 1px solid #059669 !important; + color: #ffffff !important; + box-shadow: + 0 1px 2px rgba(16, 185, 129, 0.12), + 0 3px 8px rgba(16, 185, 129, 0.22); +} + +.el-button--success:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):hover:not( + .is-disabled + ) { + background: linear-gradient(135deg, #34d399 0%, #10b981 100%) !important; + border-color: #10b981 !important; + color: #ffffff !important; + box-shadow: 0 4px 14px rgba(16, 185, 129, 0.35); + transform: translateY(-1px); +} + +.el-button--success:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):active:not( + .is-disabled + ) { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(16, 185, 129, 0.2); +} + +/* 危险按钮 (Danger - Rose/Ruby) */ +.el-button--danger:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) { + background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%) !important; + border: 1px solid #e11d48 !important; + color: #ffffff !important; + box-shadow: + 0 1px 2px rgba(244, 63, 94, 0.12), + 0 3px 8px rgba(244, 63, 94, 0.22); +} + +.el-button--danger:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):hover:not( + .is-disabled + ) { + background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%) !important; + border-color: #f43f5e !important; + color: #ffffff !important; + box-shadow: 0 4px 14px rgba(244, 63, 94, 0.35); + transform: translateY(-1px); +} + +.el-button--danger:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):active:not( + .is-disabled + ) { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(244, 63, 94, 0.2); +} + +/* 警告按钮 (Warning - Amber) */ +.el-button--warning:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important; + border: 1px solid #d97706 !important; + color: #ffffff !important; + box-shadow: + 0 1px 2px rgba(245, 158, 11, 0.12), + 0 3px 8px rgba(245, 158, 11, 0.22); +} + +.el-button--warning:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link):hover:not( + .is-disabled + ) { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%) !important; + border-color: #f59e0b !important; + color: #ffffff !important; + box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35); + transform: translateY(-1px); +} + +/* Plain 朴素按钮升级 */ +.el-button--primary.is-plain { + background: #eff6ff !important; + color: #2563eb !important; + border: 1px solid #bfdbfe !important; +} +.el-button--primary.is-plain:hover:not(.is-disabled) { + background: #2563eb !important; + color: #ffffff !important; + border-color: #2563eb !important; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25); + transform: translateY(-1px); +} + +.el-button--success.is-plain { + background: #ecfdf5 !important; + color: #059669 !important; + border: 1px solid #a7f3d0 !important; +} +.el-button--success.is-plain:hover:not(.is-disabled) { + background: #059669 !important; + color: #ffffff !important; + border-color: #059669 !important; + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.25); + transform: translateY(-1px); +} + +.el-button--danger.is-plain { + background: #fff1f2 !important; + color: #e11d48 !important; + border: 1px solid #fecdd3 !important; +} +.el-button--danger.is-plain:hover:not(.is-disabled) { + background: #e11d48 !important; + color: #ffffff !important; + border-color: #e11d48 !important; + box-shadow: 0 4px 12px rgba(225, 29, 72, 0.25); + transform: translateY(-1px); +} + +/* 链接/文字按钮 */ +.el-button.is-link { + padding: 4px 8px; + height: auto; + border-radius: 6px; + font-weight: 600; +} + +.el-button.is-link:hover:not(.is-disabled) { + background: rgba(15, 23, 42, 0.05); } .el-button.is-link.el-button--primary { - --el-button-text-color: var(--el-color-primary); - --el-button-hover-text-color: var(--el-color-primary-light-3); - --el-button-active-text-color: var(--el-color-primary-dark-2); + color: #2563eb !important; +} +.el-button.is-link.el-button--primary:hover:not(.is-disabled) { + color: #1d4ed8 !important; + background: #eff6ff !important; } -.el-button.is-link.el-button--primary span { - color: inherit !important; - text-shadow: none; +.el-button.is-link.el-button--success { + color: #059669 !important; +} +.el-button.is-link.el-button--success:hover:not(.is-disabled) { + color: #047857 !important; + background: #ecfdf5 !important; +} + +.el-button.is-link.el-button--danger { + color: #e11d48 !important; +} +.el-button.is-link.el-button--danger:hover:not(.is-disabled) { + color: #be123c !important; + background: #fff1f2 !important; +} + +/* 尺寸规格规范 */ +.el-button--small { + height: 28px; + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; +} + +.el-button--large { + height: 42px; + padding: 10px 20px; + font-size: 14px; + border-radius: 10px; +} + +.el-button.is-disabled, +.el-button.is-disabled:hover { + opacity: 0.55; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +/* ========== Enterprise Form Controls Enhancement ========== */ +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__wrapper { + border-radius: 8px !important; + box-shadow: 0 0 0 1px #cbd5e1 inset !important; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; + background-color: #ffffff !important; +} + +.el-input__wrapper:hover, +.el-select__wrapper:hover, +.el-textarea__wrapper:hover { + box-shadow: 0 0 0 1px #94a3b8 inset !important; +} + +.el-input__wrapper.is-focus, +.el-select__wrapper.is-focused, +.el-textarea__wrapper.is-focus { + box-shadow: + 0 0 0 1.5px #2563eb inset, + 0 0 0 3.5px rgba(37, 99, 235, 0.12) !important; +} + +.el-select-dropdown__item.is-selected { + color: #2563eb !important; + font-weight: 600; +} + +.el-tag { + border-radius: 6px; + font-weight: 500; } .eyebrow { diff --git a/frontend/src/styles/home-filters.css b/frontend/src/styles/home-filters.css index 98f661c..f25f857 100644 --- a/frontend/src/styles/home-filters.css +++ b/frontend/src/styles/home-filters.css @@ -104,7 +104,10 @@ font-weight: 600; line-height: 1.2; cursor: pointer; - transition: background 0.15s, color 0.15s, box-shadow 0.15s; + transition: + background 0.15s, + color 0.15s, + box-shadow 0.15s; } .pill-chip:hover { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1ec8cf8..2c4ee2f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -15,5 +15,22 @@ export default defineConfig({ environment: 'jsdom', globals: true, include: ['src/**/*.{spec,test}.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: [ + 'src/features/admin/api/adminPayments.ts', + 'src/features/admin/utils/adminOrderPresentation.ts', + 'src/features/orders/composables/useOrderPaymentCashier.ts', + 'src/features/wallet/api/wallet.ts', + 'src/features/wallet/api/withdrawal.ts', + ], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, }, }) diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh new file mode 100755 index 0000000..3017349 --- /dev/null +++ b/scripts/archive-binlog.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 分钟级 binlog 归档。每分钟检查活动 binlog 的 Position,仅在有新增已提交事务时 +# 轮转;每个对象以 server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +MYSQL_DATA_DIR='/var/lib/mysql' +work_dir='' +cleanup() { + if [[ -n "${work_dir}" && -d "${work_dir}" ]]; then + rm -rf -- "${work_dir}" + fi +} +trap cleanup EXIT + +require_archive_config() { + backup_require_env BACKUP_DIR >/dev/null + backup_require_env BACKUP_OSS_URI >/dev/null + backup_require_env BACKUP_PASSPHRASE >/dev/null +} + +mysql_binlog_query() { + local mysql_cid="$1" query="$2" output code + output="$(docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "$1"' \ + sh "${query}" 2>&1)" || { + code=$? + backup_error "MySQL binlog 查询失败(${query}):${output}" + return "${code}" + } + printf '%s\n' "${output}" +} + +master_status() { + local mysql_cid="$1" + mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOG STATUS;' | awk 'NR == 1 { print $1 "\t" $2 }' +} + +state_active_binlog() { + local state_dir="$1" key="$2" value + [[ -f "${state_dir}/active-binlog.env" ]] || return + value="$(awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${state_dir}/active-binlog.env")" + printf '%s' "${value}" +} + +write_active_binlog_state() { + local state_dir="$1" filename="$2" position="$3" + { + printf 'filename=%s\n' "${filename}" + printf 'position=%s\n' "${position}" + printf 'observed_at=%s\n' "$(date -Iseconds)" + } > "${state_dir}/active-binlog.env" +} + +state_last_index() { + local state_dir="$1" + local value + [[ -f "${state_dir}/last-archived.env" ]] || return + value="$(awk -F= '$1 == "binlog_index" { print $2; exit }' "${state_dir}/last-archived.env")" + [[ "${value}" =~ ^[0-9]+$ ]] && printf '%s' "${value}" +} + +write_state() { + local state_dir="$1" filename="$2" index="$3" raw_sha="$4" cipher_sha="$5" remote_dir="$6" + { + printf 'filename=%s\n' "${filename}" + printf 'binlog_index=%s\n' "${index}" + printf 'raw_sha256=%s\n' "${raw_sha}" + printf 'cipher_sha256=%s\n' "${cipher_sha}" + printf 'remote_dir=%s\n' "${remote_dir}" + } > "${state_dir}/${filename}.${raw_sha}.state" + cp "${state_dir}/${filename}.${raw_sha}.state" "${state_dir}/last-archived.env" +} + +archive_one() { + local mysql_cid="$1" state_dir="$2" server_uuid="$3" oss_uri="$4" filename="$5" + local source_path source_sha raw_file raw_sha cipher_file cipher_sha cipher_size binlog_index object_id remote_dir + source_path="${MYSQL_DATA_DIR}/${filename}" + if ! docker exec "${mysql_cid}" test -f "${source_path}" >/dev/null 2>&1; then + backup_error "待归档 binlog 已从 MySQL 数据目录消失:${filename};PITR 链路已断裂" + exit 1 + fi + raw_file="${work_dir}/${filename}" + docker cp "${mysql_cid}:${source_path}" "${raw_file}" + raw_sha="$(backup_sha256 "${raw_file}")" + source_sha="$(docker exec "${mysql_cid}" sha256sum "${source_path}" | awk '{print $1}')" + [[ "${source_sha}" == "${raw_sha}" ]] || { + backup_error "binlog 拷贝校验失败:${filename}" + exit 1 + } + if compgen -G "${state_dir}/${filename}.${raw_sha}.state" >/dev/null; then + return + fi + binlog_index="$(backup_binlog_index "${filename}")" + object_id="${filename}.${raw_sha}" + remote_dir="${oss_uri}/mysql/binlog/${server_uuid}/${object_id}" + cipher_file="${work_dir}/${object_id}.enc" + backup_encrypt_file "${raw_file}" "${cipher_file}" + cipher_sha="$(backup_sha256 "${cipher_file}")" + cipher_size="$(backup_file_size "${cipher_file}")" + printf '%s %s\n' "${cipher_sha}" 'binlog.enc' > "${work_dir}/${object_id}.enc.sha256" + { + printf 'format_version=1\n' + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'source_binlog=%s\n' "${filename}" + printf 'binlog_index=%s\n' "${binlog_index}" + printf 'raw_sha256=%s\n' "${raw_sha}" + printf 'payload=binlog.enc\n' + printf 'payload_sha256=%s\n' "${cipher_sha}" + printf 'payload_size_bytes=%s\n' "${cipher_size}" + printf 'created_at=%s\n' "$(date -Iseconds)" + } > "${work_dir}/${object_id}.complete.env" + + backup_oss_upload "${cipher_file}" "${remote_dir}/binlog.enc" + backup_oss_upload "${work_dir}/${object_id}.enc.sha256" "${remote_dir}/binlog.enc.sha256" + # complete.env 最后上传,恢复程序只使用有该文件的 binlog。 + backup_oss_upload "${work_dir}/${object_id}.complete.env" "${remote_dir}/complete.env" + write_state "${state_dir}" "${filename}" "${binlog_index}" "${raw_sha}" "${cipher_sha}" "${remote_dir}" + rm -f -- "${raw_file}" "${cipher_file}" "${work_dir}/${object_id}.enc.sha256" "${work_dir}/${object_id}.complete.env" +} + +archive() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + require_archive_config + local force="${1:-}" backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos binlog_list closed_binlog_list + if [[ -n "${force}" && "${force}" != '--force' ]]; then + printf '用法: %s archive [--force]\n' "$0" >&2 + exit 1 + fi + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_lock_or_exit "${backup_dir}/.binlog.lock" + mysql_cid="$(backup_mysql_container_id)" + server_uuid="$(backup_server_uuid)" + state_dir="${backup_dir}/binlog-state/${server_uuid}" + work_dir="${backup_dir}/.tmp-binlog-$$" + mkdir -p "${state_dir}" "${work_dir}" + + current_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r current_file current_pos <<< "${current_status}" + [[ -n "${current_file}" && "${current_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取当前 binlog 位点'; exit 1; } + backup_binlog_index "${current_file}" >/dev/null + previous_file="$(state_active_binlog "${state_dir}" filename || true)" + previous_pos="$(state_active_binlog "${state_dir}" position || true)" + + if [[ "${force}" == '--force' || -z "${previous_file}" || -z "${previous_pos}" || "${current_file}" != "${previous_file}" || "${current_pos}" != "${previous_pos}" ]]; then + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + else + backup_log "活动 binlog 无新增已提交事务,跳过轮转" + fi + binlog_list="$(mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOGS;')" + first_binlog="$(awk 'NR == 1 { print $1; exit }' <<< "${binlog_list}")" + [[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; } + last_index="$(state_last_index "${state_dir}" || true)" + first_index="$(backup_binlog_index "${first_binlog}")" + if [[ -n "${last_index}" && "${first_index}" -gt $((last_index + 1)) ]]; then + backup_error "发现未归档 binlog 缺口:已归档至序号 ${last_index},当前最早为 ${first_index}" + exit 1 + fi + + closed_binlog_list="$(awk 'NR > 1 { print previous } { previous = $1 }' <<< "${binlog_list}")" + expected_index="${last_index}" + while IFS= read -r filename; do + [[ -n "${filename}" ]] || continue + index="$(backup_binlog_index "${filename}")" + if [[ -n "${expected_index}" && "${index}" -le "${expected_index}" ]]; then + continue + fi + if [[ -n "${expected_index}" && "${index}" -ne $((expected_index + 1)) ]]; then + backup_error "发现未归档 binlog 缺口:期望序号 $((expected_index + 1)),实际为 ${index}" + exit 1 + fi + archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}" + expected_index="${index}" + done <<< "${closed_binlog_list}" + final_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r final_file final_pos <<< "${final_status}" + [[ -n "${final_file}" && "${final_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取归档后的 binlog 位点'; exit 1; } + write_active_binlog_state "${state_dir}" "${final_file}" "${final_pos}" + backup_log "binlog 归档完成,实例 ${server_uuid}" +} + +verify() { + backup_require_cmd ossutil + require_archive_config + local backup_dir server_uuid state_dir state_file remote_dir + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + backup_lock_or_exit "${backup_dir}/.binlog.lock" + server_uuid="$(backup_server_uuid)" + state_dir="${backup_dir}/binlog-state/${server_uuid}" + [[ -d "${state_dir}" ]] || { backup_error '尚未产生 binlog 归档状态'; exit 1; } + while IFS= read -r -d '' state_file; do + remote_dir="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${state_file}")" + [[ -n "${remote_dir}" ]] || { backup_error "归档状态不完整:${state_file}"; exit 1; } + ossutil stat "${remote_dir}/binlog.enc" >/dev/null + ossutil stat "${remote_dir}/binlog.enc.sha256" >/dev/null + ossutil stat "${remote_dir}/complete.env" >/dev/null + done < <(find "${state_dir}" -maxdepth 1 -name '*.state' -print0) + backup_log '全部本地 binlog 归档状态均有远端完整对象' +} + +case "${1:-archive}" in + archive) archive "${2:-}" ;; + verify) verify ;; + *) + printf '用法: %s {archive|verify}\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/scripts/backup-common.sh b/scripts/backup-common.sh new file mode 100755 index 0000000..0e40eee --- /dev/null +++ b/scripts/backup-common.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +# 在线备份脚本共用函数。仅供 scripts/backup-online.sh、archive-binlog.sh 和 +# restore-online.sh source,不要单独执行。 + +BACKUP_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKUP_COMPOSE_FILE="${BACKUP_ROOT_DIR}/deploy/docker-compose.prod.yml" +BACKUP_ENV_FILE="${BACKUP_ROOT_DIR}/backend/.env" +BACKUP_XTRABACKUP_IMAGE="${XTRABACKUP_IMAGE:-percona/percona-xtrabackup:8.4}" + +backup_log() { + printf '[backup] %s\n' "$*" >&2 +} + +backup_error() { + printf '[backup] ERROR: %s\n' "$*" >&2 +} + +backup_require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + backup_error "缺少命令:$1" + exit 1 + fi +} + +backup_env_value() { + local key="$1" + awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKUP_ENV_FILE}" +} + +backup_require_env() { + local key="$1" + local value + value="$(backup_env_value "${key}")" + if [[ -z "${value}" ]]; then + backup_error "${BACKUP_ENV_FILE} 缺少或未填写 ${key}" + exit 1 + fi + printf '%s' "${value}" +} + +backup_compose() { + docker compose --env-file "${BACKUP_ENV_FILE}" -f "${BACKUP_COMPOSE_FILE}" "$@" +} + +backup_mysql_container_id() { + local cid + cid="$(backup_compose ps -q mysql 2>/dev/null || true)" + if [[ -z "${cid}" ]]; then + backup_error "MySQL 容器未运行" + exit 1 + fi + printf '%s' "${cid}" +} + +backup_mysql_network() { + local network + network="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' "$1")" + if [[ -z "${network}" ]]; then + backup_error "无法读取 MySQL Docker 网络" + exit 1 + fi + printf '%s' "${network}" +} + +backup_mysql_root_query() { + backup_compose exec -T mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B "$MYSQL_DATABASE" -e "$1"' \ + sh "$1" +} + +backup_validate_dir() { + local dir="$1" + if [[ "${dir}" != /data/* || "${dir}" == "/data" ]]; then + backup_error "备份目录必须是 /data 之下的具体绝对路径,当前值:${dir}" + exit 1 + fi +} + +backup_lock_or_exit() { + local path="$1" + local wait_seconds="${2:-${BACKUP_LOCK_WAIT_SECONDS:-0}}" + backup_require_cmd flock + mkdir -p "$(dirname "${path}")" + exec 9>"${path}" + if [[ ! "${wait_seconds}" =~ ^[0-9]+$ ]]; then + backup_error "锁等待时间必须是非负整数:${wait_seconds}" + exit 1 + fi + if ! flock -w "${wait_seconds}" 9; then + backup_error "已有同类备份任务在执行,拒绝并发运行" + exit 1 + fi +} + +backup_server_uuid() { + local value + value="$(backup_mysql_root_query 'SELECT @@server_uuid;' 2>/dev/null || true)" + if [[ ! "${value}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + backup_error "无法读取 MySQL server_uuid" + exit 1 + fi + printf '%s' "${value,,}" +} + +backup_binlog_index() { + local filename="$1" + if [[ "${filename}" =~ ^mysql-bin\.([0-9]{6,})$ ]]; then + printf '%d' "$((10#${BASH_REMATCH[1]}))" + return + fi + backup_error "不支持的 binlog 文件名:${filename}" + exit 1 +} + +backup_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + return + fi + shasum -a 256 "$1" | awk '{print $1}' +} + +backup_file_size() { + if [[ "$(uname)" == 'Darwin' ]]; then + stat -f%z "$1" + else + stat -c%s "$1" + fi +} + +backup_encrypt_file() { + local source="$1" + local destination="$2" + local passphrase + passphrase="$(backup_require_env BACKUP_PASSPHRASE)" + BACKUP_PASSPHRASE="${passphrase}" openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt \ + -pass env:BACKUP_PASSPHRASE \ + -in "${source}" \ + -out "${destination}" + unset passphrase +} + +backup_decrypt_file() { + local source="$1" + local destination="$2" + local passphrase + passphrase="$(backup_require_env BACKUP_PASSPHRASE)" + BACKUP_PASSPHRASE="${passphrase}" openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ + -pass env:BACKUP_PASSPHRASE \ + -in "${source}" \ + -out "${destination}" + unset passphrase +} + +backup_oss_upload() { + local source="$1" + local destination="$2" + local kms_key_id + kms_key_id="$(backup_env_value BACKUP_KMS_KEY_ID)" + if [[ -n "${kms_key_id}" ]]; then + ossutil cp "${source}" "${destination}" \ + --encryption KMS --kms-master-key-id "${kms_key_id}" + return + fi + # 未配置 KMS 时依赖 Bucket 已启用的 OSS 托管服务器端加密;备份载荷仍会先经过客户端加密。 + ossutil cp "${source}" "${destination}" +} + +backup_validate_oss_uri() { + local uri="$1" + if [[ ! "${uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + backup_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${uri}" + exit 1 + fi +} + +backup_oss_download() { + local source="$1" + local destination="$2" + ossutil cp "${source}" "${destination}" +} + +backup_write_complete_marker() { + local path="$1" + local payload_name="$2" + local payload_sha256="$3" + local payload_size="$4" + local server_uuid="$5" + { + printf 'format_version=1\n' + printf 'created_at=%s\n' "$(date -Iseconds)" + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'payload=%s\n' "${payload_name}" + printf 'payload_sha256=%s\n' "${payload_sha256}" + printf 'payload_size_bytes=%s\n' "${payload_size}" + } > "${path}" +} diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh new file mode 100755 index 0000000..4672710 --- /dev/null +++ b/scripts/backup-online.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# MySQL 在线全量物理备份。XtraBackup 在业务读写期间运行,备份只在 +# 上传 payload、manifest、checksum 后最后写入 complete.env,恢复程序只接受 +# 带 complete.env 的备份集。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +cleanup_dir='' +cleanup() { + if [[ -n "${cleanup_dir}" && -d "${cleanup_dir}" ]]; then + # XtraBackup 会保留源文件属主;失败时先在容器内归还临时目录属主, + # 再由运行备份的业务用户删除,避免残留无法清理的大文件。 + docker run --rm --user 0:0 \ + -v "${cleanup_dir}:/cleanup" \ + --entrypoint chown \ + "${BACKUP_XTRABACKUP_IMAGE}" \ + -R "$(id -u):$(id -g)" /cleanup >/dev/null 2>&1 || true + rm -rf -- "${cleanup_dir}" || true + fi +} +trap cleanup EXIT + +require_backup_config() { + backup_require_env BACKUP_DIR >/dev/null + backup_require_env BACKUP_OSS_URI >/dev/null + backup_require_env BACKUP_PASSPHRASE >/dev/null + backup_require_env BACKUP_MYSQL_USER >/dev/null + backup_require_env BACKUP_MYSQL_PASSWORD >/dev/null +} + +upload_backup_set() { + local local_dir="$1" + local level="$2" + local ts="$3" + local server_uuid="$4" + local oss_uri="$5" + local remote_dir="${oss_uri}/mysql/full/${level}/${server_uuid}/${ts}" + + backup_log "上传 ${level} 全量备份到 ${remote_dir}" + backup_oss_upload "${local_dir}/mysql-full.tgz.enc" "${remote_dir}/mysql-full.tgz.enc" + backup_oss_upload "${local_dir}/manifest.env" "${remote_dir}/manifest.env" + backup_oss_upload "${local_dir}/mysql-full.tgz.enc.sha256" "${remote_dir}/mysql-full.tgz.enc.sha256" + # complete.env 必须最后上传,作为远端原子完成标识。 + backup_oss_upload "${local_dir}/complete.env" "${remote_dir}/complete.env" +} + +full_backup() { + backup_require_cmd docker + backup_require_cmd openssl + backup_require_cmd ossutil + require_backup_config + + local backup_dir oss_uri mysql_cid network server_uuid backup_user backup_password ts tmp_dir target_dir + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_lock_or_exit "${backup_dir}/.full.lock" + mysql_cid="$(backup_mysql_container_id)" + network="$(backup_mysql_network "${mysql_cid}")" + server_uuid="$(backup_server_uuid)" + backup_user="$(backup_require_env BACKUP_MYSQL_USER)" + backup_password="$(backup_require_env BACKUP_MYSQL_PASSWORD)" + ts="$(date +%Y%m%d_%H%M%S)" + tmp_dir="${backup_dir}/.tmp-full-${ts}-$$" + target_dir="${backup_dir}/full/daily/${ts}" + cleanup_dir="${tmp_dir}" + mkdir -p "${tmp_dir}/xtrabackup" + + cat > "${tmp_dir}/xtrabackup-client.cnf" </dev/null || true)" + } > "${tmp_dir}/manifest.env" + + # 关闭包含 XtraBackup 位点的当前 binlog 并归档。全量备份只有在该文件 + # 已有远端 complete 标识后才会被标记完成。 + BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive --force + if ! find "${backup_dir}/binlog-state/${server_uuid}" -maxdepth 1 -type f \ + -name "${binlog_file}.*.state" -print -quit | grep -q .; then + backup_error "XtraBackup 位点 ${binlog_file}:${binlog_pos} 尚未完成 binlog 归档" + exit 1 + fi + + tar czf "${tmp_dir}/mysql-full.tgz" \ + -C "${tmp_dir}/xtrabackup/data" . \ + -C "${tmp_dir}" manifest.env + backup_encrypt_file "${tmp_dir}/mysql-full.tgz" "${tmp_dir}/mysql-full.tgz.enc" + rm -f "${tmp_dir}/mysql-full.tgz" + + local payload_sha payload_size + payload_sha="$(backup_sha256 "${tmp_dir}/mysql-full.tgz.enc")" + payload_size="$(backup_file_size "${tmp_dir}/mysql-full.tgz.enc")" + printf '%s %s\n' "${payload_sha}" 'mysql-full.tgz.enc' > "${tmp_dir}/mysql-full.tgz.enc.sha256" + backup_write_complete_marker "${tmp_dir}/complete.env" 'mysql-full.tgz.enc' "${payload_sha}" "${payload_size}" "${server_uuid}" + + # complete.env 会作为远端最后上传的原子完成标识。仅当日备已上传成功后, + # 才将该备份移入本地正式保留目录,避免失败备份被 status 误判为完整。 + upload_backup_set "${tmp_dir}" daily "${ts}" "${server_uuid}" "${oss_uri}" + + mkdir -p "${target_dir}" + mv "${tmp_dir}/mysql-full.tgz.enc" "${target_dir}/" + mv "${tmp_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/" + mv "${tmp_dir}/manifest.env" "${target_dir}/" + mv "${tmp_dir}/complete.env" "${target_dir}/" + rm -rf "${tmp_dir}/xtrabackup" + cleanup_dir='' + rmdir "${tmp_dir}" + + if [[ "$(date +%u)" == '7' ]]; then + mkdir -p "${backup_dir}/full/weekly/${ts}" + ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/weekly/${ts}/mysql-full.tgz.enc" + cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/weekly/${ts}/" + upload_backup_set "${target_dir}" weekly "${ts}" "${server_uuid}" "${oss_uri}" + fi + if [[ "$(date +%d)" == '01' ]]; then + mkdir -p "${backup_dir}/full/monthly/${ts}" + ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/monthly/${ts}/mysql-full.tgz.enc" + cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/monthly/${ts}/" + upload_backup_set "${target_dir}" monthly "${ts}" "${server_uuid}" "${oss_uri}" + fi + backup_log "全量热备完成:${target_dir},位点 ${binlog_file}:${binlog_pos}" +} + +prune_level() { + local base="$1" + local days="$2" + [[ -d "${base}" ]] || return + find "${base}" -mindepth 1 -maxdepth 1 -type d -mtime "+${days}" -print0 | + while IFS= read -r -d '' dir; do + backup_log "清理过期本地备份:${dir}" + rm -rf -- "${dir}" + done +} + +prune_backups() { + local backup_dir daily weekly monthly + backup_dir="$(backup_require_env BACKUP_DIR)" + backup_validate_dir "${backup_dir}" + backup_lock_or_exit "${backup_dir}/.full.lock" + daily="$(backup_env_value BACKUP_KEEP_DAILY)"; daily="${daily:-14}" + weekly="$(backup_env_value BACKUP_KEEP_WEEKLY)"; weekly="${weekly:-8}" + monthly="$(backup_env_value BACKUP_KEEP_MONTHLY)"; monthly="${monthly:-12}" + [[ "${daily}" =~ ^[0-9]+$ && "${weekly}" =~ ^[0-9]+$ && "${monthly}" =~ ^[0-9]+$ ]] || { + backup_error 'BACKUP_KEEP_* 必须是非负整数' + exit 1 + } + prune_level "${backup_dir}/full/daily" "${daily}" + prune_level "${backup_dir}/full/weekly" "$((weekly * 7))" + prune_level "${backup_dir}/full/monthly" "$((monthly * 31))" +} + +status() { + local backup_dir oss_uri + backup_dir="$(backup_require_env BACKUP_DIR)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + backup_log '本地最近完整全量备份:' + find "${backup_dir}/full" -name complete.env -print 2>/dev/null | sort | tail -8 || true + backup_log 'OSS 最近完整全量备份:' + ossutil ls "${oss_uri}/mysql/full/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true + backup_log 'OSS 最近 binlog 完成标识:' + ossutil ls "${oss_uri}/mysql/binlog/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true +} + +case "${1:-}" in + full) full_backup ;; + prune) prune_backups ;; + status) status ;; + *) + printf '用法: %s {full|prune|status}\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/scripts/backup-prod.sh b/scripts/backup-prod.sh deleted file mode 100755 index e58f858..0000000 --- a/scripts/backup-prod.sh +++ /dev/null @@ -1,533 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml" -BACKEND_ENV="${ROOT_DIR}/backend/.env" -BACKUP_ROOT="${BACKUP_ROOT:-${ROOT_DIR}/backups}" -HELPER_IMAGE="${BACKUP_HELPER_IMAGE:-redis:7.4-alpine}" -if [[ "${BACKUP_ROOT}" != /* ]]; then - BACKUP_ROOT="${ROOT_DIR}/${BACKUP_ROOT}" -fi - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[1;34m' -NC='\033[0m' - -log() { - printf "${BLUE}[backup]${NC} %s\n" "$*" >&2 -} - -log_success() { - printf "${GREEN}[backup]${NC} %s\n" "$*" >&2 -} - -log_warn() { - printf "${YELLOW}[backup]${NC} %s\n" "$*" >&2 -} - -log_error() { - printf "${RED}[backup]${NC} %s\n" "$*" >&2 -} - -need_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - log_error "缺少命令:$1" - exit 1 - fi -} - -absolute_dir() { - local dir="$1" - ( - cd "${dir}" - pwd -P - ) -} - -show_help() { - cat << EOF -用法: ./scripts/backup-prod.sh <命令> [选项] - -命令: - backup 创建完整生产备份 - restore <备份目录> 从备份目录恢复生产数据(需要 --yes) - list 列出本机备份目录 - -选项: - --yes 确认执行破坏性恢复,只用于 restore - --no-stop backup 时不主动停止 backend/caddy,适合只读低风险窗口(不推荐) - -h, --help 显示帮助 - -环境变量: - BACKUP_ROOT=/path 备份根目录,默认 ./backups - BACKUP_HELPER_IMAGE=img 用于打包/还原 Docker volume 的镜像,默认 redis:7.4-alpine - -备份内容: - - MySQL 逻辑备份 mysql.sql - - MinIO volume: minio_data.tgz - - Redis volume: redis_data.tgz - - Caddy 证书/配置 volume: caddy_data.tgz, caddy_config.tgz - - backend/.env、deploy 配置、backend/logs - - 当前 Git commit、Docker Compose 状态、goose 迁移状态 - -示例: - ./scripts/backup-prod.sh backup - ./scripts/backup-prod.sh list - ./scripts/backup-prod.sh restore backups/20260618_153000 --yes -EOF -} - -compose() { - local app_env caddy_domain caddy_email admin_base_path - app_env="$(env_value APP_ENV)" - caddy_domain="${CADDY_DOMAIN:-$(env_value CADDY_DOMAIN)}" - caddy_email="${CADDY_EMAIL:-$(env_value CADDY_EMAIL)}" - admin_base_path="${VITE_ADMIN_BASE_PATH:-$(env_value VITE_ADMIN_BASE_PATH)}" - - if [[ "${app_env}" == "production" && (-z "${caddy_domain}" || -z "${caddy_email}") ]]; then - log_error "APP_ENV=production 时必须配置 CADDY_DOMAIN 和 CADDY_EMAIL" - exit 1 - fi - - CADDY_DOMAIN="${caddy_domain:-backup.localhost}" \ - CADDY_EMAIL="${caddy_email:-backup@example.com}" \ - VITE_ADMIN_BASE_PATH="${admin_base_path:-/manage-7x9k2p}" \ - docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@" -} - -require_env_file() { - if [[ ! -f "${BACKEND_ENV}" ]]; then - log_error "未找到 backend/.env,无法读取生产环境变量" - exit 1 - fi -} - -env_value() { - local key="$1" - awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}" -} - -require_env() { - local key="$1" - local value - value="$(env_value "${key}")" - if [[ -z "${value}" ]]; then - log_error "backend/.env 缺少或未填写 ${key}" - exit 1 - fi - printf "%s" "${value}" -} - -container_id() { - local service="$1" - local cid - cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" - if [[ -z "${cid}" ]]; then - log "创建 ${service} 容器以挂载 volume..." - compose create "${service}" >/dev/null - cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" - fi - if [[ -z "${cid}" ]]; then - log_error "无法找到 ${service} 容器" - exit 1 - fi - printf "%s" "${cid}" -} - -service_was_running() { - local service="$1" - local cid status - cid="$(compose ps -q "${service}" 2>/dev/null || true)" - if [[ -z "${cid}" ]]; then - return 1 - fi - status="$(docker inspect -f '{{.State.Status}}' "${cid}" 2>/dev/null || true)" - [[ "${status}" == "running" ]] -} - -start_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - log "启动服务:$*" - compose up -d "$@" -} - -stop_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - log "停止服务:$*" - compose stop "$@" -} - -start_recorded_services() { - if [[ "$#" -eq 0 ]]; then - return - fi - start_services "$@" -} - -wait_service_ready() { - local service="$1" - local label="$2" - local timeout="${3:-120}" - local attempt cid status - - log "等待 ${label} 就绪..." - for ((attempt = 1; attempt <= timeout; attempt++)); do - cid="$(compose ps -q "${service}" 2>/dev/null || true)" - if [[ -n "${cid}" ]]; then - status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${cid}" 2>/dev/null || true)" - if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then - log_success "${label} 已就绪" - return - fi - fi - sleep 1 - done - - log_error "${label} 启动超时" - compose logs --tail=80 "${service}" || true - exit 1 -} - -remember_running() { - local service="$1" - local result_array_name="$2" - if service_was_running "${service}"; then - eval "${result_array_name}+=(\"${service}\")" - fi -} - -tar_from_service_volume() { - local service="$1" - local source_dir="$2" - local backup_dir="$3" - local output_name="$4" - local cid - cid="$(container_id "${service}")" - log "备份 ${service}:${source_dir} -> ${output_name}" - docker run --rm \ - --volumes-from "${cid}:ro" \ - -v "${backup_dir}:/backup" \ - "${HELPER_IMAGE}" \ - sh -c "cd '${source_dir}' && tar czf '/backup/${output_name}' ." -} - -restore_service_volume() { - local service="$1" - local target_dir="$2" - local backup_dir="$3" - local input_name="$4" - local cid - if [[ ! -f "${backup_dir}/${input_name}" ]]; then - log_warn "跳过 ${service}:${target_dir},备份文件不存在:${input_name}" - return - fi - cid="$(container_id "${service}")" - log "还原 ${input_name} -> ${service}:${target_dir}" - docker run --rm \ - --volumes-from "${cid}" \ - -v "${backup_dir}:/backup:ro" \ - "${HELPER_IMAGE}" \ - sh -c "rm -rf '${target_dir}'/* '${target_dir}'/.[!.]* '${target_dir}'/..?* 2>/dev/null || true; tar xzf '/backup/${input_name}' -C '${target_dir}'" -} - -copy_if_exists() { - local source="$1" - local target="$2" - if [[ -e "${source}" ]]; then - cp -a "${source}" "${target}" - fi -} - -write_manifest() { - local backup_dir="$1" - { - printf "created_at=%s\n" "$(date -Iseconds)" - printf "host=%s\n" "$(hostname)" - printf "git_commit=%s\n" "$(git -C "${ROOT_DIR}" rev-parse HEAD 2>/dev/null || true)" - printf "git_branch=%s\n" "$(git -C "${ROOT_DIR}" branch --show-current 2>/dev/null || true)" - printf "mysql_database=%s\n" "$(require_env MYSQL_DATABASE)" - printf "storage_bucket=%s\n" "$(require_env STORAGE_BUCKET)" - } > "${backup_dir}/manifest.env" - - git -C "${ROOT_DIR}" log --oneline -5 > "${backup_dir}/git-log.txt" 2>/dev/null || true - compose ps > "${backup_dir}/docker-compose-ps.txt" 2>/dev/null || true - compose config > "${backup_dir}/docker-compose.config.rendered.yml" 2>/dev/null || true -} - -backup_mysql() { - local backup_dir="$1" - log "备份 MySQL -> mysql.sql" - compose exec -T mysql sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -uroot --single-transaction --routines --triggers --events --hex-blob --default-character-set=utf8mb4 "$MYSQL_DATABASE"' \ - > "${backup_dir}/mysql.sql" - compose exec -T mysql sh -c 'mysql --version' > "${backup_dir}/mysql-version.txt" 2>/dev/null || true -} - -backup_goose_status() { - local backup_dir="$1" - log "记录 goose 迁移状态" - compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" status \ - > "${backup_dir}/goose-status.txt" 2>&1 || true -} - -goose_dsn() { - local dsn="$1" - if [[ "${dsn}" != *"multiStatements="* ]]; then - if [[ "${dsn}" == *"?"* ]]; then - dsn="${dsn}&multiStatements=true" - else - dsn="${dsn}?multiStatements=true" - fi - fi - printf "%s" "${dsn}" -} - -backup_files() { - local backup_dir="$1" - log "备份配置文件" - mkdir -p "${backup_dir}/config" - copy_if_exists "${BACKEND_ENV}" "${backup_dir}/config/backend.env" - copy_if_exists "${COMPOSE_FILE}" "${backup_dir}/config/docker-compose.prod.yml" - copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${backup_dir}/config/Caddyfile" - copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${backup_dir}/config/mysql.my.cnf" - - if [[ -d "${ROOT_DIR}/backend/logs" ]]; then - log "备份后端日志 -> backend_logs.tgz" - tar czf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" logs - fi -} - -restore_files() { - local backup_dir="$1" - local safety_dir="${BACKUP_ROOT}/pre-restore-files-$(date +%Y%m%d_%H%M%S)" - mkdir -p "${safety_dir}" - - log "恢复配置文件(当前文件会先备份到 ${safety_dir})" - copy_if_exists "${BACKEND_ENV}" "${safety_dir}/backend.env" - copy_if_exists "${COMPOSE_FILE}" "${safety_dir}/docker-compose.prod.yml" - copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${safety_dir}/Caddyfile" - copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${safety_dir}/mysql.my.cnf" - - copy_if_exists "${backup_dir}/config/backend.env" "${BACKEND_ENV}" - copy_if_exists "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" - copy_if_exists "${backup_dir}/config/Caddyfile" "${ROOT_DIR}/deploy/caddy/Caddyfile" - copy_if_exists "${backup_dir}/config/mysql.my.cnf" "${ROOT_DIR}/deploy/mysql/my.cnf" - - if [[ -f "${backup_dir}/backend_logs.tgz" ]]; then - log "恢复后端日志" - mkdir -p "${ROOT_DIR}/backend" - rm -rf "${ROOT_DIR}/backend/logs" - tar xzf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" - fi -} - -prepare_restore_env() { - local backup_dir="$1" - if [[ ! -f "${BACKEND_ENV}" ]]; then - if [[ ! -f "${backup_dir}/config/backend.env" ]]; then - log_error "当前缺少 backend/.env,备份中也没有 config/backend.env,无法恢复" - exit 1 - fi - log_warn "当前缺少 backend/.env,先从备份恢复环境变量文件" - mkdir -p "$(dirname "${BACKEND_ENV}")" - cp -a "${backup_dir}/config/backend.env" "${BACKEND_ENV}" - fi - if [[ ! -f "${COMPOSE_FILE}" && -f "${backup_dir}/config/docker-compose.prod.yml" ]]; then - log_warn "当前缺少 deploy/docker-compose.prod.yml,先从备份恢复 Compose 文件" - mkdir -p "$(dirname "${COMPOSE_FILE}")" - cp -a "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" - fi - require_env_file -} - -restore_mysql() { - local backup_dir="$1" - local database - database="$(require_env MYSQL_DATABASE)" - if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then - log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}" - exit 1 - fi - if [[ ! -f "${backup_dir}/mysql.sql" ]]; then - log_error "缺少 MySQL 备份文件:${backup_dir}/mysql.sql" - exit 1 - fi - - start_services mysql - wait_service_ready mysql "MySQL" - log "重建数据库 ${database}" - compose exec -T mysql sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -e "DROP DATABASE IF EXISTS \`$MYSQL_DATABASE\`; CREATE DATABASE \`$MYSQL_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"' - log "导入 MySQL 备份" - compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$MYSQL_DATABASE"' < "${backup_dir}/mysql.sql" -} - -backup_all() { - require_env_file - need_cmd docker - local no_stop="$1" - local backup_dir="${BACKUP_ROOT}/$(date +%Y%m%d_%H%M%S)" - local restart_services=() - local backup_ok=0 - - backup_failure_cleanup() { - local exit_code=$? - trap - ERR - if [[ "${no_stop}" == "0" && "${backup_ok}" != "1" ]]; then - log_warn "备份失败,尝试恢复备份前运行的服务" - start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" || true - fi - exit "${exit_code}" - } - trap backup_failure_cleanup ERR - - umask 077 - mkdir -p "${backup_dir}" - chmod 700 "${backup_dir}" - log "备份目录:${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - remember_running caddy restart_services - remember_running backend restart_services - stop_services caddy backend - else - log_warn "已开启 --no-stop,备份期间仍可能有写入,建议只在低风险窗口使用" - fi - - start_services mysql - wait_service_ready mysql "MySQL" - backup_mysql "${backup_dir}" - backup_goose_status "${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - remember_running minio restart_services - remember_running redis restart_services - stop_services minio redis caddy - fi - - tar_from_service_volume minio /data "${backup_dir}" minio_data.tgz - tar_from_service_volume redis /data "${backup_dir}" redis_data.tgz - tar_from_service_volume caddy /data "${backup_dir}" caddy_data.tgz - tar_from_service_volume caddy /config "${backup_dir}" caddy_config.tgz - backup_files "${backup_dir}" - write_manifest "${backup_dir}" - - if [[ "${no_stop}" == "0" ]]; then - start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" - fi - - backup_ok=1 - trap - ERR - log_success "备份完成:${backup_dir}" - log_warn "备份包含生产密钥,请离线加密保存,不要提交到 Git。" -} - -restore_all() { - need_cmd docker - local backup_dir="$1" - local yes="$2" - local restore_ok=0 - - if [[ -z "${backup_dir}" || ! -d "${backup_dir}" ]]; then - log_error "备份目录不存在:${backup_dir}" - exit 1 - fi - if [[ "${yes}" != "1" ]]; then - log_error "restore 会覆盖当前生产数据库、MinIO、Redis、Caddy volume 和配置文件。确认执行请追加 --yes" - exit 1 - fi - backup_dir="$(absolute_dir "${backup_dir}")" - - restore_failure_cleanup() { - local exit_code=$? - trap - ERR - if [[ "${restore_ok}" != "1" ]]; then - log_warn "恢复失败,尝试启动当前配置下的服务,便于继续排查" - compose up -d || true - fi - exit "${exit_code}" - } - trap restore_failure_cleanup ERR - - prepare_restore_env "${backup_dir}" - log_warn "即将从 ${backup_dir} 恢复,当前生产数据会被覆盖。" - stop_services backend caddy minio redis - restore_files "${backup_dir}" - restore_mysql "${backup_dir}" - - restore_service_volume minio /data "${backup_dir}" minio_data.tgz - restore_service_volume redis /data "${backup_dir}" redis_data.tgz - restore_service_volume caddy /data "${backup_dir}" caddy_data.tgz - restore_service_volume caddy /config "${backup_dir}" caddy_config.tgz - - log "启动生产服务" - compose up -d - restore_ok=1 - trap - ERR - log_success "恢复完成。建议立刻执行健康检查并抽查图片/二维码/订单数据。" -} - -list_backups() { - if [[ ! -d "${BACKUP_ROOT}" ]]; then - log_warn "备份目录不存在:${BACKUP_ROOT}" - return - fi - find "${BACKUP_ROOT}" -mindepth 2 -maxdepth 2 -type f -name manifest.env -exec dirname {} \; | sort -} - -main() { - local command="${1:-}" - local no_stop=0 - local yes=0 - local restore_dir="" - - if [[ -z "${command}" || "${command}" == "-h" || "${command}" == "--help" ]]; then - show_help - exit 0 - fi - shift || true - - case "${command}" in - backup) - while [[ $# -gt 0 ]]; do - case "$1" in - --no-stop) no_stop=1; shift ;; - -h|--help) show_help; exit 0 ;; - *) log_error "未知选项:$1"; show_help; exit 1 ;; - esac - done - backup_all "${no_stop}" - ;; - restore) - restore_dir="${1:-}" - if [[ $# -gt 0 ]]; then - shift - fi - while [[ $# -gt 0 ]]; do - case "$1" in - --yes) yes=1; shift ;; - -h|--help) show_help; exit 0 ;; - *) log_error "未知选项:$1"; show_help; exit 1 ;; - esac - done - restore_all "${restore_dir}" "${yes}" - ;; - list) - list_backups - ;; - *) - log_error "未知命令:${command}" - show_help - exit 1 - ;; - esac -} - -main "$@" diff --git a/scripts/backup-scheduler.sh b/scripts/backup-scheduler.sh new file mode 100755 index 0000000..a803761 --- /dev/null +++ b/scripts/backup-scheduler.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 由宿主机 cron 每分钟调用。调度配置来自后台 system_configs,实际执行仍在宿主机, +# Web 后端不接触 Docker socket、备份载荷或客户端加密密码。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_job_state() { + local status_dir="$1" type="$2" status="$3" message="$4" + mkdir -p "${status_dir}" + { + printf 'job_type=%q\n' "${type}" + printf 'job_status=%q\n' "${status}" + printf 'job_at=%q\n' "$(date -Iseconds)" + printf 'job_message=%q\n' "${message}" + } > "${status_dir}/last-job.env" + chmod 600 "${status_dir}/last-job.env" +} + +run_job() { + local status_dir="$1" type="$2" script="$3" + shift 3 + if "${script}" "$@"; then + write_job_state "${status_dir}" "${type}" success '执行成功' + "${SCRIPT_DIR}/backup-status.sh" write || true + return + else + # 必须在失败分支中立即读取退出码;if 语句本身在无 else 时会返回 0。 + local code=$? + write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})" + "${SCRIPT_DIR}/backup-status.sh" write || true + return "${code}" + fi +} + +main() { + backup_require_cmd docker + backup_require_cmd flock + local backup_dir status_dir enabled hour minute interval now_hour now_minute + backup_dir="$(backup_require_env BACKUP_DIR)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + backup_lock_or_exit "${backup_dir}/.scheduler.lock" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + [[ "${enabled}" == true || "${enabled}" == false ]] || { backup_error 'backup.schedule_enabled 必须为 true 或 false'; exit 1; } + [[ "${hour}" =~ ^[0-9]+$ && "${hour}" -le 23 && "${minute}" =~ ^[0-9]+$ && "${minute}" -le 59 ]] || { + backup_error '备份全量时间配置无效' + exit 1 + } + [[ "${interval}" =~ ^[0-9]+$ && "${interval}" -ge 1 && "${interval}" -le 5 ]] || { + backup_error 'binlog 归档间隔必须为 1-5 分钟' + exit 1 + } + if [[ "${enabled}" == false ]]; then + "${SCRIPT_DIR}/backup-status.sh" write || true + exit 0 + fi + + now_hour="$((10#$(date +%H)))" + now_minute="$((10#$(date +%M)))" + if ((now_minute % interval == 0)); then + run_job "${status_dir}" binlog "${SCRIPT_DIR}/archive-binlog.sh" archive + fi + if ((now_hour == hour && now_minute == minute)); then + run_job "${status_dir}" full "${SCRIPT_DIR}/backup-online.sh" full + fi + "${SCRIPT_DIR}/backup-status.sh" write || true +} + +main "$@" diff --git a/scripts/backup-status.sh b/scripts/backup-status.sh new file mode 100755 index 0000000..8b6043a --- /dev/null +++ b/scripts/backup-status.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 生成供后台只读展示的备份状态摘要。内容不包含密钥、备份载荷或下载地址。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/ }" + value="${value//$'\r'/ }" + printf '%s' "${value}" +} + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_status() { + backup_require_cmd docker + backup_require_cmd ossutil + + local backup_dir oss_uri status_dir status_file temp_file enabled hour minute interval + local local_complete remote_complete binlog_state binlog_file binlog_remote job_type job_status job_at job_message health + backup_dir="$(backup_require_env BACKUP_DIR)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + mkdir -p "${status_dir}" + chmod 700 "${status_dir}" + status_file="${status_dir}/status.json" + temp_file="${status_dir}/.status-$$.json" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + local_complete="$(find "${backup_dir}/full/daily" -name complete.env -type f -print 2>/dev/null | sort | tail -1 || true)" + remote_complete="$(ossutil ls "${oss_uri}/mysql/full/daily/" -r 2>/dev/null | awk '$NF ~ /\/complete\.env$/ { latest = $NF } END { print latest }')" + binlog_state="$(find "${backup_dir}/binlog-state" -name last-archived.env -type f -print 2>/dev/null | sort | tail -1 || true)" + binlog_file='' + binlog_remote='' + if [[ -n "${binlog_state}" ]]; then + binlog_file="$(awk -F= '$1 == "filename" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + binlog_remote="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + fi + + job_type='' + job_status='' + job_at='' + job_message='' + if [[ -f "${status_dir}/last-job.env" ]]; then + # shellcheck disable=SC1090 + source "${status_dir}/last-job.env" + fi + if [[ "${job_status}" == 'failed' ]]; then + health='warning' + elif [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then + health='healthy' + else + health='warning' + fi + + cat > "${temp_file}" <&2 + exit 1 + ;; +esac diff --git a/scripts/check-backend-coverage.sh b/scripts/check-backend-coverage.sh new file mode 100755 index 0000000..fb65cde --- /dev/null +++ b/scripts/check-backend-coverage.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="${ROOT_DIR}/backend" +PROFILE="$(mktemp -t hfb-backend-cover.XXXXXX)" +OUTPUT="$(mktemp -t hfb-backend-cover-output.XXXXXX)" +trap 'rm -f "${PROFILE}" "${OUTPUT}"' EXIT + +cd "${BACKEND_DIR}" +go test -p 1 -count=1 -coverprofile="${PROFILE}" ./... | tee "${OUTPUT}" + +coverage_for_package() { + local package="$1" + awk -v package="${package}" ' + $1 == "ok" && $2 == package { + for (i = 1; i <= NF; i++) { + if ($i == "coverage:") { + value = $(i + 1) + gsub(/%/, "", value) + print value + exit + } + } + } + ' "${OUTPUT}" +} + +require_at_least() { + local label="$1" + local actual="$2" + local minimum="$3" + if [[ -z "${actual}" ]]; then + printf '[coverage] 无法读取 %s 覆盖率\n' "${label}" >&2 + return 1 + fi + if ! awk -v actual="${actual}" -v minimum="${minimum}" 'BEGIN { exit !(actual + 0 >= minimum + 0) }'; then + printf '[coverage] %s 覆盖率 %s%%,低于门禁 %s%%\n' "${label}" "${actual}" "${minimum}" >&2 + return 1 + fi + printf '[coverage] %s %s%%(门禁 %s%%)\n' "${label}" "${actual}" "${minimum}" +} + +total="$(go tool cover -func="${PROFILE}" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" +require_at_least "后端总语句" "${total}" "30.0" +require_at_least "order" "$(coverage_for_package hfb_sys/backend/internal/modules/order)" "40.0" +require_at_least "payment" "$(coverage_for_package hfb_sys/backend/internal/modules/payment)" "20.0" +require_at_least "wallet" "$(coverage_for_package hfb_sys/backend/internal/modules/wallet)" "34.0" +require_at_least "withdrawal" "$(coverage_for_package hfb_sys/backend/internal/modules/withdrawal)" "30.0" diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh new file mode 100755 index 0000000..629019e --- /dev/null +++ b/scripts/check-sql-guard.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# 检查关键 SQL 防线是否仍存在;真实数据量下的执行计划需要在 MySQL 上单独验证。 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" +failures=() + +require_pattern() { + local file="$1" pattern="$2" name="$3" + if [[ ! -f "${ROOT_DIR}/${file}" ]]; then + failures+=("${name}: 文件不存在 ${file}") + elif ! grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then + failures+=("${name}: 缺少 ${pattern} (${file})") + fi +} + +forbid_pattern() { + local file="$1" pattern="$2" name="$3" + if [[ -f "${ROOT_DIR}/${file}" ]] && grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then + failures+=("${name}: 存在被禁止的跨表全扫描反模式 ${pattern} (${file})") + fi +} + +# 数据库运行时配置和参数脱敏不能被重构删除。 +require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置" +require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置" +require_pattern "backend/internal/database/gorm_logger.go" "ParamsFilter" "SQL 参数脱敏" +require_pattern "backend/internal/database/mysql.go" "SetMaxOpenConns" "连接池应用" + +# 当前财务仪表盘的时间范围聚合和索引是高频查询防线。 +require_pattern "backend/internal/modules/adminfinance/subquery.go" "settled_at >= ? AND scoped_order.settled_at <= ?" "结算聚合时间范围" +require_pattern "backend/internal/modules/adminfinance/dashboard.go" "po.created_at >= ? AND po.created_at <= ?" "支付聚合时间范围" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_completed_at" "财务完成索引" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_cancelled_at" "财务取消索引" + +# 分页核心路径必须保留上限;配置型全量列表在运维文档中登记为白名单。 +require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限" +require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限" + +# 智能意图路由与单表唯一索引直达防线(防止多表跨表 OR/LIKE 导致 MySQL 放弃索引而退化为全表扫描) +require_pattern "backend/internal/modules/order/queries.go" "o.order_no = ?" "订单列表订单号唯一索引直达" +require_pattern "backend/internal/modules/order/queries.go" "l.listing_no = ?" "订单列表商品号唯一索引直达" +require_pattern "backend/internal/modules/order/queries.go" "owner.phone = ? OR renter.phone = ?" "订单列表手机号索引直达" +require_pattern "backend/internal/modules/order/queries.go" "page == 1 && len(rows) < pageSize" "订单列表首页面单扫描短路" + +require_pattern "backend/internal/modules/pickup/repository.go" "p.pickup_no = ?" "提号列表提号单号唯一索引直达" +require_pattern "backend/internal/modules/pickup/repository.go" "owner.phone = ?" "提号列表号主手机号索引直达" +require_pattern "backend/internal/modules/pickup/repository.go" "page == 1 && len(rows) < pageSize" "提号列表首页面单扫描短路" + +require_pattern "backend/internal/modules/dispute/query.go" "o.order_no = ?" "纠纷列表订单号唯一索引直达" +require_pattern "backend/internal/modules/dispute/query.go" "page == 1 && len(rows) < pageSize" "纠纷列表首页面单扫描短路" + +require_pattern "backend/internal/modules/adminuser/repository.go" "u.phone = ?" "用户管理手机号唯一索引直达" +require_pattern "backend/internal/modules/adminuser/repository.go" "page == 1 && len(rows) < pageSize" "用户管理首页面单扫描短路" + +require_pattern "backend/internal/modules/listing/query.go" "l.listing_no = ?" "商品列表商品号唯一索引直达" +require_pattern "backend/internal/modules/listing/query.go" "page == 1 && len(rows) < pageSize" "商品列表首页面单扫描短路" + +require_pattern "backend/internal/modules/chat/support.go" "l.listing_no = ?" "客服会话商品号唯一索引直达" +require_pattern "backend/internal/modules/chat/support.go" "renter.phone = ? OR owner.phone = ?" "客服会话手机号唯一索引直达" + +# 禁止核心列表重新引入跨表 6~10 路全扫描反模式 +forbid_pattern "backend/internal/modules/dispute/query.go" "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ?" "纠纷列表禁止6表无意图全表扫描" + +# 已发布迁移只允许新增,不允许修改或删除历史文件。 +if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then + while read -r status file _; do + [[ -z "${status:-}" ]] && continue + if [[ "${status}" != "A" ]]; then + failures+=("历史迁移文件不可修改或删除: ${status} ${file}") + fi + done <<< "${changed_migrations}" +else + failures+=("无法检查迁移文件变更") +fi + +# 初始合并迁移没有 Down,其余迁移必须提供可回滚段。 +while IFS= read -r file; do + [[ "$(basename "${file}")" == "000001_init.sql" ]] && continue + if ! grep -Fq -- '-- +goose Up' "${file}" || ! grep -Fq -- '-- +goose Down' "${file}"; then + failures+=("迁移缺少 Goose Up/Down: ${file#"${ROOT_DIR}/"}") + fi +done < <(find "${MIGRATIONS_DIR}" -maxdepth 1 -type f -name '*.sql' | sort) + +if (( ${#failures[@]} > 0 )); then + printf '[sql-guard] SQL 约束检查失败\n' >&2 + printf '[sql-guard] %s\n' "${failures[@]}" >&2 + exit 1 +fi + +printf '[sql-guard] SQL 约束检查通过\n' diff --git a/scripts/check-vue-size.sh b/scripts/check-vue-size.sh new file mode 100755 index 0000000..275c0ed --- /dev/null +++ b/scripts/check-vue-size.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_LINES="${VUE_MAX_LINES:-1800}" +failed=0 +while IFS= read -r -d '' file; do + lines="$(wc -l < "${file}")" + if (( lines > MAX_LINES )); then + printf '[vue-size] %s %s 行,超过上限 %s\n' "${file#${ROOT_DIR}/}" "${lines}" "${MAX_LINES}" >&2 + failed=1 + fi +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0) + +if (( failed != 0 )); then + exit 1 +fi +printf '[vue-size] 所有 Vue 页面均不超过 %s 行\n' "${MAX_LINES}" diff --git a/scripts/check.sh b/scripts/check.sh index e3a70a5..a2d7876 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -6,6 +6,7 @@ # - 手动校验:开发时手动跑 ./scripts/check.sh # # 包含: +# 数据库:SQL 约束与迁移完整性检查 # 后端:go vet + go build + go test # 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build # @@ -91,11 +92,12 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then log "后端检查目录: ${BACKEND_DIR}" cd "${BACKEND_DIR}" + run_step "SQL 约束检查" bash "${ROOT_DIR}/scripts/check-sql-guard.sh" || FAILED=1 run_step "go vet ./..." go vet ./... || FAILED=1 run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then # -p 1 串行避免 SQLite 内存库并发; 真实 MySQL e2e 默认 skip - run_step "go test ./..." go test -p 1 -count=1 ./... || FAILED=1 + run_step "go test + coverage" bash "${ROOT_DIR}/scripts/check-backend-coverage.sh" || FAILED=1 else log_warn "跳过后端测试 (--skip-tests)" fi @@ -110,6 +112,8 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then log "前端检查目录: ${FRONTEND_DIR}" cd "${FRONTEND_DIR}" + run_step "Vue 页面大小门禁" bash "${ROOT_DIR}/scripts/check-vue-size.sh" || FAILED=1 + if [[ ! -d node_modules ]]; then run_step "npm ci (首次安装依赖)" npm ci || FAILED=1 fi @@ -117,7 +121,7 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then # npm run check = prettier --check + eslint --max-warnings=0 + vue-tsc run_step "npm run check (prettier + eslint + typecheck)" npm run check || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then - run_step "npm run test (vitest)" npm run test || FAILED=1 + run_step "npm run test + coverage (vitest)" npm run test:coverage || FAILED=1 else log_warn "跳过前端测试 (--skip-tests)" fi diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 8fee5fa..5f4d113 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -69,7 +69,7 @@ show_help() { 环境变量: PROD_HEALTH_URL=https://你的域名/api/health - 部署后健康检查地址,默认使用 CADDY_DOMAIN + 部署后公网健康检查地址;设置后使用外部 curl 检查 PROD_PUBLIC_URL=https://你的域名 输出给用户看的访问地址 PROD_READY_TIMEOUT=120 @@ -82,6 +82,10 @@ show_help() { 选号网源码目录(相对 hfb_sys 根目录或绝对路径) 构建优先用本机 npm;没有 npm 时自动用 Docker node 镜像,服务器不必装 Node +说明: + npm 仅用于构建前端静态资源,运行已构建的 Docker 镜像不需要 npm。 + 已完成一次完整部署后,日常启动可使用 --no-build。 + 示例: ./scripts/deploy-prod.sh ./scripts/deploy-prod.sh --no-build @@ -96,6 +100,7 @@ RESET_DB=0 FOLLOW_LOGS=0 SKIP_CHECK=0 NO_CLEAN=0 +SHOW_DIST_BACKUP_DIR="" while [[ $# -gt 0 ]]; do case "$1" in @@ -146,23 +151,28 @@ validate_env() { exit 1 fi - local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid - local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_status_dir backup_restore_dir backup_oss_uri backup_user app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" caddy_email="$(require_env CADDY_EMAIL)" mysql_dsn="$(require_env MYSQL_DSN)" redis_addr="$(require_env REDIS_ADDR)" storage_endpoint="$(require_env STORAGE_ENDPOINT)" - require_env MYSQL_DATABASE >/dev/null + local mysql_database + mysql_database="$(require_env MYSQL_DATABASE)" require_env MYSQL_ROOT_PASSWORD >/dev/null require_env MYSQL_USER >/dev/null require_env MYSQL_PASSWORD >/dev/null require_env JWT_SECRET >/dev/null - minio_root_user="$(require_env MINIO_ROOT_USER)" - minio_root_password="$(require_env MINIO_ROOT_PASSWORD)" - storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)" - storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)" + require_env STORAGE_ACCESS_KEY_ID >/dev/null + require_env STORAGE_SECRET_ACCESS_KEY >/dev/null + backup_dir="$(require_env BACKUP_DIR)" + backup_status_dir="$(require_env BACKUP_STATUS_DIR)" + backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)" + backup_oss_uri="$(require_env BACKUP_OSS_URI)" + require_env BACKUP_PASSPHRASE >/dev/null + backup_user="$(require_env BACKUP_MYSQL_USER)" + require_env BACKUP_MYSQL_PASSWORD >/dev/null backend_uid="$(require_env BACKEND_UID)" backend_gid="$(require_env BACKEND_GID)" @@ -197,14 +207,40 @@ validate_env() { exit 1 fi if [[ "${storage_endpoint}" == *"127.0.0.1"* || "${storage_endpoint}" == *"localhost"* ]]; then - log_error "STORAGE_ENDPOINT 仍指向本机,请改为 http://minio:9000" + log_error "STORAGE_ENDPOINT 仍指向本机,请改为阿里云 OSS Endpoint(如 https://oss-cn-hangzhou.aliyuncs.com)" exit 1 fi - if [[ "${storage_endpoint}" == "http://minio:9000" || "${storage_endpoint}" == "minio:9000" ]]; then - if [[ "${storage_access_key_id}" != "${minio_root_user}" || "${storage_secret_access_key}" != "${minio_root_password}" ]]; then - log_error "使用内置 MinIO 时,STORAGE_ACCESS_KEY_ID/SECRET_ACCESS_KEY 必须和 MINIO_ROOT_USER/ROOT_PASSWORD 保持一致" - exit 1 - fi + if [[ "${storage_endpoint}" == *"minio"* ]]; then + log_error "STORAGE_ENDPOINT 仍指向已下线的 MinIO,请改为阿里云 OSS Endpoint" + exit 1 + fi + if [[ ! "${storage_endpoint}" =~ ^https://oss-[A-Za-z0-9-]+\.aliyuncs\.com/?$ ]]; then + log_error "STORAGE_ENDPOINT 必须是阿里云 OSS HTTPS Endpoint,当前值:${storage_endpoint}" + exit 1 + fi + if [[ "${backup_dir}" != /data/* || "${backup_dir}" == "/data" ]]; then + log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}" + exit 1 + fi + if [[ "${backup_status_dir}" != "${backup_dir}/"* ]]; then + log_error "BACKUP_STATUS_DIR 必须位于 BACKUP_DIR 之下,当前值:${backup_status_dir}" + exit 1 + fi + if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then + log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}" + exit 1 + fi + if [[ ! "${backup_oss_uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + log_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${backup_oss_uri}" + exit 1 + fi + if [[ ! "${backup_user}" =~ ^[A-Za-z0-9_]{1,32}$ ]]; then + log_error "BACKUP_MYSQL_USER 只能包含字母、数字和下划线,长度不超过 32" + exit 1 + fi + if [[ ! "${mysql_database}" =~ ^[A-Za-z0-9_]+$ ]]; then + log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${mysql_database}" + exit 1 fi if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then log_error "backend/.env 里还有占位值,请先替换后再部署" @@ -230,6 +266,26 @@ export_caddy_env() { export CADDY_SHOW_DOMAIN } +restore_show_dist_placeholders() { + if [[ -z "${SHOW_DIST_BACKUP_DIR}" || ! -d "${SHOW_DIST_BACKUP_DIR}" ]]; then + return 0 + fi + local show_dist="${ROOT_DIR}/deploy/caddy/show-dist" + local git_restore_dir + git_restore_dir="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist-restore.XXXXXX")" + rm -rf "${show_dist}" + mkdir -p "${show_dist}" + if git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/.gitkeep >"${git_restore_dir}/.gitkeep" 2>/dev/null \ + && git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/index.html >"${git_restore_dir}/index.html" 2>/dev/null; then + cp -a "${git_restore_dir}/." "${show_dist}/" + else + cp -a "${SHOW_DIST_BACKUP_DIR}/." "${show_dist}/" + fi + rm -rf "${git_restore_dir}" + rm -rf "${SHOW_DIST_BACKUP_DIR}" + SHOW_DIST_BACKUP_DIR="" +} + # 准备选号网静态资源与 Caddy 站点配置(可选) prepare_show_site() { local conf_dir="${ROOT_DIR}/deploy/caddy/conf.d" @@ -252,6 +308,17 @@ prepare_show_site() { return 0 fi + if [[ "${NO_BUILD}" == "1" ]]; then + # 运行已有镜像不需要 Node/npm;仅重新生成绑定挂载的 Caddy 站点配置。 + log_warn "--no-build:复用已有选号网静态资源,不执行 npm 构建" + if [[ "${show_domain}" == http://* || "${show_domain}" == https://* || "${show_domain}" == */* || "${show_domain}" == "${CADDY_DOMAIN}" || ! "${show_domain}" =~ ^[A-Za-z0-9.-]+$ ]]; then + log_error "CADDY_SHOW_DOMAIN 配置不正确:${show_domain}" + exit 1 + fi + sed "s/__CADDY_SHOW_DOMAIN__/${show_domain}/g" "${show_tmpl}" >"${show_conf}" + return 0 + fi + if [[ "${show_domain}" == http://* || "${show_domain}" == https://* || "${show_domain}" == */* ]]; then log_error "CADDY_SHOW_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${show_domain}" exit 1 @@ -278,13 +345,7 @@ prepare_show_site() { exit 1 fi - if [[ "${NO_BUILD}" == "1" ]]; then - if [[ ! -f "${show_dist}/index.html" ]] || grep -q '选号网静态资源尚未构建' "${show_dist}/index.html" 2>/dev/null; then - log_error "--no-build 时需要已有 deploy/caddy/show-dist 构建产物,请先完整部署一次" - exit 1 - fi - log_warn "使用已有 show-dist(--no-build)" - else + if [[ "${NO_BUILD}" != "1" ]]; then # 服务器通常无本机 Node:优先本机 npm,否则用 Docker 内 node 镜像构建。 # 国内机访问 registry.npmjs.org 易 ECONNRESET,默认走 npmmirror,可用 NPM_REGISTRY 覆盖。 local npm_registry npm_install @@ -301,11 +362,17 @@ prepare_show_site() { if command -v npm >/dev/null 2>&1; then ( cd "${show_dir}" - ${npm_install} \ - --registry="${npm_registry}" \ - --fetch-retries=5 \ - --fetch-retry-mintimeout=20000 \ - --fetch-retry-maxtimeout=120000 + deps_hash="$( { sha256sum package.json; if [[ -f package-lock.json ]]; then sha256sum package-lock.json; fi; } | sha256sum | awk '{print $1}')" + if [[ ! -d node_modules || ! -f .hfb-deps-hash || "$(cat .hfb-deps-hash)" != "${deps_hash}" ]]; then + ${npm_install} \ + --registry="${npm_registry}" \ + --fetch-retries=5 \ + --fetch-retry-mintimeout=20000 \ + --fetch-retry-maxtimeout=120000 + printf '%s\n' "${deps_hash}" > .hfb-deps-hash + else + log "选号网依赖未变化,跳过 npm 安装" + fi npm run build ) else @@ -314,16 +381,35 @@ prepare_show_site() { # 挂载源码目录;node_modules/dist 写在挂载卷上,与本机 npm 行为一致 docker run --rm \ -e npm_config_registry="${npm_registry}" \ + -e HFB_NPM_INSTALL="${npm_install}" \ -v "${show_dir}:/app" \ -w /app \ node:24-alpine \ - sh -c "${npm_install} --registry=${npm_registry} --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && npm run build" + sh -c ' + set -eu + deps_hash="$( { sha256sum package.json; if [ -f package-lock.json ]; then sha256sum package-lock.json; fi; } | sha256sum | awk '\''{print $1}'\'')" + if [ ! -d node_modules ] || [ ! -f .hfb-deps-hash ] || [ "$(cat .hfb-deps-hash)" != "$deps_hash" ]; then + if [ "$HFB_NPM_INSTALL" = "npm ci" ]; then + npm ci --registry="$npm_config_registry" --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 + else + npm install --registry="$npm_config_registry" --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 + fi + printf "%s\\n" "$deps_hash" > .hfb-deps-hash + else + echo "选号网依赖未变化,跳过 npm 安装" + fi + npm run build + ' fi if [[ ! -d "${show_dir}/dist" ]]; then log_error "选号网构建失败:未生成 dist/" log_error "若仍网络失败,可在 backend/.env 设置 NPM_REGISTRY=https://registry.npmmirror.com 后重试" exit 1 fi + # show-dist 中的 .gitkeep/index.html 是仓库占位文件。备份后再写入构建产物, + # 退出时由 restore_show_dist_placeholders 恢复,避免每次部署污染工作区。 + SHOW_DIST_BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist.XXXXXX")" + cp -a "${show_dist}/." "${SHOW_DIST_BACKUP_DIR}/" rm -rf "${show_dist}" mkdir -p "${show_dist}" cp -a "${show_dir}/dist/." "${show_dist}/" @@ -377,6 +463,24 @@ prepare_log_dir() { chmod 700 "${BACKEND_LOG_DIR}" } +prepare_backup_status_dir() { + local backend_uid backend_gid status_dir owner_mismatch + backend_uid="$(require_env BACKEND_UID)" + backend_gid="$(require_env BACKEND_GID)" + status_dir="$(require_env BACKUP_STATUS_DIR)" + mkdir -p "${status_dir}" + owner_mismatch="$(find "${status_dir}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)" + if [[ -n "${owner_mismatch}" ]]; then + if [[ "${EUID}" -ne 0 ]]; then + log_error "备份状态目录存在属主不匹配的文件:${owner_mismatch}" + log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${status_dir}" + exit 1 + fi + chown -R "${backend_uid}:${backend_gid}" "${status_dir}" + fi + chmod 700 "${status_dir}" +} + wait_service_healthy() { local service="$1" local label="$2" @@ -407,6 +511,39 @@ mysql_root_exec() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@" } +sql_string_literal() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\'/\'\'}" + printf '%s' "${value}" +} + +ensure_mysql_backup_user() { + local database backup_user backup_password escaped_password + database="$(require_env MYSQL_DATABASE)" + backup_user="$(require_env BACKUP_MYSQL_USER)" + backup_password="$(require_env BACKUP_MYSQL_PASSWORD)" + escaped_password="$(sql_string_literal "${backup_password}")" + unset backup_password + + log "配置 MySQL 在线备份账号 ${backup_user}" + printf "CREATE USER IF NOT EXISTS \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "ALTER USER \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON performance_schema.log_status TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + # MySQL 8.4 的 XtraBackup 会读取以下只读状态表,即使未启用对应组件也会探测。 + printf "GRANT SELECT ON performance_schema.replication_group_members TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON performance_schema.keyring_component_status TO \`%s\`@'%%';\n" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + printf "GRANT SELECT ON \`%s\`.* TO \`%s\`@'%%';\n" "${database}" "${backup_user}" | + compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' +} + goose_dsn() { local dsn="$1" if [[ "${dsn}" != *"multiStatements="* ]]; then @@ -454,18 +591,36 @@ run_migrations() { wait_http_ready() { local attempt - local url - url="$(health_url)" - log "等待接口健康检查通过..." + local url="${HEALTH_URL}" + + if [[ -n "${url}" ]]; then + log "等待公网接口健康检查通过..." + for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do + if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then + log_success "公网健康检查通过:${url}" + return 0 + fi + sleep 1 + done + + log_error "公网健康检查超时:${url}" + exit 1 + fi + + url="http://127.0.0.1:8080/api/health" + log "等待后端容器内健康检查通过..." for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do - if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then - log_success "健康检查通过:${url}" + if compose exec -T backend wget -q -O /dev/null -T 5 "${url}" >/dev/null 2>&1; then + log_success "容器内健康检查通过:${url}" return 0 fi sleep 1 done - log_error "健康检查超时:${url}" + log_error "后端容器内健康检查超时:${url}" + log_warn "最近 backend 日志:" + compose logs --tail=120 backend || true + log_error "可继续执行:$(compose_user_cmd) logs backend" exit 1 } @@ -492,9 +647,14 @@ main() { cd "${ROOT_DIR}" need_cmd docker need_cmd curl + need_cmd openssl + need_cmd ossutil + need_cmd flock validate_env export_caddy_env + trap restore_show_dist_placeholders EXIT prepare_log_dir + prepare_backup_status_dir # 选号网需在 build caddy 之前准备 show-dist 与 conf.d prepare_show_site @@ -534,8 +694,8 @@ main() { wait_service_healthy mysql "MySQL" wait_service_healthy redis "Redis" - wait_service_healthy minio "MinIO" wait_service_healthy caddy "Caddy" + ensure_mysql_backup_user reset_database run_migrations diff --git a/scripts/extract-vue-styles.sh b/scripts/extract-vue-styles.sh new file mode 100755 index 0000000..6b336ca --- /dev/null +++ b/scripts/extract-vue-styles.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 将大型 SFC 的内联 scoped CSS 外置,保留 ##s" "${file}" +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0) diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..2f6eaf7 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +git -C "$ROOT_DIR" config core.hooksPath .githooks +echo "已启用 .githooks(推送前执行 scripts/check.sh;SKIP_CHECKS=1 可跳过)" diff --git a/scripts/restore-online.sh b/scripts/restore-online.sh new file mode 100755 index 0000000..56d74cc --- /dev/null +++ b/scripts/restore-online.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 只用于隔离恢复演练或灾难恢复准备。它从不触碰生产 mysql 容器:prepare +# 在 BACKUP_RESTORE_DIR 下生成数据目录,start 仅启动 --network none 的临时容器。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +field_value() { + local file="$1" key="$2" + awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${file}" +} + +require_restore_dir() { + local dir + dir="$(backup_require_env BACKUP_RESTORE_DIR)" + backup_validate_dir "${dir}" + printf '%s' "${dir}" +} + +validate_sha256() { + [[ "$1" =~ ^[0-9a-f]{64}$ ]] || { backup_error "非法 SHA-256:$1"; exit 1; } +} + +validate_server_uuid() { + [[ "$1" =~ ^[0-9a-f-]{36}$ ]] || { backup_error "非法 server_uuid:$1"; exit 1; } +} + +verify_downloaded_payload() { + local payload="$1" checksum_file="$2" complete_file="$3" + local expected actual expected_size actual_size + expected="$(field_value "${complete_file}" payload_sha256)" + expected_size="$(field_value "${complete_file}" payload_size_bytes)" + validate_sha256 "${expected}" + [[ "${expected_size}" =~ ^[0-9]+$ ]] || { backup_error '完成标识中的文件大小不合法'; exit 1; } + actual="$(backup_sha256 "${payload}")" + actual_size="$(backup_file_size "${payload}")" + [[ "${expected}" == "${actual}" && "${expected_size}" == "${actual_size}" ]] || { + backup_error "下载文件校验失败:${payload}" + exit 1 + } + grep -Fqx "${expected} $(basename "${payload}")" "${checksum_file}" || { + backup_error "校验文件内容不匹配:${checksum_file}" + exit 1 + } +} + +prepare_full() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + local complete_uri="$1" + [[ "${complete_uri}" == oss://*/complete.env ]] || { + backup_error 'prepare 参数必须是完整全量备份的 complete.env OSS URI' + exit 1 + } + local restore_dir remote_dir ts server_uuid work_dir download_dir + restore_dir="$(require_restore_dir)" + remote_dir="${complete_uri%/complete.env}" + ts="${remote_dir##*/}" + server_uuid="$(basename "$(dirname "${remote_dir}")")" + validate_server_uuid "${server_uuid}" + work_dir="${restore_dir}/${server_uuid}/${ts}" + download_dir="${work_dir}/.download" + [[ ! -e "${work_dir}" ]] || { backup_error "恢复目录已存在:${work_dir}"; exit 1; } + mkdir -p "${download_dir}" "${work_dir}/data" + trap 'rm -rf -- "${download_dir}"' EXIT + + backup_oss_download "${complete_uri}" "${download_dir}/complete.env" + [[ "$(field_value "${download_dir}/complete.env" payload)" == 'mysql-full.tgz.enc' ]] || { + backup_error '不是受支持的全量备份完成标识' + exit 1 + } + [[ "$(field_value "${download_dir}/complete.env" server_uuid)" == "${server_uuid}" ]] || { + backup_error '备份路径与完成标识的 server_uuid 不一致' + exit 1 + } + backup_oss_download "${remote_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc" + backup_oss_download "${remote_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/mysql-full.tgz.enc.sha256" + verify_downloaded_payload "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/complete.env" + backup_decrypt_file "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz" + tar xzf "${download_dir}/mysql-full.tgz" -C "${work_dir}/data" + local manifest_uuid binlog_file binlog_pos + manifest_uuid="$(field_value "${work_dir}/data/manifest.env" server_uuid)" + binlog_file="$(field_value "${work_dir}/data/manifest.env" binlog_file)" + binlog_pos="$(field_value "${work_dir}/data/manifest.env" binlog_pos)" + [[ "${manifest_uuid}" == "${server_uuid}" ]] || { backup_error '归档内 manifest 与完成标识不一致'; exit 1; } + backup_binlog_index "${binlog_file}" >/dev/null + [[ "${binlog_pos}" =~ ^[0-9]+$ ]] || { backup_error 'manifest 中 binlog 位点非法'; exit 1; } + docker run --rm -v "${work_dir}/data:/restore" "${BACKUP_XTRABACKUP_IMAGE}" \ + xtrabackup --prepare --target-dir=/restore + { + printf 'server_uuid=%s\n' "${server_uuid}" + printf 'binlog_file=%s\n' "${binlog_file}" + printf 'binlog_pos=%s\n' "${binlog_pos}" + printf 'source_complete_uri=%s\n' "${complete_uri}" + printf 'prepared_at=%s\n' "$(date -Iseconds)" + } > "${work_dir}/prepared.env" + rm -rf -- "${download_dir}" + trap - EXIT + backup_log "全量备份已 prepare:${work_dir}" + backup_log "下一步执行:$0 fetch-all ${work_dir}" +} + +fetch_binlog() { + backup_require_cmd docker + backup_require_cmd ossutil + backup_require_cmd openssl + local prepared_dir="$1" complete_uri="$2" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + [[ "${complete_uri}" == oss://*/complete.env ]] || { backup_error 'binlog 参数必须是 complete.env OSS URI'; exit 1; } + local remote_dir temp_dir complete source_file raw_sha payload server_uuid expected_uuid target_base + remote_dir="${complete_uri%/complete.env}" + temp_dir="${prepared_dir}/.binlog-download-$$" + mkdir -p "${temp_dir}" "${prepared_dir}/binlogs" + trap 'rm -rf -- "${temp_dir}"' EXIT + backup_oss_download "${complete_uri}" "${temp_dir}/complete.env" + source_file="$(field_value "${temp_dir}/complete.env" source_binlog)" + raw_sha="$(field_value "${temp_dir}/complete.env" raw_sha256)" + payload="$(field_value "${temp_dir}/complete.env" payload)" + server_uuid="$(field_value "${temp_dir}/complete.env" server_uuid)" + expected_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)" + backup_binlog_index "${source_file}" >/dev/null + validate_sha256 "${raw_sha}" + validate_server_uuid "${server_uuid}" + [[ "${payload}" == 'binlog.enc' && "${server_uuid}" == "${expected_uuid}" ]] || { + backup_error 'binlog 完成标识与恢复目标不匹配' + exit 1 + } + target_base="${prepared_dir}/binlogs/${source_file}.${raw_sha}" + if [[ -f "${target_base}.meta.env" && -f "${target_base}" ]]; then + rm -rf -- "${temp_dir}" + trap - EXIT + return + fi + backup_oss_download "${remote_dir}/binlog.enc" "${temp_dir}/binlog.enc" + backup_oss_download "${remote_dir}/binlog.enc.sha256" "${temp_dir}/binlog.enc.sha256" + verify_downloaded_payload "${temp_dir}/binlog.enc" "${temp_dir}/binlog.enc.sha256" "${temp_dir}/complete.env" + backup_decrypt_file "${temp_dir}/binlog.enc" "${target_base}" + docker run --rm -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 \ + mysqlbinlog --verify-binlog-checksum "/binlogs/$(basename "${target_base}")" >/dev/null + mv "${temp_dir}/complete.env" "${target_base}.meta.env" + rm -rf -- "${temp_dir}" + trap - EXIT +} + +fetch_all() { + backup_require_cmd ossutil + local prepared_dir="$1" server_uuid oss_uri complete_uri + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + server_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + backup_validate_oss_uri "${oss_uri}" + while IFS= read -r complete_uri; do + [[ -n "${complete_uri}" ]] || continue + fetch_binlog "${prepared_dir}" "${complete_uri}" + done < <(ossutil ls "${oss_uri}/mysql/binlog/${server_uuid}/" -r | awk '$NF ~ /\/complete\.env$/ { print $NF }') + backup_log "已下载实例 ${server_uuid} 的全部完整 binlog" +} + +start_isolated_mysql() { + backup_require_cmd docker + local prepared_dir="$1" name="${2:-}" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + if [[ -z "${name}" ]]; then + name="hfb-restore-$(basename "${prepared_dir}")" + fi + [[ "${name}" =~ ^hfb-restore-[A-Za-z0-9_-]+$ ]] || { backup_error '恢复容器名必须以 hfb-restore- 开头'; exit 1; } + [[ -z "$(docker ps -aq -f "name=^/${name}$")" ]] || { backup_error "恢复容器已存在:${name}"; exit 1; } + docker run -d --name "${name}" --network none \ + -e "MYSQL_ROOT_PASSWORD=$(backup_require_env MYSQL_ROOT_PASSWORD)" \ + -v "${prepared_dir}/data:/var/lib/mysql" mysql:8.4 \ + --skip-log-bin --skip-networking=1 >/dev/null + local attempt + for ((attempt = 1; attempt <= 60; attempt++)); do + if docker exec "${name}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqladmin -uroot --protocol=socket ping' >/dev/null 2>&1; then + backup_log "隔离 MySQL 已启动:${name}" + return + fi + sleep 1 + done + backup_error "隔离 MySQL 启动失败,请执行 docker logs ${name}" + exit 1 +} + +replay_binlogs() { + backup_require_cmd docker + local prepared_dir="$1" target_container="$2" stop_datetime="${3:-}" + [[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; } + local production_cid + production_cid="$(backup_mysql_container_id)" + [[ "${target_container}" != "${production_cid}" ]] || { backup_error '禁止向生产 MySQL 回放 binlog'; exit 1; } + docker inspect "${target_container}" >/dev/null + [[ -d "${prepared_dir}/binlogs" ]] || { backup_error '尚未下载 binlog'; exit 1; } + local start_file start_pos metadata index source previous_index found=0 + start_file="$(field_value "${prepared_dir}/prepared.env" binlog_file)" + start_pos="$(field_value "${prepared_dir}/prepared.env" binlog_pos)" + mapfile -t metadata < <( + for metadata in "${prepared_dir}"/binlogs/*.meta.env; do + [[ -f "${metadata}" ]] || continue + index="$(field_value "${metadata}" binlog_index)" + source="$(field_value "${metadata}" source_binlog)" + printf '%010d\t%s\t%s\n' "${index}" "${source}" "${metadata}" + done | sort -n + ) + [[ "${#metadata[@]}" -gt 0 ]] || { backup_error '没有可回放的 binlog'; exit 1; } + local binlog_paths=() + previous_index='' + local row path + for row in "${metadata[@]}"; do + IFS=$'\t' read -r index source metadata <<< "${row}" + if [[ "${source}" == "${start_file}" ]]; then + found=1 + fi + [[ "${found}" == '1' ]] || continue + if [[ -n "${previous_index}" && "${index}" -ne $((previous_index + 1)) ]]; then + backup_error "恢复 binlog 不连续:${previous_index} -> ${index}" + exit 1 + fi + path="/binlogs/$(basename "${metadata%.meta.env}")" + binlog_paths+=("${path}") + previous_index="${index}" + done + [[ "${found}" == '1' ]] || { backup_error "缺少全量备份起始 binlog:${start_file}"; exit 1; } + local mysqlbinlog_args=(mysqlbinlog "--start-position=${start_pos}") + if [[ -n "${stop_datetime}" ]]; then + mysqlbinlog_args+=("--stop-datetime=${stop_datetime}") + fi + mysqlbinlog_args+=("${binlog_paths[@]}") + docker run --rm -i -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 "${mysqlbinlog_args[@]}" | + docker exec -i "${target_container}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket' + backup_log "binlog 已回放至隔离 MySQL:${target_container}" +} + +case "${1:-}" in + prepare) [[ $# -eq 2 ]] || { backup_error '用法:prepare '; exit 1; }; prepare_full "$2" ;; + fetch-all) [[ $# -eq 2 ]] || { backup_error '用法:fetch-all '; exit 1; }; fetch_all "$2" ;; + fetch-binlog) [[ $# -eq 3 ]] || { backup_error '用法:fetch-binlog '; exit 1; }; fetch_binlog "$2" "$3" ;; + start) [[ $# -le 3 && $# -ge 2 ]] || { backup_error '用法:start [container-name]'; exit 1; }; start_isolated_mysql "$2" "${3:-}" ;; + replay) [[ $# -ge 3 && $# -le 4 ]] || { backup_error '用法:replay [YYYY-MM-DD HH:MM:SS]'; exit 1; }; replay_binlogs "$2" "$3" "${4:-}" ;; + *) + printf '用法: %s {prepare|fetch-all|fetch-binlog|start|replay} ...\n' "$0" >&2 + exit 1 + ;; +esac