feat(admin): 微信用户观测页 + 唯一昵称 + 密钥明文回显(单管理员后台)

按需求四项:
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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 10:36:27 +08:00
parent cbd0a96ce7
commit 0f2afdaac1
10 changed files with 214 additions and 24 deletions
+19 -3
View File
@@ -231,15 +231,31 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
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<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> {
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 };
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 }> {
@@ -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<WechatPayConfig>(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("公钥 IDPUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")}
{field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")}
<label className="text-xs text-gray-500">
APIv3 {cfg.has_apiv3_key && <span className="ml-1 text-emerald-600"></span>}
APIv3
<input
type="password"
value={apiv3}
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"
/>
</label>
+7 -7
View File
@@ -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<WechatMPConfig | null>(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() {
<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>
<p className="text-xs text-gray-400">AppSecret </p>
</div>
<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" />
</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 ? "留空则沿用已保存的" : "公众号开发密钥"}
AppSecret
<input value={secret} onChange={(e) => setSecret(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>
<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 { LoginConfigPage } from "./pages/LoginConfigPage";
import { WechatUsersPage } from "./pages/WechatUsersPage";
import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -130,6 +131,13 @@ export const routes: RouteDef[] = [
ready: true,
element: <TenantsPage />,
},
{
path: "wechat-users",
label: "微信用户",
group: "平台",
ready: true,
element: <WechatUsersPage />,
},
{
path: "spaces",
label: "空间",