Merge pull request 'feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询' (#9) from feat/site into main
deploy-132 / deploy (push) Successful in 2m29s
deploy-132 / deploy (push) Successful in 2m29s
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
@@ -227,6 +227,29 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
|
||||
}
|
||||
|
||||
// ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)----
|
||||
// ---- 微信扫码登录配置 ----
|
||||
export interface WechatMPConfig {
|
||||
appid: string;
|
||||
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
|
||||
has_app_secret: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function getWechatMP(): Promise<WechatMPConfig> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
|
||||
return { appid: d.appid ?? "", token: d.token ?? "", has_app_secret: !!d.has_app_secret, enabled: !!d.enabled };
|
||||
}
|
||||
|
||||
// app_secret 传空串 = 沿用已保存的。
|
||||
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string }): Promise<{ enabled: boolean }> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||
return { enabled: !!d.enabled };
|
||||
}
|
||||
|
||||
export interface WechatPayConfig {
|
||||
mchid: string;
|
||||
cert_serial: string;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api";
|
||||
|
||||
// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。
|
||||
// AppSecret 只写不回显(密文入库,与微信支付 APIv3 密钥同一套加密)。
|
||||
export function LoginConfigPage() {
|
||||
const [cfg, setCfg] = useState<WechatMPConfig | null>(null);
|
||||
const [appid, setAppid] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [secret, setSecret] = useState(""); // 留空=沿用已存
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getWechatMP()
|
||||
.then((c) => {
|
||||
setCfg(c);
|
||||
setAppid(c.appid);
|
||||
setToken(c.token);
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setOk(false);
|
||||
try {
|
||||
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() });
|
||||
setSecret("");
|
||||
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), has_app_secret: c.has_app_secret || !!secret, enabled: r.enabled } : c));
|
||||
setOk(true);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 回调 URL 用当前后端地址推断(生产 GATEWAY 为空串=同源,显示成本域名)
|
||||
const origin = GATEWAY || (typeof window !== "undefined" ? window.location.origin : "");
|
||||
const callbackURL = origin.replace(/:\d+$/, "") + "/wx/mp/callback";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">登录设置 · 微信扫码</h3>
|
||||
<p className="text-xs text-gray-400">公众号扫码登录(登录即引导关注)。AppSecret 加密入库、只写不回显,随时可换</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">公众号配置</h4>
|
||||
{cfg?.enabled ? (
|
||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">已启用</span>
|
||||
) : (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">未启用(三项齐全才生效)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="text-xs text-gray-500">
|
||||
AppID
|
||||
<input value={appid} onChange={(e) => setAppid(e.target.value)} placeholder="wx..."
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
AppSecret{cfg?.has_app_secret && <span className="ml-1 text-emerald-600">已保存</span>}
|
||||
<input type="password" value={secret} onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder={cfg?.has_app_secret ? "留空则沿用已保存的" : "公众号开发密钥"}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
Token(消息推送令牌,须与公众平台「服务器配置」里填的完全一致)
|
||||
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||
{ok && <p className="mt-2 text-xs text-emerald-600">已保存</p>}
|
||||
|
||||
<div className="mt-3">
|
||||
<button onClick={() => void save()} disabled={busy}
|
||||
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置清单:把「必须在公众平台做什么」写在眼前,省得来回翻文档 */}
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">还需在微信公众平台「服务器配置」里配置</h4>
|
||||
<ol className="list-decimal space-y-1.5 pl-4">
|
||||
<li>服务器地址(URL) 填 <code className="break-all rounded bg-white px-1">{callbackURL}</code></li>
|
||||
<li>令牌(Token) 填与上面<b>完全一致</b>的那串</li>
|
||||
<li>消息加解密方式选 <b>明文模式</b>(本服务按明文处理,选其它会验签失败)</li>
|
||||
<li>「IP 白名单」加上服务器出网的公网 IP(否则拉取 access_token 报 40164,二维码建不出来)</li>
|
||||
<li>保存服务器配置时微信会即时回调本服务验证——需先部署好本页配置再去点提交</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||
import { LoginConfigPage } from "./pages/LoginConfigPage";
|
||||
import { lazy, type ReactNode } from "react";
|
||||
|
||||
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
||||
@@ -77,6 +78,13 @@ export const routes: RouteDef[] = [
|
||||
ready: true,
|
||||
element: <ModelConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "login-config",
|
||||
label: "登录设置",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <LoginConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "datasources",
|
||||
label: "数据源 & RAG",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
||||
)
|
||||
|
||||
// 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
|
||||
//
|
||||
// 端点:
|
||||
// POST /api/v1/wx/mp/ticket 建 login ticket + 微信二维码,返回二维码图 URL
|
||||
// GET /wx/mp/callback 服务器配置 URL 验证(echostr)
|
||||
// POST /wx/mp/callback 事件推送:关注/扫码 → 置 ticket 已授权
|
||||
// GET /api/v1/wx/mp/poll?t= PC 轮询登录态,已授权则签发 JWT
|
||||
|
||||
const (
|
||||
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
|
||||
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
|
||||
)
|
||||
|
||||
// access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)。
|
||||
var wxTokenMu sync.Mutex
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// accessToken 取(缓存优先)微信 access_token。
|
||||
func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, error) {
|
||||
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
wxTokenMu.Lock()
|
||||
defer wxTokenMu.Unlock()
|
||||
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
|
||||
return t, nil
|
||||
}
|
||||
token, ttl, err := cfg.FetchAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ttl > 200 {
|
||||
ttl -= 200 // 安全边界,避免临界过期
|
||||
}
|
||||
h.cache.WxTokenSet(ctx, cfg.AppID, token, time.Duration(ttl)*time.Second)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func newTicket() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// WxMPTicket: POST /api/v1/wx/mp/ticket
|
||||
func (h *Handler) WxMPTicket(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cfg := h.loadWechatMP(ctx)
|
||||
if !cfg.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
|
||||
return
|
||||
}
|
||||
token, err := h.accessToken(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 取 access_token 失败: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "微信登录暂不可用"})
|
||||
return
|
||||
}
|
||||
ticket := newTicket()
|
||||
qrURL, err := cfg.CreateLoginQR(ctx, token, ticket, int(wxTicketTTL.Seconds()))
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 建二维码失败: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败"})
|
||||
return
|
||||
}
|
||||
st, _ := json.Marshal(wxTicketState{Status: "pending"})
|
||||
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
|
||||
}
|
||||
|
||||
// WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证。
|
||||
func (h *Handler) WxMPVerify(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
|
||||
c.String(http.StatusForbidden, "signature check failed")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, c.Query("echostr"))
|
||||
}
|
||||
|
||||
// WxMPEvent: POST /wx/mp/callback —— 事件推送。
|
||||
// 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"。
|
||||
func (h *Handler) WxMPEvent(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cfg := h.loadWechatMP(ctx)
|
||||
// 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
|
||||
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
|
||||
c.String(http.StatusForbidden, "signature check failed")
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
ev, err := wechat.ParseEvent(body)
|
||||
if err != nil || !ev.IsLoginScan() {
|
||||
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
|
||||
return
|
||||
}
|
||||
|
||||
ticket, openID := ev.Scene(), ev.FromUserName
|
||||
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
|
||||
c.String(http.StatusOK, "success")
|
||||
return
|
||||
}
|
||||
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
||||
if err != nil {
|
||||
c.String(http.StatusOK, "success")
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
u, err = h.db.CreateWechatUser(ctx, openID, "微信用户")
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
|
||||
c.String(http.StatusOK, "success")
|
||||
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})
|
||||
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
|
||||
c.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
|
||||
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})
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 管理端配置 ----
|
||||
|
||||
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appid": cfg.AppID,
|
||||
"token": cfg.Token,
|
||||
"has_app_secret": cfg.AppSecret != "",
|
||||
"enabled": cfg.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
||||
var b struct {
|
||||
AppID string `json:"appid"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
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, Token: strings.TrimSpace(b.Token)}
|
||||
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
|
||||
}
|
||||
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
|
||||
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)
|
||||
// 微信公众号消息推送回调(服务器配置 URL):GET 验签回 echostr,POST 收关注/扫码事件。
|
||||
// 顶级路径(非 /api),微信服务器直接访问,无鉴权头。
|
||||
r.GET("/wx/mp/callback", h.WxMPVerify)
|
||||
r.POST("/wx/mp/callback", h.WxMPEvent)
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
|
||||
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
|
||||
// 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头)
|
||||
api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码
|
||||
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) // 订阅套餐(含下架)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
Email string `gorm:"uniqueIndex;size:255"`
|
||||
Name string `gorm:"size:64"`
|
||||
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;空/失效=用活跃租户的个人空间)
|
||||
}
|
||||
|
||||
@@ -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,52 @@ 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
|
||||
}
|
||||
|
||||
// WxTokenGet/Set 缓存微信 access_token(跨实例共享,避免重复拉取互相失效)。
|
||||
// 无 Redis 时返回空 → 调用方每次现拉(单实例开发可接受)。
|
||||
func (r *Redis) WxTokenGet(ctx context.Context, appID string) string {
|
||||
if r.rdb == nil {
|
||||
return ""
|
||||
}
|
||||
v, err := r.rdb.Get(ctx, "wxtoken:"+appID).Result()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *Redis) WxTokenSet(ctx context.Context, appID, token string, ttl time.Duration) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
_ = r.rdb.Set(ctx, "wxtoken:"+appID, token, ttl).Err()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,194 @@
|
||||
// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
|
||||
//
|
||||
// 登录即引导关注公众号(涨粉),流程:
|
||||
// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
|
||||
// 2. PC 显示这张微信二维码
|
||||
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
|
||||
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
|
||||
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
|
||||
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
|
||||
// 两种都带 openid(FromUserName)
|
||||
// 5. 后端按 openid 找/建用户 → ticket 置 authorized
|
||||
// 6. PC 轮询 → 登录完成
|
||||
//
|
||||
// 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。
|
||||
type Config struct {
|
||||
AppID string `json:"appid"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
// Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。
|
||||
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))。
|
||||
// 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它。
|
||||
func (c Config) CheckSignature(signature, timestamp, nonce string) bool {
|
||||
if c.Token == "" {
|
||||
return false
|
||||
}
|
||||
arr := []string{c.Token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
h := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(h[:]) == signature
|
||||
}
|
||||
|
||||
// FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。
|
||||
// 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。
|
||||
func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
|
||||
q := url.Values{}
|
||||
q.Set("grant_type", "client_credential")
|
||||
q.Set("appid", c.AppID)
|
||||
q.Set("secret", c.AppSecret)
|
||||
endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode()
|
||||
|
||||
body, err := httpGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
var r struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
if r.ErrCode != 0 || r.AccessToken == "" {
|
||||
return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
|
||||
}
|
||||
return r.AccessToken, r.ExpiresIn, nil
|
||||
}
|
||||
|
||||
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
|
||||
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
|
||||
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||||
reqBody := map[string]any{
|
||||
"expire_seconds": expireSec,
|
||||
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
|
||||
"action_info": map[string]any{"scene": map[string]any{"scene_str": scene}},
|
||||
}
|
||||
raw, _ := json.Marshal(reqBody)
|
||||
endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken)
|
||||
|
||||
body, err := httpPost(ctx, endpoint, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
if r.ErrCode != 0 || r.Ticket == "" {
|
||||
return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
|
||||
}
|
||||
// showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码
|
||||
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
|
||||
}
|
||||
|
||||
// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。
|
||||
type Event struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
MsgType string `xml:"MsgType"` // event
|
||||
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
|
||||
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>;SCAN: <scene>
|
||||
FromUserName string `xml:"FromUserName"` // 用户 openid
|
||||
}
|
||||
|
||||
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
|
||||
func (e Event) Scene() string {
|
||||
return strings.TrimPrefix(e.EventKey, "qrscene_")
|
||||
}
|
||||
|
||||
// IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。
|
||||
func (e Event) IsLoginScan() bool {
|
||||
if e.MsgType != "event" {
|
||||
return false
|
||||
}
|
||||
return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != ""
|
||||
}
|
||||
|
||||
// ParseEvent 解析明文事件 XML。
|
||||
func ParseEvent(body []byte) (*Event, error) {
|
||||
var e Event
|
||||
if err := xml.Unmarshal(body, &e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ---- HTTP 小工具 ----
|
||||
|
||||
func httpGet(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_Enabled(t *testing.T) {
|
||||
// 三者缺一不可:appid+secret 建二维码,token 验签
|
||||
cases := []struct {
|
||||
c Config
|
||||
want bool
|
||||
}{
|
||||
{Config{AppID: "a", AppSecret: "s", Token: "t"}, true},
|
||||
{Config{AppID: "a", AppSecret: "s"}, false},
|
||||
{Config{AppID: "a", Token: "t"}, false},
|
||||
{Config{}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.c.Enabled(); got != tc.want {
|
||||
t.Fatalf("%+v Enabled=%v want %v", tc.c, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验签必须与微信算法一致:sha1(sort(token,ts,nonce))。
|
||||
func TestCheckSignature(t *testing.T) {
|
||||
c := Config{Token: "mytoken"}
|
||||
ts, nonce := "1700000000", "abc123"
|
||||
arr := []string{c.Token, ts, nonce}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
good := hex.EncodeToString(sum[:])
|
||||
|
||||
if !c.CheckSignature(good, ts, nonce) {
|
||||
t.Fatal("正确签名应通过")
|
||||
}
|
||||
if c.CheckSignature("deadbeef", ts, nonce) {
|
||||
t.Fatal("错误签名不该通过")
|
||||
}
|
||||
if (Config{}).CheckSignature(good, ts, nonce) {
|
||||
t.Fatal("无 token 一律不通过(防未配置时被绕过)")
|
||||
}
|
||||
}
|
||||
|
||||
// 事件解析 + scene 提取:subscribe 带 qrscene_ 前缀,SCAN 不带;两者都要能登录。
|
||||
func TestParseEvent_SubscribeAndScan(t *testing.T) {
|
||||
subscribe := `<xml><ToUserName><![CDATA[gh_x]]></ToUserName>
|
||||
<FromUserName><![CDATA[openid_new]]></FromUserName>
|
||||
<MsgType><![CDATA[event]]></MsgType>
|
||||
<Event><![CDATA[subscribe]]></Event>
|
||||
<EventKey><![CDATA[qrscene_tkt-abc]]></EventKey></xml>`
|
||||
ev, err := ParseEvent([]byte(subscribe))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ev.IsLoginScan() {
|
||||
t.Fatal("subscribe 带 qrscene 应识别为登录扫码")
|
||||
}
|
||||
if ev.Scene() != "tkt-abc" {
|
||||
t.Fatalf("subscribe 应剥掉 qrscene_ 前缀,得 %q", ev.Scene())
|
||||
}
|
||||
if ev.FromUserName != "openid_new" {
|
||||
t.Fatalf("openid 取错:%q", ev.FromUserName)
|
||||
}
|
||||
|
||||
scan := `<xml><FromUserName><![CDATA[openid_old]]></FromUserName>
|
||||
<MsgType><![CDATA[event]]></MsgType>
|
||||
<Event><![CDATA[SCAN]]></Event>
|
||||
<EventKey><![CDATA[tkt-def]]></EventKey></xml>`
|
||||
ev2, _ := ParseEvent([]byte(scan))
|
||||
if !ev2.IsLoginScan() || ev2.Scene() != "tkt-def" {
|
||||
t.Fatalf("SCAN 事件应识别,scene=%q", ev2.Scene())
|
||||
}
|
||||
}
|
||||
|
||||
// 非登录事件(取关、普通消息)不能被当成登录。
|
||||
func TestParseEvent_IgnoresNonLogin(t *testing.T) {
|
||||
unsub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[unsubscribe]]></Event></xml>`
|
||||
ev, _ := ParseEvent([]byte(unsub))
|
||||
if ev.IsLoginScan() {
|
||||
t.Fatal("取关事件不该被当成登录")
|
||||
}
|
||||
// 无 scene 的 subscribe(用户直接搜号关注,不是扫登录码)也不登录
|
||||
plainSub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[subscribe]]></Event><EventKey><![CDATA[]]></EventKey></xml>`
|
||||
ev2, _ := ParseEvent([]byte(plainSub))
|
||||
if ev2.IsLoginScan() {
|
||||
t.Fatal("无 scene 的关注不该触发登录")
|
||||
}
|
||||
text := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hi]]></Content></xml>`
|
||||
ev3, _ := ParseEvent([]byte(text))
|
||||
if ev3.IsLoginScan() {
|
||||
t.Fatal("普通文本消息不该触发登录")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_SecretRoundTrip(t *testing.T) {
|
||||
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
||||
stored, err := c.EncryptedForStore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.AppSecret == "plain-secret" {
|
||||
t.Fatal("落库不该是明文")
|
||||
}
|
||||
if back := stored.DecryptFromStore(); back.AppSecret != "plain-secret" {
|
||||
t.Fatalf("还原失败:%q", back.AppSecret)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,25 @@ export async function authLogin(email: string, password: string): Promise<AuthUs
|
||||
return d.user;
|
||||
}
|
||||
|
||||
// ---- 微信扫码登录(带参二维码 + 关注/扫码)----
|
||||
// PC 建票拿到微信二维码图 URL → 展示 → 轮询登录态。全公开接口。
|
||||
export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" });
|
||||
return jsonOrThrow<{ ticket: string; qr_image: string; expires_in: number }>(res, "创建登录二维码失败");
|
||||
}
|
||||
|
||||
// 轮询:pending | authorized(带 token/user) | expired | consumed。
|
||||
export async function wxPoll(ticket: string): Promise<{ status: string; user?: AuthUser }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`);
|
||||
if (!res.ok) return { status: "expired" };
|
||||
const d = (await res.json()) as { status?: string; token?: string; user?: AuthUser };
|
||||
if (d.token && d.user) {
|
||||
setToken(d.token); // authorized:后端走 issueToken 返回 token+user
|
||||
return { status: "authorized", user: d.user };
|
||||
}
|
||||
return { status: d.status ?? "pending" };
|
||||
}
|
||||
|
||||
export async function authMe(): Promise<AuthUser | null> {
|
||||
if (!authToken) return null;
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
|
||||
|
||||
@@ -7,14 +7,24 @@ import { AuthPage } from "./AuthPage";
|
||||
vi.mock("../api", () => ({
|
||||
authLogin: vi.fn(),
|
||||
authRegister: vi.fn(),
|
||||
// 微信是默认 tab:给 wxTicket 一个 pending promise,组件挂载不报错、也不产生副作用。
|
||||
// 邮箱相关测试会先切到「邮箱」tab。
|
||||
wxTicket: vi.fn(() => new Promise(() => {})),
|
||||
wxPoll: vi.fn(() => new Promise(() => {})),
|
||||
}));
|
||||
import { authLogin, authRegister } from "../api";
|
||||
|
||||
// 所有邮箱表单测试的公共前置:默认在微信 tab,先切到邮箱。
|
||||
async function gotoEmail() {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "邮箱" }));
|
||||
}
|
||||
|
||||
describe("AuthPage", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("默认登录态:无名字字段,切到注册后出现", async () => {
|
||||
render(<AuthPage onAuthed={vi.fn()} />);
|
||||
await gotoEmail();
|
||||
// 登录态无「名字」标签
|
||||
expect(screen.queryByText(/名字/)).toBeNull();
|
||||
await userEvent.click(screen.getByText(/还没有账户/));
|
||||
@@ -26,6 +36,7 @@ describe("AuthPage", () => {
|
||||
|
||||
it("邮箱或密码为空时提交按钮禁用", async () => {
|
||||
render(<AuthPage onAuthed={vi.fn()} />);
|
||||
await gotoEmail();
|
||||
const btn = screen.getByRole("button", { name: "登录" });
|
||||
expect(btn).toBeDisabled();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
@@ -39,6 +50,7 @@ describe("AuthPage", () => {
|
||||
(authLogin as ReturnType<typeof vi.fn>).mockResolvedValue(user);
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
await userEvent.type(screen.getByPlaceholderText("••••••"), "pass123");
|
||||
await userEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||
@@ -51,6 +63,7 @@ describe("AuthPage", () => {
|
||||
(authLogin as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("邮箱或密码不正确"));
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
await userEvent.type(screen.getByPlaceholderText("••••••"), "wrong");
|
||||
await userEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||
@@ -63,6 +76,7 @@ describe("AuthPage", () => {
|
||||
(authRegister as ReturnType<typeof vi.fn>).mockResolvedValue(user);
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.click(screen.getByText(/还没有账户/));
|
||||
await userEvent.type(screen.getByPlaceholderText(/怎么称呼你/), "小明");
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "c@d.com");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { authLogin, authRegister, type AuthUser } from "../api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authLogin, authRegister, wxTicket, wxPoll, type AuthUser } from "../api";
|
||||
import { Button, Field, Input } from "../ui";
|
||||
|
||||
// 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。
|
||||
export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||
const [tab, setTab] = useState<"wechat" | "email">("wechat");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -32,9 +33,24 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-ink-950">
|
||||
<div className="w-[340px] rounded-xl border border-line bg-ink-900 p-6 shadow-card">
|
||||
<div className="mb-1 text-lg font-semibold tracking-tight text-slate-100">sundynix</div>
|
||||
<p className="mb-5 text-xs text-slate-500">
|
||||
{mode === "login" ? "登录以管理你的组织、团队与账单" : "注册后自动创建你的个人工作区"}
|
||||
</p>
|
||||
<p className="mb-4 text-xs text-slate-500">登录以管理你的组织、团队与账单</p>
|
||||
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-ink-950 p-1 text-xs">
|
||||
{(["wechat", "email"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 rounded-md py-1.5 transition ${tab === t ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300"}`}
|
||||
>
|
||||
{t === "wechat" ? "微信扫码" : "邮箱"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "wechat" ? (
|
||||
<WechatLogin onAuthed={onAuthed} />
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{mode === "register" && (
|
||||
<Field label="名字(可选)">
|
||||
@@ -66,7 +82,91 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||
className="mt-4 text-xs text-slate-500 transition hover:text-slate-300">
|
||||
{mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// WechatLogin 公众号扫码登录:建票拿微信二维码图 → 展示 → 轮询。
|
||||
// 二维码是微信生成的图(showqrcode),扫码后弹关注页,关注即登录。
|
||||
function WechatLogin({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||
const [qr, setQr] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const ticketRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: number | null = null;
|
||||
setErr("");
|
||||
setExpired(false);
|
||||
setQr("");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const t = await wxTicket();
|
||||
if (!alive) return;
|
||||
ticketRef.current = t.ticket;
|
||||
setQr(t.qr_image);
|
||||
const deadline = Date.now() + t.expires_in * 1000;
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
if (Date.now() > deadline) {
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await wxPoll(ticketRef.current);
|
||||
if (!alive) return;
|
||||
if (r.status === "authorized" && r.user) {
|
||||
onAuthed(r.user);
|
||||
return;
|
||||
}
|
||||
if (r.status === "expired") {
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 单次失败忽略,继续轮询 */
|
||||
}
|
||||
timer = window.setTimeout(() => void tick(), 2000);
|
||||
};
|
||||
timer = window.setTimeout(() => void tick(), 2000);
|
||||
} catch (e) {
|
||||
if (alive) setErr((e as Error).message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [nonce, onAuthed]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
{err ? (
|
||||
<p className="py-10 text-center text-xs text-danger">{err}</p>
|
||||
) : qr ? (
|
||||
<div className="relative rounded-lg bg-white p-2">
|
||||
<img src={qr} alt="微信登录二维码" width={220} height={220} />
|
||||
{expired && (
|
||||
<button
|
||||
onClick={() => setNonce((n) => n + 1)}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-lg bg-black/70 text-xs text-white"
|
||||
>
|
||||
二维码已过期
|
||||
<span className="rounded bg-white/20 px-2 py-1">点击刷新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-16 text-xs text-slate-500">正在生成二维码…</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-slate-500">微信扫一扫,关注公众号即可登录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user