From a1c68ec6b20627d4a76e9a90f9c97d88597a2555 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 09:53:27 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(auth):=20=E5=BE=AE=E4=BF=A1=20access?= =?UTF-8?q?=5Ftoken=20=E8=B5=B0=E4=B8=AD=E6=8E=A7=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=EF=BC=88=E8=85=BE=E8=AE=AF=E4=BA=91=E9=9D=99=E6=80=81?= =?UTF-8?q?=20IP=20=E6=8D=A2=20token=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隐患:gateway 换 access_token 时微信看到的是本地宽带出网 IP(106.58.232.43, 动态会变),一变白名单就失效、二维码建不出来(40164)。 按微信官方推荐的「中控服务器」架构解决:腾讯云静态 IP 统一换 token,gateway 拉取 使用。微信 IP 白名单只限制换 token 这一步,拿 token 建二维码不查 IP —— 所以 换 token 挪到中控、建二维码仍在 gateway 本地,白名单只填腾讯云 IP,永不失效。 - wechat.PullToken:从中控 HTTPS 端点拉 token(Bearer 密钥鉴权),坏响应/403/空 token 一律报错不当成功;中控没给 expires_in 时按 7200 兜底。 - handler.accessToken:配了 WECHAT_TOKEN_URL 就只从中控拉、绝不自己 FetchAccessToken (微信要求单点刷新,多点各自换会互相顶掉 token);不配维持直连,零副作用可回退。 - compose:gateway 加 WECHAT_TOKEN_URL / WECHAT_TOKEN_SECRET(从宿主机 .env 注入)。 - deploy/wechat-token-zhongkong.md:腾讯云侧 cron 脚本 + nginx 配置 + 切换验证步骤。 本地验证(假中控 httptest + 真 gateway):建票时 Redis 缓存的是中控给的 token (FAKE_TOKEN_FROM_ZHONGKONG),gateway 未直连微信换 token;随后拿该 token 调 qrcode/create(假 token 报 40001 属预期)——证明「中控换 token → 本地建二维码」链路成立。 4 组 PullToken 单测覆盖正常/403/坏JSON/兜底。 真机验证(部署后):把本地 IP 从白名单删掉、只留腾讯云 IP,扫码仍能登录即坐实 qrcode 不受 IP 限制;若建二维码报 40164 则退回 tinyproxy 正向代理备选。 Co-Authored-By: Claude Opus 4.8 --- deploy/132-app/docker-compose.yml | 5 ++ deploy/wechat-token-zhongkong.md | 84 +++++++++++++++++++ .../internal/handler/wechat_login.go | 15 +++- sundynix-gateway/internal/wechat/mp.go | 39 +++++++++ .../internal/wechat/pulltoken_test.go | 68 +++++++++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 deploy/wechat-token-zhongkong.md create mode 100644 sundynix-gateway/internal/wechat/pulltoken_test.go diff --git a/deploy/132-app/docker-compose.yml b/deploy/132-app/docker-compose.yml index e9a9754..4b884f4 100644 --- a/deploy/132-app/docker-compose.yml +++ b/deploy/132-app/docker-compose.yml @@ -33,6 +33,11 @@ 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 volumes: # 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。 diff --git a/deploy/wechat-token-zhongkong.md b/deploy/wechat-token-zhongkong.md new file mode 100644 index 0000000..8ffcbc7 --- /dev/null +++ b/deploy/wechat-token-zhongkong.md @@ -0,0 +1,84 @@ +# 微信 access_token 中控(腾讯云静态 IP) + +## 为什么 + +gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态,会变)**,一变白名单 +就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器 +统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)正好当中控。 + +微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。 +所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。 + +## 腾讯云上做(三步) + +### 1. 换 token 脚本 `/home/workspace/wxtoken/refresh.sh` + +```bash +#!/bin/bash +# 用 appid/secret 换 access_token,写成 token.json。从腾讯云静态 IP 发出。 +APPID="wxc236cddde8e7f863" +SECRET="你的公众号 AppSecret" # 建议重置后填新的 +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}") +# 只在拿到 access_token 时才覆盖,避免偶发失败把好文件冲掉 +echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT" +``` + +```bash +chmod +x /home/workspace/wxtoken/refresh.sh +/home/workspace/wxtoken/refresh.sh # 先手动跑一次,确认 token.json 生成 +``` + +### 2. cron 每 ~1.5 小时刷新(token 有效 2h,留足余量) + +```bash +crontab -e +# 加一行: +*/90 * * * * /home/workspace/wxtoken/refresh.sh +``` + +### 3. nginx 把 token.json 以 HTTPS + 密钥吐出 + +在腾讯云那台的站点配置里加(token 是 2h 高权限凭证,必须 HTTPS + 密钥,不能裸奔): + +```nginx +location = /wxtoken/token.json { + # 共享密钥校验:gateway 拉取时带 Authorization: Bearer <同一串> + if ($http_authorization != "Bearer 换成你自定的强随机串") { return 403; } + default_type application/json; + alias /home/workspace/wxtoken/token.json; + add_header Cache-Control "no-store"; +} +``` + +reload nginx 后自测(换成真域名): +```bash +curl -s -H "Authorization: Bearer 换成你自定的强随机串" https://<腾讯云域名>/wxtoken/token.json +# 应返回 {"access_token":"...","expires_in":7200} +curl -s https://<腾讯云域名>/wxtoken/token.json # 不带密钥应 403 +``` + +## gateway 侧(在 132 的 .env 里配,随部署生效) + +``` +WECHAT_TOKEN_URL=https://<腾讯云域名>/wxtoken/token.json +WECHAT_TOKEN_SECRET=换成你自定的强随机串 # 与 nginx 那串一字不差 +``` + +配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连(受本地 IP 白名单约束)。 + +## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制) + +1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面 env、部署,扫码确认能登录(此时不确定 + token 是中控给的还是本地换的都能过)。 +2. 去公众平台把**本地 IP 从白名单删掉,只留腾讯云 `162.14.122.200`**,再扫码: + - **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实,IP 隐患彻底消除。 + - **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理, + gateway 加 `WECHAT_HTTP_PROXY` env(代码侧已预留思路,需再改一处 http 客户端)。 + +## 排查 + +- gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token +- 中控 token.json 过期/为空 → 看 cron 有没有在跑、`refresh.sh` 手动能否生成 +- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致 diff --git a/sundynix-gateway/internal/handler/wechat_login.go b/sundynix-gateway/internal/handler/wechat_login.go index 8ef6aa3..3111701 100644 --- a/sundynix-gateway/internal/handler/wechat_login.go +++ b/sundynix-gateway/internal/handler/wechat_login.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "os" "strings" "sync" "time" @@ -60,7 +61,19 @@ func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, e if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检 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 { return "", err } diff --git a/sundynix-gateway/internal/wechat/mp.go b/sundynix-gateway/internal/wechat/mp.go index 3888fa4..6523b81 100644 --- a/sundynix-gateway/internal/wechat/mp.go +++ b/sundynix-gateway/internal/wechat/mp.go @@ -105,6 +105,45 @@ func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) { 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)。 // expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 展示的二维码图 URL。 func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) { diff --git a/sundynix-gateway/internal/wechat/pulltoken_test.go b/sundynix-gateway/internal/wechat/pulltoken_test.go new file mode 100644 index 0000000..470f3ec --- /dev/null +++ b/sundynix-gateway/internal/wechat/pulltoken_test.go @@ -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) + } +} From cbd0a96ce7f8877229766f2f36de5bddb0de9bcb Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 10:02:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(deploy):=20=E5=BE=AE=E4=BF=A1=20token?= =?UTF-8?q?=20=E4=B8=AD=E6=8E=A7=E6=94=B9=E7=94=A8=20frp=20stcp=20?= =?UTF-8?q?=E9=9A=A7=E9=81=93=EF=BC=88=E4=B8=8D=E4=B8=8A=E5=85=AC=E7=BD=91?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E9=9C=80=E5=9F=9F=E5=90=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户腾讯云未配域名、frp 是 toml。改成:腾讯云 token 服务只绑 127.0.0.1, 经 frp stcp(点对点加密隧道)让 132 拉取,token 全程不上公网、不用证书。 - 说明书给出 toml 版 frp 配置(腾讯云 [[proxies]] stcp + 132 [[visitors]])、 cron 换 token 脚本、切换验证步骤。 - compose:gateway 加 extra_hosts host.docker.internal:host-gateway —— 容器里的 127.0.0.1 是容器自己,token 落在宿主机 127.0.0.1:9099,须经 host.docker.internal 访问。 代码侧(PullToken + accessToken 中控分支)无改动,沿用上一提交。 Co-Authored-By: Claude Opus 4.8 --- deploy/132-app/docker-compose.yml | 4 ++ deploy/wechat-token-zhongkong.md | 107 +++++++++++++++++------------- 2 files changed, 64 insertions(+), 47 deletions(-) diff --git a/deploy/132-app/docker-compose.yml b/deploy/132-app/docker-compose.yml index 4b884f4..055cfa4 100644 --- a/deploy/132-app/docker-compose.yml +++ b/deploy/132-app/docker-compose.yml @@ -39,6 +39,10 @@ services: 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 index 8ffcbc7..3d5227c 100644 --- a/deploy/wechat-token-zhongkong.md +++ b/deploy/wechat-token-zhongkong.md @@ -1,84 +1,97 @@ -# 微信 access_token 中控(腾讯云静态 IP) +# 微信 access_token 中控(腾讯云静态 IP 换 token,经 frp 隧道拉取) ## 为什么 -gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态,会变)**,一变白名单 -就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器 -统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)正好当中控。 +gateway 换 access_token 时微信看到的是**本地宽带出网 IP(106.58.232.43,动态会变)**, +一变白名单就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** +的机器统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)当中控。 微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。 -所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。 +所以:腾讯云换 token → gateway 经 frp 隧道拉 token → gateway 本地建二维码。 +白名单只填腾讯云 IP,永不失效。 -## 腾讯云上做(三步) +token 全程**不上公网**:腾讯云 token 服务只绑 127.0.0.1,靠 frp stcp(点对点加密隧道) +让 132 访问。frp 拓扑:腾讯云=frps、132=frpc,均 **toml** 配置。 + +## 一、腾讯云侧(frps 那台) ### 1. 换 token 脚本 `/home/workspace/wxtoken/refresh.sh` - ```bash #!/bin/bash -# 用 appid/secret 换 access_token,写成 token.json。从腾讯云静态 IP 发出。 APPID="wxc236cddde8e7f863" -SECRET="你的公众号 AppSecret" # 建议重置后填新的 +SECRET="你的公众号 AppSecret" # 建议重置后填新的 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}") # 只在拿到 access_token 时才覆盖,避免偶发失败把好文件冲掉 echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT" ``` - ```bash chmod +x /home/workspace/wxtoken/refresh.sh -/home/workspace/wxtoken/refresh.sh # 先手动跑一次,确认 token.json 生成 +/home/workspace/wxtoken/refresh.sh # 手动跑一次,确认 token.json 生成 +cat /home/workspace/wxtoken/token.json # 应有 {"access_token":"...","expires_in":7200} ``` -### 2. cron 每 ~1.5 小时刷新(token 有效 2h,留足余量) - +### 2. cron 每 90 分钟刷新(token 有效 2h,留余量) ```bash crontab -e -# 加一行: -*/90 * * * * /home/workspace/wxtoken/refresh.sh +# 加:*/90 * * * * /home/workspace/wxtoken/refresh.sh ``` -### 3. nginx 把 token.json 以 HTTPS + 密钥吐出 - -在腾讯云那台的站点配置里加(token 是 2h 高权限凭证,必须 HTTPS + 密钥,不能裸奔): - -```nginx -location = /wxtoken/token.json { - # 共享密钥校验:gateway 拉取时带 Authorization: Bearer <同一串> - if ($http_authorization != "Bearer 换成你自定的强随机串") { return 403; } - default_type application/json; - alias /home/workspace/wxtoken/token.json; - add_header Cache-Control "no-store"; -} -``` - -reload nginx 后自测(换成真域名): +### 3. 本地 HTTP 服务只绑 127.0.0.1(不出公网,交给 frp) ```bash -curl -s -H "Authorization: Bearer 换成你自定的强随机串" https://<腾讯云域名>/wxtoken/token.json -# 应返回 {"access_token":"...","expires_in":7200} -curl -s https://<腾讯云域名>/wxtoken/token.json # 不带密钥应 403 +cd /home/workspace/wxtoken && nohup python3 -m http.server 9099 --bind 127.0.0.1 >/dev/null 2>&1 & +# 建议做成 systemd 服务常驻,重启不丢 ``` -## gateway 侧(在 132 的 .env 里配,随部署生效) - -``` -WECHAT_TOKEN_URL=https://<腾讯云域名>/wxtoken/token.json -WECHAT_TOKEN_SECRET=换成你自定的强随机串 # 与 nginx 那串一字不差 +### 4. 腾讯云 `frpc.toml` 追加(暴露本地 9099 给隧道) +> 腾讯云跑着 frps;要把它自己的本地服务经 stcp 暴露,需在该机也跑一个 frpc(或复用已有)。 +```toml +[[proxies]] +name = "wxtoken" +type = "stcp" +secretKey = "换成一串强随机密钥" # 与 132 visitor 一致 +localIP = "127.0.0.1" +localPort = 9099 ``` -配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连(受本地 IP 白名单约束)。 +## 二、132 侧(frpc 那台) -## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制) +`frpc.toml` 追加一个 visitor,把腾讯云的 token 服务映射到 132 本地 9099: +```toml +[[visitors]] +name = "wxtoken-visitor" +type = "stcp" +serverName = "wxtoken" +secretKey = "换成同一串强随机密钥" # 与腾讯云那串一字不差 +bindAddr = "127.0.0.1" +bindPort = 9099 +``` +reload frpc 后自测(在 132 上): +```bash +curl -s http://127.0.0.1:9099/token.json # 应返回腾讯云那份 token +``` -1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面 env、部署,扫码确认能登录(此时不确定 - token 是中控给的还是本地换的都能过)。 +## 三、gateway(132 的 .env) +``` +# 容器里的 127.0.0.1 是容器自己,须用 host.docker.internal 指到宿主机 +WECHAT_TOKEN_URL=http://host.docker.internal:9099/token.json +WECHAT_TOKEN_SECRET= +``` +- compose 已加 `extra_hosts: host.docker.internal:host-gateway`(容器可达宿主机端口)。 +- 密钥留空即可:stcp 的 secretKey 已是网络层鉴权,token 不上公网。 +- 配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连。 + +## 四、切换 + 验证(关键,坐实 qrcode 不受 IP 限制) + +1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面一切、部署,扫码确认能登录。 2. 去公众平台把**本地 IP 从白名单删掉,只留腾讯云 `162.14.122.200`**,再扫码: - - **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实,IP 隐患彻底消除。 + - **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实、IP 隐患彻底消除。 - **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理, - gateway 加 `WECHAT_HTTP_PROXY` env(代码侧已预留思路,需再改一处 http 客户端)。 + gateway 加 `WECHAT_HTTP_PROXY`(需再改一处 http 客户端)。 ## 排查 - gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token -- 中控 token.json 过期/为空 → 看 cron 有没有在跑、`refresh.sh` 手动能否生成 -- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致 +- 132 上 `curl 127.0.0.1:9099/token.json` 不通 → frp stcp 没打通(检查两端 secretKey 一致、frpc 都 reload 了) +- token.json 过期/为空 → cron 没跑或 refresh.sh 手动生成失败 +- 容器内拉不到(宿主机能拉到)→ compose 的 extra_hosts 没生效,或用了 127.0.0.1 而非 host.docker.internal From 0f2afdaac1ad5f5ff25426b633fc645d59833541 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 10:36:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(admin):=20=E5=BE=AE=E4=BF=A1=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A7=82=E6=B5=8B=E9=A1=B5=20+=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E6=98=B5=E7=A7=B0=20+=20=E5=AF=86=E9=92=A5=E6=98=8E=E6=96=87?= =?UTF-8?q?=E5=9B=9E=E6=98=BE=EF=BC=88=E5=8D=95=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按需求四项: 1. 微信新用户昵称改「微信用户_XXXXX」:后缀从 openid 的 sha1 派生(去混淆字母表), openid 唯一 → 后缀实际不重复,且同 openid 每次一致(重登不换名)。用户可自行改名。 2. 后台加「平台 → 微信用户」列表:昵称 / openid(点击复制)/ 积分余额 / 加入时间。 每人仍独立租户与积分(各买各的,确认过不共享积分池),此页只做统一观测。 3. 登录设置的 AppSecret 明文回显(不再只显示"已保存")。 4. 支付配置的 APIv3 密钥明文回显。 —— 用户明确后台单人使用、RequireAdmin 已拦,接受这一安全降级;密文仍加密入库。 修一个 gorm Scan 坑:WechatUserRow 的 WechatOpenID/BalanceMicro 没加 column tag, gorm 把 WechatOpenID 断成列名 wechat_open_id,与 SQL alias wechat_openid 对不上 → openid 静默返回空。教训:Scan 到自定义结构 + SQL 用 alias 时,字段一律显式加 column tag。 本地验证:真库造两个微信用户,列表接口正确返回 openid(修 tag 前是空); 登录设置页 AppSecret 已是可见文本框;nickname 单测覆盖唯一/稳定/去混淆。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 22 +++++- .../src/components/TopupChannels.tsx | 13 ++-- sundynix-admin/src/pages/LoginConfigPage.tsx | 14 ++-- sundynix-admin/src/pages/WechatUsersPage.tsx | 76 +++++++++++++++++++ sundynix-admin/src/routes.tsx | 8 ++ .../internal/handler/payment_admin.go | 2 +- .../internal/handler/wechat_login.go | 31 ++++++-- .../internal/handler/wechat_nickname_test.go | 37 +++++++++ sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/user.go | 34 +++++++++ 10 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 sundynix-admin/src/pages/WechatUsersPage.tsx create mode 100644 sundynix-gateway/internal/handler/wechat_nickname_test.go 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" />