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:
@@ -63,9 +63,9 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
pyUp bool // mcp-py 在线
|
||||
pyTools []toolInfo // mcp-py 注册工具
|
||||
pyLatency int // mcp-py 探针耗时
|
||||
dispUp bool // dispatcher 在线
|
||||
dispDetail string // dispatcher 详情(模型/运行时长)
|
||||
dispLatency int // dispatcher 探针耗时
|
||||
dispUp bool // dispatcher 在线
|
||||
dispDetail string // dispatcher 详情(模型/运行时长)
|
||||
dispLatency int // dispatcher 探针耗时
|
||||
|
||||
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
||||
)
|
||||
|
||||
// 微信公众号(服务号)扫码登录。设计见 internal/wechat/mp.go。
|
||||
//
|
||||
// 端点:
|
||||
// POST /api/v1/wx/mp/ticket 建 ticket,返回二维码里要放的 URL
|
||||
// GET /wx/mp?t=<ticket> 用户扫码后微信打开这个,302 到微信授权页
|
||||
// GET /api/v1/wx/mp/callback 微信授权后回调,换 openid、找/建用户、置 ticket 已授权
|
||||
// GET /api/v1/wx/mp/poll?t= PC 端轮询,已授权则签发 JWT
|
||||
|
||||
const (
|
||||
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
|
||||
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
|
||||
)
|
||||
|
||||
// wxTicketState 是 ticket 在 Redis 里的值。
|
||||
type wxTicketState struct {
|
||||
Status string `json:"status"` // pending / authorized / consumed
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) loadWechatMP(ctx context.Context) wechat.Config {
|
||||
raw := h.db.GetSetting(ctx, SettingWechatMP)
|
||||
if raw == "" {
|
||||
return wechat.Config{}
|
||||
}
|
||||
var c wechat.Config
|
||||
if json.Unmarshal([]byte(raw), &c) != nil {
|
||||
return wechat.Config{}
|
||||
}
|
||||
return c.DecryptFromStore()
|
||||
}
|
||||
|
||||
func newTicket() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// WxMPTicket: POST /api/v1/wx/mp/ticket —— 建一个待授权 ticket,返回二维码内容 URL。
|
||||
func (h *Handler) WxMPTicket(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
if !cfg.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
|
||||
return
|
||||
}
|
||||
ticket := newTicket()
|
||||
st, _ := json.Marshal(wxTicketState{Status: "pending"})
|
||||
if err := h.cache.WxTicketSet(c.Request.Context(), ticket, string(st), wxTicketTTL); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
|
||||
return
|
||||
}
|
||||
// 二维码里放的是本服务的中转地址,不是微信授权地址本身 —— 授权地址带 secret 相关参数,
|
||||
// 且要在用户扫码那一刻才拼(redirect_uri 要精确匹配),所以扫码后再由 /wx/mp 现拼现跳。
|
||||
base := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if base == "" {
|
||||
base = "https://" + c.Request.Host
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ticket": ticket,
|
||||
"qr_url": base + "/wx/mp?t=" + ticket,
|
||||
"expires_in": int(wxTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// WxMPEntry: GET /wx/mp?t=<ticket> —— 用户微信扫码后打开,302 到微信 OAuth 授权页。
|
||||
// 这是公开路由(微信内置浏览器访问,无鉴权头)。
|
||||
func (h *Handler) WxMPEntry(c *gin.Context) {
|
||||
ticket := c.Query("t")
|
||||
if ticket == "" {
|
||||
c.String(http.StatusBadRequest, "缺少登录票据")
|
||||
return
|
||||
}
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
if !cfg.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "微信登录未配置")
|
||||
return
|
||||
}
|
||||
// ticket 必须存在且仍 pending,否则可能是过期或伪造
|
||||
if h.cache.WxTicketGet(c.Request.Context(), ticket) == "" {
|
||||
c.String(http.StatusBadRequest, "登录二维码已过期,请刷新重试")
|
||||
return
|
||||
}
|
||||
base := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if base == "" {
|
||||
base = "https://" + c.Request.Host
|
||||
}
|
||||
redirectURI := base + "/api/v1/wx/mp/callback"
|
||||
c.Redirect(http.StatusFound, cfg.AuthorizeURL(redirectURI, ticket))
|
||||
}
|
||||
|
||||
// WxMPCallback: GET /api/v1/wx/mp/callback?code&state —— 微信授权后回调(公开路由)。
|
||||
// 用 code 换 openid → 找/建用户 → ticket 置 authorized。
|
||||
func (h *Handler) WxMPCallback(c *gin.Context) {
|
||||
code, ticket := c.Query("code"), c.Query("state")
|
||||
if code == "" || ticket == "" {
|
||||
c.String(http.StatusBadRequest, "授权参数缺失")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
// ticket 必须仍有效(防止拿别人的回调套自己的 ticket)
|
||||
if h.cache.WxTicketGet(ctx, ticket) == "" {
|
||||
c.String(http.StatusBadRequest, "登录已过期,请在电脑上重新扫码")
|
||||
return
|
||||
}
|
||||
cfg := h.loadWechatMP(ctx)
|
||||
if !cfg.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "微信登录未配置")
|
||||
return
|
||||
}
|
||||
info, err := cfg.ExchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err)
|
||||
c.String(http.StatusBadGateway, "微信授权失败,请重试")
|
||||
return
|
||||
}
|
||||
|
||||
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套)
|
||||
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "登录失败,请重试")
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户")
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err)
|
||||
c.String(http.StatusBadGateway, "登录失败,请重试")
|
||||
return
|
||||
}
|
||||
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
||||
log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e)
|
||||
}
|
||||
}
|
||||
|
||||
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
|
||||
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
|
||||
c.String(http.StatusBadGateway, "登录失败,请重试")
|
||||
return
|
||||
}
|
||||
// 微信内置浏览器里显示一句提示即可,登录动作在 PC 端完成
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:sans-serif;text-align:center;padding-top:30vh;color:#333"><h2>✅ 登录成功</h2><p style="color:#888">请回到电脑继续</p></body>`)
|
||||
}
|
||||
|
||||
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket> —— PC 端轮询登录状态(公开路由)。
|
||||
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
|
||||
func (h *Handler) WxMPPoll(c *gin.Context) {
|
||||
ticket := c.Query("t")
|
||||
if ticket == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少票据"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
raw := h.cache.WxTicketGet(ctx, ticket)
|
||||
if raw == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||||
return
|
||||
}
|
||||
var st wxTicketState
|
||||
if json.Unmarshal([]byte(raw), &st) != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||||
return
|
||||
}
|
||||
if st.Status != "authorized" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": st.Status}) // pending
|
||||
return
|
||||
}
|
||||
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿到两个令牌
|
||||
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
|
||||
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
|
||||
|
||||
u, err := h.db.GetUserByID(ctx, st.UserID)
|
||||
if err != nil || u == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||||
return
|
||||
}
|
||||
issueToken(c, u)
|
||||
}
|
||||
|
||||
// ---- 管理端配置 ----
|
||||
|
||||
// AdminGetWechatMP: GET /api/v1/admin/wechat-mp —— 当前配置(secret 不回显,只报有无)。
|
||||
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appid": cfg.AppID,
|
||||
"base_url": cfg.BaseURL,
|
||||
"has_app_secret": cfg.AppSecret != "",
|
||||
"enabled": cfg.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// AdminSaveWechatMP: PUT /api/v1/admin/wechat-mp —— 保存配置。
|
||||
// app_secret 留空 = 沿用已存(只写不回显,同微信支付 APIv3 密钥)。
|
||||
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
||||
var b struct {
|
||||
AppID string `json:"appid"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
secret := strings.TrimSpace(b.AppSecret)
|
||||
if secret == "" {
|
||||
secret = h.loadWechatMP(ctx).AppSecret // 留空沿用旧密钥(明文,下面统一加密)
|
||||
}
|
||||
cfg := wechat.Config{
|
||||
AppID: strings.TrimSpace(b.AppID),
|
||||
AppSecret: secret,
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
|
||||
}
|
||||
stored, err := cfg.EncryptedForStore()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(stored)
|
||||
if err := h.db.SetSetting(ctx, SettingWechatMP, string(raw)); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
|
||||
}
|
||||
@@ -47,11 +47,18 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
r.GET("/healthz", h.Healthz)
|
||||
r.GET("/readyz", h.Readyz)
|
||||
// 微信扫码入口:用户在微信里打开二维码指向的这个地址,服务端 302 到微信授权页。
|
||||
// 顶级路径(非 /api)——二维码 URL 越短越好,且要落在「网页授权域名」根下。
|
||||
r.GET("/wx/mp", h.WxMPEntry)
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
|
||||
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
|
||||
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
|
||||
// 微信扫码登录(全公开:ticket 是唯一凭证;微信浏览器/PC 轮询都无鉴权头)
|
||||
api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票,返回二维码 URL
|
||||
api.GET("/wx/mp/callback", h.WxMPCallback) // 微信授权回调
|
||||
api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态
|
||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||
@@ -148,6 +155,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.GET("/redeem-codes", h.AdminRedeemCodes)
|
||||
admin.GET("/packs", h.AdminPacks)
|
||||
admin.PUT("/packs", h.AdminSavePack)
|
||||
admin.GET("/wechat-mp", h.AdminGetWechatMP) // 微信扫码登录配置
|
||||
admin.PUT("/wechat-mp", h.AdminSaveWechatMP)
|
||||
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
||||
|
||||
@@ -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_id,grant→订单号)
|
||||
Ref string `gorm:"size:64;index"` // 关联(usage→task_id,grant→订单号)
|
||||
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").
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package wechat 实现微信公众号(服务号)网页授权登录。
|
||||
//
|
||||
// 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」,
|
||||
// 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配
|
||||
// 「网页授权域名」,已认证服务号默认具备,副作用最小。
|
||||
//
|
||||
// 登录流程(PC 端):
|
||||
// 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t=<ticket>
|
||||
// 2. 用户微信扫码 → 微信内置浏览器打开该 URL → 后端 302 到微信 OAuth 授权页(state=ticket)
|
||||
// 3. 用户「允许」→ 微信回调 /api/v1/wx/mp/callback?code&state → 用 code 换 openid
|
||||
// 4. openid 找/建用户 → ticket 置 authorized(userID)
|
||||
// 5. PC 端轮询 ticket → authorized → 签发 JWT
|
||||
//
|
||||
// 配置(appid/secret)后台可改、密钥加密入库,与微信支付同一套 secrets。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
// Config 是公众号网页授权所需配置。AppSecret 加密入库,只写不回显(同微信支付 APIv3 密钥)。
|
||||
type Config struct {
|
||||
AppID string `json:"appid"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
// 授权回调基地址,如 https://agent.sundynix.cn。留空则用请求 Host 推断。
|
||||
// 显式配置更稳:微信要求回调域名与「网页授权域名」完全一致,靠 Host 推断在反代下易错。
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
// Enabled 报告配置是否完整到可用。
|
||||
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" }
|
||||
|
||||
// EncryptedForStore 返回一份 AppSecret 已加密的副本,用于落库。
|
||||
func (c Config) EncryptedForStore() (Config, error) {
|
||||
if c.AppSecret == "" {
|
||||
return c, nil
|
||||
}
|
||||
enc, err := secrets.Encrypt(c.AppSecret)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.AppSecret = enc
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DecryptFromStore 把库内密文 AppSecret 还原为明文(无前缀的历史明文透传)。
|
||||
func (c Config) DecryptFromStore() Config {
|
||||
if c.AppSecret != "" {
|
||||
if plain, err := secrets.Decrypt(c.AppSecret); err == nil {
|
||||
c.AppSecret = plain
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// AuthorizeURL 构造微信 OAuth 授权跳转地址。
|
||||
// scope=snsapi_base:只拿 openid,不弹授权页、用户无感(登录只需要唯一标识,够用了)。
|
||||
// state 回传我们的 ticket,用于把回调关联回发起登录的那个 PC 会话。
|
||||
func (c Config) AuthorizeURL(redirectURI, state string) string {
|
||||
q := url.Values{}
|
||||
q.Set("appid", c.AppID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "snsapi_base")
|
||||
q.Set("state", state)
|
||||
// #wechat_redirect 是微信要求的锚点,缺了不跳转
|
||||
return "https://open.weixin.qq.com/connect/oauth2/authorize?" + q.Encode() + "#wechat_redirect"
|
||||
}
|
||||
|
||||
// UserInfo 是 code 换取的结果(snsapi_base 下只有 openid)。
|
||||
type UserInfo struct {
|
||||
OpenID string
|
||||
}
|
||||
|
||||
// ExchangeCode 用授权 code 换 openid(网页授权专用接口,不占 access_token 的 IP 白名单?——
|
||||
// 实际上仍走 api.weixin.qq.com,出网 IP 必须在白名单里,否则报 40164)。
|
||||
func (c Config) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
|
||||
q := url.Values{}
|
||||
q.Set("appid", c.AppID)
|
||||
q.Set("secret", c.AppSecret)
|
||||
q.Set("code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
endpoint := "https://api.weixin.qq.com/sns/oauth2/access_token?" + q.Encode()
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信换 openid 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 微信无论成败都返回 200 + JSON;errcode 非 0 才是失败。
|
||||
var r struct {
|
||||
OpenID string `json:"openid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, fmt.Errorf("解析微信响应失败: %s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
if r.ErrCode != 0 || r.OpenID == "" {
|
||||
// 40163=code 已使用,40029=code 无效,40164=IP 不在白名单——原样带出便于排查
|
||||
return nil, fmt.Errorf("微信换 openid 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
|
||||
}
|
||||
return &UserInfo{OpenID: r.OpenID}, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_Enabled(t *testing.T) {
|
||||
if (Config{AppID: "x"}).Enabled() {
|
||||
t.Fatal("缺 secret 不该 enabled")
|
||||
}
|
||||
if !(Config{AppID: "x", AppSecret: "y"}).Enabled() {
|
||||
t.Fatal("齐全应 enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeURL(t *testing.T) {
|
||||
c := Config{AppID: "wxAPP"}
|
||||
u := c.AuthorizeURL("https://a.b/cb", "tkt123")
|
||||
// 必备参数与微信要求的锚点
|
||||
for _, want := range []string{
|
||||
"open.weixin.qq.com/connect/oauth2/authorize",
|
||||
"appid=wxAPP",
|
||||
"scope=snsapi_base",
|
||||
"state=tkt123",
|
||||
"redirect_uri=https%3A%2F%2Fa.b%2Fcb", // 必须 URL 编码
|
||||
"#wechat_redirect", // 缺了微信不跳转
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Fatalf("授权 URL 缺 %q:%s", want, u)
|
||||
}
|
||||
}
|
||||
// 锚点必须在最后
|
||||
if !strings.HasSuffix(u, "#wechat_redirect") {
|
||||
t.Fatalf("#wechat_redirect 必须在末尾:%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// secret 加密往返:落库是密文,取出还原成明文。
|
||||
func TestConfig_SecretRoundTrip(t *testing.T) {
|
||||
c := Config{AppID: "x", AppSecret: "the-plain-secret"}
|
||||
stored, err := c.EncryptedForStore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.AppSecret == "the-plain-secret" {
|
||||
t.Fatal("落库的 secret 不该是明文")
|
||||
}
|
||||
back := stored.DecryptFromStore()
|
||||
if back.AppSecret != "the-plain-secret" {
|
||||
t.Fatalf("还原失败:%q", back.AppSecret)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user