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.
375 lines
9.4 KiB
375 lines
9.4 KiB
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<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),
|
|
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<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,
|
|
activeApiKey: rawToken,
|
|
}),
|
|
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" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const activeKey = updated.apiKeys.find((item) => item.isActive);
|
|
return serializeCustomer({
|
|
...updated,
|
|
activeApiKey: decryptServiceToken(activeKey?.tokenCiphertext),
|
|
});
|
|
}
|
|
|
|
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,
|
|
activeApiKey: rawToken,
|
|
}),
|
|
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),
|
|
};
|
|
}
|