diff --git a/deploy/132-app/docker-compose.yml b/deploy/132-app/docker-compose.yml index e9a9754..055cfa4 100644 --- a/deploy/132-app/docker-compose.yml +++ b/deploy/132-app/docker-compose.yml @@ -33,7 +33,16 @@ services: OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318 # 微信域名校验文件目录(容器内路径);未设则该路由不注册 WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify + # 微信 access_token 中控(腾讯云静态 IP 换 token,gateway 只拉不换)。 + # 配了才从中控拉,避免本地动态出网 IP 触发 40164;不配则 gateway 直连微信换 token。 + # 值放宿主机 .env:WECHAT_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 + # 让容器能访问宿主机端口(微信 token 中控经 frp stcp 落在宿主机 127.0.0.1:9099, + # 容器里的 127.0.0.1 是容器自己,须用 host.docker.internal 指到宿主机)。 + extra_hosts: + - "host.docker.internal:host-gateway" volumes: # 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。 # ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**(/etc/sundynix/wechat-cert/...), diff --git a/deploy/wechat-token-zhongkong.md b/deploy/wechat-token-zhongkong.md new file mode 100644 index 0000000..bb38937 --- /dev/null +++ b/deploy/wechat-token-zhongkong.md @@ -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 只发生在这台)。 + +### gateway(132 的 .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 过期/为空 → 宝塔计划任务没跑,或脚本手动执行失败 diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 9122b0c..83b2fbc 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -231,15 +231,31 @@ export async function listRedeemCodes(): Promise { export interface WechatMPConfig { appid: string; token: string; // 消息推送签名校验用(与公众平台服务器配置一致) - has_app_secret: boolean; + app_secret: string; // 单管理员后台:明文回显 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 { + 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 { const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() })); const d = (await res.json().catch(() => ({}))) as Partial & { 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 }; + return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", enabled: !!d.enabled }; } // app_secret 传空串 = 沿用已保存的。 @@ -258,7 +274,7 @@ export interface WechatPayConfig { public_key_id: string; // PUB_KEY_ID_ 开头 appid: string; notify_url: string; - has_apiv3_key: boolean; + apiv3_key: string; // 单管理员后台:明文回显 } export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> { diff --git a/sundynix-admin/src/components/TopupChannels.tsx b/sundynix-admin/src/components/TopupChannels.tsx index 2518c8c..1429fc7 100644 --- a/sundynix-admin/src/components/TopupChannels.tsx +++ b/sundynix-admin/src/components/TopupChannels.tsx @@ -27,10 +27,9 @@ export function TopupChannels() { } // ---- 微信支付配置:DB 存储、保存即热生效(不重启 gateway)。 -// APIv3 密钥只写不回显(密文入库,与模型 API Key 同一把密钥加密); -// 商户证书私钥文件放服务器磁盘,这里只填路径。 +// APIv3 密钥密文入库,但单管理员后台明文回显方便核对;证书私钥文件放服务器磁盘,此处只填路径。 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(empty); const [apiv3, setApiv3] = useState(""); // 留空=沿用已存 const [enabled, setEnabled] = useState(false); @@ -42,6 +41,7 @@ function WechatConfigBlock() { getWechatPay() .then((r) => { setCfg(r.config); + setApiv3(r.config.apiv3_key); setEnabled(r.enabled); setReason(r.reason); }) @@ -65,8 +65,6 @@ function WechatConfigBlock() { }); setEnabled(r.enabled); setReason(r.reason); - setApiv3(""); - if (apiv3) setCfg((c) => ({ ...c, has_apiv3_key: true })); } catch (e) { setErr((e as Error).message); } finally { @@ -108,12 +106,11 @@ function WechatConfigBlock() { {field("公钥 ID(PUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")} {field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")} diff --git a/sundynix-admin/src/pages/LoginConfigPage.tsx b/sundynix-admin/src/pages/LoginConfigPage.tsx index fc0671c..077042c 100644 --- a/sundynix-admin/src/pages/LoginConfigPage.tsx +++ b/sundynix-admin/src/pages/LoginConfigPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api"; // 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。 -// AppSecret 只写不回显(密文入库,与微信支付 APIv3 密钥同一套加密)。 +// AppSecret 明文回显(单管理员后台,方便核对/复制;密文仍加密入库)。 export function LoginConfigPage() { const [cfg, setCfg] = useState(null); const [appid, setAppid] = useState(""); @@ -18,6 +18,7 @@ export function LoginConfigPage() { setCfg(c); setAppid(c.appid); setToken(c.token); + setSecret(c.app_secret); }) .catch((e) => setErr((e as Error).message)); }, []); @@ -29,8 +30,7 @@ export function LoginConfigPage() { 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)); + setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, enabled: r.enabled } : c)); setOk(true); } catch (e) { setErr((e as Error).message); @@ -47,7 +47,7 @@ export function LoginConfigPage() {

登录设置 · 微信扫码

-

公众号扫码登录(登录即引导关注)。AppSecret 加密入库、只写不回显,随时可换

+

公众号扫码登录(登录即引导关注)。AppSecret 加密入库、后台明文可见,随时可换

@@ -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" />