Merge pull request 'feat(auth): 微信 access_token 走中控服务器(腾讯云静态 IP 换 token)' (#11) from feat/site into main
deploy-132 / deploy (push) Successful in 2m35s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-07-21 02:42:16 +00:00
14 changed files with 414 additions and 25 deletions
+9
View File
@@ -33,7 +33,16 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
# 微信域名校验文件目录(容器内路径);未设则该路由不注册 # 微信域名校验文件目录(容器内路径);未设则该路由不注册
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
# 微信 access_token 中控(腾讯云静态 IP 换 token,gateway 只拉不换)。
# 配了才从中控拉,避免本地动态出网 IP 触发 40164;不配则 gateway 直连微信换 token。
# 值放宿主机 .envWECHAT_TOKEN_URL=https://.../token.json、WECHAT_TOKEN_SECRET=<共享密钥>
WECHAT_TOKEN_URL: ${WECHAT_TOKEN_URL:-}
WECHAT_TOKEN_SECRET: ${WECHAT_TOKEN_SECRET:-}
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080 ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
# 让容器能访问宿主机端口(微信 token 中控经 frp stcp 落在宿主机 127.0.0.1:9099
# 容器里的 127.0.0.1 是容器自己,须用 host.docker.internal 指到宿主机)。
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。 # 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**/etc/sundynix/wechat-cert/...), # ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**/etc/sundynix/wechat-cert/...),
+70
View File
@@ -0,0 +1,70 @@
# 微信 access_token 中控(腾讯云静态 IP 换 token)
## 为什么
gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态会变)**,一变白名单
就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器
统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)当中控。
微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。
所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。
## 实际采用方案(B:宝塔 nginx + IP + 密钥)
腾讯云未配域名,用 IP 直接 serve token.json,加密钥头保护。token 走公网明文传,
但强随机密钥保护 + 90 分钟轮换,风险可控(单产品早期可接受;要更严可后续换 frp 隧道)。
### 腾讯云(宝塔)
1. **计划任务**(宝塔「计划任务」→ 添加,Shell 脚本,每 90 分钟):
```bash
#!/bin/bash
APPID="wxc236cddde8e7f863"
SECRET="你的公众号AppSecret"
mkdir -p /home/workspace/wxtoken
OUT="/home/workspace/wxtoken/token.json"
resp=$(curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${SECRET}")
echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT"
```
加完点一次「执行」,确认 `/home/workspace/wxtoken/token.json` 生成、内容含 access_token。
2. **建站点 + nginx**(宝塔「网站」→ 添加,域名填 IP `162.14.122.200`,纯静态)。
站点「配置文件」的 `server { }` 里加(密钥换成 `openssl rand -hex 24` 生成的强随机串):
```nginx
location = /wxtoken/token.json {
if ($http_authorization != "Bearer <强随机密钥>") { return 403; }
default_type application/json;
alias /home/workspace/wxtoken/token.json;
add_header Cache-Control "no-store";
}
```
自测:带密钥能取、不带 403。
3. **微信 IP 白名单**:只填腾讯云 `162.14.122.200`(换 token 只发生在这台)。
### gateway132 的 .env
```
WECHAT_TOKEN_URL=http://162.14.122.200/wxtoken/token.json
WECHAT_TOKEN_SECRET=<与 nginx 那串一字不差>
```
配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连。
## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制)
1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面一切、部署,扫码确认能登录。
2. 去公众平台把**本地 IP 删掉、只留腾讯云 `162.14.122.200`**,再扫码:
- **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实、IP 隐患彻底消除。
- **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理,
gateway 加 `WECHAT_HTTP_PROXY`(需再改一处 http 客户端)。
## 安全提醒
- `WECHAT_TOKEN_SECRET` 用强随机串(`openssl rand -hex 24`),别用品牌名等易猜词——
泄露就能拉到 token 冒用公众号 2 小时。
- token 走公网明文传是本方案的固有代价;要消除,后续可改 frp stcp 隧道(token 不出公网)。
## 排查
- gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token
- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致
- token.json 过期/为空 → 宝塔计划任务没跑,或脚本手动执行失败
+19 -3
View File
@@ -231,15 +231,31 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
export interface WechatMPConfig { export interface WechatMPConfig {
appid: string; appid: string;
token: string; // 消息推送签名校验用(与公众平台服务器配置一致) token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
has_app_secret: boolean; app_secret: string; // 单管理员后台:明文回显
enabled: boolean; enabled: boolean;
} }
export interface WechatUserRow {
id: string;
name: string;
wechat_openid: string;
created_at: string;
tenant_name: string;
balance_micro: number;
}
export async function adminWechatUsers(): Promise<WechatUserRow[]> {
const res = guard(await fetch(`${ADMIN}/wechat-users`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { users?: WechatUserRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `wechat users failed: ${res.status}`);
return d.users ?? [];
}
export async function getWechatMP(): Promise<WechatMPConfig> { export async function getWechatMP(): Promise<WechatMPConfig> {
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() })); const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string }; const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`); 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 }; return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", enabled: !!d.enabled };
} }
// app_secret 传空串 = 沿用已保存的。 // app_secret 传空串 = 沿用已保存的。
@@ -258,7 +274,7 @@ export interface WechatPayConfig {
public_key_id: string; // PUB_KEY_ID_ 开头 public_key_id: string; // PUB_KEY_ID_ 开头
appid: string; appid: string;
notify_url: string; notify_url: string;
has_apiv3_key: boolean; apiv3_key: string; // 单管理员后台:明文回显
} }
export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> { export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> {
@@ -27,10 +27,9 @@ export function TopupChannels() {
} }
// ---- 微信支付配置:DB 存储、保存即热生效(不重启 gateway)。 // ---- 微信支付配置:DB 存储、保存即热生效(不重启 gateway)。
// APIv3 密钥只写不回显(密文入库,与模型 API Key 同一把密钥加密); // APIv3 密钥密文入库,但单管理员后台明文回显方便核对;证书私钥文件放服务器磁盘,此处只填路径。
// 商户证书私钥文件放服务器磁盘,这里只填路径。
function WechatConfigBlock() { function WechatConfigBlock() {
const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", has_apiv3_key: false }; const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", apiv3_key: "" };
const [cfg, setCfg] = useState<WechatPayConfig>(empty); const [cfg, setCfg] = useState<WechatPayConfig>(empty);
const [apiv3, setApiv3] = useState(""); // 留空=沿用已存 const [apiv3, setApiv3] = useState(""); // 留空=沿用已存
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
@@ -42,6 +41,7 @@ function WechatConfigBlock() {
getWechatPay() getWechatPay()
.then((r) => { .then((r) => {
setCfg(r.config); setCfg(r.config);
setApiv3(r.config.apiv3_key);
setEnabled(r.enabled); setEnabled(r.enabled);
setReason(r.reason); setReason(r.reason);
}) })
@@ -65,8 +65,6 @@ function WechatConfigBlock() {
}); });
setEnabled(r.enabled); setEnabled(r.enabled);
setReason(r.reason); setReason(r.reason);
setApiv3("");
if (apiv3) setCfg((c) => ({ ...c, has_apiv3_key: true }));
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
} finally { } finally {
@@ -108,12 +106,11 @@ function WechatConfigBlock() {
{field("公钥 IDPUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")} {field("公钥 IDPUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")}
{field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")} {field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")}
<label className="text-xs text-gray-500"> <label className="text-xs text-gray-500">
APIv3 {cfg.has_apiv3_key && <span className="ml-1 text-emerald-600"></span>} APIv3
<input <input
type="password"
value={apiv3} value={apiv3}
onChange={(e) => setApiv3(e.target.value)} onChange={(e) => setApiv3(e.target.value)}
placeholder={cfg.has_apiv3_key ? "留空则沿用已保存的" : "32 字节"} placeholder="32 字节"
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" 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>
+7 -7
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api"; import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api";
// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。 // 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。
// AppSecret 只写不回显(密文入库,与微信支付 APIv3 密钥同一套加密)。 // AppSecret 明文回显(单管理员后台,方便核对/复制;密文仍加密入库)。
export function LoginConfigPage() { export function LoginConfigPage() {
const [cfg, setCfg] = useState<WechatMPConfig | null>(null); const [cfg, setCfg] = useState<WechatMPConfig | null>(null);
const [appid, setAppid] = useState(""); const [appid, setAppid] = useState("");
@@ -18,6 +18,7 @@ export function LoginConfigPage() {
setCfg(c); setCfg(c);
setAppid(c.appid); setAppid(c.appid);
setToken(c.token); setToken(c.token);
setSecret(c.app_secret);
}) })
.catch((e) => setErr((e as Error).message)); .catch((e) => setErr((e as Error).message));
}, []); }, []);
@@ -29,8 +30,7 @@ export function LoginConfigPage() {
setOk(false); setOk(false);
try { try {
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() }); const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() });
setSecret(""); setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, enabled: r.enabled } : c));
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), has_app_secret: c.has_app_secret || !!secret, enabled: r.enabled } : c));
setOk(true); setOk(true);
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
@@ -47,7 +47,7 @@ export function LoginConfigPage() {
<div className="space-y-5"> <div className="space-y-5">
<div className="border-b border-gray-200 pb-4"> <div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3> <h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">AppSecret </p> <p className="text-xs text-gray-400">AppSecret </p>
</div> </div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
@@ -67,9 +67,9 @@ export function LoginConfigPage() {
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" /> 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>
<label className="text-xs text-gray-500"> <label className="text-xs text-gray-500">
AppSecret{cfg?.has_app_secret && <span className="ml-1 text-emerald-600"></span>} AppSecret
<input type="password" value={secret} onChange={(e) => setSecret(e.target.value)} <input value={secret} onChange={(e) => setSecret(e.target.value)}
placeholder={cfg?.has_app_secret ? "留空则沿用已保存的" : "公众号开发密钥"} 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" /> 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>
<label className="text-xs text-gray-500 md:col-span-2"> <label className="text-xs text-gray-500 md:col-span-2">
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { adminWechatUsers, type WechatUserRow } from "../api";
// 平台 · 微信用户:所有微信扫码登录的用户,集中查看。
// 每个用户仍是独立租户/独立积分(各买各的),这里只做统一观测。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
export function WechatUsersPage() {
const [rows, setRows] = useState<WechatUserRow[]>([]);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState("");
useEffect(() => {
adminWechatUsers()
.then(setRows)
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, []);
const copy = (s: string) => {
void navigator.clipboard?.writeText(s);
setCopied(s);
setTimeout(() => setCopied(""), 1200);
};
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">
· {rows.length} ·
</p>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium">OpenID</th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr key={u.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{u.name || "—"}</td>
<td className="py-2 pr-3">
{/* openid 长且要复制,点一下复制全串 */}
<button
onClick={() => copy(u.wechat_openid)}
title="点击复制"
className="font-mono text-[11px] text-gray-500 hover:text-violet-600"
>
{copied === u.wechat_openid ? "已复制 ✓" : `${u.wechat_openid.slice(0, 10)}${u.wechat_openid.slice(-6)}`}
</button>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{credits(u.balance_micro)}</td>
<td className="py-2 text-xs text-gray-500">{new Date(u.created_at).toLocaleString("zh-CN")}</td>
</tr>
))}
{rows.length === 0 && !loading && (
<tr>
<td colSpan={4} className="py-8 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+8
View File
@@ -1,5 +1,6 @@
import { SubscriptionPage } from "./pages/SubscriptionPage"; import { SubscriptionPage } from "./pages/SubscriptionPage";
import { LoginConfigPage } from "./pages/LoginConfigPage"; import { LoginConfigPage } from "./pages/LoginConfigPage";
import { WechatUsersPage } from "./pages/WechatUsersPage";
import { lazy, type ReactNode } from "react"; import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。 // 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -130,6 +131,13 @@ export const routes: RouteDef[] = [
ready: true, ready: true,
element: <TenantsPage />, element: <TenantsPage />,
}, },
{
path: "wechat-users",
label: "微信用户",
group: "平台",
ready: true,
element: <WechatUsersPage />,
},
{ {
path: "spaces", path: "spaces",
label: "空间", label: "空间",
@@ -54,7 +54,7 @@ func (h *Handler) AdminGetWechatPay(c *gin.Context) {
"public_key_id": cfg.PublicKeyID, "public_key_id": cfg.PublicKeyID,
"appid": cfg.AppID, "appid": cfg.AppID,
"notify_url": cfg.NotifyURL, "notify_url": cfg.NotifyURL,
"has_apiv3_key": cfg.APIv3Key != "", // 密钥只报有无,明文永不回显 "apiv3_key": cfg.APIv3Key, // 单管理员后台:明文回显(RequireAdmin 已拦),方便核对
}, },
"enabled": enabled, "enabled": enabled,
"reason": reason, "reason": reason,
@@ -3,11 +3,13 @@ package handler
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha1"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -60,7 +62,19 @@ func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, e
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检 if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
return t, nil return t, nil
} }
token, ttl, err := cfg.FetchAccessToken(ctx) // 中控模式:配了 WECHAT_TOKEN_URL 就只从中控拉(腾讯云静态 IP 换 token),
// 绝不自己调 FetchAccessToken——微信要求单点刷新,多点各自换会互相顶掉 token。
// 没配则维持直连(本地出网换 token,受动态 IP 白名单约束)。
var (
token string
ttl int
err error
)
if tokenURL := os.Getenv("WECHAT_TOKEN_URL"); tokenURL != "" {
token, ttl, err = wechat.PullToken(ctx, tokenURL, os.Getenv("WECHAT_TOKEN_SECRET"))
} else {
token, ttl, err = cfg.FetchAccessToken(ctx)
}
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -71,6 +85,20 @@ func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, e
return token, nil return token, nil
} }
// wechatNickname 从 openid 派生稳定昵称:微信用户_XXXXX。
// 后缀取 openid 的 sha1 前 5 位、映射到去混淆字母表 —— openid 唯一 → 后缀实际不重复,
// 且同一 openid 每次一致(重登不会换名)。用户可事后自己改名。
func wechatNickname(openID string) string {
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // 去掉 0/O、1/I/L 等易混
sum := sha1.Sum([]byte(openID))
var sb strings.Builder
sb.WriteString("微信用户_")
for i := 0; i < 5; i++ {
sb.WriteByte(alphabet[int(sum[i])%len(alphabet)])
}
return sb.String()
}
func newTicket() string { func newTicket() string {
b := make([]byte, 16) b := make([]byte, 16)
_, _ = rand.Read(b) _, _ = rand.Read(b)
@@ -144,7 +172,7 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
return return
} }
if u == nil { if u == nil {
u, err = h.db.CreateWechatUser(ctx, openID, "微信用户") u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
if err != nil { if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err) log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
c.String(http.StatusOK, "success") c.String(http.StatusOK, "success")
@@ -197,11 +225,12 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
func (h *Handler) AdminGetWechatMP(c *gin.Context) { func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context()) cfg := h.loadWechatMP(c.Request.Context())
// 单管理员后台:AppSecret 明文回显(RequireAdmin 已拦,方便核对/复制)。
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID, "appid": cfg.AppID,
"token": cfg.Token, "token": cfg.Token,
"has_app_secret": cfg.AppSecret != "", "app_secret": cfg.AppSecret,
"enabled": cfg.Enabled(), "enabled": cfg.Enabled(),
}) })
} }
@@ -234,3 +263,8 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废 h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()}) c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
} }
// AdminWechatUsers: GET /api/v1/admin/wechat-users —— 后台微信用户列表(openid/昵称/加入时间/余额)。
func (h *Handler) AdminWechatUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": h.db.ListWechatUsers(c.Request.Context(), 200)})
}
@@ -0,0 +1,37 @@
package handler
import (
"strings"
"testing"
)
func TestWechatNickname(t *testing.T) {
// 前缀 + 5 位后缀
n := wechatNickname("openid_abc")
if !strings.HasPrefix(n, "微信用户_") {
t.Fatalf("前缀不对: %q", n)
}
suffix := strings.TrimPrefix(n, "微信用户_")
if len([]rune(suffix)) != 5 {
t.Fatalf("后缀应 5 位,得 %q", suffix)
}
// 稳定:同 openid 每次一致(重登不换名)
if wechatNickname("openid_abc") != n {
t.Fatal("同 openid 昵称应稳定")
}
// 去混淆:不含 0/O/1/I/L
for _, c := range suffix {
if strings.ContainsRune("0O1IL", c) {
t.Fatalf("后缀含易混字符: %q", suffix)
}
}
// 不同 openid 后缀基本不同(抽样几个不撞)
seen := map[string]bool{}
for _, o := range []string{"a", "b", "c", "d", "e", "openid_1", "openid_2"} {
s := wechatNickname(o)
if seen[s] {
t.Fatalf("后缀撞了: %q", s)
}
seen[s] = true
}
}
@@ -157,6 +157,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.PUT("/packs", h.AdminSavePack) admin.PUT("/packs", h.AdminSavePack)
admin.GET("/wechat-mp", h.AdminGetWechatMP) // 微信扫码登录配置 admin.GET("/wechat-mp", h.AdminGetWechatMP) // 微信扫码登录配置
admin.PUT("/wechat-mp", h.AdminSaveWechatMP) admin.PUT("/wechat-mp", h.AdminSaveWechatMP)
admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额)
admin.GET("/payment/wechat", h.AdminGetWechatPay) admin.GET("/payment/wechat", h.AdminGetWechatPay)
admin.PUT("/payment/wechat", h.AdminSaveWechatPay) admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架) admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
+34
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"errors" "errors"
"time"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -89,3 +90,36 @@ func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*
} }
// (测试辅助见 user_wechat_test.go) // (测试辅助见 user_wechat_test.go)
// WechatUserRow 是后台微信用户列表一行:账号 + 其个人租户名 + 余额。
type WechatUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
// gorm 默认把 WechatOpenID 断成列名 wechat_open_id,与 SQL alias wechat_openid 对不上 → 空。
// 显式指定列名(BalanceMicro 同理,避免 balance_micro 被断成 balance_micro 之外的名字)。
WechatOpenID string `gorm:"column:wechat_openid" json:"wechat_openid"`
CreatedAt time.Time `json:"created_at"`
TenantName string `json:"tenant_name"`
BalanceMicro int64 `gorm:"column:balance_micro" json:"balance_micro"`
}
// ListWechatUsers 列出所有微信登录用户(wechat_openid 非空),带其 owner 租户名与余额。
// 系统级读,跨租户。倒序(新注册在前)。
func (p *Postgres) ListWechatUsers(ctx context.Context, limit int) []WechatUserRow {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []WechatUserRow
// 用户 → 其作为 owner 的租户(EnsureDefaultTenant 建的个人租户)
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_user as u").
Select("u.id, u.name, u.wechat_openid, u.created_at, " +
"coalesce(t.name,'') as tenant_name, coalesce(t.credit_balance_micro,0) as balance_micro").
Joins("left join sundynix_tenant_member m on m.user_id = u.id and m.role = 'owner'").
Joins("left join sundynix_tenant t on t.id = m.tenant_id").
Where("u.wechat_openid <> '' and u.deleted_at is null").
Order("u.created_at desc").Limit(limit).Scan(&out)
return out
}
+39
View File
@@ -105,6 +105,45 @@ func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
return r.AccessToken, r.ExpiresIn, nil return r.AccessToken, r.ExpiresIn, nil
} }
// PullToken 从「中控服务器」拉取 access_token(微信官方推荐架构:单点取 token、多点用)。
// 中控(腾讯云静态 IP)负责换 token 并 serve,这样微信 IP 白名单只需填中控 IP;
// gateway 出网 IP(本地动态宽带)不再参与换 token,避免 IP 一变就 40164。
// tokenURL serve 的 JSON 形如 {"access_token":"...","expires_in":7200}secret 走 Bearer 头鉴权。
func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(rctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", 0, err
}
if secret != "" {
req.Header.Set("Authorization", "Bearer "+secret)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("请求中控 token 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("中控 token 返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var r struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析中控 token 失败: %s", strings.TrimSpace(string(body)))
}
if r.AccessToken == "" {
return "", 0, fmt.Errorf("中控返回空 token: %s", strings.TrimSpace(string(body)))
}
if r.ExpiresIn <= 0 {
r.ExpiresIn = 7200 // 中控没给有效期时按微信默认兜底
}
return r.AccessToken, r.ExpiresIn, nil
}
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。 // CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。 // expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) { func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
@@ -0,0 +1,68 @@
package wechat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// PullToken 正常路径:带对密钥、解析 token + 有效期。
func TestPullToken_OK(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN_ABC","expires_in":7200}`))
}))
defer srv.Close()
tok, ttl, err := PullToken(context.Background(), srv.URL, "s3cr3t")
if err != nil {
t.Fatal(err)
}
if tok != "TOKEN_ABC" || ttl != 7200 {
t.Fatalf("解析错:tok=%q ttl=%d", tok, ttl)
}
if gotAuth != "Bearer s3cr3t" {
t.Fatalf("密钥头没带对:%q", gotAuth)
}
}
// 中控拒绝(密钥不对 → 403)必须报错,不能把 403 页面当 token。
func TestPullToken_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("forbidden"))
}))
defer srv.Close()
if _, _, err := PullToken(context.Background(), srv.URL, "wrong"); err == nil {
t.Fatal("403 应报错")
}
}
// 坏 JSON / 空 token 必须报错,不能返回空串当成功。
func TestPullToken_BadBody(t *testing.T) {
for _, body := range []string{`not json`, `{"access_token":""}`, `{}`} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
if _, _, err := PullToken(context.Background(), srv.URL, ""); err == nil {
t.Fatalf("坏响应 %q 应报错", body)
}
srv.Close()
}
}
// 中控没给 expires_in 时按微信默认 7200 兜底(避免缓存 0 秒后每次都拉)。
func TestPullToken_DefaultExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"X"}`))
}))
defer srv.Close()
_, ttl, err := PullToken(context.Background(), srv.URL, "")
if err != nil || ttl != 7200 {
t.Fatalf("应兜底 7200,得 ttl=%d err=%v", ttl, err)
}
}