package activityclient import ( "context" "encoding/json" "fmt" "io" "net/http" "time" "91porn-server/app/appg" "91porn-server/common" "91porn-server/common/httputil" "91porn-server/common/log" ) // 抵扣券状态回调(与 activity-public-server /api/app/deduct/coupon/notify 约定一致)。 // 券状态流转由本服双回调驱动:建单成功->used、支付成功->verified。 const ( DeductCouponStatusUsed = "used" // 建单成功: 券 active -> used(下单占用/已使用),需携 deductAmount DeductCouponStatusVerified = "verified" // 支付成功: 券 used -> verified(已核销),usedCount++ DeductCouponStatusFailed = "failed" // 支付失败: 券 used -> active(释放) DeductCouponStatusClosed = "closed" // 订单关闭/超时: 券 used -> active(释放) ) const deductCouponNotifyPath = "/api/app/deduct/coupon/notify" // deductCouponNotifyReq 支付结果回调请求体 type deductCouponNotifyReq struct { CouponID string `json:"couponId"` // 下单时收到的券号,原样回传 OrderID string `json:"orderId,omitempty"` // 本服订单号,活动服存档对账用 Status string `json:"status"` // paid/failed/closed DeductAmount int64 `json:"deductAmount,omitempty"` // 实际抵扣金额(分),对账用 } // NotifyDeductCoupon 向活动服回调抵扣券支付结果。 // 鉴权走方向B(X-Svc-*),密钥用 secretKey 原始字节(BuildSvcHeaders 已按此实现)。 // 单次调用;活动服对 couponId 幂等,重复回调安全。 // 返回 nil 表示活动服已受理(HTTP 200 且 body.code==200)。 func NotifyDeductCoupon(ctx context.Context, couponID, orderID, status string, deductAmount int64) error { conf := appg.Conf.ActivityServer if conf.ApiUrl == "" || conf.AppId == "" { return fmt.Errorf("活动服配置缺失(apiUrl/appId)") } if couponID == "" { return fmt.Errorf("couponId 为空") } body, err := json.Marshal(deductCouponNotifyReq{ CouponID: couponID, OrderID: orderID, Status: status, DeductAmount: deductAmount, }) if err != nil { return fmt.Errorf("marshal notify body failed: %w", err) } // 签名针对下面实际发送的 body 字节;httputil 对 []byte 原样透传,两者一致 headers := BuildSvcHeaders(conf.AppId, conf.SecretKey, "POST", deductCouponNotifyPath, "", body) if headers == nil { return fmt.Errorf("生成服务间签名头失败") } headers["Content-Type"] = "application/json" resp, err := httputil.DefaultClientPostWithCtx(ctx, conf.ApiUrl+deductCouponNotifyPath, headers, body) if err != nil { return fmt.Errorf("请求活动服失败: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("活动服回调HTTP状态异常: %d, body: %s", resp.StatusCode, string(respBody)) } var out struct { Code int `json:"code"` Msg string `json:"msg"` } if err := json.Unmarshal(respBody, &out); err != nil { return fmt.Errorf("解析活动服回调响应失败: %w, body: %s", err, string(respBody)) } if out.Code != 200 { return fmt.Errorf("活动服回调返回非成功: code=%d, msg=%s", out.Code, out.Msg) } return nil } // NotifyDeductCouponAsync 异步回调抵扣券状态,失败按 1s/3s/5s/10s 退避重试。 // 独立 goroutine 执行,不阻塞下单/支付主流程;最终仍失败仅记录错误—— // 活动服会在 used 券超 30 分钟未核销时由定时任务兜底释放回 active,不会丢一致性。 func NotifyDeductCouponAsync(couponID, orderID, status string, deductAmount int64) { if couponID == "" { return } common.Go(func() { backoffs := []time.Duration{0, time.Second, 3 * time.Second, 5 * time.Second, 10 * time.Second} var lastErr error for i, d := range backoffs { if d > 0 { time.Sleep(d) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) lastErr = NotifyDeductCoupon(ctx, couponID, orderID, status, deductAmount) cancel() if lastErr == nil { if i > 0 { log.Info("活动服-抵扣券回调重试成功", log.Any("couponId", couponID), log.Any("status", status), log.Any("attempt", i+1)) } return } log.Warn("活动服-抵扣券回调失败,待重试", log.Any("couponId", couponID), log.Any("orderId", orderID), log.Any("status", status), log.Any("attempt", i+1), log.E(lastErr)) } log.Error("活动服-抵扣券回调最终失败(依赖活动服到期兜底释放)", log.Any("couponId", couponID), log.Any("orderId", orderID), log.Any("status", status), log.Any("deductAmount", deductAmount), log.E(lastErr)) }) }