From 8e05f4b3fe8c8ecb98bab8df94c9867ff100f002 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 17 Jul 2026 10:18:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E6=94=AF=E4=BB=98=20P5.1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=AE=A2=E5=8D=95=E9=AA=A8=E6=9E=B6=20+?= =?UTF-8?q?=20=E5=85=91=E6=8D=A2=E7=A0=81=E6=B8=A0=E9=81=93=E5=85=A8?= =?UTF-8?q?=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 PAYMENT_DESIGN.md 施工。零资质渠道先把「订单→入账→对账」的骨架跑真, 微信 Native(P5.2)进来只是多一个 adapter。 - store/payment.go:CreditPack(admin 可配包)/PaymentOrder(支付侧事实源,兑换 也写单,全部充值一个查法)/RedeemCode(SDX-XXXX-XXXX-XXXX,crypto/rand,剔除 易混字符)。三模型都不标 isTenantScoped——订单归计费租户,与活跃租户可能 分叉,插件自动注入会写错归属(RecentRuns 同款教训),显式赋值+显式过滤。 - Redeem 单事务:码 CAS 占用(unused→used 只成功一次,幂等主闸)→建已支付 订单→账本分录+物化余额。credit_ledger 加 (kind,ref) 部分唯一索引兜底 ——GrantCredits 此前对 ref 零约束,回调 at-least-once 就是重复入账事故。 - 路由:/billing/packs|orders(查,全员) + /billing/redeem(≥member+审计); admin /redeem-codes(生成/列表) + /packs(配包)。 - Web 面账单页:兑换码输入(viewer 不摆输入框,真闸在后端)+最近充值订单流。 - 测试:码形态/200 样本无撞码/参数边界;go+tsc+vitest 全绿。 live 13 项:生成→核销余额精确+100→重core 400(主闸)→DB 直插重复 ref 被唯一 索引打回(兜底闸实证)→viewer 403→balance==SUM(ledger) 不变量→审计留痕→ 浏览器 UI 兑换 100→200+订单流展示。 已知余项:admin 生成码暂只有 API(admin 页 UI 下一刀);微信 adapter=P5.2。 Co-Authored-By: Claude Opus 4.8 --- .../internal/handler/billing_pay.go | 128 ++++++++++ sundynix-gateway/internal/router/router.go | 9 + sundynix-gateway/internal/store/payment.go | 237 ++++++++++++++++++ .../internal/store/payment_test.go | 39 +++ sundynix-gateway/internal/store/pgsql.go | 7 +- sundynix-web/src/api.ts | 30 +++ sundynix-web/src/pages/Usage.tsx | 87 ++++++- 7 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 sundynix-gateway/internal/handler/billing_pay.go create mode 100644 sundynix-gateway/internal/store/payment.go create mode 100644 sundynix-gateway/internal/store/payment_test.go diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go new file mode 100644 index 0000000..6fd3c1b --- /dev/null +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -0,0 +1,128 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sundynix/sundynix-gateway/internal/store" +) + +// 充值(P5.1:兑换码渠道;设计见 PAYMENT_DESIGN.md)。 +// 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账, +// 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。 + +// BillingPacks: GET /api/v1/billing/packs —— 在售积分包(微信渠道 P5.2 上线前仅展示)。 +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 + } + c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": []string{store.ChannelRedeem}}) +} + +// 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 + } + 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}) +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index c4b2727..8be5e89 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -105,6 +105,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间 p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下 p.GET("/billing", h.Billing) + // 充值(P5.1 兑换码):核销会动钱,≥member + 审计;查询全员可看。 + p.GET("/billing/packs", h.BillingPacks) + p.GET("/billing/orders", h.BillingOrders) + p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem) p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合 p.GET("/runs", h.Runs) // 运行历史(复盘) p.GET("/tasks/:id/replay", h.TaskReplay) // 历史运行复盘(持久化输出+轨迹,免 Redis TTL) @@ -123,6 +127,11 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关) admin.PUT("/billing-config", h.SaveBillingConfig) admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分 + // 支付配置面(P5.1):兑换码生成/查看 + 积分包配置 + admin.POST("/redeem-codes", h.AdminGenRedeemCodes) + admin.GET("/redeem-codes", h.AdminRedeemCodes) + admin.GET("/packs", h.AdminPacks) + admin.PUT("/packs", h.AdminSavePack) // 多租户成员管理(平台运维口径) admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go new file mode 100644 index 0000000..c4d2bbb --- /dev/null +++ b/sundynix-gateway/internal/store/payment.go @@ -0,0 +1,237 @@ +package store + +import ( + "context" + "crypto/rand" + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +// 支付(P5,设计见 PAYMENT_DESIGN.md):预付积分包充值。 +// 两层汇率各管各的:钱→积分 = 本文件的积分包定价;积分→token = SettingTokensPerCredit(已有)。 +// +// 三个模型都**不标 isTenantScoped**: +// - PaymentOrder 归「计费租户」,与请求 ctx 的活跃租户可能不同(共享计费分叉), +// 插件自动注入会写错归属——tenant_id 一律显式赋值、查询显式过滤(RecentRuns 同款教训)。 +// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。 + +// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。 +const ( + OrderPending = "pending" + OrderPaid = "paid" + OrderFailed = "failed" + OrderExpired = "expired" + OrderRefunded = "refunded" +) + +// 渠道名。P5.1 只有 redeem;wechat 在 P5.2 挂上。 +const ( + ChannelRedeem = "redeem" + ChannelWechat = "wechat" +) + +// CreditPack 积分包(admin 可改价/上下架;订单锁定下单当时的价与积分,改包不影响已付订单)。 +type CreditPack struct { + BaseModel + Name string `gorm:"size:64" json:"name"` + CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"` + PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 应付人民币(分) + Active bool `json:"active"` + Sort int `json:"sort"` +} + +func (CreditPack) TableName() string { return "sundynix_credit_pack" } + +// PaymentOrder 充值订单——支付侧事实源(与 credit_ledger 对账的另一条腿)。 +// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。 +type PaymentOrder struct { + BaseModel + TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定) + UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计) + PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空 + AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"` + CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"` + Channel string `gorm:"size:16;index" json:"channel"` + Status string `gorm:"size:16;index" json:"status"` + ChannelTxn string `gorm:"size:128" json:"channel_txn"` // 渠道流水号 / 兑换码 id + PaidAt *time.Time `json:"paid_at"` +} + +func (PaymentOrder) TableName() string { return "sundynix_payment_order" } + +// RedeemCode 兑换码(平台级凭证;admin 生成,任意租户核销一次)。 +type RedeemCode struct { + BaseModel + Code string `gorm:"size:32;uniqueIndex" json:"code"` + CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"` + Status string `gorm:"size:16;index" json:"status"` // unused / used + UsedTenant string `gorm:"size:64" json:"used_tenant"` + UsedBy string `gorm:"size:64" json:"used_by"` + UsedAt *time.Time `json:"used_at"` + Memo string `gorm:"size:255" json:"memo"` +} + +func (RedeemCode) TableName() string { return "sundynix_redeem_code" } + +// codeAlphabet 去掉易混字符(0/O、1/I/L)的 base32 变体。 +const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +// newRedeemCode 生成 SDX-XXXX-XXXX-XXXX 形式的兑换码(crypto/rand,31^12 ≈ 7.9e17 空间)。 +func newRedeemCode() (string, error) { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + var sb strings.Builder + sb.WriteString("SDX") + for i, c := range b { + if i%4 == 0 { + sb.WriteByte('-') + } + sb.WriteByte(codeAlphabet[int(c)%len(codeAlphabet)]) + } + return sb.String(), nil +} + +// GenerateRedeemCodes admin 批量生成兑换码(每张 creditsMicro 积分)。返回明文码列表。 +func (p *Postgres) GenerateRedeemCodes(ctx context.Context, n int, creditsMicro int64, memo string) ([]string, error) { + if p.db == nil { + return nil, errStoreDisabled + } + if n <= 0 || n > 200 || creditsMicro <= 0 { + return nil, errors.New("数量须在 1-200、面额须为正") + } + out := make([]string, 0, n) + err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error { + for i := 0; i < n; i++ { + code, err := newRedeemCode() + if err != nil { + return err + } + if err := tx.Create(&RedeemCode{Code: code, CreditsMicro: creditsMicro, Status: "unused", Memo: memo}).Error; err != nil { + return err // 撞唯一索引概率约 n/31^12,整批重试比码内重试省事——直接报错让 admin 再点一次 + } + out = append(out, code) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// ListRedeemCodes admin 查看兑换码(倒序)。 +func (p *Postgres) ListRedeemCodes(ctx context.Context, limit int) ([]RedeemCode, error) { + if p.db == nil { + return nil, nil + } + if limit <= 0 || limit > 500 { + limit = 100 + } + var out []RedeemCode + err := p.db.WithContext(WithoutTenant(ctx)).Order("created_at desc").Limit(limit).Find(&out).Error + return out, err +} + +// Redeem 核销兑换码:一个事务里完成「码 CAS 占用 → 建已支付订单 → 入账(分录+物化余额)」。 +// 幂等双闸:码状态 CAS 是主闸(unused→used 只成功一次);credit_ledger 的 +// (kind,ref) 部分唯一索引兜底(ref=订单号)。任何一步失败整体回滚,码不会白烧。 +func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*PaymentOrder, error) { + if p.db == nil { + return nil, errStoreDisabled + } + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" || tenantID == "" { + return nil, errors.New("兑换码必填") + } + var order *PaymentOrder + // 计费租户可能 ≠ 请求 ctx 的活跃租户,且账本表是 tenant-scoped——旁路插件、全部显式赋值。 + err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error { + var rc RedeemCode + if err := tx.First(&rc, "code = ?", code).Error; err != nil { + return errors.New("兑换码不存在") + } + now := time.Now() + res := tx.Model(&RedeemCode{}). + Where("id = ? AND status = ?", rc.ID, "unused"). + Updates(map[string]any{"status": "used", "used_tenant": tenantID, "used_by": userID, "used_at": now}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("兑换码已被使用") + } + o := &PaymentOrder{ + TenantID: tenantID, UserID: userID, + AmountFen: 0, CreditsMicro: rc.CreditsMicro, + Channel: ChannelRedeem, Status: OrderPaid, ChannelTxn: rc.ID, PaidAt: &now, + } + if err := tx.Create(o).Error; err != nil { + return err + } + if err := tx.Create(&CreditLedger{ + TenantID: tenantID, Kind: LedgerGrant, CreditsMicro: rc.CreditsMicro, Ref: o.ID, Memo: "兑换码 " + code, + }).Error; err != nil { + return err + } + if err := tx.Model(&Tenant{}).Where("id = ?", tenantID). + UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil { + return err + } + order = o + return nil + }) + if err != nil { + return nil, err + } + return order, nil +} + +// ActivePacks 在售积分包(用户侧展示)。 +func (p *Postgres) ActivePacks(ctx context.Context) ([]CreditPack, error) { + if p.db == nil { + return nil, nil + } + var out []CreditPack + err := p.db.WithContext(ctx).Where("active = ?", true).Order("sort asc, price_fen asc").Find(&out).Error + return out, err +} + +// SavePack admin 新建/更新积分包(id 空=新建)。 +func (p *Postgres) SavePack(ctx context.Context, pk *CreditPack) error { + if p.db == nil { + return errStoreDisabled + } + if strings.TrimSpace(pk.Name) == "" || pk.CreditsMicro <= 0 || pk.PriceFen < 0 { + return errors.New("name/credits 必填且为正") + } + return p.db.WithContext(WithoutTenant(ctx)).Save(pk).Error +} + +// ListPacks admin 查看全部积分包(含下架)。 +func (p *Postgres) ListPacks(ctx context.Context) ([]CreditPack, error) { + if p.db == nil { + return nil, nil + } + var out []CreditPack + err := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, created_at asc").Find(&out).Error + return out, err +} + +// TenantOrders 某计费租户的充值订单(倒序)。tenant_id 显式过滤(模型未挂租户插件)。 +func (p *Postgres) TenantOrders(ctx context.Context, tenantID string, limit int) ([]PaymentOrder, error) { + if p.db == nil || tenantID == "" { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 20 + } + var out []PaymentOrder + err := p.db.WithContext(WithoutTenant(ctx)).Where("tenant_id = ?", tenantID). + Order("created_at desc").Limit(limit).Find(&out).Error + return out, err +} diff --git a/sundynix-gateway/internal/store/payment_test.go b/sundynix-gateway/internal/store/payment_test.go new file mode 100644 index 0000000..afac5b1 --- /dev/null +++ b/sundynix-gateway/internal/store/payment_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "regexp" + "testing" +) + +// 兑换码:固定形态 SDX-XXXX-XXXX-XXXX,字母表剔除易混字符(0/O、1/I/L)。 +func TestNewRedeemCodeFormat(t *testing.T) { + re := regexp.MustCompile(`^SDX(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$`) + seen := map[string]bool{} + for i := 0; i < 200; i++ { + code, err := newRedeemCode() + if err != nil { + t.Fatalf("生成失败: %v", err) + } + if !re.MatchString(code) { + t.Fatalf("形态不符: %q", code) + } + if seen[code] { + t.Fatalf("200 个样本内撞码: %q(crypto/rand 出问题了)", code) + } + seen[code] = true + } +} + +// 参数校验在 nil-db 之前生效——admin 手滑发 0 面额/超量不该打到数据库才被拒。 +func TestGenerateRedeemCodesBounds(t *testing.T) { + p := &Postgres{} // db==nil + for _, tc := range []struct { + n int + credit int64 + }{{0, 1e6}, {201, 1e6}, {1, 0}, {1, -5}} { + if _, err := p.GenerateRedeemCodes(context.Background(), tc.n, tc.credit, ""); err == nil { + t.Errorf("n=%d credit=%d 应被拒", tc.n, tc.credit) + } + } +} diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index d7c9fdd..ba54723 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -66,10 +66,15 @@ func OpenPostgres(dsn string) *Postgres { migrateLegacyIntIDs(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{}); 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{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) return &Postgres{} } + // 支付入账幂等兜底闸:grant 分录按 ref(=订单号) 唯一——支付回调是 at-least-once, + // 订单状态机 CAS 是主闸,这里是第二道。部分索引:admin 手工发放 ref 为空、usage 分录不受影响。 + if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil { + log.Printf("[store] 账本 grant/ref 唯一索引创建失败(重复入账兜底闸缺位): %v", err) + } registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离) log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)") return &Postgres{db: db} diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index bcb5867..d22be5f 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -246,6 +246,36 @@ export async function myUsage(days = 30): Promise { }; } +// ---- 充值(P5.1 兑换码;微信支付 P5.2 上线)---- +export interface TopupOrder { + id: string; + credits_micro: number; + amount_fen: number; + channel: string; + status: string; + created_at: string; +} + +// redeemCode 核销兑换码,返回入账后的余额。 +export async function redeemCode(code: string): Promise<{ balance_micro: number }> { + const res = guard401( + await fetch(`${GATEWAY}/api/v1/billing/redeem`, { + method: "POST", + headers: { "Content-Type": "application/json", ...bearer() }, + body: JSON.stringify({ code }), + }), + ); + return jsonOrThrow<{ balance_micro: number }>(res, "兑换失败"); +} + +// billingOrders 计费租户最近充值记录。 +export async function billingOrders(): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders`, { headers: bearer() })); + if (!res.ok) return []; + const d = (await res.json()) as { orders?: TopupOrder[] }; + return d.orders ?? []; +} + // 积分显示:micro(1e6) → 人类可读。与桌面端同一约定;小数去尾零(1.50 → 1.5)。 export function fmtCredits(micro: number): string { const v = micro / 1e6; diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx index 00d0af2..25b6fee 100644 --- a/sundynix-web/src/pages/Usage.tsx +++ b/sundynix-web/src/pages/Usage.tsx @@ -1,19 +1,43 @@ -import { useEffect, useState } from "react"; -import { Coins, ReceiptText } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Coins, ReceiptText, Ticket } from "lucide-react"; import { useTenant } from "../shell/AppShell"; -import { myUsage, fmtCredits, type MyUsage } from "../api"; -import { Badge, Panel, Table, Tr, Td, cn } from "../ui"; +import { myUsage, redeemCode, billingOrders, fmtCredits, type MyUsage, type TopupOrder } from "../api"; +import { Badge, Button, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui"; -// 用量与账单:余额 + 消耗趋势 + 区间合计 + 最近消耗(数据全部来自 /me/usage)。 -// 充值本期只有说明占位——支付是 P5,先不做假入口。 +// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。 +// 微信扫码支付在 P5.2 挂上(渠道适配器已留位),此前不做假支付入口。 export function Usage() { - const { ctx } = useTenant(); + const toast = useToast(); + const { ctx, refresh } = useTenant(); const [days, setDays] = useState(7); const [u, setU] = useState(null); + const [orders, setOrders] = useState([]); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); - useEffect(() => { + const load = useCallback(() => { myUsage(days).then(setU).catch(() => {}); - }, [days, ctx?.tenant?.id]); + billingOrders().then(setOrders).catch(() => {}); + }, [days]); + useEffect(load, [load, ctx?.tenant?.id]); + + const canTopup = ctx?.role !== "viewer"; // 真闸在后端(≥member),这里只是不摆没用的输入框 + + const redeem = async () => { + if (!code.trim() || busy) return; + setBusy(true); + try { + const r = await redeemCode(code.trim()); + toast.push("success", `已入账,当前余额 ${fmtCredits(r.balance_micro)} 积分`); + setCode(""); + load(); + refresh(); // 顶栏/概览的余额也跟着变 + } catch (e) { + toast.push("error", (e as Error).message); + } finally { + setBusy(false); + } + }; const maxTok = Math.max(...(u?.trend.map((d) => d.total_tok) ?? []), 1); const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`); @@ -39,9 +63,28 @@ export function Usage() { 余额不足,任务提交将被拒绝 ) : null} -

- 充值暂由平台管理员处理,在线支付即将上线。如需增加额度,请联系你的服务对接人。 -

+ {canTopup ? ( +
+
+ 兑换码充值 +
+
+ setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && redeem()} + placeholder="SDX-XXXX-XXXX-XXXX" + /> + + 微信扫码支付即将上线;兑换码请向平台获取。 +
+
+ ) : ( +

你是只读角色,充值请联系组织管理员。

+ )} {/* 趋势 */} @@ -90,6 +133,26 @@ export function Usage() { {(!u || u.recent.length === 0) &&

暂无消耗记录。

} + + {/* 最近充值(订单流:兑换码与将来的微信支付一个查法) */} + {orders.length > 0 && ( + + + {orders.map((o) => ( + + + + + + + ))} +
{new Date(o.created_at).toLocaleString()}{o.channel === "redeem" ? "兑换码" : o.channel === "wechat" ? "微信支付" : o.channel}+{fmtCredits(o.credits_micro)} + + {o.status === "paid" ? "已入账" : o.status === "pending" ? "待支付" : o.status} + +
+
+ )} ); }