结余
@@ -174,6 +186,9 @@ function money(value: number) {
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; }
+.stats-scope { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:0 0 12px; border-radius:8px; padding:4px; background:#e7eeec; }
+.stats-scope button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
+.stats-scope button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; }
.stats-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
.stats-summary > div:first-child { border-left: 0; }
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index cbfc356..1cc2cd6 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -6,7 +6,8 @@ export type ExchangeRateSource = "manual" | "system";
export type LedgerEntry = {
id: string;
- ledgerId: string;
+ ownerId: string;
+ ledgerIds: string[];
type: EntryType;
amount: number;
currency: CurrencyCode;
@@ -30,7 +31,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
export type SyncOperation = {
id: string;
userId?: string;
- ledgerId: string;
+ ledgerIds: string[];
entity: "entry";
entityId: string;
action: SyncOperationAction;
diff --git a/services/api/src/db.ts b/services/api/src/db.ts
index bbe0cb3..7cd08bc 100644
--- a/services/api/src/db.ts
+++ b/services/api/src/db.ts
@@ -84,7 +84,7 @@ CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
CREATE TABLE IF NOT EXISTS entries (
id text PRIMARY KEY,
- ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
+ owner_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
amount integer NOT NULL CHECK (amount > 0),
currency varchar(3) NOT NULL,
@@ -104,8 +104,41 @@ CREATE TABLE IF NOT EXISTS entries (
server_updated_at timestamptz NOT NULL DEFAULT now()
);
-CREATE INDEX IF NOT EXISTS entries_ledger_occurred
- ON entries (ledger_id, occurred_at DESC);
+ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
+UPDATE entries SET owner_id = COALESCE(created_by, updated_by) WHERE owner_id IS NULL;
+ALTER TABLE entries ALTER COLUMN owner_id SET NOT NULL;
+
+CREATE TABLE IF NOT EXISTS entry_ledgers (
+ entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
+ ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
+ linked_at timestamptz NOT NULL DEFAULT now(),
+ unlinked_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (entry_id, ledger_id)
+);
+
+ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS unlinked_at timestamptz;
+ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'entries' AND column_name = 'ledger_id'
+ ) THEN
+ EXECUTE 'INSERT INTO entry_ledgers (entry_id, ledger_id)
+ SELECT id, ledger_id FROM entries
+ ON CONFLICT (entry_id, ledger_id) DO NOTHING';
+ END IF;
+END $$;
+
+DROP INDEX IF EXISTS entries_ledger_occurred;
+ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
+
+CREATE INDEX IF NOT EXISTS entries_owner_occurred
+ ON entries (owner_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
+ ON entry_ledgers (ledger_id, entry_id);
CREATE TABLE IF NOT EXISTS entry_sync_operations (
id text PRIMARY KEY,
diff --git a/services/api/src/server.ts b/services/api/src/server.ts
index 80af9cc..a9ff279 100644
--- a/services/api/src/server.ts
+++ b/services/api/src/server.ts
@@ -120,11 +120,18 @@ function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
+function validLedgerIds(value: unknown): value is string[] {
+ return Array.isArray(value) && value.length > 0 && value.length <= 20
+ && value.every((ledgerId) => typeof ledgerId === "string" && ledgerId.length > 0 && ledgerId.length <= 100)
+ && new Set(value).size === value.length;
+}
+
function validEntry(value: unknown): value is LedgerEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial;
return typeof entry.id === "string" && entry.id.length > 0 && entry.id.length <= 100
- && typeof entry.ledgerId === "string" && entry.ledgerId.length > 0 && entry.ledgerId.length <= 100
+ && typeof entry.ownerId === "string" && entry.ownerId.length > 0 && entry.ownerId.length <= 100
+ && validLedgerIds(entry.ledgerIds)
&& (entry.type === "expense" || entry.type === "income")
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
&& typeof entry.currency === "string" && currencies.has(entry.currency)
@@ -142,13 +149,16 @@ function validEntry(value: unknown): value is LedgerEntry {
function validSyncOperation(value: unknown): value is SyncOperation {
if (!value || typeof value !== "object") return false;
const operation = value as Partial;
+ const payload = operation.payload;
return typeof operation.id === "string" && operation.id.length > 0 && operation.id.length <= 100
&& operation.entity === "entry"
&& (operation.action === "create" || operation.action === "update" || operation.action === "delete")
- && validEntry(operation.payload)
- && operation.entityId === operation.payload.id
- && operation.ledgerId === operation.payload.ledgerId
- && (operation.action !== "delete" || operation.payload.deletedAt !== null);
+ && validEntry(payload)
+ && operation.entityId === payload.id
+ && validLedgerIds(operation.ledgerIds)
+ && operation.ledgerIds.length === payload.ledgerIds.length
+ && operation.ledgerIds.every((ledgerId) => payload.ledgerIds.includes(ledgerId))
+ && (operation.action !== "delete" || payload.deletedAt !== null);
}
type SyncCursor = { serverUpdatedAt: string; id: string };
@@ -440,7 +450,11 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
[invitation!.ledgerId, user.id, invitation!.role],
);
await client.query(
- "UPDATE entries SET server_updated_at = now() WHERE ledger_id = $1",
+ `UPDATE entries e SET server_updated_at = now()
+ WHERE EXISTS (
+ SELECT 1 FROM entry_ledgers el
+ WHERE el.entry_id = e.id AND el.ledger_id = $1 AND el.unlinked_at IS NULL
+ )`,
[invitation!.ledgerId],
);
await client.query(
@@ -479,35 +493,43 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
continue;
}
- const targetRole = await client.query(
- `SELECT role FROM ledger_members
- WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
- [operation.ledgerId, user.id],
+ const existing = await client.query<{ ownerId: string; canAccess: boolean }>(
+ `SELECT e.owner_id AS "ownerId",
+ (e.owner_id = $2 OR EXISTS (
+ SELECT 1 FROM entry_ledgers el
+ JOIN ledger_members m ON m.ledger_id = el.ledger_id
+ WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
+ AND m.user_id = $2 AND m.removed_at IS NULL
+ )) AS "canAccess"
+ FROM entries e WHERE e.id = $1`,
+ [operation.entityId, user.id],
);
- if (!targetRole.rowCount) {
+ if (existing.rows[0] && !existing.rows[0].canAccess) {
await client.query("ROLLBACK");
- return reply.code(403).send({ error: "无权同步该账本的流水" });
+ return reply.code(403).send({ error: "无权修改该流水" });
}
- const existing = await client.query<{ ledgerId: string }>(
- `SELECT ledger_id AS "ledgerId" FROM entries WHERE id = $1`,
- [operation.entityId],
+ const inaccessibleTargets = await client.query<{ count: number }>(
+ `SELECT count(*)::int AS count
+ FROM unnest($1::text[]) AS target(ledger_id)
+ WHERE NOT EXISTS (
+ SELECT 1 FROM ledger_members m
+ WHERE m.ledger_id = target.ledger_id AND m.user_id = $2 AND m.removed_at IS NULL
+ )
+ AND NOT ($3::boolean AND EXISTS (
+ SELECT 1 FROM entry_ledgers el
+ WHERE el.entry_id = $4 AND el.ledger_id = target.ledger_id AND el.unlinked_at IS NULL
+ ))`,
+ [operation.ledgerIds, user.id, existing.rows[0]?.ownerId === user.id, operation.entityId],
);
- if (existing.rows[0] && existing.rows[0].ledgerId !== operation.ledgerId) {
- const sourceRole = await client.query(
- `SELECT role FROM ledger_members
- WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
- [existing.rows[0].ledgerId, user.id],
- );
- if (!sourceRole.rowCount) {
- await client.query("ROLLBACK");
- return reply.code(403).send({ error: "无权移动该流水" });
- }
+ if (inaccessibleTargets.rows[0]?.count) {
+ await client.query("ROLLBACK");
+ return reply.code(403).send({ error: "无权关联其中一个账本" });
}
const entry = operation.payload;
- await client.query(
+ const writeResult = await client.query(
`INSERT INTO entries (
- id, ledger_id, type, amount, currency, base_currency, base_amount,
+ id, owner_id, type, amount, currency, base_currency, base_amount,
exchange_rate, exchange_rate_source, category_id, note, occurred_at,
created_by, updated_by, created_at, updated_at, deleted_at, version
) VALUES (
@@ -515,7 +537,6 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
$13, $14, $15, $16, $17, $18
)
ON CONFLICT (id) DO UPDATE SET
- ledger_id = EXCLUDED.ledger_id,
type = EXCLUDED.type,
amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
@@ -531,15 +552,41 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
deleted_at = EXCLUDED.deleted_at,
version = EXCLUDED.version,
server_updated_at = now()
- WHERE entries.version < EXCLUDED.version
+ WHERE entries.version < EXCLUDED.version
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`,
[
- entry.id, entry.ledgerId, entry.type, entry.amount, entry.currency, entry.baseCurrency,
+ entry.id, user.id, entry.type, entry.amount, entry.currency, entry.baseCurrency,
entry.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version,
],
);
+ if (operation.action !== "delete" && writeResult.rowCount) {
+ if (existing.rows[0]?.ownerId === user.id) {
+ await client.query(
+ `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],
+ );
+ } else if (existing.rows[0]) {
+ await client.query(
+ `UPDATE entry_ledgers el SET unlinked_at = now(), updated_at = now()
+ FROM ledger_members m
+ 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],
+ );
+ }
+ await client.query(
+ `INSERT INTO entry_ledgers (entry_id, ledger_id)
+ SELECT $1, unnest($2::text[])
+ ON CONFLICT (entry_id, ledger_id) DO UPDATE
+ SET unlinked_at = NULL, updated_at = now()`,
+ [entry.id, operation.ledgerIds],
+ );
+ }
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
await client.query(
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)",
@@ -563,20 +610,53 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
const cursor = request.query.cursor ? decodeSyncCursor(request.query.cursor) : null;
if (request.query.cursor && !cursor) return reply.code(400).send({ error: "同步游标无效" });
const result = await pool.query(
- `SELECT e.id, e.ledger_id AS "ledgerId", e.type, e.amount,
+ `SELECT e.id, e.owner_id AS "ownerId",
+ ARRAY(
+ SELECT el.ledger_id
+ FROM entry_ledgers el
+ LEFT JOIN ledger_members visible_member
+ ON visible_member.ledger_id = el.ledger_id
+ AND visible_member.user_id = $1
+ AND visible_member.removed_at IS NULL
+ WHERE el.entry_id = e.id
+ AND el.unlinked_at IS NULL
+ AND (e.owner_id = $1 OR visible_member.user_id IS NOT NULL)
+ ORDER BY el.ledger_id
+ ) AS "ledgerIds",
+ e.type, e.amount,
e.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource",
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
e.deleted_at AS "deletedAt", e.version,
- e.server_updated_at AS "serverUpdatedAt"
- FROM entries e JOIN ledger_members m ON m.ledger_id = e.ledger_id
- WHERE m.user_id = $1 AND m.removed_at IS NULL
+ entry_change.changed_at AS "serverUpdatedAt"
+ FROM entries e
+ CROSS JOIN LATERAL (
+ SELECT GREATEST(
+ e.server_updated_at,
+ COALESCE(MAX(el.updated_at) FILTER (
+ WHERE e.owner_id = $1 OR change_member.user_id IS NOT NULL
+ ), e.server_updated_at)
+ ) AS changed_at
+ FROM entry_ledgers el
+ LEFT JOIN ledger_members change_member
+ ON change_member.ledger_id = el.ledger_id
+ AND change_member.user_id = $1
+ AND change_member.removed_at IS NULL
+ WHERE el.entry_id = e.id
+ ) entry_change
+ WHERE (e.owner_id = $1 OR EXISTS (
+ SELECT 1 FROM entry_ledgers accessible_link
+ JOIN ledger_members accessible_member ON accessible_member.ledger_id = accessible_link.ledger_id
+ WHERE accessible_link.entry_id = e.id
+ AND accessible_member.user_id = $1
+ AND accessible_member.removed_at IS NULL
+ ))
AND ($2::timestamptz IS NULL
- OR e.server_updated_at > $2::timestamptz
- OR (e.server_updated_at = $2::timestamptz AND e.id > $3))
- ORDER BY e.server_updated_at, e.id
+ OR entry_change.changed_at > $2::timestamptz
+ OR (entry_change.changed_at = $2::timestamptz AND e.id > $3))
+ ORDER BY entry_change.changed_at, e.id
LIMIT 501`,
[user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""],
);