feat: 微信V3 + 支付宝手机站/UA自适应 + 业务对接(签名下单/webhook) + 通用多业务

- 微信支付 V3 Native 渠道 (wechat.go):Native下单/回调AES-GCM解密验签/查单
- 支付宝:手机网站支付 wap.pay + 按 UA 自适应(PC page.pay扫码 / 手机拉App);qr_pay_mode=2 完整扫码收银台
- 业务对接:下单接口扩展(biz_system/biz_ref/return_url)+ HMAC 签名鉴权;支付成功 webhook 主动推送业务方 + 60s 重试 + BizNotifyLog
- 通用多业务:config.biz 改 map,加业务只改配置(BIZ_<SYS>_SECRET/_CALLBACK_URL)
- seedPlans:四档真实套餐 + promo_first_month(¥1) + test_liandiao(0.01),均挂 biz_code;/products 暴露 biz_code
- 删除沙箱 pay.html;对接设计文档入 docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
This commit is contained in:
2026-07-03 22:44:34 +08:00
parent 6da6964451
commit fd5f56f7de
22 changed files with 861 additions and 122 deletions
+85
View File
@@ -22,6 +22,7 @@ func main() {
db := initDB()
autoMigrate(db)
seed(db)
seedWechat(db)
reg := channel.NewRegistry(db)
@@ -39,6 +40,9 @@ func main() {
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 {
@@ -75,6 +79,7 @@ func autoMigrate(db *gorm.DB) {
&model.Product{},
&model.Order{},
&model.NotifyLog{},
&model.BizNotifyLog{},
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}
@@ -121,4 +126,84 @@ func seed(db *gorm.DB) {
}
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) {
plans := []model.Product{
{Name: "联调测试套餐", Description: "1 分钱端到端联调用(标准权益)", Price: "0.01", BizCode: "test_liandiao", Sort: 0},
{Name: "限时优惠 · 1元首月体验", 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))
}
}