✨ feat: impl accounts and channels
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"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";
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("请先登录");
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
const accountSchema = z.object({
|
||||
name: z.string().min(1, "请输入账户名称").max(100),
|
||||
accountType: z.enum(["BANK", "E_WALLET", "CASH"] as const),
|
||||
balanceType: z.enum(["ASSET", "LIABILITY", "EQUITY"] as const),
|
||||
primaryCurrency: z.string().trim().max(10).optional().nullable(),
|
||||
supportedCurrencies: z.array(z.string().trim()).optional().nullable(),
|
||||
remark: z.string().max(500).optional().nullable(),
|
||||
ext: z.string().optional().nullable(),
|
||||
|
||||
// 银行账户字段
|
||||
issuerName: z.string().max(100).optional().nullable(),
|
||||
accountNumber: z.string().max(100).optional().nullable(),
|
||||
|
||||
// 电子钱包字段
|
||||
platform: z.string().max(64).optional().nullable(),
|
||||
accountId: z.string().max(100).optional().nullable(),
|
||||
|
||||
// 现金账户字段
|
||||
location: z.string().max(150).optional().nullable(),
|
||||
});
|
||||
|
||||
export type AccountInput = z.infer<typeof accountSchema>;
|
||||
|
||||
export type AccountWithChannels = Account & {
|
||||
channelCount: number;
|
||||
};
|
||||
|
||||
export async function getAccountsAction(): Promise<{
|
||||
success: boolean;
|
||||
data?: AccountWithChannels[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
|
||||
const userAccounts = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.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)));
|
||||
|
||||
const result: AccountWithChannels[] = userAccounts.map((acc) => {
|
||||
const count = userChannels.filter((ch) =>
|
||||
Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
|
||||
).length;
|
||||
return {
|
||||
...acc,
|
||||
channelCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
console.error("getAccountsAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "获取账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAccountAction(
|
||||
data: AccountInput
|
||||
): Promise<{ success: boolean; data?: Account; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = accountSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
const [newAccount] = await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId,
|
||||
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,
|
||||
remark: val.remark?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
accountNumber: val.accountNumber?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
accountId: val.accountId?.trim() || null,
|
||||
location: val.location?.trim() || null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
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 : "创建账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
id: string,
|
||||
data: AccountInput
|
||||
): Promise<{ success: boolean; data?: Account; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = accountSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
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,
|
||||
remark: val.remark?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
accountNumber: val.accountNumber?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
accountId: val.accountId?.trim() || null,
|
||||
location: val.location?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return { success: false, error: "未找到该账户或无权修改" };
|
||||
}
|
||||
|
||||
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 : "更新账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAccountActiveAction(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
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)));
|
||||
|
||||
revalidatePath("/accounts");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("toggleAccountActiveAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
// 软删除
|
||||
const [deleted] = await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return { success: false, error: "账户不存在或已删除" };
|
||||
}
|
||||
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("deleteAccountAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "删除账户失败" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"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";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "请输入姓名/昵称").max(100),
|
||||
email: z.string().email("请输入有效的邮箱地址").max(255),
|
||||
password: z.string().min(8, "密码长度至少需要 8 个字符"),
|
||||
confirmPassword: z.string().min(1, "请确认密码"),
|
||||
})
|
||||
.refine((data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword, {
|
||||
message: "两次输入的密码不一致",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export type RegisterState = {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export async function registerAction(
|
||||
prevState: RegisterState | null,
|
||||
formData: FormData
|
||||
): Promise<RegisterState> {
|
||||
const rawData = {
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
// 检查邮箱是否已被注册
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalizedEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: "该邮箱已被注册,请直接登录",
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 Argon2 哈希密码
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// 插入新用户
|
||||
await db.insert(users).values({
|
||||
name: name.trim(),
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Registration error:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: "注册失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"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";
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("请先登录");
|
||||
}
|
||||
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, "请至少关联一个资金账户"),
|
||||
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(),
|
||||
cardNumberFull: z.string().max(100).optional().nullable(),
|
||||
cardNumberSuffix: z.string().max(10).optional().nullable(),
|
||||
cardBrand: z.string().max(32).optional().nullable(),
|
||||
|
||||
// 电子钱包特定字段
|
||||
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(),
|
||||
});
|
||||
|
||||
export type ChannelInput = z.infer<typeof channelSchema>;
|
||||
|
||||
export type ChannelWithAccountNames = Channel & {
|
||||
linkedAccounts: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export async function getChannelsAction(): Promise<{
|
||||
success: boolean;
|
||||
data?: ChannelWithAccountNames[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
|
||||
const userChannels = await db
|
||||
.select()
|
||||
.from(channels)
|
||||
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||
.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)));
|
||||
|
||||
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]));
|
||||
|
||||
const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
|
||||
const linked: { id: string; name: string }[] = [];
|
||||
if (Array.isArray(ch.refAccounts)) {
|
||||
for (const accId of ch.refAccounts) {
|
||||
const name = accountMap.get(accId);
|
||||
if (name) {
|
||||
linked.push({ id: accId, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...ch,
|
||||
linkedAccounts: linked,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
console.error("getChannelsAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createChannelAction(
|
||||
data: ChannelInput
|
||||
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = channelSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
|
||||
// 校验关联的所有账户必须属于当前登录用户
|
||||
const userOwnedAccounts = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
inArray(accounts.id, val.refAccounts),
|
||||
isNull(accounts.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
||||
return { success: false, error: "关联的部分账户不存在或已被删除" };
|
||||
}
|
||||
|
||||
// 自动提取或补全卡号后4位
|
||||
let suffix = val.cardNumberSuffix?.trim() || null;
|
||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
||||
suffix = val.cardNumberFull.slice(-4);
|
||||
}
|
||||
|
||||
const [newChannel] = await db
|
||||
.insert(channels)
|
||||
.values({
|
||||
userId,
|
||||
channelType: val.channelType,
|
||||
refAccounts: val.refAccounts,
|
||||
isActive: true,
|
||||
desc: val.desc?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
cardType: val.cardType || null,
|
||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
||||
cardNumberSuffix: suffix,
|
||||
cardBrand: val.cardBrand?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
platformAccountId: val.platformAccountId?.trim() || null,
|
||||
subChannel: val.subChannel?.trim() || null,
|
||||
subChannelType: val.subChannelType || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
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 : "创建渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChannelAction(
|
||||
id: string,
|
||||
data: ChannelInput
|
||||
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = channelSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
|
||||
const userOwnedAccounts = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
inArray(accounts.id, val.refAccounts),
|
||||
isNull(accounts.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
||||
return { success: false, error: "关联的部分账户不存在或已被删除" };
|
||||
}
|
||||
|
||||
let suffix = val.cardNumberSuffix?.trim() || null;
|
||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
||||
suffix = val.cardNumberFull.slice(-4);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(channels)
|
||||
.set({
|
||||
channelType: val.channelType,
|
||||
refAccounts: val.refAccounts,
|
||||
desc: val.desc?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
cardType: val.cardType || null,
|
||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
||||
cardNumberSuffix: suffix,
|
||||
cardBrand: val.cardBrand?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
platformAccountId: val.platformAccountId?.trim() || null,
|
||||
subChannel: val.subChannel?.trim() || null,
|
||||
subChannelType: val.subChannelType || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return { success: false, error: "渠道不存在或无权修改" };
|
||||
}
|
||||
|
||||
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 : "更新渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleChannelActiveAction(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
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)));
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("toggleChannelActiveAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChannelAction(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
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();
|
||||
|
||||
if (!deleted) {
|
||||
return { success: false, error: "渠道不存在或已删除" };
|
||||
}
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("deleteChannelAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user