Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bab545b1 | ||
|
|
f4f5ed0b0f |
@@ -27,7 +27,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|
||||
|------|------|
|
||||
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
|
||||
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||
| `bun run db:push` | 推送到本地库(开发期) |
|
||||
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
|
||||
| `bun run db:studio` | Drizzle Studio 看库 |
|
||||
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
||||
| `bun run dev:server` | 开发跑后端(hot reload) |
|
||||
|
||||
@@ -69,7 +69,7 @@ packages/
|
||||
```bash
|
||||
bun install
|
||||
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:engine # 同步引擎(另开终端)
|
||||
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`
|
||||
- 升级:`git pull` 后重复 `docker compose up -d --build`(改表随启动迁移)
|
||||
- 升级:`git pull` → 有表变更时先应用 schema → 再 `docker compose up -d --build`
|
||||
|
||||
### 环境变量要点
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
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 { createSyncLoop } from "./loop.ts";
|
||||
|
||||
@@ -10,12 +9,6 @@ const log = appLogger("engine");
|
||||
|
||||
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 loop = createSyncLoop({
|
||||
|
||||
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
|
||||
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
|
||||
{ name: "引擎", description: "同步引擎状态" },
|
||||
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
|
||||
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
|
||||
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
|
||||
{ name: "成员视图", description: "令牌自助查询" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp } from "./app.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";
|
||||
|
||||
const env = loadEnv(process.env);
|
||||
@@ -9,13 +8,6 @@ setupLogging(env.LOG_LEVEL);
|
||||
|
||||
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({
|
||||
db,
|
||||
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<MemberItem> {
|
||||
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
||||
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 }> {
|
||||
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
|
||||
*/
|
||||
import { computed, h, reactive, ref, watch } from "vue";
|
||||
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 {
|
||||
useCreateMember,
|
||||
useDeleteMember,
|
||||
useIssueMemberToken,
|
||||
useMembers,
|
||||
usePatchMember,
|
||||
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
|
||||
const membersQuery = useMembers();
|
||||
const createMember = useCreateMember();
|
||||
const patchMember = usePatchMember();
|
||||
const deleteMember = useDeleteMember();
|
||||
const issueToken = useIssueMemberToken();
|
||||
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const tokenOpen = ref(false);
|
||||
const plainToken = ref("");
|
||||
const tokenMemberName = ref("");
|
||||
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
|
||||
const createForm = reactive({ name: "", remark: "" });
|
||||
const createError = ref("");
|
||||
|
||||
const editForm = reactive({ id: 0, name: "", remark: "" });
|
||||
const editError = ref("");
|
||||
|
||||
const filterName = ref("");
|
||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||
|
||||
@@ -83,7 +89,7 @@ const columns = [
|
||||
width: 170,
|
||||
},
|
||||
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
||||
{ title: "操作", key: "actions", width: 200 },
|
||||
{ title: "操作", key: "actions", width: 280 },
|
||||
];
|
||||
|
||||
const rows = computed(() => {
|
||||
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
|
||||
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
|
||||
<template #actions>
|
||||
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
||||
</template>
|
||||
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
|
||||
>
|
||||
查调用
|
||||
</a-button>
|
||||
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="重发后旧令牌立即失效"
|
||||
description="该成员正在用的访问将中断"
|
||||
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
|
||||
<a-button v-else size="small" @click="() => onToggle(record)">
|
||||
启用
|
||||
</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>
|
||||
</template>
|
||||
</template>
|
||||
</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
|
||||
v-model:open="createOpen"
|
||||
title="新建成员"
|
||||
|
||||
@@ -36,6 +36,10 @@ export const ERR = {
|
||||
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
|
||||
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: () =>
|
||||
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
|
||||
not_found: () => errorBody("not_found", "请求的资源不存在"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -294,6 +294,23 @@ export function createRepos(db: Db) {
|
||||
.returning();
|
||||
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(
|
||||
id: number,
|
||||
token_hash: string,
|
||||
|
||||
Reference in New Issue
Block a user