chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+214
View File
@@ -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)
}
+228
View File
@@ -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
}
+183
View File
@@ -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
}
+228
View File
@@ -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
}
}