You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

354 lines
8.8 KiB

import { randomUUID } from "crypto";
import type { Permission } from "@/lib/constants/auth";
import { prisma } from "@/lib/prisma";
import {
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;
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),
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
api_keys: row.apiKeys.map(serializeApiKeySummary),
};
}
async function resolveEmbedPasswordHash(
password?: string,
): Promise<string> {
const raw = password?.trim() || DEFAULT_EMBED_PASSWORD;
const error = validateEmbedPassword(raw);
if (error) {
throw new Error(error);
}
return hashEmbedPassword(raw);
}
async function nextCustomerId(): Promise<string> {
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),
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(serializeCustomer),
total,
page: options.page,
size: options.size,
};
}
export async function createHostCustomer(input: {
name: string;
remark?: string | null;
embed_password?: string;
}): Promise<CreateHostCustomerResult> {
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),
api_key: rawToken,
};
}
export async function updateHostCustomer(
customerId: string,
input: {
name?: string;
status?: HostCustomerStatus;
remark?: string | null;
embed_password?: string;
},
): Promise<HostCustomerDto> {
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" },
},
},
});
return serializeCustomer(updated);
}
export async function rotateHostCustomerApiKey(
customerId: string,
): Promise<CreateHostCustomerResult> {
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),
api_key: rawToken,
};
}
export async function listActiveDbCustomerIds(): Promise<string[]> {
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<boolean> {
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<boolean> {
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),
};
}