diff --git a/apps/web/src/components/QuickEntryDrawer.vue b/apps/web/src/components/QuickEntryDrawer.vue index 4a00827..2f23d36 100644 --- a/apps/web/src/components/QuickEntryDrawer.vue +++ b/apps/web/src/components/QuickEntryDrawer.vue @@ -23,7 +23,8 @@ type SelectorLevel = "type" | "category" | "subcategory"; const props = defineProps<{ ledgerId: string; ledgerName: string; - ledgerOptions: Array<{ id: string; name: string; color: string }>; + personalLedgerId: string; + ledgerOptions: Array<{ id: string; name: string; color: string; isPersonal: boolean }>; }>(); const emit = defineEmits<{ @@ -41,7 +42,7 @@ const selectorOpen = ref(false); const selectorLevel = ref("type"); const noteEditing = ref(false); const ledgerPickerOpen = ref(false); -const selectedLedgerIds = ref([props.ledgerId]); +const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]); const axisOffset = ref(0); let axisPointerId: number | null = null; let axisDragStartY = 0; @@ -67,9 +68,14 @@ const displayAmount = computed(() => maximumFractionDigits: 2, }).format(calculatedAmount.value), ); -const selectedLedgerLabel = computed(() => selectedLedgerIds.value.length === 1 - ? props.ledgerOptions.find((ledger) => ledger.id === selectedLedgerIds.value[0])?.name ?? props.ledgerName - : `${selectedLedgerIds.value.length} 个账本`); +const selectedLedgerLabel = computed(() => { + const visibleNames = props.ledgerOptions + .filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id)) + .map((ledger) => ledger.name); + if (!visibleNames.length) return "个人账本"; + const names = visibleNames.slice(0, 3).join(","); + return selectedLedgerIds.value.length > 1 ? `${names} 等 ${selectedLedgerIds.value.length} 个账本` : names; +}); const dateLabel = computed(() => { const date = new Date(occurredAt.value); const now = new Date(); @@ -327,6 +333,7 @@ function openLedgerPicker() { } function toggleLedger(ledgerId: string) { + if (ledgerId === props.personalLedgerId) return; if (selectedLedgerIds.value.includes(ledgerId)) { if (selectedLedgerIds.value.length > 1) { selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId); @@ -525,9 +532,9 @@ function saveEntry() {
选择账本至少选择一个账本
-
diff --git a/apps/web/src/components/QuickEntryHost.vue b/apps/web/src/components/QuickEntryHost.vue index 19adc7e..0afdd33 100644 --- a/apps/web/src/components/QuickEntryHost.vue +++ b/apps/web/src/components/QuickEntryHost.vue @@ -16,7 +16,7 @@ const drawerOpen = ref(false); const savedToast = ref(false); const ledgerOptions = computed(() => - ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color })), + ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color, isPersonal: ledger.isPersonal })), ); onMounted(async () => { @@ -63,6 +63,7 @@ async function saveEntry(input: { :ledger-id="ledgerStore.currentLedger.id" :ledger-name="ledgerStore.currentLedger.name" :ledger-options="ledgerOptions" + :personal-ledger-id="ledgerStore.personalLedger?.id ?? ''" @close="drawerOpen = false" @save="saveEntry" /> diff --git a/apps/web/src/data/ledgers.ts b/apps/web/src/data/ledgers.ts index 8e0a060..e2d88ea 100644 --- a/apps/web/src/data/ledgers.ts +++ b/apps/web/src/data/ledgers.ts @@ -9,6 +9,7 @@ export type LedgerRecord = { updatedAt: string; archivedAt: string | null; role?: "owner" | "member"; + isPersonal: boolean; }; export type UserPreference = { @@ -34,6 +35,7 @@ export function createDefaultLedgers(): LedgerRecord[] { createdAt: now, updatedAt: now, archivedAt: null, + isPersonal: true, }, { id: "travel-ledger", @@ -43,6 +45,7 @@ export function createDefaultLedgers(): LedgerRecord[] { createdAt: now, updatedAt: now, archivedAt: null, + isPersonal: false, }, ]; } diff --git a/apps/web/src/stores/ledgers.ts b/apps/web/src/stores/ledgers.ts index cc47878..350252c 100644 --- a/apps/web/src/stores/ledgers.ts +++ b/apps/web/src/stores/ledgers.ts @@ -27,6 +27,9 @@ export const useLedgerStore = defineStore("ledgers", { currentRole(): "owner" | "member" | null { return this.currentLedger?.role ?? null; }, + personalLedger(state) { + return state.ledgers.find((ledger) => ledger.isPersonal) ?? null; + }, }, actions: { async loadLedgers() { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 374046f..a0eb2bf 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -695,7 +695,9 @@ input:focus-visible { .quick-ledger-options > button:last-child { border-bottom:0; } .quick-ledger-options > button > span { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; color:#fff; } .quick-ledger-options > button > strong { overflow:hidden; font-size:14px; text-overflow:ellipsis; white-space:nowrap; } +.quick-ledger-options > button > strong small { margin-left:8px; color:#82908c; font-size:11px; font-weight:500; } .quick-ledger-options > button > svg { color:#087f72; } +.quick-ledger-options > button.automatic { cursor:default; opacity:.62; } .quick-ledger-picker-done { width:100%; height:46px; display:flex; align-items:center; justify-content:center; gap:6px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; } .amount-result { diff --git a/apps/web/src/views/EntryDetailView.vue b/apps/web/src/views/EntryDetailView.vue index 54f73bc..39e1458 100644 --- a/apps/web/src/views/EntryDetailView.vue +++ b/apps/web/src/views/EntryDetailView.vue @@ -104,7 +104,7 @@ onMounted(async () => { function hydrate(value: LedgerEntry) { entry.value = value; amount.value = (value.amount / 100).toFixed(2); - ledgerIds.value = [...value.ledgerIds]; + ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))]; note.value = value.note; occurredAt.value = toLocalDateTime(new Date(value.occurredAt)); const path = findCategoryPath(value.type, value.categoryId); @@ -137,6 +137,7 @@ function chooseChild(category: Category) { } function toggleLedger(ledgerId: string) { + if (ledgerId === ledgerStore.personalLedger?.id) return; if (ledgerIds.value.includes(ledgerId)) { if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId); return; @@ -346,9 +347,9 @@ async function deleteEntry() {
选择账本至少选择一个账本
-
diff --git a/apps/web/src/views/LedgerSettingsView.vue b/apps/web/src/views/LedgerSettingsView.vue index afd7dfd..7afb401 100644 --- a/apps/web/src/views/LedgerSettingsView.vue +++ b/apps/web/src/views/LedgerSettingsView.vue @@ -41,7 +41,7 @@ async function save() {
- +
diff --git a/apps/web/src/views/LedgerView.vue b/apps/web/src/views/LedgerView.vue index dc17f91..da88d07 100644 --- a/apps/web/src/views/LedgerView.vue +++ b/apps/web/src/views/LedgerView.vue @@ -236,11 +236,12 @@ async function shareLedger() {
- +
diff --git a/apps/web/src/views/LedgersView.vue b/apps/web/src/views/LedgersView.vue index 5a2d5d1..1c6d379 100644 --- a/apps/web/src/views/LedgersView.vue +++ b/apps/web/src/views/LedgersView.vue @@ -77,7 +77,7 @@ async function createLedger() {
diff --git a/docs/账号与权限.md b/docs/账号与权限.md index 6e9a81e..94dad10 100644 --- a/docs/账号与权限.md +++ b/docs/账号与权限.md @@ -10,13 +10,22 @@ PUBLIC_ORIGIN=https://cents.example.com npm run bootstrap --workspace @cents/api 应用邀请使用 256 位随机 `key`,数据库只保存 SHA-256 摘要。链接固定 7 天过期并且只能创建一个账号。 +管理员也可以直接添加用户: + +```bash +npm run user:add -- '用户名' '至少8位的密码' +``` + +用户与个人账本在同一数据库事务内创建;用户名已存在或任一步失败时都不会留下半成品数据。 + 新用户打开 `/invite?key=...` 后填写姓名和密码: - 用户 ID 由服务端生成 UUID。 - 姓名同时作为登录名,忽略首尾空格和大小写后必须唯一。 - 密码长度为 8 至 128 个字符。 - 密码使用 Argon2id 保存,参数为 19 MiB 内存、2 次迭代、并行度 1。 -- 新账号自动获得一个自己的默认账本,并成为 owner。 +- 新账号自动获得唯一的“个人账本”,并成为 owner。 +- 个人账本不能分享或加入其他成员,每笔账目都会由服务端强制关联到其所有者的个人账本。 已登录用户打开应用邀请时直接返回应用,不创建账号,也不消费邀请。 @@ -56,6 +65,7 @@ NEW_PASSWORD='新的密码' npm run password:reset --workspace @cents/api -- ' - 用户只能访问自己是成员的账本。 - `owner` 可以修改账本设置并创建账本邀请。 +- 个人账本名称固定,且不能创建账本邀请。 - `member` 可以查看和记账,但不能创建邀请或修改账本设置。 - 服务端在每次请求时查询成员关系,不依赖前端隐藏按钮。 - 所有写请求检查 `Origin`,API 同时启用安全响应头和请求限速。 diff --git a/package.json b/package.json index eece7a6..960dab9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:web": "npm run dev --workspace @cents/web", "dev:web:tunnel": "npm run dev --workspace @cents/web -- --port 6064", "dev:api": "npm run dev --workspace @cents/api", + "user:add": "npm run user:add --workspace @cents/api --", "build": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", "lint": "npm run lint --workspaces --if-present" diff --git a/services/api/package.json b/services/api/package.json index 1741c2f..e6b38d8 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -7,6 +7,7 @@ "dev": "tsx watch src/server.ts", "start": "tsx src/server.ts", "bootstrap": "tsx src/bootstrap.ts", + "user:add": "tsx src/add-user.ts", "password:reset": "tsx src/reset-password.ts", "build": "tsc --noEmit", "typecheck": "tsc --noEmit" diff --git a/services/api/src/accounts.ts b/services/api/src/accounts.ts new file mode 100644 index 0000000..76a6ae2 --- /dev/null +++ b/services/api/src/accounts.ts @@ -0,0 +1,28 @@ +import type { PoolClient } from "pg"; +import { createId, hashPassword, normalizeName, validPassword } from "./security.js"; + +export const PERSONAL_LEDGER_NAME = "个人账本"; + +export async function createUserWithPersonalLedger(client: PoolClient, name: string, password: string) { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName.length > 40) throw new Error("用户名应为 1 至 40 个字符"); + if (!validPassword(password)) throw new Error("密码应为 8 至 128 个字符"); + + const user = { id: createId(), name: trimmedName }; + const passwordHash = await hashPassword(password); + await client.query( + "INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)", + [user.id, user.name, normalizeName(user.name), passwordHash], + ); + const ledgerId = createId(); + await client.query( + `INSERT INTO ledgers (id, name, color, default_currency, personal_owner_id) + VALUES ($1, $2, '#087f72', 'CNY', $3)`, + [ledgerId, PERSONAL_LEDGER_NAME, user.id], + ); + await client.query( + "INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')", + [ledgerId, user.id], + ); + return user; +} diff --git a/services/api/src/add-user.ts b/services/api/src/add-user.ts new file mode 100644 index 0000000..eef0594 --- /dev/null +++ b/services/api/src/add-user.ts @@ -0,0 +1,26 @@ +import { createUserWithPersonalLedger } from "./accounts.js"; +import { initializeDatabase, pool } from "./db.js"; + +const name = process.argv[2]?.trim() ?? ""; +const password = process.argv[3] ?? ""; +if (!name || !password) { + throw new Error("用法:npm run user:add -- <用户名> <密码>"); +} + +await initializeDatabase(); +const client = await pool.connect(); +try { + await client.query("BEGIN"); + const user = await createUserWithPersonalLedger(client, name, password); + await client.query("COMMIT"); + process.stdout.write(`已添加用户 ${user.name},并创建个人账本。\n`); +} catch (error) { + await client.query("ROLLBACK"); + if (typeof error === "object" && error !== null && "code" in error && error.code === "23505") { + throw new Error("该用户名已存在"); + } + throw error; +} finally { + client.release(); + await pool.end(); +} diff --git a/services/api/src/db.ts b/services/api/src/db.ts index 7cd08bc..2048449 100644 --- a/services/api/src/db.ts +++ b/services/api/src/db.ts @@ -1,4 +1,5 @@ import pg from "pg"; +import { createId } from "./security.js"; const { Pool } = pg; @@ -32,6 +33,10 @@ CREATE TABLE IF NOT EXISTS ledgers ( archived_at timestamptz ); +ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS personal_owner_id text REFERENCES users(id) ON DELETE CASCADE; +CREATE UNIQUE INDEX IF NOT EXISTS ledgers_personal_owner_unique + ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS ledger_members ( ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE, user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -149,4 +154,38 @@ CREATE TABLE IF NOT EXISTS entry_sync_operations ( export async function initializeDatabase() { await pool.query(schema); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const users = await client.query<{ id: string }>( + `SELECT u.id FROM users u + WHERE NOT EXISTS (SELECT 1 FROM ledgers l WHERE l.personal_owner_id = u.id) + FOR UPDATE`, + ); + for (const user of users.rows) { + const ledgerId = createId(); + await client.query( + `INSERT INTO ledgers (id, name, color, default_currency, personal_owner_id) + VALUES ($1, '个人账本', '#087f72', 'CNY', $2)`, + [ledgerId, user.id], + ); + await client.query( + "INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')", + [ledgerId, user.id], + ); + } + await client.query( + `INSERT INTO entry_ledgers (entry_id, ledger_id) + SELECT e.id, l.id FROM entries e JOIN ledgers l ON l.personal_owner_id = e.owner_id + ON CONFLICT (entry_id, ledger_id) DO UPDATE + SET unlinked_at = NULL, updated_at = now() + WHERE entry_ledgers.unlinked_at IS NOT NULL`, + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } } diff --git a/services/api/src/server.ts b/services/api/src/server.ts index a9ff279..588fdac 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -3,6 +3,7 @@ import helmet from "@fastify/helmet"; import rateLimit from "@fastify/rate-limit"; import type { LedgerEntry, SyncOperation } from "@cents/domain"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; +import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js"; import { initializeDatabase, pool } from "./db.js"; import { clearSessionCookie, @@ -232,10 +233,11 @@ server.get("/api/ledgers", async (request, reply) => { const result = await pool.query( `SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency", l.created_at AS "createdAt", l.updated_at AS "updatedAt", - l.archived_at AS "archivedAt", m.role + l.archived_at AS "archivedAt", m.role, + (l.personal_owner_id IS NOT NULL) AS "isPersonal" FROM ledgers l JOIN ledger_members m ON m.ledger_id = l.id WHERE m.user_id = $1 AND m.removed_at IS NULL AND l.archived_at IS NULL - ORDER BY l.updated_at DESC`, + ORDER BY (l.personal_owner_id = $1) DESC, l.updated_at DESC`, [user.id], ); return { ledgers: result.rows }; @@ -304,6 +306,10 @@ server.patch<{ if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") { return reply.code(403).send({ error: "只有账本拥有者可以修改设置" }); } + const personalLedger = await pool.query<{ isPersonal: boolean }>( + `SELECT (personal_owner_id IS NOT NULL) AS "isPersonal" FROM ledgers WHERE id = $1`, + [request.params.ledgerId], + ); const name = request.body.name?.trim() ?? ""; if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" }); const result = await pool.query( @@ -311,7 +317,7 @@ server.patch<{ WHERE id = $4 RETURNING id, name, color, default_currency AS "defaultCurrency", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`, - [name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId], + [personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId], ); return { ledger: result.rows[0] }; }); @@ -341,8 +347,6 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string const password = request.body.password ?? ""; if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" }); if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" }); - const passwordHash = await hashPassword(password); - const client = await pool.connect(); try { await client.query("BEGIN"); @@ -361,17 +365,7 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string return reply.code(code).send({ error: status === "accepted" ? "邀请已被使用" : status === "expired" ? "邀请已过期" : status === "revoked" ? "邀请已撤销" : "邀请链接无效" }); } - const user: User = { id: createId(), name }; - await client.query( - "INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)", - [user.id, user.name, normalizeName(user.name), passwordHash], - ); - const ledgerId = createId(); - await client.query("INSERT INTO ledgers (id, name) VALUES ($1, '家庭日常')", [ledgerId]); - await client.query( - "INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')", - [ledgerId, user.id], - ); + const user = await createUserWithPersonalLedger(client, name, password); await client.query( "UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2", [user.id, invitation!.id], @@ -397,6 +391,8 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string server.post<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/invitations", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; + const personalLedger = await pool.query("SELECT 1 FROM ledgers WHERE id = $1 AND personal_owner_id IS NOT NULL", [request.params.ledgerId]); + if (personalLedger.rowCount) return reply.code(403).send({ error: "个人账本不能共享" }); if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") { return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" }); } @@ -432,13 +428,20 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept", await client.query("BEGIN"); const result = await client.query<{ id: string; ledgerId: string; role: MemberRole; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null; + personalOwnerId: string | null; }>( - `SELECT id, ledger_id AS "ledgerId", role, expires_at AS "expiresAt", - accepted_at AS "acceptedAt", revoked_at AS "revokedAt" - FROM ledger_invitations WHERE key_hash = $1 FOR UPDATE`, + `SELECT i.id, i.ledger_id AS "ledgerId", i.role, i.expires_at AS "expiresAt", + i.accepted_at AS "acceptedAt", i.revoked_at AS "revokedAt", + l.personal_owner_id AS "personalOwnerId" + FROM ledger_invitations i JOIN ledgers l ON l.id = i.ledger_id + WHERE i.key_hash = $1 FOR UPDATE OF i`, [hashSecret(request.params.key)], ); const invitation = result.rows[0]; + if (invitation?.personalOwnerId) { + await client.query("ROLLBACK"); + return reply.code(403).send({ error: "个人账本不能共享" }); + } const status = invitation ? invitationStatus(invitation) : "missing"; if (status !== "valid") { await client.query("ROLLBACK"); @@ -526,6 +529,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque return reply.code(403).send({ error: "无权关联其中一个账本" }); } + const entryOwnerId = existing.rows[0]?.ownerId ?? user.id; + const personalLedger = await client.query<{ id: string }>( + "SELECT id FROM ledgers WHERE personal_owner_id = $1", + [entryOwnerId], + ); + if (!personalLedger.rows[0]) throw new Error("用户缺少个人账本"); + const targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])]; + const entry = operation.payload; const writeResult = await client.query( `INSERT INTO entries ( @@ -567,7 +578,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque `UPDATE entry_ledgers SET unlinked_at = now(), updated_at = now() WHERE entry_id = $1 AND unlinked_at IS NULL AND NOT (ledger_id = ANY($2::text[]))`, - [entry.id, operation.ledgerIds], + [entry.id, targetLedgerIds], ); } else if (existing.rows[0]) { await client.query( @@ -576,7 +587,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque WHERE el.entry_id = $1 AND el.unlinked_at IS NULL AND el.ledger_id = m.ledger_id AND m.user_id = $2 AND m.removed_at IS NULL AND NOT (el.ledger_id = ANY($3::text[]))`, - [entry.id, user.id, operation.ledgerIds], + [entry.id, user.id, targetLedgerIds], ); } await client.query( @@ -584,7 +595,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque SELECT $1, unnest($2::text[]) ON CONFLICT (entry_id, ledger_id) DO UPDATE SET unlinked_at = NULL, updated_at = now()`, - [entry.id, operation.ledgerIds], + [entry.id, targetLedgerIds], ); } await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);