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:
@@ -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("公钥 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")}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "空间",
|
||||
|
||||
@@ -54,7 +54,7 @@ func (h *Handler) AdminGetWechatPay(c *gin.Context) {
|
||||
"public_key_id": cfg.PublicKeyID,
|
||||
"appid": cfg.AppID,
|
||||
"notify_url": cfg.NotifyURL,
|
||||
"has_apiv3_key": cfg.APIv3Key != "", // 密钥只报有无,明文永不回显
|
||||
"apiv3_key": cfg.APIv3Key, // 单管理员后台:明文回显(RequireAdmin 已拦),方便核对
|
||||
},
|
||||
"enabled": enabled,
|
||||
"reason": reason,
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -84,6 +85,20 @@ func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, e
|
||||
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 {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
@@ -157,7 +172,7 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
u, err = h.db.CreateWechatUser(ctx, openID, "微信用户")
|
||||
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
|
||||
c.String(http.StatusOK, "success")
|
||||
@@ -210,11 +225,12 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
|
||||
|
||||
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
// 单管理员后台:AppSecret 明文回显(RequireAdmin 已拦,方便核对/复制)。
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appid": cfg.AppID,
|
||||
"token": cfg.Token,
|
||||
"has_app_secret": cfg.AppSecret != "",
|
||||
"enabled": cfg.Enabled(),
|
||||
"appid": cfg.AppID,
|
||||
"token": cfg.Token,
|
||||
"app_secret": cfg.AppSecret,
|
||||
"enabled": cfg.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,3 +263,8 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
||||
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
|
||||
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.GET("/wechat-mp", h.AdminGetWechatMP) // 微信扫码登录配置
|
||||
admin.PUT("/wechat-mp", h.AdminSaveWechatMP)
|
||||
admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额)
|
||||
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -89,3 +90,36 @@ func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*
|
||||
}
|
||||
|
||||
// (测试辅助见 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user