aa14204640
Task 3 计划的 RefundSum→比较→CreateRefund 两段式裸读写在并发下会超退(两个请求都读到 reserved=0、都通过、都插入)。仿 store/order.go::MarkAttemptPaid 的模式,把锁订单行 (clause.Locking FOR UPDATE,MySQL 真锁/SQLite 由 glebarez 静默丢弃)+ 求和 + 校验 + 插入 收进单个 s.db.Transaction。SQLite 侧真正的串行化靠 DSN _txlock=immediate(BEGIN IMMEDIATE 在事务开始就抢写锁)+ busy_timeout,补进 testdb.go 与 main.go 的 sqlite DSN 构造。
258 lines
9.9 KiB
Go
258 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/wangjia/pay/config"
|
|
"github.com/wangjia/pay/internal/accounts"
|
|
"github.com/wangjia/pay/internal/channel"
|
|
"github.com/wangjia/pay/internal/gateway"
|
|
"github.com/wangjia/pay/internal/model"
|
|
"github.com/wangjia/pay/internal/providerbuild"
|
|
"github.com/wangjia/pay/internal/router"
|
|
"github.com/wangjia/pay/internal/store"
|
|
"github.com/wangjia/pay/internal/webhook"
|
|
)
|
|
|
|
func main() {
|
|
config.Load()
|
|
|
|
db := initDB()
|
|
autoMigrate(db)
|
|
seed(db)
|
|
seedWechat(db)
|
|
|
|
reg := channel.NewRegistry(db)
|
|
|
|
gin.SetMode(config.C.Server.Mode)
|
|
r := gin.New()
|
|
r.Use(gin.Logger(), gin.Recovery())
|
|
|
|
orderSvc := router.Setup(r, db, reg)
|
|
|
|
// v2 统一网关装配(P2 骨架,P3 配置驱动注册接线):provider 注册表 + gateway + webhook notifier。
|
|
orderStore := store.NewOrderStore(db)
|
|
webhookStore := store.NewWebhookStore(db)
|
|
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
|
|
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return o.Status.Settled(), nil
|
|
})
|
|
notifier.Start(60 * time.Second)
|
|
productResolver := gateway.NewDBProductResolver(db) // 币种由下单渠道结算能力驱动(设计 §4.1)
|
|
acctReg := accounts.New(config.C.Accounts)
|
|
pReg := providerbuild.BuildRegistry(acctReg) // 配置驱动:有 enabled 账户才注册对应渠道(P3)
|
|
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
|
|
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
|
|
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, accounts.NopUsage{})
|
|
gw := gateway.New(orderStore, pReg, acctPicker, productResolver, notifier, "cn")
|
|
router.SetupV2(r, gw)
|
|
|
|
if config.C.QuerySync.Enabled {
|
|
orderSvc.StartQuerySync(
|
|
time.Duration(config.C.QuerySync.IntervalSec)*time.Second,
|
|
time.Duration(config.C.QuerySync.MaxAgeMin)*time.Minute,
|
|
)
|
|
log.Printf("查单兜底已启动:每 %ds 一次", config.C.QuerySync.IntervalSec)
|
|
}
|
|
|
|
// 业务回调重试兜底(webhook 丢失/业务方短暂不可用时补推)。
|
|
orderSvc.StartBizNotifyRetry(60 * time.Second)
|
|
|
|
addr := ":" + config.C.Server.Port
|
|
log.Printf("支付服务启动 %s (mode=%s, base_url=%s)", addr, config.C.Server.Mode, config.C.Server.BaseURL)
|
|
if err := r.Run(addr); err != nil {
|
|
log.Fatalf("启动失败: %v", err)
|
|
}
|
|
}
|
|
|
|
func initDB() *gorm.DB {
|
|
logLevel := logger.Silent
|
|
if config.C.Server.Mode == "debug" {
|
|
logLevel = logger.Info
|
|
}
|
|
gormCfg := &gorm.Config{Logger: logger.Default.LogMode(logLevel), TranslateError: true}
|
|
|
|
switch config.C.Database.Driver {
|
|
case "sqlite", "":
|
|
db, err := gorm.Open(sqlite.Open(sqliteDSN(config.C.Database.DSN)), gormCfg)
|
|
if err != nil {
|
|
log.Fatalf("连接 sqlite 失败: %v", err)
|
|
}
|
|
return db
|
|
case "mysql":
|
|
log.Fatal("mysql 驱动尚未启用:go get gorm.io/driver/mysql 后在 initDB 接入")
|
|
return nil
|
|
default:
|
|
log.Fatalf("未知 database.driver: %s", config.C.Database.Driver)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// sqliteDSN 补 _txlock=immediate(BEGIN IMMEDIATE 立即抢库级写锁,串行化并发事务的
|
|
// 读阶段——RefundStore.CreateRefundGuarded 等守卫类事务依赖此语义)+
|
|
// busy_timeout(5000)(抢不到锁时等待重试而非立即报 SQLITE_BUSY)。呼应 pangolin
|
|
// 的 SQLite DSN 约定(server/internal/db/db.go)。config.yaml 里的 dsn 是纯文件路径
|
|
// (如 "pay.db"),这里补上查询串;若用户已经自带 "?",追加而不是覆盖。
|
|
func sqliteDSN(raw string) string {
|
|
extra := "_txlock=immediate&_pragma=busy_timeout(5000)"
|
|
if strings.Contains(raw, "?") {
|
|
return raw + "&" + extra
|
|
}
|
|
return raw + "?" + extra
|
|
}
|
|
|
|
func autoMigrate(db *gorm.DB) {
|
|
if err := db.AutoMigrate(
|
|
&model.Merchant{},
|
|
&model.Product{},
|
|
&model.ProductPrice{},
|
|
&model.Order{},
|
|
&model.NotifyLog{},
|
|
&model.BizNotifyLog{},
|
|
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, // v2
|
|
); err != nil {
|
|
log.Fatalf("自动迁移失败: %v", err)
|
|
}
|
|
// AutoMigrate 只按名字判断索引是否存在,不会把老库同名的 2 列 uq_delivery
|
|
// 加宽到当前模型的 3 列(out_trade_no,event_type,refund_id);显式补一刀。
|
|
if err := model.UpgradeWebhookDeliveryIndex(db); err != nil {
|
|
log.Fatalf("升级 uq_delivery 索引失败: %v", err)
|
|
}
|
|
log.Println("AutoMigrate 完成")
|
|
}
|
|
|
|
// seed 据 config 的 alipay_sandbox upsert 一个支付宝商户,并为其补两个测试套餐。
|
|
func seed(db *gorm.DB) {
|
|
sb := config.C.AlipaySandbox
|
|
if !sb.Enabled {
|
|
log.Println("[seed] alipay_sandbox.enabled=false,跳过沙箱商户初始化")
|
|
return
|
|
}
|
|
var m model.Merchant
|
|
err := db.Where("code = ?", sb.MerchantCode).First(&m).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
m = model.Merchant{
|
|
Code: sb.MerchantCode,
|
|
Name: sb.MerchantName,
|
|
Channel: "alipay",
|
|
}
|
|
} else if err != nil {
|
|
log.Fatalf("[seed] 查询商户失败: %v", err)
|
|
}
|
|
m.Production = sb.Production
|
|
m.Enabled = true
|
|
m.AppID = sb.AppID
|
|
m.AppPrivateKey = sb.AppPrivateKey
|
|
m.AlipayPublicKey = sb.AlipayPublicKey
|
|
if err := db.Save(&m).Error; err != nil {
|
|
log.Fatalf("[seed] 保存商户失败: %v", err)
|
|
}
|
|
log.Printf("[seed] 支付宝沙箱商户就绪: code=%s app_id=%s", m.Code, m.AppID)
|
|
|
|
var cnt int64
|
|
db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt)
|
|
if cnt == 0 {
|
|
samples := []model.Product{
|
|
{MerchantID: m.ID, Name: "测试套餐 A", Description: "沙箱联调用", Price: "0.01", Active: true, Sort: 1},
|
|
{MerchantID: m.ID, Name: "测试套餐 B", Description: "沙箱联调用", Price: "0.02", Active: true, Sort: 2},
|
|
}
|
|
if err := db.Create(&samples).Error; err != nil {
|
|
log.Fatalf("[seed] 创建测试套餐失败: %v", err)
|
|
}
|
|
log.Printf("[seed] 已为商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
|
|
}
|
|
|
|
seedPlans(db, m.ID)
|
|
}
|
|
|
|
// seedPlans 幂等 upsert 业务套餐(带 biz_code,供 jiu 等业务系统按 biz_code 查 product)。
|
|
// 价格权威在此表;权益(时长/档位/设备/功能)由业务方按 biz_code 映射。
|
|
func seedPlans(db *gorm.DB, merchantID uint64) {
|
|
// Name 会成为支付宝收银台的 subject(一行约 12-14 汉字可见):
|
|
// 带品牌前缀、短句式,避免截断(2026-07-04 用户反馈收银台信息太少/被截断)。
|
|
plans := []model.Product{
|
|
{Name: "岩美酒库·联调测试", Description: "1 分钱端到端联调用(标准权益)", Price: "0.01", BizCode: "test_liandiao", Sort: 0},
|
|
{Name: "岩美酒库·首月特惠30天", Description: "标准权益 · 30 天 · 每门店限一次(限次由 jiu 侧校验)", Price: "1.00", BizCode: "promo_first_month", Sort: 1},
|
|
{Name: "岩美酒库·标准版月付", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "299.00", BizCode: "monthly_standard", Sort: 2},
|
|
{Name: "岩美酒库·高级版月付", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "599.00", BizCode: "monthly_pro", Sort: 3},
|
|
{Name: "岩美酒库·标准版年付", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "2999.00", BizCode: "annual_standard", Sort: 4},
|
|
{Name: "岩美酒库·高级版年付", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "5999.00", BizCode: "annual_pro", Sort: 5},
|
|
}
|
|
for _, p := range plans {
|
|
var existing model.Product
|
|
err := db.Where("biz_code = ?", p.BizCode).First(&existing).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
p.MerchantID = merchantID
|
|
p.Active = true
|
|
if err := db.Create(&p).Error; err != nil {
|
|
log.Fatalf("[seed] 创建套餐 %s 失败: %v", p.BizCode, err)
|
|
}
|
|
continue
|
|
} else if err != nil {
|
|
log.Fatalf("[seed] 查询套餐 %s 失败: %v", p.BizCode, err)
|
|
}
|
|
// 已存在:更新价格/文案/归属(价格权威可从此改)。
|
|
db.Model(&existing).Updates(map[string]any{
|
|
"merchant_id": merchantID, "name": p.Name, "description": p.Description,
|
|
"price": p.Price, "active": true, "sort": p.Sort,
|
|
})
|
|
}
|
|
log.Printf("[seed] 业务套餐就绪:%d 个(含联调 0.01)", len(plans))
|
|
}
|
|
|
|
// seedWechat 据 config 的 wechat upsert 一个微信商户(V3 Native),并为其补两个测试套餐。
|
|
// 微信无沙箱:wechat.enabled=true 即视为正式(Production=true),只能用真实商户号做 1 分钱实测。
|
|
func seedWechat(db *gorm.DB) {
|
|
wc := config.C.Wechat
|
|
if !wc.Enabled {
|
|
log.Println("[seed] wechat.enabled=false,跳过微信商户初始化")
|
|
return
|
|
}
|
|
var m model.Merchant
|
|
err := db.Where("code = ?", wc.MerchantCode).First(&m).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
m = model.Merchant{
|
|
Code: wc.MerchantCode,
|
|
Name: wc.MerchantName,
|
|
Channel: "wechat",
|
|
}
|
|
} else if err != nil {
|
|
log.Fatalf("[seed] 查询微信商户失败: %v", err)
|
|
}
|
|
m.Production = true // 微信无沙箱,一律正式网关
|
|
m.Enabled = true
|
|
m.MchID = wc.MchID
|
|
m.WxAppID = wc.AppID
|
|
m.CertSerial = wc.CertSerial
|
|
m.WxPrivateKey = wc.PrivateKey
|
|
m.APIv3Key = wc.APIv3Key
|
|
if err := db.Save(&m).Error; err != nil {
|
|
log.Fatalf("[seed] 保存微信商户失败: %v", err)
|
|
}
|
|
log.Printf("[seed] 微信商户就绪: code=%s mch_id=%s", m.Code, m.MchID)
|
|
|
|
var cnt int64
|
|
db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt)
|
|
if cnt == 0 {
|
|
samples := []model.Product{
|
|
{MerchantID: m.ID, Name: "微信测试套餐 A", Description: "微信联调用", Price: "0.01", Active: true, Sort: 1},
|
|
{MerchantID: m.ID, Name: "微信测试套餐 B", Description: "微信联调用", Price: "0.02", Active: true, Sort: 2},
|
|
}
|
|
if err := db.Create(&samples).Error; err != nil {
|
|
log.Fatalf("[seed] 创建微信测试套餐失败: %v", err)
|
|
}
|
|
log.Printf("[seed] 已为微信商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
|
|
}
|
|
}
|