From f4f5ed0b0f00fba64d959dd74ac21c1d42fb1fd5 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Mon, 12 Oct 2026 13:57:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(member):=20=E6=A1=A3=E6=A1=88=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=8E=E7=A9=BA=E5=A3=B3=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。 --- apps/server/src/app.ts | 2 +- apps/server/src/routes/admin-members.ts | 115 ++++++++++++++++++++-- apps/web/src/api/admin/members.ts | 11 ++- apps/web/src/queries/admin.ts | 19 +++- apps/web/src/views/admin/members-view.vue | 85 +++++++++++++++- packages/core/src/errors.ts | 4 + packages/core/src/schemas.ts | 4 +- packages/core/src/types.ts | 2 + packages/db/src/repos.ts | 17 ++++ 9 files changed, 243 insertions(+), 16 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e9157d0..16ce2c9 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) { { name: "引导与会话", description: "三态判定 / 登录 / 改密" }, { name: "引擎", description: "同步引擎状态" }, { name: "Key 管理", description: "登记 / 启停 / 归属" }, - { name: "成员管理", description: "开户 / 令牌 / 启停" }, + { name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" }, { name: "成员视图", description: "令牌自助查询" }, ], }); diff --git a/apps/server/src/routes/admin-members.ts b/apps/server/src/routes/admin-members.ts index 47e752a..433e214 100644 --- a/apps/server/src/routes/admin-members.ts +++ b/apps/server/src/routes/admin-members.ts @@ -135,7 +135,8 @@ export function adminMemberRoutes() { createRoute({ method: "patch", path: "/members/{id}", - summary: "停用 / 启用", + summary: "档案修改 / 停用 / 启用", + description: "部分更新 name/remark/status;审计按实际变更字段各落一行", tags: ["成员管理"], security: [{ adminSession: [] }], request: { @@ -152,27 +153,123 @@ export function adminMemberRoutes() { }, 401: err401, 404: err404, + 409: err409, 422: err422, }, }), async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json().catch(() => null); - const status = body?.status; - if (status !== "active" && status !== "disabled") { - return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" })); - } const repos = c.get("repos"); const before = await repos.members.findById(id); if (!before) return jsonError(c, 404, ERR.not_found()); - const row = await repos.members.updateStatus(id, status); + + const profile: { name?: string; remark?: string } = {}; + const actions: Array<{ action: string; summary: string }> = []; + + if (typeof body?.name === "string") { + const name = body.name.trim(); + if (!name || name.length > 64) { + return jsonError( + c, + 422, + ERR.validation_failed({ name: "名称无效" }), + ); + } + if (name !== before.name) { + const taken = await repos.members.findByName(name); + if (taken && taken.id !== id) { + return jsonError(c, 409, ERR.member_name_taken()); + } + profile.name = name; + actions.push({ + action: "member.update", + summary: `成员 ${before.name} · 改名为 ${name}`, + }); + } + } + if (typeof body?.remark === "string") { + if (body.remark !== before.remark) { + profile.remark = body.remark; + actions.push({ + action: "member.update", + summary: `成员 ${before.name} · 修改备注`, + }); + } + } + let nextStatus: "active" | "disabled" | null = null; + if (body?.status === "active" || body?.status === "disabled") { + if (body.status !== before.status) { + nextStatus = body.status; + actions.push({ + action: body.status === "active" ? "member.enable" : "member.disable", + summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`, + }); + } + } + + if (Object.keys(profile).length === 0 && !nextStatus) { + return jsonError( + c, + 422, + ERR.validation_failed({ body: "没有需要更新的字段" }), + ); + } + + let row = before; + if (Object.keys(profile).length > 0) { + row = await repos.members.updateProfile(id, profile); + } + if (nextStatus) { + row = await repos.members.updateStatus(id, nextStatus); + } + for (const a of actions) { + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "member", + action: a.action, + summary: a.summary, + }); + } + return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200); + }, + ); + + r.openapi( + createRoute({ + method: "delete", + path: "/members/{id}", + summary: "空壳删除", + description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys", + tags: ["成员管理"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + }, + responses: { + 204: { description: "已删除" }, + 401: err401, + 404: err404, + 409: err409, + }, + }), + async (c) => { + const id = Number(c.req.param("id")); + const repos = c.get("repos"); + const before = await repos.members.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + const keyCount = await repos.members.countKeys(id); + if (keyCount > 0) { + return jsonError(c, 409, ERR.member_has_keys(keyCount)); + } + await repos.members.deleteById(id); await repos.audit.insert({ operator: c.get("adminUsername") ?? null, domain: "member", - action: status === "active" ? "member.enable" : "member.disable", - summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`, + action: "member.delete", + summary: `删除成员 ${before.name}`, }); - return c.json(toMemberItem(row, 0), 200); + return c.body(null, 204); }, ); diff --git a/apps/web/src/api/admin/members.ts b/apps/web/src/api/admin/members.ts index 39af258..0be9379 100644 --- a/apps/web/src/api/admin/members.ts +++ b/apps/web/src/api/admin/members.ts @@ -22,12 +22,21 @@ export async function createMember(body: { export async function patchMember( id: number, - body: { status: "active" | "disabled" }, + body: { + name?: string; + remark?: string; + status?: "active" | "disabled"; + }, ): Promise { const { data } = await adminHttp.patch(`/members/${id}`, body); return data; } +/** 空壳删除:仅 key_count = 0 */ +export async function deleteMember(id: number): Promise { + await adminHttp.delete(`/members/${id}`); +} + /** 重发:旧令牌即刻失效,明文仅此一次 */ export async function issueMemberToken(id: number): Promise<{ token: string }> { const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`); diff --git a/apps/web/src/queries/admin.ts b/apps/web/src/queries/admin.ts index 5ef80f4..5a8af0a 100644 --- a/apps/web/src/queries/admin.ts +++ b/apps/web/src/queries/admin.ts @@ -23,6 +23,7 @@ import { } from "@/api/admin/topups.ts"; import { createMember, + deleteMember, fetchMembers, issueMemberToken, patchMember, @@ -207,10 +208,26 @@ export function usePatchMember() { body, }: { id: number; - body: { status: "active" | "disabled" }; + body: { + name?: string; + remark?: string; + status?: "active" | "disabled"; + }; }) => patchMember(id, body), onSuccess: () => { void qc.invalidateQueries({ queryKey: qk.admin.members() }); + void qc.invalidateQueries({ queryKey: ["admin", "keys"] }); + }, + }); +} + +export function useDeleteMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: deleteMember, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: qk.admin.members() }); + void qc.invalidateQueries({ queryKey: ["admin", "keys"] }); }, }); } diff --git a/apps/web/src/views/admin/members-view.vue b/apps/web/src/views/admin/members-view.vue index 6b9bac3..0837661 100644 --- a/apps/web/src/views/admin/members-view.vue +++ b/apps/web/src/views/admin/members-view.vue @@ -1,6 +1,6 @@ + + + + + + + + + + + + errorBody("member_name_taken", "成员名称已被占用"), + member_has_keys: (key_count: number) => + errorBody("member_has_keys", `名下仍有 ${key_count} 枚 key,只能停用`, { + fields: { key_count: String(key_count) }, + }), upstream_unreachable: () => errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"), not_found: () => errorBody("not_found", "请求的资源不存在"), diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 0582f33..62dc90e 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -115,7 +115,9 @@ export const createMemberBody = z.object({ }); export const patchMemberBody = z.object({ - status: z.enum(["active", "disabled"]), + name: z.string().min(1).max(64).optional(), + remark: z.string().optional(), + status: z.enum(["active", "disabled"]).optional(), }); export const memberWithToken = z.object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 31b6a15..902e59e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -138,10 +138,12 @@ export type AuditAction = | "payment.update" | "payment.delete" | "member.create" + | "member.update" | "member.enable" | "member.disable" | "member.token_issue" | "member.token_reissue" + | "member.delete" | "account.password_change" | "account.init"; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 2bc37b4..b3d4498 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -294,6 +294,23 @@ export function createRepos(db: Db) { .returning(); return row!; }, + async updateProfile( + id: number, + values: { name?: string; remark?: string }, + ): Promise { + const patch: { name?: string; remark?: string } = {}; + if (values.name !== undefined) patch.name = values.name; + if (values.remark !== undefined) patch.remark = values.remark; + const [row] = await db + .update(members) + .set(patch) + .where(eq(members.id, id)) + .returning(); + return row!; + }, + async deleteById(id: number): Promise { + await db.delete(members).where(eq(members.id, id)); + }, async reissueToken( id: number, token_hash: string,