Files
sundynix-agentix/sundynix-gateway/internal/handler/billing_pay.go
T
Blizzard 68608c1592 feat(wechat): 支付成功后微信客服消息推回执(积分包/订阅购买)
用户扫码付款后(在 48h 互动窗口内),主动推一条客服消息回执:
到账积分/开通套餐 + 当前余额。只在 MarkOrderPaid changed=true 首次到账时推,
异步+超时隔离,失败只记日志、绝不影响入账。非微信用户自动跳过。

不做「周期刷新提醒」:客服消息受 48h 窗口限制,定时刷新那刻用户多半已超窗、
必然失败,那类隔天提醒须用模板消息(暂缓)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:23:41 +08:00

415 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// 充值(P5.1:兑换码渠道;设计见 PAYMENT_DESIGN.md)。
// 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账,
// 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。
// BillingPacks: GET /api/v1/billing/packs —— 在售积分包 + 可用渠道(wechat 配了 env 才亮)。
func (h *Handler) BillingPacks(c *gin.Context) {
packs, err := h.db.ActivePacks(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 兑换码渠道始终可用(无需配置);真渠道由 Manager 报告哪些已启用。
channels := append([]string{store.ChannelRedeem}, h.pay.Available()...)
c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": channels})
}
// orderTTL 待支付订单的有效期:过期后前端轮询会把它置 expired,不再确认到账。
// 微信 Native 的 code_url 本身约 2 小时有效,这里收紧到 30 分钟——挂太久的单
// 价格可能已经改过,不让旧价格的单无限期可付。
const orderTTL = 30 * time.Minute
// BillingCreateOrder: POST /api/v1/billing/orders {pack_id, channel?} —— 真渠道下单,返回支付凭据。
// channel 缺省 wechat(当前唯一真渠道);金额/积分由服务端按在售包锁定进订单行,前端只传包 id,
// 不信任任何客户端金额。订单记下所用渠道,后续查单/回调/补偿据此路由。
func (h *Handler) BillingCreateOrder(c *gin.Context) {
var b struct {
PackID string `json:"pack_id"`
PlanID string `json:"plan_id"` // 传它=买订阅周期;与 pack_id 二选一
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&b); err != nil || (strings.TrimSpace(b.PackID) == "" && strings.TrimSpace(b.PlanID) == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 或 plan_id 必填"})
return
}
channel := strings.TrimSpace(b.Channel)
if channel == "" {
channel = store.ChannelWechat
}
ch := h.pay.Get(channel)
if ch == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "渠道 " + channel + " 未启用,请换渠道或用兑换码充值"})
return
}
ctx := c.Request.Context()
uid := userID(c)
billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c))
if billing == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return
}
// 订阅单与积分包单走同一条支付链路:只有订单内容不同,下单/回调/查单/掉单补偿全复用。
var o *store.PaymentOrder
var desc string
if pid := strings.TrimSpace(b.PlanID); pid != "" {
pl := h.db.GetSubPlan(ctx, pid)
if pl == nil || !pl.Active {
c.JSON(http.StatusBadRequest, gin.H{"error": "订阅套餐不存在或已下架"})
return
}
// 订阅单 credits_micro 恒为 0:积分不在付款时一次给,而是订阅期内按周期发放。
o = &store.PaymentOrder{
TenantID: billing, UserID: uid, Kind: store.OrderKindSub, PlanID: pl.ID,
AmountFen: pl.PriceFen, CreditsMicro: 0,
Channel: channel, Status: store.OrderPending,
}
desc = "sundynix 订阅 · " + pl.Name
} else {
pk, err := h.db.GetPack(ctx, b.PackID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
return
}
o = &store.PaymentOrder{
TenantID: billing, UserID: uid, PackID: pk.ID, Kind: store.OrderKindPack,
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
Channel: channel, Status: store.OrderPending,
}
desc = "sundynix 积分充值 · " + pk.Name
}
if err := h.db.CreateOrder(ctx, o); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
intent, err := ch.CreatePay(ctx, o.ID, desc, o.AmountFen)
if err != nil {
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
_ = h.db.ExpireOrder(ctx, o.ID)
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
return
}
// expires_at 由后端下发:二维码有效期是服务端的 orderTTL 说了算,前端硬编码一份迟早漂移。
c.JSON(http.StatusOK, gin.H{
"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen,
"expires_at": o.CreatedAt.Add(orderTTL),
})
}
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
// pending 时顺路主动查单确认(本地/内网收不到公网回调也能到账——回调只是生产更快的通道,
// 两条路汇入同一个 MarkOrderPaid 幂等闸);超过 TTL 置 expired。
func (h *Handler) BillingOrderStatus(c *gin.Context) {
ctx := c.Request.Context()
o, err := h.db.GetOrder(ctx, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
return
}
// 只允许看自己计费租户的单(订单表未挂租户插件,这里显式校验)。
if o.TenantID != h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c)) {
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
return
}
updated, mismatch := h.reconcileOrder(ctx, o)
if mismatch {
c.JSON(http.StatusOK, gin.H{"order": updated, "warn": "支付金额与订单不符,已挂起待人工核对"})
return
}
c.JSON(http.StatusOK, gin.H{"order": updated})
}
// reconcileOrder 对一张 pending 微信单主动查单并落态:已付且金额相符→入账(幂等闸),
// 渠道关单/超 TTL→过期。返回最新订单 + 是否金额不符(不符则不入账、留人工对账)。
// 前端轮询与掉单补偿定时器共用这一份,避免两处「查单→落态」逻辑漂移。
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
if o.Status != store.OrderPending || ch == nil {
forgetQuery(o.ID)
return o, false
}
// 节流:间隔内跳过真实查单,只走下面本地可判定的 TTL 过期。回调是主路径,
// 少查几次不影响到账,只影响「用户在场时的确认延迟」,最坏多等 5s。
if allowQuery(o.ID) {
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
switch {
case r.Paid && r.AmountFen == o.AmountFen:
if changed, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
if changed {
h.notifyOrderPaid(o.ID) // 首次到账才推回执,避免重复查单重复推送
}
o, _ = h.db.GetOrder(ctx, o.ID)
}
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
}
}
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
if o.Status != store.OrderPending {
forgetQuery(o.ID) // 已落终态,标记没用了
}
return o, false
}
// PaymentCallback: POST /api/v1/billing/callback/:channel —— 渠道支付回调(公开路由,验签是唯一的门)。
// 按 :channel 路由到对应适配器验签。应答契约当前为微信形态({code:SUCCESS}/{code:FAIL}):
// 入账成功/重复推送都回 200 SUCCESS;验签失败 4xx;处理失败 5xx 让渠道重试。
// 注:其它渠道的 ack 格式不同(如支付宝要求纯文本 "success"),接入时按渠道分应答即可。
func (h *Handler) PaymentCallback(c *gin.Context) {
ch := h.pay.Get(c.Param("channel"))
if ch == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "渠道未配置"})
return
}
r, err := ch.VerifyCallback(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "验签失败"})
return
}
if !r.Paid {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 非成功态通知:确认收到即可
return
}
ctx := c.Request.Context()
o, err := h.db.GetOrder(ctx, r.OrderID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 不认识的单:可能是别的环境,别让微信无限重试
return
}
if r.AmountFen != o.AmountFen {
// 金额不符:不入账、不让重试(重试也不会变对),落审计人工处理。
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
changed, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"})
return
}
if changed {
h.notifyOrderPaid(o.ID) // 首次到账才推回执(回调可能重复推送,靠 changed 去重)
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
}
// BillingRedeem: POST /api/v1/billing/redeem {code} —— 核销兑换码,积分入计费租户。
func (h *Handler) BillingRedeem(c *gin.Context) {
var b struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Code) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code 必填"})
return
}
ctx := c.Request.Context()
uid := userID(c)
billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c))
if billing == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return
}
order, err := h.db.Redeem(ctx, b.Code, billing, uid)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"order": order,
"balance_micro": h.db.TenantBalance(ctx, billing),
})
}
// BillingOrders: GET /api/v1/billing/orders —— 计费租户最近充值记录(账单页展示)。
func (h *Handler) BillingOrders(c *gin.Context) {
ctx := c.Request.Context()
billing := h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c))
rows, err := h.db.TenantOrders(ctx, billing, 20)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"orders": rows})
}
// AdminGenRedeemCodes: POST /api/v1/admin/redeem-codes {credits, count, memo} —— 批量生成兑换码。
// credits 单位:积分(面向人,非 micro)。
func (h *Handler) AdminGenRedeemCodes(c *gin.Context) {
var b struct {
Credits float64 `json:"credits"`
Count int `json:"count"`
Memo string `json:"memo"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.Credits <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "credits 必填且为正"})
return
}
if b.Count <= 0 {
b.Count = 1
}
codes, err := h.db.GenerateRedeemCodes(c.Request.Context(), b.Count, int64(b.Credits*1e6), b.Memo)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"codes": codes})
}
// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码台账(含核销状态)。
// 码在台账里脱敏只露首尾:完整明文只在生成响应里给一次。兑换码等同现金,
// 常驻可查的列表接口不该是第二个明文出口(丢了码就重新生成一张,不提供找回)。
func (h *Handler) AdminRedeemCodes(c *gin.Context) {
rows, err := h.db.ListRedeemCodes(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
for i := range rows {
if n := len(rows[i].Code); n > 12 {
rows[i].Code = rows[i].Code[:8] + "…" + rows[i].Code[n-4:]
}
}
c.JSON(http.StatusOK, gin.H{"codes": rows})
}
// AdminSavePack: PUT /api/v1/admin/packs {id?, name, credits, price_fen, active, sort} —— 配积分包。
func (h *Handler) AdminSavePack(c *gin.Context) {
var b struct {
ID string `json:"id"`
Name string `json:"name"`
Credits float64 `json:"credits"` // 积分(面向人)
PriceFen int64 `json:"price_fen"`
Active bool `json:"active"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
pk := &store.CreditPack{Name: strings.TrimSpace(b.Name), CreditsMicro: int64(b.Credits * 1e6), PriceFen: b.PriceFen, Active: b.Active, Sort: b.Sort}
pk.ID = b.ID
if err := h.db.SavePack(c.Request.Context(), pk); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"pack": pk})
}
// AdminPacks: GET /api/v1/admin/packs —— 全部积分包(含下架)。
func (h *Handler) AdminPacks(c *gin.Context) {
rows, err := h.db.ListPacks(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"packs": rows})
}
// AdminOrders: GET /api/v1/admin/orders?status= —— 全平台充值订单流 + 状态计数(P5.3 观测)。
func (h *Handler) AdminOrders(c *gin.Context) {
ctx := c.Request.Context()
rows, err := h.db.AllOrders(ctx, c.Query("status"), 50)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"orders": rows, "stats": h.db.OrderStats(ctx)})
}
// AdminReconcile: GET /api/v1/admin/orders/reconcile —— 日终对账(P5.3)。
// paid 订单 ↔ 账本 grant 分录逐单比对,列出对不上的(正常应为空)。
func (h *Handler) AdminReconcile(c *gin.Context) {
rows, err := h.db.ReconcileOrders(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
}
// AdminTasks: GET /api/v1/admin/tasks?status=&tenant=&limit= —— 全平台任务/运行观测。
// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。
func (h *Handler) AdminTasks(c *gin.Context) {
ctx := c.Request.Context()
limit := 50
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit)
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
}
// AdminTaskDetail: GET /api/v1/admin/tasks/:id —— 任务下钻(跨租户)。
// 不复用用户面的 /tasks/:id/replay:那条走请求 ctx,受租户插件过滤,管理员看别的租户
// 的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
func (h *Handler) AdminTaskDetail(c *gin.Context) {
d := h.db.TaskDetail(c.Request.Context(), c.Param("id"))
if d == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
// trace 是收尾时落库的事件数组原文;空串是正常情况(任务未跑完/早于该功能上线)。
exec := []json.RawMessage{}
if d.Trace != "" {
if err := json.Unmarshal([]byte(d.Trace), &exec); err != nil {
exec = []json.RawMessage{} // 脏数据不该让整个下钻 500
}
}
c.JSON(http.StatusOK, gin.H{"task": d, "exec": exec})
}
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
func (h *Handler) AdminSpaces(c *gin.Context) {
limit := 200
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)})
}
// AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。
// 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。
// 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作
// —— 本期不接自动退款 APIPAYMENT_DESIGN 明确不做),故 memo 里留操作痕迹。
func (h *Handler) AdminRefundOrder(c *gin.Context) {
id := c.Param("id")
var b struct {
Memo string `json:"memo"`
}
_ = c.ShouldBindJSON(&b) // memo 可选
changed, err := h.db.RefundOrder(c.Request.Context(), id, userID(c), b.Memo)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !changed {
// 幂等:本就无需退(已退 / 未支付 / 不存在)。据现状返回可读提示,不当错误。
c.JSON(http.StatusOK, gin.H{"status": "noop", "detail": "订单非已支付状态或已退款,未做冲销"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "refunded", "order_id": id})
}