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>
This commit is contained in:
Blizzard
2026-07-21 08:44:00 +08:00
parent a8a497b4ce
commit 07955ddf07
18 changed files with 592 additions and 27 deletions
+4 -4
View File
@@ -20,9 +20,9 @@ const (
type CreditLedger struct {
BaseModel
TenantID string `gorm:"size:64;index"`
Kind string `gorm:"size:16"` // grant / usage / adjust
Kind string `gorm:"size:16"` // grant / usage / adjust
CreditsMicro int64 `gorm:"column:credits_micro"` // 带符号增量(usage 为负)
Ref string `gorm:"size:64;index"` // 关联(usage→task_idgrant→订单号)
Ref string `gorm:"size:64;index"` // 关联(usage→task_idgrant→订单号)
Memo string `gorm:"size:255"`
}
@@ -36,8 +36,8 @@ type UsageRollup struct {
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"`
CreditsMicro int64 `gorm:"column:credits_micro"`
CostMicros int64 `gorm:"column:cost_micros"`
TaskCount int64
Currency string `gorm:"size:8"`
}
@@ -27,7 +27,7 @@ func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB {
}
var out []DatasourceKB
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k").
Select("k.id, k.name, k.space_id, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
Select("k.id, k.name, k.space_id, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, " +
"count(d.id) as doc_count, coalesce(sum(d.size),0) as total_words").
Joins("left join sundynix_doc d on d.kb = k.name and d.space_id = k.space_id and d.deleted_at is null").
Joins("left join sundynix_tenant t on t.id = k.tenant_id").
@@ -87,8 +87,8 @@ func (p *Postgres) PoorEvals(ctx context.Context, limit int) []PoorEval {
}
var out []PoorEval
p.db.WithContext(ctx).Table("sundynix_eval e").
Select("e.task_id, coalesce(t.name,'') as tenant_name, e.owner, e.overall, e.rule, e.llm, "+
"e.faithful, e.level, e.reason, e.sources, e.corrected, "+
Select("e.task_id, coalesce(t.name,'') as tenant_name, e.owner, e.overall, e.rule, e.llm, " +
"e.faithful, e.level, e.reason, e.sources, e.corrected, " +
"to_char(e.created_at,'YYYY-MM-DD HH24:MI') as created_at").
Joins("left join sundynix_tenant t on t.id = e.tenant_id").
Where("e.level in ('poor','warn') AND e.deleted_at IS NULL").
+7 -7
View File
@@ -24,8 +24,8 @@ type KB struct {
Kind string `gorm:"size:16"` // folder / project / case / general
}
func (KB) TableName() string { return "sundynix_kb" }
func (KB) isTenantScoped() {}
func (KB) TableName() string { return "sundynix_kb" }
func (KB) isTenantScoped() {}
// ListKB 列出某工作区的全部知识库(按创建时间;tenant 插件仍按 ctx 租户过滤)。
func (p *Postgres) ListKB(ctx context.Context, spaceID string) ([]KB, error) {
@@ -153,11 +153,11 @@ type DocLink struct {
BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(ReplaceDocLinks 按 space 的租户补)
SpaceID string `gorm:"size:64;index:idx_link_sf"`
Owner string `gorm:"size:64;index"` // 创建人(归属)
KB string `gorm:"size:64;index:idx_link_sf"`
FromID string `gorm:"size:24;index:idx_link_sf"` // 源文档 Doc.ID
ToID string `gorm:"size:24;index"` // 目标文档 Doc.ID(空=悬空:目标尚未入库)
ToName string `gorm:"size:160"` // [[原始名]],供悬空链接展示 / 目标入库后回填 ToID
Owner string `gorm:"size:64;index"` // 创建人(归属)
KB string `gorm:"size:64;index:idx_link_sf"`
FromID string `gorm:"size:24;index:idx_link_sf"` // 源文档 Doc.ID
ToID string `gorm:"size:24;index"` // 目标文档 Doc.ID(空=悬空:目标尚未入库)
ToName string `gorm:"size:160"` // [[原始名]],供悬空链接展示 / 目标入库后回填 ToID
}
func (DocLink) TableName() string { return "sundynix_doc_link" }
+4 -3
View File
@@ -10,9 +10,10 @@ type User struct {
BaseModel
Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:64"`
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
WechatOpenID string `gorm:"column:wechat_openid;size:64" json:"-"` // 微信登录唯一标识;邮箱用户为空(唯一性靠部分索引,见 pgsql.go
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
}
// Task 是一次提交的 Agent 编排任务(DSL)。
+6
View File
@@ -90,6 +90,12 @@ func OpenPostgres(dsn string) *Postgres {
log.Printf("[store] 历史 NULL 余额回填失败: %v", err)
}
// 微信 openid 部分唯一索引:只约束非空值。存量邮箱用户该列是空串 '' 而非 NULL
// 若建普通唯一索引,多个空串会互撞、AutoMigrate 直接失败(NULL 余额那次的同类坑)。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_wechat_openid ON sundynix_user (wechat_openid) WHERE wechat_openid <> ''`).Error; err != nil {
log.Printf("[store] 微信 openid 唯一索引创建失败: %v", err)
}
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
return &Postgres{db: db}
+26
View File
@@ -156,6 +156,32 @@ func asString(v any) string {
return ""
}
// ---- 微信登录 ticket ----
// ticket 是一次性、短命、高频轮询的临时态,用 Redis 存(不落 PG)。
// Redis 降级时回退进程内内存 map —— 本地开发无 Redis 也能登录;生产多实例下内存回退
// 会因 PC 轮询与微信回调可能落在不同实例而失效,所以生产**必须**有 Redis(服务状态页会亮)。
// WxTicketSet 写入 ticket 状态(JSON 值),带 TTL。
func (r *Redis) WxTicketSet(ctx context.Context, ticket, value string, ttl time.Duration) error {
if r.rdb == nil {
memTicketSet(ticket, value, ttl)
return nil
}
return r.rdb.Set(ctx, "wxlogin:"+ticket, value, ttl).Err()
}
// WxTicketGet 读取 ticket 状态;不存在或过期返回空串。
func (r *Redis) WxTicketGet(ctx context.Context, ticket string) string {
if r.rdb == nil {
return memTicketGet(ticket)
}
v, err := r.rdb.Get(ctx, "wxlogin:"+ticket).Result()
if err != nil {
return ""
}
return v
}
// Close 释放底层连接。
func (r *Redis) Close() {
if r.rdb != nil {
@@ -0,0 +1,40 @@
package store
import (
"sync"
"time"
)
// Redis 降级时的进程内 ticket 兜底:仅供本地开发/单实例。带惰性过期,避免泄漏。
var (
memTicketMu sync.Mutex
memTickets = map[string]memTicketEntry{}
)
type memTicketEntry struct {
value string
expireAt time.Time
}
func memTicketSet(ticket, value string, ttl time.Duration) {
memTicketMu.Lock()
defer memTicketMu.Unlock()
memTickets[ticket] = memTicketEntry{value: value, expireAt: time.Now().Add(ttl)}
// 顺带清理已过期项(登录频率低,全表扫无压力)
now := time.Now()
for k, e := range memTickets {
if now.After(e.expireAt) {
delete(memTickets, k)
}
}
}
func memTicketGet(ticket string) string {
memTicketMu.Lock()
defer memTicketMu.Unlock()
e, ok := memTickets[ticket]
if !ok || time.Now().After(e.expireAt) {
return ""
}
return e.value
}
+2 -2
View File
@@ -395,7 +395,7 @@ func (p *Postgres) MigrateAgentSpaces(ctx context.Context) error {
// 回填 space_id:按 agent.tenant_id + agent.owner 命中个人空间。
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = a.tenant_id AND s.creator = a.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
if err := p.db.WithContext(ctx).Exec(
"UPDATE sundynix_agent a SET space_id = ("+sub+") WHERE (a.space_id IS NULL OR a.space_id = '') AND a.owner <> ''",
"UPDATE sundynix_agent a SET space_id = (" + sub + ") WHERE (a.space_id IS NULL OR a.space_id = '') AND a.owner <> ''",
).Error; err != nil {
return err
}
@@ -445,7 +445,7 @@ func (p *Postgres) MigrateKBSpaces(ctx context.Context) error {
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = t.tenant_id AND s.creator = t.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
for _, tbl := range []string{"sundynix_kb", "sundynix_doc", "sundynix_doc_link"} {
if err := p.db.WithContext(ctx).Exec(
"UPDATE "+tbl+" t SET space_id = ("+sub+") WHERE (t.space_id IS NULL OR t.space_id = '') AND t.owner <> ''",
"UPDATE " + tbl + " t SET space_id = (" + sub + ") WHERE (t.space_id IS NULL OR t.space_id = '') AND t.owner <> ''",
).Error; err != nil {
return err
}
+1 -2
View File
@@ -295,7 +295,7 @@ func (p *Postgres) ListTenants(ctx context.Context) ([]TenantInfo, error) {
}
var out []TenantInfo
err := p.db.WithContext(ctx).Table("sundynix_tenant t").
Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, "+
Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, " +
"(SELECT count(*) FROM sundynix_tenant_member m WHERE m.tenant_id = t.id AND m.status = 'active') as members").
Where("t.deleted_at IS NULL").Order("t.created_at asc").Scan(&out).Error
return out, err
@@ -402,4 +402,3 @@ func (p *Postgres) SetTenantStatus(ctx context.Context, tenantID string, status
}
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("status", status).Error
}
+2 -2
View File
@@ -18,9 +18,9 @@ import (
type UsageEvent struct {
BaseModel
TenantID string `gorm:"size:64;index"`
Owner string `gorm:"size:64;index"` // 提交者 user.id= 事件 UserID
Owner string `gorm:"size:64;index"` // 提交者 user.id= 事件 UserID
TaskID string `gorm:"size:64;uniqueIndex"` // 一任务一计量 → 幂等键
Model string `gorm:"size:64"` // 计费模型名(空=按激活 chat 模型近似)
Model string `gorm:"size:64"` // 计费模型名(空=按激活 chat 模型近似)
PromptTok int
CompTok int
TotalTok int
+36
View File
@@ -53,3 +53,39 @@ func (p *Postgres) GetUserByID(ctx context.Context, id string) (*User, error) {
}
return &u, nil
}
// GetUserByWechatOpenID 按微信 openid 查用户;不存在返回 (nil, nil)。
func (p *Postgres) GetUserByWechatOpenID(ctx context.Context, openID string) (*User, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if openID == "" {
return nil, nil
}
var u User
err := p.db.WithContext(ctx).Where("wechat_openid = ?", openID).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// CreateWechatUser 为首次微信登录的用户建号(无邮箱/密码)。name 是展示名(微信昵称或默认)。
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if openID == "" {
return nil, errors.New("openid 必填")
}
u := &User{WechatOpenID: openID, Name: name}
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
return nil, err
}
return u, nil
}
// (测试辅助见 user_wechat_test.go)
@@ -0,0 +1,35 @@
package store
import (
"context"
"testing"
)
// 微信首次登录建号:无邮箱、按 openid 可查回。
func TestCreateAndGetWechatUser(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
// 不存在时返回 (nil, nil),不报错
if u, err := p.GetUserByWechatOpenID(ctx, "openid-x"); err != nil || u != nil {
t.Fatalf("未建号应返回 nil,nil,得 %v,%v", u, err)
}
u, err := p.CreateWechatUser(ctx, "openid-x", "微信用户")
if err != nil {
t.Fatalf("建号失败: %v", err)
}
if u.Email != "" {
t.Fatalf("微信用户不该有邮箱,得 %q", u.Email)
}
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
if err != nil || got == nil || got.ID != u.ID {
t.Fatalf("按 openid 查不回: %v,%v", got, err)
}
// 空 openid 查询不匹配任何人(避免存量邮箱用户的空串被 openid 查询命中)
if got, _ := p.GetUserByWechatOpenID(ctx, ""); got != nil {
t.Fatalf("空 openid 不该命中任何用户,得 %+v", got)
}
}