import { randomUUID } from "crypto"; import type { Permission } from "@/lib/constants/auth"; import { prisma } from "@/lib/prisma"; import { decryptServiceToken, encryptServiceToken, generateServiceToken, hashServiceToken, tokenKeyPrefix, } from "@/modules/customer/token-crypto"; import { DEFAULT_EMBED_PASSWORD, hashEmbedPassword, validateEmbedPassword, compareEmbedPassword, } from "@/modules/customer/embed-password"; import { getEmbedDemoPassword } from "@/modules/embed-demo/auth"; import { isKnownCustomerAsync } from "@/modules/auth/service-token"; import type { CreateHostCustomerResult, HostCustomerDto, HostCustomerStatus, ServiceApiKeySummaryDto, } from "@/modules/customer/types"; const DEFAULT_PERMISSIONS: Permission[] = ["pricing:markup:write"]; function parsePermissions(value: unknown): Permission[] { if (!Array.isArray(value)) { return DEFAULT_PERMISSIONS; } return value.filter( (item): item is Permission => typeof item === "string", ); } function serializeApiKeySummary(row: { keyId: string; keyPrefix: string; isActive: boolean; permissions: unknown; createdAt: Date; }): ServiceApiKeySummaryDto { return { key_id: row.keyId, key_prefix: row.keyPrefix, is_active: row.isActive, permissions: parsePermissions(row.permissions), created_at: row.createdAt.toISOString(), }; } function serializeCustomer(row: { customerId: string; name: string; status: string; remark: string | null; embedPasswordHash?: string | null; activeApiKey?: string | null; createdAt: Date; updatedAt: Date; apiKeys: Array<{ keyId: string; keyPrefix: string; isActive: boolean; permissions: unknown; createdAt: Date; }>; }): HostCustomerDto { return { customer_id: row.customerId, name: row.name, status: row.status as HostCustomerStatus, remark: row.remark, embed_password_set: Boolean(row.embedPasswordHash), active_api_key: row.activeApiKey ?? null, created_at: row.createdAt.toISOString(), updated_at: row.updatedAt.toISOString(), api_keys: row.apiKeys.map(serializeApiKeySummary), }; } async function resolveEmbedPasswordHash( password?: string, ): Promise { const raw = password?.trim() || DEFAULT_EMBED_PASSWORD; const error = validateEmbedPassword(raw); if (error) { throw new Error(error); } return hashEmbedPassword(raw); } async function nextCustomerId(): Promise { const rows = await prisma.hostCustomer.findMany({ where: { customerId: { startsWith: "CUST_" } }, select: { customerId: true }, }); let max = 0; for (const row of rows) { const n = Number.parseInt(row.customerId.replace(/^CUST_/, ""), 10); if (Number.isFinite(n) && n > max) { max = n; } } return `CUST_${String(max + 1).padStart(3, "0")}`; } async function createApiKeyRecord( customerId: string, ): Promise<{ rawToken: string; summary: ServiceApiKeySummaryDto }> { const rawToken = generateServiceToken(); const created = await prisma.serviceApiKey.create({ data: { keyId: `KEY_${randomUUID().replace(/-/g, "").slice(0, 12).toUpperCase()}`, customerId, tokenHash: hashServiceToken(rawToken), tokenCiphertext: encryptServiceToken(rawToken), keyPrefix: tokenKeyPrefix(rawToken), permissions: DEFAULT_PERMISSIONS, isActive: true, }, }); return { rawToken, summary: serializeApiKeySummary(created), }; } export async function listHostCustomers(options: { page: number; size: number; keyword?: string; }): Promise<{ list: HostCustomerDto[]; total: number; page: number; size: number; }> { const keyword = options.keyword?.trim(); const where = { isDeleted: false, ...(keyword ? { OR: [ { customerId: { contains: keyword } }, { name: { contains: keyword } }, ], } : {}), }; const [total, rows] = await Promise.all([ prisma.hostCustomer.count({ where }), prisma.hostCustomer.findMany({ where, include: { apiKeys: { where: { isActive: true }, orderBy: { createdAt: "desc" }, }, }, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.size, take: options.size, }), ]); return { list: rows.map((row) => { const activeKey = row.apiKeys.find((item) => item.isActive); return serializeCustomer({ ...row, activeApiKey: decryptServiceToken(activeKey?.tokenCiphertext), }); }), total, page: options.page, size: options.size, }; } export async function createHostCustomer(input: { name: string; remark?: string | null; embed_password?: string; }): Promise { const name = input.name.trim(); if (!name) { throw new Error("客户名称不能为空"); } const customerId = await nextCustomerId(); const embedPasswordHash = await resolveEmbedPasswordHash(input.embed_password); await prisma.hostCustomer.create({ data: { customerId, name, remark: input.remark?.trim() || null, status: "active", embedPasswordHash, }, }); const { rawToken } = await createApiKeyRecord(customerId); const refreshed = await prisma.hostCustomer.findFirstOrThrow({ where: { customerId }, include: { apiKeys: { orderBy: { createdAt: "desc" } } }, }); return { ...serializeCustomer({ ...refreshed, activeApiKey: rawToken, }), api_key: rawToken, }; } export async function updateHostCustomer( customerId: string, input: { name?: string; status?: HostCustomerStatus; remark?: string | null; embed_password?: string; }, ): Promise { const existing = await prisma.hostCustomer.findFirst({ where: { customerId, isDeleted: false }, }); if (!existing) { throw new Error("客户不存在"); } const data: { name?: string; status?: string; remark?: string | null; embedPasswordHash?: string; } = {}; if (input.name !== undefined) { const name = input.name.trim(); if (!name) { throw new Error("客户名称不能为空"); } data.name = name; } if (input.status !== undefined) { data.status = input.status; } if (input.remark !== undefined) { data.remark = input.remark?.trim() || null; } if (input.embed_password !== undefined) { const error = validateEmbedPassword(input.embed_password); if (error) { throw new Error(error); } data.embedPasswordHash = await hashEmbedPassword(input.embed_password); } const updated = await prisma.hostCustomer.update({ where: { customerId }, data, include: { apiKeys: { orderBy: { createdAt: "desc" }, }, }, }); const activeKey = updated.apiKeys.find((item) => item.isActive); return serializeCustomer({ ...updated, activeApiKey: decryptServiceToken(activeKey?.tokenCiphertext), }); } export async function rotateHostCustomerApiKey( customerId: string, ): Promise { const customer = await prisma.hostCustomer.findFirst({ where: { customerId, isDeleted: false }, include: { apiKeys: { orderBy: { createdAt: "desc" } } }, }); if (!customer) { throw new Error("客户不存在"); } if (customer.status !== "active") { throw new Error("客户已停用,无法轮换密钥"); } await prisma.serviceApiKey.updateMany({ where: { customerId, isActive: true }, data: { isActive: false }, }); const { rawToken } = await createApiKeyRecord(customerId); const refreshed = await prisma.hostCustomer.findFirstOrThrow({ where: { customerId }, include: { apiKeys: { orderBy: { createdAt: "desc" } } }, }); return { ...serializeCustomer({ ...refreshed, activeApiKey: rawToken, }), api_key: rawToken, }; } export async function listActiveDbCustomerIds(): Promise { const rows = await prisma.hostCustomer.findMany({ where: { isDeleted: false, status: "active" }, select: { customerId: true }, orderBy: { customerId: "asc" }, }); return rows.map((row) => row.customerId); } export async function isDbCustomerActive(customerId: string): Promise { const row = await prisma.hostCustomer.findFirst({ where: { customerId, isDeleted: false, status: "active" }, select: { customerId: true }, }); return Boolean(row); } export async function verifyCustomerEmbedPassword( customerId: string, password: string, ): Promise { const row = await prisma.hostCustomer.findFirst({ where: { customerId, isDeleted: false, status: "active" }, select: { embedPasswordHash: true }, }); if (row?.embedPasswordHash) { return compareEmbedPassword(password, row.embedPasswordHash); } if (row) { return password === getEmbedDemoPassword(); } if (await isKnownCustomerAsync(customerId)) { return password === getEmbedDemoPassword(); } return false; } export async function resolveDbServiceToken( rawToken: string, ): Promise<{ customerId: string; permissions: Permission[] } | null> { const tokenHash = hashServiceToken(rawToken); const row = await prisma.serviceApiKey.findFirst({ where: { tokenHash, isActive: true, customer: { isDeleted: false, status: "active" }, }, include: { customer: true }, }); if (!row) { return null; } return { customerId: row.customerId, permissions: parsePermissions(row.permissions), }; }