Files
Blizzard 07955ddf07 feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感),
不接管消息推送,副作用最小。

流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 →
回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized →
PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。

- 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets;
- ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis);
- User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一:
  存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败
  —— 与之前 NULL 余额同类的坑,这次提前避开。

单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、
建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了,
留待部署后真机扫码。

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

171 lines
6.8 KiB
Go
Raw Permalink 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 store
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 积分账本分录类型。
const (
LedgerGrant = "grant" // 充值 / 发放(+
LedgerUsage = "usage" // 用量消耗(−)
LedgerAdjust = "adjust" // 人工校正(±)
)
// CreditLedger 是积分账本一条分录(append-only)——扣量 / 充值的事实源。
// 租户当前余额 = SUM(credits_micro);为快取物化在 sundynix_tenant.credit_balance_micro。
// 表名 sundynix_credit_ledger。
type CreditLedger struct {
BaseModel
TenantID string `gorm:"size:64;index"`
Kind string `gorm:"size:16"` // grant / usage / adjust
CreditsMicro int64 `gorm:"column:credits_micro"` // 带符号增量(usage 为负)
Ref string `gorm:"size:64;index"` // 关联(usage→task_idgrant→订单号)
Memo string `gorm:"size:255"`
}
func (CreditLedger) TableName() string { return "sundynix_credit_ledger" }
func (CreditLedger) isTenantScoped() {}
// UsageRollup 是用量按 租户/天 的聚合快照(配额 / 账单读一行,不扫明细)。
// 消费 UsageEvent 时 upsert 累加。表名 sundynix_usage_rollup。
type UsageRollup struct {
BaseModel
TenantID string `gorm:"size:64;uniqueIndex:idx_rollup_td"`
Day string `gorm:"size:8;uniqueIndex:idx_rollup_td"` // 20060102
TotalTok int64
CreditsMicro int64 `gorm:"column:credits_micro"`
CostMicros int64 `gorm:"column:cost_micros"`
TaskCount int64
Currency string `gorm:"size:8"`
}
func (UsageRollup) TableName() string { return "sundynix_usage_rollup" }
func (UsageRollup) isTenantScoped() {}
// applyUsageCredit 在事务内记 usage 分录(负)+ 扣物化余额(软扣,可为负)。
// 幂等由上层的 usage_event(task_id 唯一) 插入门控保证——仅在新明细插入后调用一次。
func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64) error {
if tenantID == "" || creditsMicro <= 0 {
return nil
}
if err := tx.Create(&CreditLedger{
TenantID: tenantID, Kind: LedgerUsage, CreditsMicro: -creditsMicro, Ref: taskID, Memo: "task usage",
}).Error; err != nil {
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
}
// upsertRollup 在事务内累加 租户/天 聚合。
func upsertRollup(tx *gorm.DB, tenantID, day string, tok, creditsMicro, costMicros int64, currency string) error {
if tenantID == "" {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}},
DoUpdates: clause.Assignments(map[string]any{
"total_tok": gorm.Expr("sundynix_usage_rollup.total_tok + ?", tok),
"credits_micro": gorm.Expr("sundynix_usage_rollup.credits_micro + ?", creditsMicro),
"cost_micros": gorm.Expr("sundynix_usage_rollup.cost_micros + ?", costMicros),
"task_count": gorm.Expr("sundynix_usage_rollup.task_count + 1"),
}),
}).Create(&UsageRollup{
TenantID: tenantID, Day: day, TotalTok: tok, CreditsMicro: creditsMicro,
CostMicros: costMicros, TaskCount: 1, Currency: currency,
}).Error
}
// UsageDay 是按天聚合的用量(趋势用)。
type UsageDay struct {
Day string `json:"day"`
TotalTok int64 `json:"total_tok"`
CreditsMicro int64 `json:"credits_micro"`
CostMicros int64 `json:"cost_micros"`
TaskCount int64 `json:"task_count"`
}
// UsageTenantSum 是某租户在区间内的用量汇总(+当前余额),供全平台排行。
type UsageTenantSum struct {
TenantID string `json:"tenant_id"`
Name string `json:"name"`
TotalTok int64 `json:"total_tok"`
CreditsMicro int64 `json:"credits_micro"`
CostMicros int64 `json:"cost_micros"`
TaskCount int64 `json:"task_count"`
BalanceMicro int64 `json:"balance_micro"`
}
// UsageTrend 返回按天聚合的用量(tenantID 空=全平台跨租户 SUM)。区间 [from,to] 为 YYYYMMDD。
// 需系统级 ctxstore.WithoutTenant)才跨租户;否则受插件按调用者租户过滤。
func (p *Postgres) UsageTrend(ctx context.Context, tenantID, from, to string) []UsageDay {
if p.db == nil {
return nil
}
q := p.db.WithContext(ctx).Model(&UsageRollup{}).
Select("day, sum(total_tok) as total_tok, sum(credits_micro) as credits_micro, sum(cost_micros) as cost_micros, sum(task_count) as task_count").
Where("day >= ? AND day <= ?", from, to).Group("day").Order("day")
if tenantID != "" {
q = q.Where("tenant_id = ?", tenantID)
}
var out []UsageDay
q.Scan(&out)
return out
}
// UsageByTenant 返回各租户在区间内的用量汇总(含当前余额),按积分消耗降序,供全平台排行。
func (p *Postgres) UsageByTenant(ctx context.Context, from, to string, limit int) []UsageTenantSum {
if p.db == nil {
return nil
}
if limit <= 0 {
limit = 20
}
var out []UsageTenantSum
p.db.WithContext(ctx).Table("sundynix_usage_rollup r").
Select("r.tenant_id, t.name, sum(r.total_tok) as total_tok, sum(r.credits_micro) as credits_micro, "+
"sum(r.cost_micros) as cost_micros, sum(r.task_count) as task_count, "+
"coalesce(max(t.credit_balance_micro),0) as balance_micro").
Joins("left join sundynix_tenant t on t.id = r.tenant_id").
Where("r.day >= ? AND r.day <= ?", from, to).
Group("r.tenant_id, t.name").Order("credits_micro desc").Limit(limit).Scan(&out)
return out
}
// TenantBalance 返回某租户当前物化积分余额(微积分)。
func (p *Postgres) TenantBalance(ctx context.Context, tenantID string) int64 {
if p.db == nil || tenantID == "" {
return 0
}
var t Tenant
if err := p.db.WithContext(ctx).Select("credit_balance_micro").First(&t, "id = ?", tenantID).Error; err != nil {
return 0
}
return t.CreditBalanceMicro
}
// GrantCredits 给租户充值 / 发放积分(记 grant 分录 + 增物化余额)。供充值 / 套餐发放 / 人工校正复用。
func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, creditsMicro int64, ref, memo string) error {
if p.db == nil || tenantID == "" || creditsMicro == 0 {
return nil
}
if kind == "" {
kind = LedgerGrant
}
// 跨租户管理操作:admin 请求 ctx 带着 admin 自己的租户,须旁路,否则 tenant 插件会把
// 账本分录的 tenant_id 覆盖成 admin 的租户(余额记到目标租户、分录却记到 admin,破坏对账)。
ctx = WithoutTenant(ctx)
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&CreditLedger{
TenantID: tenantID, Kind: kind, CreditsMicro: creditsMicro, Ref: ref, Memo: memo,
}).Error; err != nil {
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
})
}