package service import ( "encoding/json" "errors" "fmt" "io" "log" "net/http" "strconv" "strings" "sync" "time" "github.com/google/uuid" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" ) // PayService jiu ↔ pay 收款中枢对接(契约 ~/code/pay-contract openapi.yaml v1.0.0)。 // 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。 type PayService struct { db *gorm.DB baseURL string secret string retURL string client *http.Client prodMu sync.Mutex prodCache map[string]int64 // biz_code -> pay product_id prodAt time.Time } var ( ErrPayNotConfigured = errors.New("在线支付未配置") ErrUnknownPlan = errors.New("未知套餐") ErrPaySignature = errors.New("签名校验失败") ErrPayAmount = errors.New("回调金额与订单不符") ErrPurchaseNotFound = errors.New("购买记录不存在") ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过") ) // PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。 const PromoBizCode = "promo_first_month" // payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。 type payPlan struct { Days int Tier string Type string // License.Type: monthly | annual MaxDevices int Features model.JSON } var payPlans = map[string]payPlan{ PromoBizCode: {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2, Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}}, "monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2, Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}}, "annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2, Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}}, "monthly_pro": {Days: 30, Tier: "pro", Type: "monthly", MaxDevices: 5, Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}}, "annual_pro": {Days: 365, Tier: "pro", Type: "annual", MaxDevices: 5, Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}}, } func NewPayService(db *gorm.DB, baseURL, secret, returnURL string) *PayService { return &PayService{ db: db, baseURL: strings.TrimRight(baseURL, "/"), secret: secret, retURL: returnURL, client: &http.Client{Timeout: 10 * time.Second}, } } func (s *PayService) Configured() bool { return s.secret != "" } // ---------- ① 购买下单 ---------- type PurchaseResult struct { PayURL string `json:"pay_url"` OutTradeNo string `json:"out_trade_no"` Amount string `json:"amount"` Subject string `json:"subject"` } // CreatePurchase 建购买记录并调 pay 下单,返回收银台跳转 URL。 func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*PurchaseResult, error) { if !s.Configured() { return nil, ErrPayNotConfigured } if _, ok := payPlans[bizCode]; !ok { return nil, ErrUnknownPlan } if bizCode == PromoBizCode { used, err := s.PromoUsed(shopID) if err != nil { return nil, err } if used { return nil, ErrPromoUsed } } productID, err := s.productID(bizCode) if err != nil { return nil, fmt.Errorf("获取套餐信息失败: %w", err) } p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"} if err := s.db.Create(&p).Error; err != nil { return nil, err } reqBody, _ := json.Marshal(map[string]any{ "product_id": productID, "biz_system": "jiu", "biz_ref": strconv.FormatUint(p.ID, 10), "return_url": s.retURL, }) respBody, err := s.signedPost("/api/v1/orders", reqBody) if err != nil { return nil, fmt.Errorf("pay 下单失败: %w", err) } var resp struct { Data PurchaseResult `json:"data"` } if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.PayURL == "" || resp.Data.OutTradeNo == "" { return nil, fmt.Errorf("pay 下单响应异常") } if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{ "out_trade_no": resp.Data.OutTradeNo, "amount": resp.Data.Amount, }).Error; err != nil { return nil, err } return &resp.Data, nil } // signedPost 按契约对原始 body 签名后 POST 到 pay。 func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) { req, err := http.NewRequest(http.MethodPost, s.baseURL+path, strings.NewReader(string(rawBody))) if err != nil { return nil, err } ts := strconv.FormatInt(time.Now().Unix(), 10) nonce := uuid.New().String() req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Pay-System", "jiu") req.Header.Set("X-Pay-Timestamp", ts) req.Header.Set("X-Pay-Nonce", nonce) req.Header.Set("X-Pay-Sign", util.PaySign(s.secret, "jiu", ts, nonce, string(rawBody))) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("pay HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } return body, nil } // productID 按 biz_code 查 pay 套餐 id(GET /api/v1/products,内存缓存 10 分钟)。 func (s *PayService) productID(bizCode string) (int64, error) { s.prodMu.Lock() defer s.prodMu.Unlock() if s.prodCache != nil && time.Since(s.prodAt) < 10*time.Minute { if id, ok := s.prodCache[bizCode]; ok { return id, nil } } resp, err := s.client.Get(s.baseURL + "/api/v1/products") if err != nil { return 0, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("pay HTTP %d", resp.StatusCode) } var pr struct { Data []struct { ID int64 `json:"id"` BizCode string `json:"biz_code"` } `json:"data"` } if err := json.Unmarshal(body, &pr); err != nil { return 0, err } cache := make(map[string]int64, len(pr.Data)) for _, p := range pr.Data { if p.BizCode != "" { cache[p.BizCode] = p.ID } } s.prodCache, s.prodAt = cache, time.Now() id, ok := cache[bizCode] if !ok { return 0, fmt.Errorf("pay 侧无 biz_code=%s 的套餐", bizCode) } return id, nil } // ---------- ② webhook 入账 ---------- type payNotification struct { OutTradeNo string `json:"out_trade_no"` BizSystem string `json:"biz_system"` BizRef string `json:"biz_ref"` ProductBizCode string `json:"product_biz_code"` Amount string `json:"amount"` TradeNo string `json:"trade_no"` Channel string `json:"channel"` PaidAt string `json:"paid_at"` } // HandleCallback 验签 + 幂等 + 金额核对 + 续期。错误分两类: // ErrPaySignature(回 401,不重试也无效);其余(回非 SUCCESS,pay 会重试)。 func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) error { if !s.Configured() { return ErrPaySignature } if !util.PaySignVerify(s.secret, sign, "jiu", ts, nonce, string(rawBody)) { return ErrPaySignature } tsInt, err := strconv.ParseInt(ts, 10, 64) if err != nil { return ErrPaySignature } if d := time.Since(time.Unix(tsInt, 0)); d > 5*time.Minute || d < -5*time.Minute { return ErrPaySignature } var n payNotification if err := json.Unmarshal(rawBody, &n); err != nil || n.OutTradeNo == "" { return fmt.Errorf("回调体解析失败") } paidAt := time.Now() if t, err := time.Parse(time.RFC3339, n.PaidAt); err == nil { paidAt = t } return s.settle(n.OutTradeNo, n.ProductBizCode, n.Amount, n.TradeNo, n.Channel, paidAt) } // settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对 + 同事务续期。 // webhook 与查单兜底共用此入口。 func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string, paidAt time.Time) error { var shopID uint64 err := s.db.Transaction(func(tx *gorm.DB) error { var p model.LicensePurchase if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("out_trade_no = ?", outTradeNo).First(&p).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrPurchaseNotFound } return err } if p.Status == "paid" { // 幂等:pay 会重发 return nil } if !amountEqual(p.Amount, amount) { log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.Amount, amount) return ErrPayAmount } // 权益按建单时的套餐映射;回调 biz_code 仅一致性校验(不一致以本地为准并告警) if bizCode != "" && bizCode != p.ProductBizCode { log.Printf("[pay] biz_code mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.ProductBizCode, bizCode) } plan, ok := payPlans[p.ProductBizCode] if !ok { return ErrUnknownPlan } // 特惠限次 webhook 兜底(防绕过前端/并发双买):入账前事务内再查一次, // 本店已有另一笔已支付特惠单 → 本单只标 paid 不叠加时长,记告警(契约 INTEGRATION-BOARD 要求) entitleOK := true if p.ProductBizCode == PromoBizCode { var dup int64 if err := tx.Model(&model.LicensePurchase{}). Where("shop_id = ? AND product_biz_code = ? AND status = ? AND id <> ?", p.ShopID, PromoBizCode, "paid", p.ID). Count(&dup).Error; err != nil { return err } if dup > 0 { entitleOK = false log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID) } } if entitleOK { if err := entitle(tx, p.ShopID, plan); err != nil { return err } } shopID = p.ShopID return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{ "status": "paid", "trade_no": tradeNo, "channel": channel, "paid_at": paidAt, }).Error }) if err != nil { return err } if shopID != 0 { // 事务提交后失效授权 phase 缓存,写权限即时恢复(同 Redeem) middleware.InvalidateLicensePhase(shopID) log.Printf("[pay] settled out_trade_no=%s shop=%d", outTradeNo, shopID) } return nil } // entitle 给门店续期:时长直接叠加(未过期从到期日叠,已过期从现在起算), // 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。 func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error { now := time.Now() var lic model.License err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND is_active = ?", shopID, true). Order("id DESC").First(&lic).Error creating := errors.Is(err, gorm.ErrRecordNotFound) if err != nil && !creating { return err } base := now if !creating && lic.ExpiresAt != nil && lic.ExpiresAt.After(now) { base = *lic.ExpiresAt } expires := base.Add(time.Duration(plan.Days) * 24 * time.Hour) if creating { lic = model.License{ ShopID: shopID, LicenseKey: "PAY-" + uuid.New().String(), Type: plan.Type, Tier: plan.Tier, ExpiresAt: &expires, IsActive: true, MaxDevices: plan.MaxDevices, Features: plan.Features, } return tx.Create(&lic).Error } return tx.Model(&lic).Updates(map[string]any{ "type": plan.Type, "tier": plan.Tier, "expires_at": expires, "is_active": true, "max_devices": plan.MaxDevices, "features": plan.Features, }).Error } // amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。 func amountEqual(a, b string) bool { ca, ea := toCents(a) cb, eb := toCents(b) return ea == nil && eb == nil && ca == cb } func toCents(s string) (int64, error) { s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty amount") } neg := false if strings.HasPrefix(s, "-") { neg, s = true, s[1:] } intPart, frac, _ := strings.Cut(s, ".") if intPart == "" { intPart = "0" } frac = frac + "00" i, err := strconv.ParseInt(intPart, 10, 64) if err != nil { return 0, err } f, err := strconv.ParseInt(frac[:2], 10, 64) if err != nil { return 0, err } c := i*100 + f if neg { c = -c } return c, nil } // ---------- ③ 状态查询(结果页轮询) ---------- type PurchaseStatus struct { OutTradeNo string `json:"out_trade_no"` Status string `json:"status"` BizCode string `json:"product_biz_code"` Amount string `json:"amount"` PaidAt *time.Time `json:"paid_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间 } func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus, error) { var p model.LicensePurchase if err := s.db.Where("shop_id = ? AND out_trade_no = ?", shopID, outTradeNo).First(&p).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrPurchaseNotFound } return nil, err } st := &PurchaseStatus{OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode, Amount: p.Amount, PaidAt: p.PaidAt} if p.Status == "paid" { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true). Order("id DESC").First(&lic).Error; err == nil { st.ExpiresAt = lic.ExpiresAt } } return st, nil } // PromoUsed 返回本店是否已享受过首月特惠(已支付的特惠单存在即视为已用)。 func (s *PayService) PromoUsed(shopID uint64) (bool, error) { var count int64 err := s.db.Model(&model.LicensePurchase{}). Where("shop_id = ? AND product_biz_code = ? AND status = ?", shopID, PromoBizCode, "paid"). Count(&count).Error return count > 0, err } // ---------- ④ 查单兜底 ---------- // StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账, // 防 webhook 全丢。与 webhook 同一入账入口(settle),天然幂等。 func StartPayReconcile(s *PayService) { if !s.Configured() { log.Println("[pay] PAY_SECRET 未配置,查单兜底不启动") return } go func() { for { time.Sleep(time.Minute) s.reconcileOnce() } }() } func (s *PayService) reconcileOnce() { var pendings []model.LicensePurchase cutoff := time.Now().Add(-5 * time.Minute) if err := s.db.Where("status = ? AND out_trade_no <> '' AND created_at < ?", "pending", cutoff). Limit(50).Find(&pendings).Error; err != nil { return } for _, p := range pendings { st, err := s.queryOrder(p.OutTradeNo) if err != nil { continue } switch st.Status { case "paid": paidAt := time.Now() if st.PaidAt != nil { paidAt = *st.PaidAt } if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.Amount, st.TradeNo, "", paidAt); err != nil { log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err) } else { log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo) } case "closed": s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID). Update("status", "failed") case "refunded": // 本轮不冲权益,仅记录(退款处理后续设计) log.Printf("[pay] order refunded out_trade_no=%s (no-op)", p.OutTradeNo) } } } type payOrderStatus struct { OutTradeNo string `json:"out_trade_no"` Amount string `json:"amount"` Status string `json:"status"` // pending | paid | closed | refunded TradeNo string `json:"trade_no"` PaidAt *time.Time `json:"paid_at"` } // queryOrder 查单(契约未要求签名头)。 func (s *PayService) queryOrder(outTradeNo string) (*payOrderStatus, error) { resp, err := s.client.Get(s.baseURL + "/api/v1/orders/" + outTradeNo) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("pay HTTP %d", resp.StatusCode) } var r struct { Data payOrderStatus `json:"data"` } if err := json.Unmarshal(body, &r); err != nil { return nil, err } return &r.Data, nil }