feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+121 -72
View File
@@ -1,18 +1,18 @@
"use server";
"use server"
import { z } from "zod";
import { and, desc, eq, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { accounts, channels, type Account } from "@/lib/db/schema";
import { z } from "zod"
import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { accounts, channels, type Account } from "@/lib/db/schema"
async function requireUser() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录");
throw new Error("请先登录")
}
return session.user.id;
return session.user.id
}
const accountSchema = z.object({
@@ -34,47 +34,50 @@ const accountSchema = z.object({
// 现金账户字段
location: z.string().max(150).optional().nullable(),
});
})
export type AccountInput = z.infer<typeof accountSchema>;
export type AccountInput = z.infer<typeof accountSchema>
export type AccountWithChannels = Account & {
channelCount: number;
};
channelCount: number
}
export async function getAccountsAction(): Promise<{
success: boolean;
data?: AccountWithChannels[];
error?: string;
success: boolean
data?: AccountWithChannels[]
error?: string
}> {
try {
const userId = await requireUser();
const userId = await requireUser()
const userAccounts = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.orderBy(desc(accounts.createdAt));
.orderBy(desc(accounts.createdAt))
const userChannels = await db
.select({ id: channels.id, refAccounts: channels.refAccounts })
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)));
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const result: AccountWithChannels[] = userAccounts.map((acc) => {
const count = userChannels.filter((ch) =>
Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length;
const count = userChannels.filter(
(ch) => Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length
return {
...acc,
channelCount: count,
};
});
}
})
return { success: true, data: result };
return { success: true, data: result }
} catch (err) {
console.error("getAccountsAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "获取账户失败" };
console.error("getAccountsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取账户失败",
}
}
}
@@ -82,13 +85,16 @@ export async function createAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser();
const parsed = accountSchema.safeParse(data);
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const [newAccount] = await db
.insert(accounts)
.values({
@@ -96,8 +102,13 @@ export async function createAccountAction(
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -107,15 +118,18 @@ export async function createAccountAction(
location: val.location?.trim() || null,
isActive: true,
})
.returning();
.returning()
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true, data: newAccount };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: newAccount }
} catch (err) {
console.error("createAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "创建账户失败" };
console.error("createAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "创建账户失败",
}
}
}
@@ -124,21 +138,29 @@ export async function updateAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser();
const parsed = accountSchema.safeParse(data);
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const [updated] = await db
.update(accounts)
.set({
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -148,20 +170,29 @@ export async function updateAccountAction(
location: val.location?.trim() || null,
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.returning();
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!updated) {
return { success: false, error: "未找到该账户或无权修改" };
return { success: false, error: "未找到该账户或无权修改" }
}
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true, data: updated };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: updated }
} catch (err) {
console.error("updateAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "更新账户失败" };
console.error("updateAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新账户失败",
}
}
}
@@ -170,20 +201,29 @@ export async function toggleAccountActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
await db
.update(accounts)
.set({
isActive,
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)));
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
revalidatePath("/accounts");
return { success: true };
revalidatePath("/accounts")
return { success: true }
} catch (err) {
console.error("toggleAccountActiveAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
console.error("toggleAccountActiveAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
}
}
@@ -191,7 +231,7 @@ export async function deleteAccountAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
// 软删除
const [deleted] = await db
.update(accounts)
@@ -199,19 +239,28 @@ export async function deleteAccountAction(
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.returning();
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "账户不存在或已删除" };
return { success: false, error: "账户不存在或已删除" }
}
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "删除账户失败" };
console.error("deleteAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除账户失败",
}
}
}
+30 -26
View File
@@ -1,10 +1,10 @@
"use server";
"use server"
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { z } from "zod"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { hashPassword } from "@/lib/auth/password"
const registerSchema = z
.object({
@@ -13,16 +13,20 @@ const registerSchema = z
password: z.string().min(8, "密码长度至少需要 8 个字符"),
confirmPassword: z.string().min(1, "请确认密码"),
})
.refine((data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
});
.refine(
(data: { password: string; confirmPassword: string }) =>
data.password === data.confirmPassword,
{
message: "两次输入的密码不一致",
path: ["confirmPassword"],
}
)
export type RegisterState = {
success?: boolean;
error?: string;
fieldErrors?: Record<string, string[]>;
};
success?: boolean
error?: string
fieldErrors?: Record<string, string[]>
}
export async function registerAction(
prevState: RegisterState | null,
@@ -33,18 +37,18 @@ export async function registerAction(
email: formData.get("email"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
};
}
const parsed = registerSchema.safeParse(rawData);
const parsed = registerSchema.safeParse(rawData)
if (!parsed.success) {
return {
success: false,
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
}
const { name, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase().trim();
const { name, email, password } = parsed.data
const normalizedEmail = email.toLowerCase().trim()
try {
// 检查邮箱是否已被注册
@@ -52,17 +56,17 @@ export async function registerAction(
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1);
.limit(1)
if (existing) {
return {
success: false,
error: "该邮箱已被注册,请直接登录",
};
}
}
// 使用 Argon2 哈希密码
const passwordHash = await hashPassword(password);
const passwordHash = await hashPassword(password)
// 插入新用户
await db.insert(users).values({
@@ -70,16 +74,16 @@ export async function registerAction(
email: normalizedEmail,
passwordHash,
isActive: true,
});
})
return {
success: true,
};
}
} catch (err) {
console.error("Registration error:", err);
console.error("Registration error:", err)
return {
success: false,
error: "注册失败,请稍后重试",
};
}
}
}
+436
View File
@@ -0,0 +1,436 @@
"use server"
import { z } from "zod"
import { and, desc, eq, inArray, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import {
channels,
transactions,
transactionsDirty,
type TransactionDirty,
} from "@/lib/db/schema"
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
const dirtyTransactionSchema = z.object({
txnDate: z.string().min(1, "请选择交易日期"),
txnAmt: z.string().min(1, "请输入交易金额"),
txnCcy: z.string().min(1, "请输入交易币种").max(10),
postingAmt: z.string().optional().nullable(),
postingCcy: z.string().max(10).optional().nullable(),
commAmt: z.string().optional().nullable(),
commCcy: z.string().max(10).optional().nullable(),
surchargeAmt: z.string().optional().nullable(),
surchargeCcy: z.string().max(10).optional().nullable(),
discAmt: z.string().optional().nullable(),
discCcy: z.string().max(10).optional().nullable(),
dcFlag: z.enum(["DEBIT", "CREDIT"] as const).default("DEBIT"),
refChannels: z.array(z.string().uuid()).default([]),
txnScene: z.string().min(1).default("PAYMENT"),
merchantName: z.string().max(255).optional().nullable(),
description: z.string().max(500).optional().nullable(),
memo: z.string().max(500).optional().nullable(),
})
export type DirtyTransactionInput = z.infer<typeof dirtyTransactionSchema>
export async function getDirtyTransactionsAction(): Promise<{
success: boolean
data?: TransactionDirty[]
error?: string
}> {
try {
const userId = await requireUser()
const rows = await db
.select()
.from(transactionsDirty)
.where(
and(
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt)
)
)
.orderBy(
desc(transactionsDirty.txnDate),
desc(transactionsDirty.createdAt)
)
return { success: true, data: rows }
} catch (err) {
console.error("getDirtyTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取待清洗交易失败",
}
}
}
export async function createDirtyTransactionAction(
data: DirtyTransactionInput
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
try {
const userId = await requireUser()
const parsed = dirtyTransactionSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [row] = await db
.insert(transactionsDirty)
.values({
userId,
txnDate: new Date(val.txnDate),
txnAmt: val.txnAmt.trim(),
txnCcy: val.txnCcy.trim().toUpperCase(),
postingAmt: val.postingAmt?.trim() || null,
postingCcy: val.postingCcy?.trim()
? val.postingCcy.trim().toUpperCase()
: null,
commAmt: val.commAmt?.trim() || null,
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
surchargeAmt: val.surchargeAmt?.trim() || null,
surchargeCcy: val.surchargeCcy?.trim()
? val.surchargeCcy.trim().toUpperCase()
: null,
discAmt: val.discAmt?.trim() || null,
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
dcFlag: val.dcFlag,
refChannels: val.refChannels,
txnScene: val.txnScene,
merchantName: val.merchantName?.trim() || null,
description: val.description?.trim() || null,
memo: val.memo?.trim() || null,
})
.returning()
revalidatePath("/bookkeeping")
return { success: true, data: row }
} catch (err) {
console.error("createDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "暂存交易失败",
}
}
}
export async function updateDirtyTransactionAction(
id: string,
data: DirtyTransactionInput
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
try {
const userId = await requireUser()
const parsed = dirtyTransactionSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [row] = await db
.update(transactionsDirty)
.set({
txnDate: new Date(val.txnDate),
txnAmt: val.txnAmt.trim(),
txnCcy: val.txnCcy.trim().toUpperCase(),
postingAmt: val.postingAmt?.trim() || null,
postingCcy: val.postingCcy?.trim()
? val.postingCcy.trim().toUpperCase()
: null,
commAmt: val.commAmt?.trim() || null,
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
surchargeAmt: val.surchargeAmt?.trim() || null,
surchargeCcy: val.surchargeCcy?.trim()
? val.surchargeCcy.trim().toUpperCase()
: null,
discAmt: val.discAmt?.trim() || null,
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
dcFlag: val.dcFlag,
refChannels: val.refChannels,
txnScene: val.txnScene,
merchantName: val.merchantName?.trim() || null,
description: val.description?.trim() || null,
memo: val.memo?.trim() || null,
updatedAt: new Date(),
})
.where(
and(
eq(transactionsDirty.id, id),
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt)
)
)
.returning()
if (!row) {
return { success: false, error: "未找到该待清洗交易" }
}
revalidatePath("/bookkeeping")
return { success: true, data: row }
} catch (err) {
console.error("updateDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新交易失败",
}
}
}
export async function deleteDirtyTransactionAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
const [deleted] = await db
.delete(transactionsDirty)
.where(
and(eq(transactionsDirty.id, id), eq(transactionsDirty.userId, userId))
)
.returning()
if (!deleted) {
return { success: false, error: "交易不存在或已被删除" }
}
revalidatePath("/bookkeeping")
return { success: true }
} catch (err) {
console.error("deleteDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除失败",
}
}
}
/**
* 校验单笔待清洗交易的合规性与会计平衡
*/
function validateDirtyTransaction(
item: TransactionDirty,
userChannelIds: Set<string>
): string | null {
// 1. 金额必须为有效正数
const amt = parseFloat(item.txnAmt)
if (isNaN(amt) || amt <= 0) {
return `交易金额「${item.txnAmt}」无效,必须为大于0的数值`
}
// 2. 币种不能为空
if (!item.txnCcy || item.txnCcy.trim() === "") {
return "交易币种不能为空"
}
// 3. 必须绑定属于当前用户的支付渠道
if (!Array.isArray(item.refChannels) || item.refChannels.length === 0) {
return "未指定支付渠道,无法过账"
}
for (const chId of item.refChannels) {
if (!userChannelIds.has(chId)) {
return "绑定的支付渠道无效或已被删除"
}
}
// 4. 入账金额与币种必须同时存在或同时为空
const hasPostingAmt =
item.postingAmt !== null && item.postingAmt.trim() !== ""
const hasPostingCcy =
item.postingCcy !== null && item.postingCcy.trim() !== ""
if (hasPostingAmt !== hasPostingCcy) {
return "入账金额与入账币种必须同时提供"
}
// 5. 若为同币种入账,执行会计恒等式平衡检验
if (hasPostingAmt && item.postingCcy === item.txnCcy) {
const pAmt = parseFloat(item.postingAmt!)
const comm =
item.commAmt && item.commCcy === item.txnCcy
? parseFloat(item.commAmt)
: 0
const surcharge =
item.surchargeAmt && item.surchargeCcy === item.txnCcy
? parseFloat(item.surchargeAmt)
: 0
const disc =
item.discAmt && item.discCcy === item.txnCcy
? parseFloat(item.discAmt)
: 0
let expected = amt + surcharge - disc
if (item.dcFlag === "DEBIT") {
expected += comm
} else {
expected -= comm
}
// 允许 0.01 的浮点微差
if (Math.abs(pAmt - expected) > 0.015) {
return `会计恒等式不平衡:实际入账 ${pAmt} 与计算期望值 ${expected.toFixed(2)} 不符`
}
}
return null
}
/**
* 全量原子阻断清洗动作 (All-or-Nothing)
* 只要有哪怕一笔交易存在问题,就全量阻断合并,返回详尽错误清单,绝不污染正式账本。
*/
export async function cleanseTransactionsAction(
specificIds?: string[]
): Promise<{
success: boolean
cleansedCount?: number
errors?: { id: string; name: string; error: string }[]
error?: string
}> {
try {
const userId = await requireUser()
// 1. 获取目标待清洗交易
const queryConditions = [
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt),
]
if (specificIds && specificIds.length > 0) {
queryConditions.push(inArray(transactionsDirty.id, specificIds))
}
const dirtyList = await db
.select()
.from(transactionsDirty)
.where(and(...queryConditions))
if (dirtyList.length === 0) {
return { success: false, error: "当前暂无待清洗的交易记录" }
}
// 2. 获取用户的所有有效渠道 ID 用于归属校验
const userChannels = await db
.select({ id: channels.id })
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const userChannelIdSet = new Set(userChannels.map((c) => c.id))
// 3. 执行严格的前置全量校验网关 (Pre-flight Validation Gate)
const validationErrors: { id: string; name: string; error: string }[] = []
for (const item of dirtyList) {
const err = validateDirtyTransaction(item, userChannelIdSet)
if (err) {
const identifier =
item.merchantName ||
item.description ||
`交易(${item.txnAmt} ${item.txnCcy})`
validationErrors.push({
id: item.id,
name: identifier,
error: err,
})
}
}
// 4. 零容忍阻断:只要有一项不合格,立即全量中止,不发生任何写入与删除!
if (validationErrors.length > 0) {
return {
success: false,
error: `批次中存在 ${validationErrors.length} 笔未平账或要素不全的交易,全量阻断合并!请修正后再试。`,
errors: validationErrors,
}
}
// 5. 100% 校验通过,开启原子事务迁移至正式表
const idsToCleanse = dirtyList.map((d) => d.id)
await db.transaction(async (tx) => {
// 5.1 批量插入正式 transactions 表
await tx.insert(transactions).values(
dirtyList.map((item) => {
// 若未填入账信息且为单币种,自动补足
const pAmt = item.postingAmt || item.txnAmt
const pCcy = item.postingCcy || item.txnCcy
return {
id: item.id,
userId: item.userId,
version: item.version,
refTransactions: item.refTransactions,
txnDate: item.txnDate,
clearingDate: item.clearingDate,
postingDate: item.postingDate,
txnAmt: item.txnAmt,
txnCcy: item.txnCcy,
postingAmt: pAmt,
postingCcy: pCcy,
commAmt: item.commAmt,
commCcy: item.commCcy,
surchargeAmt: item.surchargeAmt,
surchargeCcy: item.surchargeCcy,
discAmt: item.discAmt,
discCcy: item.discCcy,
fxRates: item.fxRates,
dcFlag: item.dcFlag,
refChannels: item.refChannels,
cp: item.cp,
acqInst: item.acqInst,
clearingNetwork: item.clearingNetwork,
txnSts: item.txnSts,
description: item.description,
memo: item.memo,
ext: item.ext,
rawDescription: item.rawDescription,
rawData: item.rawData,
txnScene: item.txnScene,
merchantName: item.merchantName,
orderId: item.orderId,
geo: item.geo,
}
})
)
// 5.2 从 transactions_dirty 表彻底清除已清洗记录
await tx
.delete(transactionsDirty)
.where(
and(
eq(transactionsDirty.userId, userId),
inArray(transactionsDirty.id, idsToCleanse)
)
)
})
// 6. 成功,全量刷新路由缓存
revalidatePath("/bookkeeping")
revalidatePath("/transactions")
revalidatePath("/accounts")
revalidatePath("/")
return {
success: true,
cleansedCount: idsToCleanse.length,
}
} catch (err) {
console.error("cleanseTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "清洗合并事务失败",
}
}
}
+135 -87
View File
@@ -1,34 +1,40 @@
"use server";
"use server"
import { z } from "zod";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import {
accounts,
channels,
type Channel,
} from "@/lib/db/schema";
import { z } from "zod"
import { and, desc, eq, inArray, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { accounts, channels, type Channel } from "@/lib/db/schema"
async function requireUser() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录");
throw new Error("请先登录")
}
return session.user.id;
return session.user.id
}
const channelSchema = z.object({
channelType: z.enum(["PAYMENT_CARD", "E_WALLET", "CASH", "TRANSFER"] as const),
refAccounts: z.array(z.string().uuid("无效的账户标识")).min(1, "请至少关联一个资金账户"),
channelType: z.enum([
"PAYMENT_CARD",
"E_WALLET",
"CASH",
"TRANSFER",
] as const),
refAccounts: z
.array(z.string().uuid("无效的账户标识"))
.min(1, "请至少关联一个资金账户"),
desc: z.string().max(500).optional().nullable(),
ext: z.string().optional().nullable(),
// 支付卡特定字段
region: z.string().max(10).optional().nullable(),
issuerName: z.string().max(100).optional().nullable(),
cardType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(),
cardType: z
.enum(["CREDIT", "DEBIT"] as const)
.optional()
.nullable(),
cardNumberFull: z.string().max(100).optional().nullable(),
cardNumberSuffix: z.string().max(10).optional().nullable(),
cardBrand: z.string().max(32).optional().nullable(),
@@ -37,56 +43,62 @@ const channelSchema = z.object({
platform: z.string().max(64).optional().nullable(),
platformAccountId: z.string().max(100).optional().nullable(),
subChannel: z.string().max(64).optional().nullable(),
subChannelType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(),
});
subChannelType: z
.enum(["CREDIT", "DEBIT"] as const)
.optional()
.nullable(),
})
export type ChannelInput = z.infer<typeof channelSchema>;
export type ChannelInput = z.infer<typeof channelSchema>
export type ChannelWithAccountNames = Channel & {
linkedAccounts: { id: string; name: string }[];
};
linkedAccounts: { id: string; name: string }[]
}
export async function getChannelsAction(): Promise<{
success: boolean;
data?: ChannelWithAccountNames[];
error?: string;
success: boolean
data?: ChannelWithAccountNames[]
error?: string
}> {
try {
const userId = await requireUser();
const userId = await requireUser()
const userChannels = await db
.select()
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
.orderBy(desc(channels.createdAt));
.orderBy(desc(channels.createdAt))
const userAccounts = await db
.select({ id: accounts.id, name: accounts.name })
.from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)));
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]));
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]))
const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
const linked: { id: string; name: string }[] = [];
const linked: { id: string; name: string }[] = []
if (Array.isArray(ch.refAccounts)) {
for (const accId of ch.refAccounts) {
const name = accountMap.get(accId);
const name = accountMap.get(accId)
if (name) {
linked.push({ id: accId, name });
linked.push({ id: accId, name })
}
}
}
return {
...ch,
linkedAccounts: linked,
};
});
}
})
return { success: true, data: result };
return { success: true, data: result }
} catch (err) {
console.error("getChannelsAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" };
console.error("getChannelsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取渠道失败",
}
}
}
@@ -94,13 +106,16 @@ export async function createChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
const userId = await requireUser();
const parsed = channelSchema.safeParse(data);
const userId = await requireUser()
const parsed = channelSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
// 校验关联的所有账户必须属于当前登录用户
const userOwnedAccounts = await db
@@ -112,16 +127,16 @@ export async function createChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
);
)
if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" };
return { success: false, error: "关联的部分账户不存在或已被删除" }
}
// 自动提取或补全卡号后4位
let suffix = val.cardNumberSuffix?.trim() || null;
let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4);
suffix = val.cardNumberFull.slice(-4)
}
const [newChannel] = await db
@@ -144,15 +159,18 @@ export async function createChannelAction(
subChannel: val.subChannel?.trim() || null,
subChannelType: val.subChannelType || null,
})
.returning();
.returning()
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true, data: newChannel };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true, data: newChannel }
} catch (err) {
console.error("createChannelAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "创建渠道失败" };
console.error("createChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "创建渠道失败",
}
}
}
@@ -161,13 +179,16 @@ export async function updateChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
const userId = await requireUser();
const parsed = channelSchema.safeParse(data);
const userId = await requireUser()
const parsed = channelSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const userOwnedAccounts = await db
.select({ id: accounts.id })
@@ -178,15 +199,15 @@ export async function updateChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
);
)
if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" };
return { success: false, error: "关联的部分账户不存在或已被删除" }
}
let suffix = val.cardNumberSuffix?.trim() || null;
let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4);
suffix = val.cardNumberFull.slice(-4)
}
const [updated] = await db
@@ -208,20 +229,29 @@ export async function updateChannelAction(
subChannelType: val.subChannelType || null,
updatedAt: new Date(),
})
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
.returning();
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!updated) {
return { success: false, error: "渠道不存在或无权修改" };
return { success: false, error: "渠道不存在或无权修改" }
}
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true, data: updated };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true, data: updated }
} catch (err) {
console.error("updateChannelAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "更新渠道失败" };
console.error("updateChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新渠道失败",
}
}
}
@@ -230,22 +260,31 @@ export async function toggleChannelActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
await db
.update(channels)
.set({
isActive,
updatedAt: new Date(),
})
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)));
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("toggleChannelActiveAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
console.error("toggleChannelActiveAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
}
}
@@ -253,26 +292,35 @@ export async function deleteChannelAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
const [deleted] = await db
.update(channels)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
.returning();
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "渠道不存在或已删除" };
return { success: false, error: "渠道不存在或已删除" }
}
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteChannelAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" };
console.error("deleteChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除渠道失败",
}
}
}
+119
View File
@@ -0,0 +1,119 @@
"use server"
import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { channels, transactions, type Transaction } from "@/lib/db/schema"
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
export type TransactionWithChannelDetails = Transaction & {
channelDetails: {
id: string
cardBrand: string | null
channelType: string
displayName: string
region: string | null
cardNumberSuffix: string | null
}[]
}
export async function getOfficialTransactionsAction(): Promise<{
success: boolean
data?: TransactionWithChannelDetails[]
error?: string
}> {
try {
const userId = await requireUser()
const officialTxns = await db
.select()
.from(transactions)
.where(
and(eq(transactions.userId, userId), isNull(transactions.deletedAt))
)
.orderBy(desc(transactions.txnDate), desc(transactions.createdAt))
const userChannels = await db
.select()
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const channelMap = new Map(userChannels.map((c) => [c.id, c]))
const result: TransactionWithChannelDetails[] = officialTxns.map((t) => {
const chDetails: TransactionWithChannelDetails["channelDetails"] = []
if (Array.isArray(t.refChannels)) {
for (const chId of t.refChannels) {
const ch = channelMap.get(chId)
if (ch) {
const name = ch.desc || ch.issuerName || ch.platform || "渠道"
chDetails.push({
id: ch.id,
cardBrand: ch.cardBrand,
channelType: ch.channelType,
displayName: name,
region: ch.region,
cardNumberSuffix: ch.cardNumberSuffix,
})
}
}
}
return {
...t,
channelDetails: chDetails,
}
})
return { success: true, data: result }
} catch (err) {
console.error("getOfficialTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取交易记录失败",
}
}
}
export async function deleteOfficialTransactionAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
const [deleted] = await db
.update(transactions)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(transactions.id, id),
eq(transactions.userId, userId),
isNull(transactions.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "交易不存在或已删除" }
}
revalidatePath("/transactions")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteOfficialTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除交易失败",
}
}
}