Compare commits

...
2 Commits
Author SHA1 Message Date
skycurtain d0bab545b1 chore: 服务启动不再自动迁移数据库
schema 变更改为显式 db:push / 手动应用后再启动。
2026-10-12 13:57:25 +08:00
skycurtain f4f5ed0b0f feat(member): 档案修改与空壳删除
可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。
2026-10-12 13:57:19 +08:00
13 changed files with 249 additions and 37 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|------|------| |------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) | | `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) | | `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期) | | `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
| `bun run db:studio` | Drizzle Studio 看库 | | `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) | | `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑后端(hot reload) | | `bun run dev:server` | 开发跑后端(hot reload) |
+3 -3
View File
@@ -69,7 +69,7 @@ packages/
```bash ```bash
bun install bun install
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
bun run db:push # 开发期推送 schema bun run db:push # 开发期推送 schema(启动不自动迁)
bun run dev:server # 后端(默认 18456) bun run dev:server # 后端(默认 18456)
bun run dev:engine # 同步引擎(另开终端) bun run dev:engine # 同步引擎(另开终端)
bun run dev:web # 前端 Vite(5175) bun run dev:web # 前端 Vite(5175)
@@ -81,7 +81,7 @@ bun run dev:web # 前端 Vite(5175)
## 部署 ## 部署
生产形态:**Caddy**(前端静态 + `/api` 反代)+ **server / engine 单文件**(Bun `--compile`)+ PostgreSQL;启动时自动应用 `packages/db/drizzle` 迁移。开发不依赖本 Compose——把 `DATABASE_URL` 指到任意可用 PostgreSQL 即可。 生产形态:**Caddy**(前端静态 + `/api` 反代)+ **server / engine 单文件**(Bun `--compile`)+ PostgreSQL。**迁移不随服务启动**——改表后先用 `db:push` / 手动应用 SQL,再起服务。开发不依赖本 Compose——把 `DATABASE_URL` 指到任意可用 PostgreSQL 即可。
### 起服务 ### 起服务
@@ -92,7 +92,7 @@ docker compose up -d --build
``` ```
- 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs` - 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs`
- 升级:`git pull` 后重复 `docker compose up -d --build`(改表随启动迁移) - 升级:`git pull` → 有表变更时先应用 schema → 再 `docker compose up -d --build`
### 环境变量要点 ### 环境变量要点
+1 -8
View File
@@ -1,6 +1,5 @@
import { fileURLToPath } from "node:url";
import { appLogger, setupLogging } from "@new-api-key-usage/core"; import { appLogger, setupLogging } from "@new-api-key-usage/core";
import { createDb, createRepos, runMigrations } from "@new-api-key-usage/db"; import { createDb, createRepos } from "@new-api-key-usage/db";
import { loadEnv } from "./env.ts"; import { loadEnv } from "./env.ts";
import { createSyncLoop } from "./loop.ts"; import { createSyncLoop } from "./loop.ts";
@@ -10,12 +9,6 @@ const log = appLogger("engine");
const db = createDb(env.DATABASE_URL); const db = createDb(env.DATABASE_URL);
const migrationsFolder =
process.env.MIGRATIONS_FOLDER ??
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
await runMigrations(db, migrationsFolder);
log.info("数据库迁移已就绪");
const repos = createRepos(db); const repos = createRepos(db);
const loop = createSyncLoop({ const loop = createSyncLoop({
+1 -1
View File
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" }, { name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" }, { name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" }, { name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" }, { name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
{ name: "成员视图", description: "令牌自助查询" }, { name: "成员视图", description: "令牌自助查询" },
], ],
}); });
+1 -9
View File
@@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url";
import { createApp } from "./app.ts"; import { createApp } from "./app.ts";
import { loadEnv } from "./env.ts"; import { loadEnv } from "./env.ts";
import { createDb, runMigrations } from "@new-api-key-usage/db"; import { createDb } from "@new-api-key-usage/db";
import { appLogger, setupLogging } from "@new-api-key-usage/core"; import { appLogger, setupLogging } from "@new-api-key-usage/core";
const env = loadEnv(process.env); const env = loadEnv(process.env);
@@ -9,13 +8,6 @@ setupLogging(env.LOG_LEVEL);
const db = createDb(env.DATABASE_URL); const db = createDb(env.DATABASE_URL);
const migrationsFolder =
process.env.MIGRATIONS_FOLDER ??
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
await runMigrations(db, migrationsFolder);
appLogger("server").info("数据库迁移已就绪");
const app = createApp({ const app = createApp({
db, db,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET, skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/members/{id}", path: "/members/{id}",
summary: "停用 / 启用", summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"], tags: ["成员管理"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: { request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
}, },
401: err401, 401: err401,
404: err404, 404: err404,
409: err409,
422: err422, 422: err422,
}, },
}), }),
async (c) => { async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null); 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 repos = c.get("repos");
const before = await repos.members.findById(id); const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found()); 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({ await repos.audit.insert({
operator: c.get("adminUsername") ?? null, operator: c.get("adminUsername") ?? null,
domain: "member", domain: "member",
action: status === "active" ? "member.enable" : "member.disable", action: "member.delete",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`, summary: `删除成员 ${before.name}`,
}); });
return c.json(toMemberItem(row, 0), 200); return c.body(null, 204);
}, },
); );
+10 -1
View File
@@ -22,12 +22,21 @@ export async function createMember(body: {
export async function patchMember( export async function patchMember(
id: number, id: number,
body: { status: "active" | "disabled" }, body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> { ): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body); const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data; return data;
} }
/** 空壳删除:仅 key_count = 0 */
export async function deleteMember(id: number): Promise<void> {
await adminHttp.delete(`/members/${id}`);
}
/** 重发:旧令牌即刻失效,明文仅此一次 */ /** 重发:旧令牌即刻失效,明文仅此一次 */
export async function issueMemberToken(id: number): Promise<{ token: string }> { export async function issueMemberToken(id: number): Promise<{ token: string }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`); const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
+18 -1
View File
@@ -23,6 +23,7 @@ import {
} from "@/api/admin/topups.ts"; } from "@/api/admin/topups.ts";
import { import {
createMember, createMember,
deleteMember,
fetchMembers, fetchMembers,
issueMemberToken, issueMemberToken,
patchMember, patchMember,
@@ -207,10 +208,26 @@ export function usePatchMember() {
body, body,
}: { }: {
id: number; id: number;
body: { status: "active" | "disabled" }; body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body), }) => patchMember(id, body),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() }); 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"] });
}, },
}); });
} }
+82 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停 * 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/ */
import { computed, h, reactive, ref, watch } from "vue"; import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next"; import { Tooltip, message } from "antdv-next";
@@ -16,6 +16,7 @@ import TableRefresh from "@/components/common/table-refresh.vue";
import MemberTokenModal from "@/components/admin/member-token-modal.vue"; import MemberTokenModal from "@/components/admin/member-token-modal.vue";
import { import {
useCreateMember, useCreateMember,
useDeleteMember,
useIssueMemberToken, useIssueMemberToken,
useMembers, useMembers,
usePatchMember, usePatchMember,
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers(); const membersQuery = useMembers();
const createMember = useCreateMember(); const createMember = useCreateMember();
const patchMember = usePatchMember(); const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken(); const issueToken = useIssueMemberToken();
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false); const tokenOpen = ref(false);
const plainToken = ref(""); const plainToken = ref("");
const tokenMemberName = ref(""); const tokenMemberName = ref("");
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" }); const createForm = reactive({ name: "", remark: "" });
const createError = ref(""); const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref(""); const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null); const filterStatus = ref<"active" | "disabled" | null>(null);
@@ -83,7 +89,7 @@ const columns = [
width: 170, width: 170,
}, },
{ title: "名下 Key 数", key: "key_count", width: 110 }, { title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 200 }, { title: "操作", key: "actions", width: 280 },
]; ];
const rows = computed(() => { const rows = computed(() => {
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
message.error((e as ApiError).message || "状态更新失败"); message.error((e as ApiError).message || "状态更新失败");
} }
} }
function openEdit(record: MemberItem) {
editForm.id = record.id;
editForm.name = record.name;
editForm.remark = record.remark ?? "";
editError.value = "";
editOpen.value = true;
}
async function onEdit() {
editError.value = "";
const name = editForm.name.trim();
if (!name) {
editError.value = "请填写成员名称";
return;
}
try {
await patchMember.mutateAsync({
id: editForm.id,
body: { name, remark: editForm.remark },
});
editOpen.value = false;
message.success("已保存");
} catch (e) {
const err = e as ApiError;
editError.value = err.message || "保存失败";
}
}
async function onDelete(record: MemberItem) {
try {
await deleteMember.mutateAsync(record.id);
message.success("已删除——仅清理未挂 key 的空壳");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
</script> </script>
<template> <template>
<section> <section>
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停"> <PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions> <template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button> <a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template> </template>
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
> >
查调用 查调用
</a-button> </a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm <a-popconfirm
title="重发后旧令牌立即失效" title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断" description="该成员正在用的访问将中断"
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
<a-button v-else size="small" @click="() => onToggle(record)"> <a-button v-else size="small" @click="() => onToggle(record)">
启用 启用
</a-button> </a-button>
<a-tooltip
v-if="record.key_count > 0"
title="名下仍有 key,只能停用"
>
<a-button size="small" danger disabled>删除</a-button>
</a-tooltip>
<a-popconfirm
v-else
title="删除后不可恢复(名称与令牌一并作废)"
description="仅清理未挂 key 的空壳"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space> </a-space>
</template> </template>
</template> </template>
</a-table> </a-table>
<a-modal
v-model:open="editOpen"
title="编辑成员"
:confirm-loading="patchMember.isPending.value"
ok-text="保存"
cancel-text="取消"
@ok="onEdit"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="editForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="editError" type="error" show-icon :message="editError" />
</a-form>
</a-modal>
<a-modal <a-modal
v-model:open="createOpen" v-model:open="createOpen"
title="新建成员" title="新建成员"
+4
View File
@@ -36,6 +36,10 @@ export const ERR = {
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }), errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () => member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"), 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: () => upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"), errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"), not_found: () => errorBody("not_found", "请求的资源不存在"),
+3 -1
View File
@@ -115,7 +115,9 @@ export const createMemberBody = z.object({
}); });
export const patchMemberBody = 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({ export const memberWithToken = z.object({
+2
View File
@@ -138,10 +138,12 @@ export type AuditAction =
| "payment.update" | "payment.update"
| "payment.delete" | "payment.delete"
| "member.create" | "member.create"
| "member.update"
| "member.enable" | "member.enable"
| "member.disable" | "member.disable"
| "member.token_issue" | "member.token_issue"
| "member.token_reissue" | "member.token_reissue"
| "member.delete"
| "account.password_change" | "account.password_change"
| "account.init"; | "account.init";
+17
View File
@@ -294,6 +294,23 @@ export function createRepos(db: Db) {
.returning(); .returning();
return row!; return row!;
}, },
async updateProfile(
id: number,
values: { name?: string; remark?: string },
): Promise<MemberRow> {
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<void> {
await db.delete(members).where(eq(members.id, id));
},
async reissueToken( async reissueToken(
id: number, id: number,
token_hash: string, token_hash: string,