chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/jobs/distributionsettlement"
|
||||
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
||||
"hfb_sys/backend/internal/jobs/listingstale"
|
||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||
"hfb_sys/backend/internal/jobs/refundretry"
|
||||
"hfb_sys/backend/internal/jobs/smsdispatch"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/modules/adminauth"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/payment"
|
||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
"hfb_sys/backend/internal/router"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// @title HFB Sys API
|
||||
// @version 1.0
|
||||
// @description 哈夫币租号平台 API 文档
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.email support@hfb.com
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
logger, err := logging.New(cfg.Log)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
if err := cfg.ValidateProductionSecurity(); err != nil {
|
||||
logger.Fatal("生产安全配置校验失败", zap.Error(err))
|
||||
}
|
||||
logAuthRuntimeIdentity(logger, cfg)
|
||||
|
||||
var deps router.Dependencies
|
||||
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 {
|
||||
deps.DB = db
|
||||
if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{
|
||||
AppEnv: cfg.AppEnv,
|
||||
Username: cfg.BootstrapAdminUsername,
|
||||
Password: cfg.BootstrapAdminPassword,
|
||||
Nickname: cfg.BootstrapAdminNickname,
|
||||
}); err != nil {
|
||||
logger.Fatal("初始管理员创建失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
|
||||
Addr: cfg.RedisAddr,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("Redis 不可用,相关接口将返回 503", zap.Error(err))
|
||||
} else {
|
||||
deps.Redis = redisClient
|
||||
defer func() {
|
||||
_ = redisClient.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
engine := router.New(cfg, deps, logger)
|
||||
jobCtx, stopJobs := context.WithCancel(context.Background())
|
||||
defer stopJobs()
|
||||
if deps.DB != nil {
|
||||
var paymentRepo *payment.Repository
|
||||
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
||||
// 超时取消前的渠道核对要把收款推进到订单上,所以这里必须带真实的订单仓库,
|
||||
// 否则入账会在依赖缺失处失败,支付单仍旧对不上。
|
||||
jobOrderRepo := order.NewRepository(deps.DB)
|
||||
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, jobOrderRepo, payment.WithLogger(logger))
|
||||
}
|
||||
ordertimeout.New(deps.DB, deps.Redis, logger, paymentProbeOrNil(paymentRepo)).Start(jobCtx)
|
||||
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||
distributionsettlement.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||
if paymentRepo != nil {
|
||||
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
||||
}
|
||||
startSMSJobs(jobCtx, cfg, deps, logger)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.AppAddr,
|
||||
Handler: engine,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("API 服务启动", zap.String("addr", cfg.AppAddr))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal("API 服务异常退出", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
stopJobs()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Fatal("API 服务关闭失败", zap.Error(err))
|
||||
}
|
||||
logger.Info("API 服务已停止")
|
||||
}
|
||||
|
||||
// logAuthRuntimeIdentity 记录可用于排查多实例或部署配置漂移的非敏感身份信息。
|
||||
func logAuthRuntimeIdentity(logger *zap.Logger, cfg config.Config) {
|
||||
instanceID, err := os.Hostname()
|
||||
if err != nil || instanceID == "" {
|
||||
instanceID = "unknown"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(cfg.JWTSecret))
|
||||
logger.Debug("认证运行实例",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("app_env", cfg.AppEnv),
|
||||
zap.String("jwt_secret_fingerprint", hex.EncodeToString(sum[:])[:12]),
|
||||
)
|
||||
}
|
||||
|
||||
// paymentProbeOrNil 避免把类型化的 nil 指针当成非空接口传给定时任务。
|
||||
func paymentProbeOrNil(repo *payment.Repository) ordertimeout.ChannelPaymentProbe {
|
||||
if repo == nil {
|
||||
return nil
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *zap.Logger) *paymentconfig.Repository {
|
||||
encryptionKey := cfg.PaymentConfigEncryptionKey
|
||||
var encryptor paymentconfig.Encryptor
|
||||
if encryptionKey != "" {
|
||||
if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil {
|
||||
encryptor = aesEncryptor
|
||||
}
|
||||
}
|
||||
if encryptor == nil {
|
||||
if config.IsProductionEnv(cfg.AppEnv) {
|
||||
logger.Fatal("支付配置加密密钥未设置或无效")
|
||||
}
|
||||
encryptor = &paymentconfig.MockEncryptor{}
|
||||
logger.Debug("定时任务使用模拟支付配置加密器")
|
||||
}
|
||||
return paymentconfig.NewRepository(db, encryptor)
|
||||
}
|
||||
|
||||
// startSMSJobs 启动节点短信派发与「上架未成交」扫描任务。
|
||||
// 分站短信密钥需要解密,因此这里单独构造一份带业务字段加密器的分销仓库。
|
||||
func startSMSJobs(ctx context.Context, cfg config.Config, deps router.Dependencies, logger *zap.Logger) {
|
||||
if deps.DB == nil {
|
||||
return
|
||||
}
|
||||
configRepo := systemconfig.NewRepository(deps.DB)
|
||||
if err := configRepo.EnsureDefaults(ctx); err != nil {
|
||||
logger.Warn("系统配置默认值初始化失败,节点短信将按默认关闭处理", zap.Error(err))
|
||||
}
|
||||
|
||||
distributionRepo := distribution.NewRepository(deps.DB, deps.Redis)
|
||||
distributionRepo.SetEncryptor(router.NewFieldEncryptor(cfg, logger))
|
||||
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
|
||||
resolver := distribution.NewSMSResolver(distributionRepo, logger)
|
||||
|
||||
smsdispatch.New(
|
||||
deps.DB,
|
||||
deps.Redis,
|
||||
logger,
|
||||
router.NewSMSProvider(cfg, logger),
|
||||
cfg.SMS.AliyunSignName,
|
||||
configRepo,
|
||||
resolver,
|
||||
).Start(ctx)
|
||||
|
||||
listingstale.New(deps.DB, deps.Redis, logger, configRepo).Start(ctx)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// rebate-backfill-rental 为「仲裁按实际消耗结算」的租赁订单补发邀请返利。
|
||||
//
|
||||
// 背景:仲裁结案历史路径未触发邀请返利;且订单保留原预收金额快照,
|
||||
// 不能直接用 order.rent_amount_cent - owner_rent_amount_cent 计算。
|
||||
// 本脚本按仲裁后的实际平台毛利(优先取已结算 checkout 的平台费,
|
||||
// 否则取 actual_pure_coin_amount_cent - 仲裁入账给号主的金额)补发。
|
||||
//
|
||||
// 幂等:复用 invitation_rebate_ledgers 的 (source, order_id, trade_role) 唯一键。
|
||||
//
|
||||
// 用法(默认预演):
|
||||
//
|
||||
// go run ./cmd/rebate-backfill-rental -order-no RO20261017002241506,RO20261012185753383
|
||||
// go run ./cmd/rebate-backfill-rental -apply -order-no RO20261017002241506,RO20261012185753383
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/invitation"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
Order model.RentalOrder
|
||||
ActualProfitCent int64
|
||||
ActualProfitSource string
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)")
|
||||
orderNos := flag.String("order-no", "", "需要补发的租赁订单号,多个用逗号分隔")
|
||||
flag.Parse()
|
||||
|
||||
orderNoList := splitOrderNos(*orderNos)
|
||||
if len(orderNoList) == 0 {
|
||||
log.Fatal("必须通过 -order-no 指定订单号,避免误补全站历史订单")
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
logger, err := logging.New(cfg.Log)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化日志失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := loadCandidates(db, orderNoList)
|
||||
if err != nil {
|
||||
log.Fatalf("查询待补发订单失败: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有可补发的订单:订单不存在、未仲裁完成、无实际毛利或已补发。")
|
||||
return
|
||||
}
|
||||
|
||||
mode := "预演"
|
||||
if *apply {
|
||||
mode = "实际补发"
|
||||
}
|
||||
fmt.Printf("模式=%s,候选订单=%d\n", mode, len(rows))
|
||||
|
||||
var paidCount int
|
||||
var paidCent int64
|
||||
for _, row := range rows {
|
||||
rateAt := time.Now()
|
||||
if row.Order.SettledAt != nil {
|
||||
rateAt = *row.Order.SettledAt
|
||||
}
|
||||
fmt.Printf("[%s] %s 号主=%d 原毛利=%s 实际平台毛利=%s(%s)计提时刻=%s\n",
|
||||
mode, row.Order.OrderNo, row.Order.OwnerID,
|
||||
money.FormatWithSymbol(row.Order.RentAmountCent-row.Order.OwnerRentAmountCent),
|
||||
money.FormatWithSymbol(row.ActualProfitCent), row.ActualProfitSource,
|
||||
rateAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if !*apply {
|
||||
continue
|
||||
}
|
||||
amountCent, err := backfillOne(db, row, rateAt)
|
||||
if err != nil {
|
||||
log.Printf("补发失败 %s: %v", row.Order.OrderNo, err)
|
||||
continue
|
||||
}
|
||||
if amountCent <= 0 {
|
||||
fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.Order.OrderNo)
|
||||
continue
|
||||
}
|
||||
paidCount++
|
||||
paidCent += amountCent
|
||||
fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.Order.OrderNo, money.FormatWithSymbol(amountCent))
|
||||
}
|
||||
|
||||
if *apply {
|
||||
fmt.Printf("完成:补发 %d 单,合计 %s。\n", paidCount, money.FormatWithSymbol(paidCent))
|
||||
}
|
||||
}
|
||||
|
||||
func splitOrderNos(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadCandidates(db *gorm.DB, orderNos []string) ([]candidate, error) {
|
||||
var orders []model.RentalOrder
|
||||
if err := db.Where("order_no IN ?", orderNos).Order("id ASC").Find(&orders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]candidate, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
if order.Status != "completed" || order.SettlementStatus != "arbitrated" {
|
||||
continue
|
||||
}
|
||||
var rebateCount int64
|
||||
if err := db.Model(&model.InvitationRebateLedger{}).
|
||||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||||
Count(&rebateCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rebateCount > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
profitCent, source, err := actualProfitCent(db, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profitCent <= 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate{
|
||||
Order: order,
|
||||
ActualProfitCent: profitCent,
|
||||
ActualProfitSource: source,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// actualProfitCent 返回仲裁后的实际平台毛利。
|
||||
// 已结算 checkout 的平台费最准确;没有 checkout 时用实际纯币收入减去仲裁入账号主的金额。
|
||||
func actualProfitCent(db *gorm.DB, order model.RentalOrder) (int64, string, error) {
|
||||
var checkout model.OrderCheckout
|
||||
checkoutErr := db.Where("order_id = ? AND status = ?", order.ID, "settled").
|
||||
Order("id DESC").First(&checkout).Error
|
||||
if checkoutErr == nil && checkout.PlatformFeeCent > 0 {
|
||||
return checkout.PlatformFeeCent, "settled_checkout.platform_fee_cent", nil
|
||||
}
|
||||
if checkoutErr != nil && checkoutErr != gorm.ErrRecordNotFound {
|
||||
return 0, "", checkoutErr
|
||||
}
|
||||
|
||||
var ownerIncomeCent int64
|
||||
if err := db.Model(&model.WalletLedger{}).
|
||||
Where("order_id = ? AND biz_type = ? AND direction = 'in' AND balance_type = 'available'", order.ID, "arbitration_owner_income").
|
||||
Select("COALESCE(SUM(amount_cent), 0)").Scan(&ownerIncomeCent).Error; err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if order.ActualPureCoinAmountCent <= 0 || ownerIncomeCent <= 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
return order.ActualPureCoinAmountCent - ownerIncomeCent, "actual_pure_coin - arbitration_owner_income", nil
|
||||
}
|
||||
|
||||
func backfillOne(db *gorm.DB, row candidate, rateAt time.Time) (int64, error) {
|
||||
var amountCent int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// 事务内重新读取,避免预演后订单状态或金额发生变化。
|
||||
var order model.RentalOrder
|
||||
if err := tx.First(&order, row.Order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status != "completed" || order.SettlementStatus != "arbitrated" {
|
||||
return nil
|
||||
}
|
||||
|
||||
profitCent, _, err := actualProfitCent(tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profitCent <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
options := invitation.OrderRebateOptions{
|
||||
GrossProfitCent: &profitCent,
|
||||
RateReferenceAt: rateAt,
|
||||
SettledAt: time.Now(),
|
||||
}
|
||||
before := int64(0)
|
||||
_ = tx.Model(&model.InvitationRebateLedger{}).
|
||||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||||
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&before).Error
|
||||
if err := invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, options); err != nil {
|
||||
return err
|
||||
}
|
||||
var after int64
|
||||
if err := tx.Model(&model.InvitationRebateLedger{}).
|
||||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||||
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&after).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
amountCent = after - before
|
||||
return nil
|
||||
})
|
||||
return amountCent, err
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// rebate-backfill 为「线下提号」补发邀请返利(一次性运维脚本,可安全重复执行)。
|
||||
//
|
||||
// 背景:线下提号(admin_pickups)不走正常订单结账流程,历史上未接入邀请返利结算,
|
||||
// 导致已完成提号单的号主邀请人拿不到分成。代码侧已在 pickup.Complete 内接入,
|
||||
// 本脚本用于补齐「接入前已完成」的历史提号单;仍在进行中的提号单会在完成时自动发放。
|
||||
//
|
||||
// 幂等:写入 (source='pickup', order_id, trade_role='seller') 唯一键,
|
||||
// 且候选集已排除已有提号返利记录的单据,重复执行不会重复发放。
|
||||
//
|
||||
// 口径:单边基数 = 提号利润对半(profit_amount_cent / 2),与租赁订单卖家侧一致;
|
||||
// 比例阶梯按提号完成时刻距今(被邀请人实名生效)的档位计算。
|
||||
//
|
||||
// 用法(默认预演,不写库):
|
||||
//
|
||||
// go run ./cmd/rebate-backfill # 预演,只列待补发单据
|
||||
// go run ./cmd/rebate-backfill -apply # 实际补发
|
||||
// go run ./cmd/rebate-backfill -apply -pickup-no PK20261011113459355
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/invitation"
|
||||
"hfb_sys/backend/internal/modules/pickup"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
ID uint64
|
||||
PickupNo string
|
||||
OwnerID uint64
|
||||
ProfitAmountCent int64
|
||||
CompletedAt *time.Time
|
||||
ListingSiteID *uint64
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)")
|
||||
pickupNo := flag.String("pickup-no", "", "仅处理指定提号单号")
|
||||
limit := flag.Int("limit", 0, "最多处理条数,0 表示不限制")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.Load()
|
||||
logger, err := logging.New(cfg.Log)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化日志失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := loadCandidates(db, *pickupNo, *limit)
|
||||
if err != nil {
|
||||
log.Fatalf("查询待补发提号单失败: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有需要补发的提号单:已完成提号单均已覆盖或不存在生效邀请关系。")
|
||||
return
|
||||
}
|
||||
|
||||
mode := "预演"
|
||||
if *apply {
|
||||
mode = "实际补发"
|
||||
}
|
||||
fmt.Printf("模式=%s,候选提号单=%d\n", mode, len(rows))
|
||||
|
||||
var paidCount, skippedCount int
|
||||
var paidCent int64
|
||||
for _, row := range rows {
|
||||
completedAt := time.Now()
|
||||
if row.CompletedAt != nil {
|
||||
completedAt = *row.CompletedAt
|
||||
}
|
||||
siteID := uint64(0)
|
||||
if row.ListingSiteID != nil {
|
||||
siteID = *row.ListingSiteID
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Printf("[预演] %s 号主=%d 利润=%s 单边基数=%s 完成于=%s\n",
|
||||
row.PickupNo, row.OwnerID,
|
||||
money.FormatWithSymbol(row.ProfitAmountCent),
|
||||
money.FormatWithSymbol(row.ProfitAmountCent/2),
|
||||
completedAt.Format("2006-01-02 15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
amountCent, err := backfillOne(db, row.ID, siteID, completedAt)
|
||||
if err != nil {
|
||||
log.Printf("补发失败 %s: %v", row.PickupNo, err)
|
||||
continue
|
||||
}
|
||||
if amountCent <= 0 {
|
||||
skippedCount++
|
||||
fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.PickupNo)
|
||||
continue
|
||||
}
|
||||
paidCount++
|
||||
paidCent += amountCent
|
||||
fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.PickupNo, money.FormatWithSymbol(amountCent))
|
||||
}
|
||||
|
||||
if *apply {
|
||||
fmt.Printf("完成:补发 %d 单,合计 %s;跳过 %d 单。\n",
|
||||
paidCount, money.FormatWithSymbol(paidCent), skippedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCandidates(db *gorm.DB, pickupNo string, limit int) ([]candidate, error) {
|
||||
query := db.Table("admin_pickups AS p").
|
||||
Select(`p.id, p.pickup_no, p.owner_id, p.profit_amount_cent, p.completed_at,
|
||||
l.distribution_site_id AS listing_site_id`).
|
||||
Joins("JOIN user_invitations i ON i.invitee_id = p.owner_id AND i.status = ? AND i.inviter_id <> p.owner_id", invitation.StatusActive).
|
||||
Joins("LEFT JOIN rental_listings l ON l.id = p.listing_id").
|
||||
Where("p.status = ?", pickup.StatusCompleted).
|
||||
Where("p.profit_amount_cent > 0").
|
||||
Where(`NOT EXISTS (
|
||||
SELECT 1 FROM invitation_rebate_ledgers r
|
||||
WHERE r.source = ? AND r.order_id = p.id AND r.trade_role = 'seller'
|
||||
)`, invitation.RebateSourcePickup).
|
||||
Order("p.completed_at ASC")
|
||||
|
||||
if trimmed := strings.TrimSpace(pickupNo); trimmed != "" {
|
||||
query = query.Where("p.pickup_no = ?", trimmed)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
var rows []candidate
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// backfillOne 在独立事务内为单张提号单补发返利并返回实际发放金额(分)。
|
||||
func backfillOne(db *gorm.DB, pickupID uint64, siteID uint64, completedAt time.Time) (int64, error) {
|
||||
var amountCent int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var record model.AdminPickup
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, pickupID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status != pickup.StatusCompleted {
|
||||
return nil
|
||||
}
|
||||
amount, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{
|
||||
Source: invitation.RebateSourcePickup,
|
||||
SourceID: record.ID,
|
||||
SourceNo: record.PickupNo,
|
||||
SellerID: record.OwnerID,
|
||||
GrossBasisCent: record.ProfitAmountCent,
|
||||
BasisCent: record.ProfitAmountCent / 2,
|
||||
SiteID: siteID,
|
||||
RateReferenceAt: completedAt,
|
||||
SettledAt: time.Now(),
|
||||
RemarkPrefix: "邀请卖家提号返利",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amountCent = amount
|
||||
return nil
|
||||
})
|
||||
return amountCent, err
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
filemodule "hfb_sys/backend/internal/modules/file"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
type migrationConfig struct {
|
||||
source config.StorageConfig
|
||||
target config.StorageConfig
|
||||
}
|
||||
|
||||
type summary struct {
|
||||
objectsCopied int64
|
||||
bytesCopied int64
|
||||
objectsSkipped int64
|
||||
objectsFailed int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
verifyOnly := flag.Bool("verify", false, "仅校验目标对象,不上传文件")
|
||||
dryRun := flag.Bool("dry-run", false, "只输出待迁移对象,不上传文件")
|
||||
prefix := flag.String("prefix", "", "仅迁移指定前缀")
|
||||
flag.Parse()
|
||||
if *verifyOnly && *dryRun {
|
||||
log.Fatal("--verify 与 --dry-run 不能同时使用")
|
||||
}
|
||||
|
||||
cfg, err := loadMigrationConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if sameStorage(cfg.source, cfg.target) {
|
||||
log.Fatal("源存储和目标存储相同,已拒绝执行")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
source, err := filemodule.NewStorageClient(cfg.source)
|
||||
if err != nil {
|
||||
log.Fatalf("创建 MinIO 源客户端失败: %v", err)
|
||||
}
|
||||
target, err := filemodule.NewStorageClient(cfg.target)
|
||||
if err != nil {
|
||||
log.Fatalf("创建 OSS 目标客户端失败: %v", err)
|
||||
}
|
||||
if err := requireBucket(ctx, source, cfg.source.Bucket, "源"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := requireBucket(ctx, target, cfg.target.Bucket, "目标"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mode := "同步"
|
||||
if *verifyOnly {
|
||||
mode = "校验"
|
||||
} else if *dryRun {
|
||||
mode = "预演"
|
||||
}
|
||||
log.Printf("开始%s:%s/%s -> %s/%s,前缀=%q", mode, cfg.source.Endpoint, cfg.source.Bucket, cfg.target.Endpoint, cfg.target.Bucket, *prefix)
|
||||
|
||||
result, err := migrate(ctx, source, cfg.source.Bucket, target, cfg.target.Bucket, *prefix, *verifyOnly, *dryRun)
|
||||
log.Printf("结束:已复制 %d 个对象(%d 字节),已跳过 %d 个对象,失败 %d 个对象", result.objectsCopied, result.bytesCopied, result.objectsSkipped, result.objectsFailed)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMigrationConfig() (migrationConfig, error) {
|
||||
source := config.StorageConfig{
|
||||
Endpoint: os.Getenv("STORAGE_ENDPOINT"),
|
||||
Bucket: os.Getenv("STORAGE_BUCKET"),
|
||||
AccessKeyID: os.Getenv("STORAGE_ACCESS_KEY_ID"),
|
||||
SecretAccessKey: os.Getenv("STORAGE_SECRET_ACCESS_KEY"),
|
||||
Region: os.Getenv("STORAGE_REGION"),
|
||||
BucketLookup: valueOrDefault("STORAGE_BUCKET_LOOKUP", "auto"),
|
||||
}
|
||||
target := config.StorageConfig{
|
||||
Endpoint: os.Getenv("STORAGE_MIRROR_ENDPOINT"),
|
||||
Bucket: os.Getenv("STORAGE_MIRROR_BUCKET"),
|
||||
AccessKeyID: os.Getenv("STORAGE_MIRROR_ACCESS_KEY_ID"),
|
||||
SecretAccessKey: os.Getenv("STORAGE_MIRROR_SECRET_ACCESS_KEY"),
|
||||
Region: os.Getenv("STORAGE_MIRROR_REGION"),
|
||||
BucketLookup: valueOrDefault("STORAGE_MIRROR_BUCKET_LOOKUP", "auto"),
|
||||
}
|
||||
if err := validateStorageConfig("源 MinIO", source); err != nil {
|
||||
return migrationConfig{}, err
|
||||
}
|
||||
if err := validateStorageConfig("目标 OSS", target); err != nil {
|
||||
return migrationConfig{}, err
|
||||
}
|
||||
return migrationConfig{source: source, target: target}, nil
|
||||
}
|
||||
|
||||
func valueOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func validateStorageConfig(name string, cfg config.StorageConfig) error {
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" || strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
|
||||
return fmt.Errorf("%s 配置不完整,请检查 endpoint、bucket 和访问密钥", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameStorage(source, target config.StorageConfig) bool {
|
||||
return strings.TrimRight(strings.ToLower(source.Endpoint), "/") == strings.TrimRight(strings.ToLower(target.Endpoint), "/") && source.Bucket == target.Bucket
|
||||
}
|
||||
|
||||
func requireBucket(ctx context.Context, client *minio.Client, bucket, label string) error {
|
||||
exists, err := client.BucketExists(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查%s Bucket 失败: %w", label, err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s Bucket 不存在: %s", label, bucket)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, source *minio.Client, sourceBucket string, target *minio.Client, targetBucket, prefix string, verifyOnly, dryRun bool) (summary, error) {
|
||||
var result summary
|
||||
for item := range source.ListObjects(ctx, sourceBucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) {
|
||||
if item.Err != nil {
|
||||
return result, fmt.Errorf("列出源对象失败: %w", item.Err)
|
||||
}
|
||||
sourceInfo, err := source.StatObject(ctx, sourceBucket, item.Key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
result.objectsFailed++
|
||||
log.Printf("跳过对象 %s:读取源对象元数据失败: %v", item.Key, err)
|
||||
continue
|
||||
}
|
||||
matches, err := targetMatches(ctx, target, targetBucket, item.Key, sourceInfo)
|
||||
if err != nil {
|
||||
result.objectsFailed++
|
||||
log.Printf("跳过对象 %s:检查目标对象失败: %v", item.Key, err)
|
||||
continue
|
||||
}
|
||||
if matches {
|
||||
result.objectsSkipped++
|
||||
continue
|
||||
}
|
||||
if verifyOnly {
|
||||
result.objectsFailed++
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
log.Printf("待复制:%s(%d 字节)", item.Key, sourceInfo.Size)
|
||||
continue
|
||||
}
|
||||
if err := copyObject(ctx, source, sourceBucket, target, targetBucket, item.Key, sourceInfo); err != nil {
|
||||
result.objectsFailed++
|
||||
log.Printf("复制对象 %s 失败: %v", item.Key, err)
|
||||
continue
|
||||
}
|
||||
result.objectsCopied++
|
||||
result.bytesCopied += sourceInfo.Size
|
||||
}
|
||||
if result.objectsFailed > 0 {
|
||||
if verifyOnly {
|
||||
return result, fmt.Errorf("校验失败:%d 个对象缺失、内容不匹配或无法检查", result.objectsFailed)
|
||||
}
|
||||
return result, fmt.Errorf("同步未完成:%d 个对象处理失败", result.objectsFailed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func targetMatches(ctx context.Context, target *minio.Client, bucket, key string, source minio.ObjectInfo) (bool, error) {
|
||||
targetInfo, err := target.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("读取目标对象 %s 元数据失败: %w", key, err)
|
||||
}
|
||||
if targetInfo.Size != source.Size {
|
||||
return false, nil
|
||||
}
|
||||
return sameETag(source.ETag, targetInfo.ETag), nil
|
||||
}
|
||||
|
||||
func copyObject(ctx context.Context, source *minio.Client, sourceBucket string, target *minio.Client, targetBucket, key string, info minio.ObjectInfo) error {
|
||||
object, err := source.GetObject(ctx, sourceBucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取源对象 %s 失败: %w", key, err)
|
||||
}
|
||||
defer func() { _ = object.Close() }()
|
||||
_, err = target.PutObject(ctx, targetBucket, key, object, info.Size, minio.PutObjectOptions{
|
||||
ContentType: info.ContentType,
|
||||
UserMetadata: info.UserMetadata,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("复制对象 %s 到目标 OSS 失败: %w", key, err)
|
||||
}
|
||||
verified, err := targetMatches(ctx, target, targetBucket, key, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !verified {
|
||||
return fmt.Errorf("复制对象 %s 后校验失败", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameETag(left, right string) bool {
|
||||
return strings.Trim(strings.ToLower(left), "\"") == strings.Trim(strings.ToLower(right), "\"")
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
response := minio.ToErrorResponse(err)
|
||||
switch response.Code {
|
||||
case "NoSuchKey", "NoSuchObject", "NoSuchBucket", "NotFound":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user