fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH #8
@@ -39,10 +39,11 @@ const orderTTL = 30 * time.Minute
|
|||||||
func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
||||||
var b struct {
|
var b struct {
|
||||||
PackID string `json:"pack_id"`
|
PackID string `json:"pack_id"`
|
||||||
|
PlanID string `json:"plan_id"` // 传它=买订阅周期;与 pack_id 二选一
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" {
|
if err := c.ShouldBindJSON(&b); err != nil || (strings.TrimSpace(b.PackID) == "" && strings.TrimSpace(b.PlanID) == "") {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 或 plan_id 必填"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channel := strings.TrimSpace(b.Channel)
|
channel := strings.TrimSpace(b.Channel)
|
||||||
@@ -61,21 +62,40 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
|
||||||
return
|
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)
|
pk, err := h.db.GetPack(ctx, b.PackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
o := &store.PaymentOrder{
|
o = &store.PaymentOrder{
|
||||||
TenantID: billing, UserID: uid, PackID: pk.ID,
|
TenantID: billing, UserID: uid, PackID: pk.ID, Kind: store.OrderKindPack,
|
||||||
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
|
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
|
||||||
Channel: channel, Status: store.OrderPending,
|
Channel: channel, Status: store.OrderPending,
|
||||||
}
|
}
|
||||||
|
desc = "sundynix 积分充值 · " + pk.Name
|
||||||
|
}
|
||||||
if err := h.db.CreateOrder(ctx, o); err != nil {
|
if err := h.db.CreateOrder(ctx, o); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
intent, err := ch.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen)
|
intent, err := ch.CreatePay(ctx, o.ID, desc, o.AmountFen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
|
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
|
||||||
_ = h.db.ExpireOrder(ctx, o.ID)
|
_ = h.db.ExpireOrder(ctx, o.ID)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 订阅 API。用户面只有「看套餐 / 看我的订阅」,下单复用现有 /billing/orders
|
||||||
|
// (多传 plan_id 即可),因为支付链路、幂等、掉单补偿都已经在那条路上验过了,
|
||||||
|
// 没必要为订阅再造一条支付路径。
|
||||||
|
|
||||||
|
// ---- 用户面 ----
|
||||||
|
|
||||||
|
// BillingSubPlans: GET /api/v1/billing/sub-plans —— 在售订阅套餐。
|
||||||
|
func (h *Handler) BillingSubPlans(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), true)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySubscription: GET /api/v1/billing/subscription —— 我的当前订阅(无则 null)。
|
||||||
|
func (h *Handler) MySubscription(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
billing := h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c))
|
||||||
|
if billing == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"subscription": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := h.db.ActiveSubscription(ctx, billing)
|
||||||
|
if sub == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"subscription": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"subscription": sub, "plan": h.db.GetSubPlan(ctx, sub.PlanID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 管理端 ----
|
||||||
|
|
||||||
|
// AdminSubPlans: GET /api/v1/admin/sub-plans —— 全部套餐(含下架)。
|
||||||
|
func (h *Handler) AdminSubPlans(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), false)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSaveSubPlan: PUT /api/v1/admin/sub-plans —— 新增/改套餐(id 空=新增)。
|
||||||
|
// 积分以「积分」为单位收(面向人),服务端转 micro。
|
||||||
|
func (h *Handler) AdminSaveSubPlan(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PriceFen int64 `json:"price_fen"`
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
RefillCredits float64 `json:"refill_credits"`
|
||||||
|
RefillInterval int `json:"refill_interval_days"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pl := &store.SubscriptionPlan{
|
||||||
|
BaseModel: store.BaseModel{ID: b.ID},
|
||||||
|
Name: strings.TrimSpace(b.Name),
|
||||||
|
PriceFen: b.PriceFen,
|
||||||
|
DurationDays: b.DurationDays,
|
||||||
|
RefillCreditsMicro: int64(b.RefillCredits * 1e6),
|
||||||
|
RefillIntervalDays: b.RefillInterval,
|
||||||
|
Active: b.Active,
|
||||||
|
Sort: b.Sort,
|
||||||
|
}
|
||||||
|
if err := h.db.SaveSubPlan(c.Request.Context(), pl); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": pl.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSubscriptions: GET /api/v1/admin/subscriptions —— 全平台订阅观测。
|
||||||
|
func (h *Handler) AdminSubscriptions(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"subscriptions": h.db.AllSubscriptions(c.Request.Context(), 200)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 订阅推进定时器:周期扫 active 订阅 → 该发的发、该过期的置过期。
|
||||||
|
// 与掉单补偿(payment_reconcile.go)同一范式:定时器只是"兜底触发器",
|
||||||
|
// 真正的语义与幂等都在 store.TickSubscription 里,两处不会漂移。
|
||||||
|
//
|
||||||
|
// 为什么需要它:订阅是"有效期内每 N 天发一次积分",没有用户请求来驱动这个节拍。
|
||||||
|
// 进程停机期间欠下的发放,由 TickSubscription 的补发逻辑一次性补齐。
|
||||||
|
const subTickInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// StartSubscriptionTicker 随进程生命周期运行;多实例并发也安全(发放靠 ledger 唯一索引幂等)。
|
||||||
|
func (h *Handler) StartSubscriptionTicker(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(subTickInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
h.tickSubscriptions(ctx) // 启动即跑一次,把停机期间欠的补上
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
h.tickSubscriptions(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("[sub] 订阅推进定时器已启动(每 %s 扫一次)", subTickInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) tickSubscriptions(ctx context.Context) {
|
||||||
|
subs := h.db.DueSubscriptions(ctx, 200)
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
var granted, expired int
|
||||||
|
for i := range subs {
|
||||||
|
g, exp, err := h.db.TickSubscription(ctx, &subs[i], now)
|
||||||
|
if err != nil {
|
||||||
|
// 单条失败不影响其它订阅;下一轮会重试(幂等,不会重复发)
|
||||||
|
log.Printf("[sub] ⚠️ 推进订阅 %s 失败: %v", subs[i].ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
granted += g
|
||||||
|
if exp {
|
||||||
|
expired++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只在有变化时记一行,避免空转刷屏
|
||||||
|
if granted+expired > 0 {
|
||||||
|
log.Printf("[sub] 推进:发放 %d 笔、过期 %d 条(本轮 %d 条订阅)", granted, expired, len(subs))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
|
|
||||||
"github.com/sundynix/sundynix-shared/blob"
|
|
||||||
"github.com/sundynix/sundynix-gateway/internal/handler"
|
"github.com/sundynix/sundynix-gateway/internal/handler"
|
||||||
"github.com/sundynix/sundynix-gateway/internal/middleware"
|
"github.com/sundynix/sundynix-gateway/internal/middleware"
|
||||||
"github.com/sundynix/sundynix-gateway/internal/nats"
|
"github.com/sundynix/sundynix-gateway/internal/nats"
|
||||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
"github.com/sundynix/sundynix-gateway/internal/webui"
|
"github.com/sundynix/sundynix-gateway/internal/webui"
|
||||||
|
"github.com/sundynix/sundynix-shared/blob"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
|
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
|
||||||
@@ -40,6 +40,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
h.InitWechat(context.Background())
|
h.InitWechat(context.Background())
|
||||||
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
|
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
|
||||||
h.StartReconcile(context.Background())
|
h.StartReconcile(context.Background())
|
||||||
|
h.StartSubscriptionTicker(context.Background()) // 订阅按周期发放积分 + 到期置失效
|
||||||
|
|
||||||
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
|
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
|
||||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||||
@@ -116,6 +117,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.GET("/billing", h.Billing)
|
p.GET("/billing", h.Billing)
|
||||||
// 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。
|
// 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。
|
||||||
p.GET("/billing/packs", h.BillingPacks)
|
p.GET("/billing/packs", h.BillingPacks)
|
||||||
|
p.GET("/billing/sub-plans", h.BillingSubPlans) // 在售订阅套餐
|
||||||
|
p.GET("/billing/subscription", h.MySubscription) // 我的订阅(到期时间/发放次数)
|
||||||
p.GET("/billing/orders", h.BillingOrders)
|
p.GET("/billing/orders", h.BillingOrders)
|
||||||
p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认)
|
p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认)
|
||||||
p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem)
|
p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem)
|
||||||
@@ -145,6 +148,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.PUT("/packs", h.AdminSavePack)
|
admin.PUT("/packs", h.AdminSavePack)
|
||||||
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||||
|
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
||||||
|
admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏
|
||||||
|
admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测
|
||||||
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
||||||
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
||||||
admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计)
|
admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ import (
|
|||||||
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
|
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
|
||||||
|
|
||||||
// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。
|
// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。
|
||||||
|
const (
|
||||||
|
// 订单类型:一次性积分包 vs 订阅周期
|
||||||
|
OrderKindPack = "pack"
|
||||||
|
OrderKindSub = "sub"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
OrderPending = "pending"
|
OrderPending = "pending"
|
||||||
OrderPaid = "paid"
|
OrderPaid = "paid"
|
||||||
@@ -52,6 +59,8 @@ type PaymentOrder struct {
|
|||||||
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
||||||
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
||||||
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
|
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
|
||||||
|
Kind string `gorm:"size:16;default:pack" json:"kind"` // pack=积分包(一次性) / sub=订阅
|
||||||
|
PlanID string `gorm:"size:24" json:"plan_id"` // kind=sub 时的订阅套餐
|
||||||
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
|
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
|
||||||
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
|
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
|
||||||
Channel string `gorm:"size:16;index" json:"channel"`
|
Channel string `gorm:"size:16;index" json:"channel"`
|
||||||
@@ -219,6 +228,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
return false, errStoreDisabled
|
return false, errStoreDisabled
|
||||||
}
|
}
|
||||||
changed := false
|
changed := false
|
||||||
|
var paid *PaymentOrder
|
||||||
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
res := tx.Model(&PaymentOrder{}).
|
res := tx.Model(&PaymentOrder{}).
|
||||||
@@ -234,6 +244,9 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
|
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
paid = &o
|
||||||
|
// 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
|
||||||
|
if o.CreditsMicro != 0 {
|
||||||
if err := tx.Create(&CreditLedger{
|
if err := tx.Create(&CreditLedger{
|
||||||
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
@@ -243,11 +256,24 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
changed = true
|
changed = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
return changed, err
|
return changed, err
|
||||||
}
|
}
|
||||||
|
// 订阅开通放在这里、而不是各调用方:回调与掉单补偿两条路都经过 MarkOrderPaid,
|
||||||
|
// 放在这一处才没人能漏掉。ActivateSubscription 按 orderID 幂等,重复调用无害。
|
||||||
|
if paid != nil && paid.Kind == OrderKindSub && paid.PlanID != "" {
|
||||||
|
if _, aerr := p.ActivateSubscription(ctx, paid.TenantID, paid.PlanID, paid.ID); aerr != nil {
|
||||||
|
// 钱已收、订单已 paid:这里失败**不能**回滚订单(否则用户付了钱订单还回到 pending,
|
||||||
|
// 补偿定时器会再入账一次)。出声即可,可人工或下次回调补开通。
|
||||||
|
log.Printf("[payment] ⚠️ 订单 %s 已入账但订阅开通失败: %v(需人工补开通)", paid.ID, aerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
||||||
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
|
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
migrateLegacyIntIDs(db)
|
migrateLegacyIntIDs(db)
|
||||||
migrateDocLinkToID(db)
|
migrateDocLinkToID(db)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 订阅(手动购买制)。刻意**不做自动续费**:微信 Native 扫码支付没有代扣能力,
|
||||||
|
// 真自动续费要走「委托代扣」——另一套产品与资质。这里的语义是:
|
||||||
|
//
|
||||||
|
// 用户扫码买一个订阅周期 → 有效期内每 N 天发一次积分 → 到期即失效,要续得再买一次。
|
||||||
|
//
|
||||||
|
// N(间隔天数)与每次发多少积分都在套餐里配,后台可改。
|
||||||
|
//
|
||||||
|
// **发放语义是「累加」而非「重置」**:每次刷新写一条 grant 分录、余额累加,
|
||||||
|
// 用不完的会留着,也绝不会清掉用户自己充值的积分。重置型(月度配额清零)会让
|
||||||
|
// 「余额 = SUM(ledger)」这条对账不变量变复杂,且有误删用户已付费积分的风险,
|
||||||
|
// 故不采用。
|
||||||
|
|
||||||
|
// SubscriptionPlan 订阅套餐(价格 / 时长 / 发放节奏,全部后台可配)。
|
||||||
|
type SubscriptionPlan struct {
|
||||||
|
BaseModel
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 售价(分)
|
||||||
|
DurationDays int `gorm:"column:duration_days" json:"duration_days"` // 一个订阅周期多少天
|
||||||
|
RefillCreditsMicro int64 `gorm:"column:refill_credits_micro" json:"refill_credits_micro"` // 每次发放的积分 ×10⁻⁶
|
||||||
|
RefillIntervalDays int `gorm:"column:refill_interval_days" json:"refill_interval_days"` // 每几天发一次
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SubscriptionPlan) TableName() string { return "sundynix_sub_plan" }
|
||||||
|
|
||||||
|
// Subscription 一次已购订阅。到期即 expired,不自动续。
|
||||||
|
type Subscription struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;index" json:"tenant_id"`
|
||||||
|
PlanID string `gorm:"size:24;index" json:"plan_id"`
|
||||||
|
OrderID string `gorm:"size:24" json:"order_id"` // 来源支付订单(人工发放为空)
|
||||||
|
Status string `gorm:"size:16;index" json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||||
|
RefillSeq int `gorm:"column:refill_seq" json:"refill_seq"` // 已发放次数;兼作幂等序号
|
||||||
|
LastRefillAt *time.Time `json:"last_refill_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Subscription) TableName() string { return "sundynix_subscription" }
|
||||||
|
func (Subscription) isTenantScoped() {} // 用户面只看得到自己租户的订阅;系统级扫描须 WithoutTenant
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubActive = "active"
|
||||||
|
SubExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refillRef 是一次发放的幂等键,落到 credit_ledger.ref。
|
||||||
|
// credit_ledger 上 (kind='grant', ref) 的唯一索引是最终闸门:定时器重跑、多实例并发、
|
||||||
|
// 手动补发,撞到同一序号都只会成功一次。
|
||||||
|
func refillRef(subID string, seq int) string { return fmt.Sprintf("sub:%s:%d", subID, seq) }
|
||||||
|
|
||||||
|
// isDupKey 判断是否唯一索引冲突(= 这一笔已经发过了,幂等成功而非失败)。
|
||||||
|
func isDupKey(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") ||
|
||||||
|
strings.Contains(s, "unique violation") || strings.Contains(s, "constraint failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 套餐配置 ----
|
||||||
|
|
||||||
|
func (p *Postgres) ListSubPlans(ctx context.Context, onlyActive bool) []SubscriptionPlan {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, price_fen asc")
|
||||||
|
if onlyActive {
|
||||||
|
q = q.Where("active = ?", true)
|
||||||
|
}
|
||||||
|
var out []SubscriptionPlan
|
||||||
|
q.Find(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetSubPlan(ctx context.Context, id string) *SubscriptionPlan {
|
||||||
|
if p.db == nil || id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pl SubscriptionPlan
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).First(&pl, "id = ?", id).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pl
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSubPlan 新增或更新套餐(id 空=新增)。
|
||||||
|
func (p *Postgres) SaveSubPlan(ctx context.Context, pl *SubscriptionPlan) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
if pl.DurationDays <= 0 {
|
||||||
|
return errors.New("订阅时长必须大于 0 天")
|
||||||
|
}
|
||||||
|
if pl.RefillIntervalDays <= 0 {
|
||||||
|
return errors.New("发放间隔必须大于 0 天")
|
||||||
|
}
|
||||||
|
// 间隔比时长还长 = 一个周期内一次都发不到第二回,多半是配错了,直接拦下。
|
||||||
|
if pl.RefillIntervalDays > pl.DurationDays {
|
||||||
|
return errors.New("发放间隔不能大于订阅时长")
|
||||||
|
}
|
||||||
|
return p.db.WithContext(WithoutTenant(ctx)).Save(pl).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 订阅生命周期 ----
|
||||||
|
|
||||||
|
// ActivateSubscription 支付成功后开通/续期,并立即发放第一笔积分。
|
||||||
|
// 已有生效中的订阅则**顺延**到期时间(而不是新建一条),避免同租户多条 active 互相打架。
|
||||||
|
// 幂等:同一 orderID 只会开通一次。
|
||||||
|
func (p *Postgres) ActivateSubscription(ctx context.Context, tenantID, planID, orderID string) (*Subscription, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
pl := p.GetSubPlan(ctx, planID)
|
||||||
|
if pl == nil {
|
||||||
|
return nil, errors.New("订阅套餐不存在")
|
||||||
|
}
|
||||||
|
ctx = WithoutTenant(ctx) // 系统级:为目标租户开通,调用方可能是 admin
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var sub *Subscription
|
||||||
|
err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 幂等闸:同一订单已开通过就直接返回,不重复延期
|
||||||
|
if orderID != "" {
|
||||||
|
var exist Subscription
|
||||||
|
if err := tx.First(&exist, "order_id = ?", orderID).Error; err == nil {
|
||||||
|
sub = &exist
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cur Subscription
|
||||||
|
err := tx.Where("tenant_id = ? AND status = ?", tenantID, SubActive).
|
||||||
|
Order("expires_at desc").First(&cur).Error
|
||||||
|
switch {
|
||||||
|
case err == nil: // 续期:在原到期时间上顺延
|
||||||
|
cur.ExpiresAt = cur.ExpiresAt.AddDate(0, 0, pl.DurationDays)
|
||||||
|
cur.PlanID, cur.OrderID = pl.ID, orderID
|
||||||
|
if err := tx.Save(&cur).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sub = &cur
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
s := &Subscription{
|
||||||
|
TenantID: tenantID, PlanID: pl.ID, OrderID: orderID, Status: SubActive,
|
||||||
|
StartedAt: now, ExpiresAt: now.AddDate(0, 0, pl.DurationDays),
|
||||||
|
}
|
||||||
|
if err := tx.Create(s).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sub = s
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 首笔发放走与定时器**同一套排期判断**(而不是无条件发一笔):否则同一订单重复
|
||||||
|
// 开通时(回调重推、查单与回调赛跑)会各发一笔,序号递增绕过幂等索引 —— 白送钱。
|
||||||
|
// 排期判断天然幂等:seq 已发过则下一笔的到期时间在未来,不会发。
|
||||||
|
// 放在事务外:发放失败不该导致"已付款却没开通",定时器下一轮会补上。
|
||||||
|
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
|
||||||
|
return sub, nil // 开通已成功,发放失败交给定时器补
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refillOnce 发放一次积分并推进序号。返回 granted=false 表示这一笔已发过(幂等)。
|
||||||
|
func (p *Postgres) refillOnce(ctx context.Context, sub *Subscription, pl *SubscriptionPlan, now time.Time) (bool, error) {
|
||||||
|
if pl.RefillCreditsMicro <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
seq := sub.RefillSeq + 1
|
||||||
|
err := p.GrantCredits(ctx, sub.TenantID, LedgerGrant, pl.RefillCreditsMicro,
|
||||||
|
refillRef(sub.ID, seq), "订阅发放 "+pl.Name)
|
||||||
|
if err != nil {
|
||||||
|
if isDupKey(err) {
|
||||||
|
return false, nil // 已发过:幂等成功
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
sub.RefillSeq = seq
|
||||||
|
sub.LastRefillAt = &now
|
||||||
|
return true, p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
|
||||||
|
Where("id = ?", sub.ID).
|
||||||
|
Updates(map[string]any{"refill_seq": seq, "last_refill_at": now}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueSubscriptions 取到期需处理的订阅(系统级,跨租户)。
|
||||||
|
func (p *Postgres) DueSubscriptions(ctx context.Context, limit int) []Subscription {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
var out []Subscription
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).
|
||||||
|
Where("status = ?", SubActive).Order("expires_at asc").Limit(limit).Find(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TickSubscription 推进一条订阅:先看是否到期,再看是否该发放。
|
||||||
|
// 返回 (发放笔数, 是否刚过期)。定时器与手动触发共用这一份逻辑,避免两处行为漂移。
|
||||||
|
func (p *Postgres) TickSubscription(ctx context.Context, sub *Subscription, now time.Time) (int, bool, error) {
|
||||||
|
pl := p.GetSubPlan(ctx, sub.PlanID)
|
||||||
|
if pl == nil {
|
||||||
|
return 0, false, errors.New("订阅套餐已不存在: " + sub.PlanID)
|
||||||
|
}
|
||||||
|
granted := 0
|
||||||
|
// 补发:进程停机/定时器漏跑期间欠下的次数要一次性补齐,而不是只发最近一次。
|
||||||
|
// 上限用到期时间卡住——过期之后的周期一律不发。
|
||||||
|
for {
|
||||||
|
due := sub.StartedAt.AddDate(0, 0, pl.RefillIntervalDays*(sub.RefillSeq))
|
||||||
|
if due.After(now) || !due.Before(sub.ExpiresAt) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ok, err := p.refillOnce(ctx, sub, pl, now)
|
||||||
|
if err != nil {
|
||||||
|
return granted, false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
granted++
|
||||||
|
}
|
||||||
|
if sub.RefillSeq > 1000 { // 防呆:配置异常(间隔 0)时不至于死循环
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if now.After(sub.ExpiresAt) {
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
|
||||||
|
Where("id = ? AND status = ?", sub.ID, SubActive).
|
||||||
|
Update("status", SubExpired).Error; err != nil {
|
||||||
|
return granted, false, err
|
||||||
|
}
|
||||||
|
return granted, true, nil
|
||||||
|
}
|
||||||
|
return granted, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveSubscription 取某租户当前生效的订阅(用户面:账单页展示到期时间)。
|
||||||
|
func (p *Postgres) ActiveSubscription(ctx context.Context, tenantID string) *Subscription {
|
||||||
|
if p.db == nil || tenantID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s Subscription
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).
|
||||||
|
Where("tenant_id = ? AND status = ?", tenantID, SubActive).
|
||||||
|
Order("expires_at desc").First(&s).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSubRow 是管理端订阅观测一行:订阅 + 租户名 + 套餐名。
|
||||||
|
type AdminSubRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
TenantName string `json:"tenant_name"`
|
||||||
|
PlanName string `json:"plan_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
RefillSeq int `json:"refill_seq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllSubscriptions 全平台订阅(管理端观测,跨租户)。
|
||||||
|
func (p *Postgres) AllSubscriptions(ctx context.Context, limit int) []AdminSubRow {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
var out []AdminSubRow
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_subscription as s").
|
||||||
|
Select("s.id, s.tenant_id, s.status, s.started_at, s.expires_at, s.refill_seq, " +
|
||||||
|
"coalesce(t.name,'') as tenant_name, coalesce(pl.name,'') as plan_name").
|
||||||
|
Joins("left join sundynix_tenant t on t.id = s.tenant_id").
|
||||||
|
Joins("left join sundynix_sub_plan pl on pl.id = s.plan_id").
|
||||||
|
Where("s.deleted_at is null").
|
||||||
|
Order("s.expires_at desc").Limit(limit).Scan(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 订阅是涉及钱的路径:发多了是白送,发少了是欠付费用户的。这组测试钉死三件事——
|
||||||
|
// 幂等(重跑不重复发)、补发(漏跑要补齐)、到期边界(过期后一分不发)。
|
||||||
|
|
||||||
|
func seedPlan(t *testing.T, p *Postgres, durationDays, intervalDays int, credits int64) *SubscriptionPlan {
|
||||||
|
t.Helper()
|
||||||
|
pl := &SubscriptionPlan{
|
||||||
|
Name: "测试套餐", PriceFen: 9900, DurationDays: durationDays,
|
||||||
|
RefillCreditsMicro: credits, RefillIntervalDays: intervalDays, Active: true,
|
||||||
|
}
|
||||||
|
if err := p.SaveSubPlan(context.Background(), pl); err != nil {
|
||||||
|
t.Fatalf("建套餐失败: %v", err)
|
||||||
|
}
|
||||||
|
return pl
|
||||||
|
}
|
||||||
|
|
||||||
|
func balance(t *testing.T, p *Postgres, tenantID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
return p.TenantBalance(WithoutTenant(context.Background()), tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscription_ActivateGrantsFirstRefill(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||||
|
|
||||||
|
sub, err := p.ActivateSubscription(context.Background(), "t1", pl.ID, "order-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("开通失败: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Status != SubActive {
|
||||||
|
t.Fatalf("应为 active,得 %q", sub.Status)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||||
|
t.Fatalf("开通即应发第一笔,余额应 100e6,得 %d", got)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一订单重复开通(回调重推 / 查单与回调赛跑)不能重复延期、不能重复发放。
|
||||||
|
func TestSubscription_ActivateIsIdempotentPerOrder(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s1, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s1.ExpiresAt.Equal(s2.ExpiresAt) {
|
||||||
|
t.Fatalf("同一订单重复开通不该延期:%v → %v", s1.ExpiresAt, s2.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||||
|
t.Fatalf("重复开通不该重复发放,余额应仍为 100e6,得 %d", got)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续订(不同订单)应在原到期时间上顺延,而不是新建第二条 active。
|
||||||
|
func TestSubscription_RenewExtendsInsteadOfDuplicating(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s1, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||||
|
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s1.ID != s2.ID {
|
||||||
|
t.Fatalf("续订应复用同一条订阅,得两条:%s / %s", s1.ID, s2.ID)
|
||||||
|
}
|
||||||
|
want := s1.ExpiresAt.AddDate(0, 0, 30)
|
||||||
|
if !s2.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("续订应顺延 30 天:want %v got %v", want, s2.ExpiresAt)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
|
||||||
|
Where("tenant_id = ? AND status = ?", "t1", SubActive).Count(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("同租户不应出现多条 active 订阅,得 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时器漏跑(进程停机数周)后要把欠下的次数一次补齐,而不是只补最近一次。
|
||||||
|
func TestSubscription_TickBackfillsMissedRefills(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000) // 30 天订阅,每 7 天发一次
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1") // 已发第 1 笔
|
||||||
|
// 快进 22 天:第 7/14/21 天各应发一次,共补 3 笔
|
||||||
|
now := sub.StartedAt.AddDate(0, 0, 22)
|
||||||
|
granted, expired, err := p.TickSubscription(ctx, sub, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if expired {
|
||||||
|
t.Fatal("22 天时不该过期(周期 30 天)")
|
||||||
|
}
|
||||||
|
if granted != 3 {
|
||||||
|
t.Fatalf("应补发 3 笔(第 7/14/21 天),得 %d", granted)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 400_000_000 {
|
||||||
|
t.Fatalf("首笔 + 补 3 笔 = 400e6,得 %d", got)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一时刻重复 tick(多实例并发 / 定时器重叠)不能重复发放。
|
||||||
|
func TestSubscription_TickIsIdempotent(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||||
|
now := sub.StartedAt.AddDate(0, 0, 8)
|
||||||
|
|
||||||
|
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := balance(t, p, "t1")
|
||||||
|
// 再 tick 两次,余额不能变
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after := balance(t, p, "t1"); after != before {
|
||||||
|
t.Fatalf("重复 tick 不该重复发放:%d → %d", before, after)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过期后一分不发,且状态置 expired(到期即失效,无自动续费)。
|
||||||
|
func TestSubscription_ExpiresAndStopsGranting(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 14, 7, 100_000_000) // 14 天,7 天一发 → 最多发第 1、第 7 天两笔
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||||
|
now := sub.StartedAt.AddDate(0, 0, 100) // 远超到期
|
||||||
|
granted, expired, err := p.TickSubscription(ctx, sub, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !expired {
|
||||||
|
t.Fatal("早该过期了")
|
||||||
|
}
|
||||||
|
// 到期时间点(第 14 天)之后的周期不发:第 7 天那笔算,第 14 天正好等于到期不算
|
||||||
|
if granted != 1 {
|
||||||
|
t.Fatalf("过期前只应补第 7 天那一笔,得 %d 笔", granted)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 200_000_000 {
|
||||||
|
t.Fatalf("首笔 + 第 7 天 = 200e6,得 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var s Subscription
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).First(&s, "id = ?", sub.ID)
|
||||||
|
if s.Status != SubExpired {
|
||||||
|
t.Fatalf("状态应为 expired,得 %q", s.Status)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置校验:间隔比时长长 = 一个周期只发得到首笔,多半是配错了,直接拦。
|
||||||
|
func TestSubPlan_RejectsBadConfig(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
duration, ivl int
|
||||||
|
wantErrSubstring string
|
||||||
|
}{
|
||||||
|
{"时长为 0", 0, 7, "订阅时长"},
|
||||||
|
{"间隔为 0", 30, 0, "发放间隔"},
|
||||||
|
{"间隔大于时长", 7, 30, "不能大于"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := p.SaveSubPlan(ctx, &SubscriptionPlan{
|
||||||
|
Name: "x", DurationDays: tc.duration, RefillIntervalDays: tc.ivl, RefillCreditsMicro: 1,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("应被拒绝")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = time.Now
|
||||||
|
|
||||||
|
// 订阅单经支付回调入账后必须真的开通订阅。这是"钱收了但订阅没生效"的高危点,
|
||||||
|
// 而且开通逻辑刻意放在 MarkOrderPaid 里(回调与掉单补偿两条路共用),这里一并钉死。
|
||||||
|
func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
o := &PaymentOrder{
|
||||||
|
TenantID: "t1", UserID: "u1", Kind: OrderKindSub, PlanID: pl.ID,
|
||||||
|
AmountFen: pl.PriceFen, CreditsMicro: 0, // 订阅单自身不带积分
|
||||||
|
Channel: ChannelWechat, Status: OrderPending,
|
||||||
|
}
|
||||||
|
if err := p.CreateOrder(ctx, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed, err := p.MarkOrderPaid(ctx, o.ID, "txn-1")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("入账应成功: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := p.ActiveSubscription(ctx, "t1")
|
||||||
|
if sub == nil {
|
||||||
|
t.Fatal("付款后应已开通订阅")
|
||||||
|
}
|
||||||
|
if sub.OrderID != o.ID {
|
||||||
|
t.Fatalf("订阅应关联来源订单 %s,得 %s", o.ID, sub.OrderID)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||||
|
t.Fatalf("开通即发首笔,余额应 100e6,得 %d", got)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
|
||||||
|
// 回调重复推送:不能重复开通、不能重复发放
|
||||||
|
if _, err := p.MarkOrderPaid(ctx, o.ID, "txn-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||||
|
t.Fatalf("重复回调不该重复发放,得 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func newTestStore(t *testing.T) *Postgres {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
||||||
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
||||||
&AuditLog{}, &Task{}, &Eval{},
|
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
||||||
&KB{}, // 租户作用域模型,验证隔离插件
|
&KB{}, // 租户作用域模型,验证隔离插件
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("AutoMigrate 失败: %v", err)
|
t.Fatalf("AutoMigrate 失败: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user