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:
+121
-72
@@ -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
@@ -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: "注册失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 : "删除渠道失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : "删除交易失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import type { NextAuthConfig } from "next-auth"
|
||||
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
@@ -7,37 +7,37 @@ export const authConfig = {
|
||||
},
|
||||
callbacks: {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const pathname = nextUrl.pathname;
|
||||
const publicPaths = ["/login", "/register", "/api/auth"];
|
||||
const isLoggedIn = !!auth?.user
|
||||
const pathname = nextUrl.pathname
|
||||
const publicPaths = ["/login", "/register", "/api/auth"]
|
||||
const isPublic = publicPaths.some(
|
||||
(path) => pathname === path || pathname.startsWith("/api/auth/")
|
||||
);
|
||||
)
|
||||
|
||||
// 已登录用户在登录/注册页时重定向到首页
|
||||
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||
return Response.redirect(new URL("/", nextUrl));
|
||||
return Response.redirect(new URL("/", nextUrl))
|
||||
}
|
||||
|
||||
// 访问受保护页面必须已登录
|
||||
if (!isLoggedIn && !isPublic) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
jwt({ token, user }) {
|
||||
if (user?.id) {
|
||||
token.id = user.id;
|
||||
token.id = user.id
|
||||
}
|
||||
return token;
|
||||
return token
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.id = token.id as string
|
||||
}
|
||||
return session;
|
||||
return session
|
||||
},
|
||||
},
|
||||
providers: [],
|
||||
} satisfies NextAuthConfig;
|
||||
} satisfies NextAuthConfig
|
||||
|
||||
+34
-34
@@ -1,11 +1,11 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, userAccounts } from "@/lib/db/schema";
|
||||
import { verifyPassword } from "./password";
|
||||
import { authConfig } from "./config";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { users, userAccounts } from "@/lib/db/schema"
|
||||
import { verifyPassword } from "./password"
|
||||
import { authConfig } from "./config"
|
||||
import type { Provider } from "next-auth/providers"
|
||||
|
||||
// 动态构建 Providers 列表
|
||||
const providers: Provider[] = [
|
||||
@@ -17,25 +17,25 @@ const providers: Provider[] = [
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const email = String(credentials.email).toLowerCase().trim();
|
||||
const password = String(credentials.password);
|
||||
const email = String(credentials.email).toLowerCase().trim()
|
||||
const password = String(credentials.password)
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
.limit(1)
|
||||
|
||||
if (!user || !user.passwordHash || !user.isActive) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
const isValid = await verifyPassword(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -43,16 +43,16 @@ const providers: Provider[] = [
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatar,
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
]
|
||||
|
||||
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
|
||||
const isOidcEnabled =
|
||||
process.env.AUTH_OIDC_ENABLED === "true" &&
|
||||
Boolean(process.env.AUTH_OIDC_ISSUER) &&
|
||||
Boolean(process.env.AUTH_OIDC_CLIENT_ID);
|
||||
Boolean(process.env.AUTH_OIDC_CLIENT_ID)
|
||||
|
||||
if (isOidcEnabled) {
|
||||
providers.push({
|
||||
@@ -72,7 +72,7 @@ if (isOidcEnabled) {
|
||||
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
@@ -86,13 +86,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
if (!account || account.type === "credentials") {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
|
||||
const email = user.email?.toLowerCase().trim();
|
||||
const email = user.email?.toLowerCase().trim()
|
||||
if (!email) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 查询用户是否已存在
|
||||
@@ -100,7 +100,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
.limit(1)
|
||||
|
||||
if (!existingUser) {
|
||||
// 创建新用户
|
||||
@@ -112,20 +112,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
avatar: user.image || null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
existingUser = newUser;
|
||||
.returning()
|
||||
existingUser = newUser
|
||||
}
|
||||
|
||||
user.id = existingUser.id;
|
||||
user.id = existingUser.id
|
||||
|
||||
// 2. 查询是否已记录该 provider 的账户绑定
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(userAccounts)
|
||||
.where(
|
||||
eq(userAccounts.providerAccountId, account.providerAccountId)
|
||||
)
|
||||
.limit(1);
|
||||
.where(eq(userAccounts.providerAccountId, account.providerAccountId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAccount) {
|
||||
await db.insert(userAccounts).values({
|
||||
@@ -134,14 +132,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providerAccountId: account.providerAccountId,
|
||||
refreshToken: account.refresh_token,
|
||||
accessToken: account.access_token,
|
||||
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null,
|
||||
expiresAt: account.expires_at
|
||||
? new Date(account.expires_at * 1000)
|
||||
: null,
|
||||
tokenType: account.token_type,
|
||||
scope: account.scope,
|
||||
idToken: account.id_token,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import { hash, verify } from "@node-rs/argon2"
|
||||
|
||||
// 遵循 OWASP 密码哈希安全推荐配置
|
||||
const ARGON2_OPTIONS = {
|
||||
@@ -6,16 +6,19 @@ const ARGON2_OPTIONS = {
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await hash(password, ARGON2_OPTIONS);
|
||||
return await hash(password, ARGON2_OPTIONS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
passwordHash: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwordHash, password);
|
||||
return await verify(passwordHash, password)
|
||||
} catch {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -1,15 +1,17 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
import * as schema from "./schema"
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || "postgresql://postgres:Aa110011@localhost:5432/fluxent";
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgresql://postgres:Aa110011@localhost:5432/fluxent"
|
||||
|
||||
// 在 Next.js 开发环境下避免热重载创建重复连接池
|
||||
const globalForDb = globalThis as unknown as {
|
||||
conn: postgres.Sql | undefined;
|
||||
};
|
||||
conn: postgres.Sql | undefined
|
||||
}
|
||||
|
||||
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 });
|
||||
if (process.env.NODE_ENV !== "production") globalForDb.conn = client;
|
||||
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 })
|
||||
if (process.env.NODE_ENV !== "production") globalForDb.conn = client
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
export const db = drizzle(client, { schema })
|
||||
|
||||
@@ -41,7 +41,10 @@ async function main() {
|
||||
`
|
||||
|
||||
console.log(`Normalized ${updated} channel card brands.`)
|
||||
console.log("Stored card brands:", brands.map((row) => row.card_brand))
|
||||
console.log(
|
||||
"Stored card brands:",
|
||||
brands.map((row) => row.card_brand)
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
+15
-15
@@ -1,22 +1,22 @@
|
||||
import postgres from "postgres";
|
||||
import * as dotenv from "dotenv";
|
||||
import postgres from "postgres"
|
||||
import * as dotenv from "dotenv"
|
||||
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env.local" })
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL!);
|
||||
const sql = postgres(process.env.DATABASE_URL!)
|
||||
|
||||
async function resetAndInit() {
|
||||
console.log("Dropping old tables...");
|
||||
await sql`DROP TABLE IF EXISTS transactions CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS channels CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS accounts CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS users CASCADE`;
|
||||
console.log("Old tables dropped.");
|
||||
await sql.end();
|
||||
console.log("Dropping old tables...")
|
||||
await sql`DROP TABLE IF EXISTS transactions CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS channels CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS accounts CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS users CASCADE`
|
||||
console.log("Old tables dropped.")
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
resetAndInit().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
+111
-39
@@ -8,8 +8,8 @@ import {
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础时间戳辅助
|
||||
@@ -22,7 +22,7 @@ export const timestamps = {
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. 用户与认证表
|
||||
@@ -42,13 +42,11 @@ export const users = pgTable(
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("users_email_unique").on(t.email),
|
||||
]
|
||||
);
|
||||
(t) => [uniqueIndex("users_email_unique").on(t.email)]
|
||||
)
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type User = typeof users.$inferSelect
|
||||
export type NewUser = typeof users.$inferInsert
|
||||
|
||||
/**
|
||||
* 用户认证授权表 (支持 OIDC / OAuth 账号关联)
|
||||
@@ -61,7 +59,9 @@ export const userAccounts = pgTable(
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
provider: varchar("provider", { length: 64 }).notNull(), // 如 'oidc'
|
||||
providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(),
|
||||
providerAccountId: varchar("provider_account_id", {
|
||||
length: 255,
|
||||
}).notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
@@ -77,14 +77,14 @@ export const userAccounts = pgTable(
|
||||
),
|
||||
index("user_accounts_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. 资金账户表 (Accounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AccountType = "BANK" | "E_WALLET" | "CASH";
|
||||
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY";
|
||||
export type AccountType = "BANK" | "E_WALLET" | "CASH"
|
||||
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY"
|
||||
|
||||
export const accounts = pgTable(
|
||||
"accounts",
|
||||
@@ -94,7 +94,9 @@ export const accounts = pgTable(
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
name: varchar("name", { length: 150 }).notNull(),
|
||||
accountType: varchar("account_type", { length: 32 }).$type<AccountType>().notNull(),
|
||||
accountType: varchar("account_type", { length: 32 })
|
||||
.$type<AccountType>()
|
||||
.notNull(),
|
||||
balanceType: varchar("balance_type", { length: 32 })
|
||||
.$type<BalanceType>()
|
||||
.default("ASSET")
|
||||
@@ -119,20 +121,18 @@ export const accounts = pgTable(
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("accounts_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
(t) => [index("accounts_user_id_idx").on(t.userId)]
|
||||
)
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type NewAccount = typeof accounts.$inferInsert;
|
||||
export type Account = typeof accounts.$inferSelect
|
||||
export type NewAccount = typeof accounts.$inferInsert
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. 渠道表 (Channels)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER";
|
||||
export type PaymentInstrumentType = "CREDIT" | "DEBIT";
|
||||
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
|
||||
export type PaymentInstrumentType = "CREDIT" | "DEBIT"
|
||||
|
||||
export const channels = pgTable(
|
||||
"channels",
|
||||
@@ -141,7 +141,9 @@ export const channels = pgTable(
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
channelType: varchar("channel_type", { length: 32 }).$type<ChannelType>().notNull(),
|
||||
channelType: varchar("channel_type", { length: 32 })
|
||||
.$type<ChannelType>()
|
||||
.notNull(),
|
||||
refAccounts: jsonb("ref_accounts").$type<string[]>().notNull(), // 关联的账户 UUID 列表
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
desc: text("desc"),
|
||||
@@ -150,7 +152,9 @@ export const channels = pgTable(
|
||||
// 支付卡渠道字段
|
||||
region: varchar("region", { length: 10 }), // 发卡地如 HK, CN
|
||||
issuerName: varchar("issuer_name", { length: 100 }),
|
||||
cardType: varchar("card_type", { length: 32 }).$type<PaymentInstrumentType>(),
|
||||
cardType: varchar("card_type", {
|
||||
length: 32,
|
||||
}).$type<PaymentInstrumentType>(),
|
||||
cardNumberFull: varchar("card_number_full", { length: 100 }),
|
||||
cardNumberSuffix: varchar("card_number_suffix", { length: 10 }),
|
||||
cardBrand: varchar("card_brand", { length: 32 }),
|
||||
@@ -159,29 +163,29 @@ export const channels = pgTable(
|
||||
platform: varchar("platform", { length: 64 }),
|
||||
platformAccountId: varchar("platform_account_id", { length: 100 }),
|
||||
subChannel: varchar("sub_channel", { length: 64 }),
|
||||
subChannelType: varchar("sub_channel_type", { length: 32 }).$type<PaymentInstrumentType>(),
|
||||
subChannelType: varchar("sub_channel_type", {
|
||||
length: 32,
|
||||
}).$type<PaymentInstrumentType>(),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("channels_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
(t) => [index("channels_user_id_idx").on(t.userId)]
|
||||
)
|
||||
|
||||
export type Channel = typeof channels.$inferSelect;
|
||||
export type NewChannel = typeof channels.$inferInsert;
|
||||
export type Channel = typeof channels.$inferSelect
|
||||
export type NewChannel = typeof channels.$inferInsert
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. 交易记账表 (Transactions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED";
|
||||
export type DcFlag = "DEBIT" | "CREDIT";
|
||||
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED"
|
||||
export type DcFlag = "DEBIT" | "CREDIT"
|
||||
|
||||
export interface FxRateItem {
|
||||
fromCcy: string;
|
||||
toCcy: string;
|
||||
rate: string;
|
||||
fromCcy: string
|
||||
toCcy: string
|
||||
rate: string
|
||||
}
|
||||
|
||||
export const transactions = pgTable(
|
||||
@@ -238,7 +242,75 @@ export const transactions = pgTable(
|
||||
index("transactions_user_id_idx").on(t.userId),
|
||||
index("transactions_txn_date_idx").on(t.txnDate),
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
export type Transaction = typeof transactions.$inferSelect;
|
||||
export type NewTransaction = typeof transactions.$inferInsert;
|
||||
export type Transaction = typeof transactions.$inferSelect
|
||||
export type NewTransaction = typeof transactions.$inferInsert
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. 待清洗暂存交易表 (Transactions Dirty)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const transactionsDirty = pgTable(
|
||||
"transactions_dirty",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
version: varchar("version", { length: 16 }).default("0").notNull(),
|
||||
refTransactions: jsonb("ref_transactions").$type<string[]>(),
|
||||
txnDate: timestamp("txn_date", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
clearingDate: timestamp("clearing_date", { withTimezone: true }),
|
||||
postingDate: timestamp("posting_date", { withTimezone: true }),
|
||||
|
||||
// 金额与币种(草稿阶段)
|
||||
txnAmt: varchar("txn_amt", { length: 32 }).notNull(),
|
||||
txnCcy: varchar("txn_ccy", { length: 10 }).notNull(),
|
||||
postingAmt: varchar("posting_amt", { length: 32 }),
|
||||
postingCcy: varchar("posting_ccy", { length: 10 }),
|
||||
commAmt: varchar("comm_amt", { length: 32 }),
|
||||
commCcy: varchar("comm_ccy", { length: 10 }),
|
||||
surchargeAmt: varchar("surcharge_amt", { length: 32 }),
|
||||
surchargeCcy: varchar("surcharge_ccy", { length: 10 }),
|
||||
discAmt: varchar("disc_amt", { length: 32 }),
|
||||
discCcy: varchar("disc_ccy", { length: 10 }),
|
||||
fxRates: jsonb("fx_rates").$type<FxRateItem[]>(),
|
||||
|
||||
dcFlag: varchar("dc_flag", { length: 16 })
|
||||
.$type<DcFlag>()
|
||||
.default("DEBIT")
|
||||
.notNull(),
|
||||
refChannels: jsonb("ref_channels").$type<string[]>().default([]).notNull(),
|
||||
cp: varchar("cp", { length: 255 }),
|
||||
acqInst: varchar("acq_inst", { length: 150 }),
|
||||
clearingNetwork: varchar("clearing_network", { length: 100 }),
|
||||
txnSts: varchar("txn_sts", { length: 32 })
|
||||
.$type<TransactionStatus>()
|
||||
.default("COMPLETED")
|
||||
.notNull(),
|
||||
|
||||
description: text("description"),
|
||||
memo: text("memo"),
|
||||
ext: text("ext"),
|
||||
rawDescription: text("raw_description"),
|
||||
rawData: jsonb("raw_data"),
|
||||
|
||||
// 场景类型
|
||||
txnScene: varchar("txn_scene", { length: 64 }).default("PAYMENT").notNull(),
|
||||
merchantName: varchar("merchant_name", { length: 255 }),
|
||||
orderId: varchar("order_id", { length: 150 }),
|
||||
geo: varchar("geo", { length: 64 }),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("transactions_dirty_user_id_idx").on(t.userId),
|
||||
index("transactions_dirty_txn_date_idx").on(t.txnDate),
|
||||
]
|
||||
)
|
||||
|
||||
export type TransactionDirty = typeof transactionsDirty.$inferSelect
|
||||
export type NewTransactionDirty = typeof transactionsDirty.$inferInsert
|
||||
|
||||
Reference in New Issue
Block a user