v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+719
View File
@@ -0,0 +1,719 @@
/**
* # 模块 后台管理接口
* 集中存放 /admin 后台使用的全部 server functions。
* 规范:每个函数第一步必须 await requireAdmin();写操作写入 auditLogs 留痕。
*/
import { db } from "~/db";
import {
contents,
products,
productAttributes,
productAttributeValues,
productAttributeMap,
productTerms,
categories,
orders,
orderItems,
users,
media,
redirects,
filters,
languages,
inquiries,
auditLogs,
} from "~/db/schema";
import { createServerFn } from "@tanstack/react-start";
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { Json } from "~/components/admin/common";
/** # 类型 后台通用分页入参 **/
type PageInput = { page?: number; pageSize?: number };
/** # 函数 内部工具:安全解析分页参数
* 参数 d:原始分页输入
* 返回值 { page, pageSize, offset }
*/
function parsePage(d: PageInput) {
const page = Math.max(1, Math.floor(d.page || 1));
const pageSize = Math.min(100, Math.floor(d.pageSize || 20));
return { page, pageSize, offset: (page - 1) * pageSize };
}
/** # 函数 内部工具:写入审计日志
* 参数 adminId:操作人 ID;action:动作名;entity:实体类型;entityId:实体 ID;meta:附加数据
*/
async function writeAudit(adminId: number, action: string, entity: string, entityId: string | number, meta?: unknown) {
await db.insert(auditLogs).values({
actorId: adminId,
action,
entity,
entityId: String(entityId),
meta: (meta ?? {}) as Record<string, unknown>,
});
}
/** # 函数 内部工具:把标题转成 URL 别名(slug)
* 参数 title:原始标题
* 返回值 小写短横线 slug
*/
function slugify(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 200) || `item-${Date.now()}`
);
}
/* ============================= 内容 Contents ============================= */
/** # 函数 Server Function:内容列表(分页 + 类型/状态/关键字过滤) */
export const adminListContents = createServerFn({ method: "GET" })
.validator((d: PageInput & { type?: string; status?: string; q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.type) conds.push(eq(contents.type, data.type));
if (data.status) conds.push(eq(contents.status, data.status));
if (data.q) conds.push(or(ilike(contents.title, `%${data.q}%`), ilike(contents.slug, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: contents.id,
type: contents.type,
slug: contents.slug,
title: contents.title,
status: contents.status,
template: contents.template,
views: contents.views,
publishedAt: contents.publishedAt,
updatedAt: contents.updatedAt,
})
.from(contents)
.where(where)
.orderBy(desc(contents.updatedAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(contents).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:读取单条内容(编辑页用) */
export const adminGetContent = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [item] = await db.select().from(contents).where(eq(contents.id, data.id));
// meta 为 jsonb(unknown),收窄为 Json 以满足 loader 可序列化校验
return item ? { ...item, meta: item.meta as Json } : null;
});
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成) */
export const adminSaveContent = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
type: string;
slug?: string;
title: string;
excerpt?: string;
html?: string;
status?: string;
template?: string;
seoTitle?: string | null;
seoDescription?: string | null;
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const slug = (data.slug || "").trim() || slugify(data.title);
const values = {
type: data.type,
slug,
title: data.title,
excerpt: data.excerpt ?? "",
html: data.html ?? "",
status: data.status || "draft",
template: data.template || "default",
seoTitle: data.seoTitle ?? null,
seoDescription: data.seoDescription ?? null,
updatedAt: new Date(),
};
let id = data.id;
if (id) {
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status }).from(contents).where(eq(contents.id, id));
if (existing && values.status === "published" && !existing.publishedAt) {
Object.assign(values, { publishedAt: new Date() });
}
await db.update(contents).set(values).where(eq(contents.id, id));
} else {
const [row] = await db
.insert(contents)
.values({ ...values, publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
.returning({ id: contents.id });
id = row!.id;
}
await writeAudit(admin.id, "content.save", "content", id!, { title: data.title, type: data.type });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除内容 */
export const adminDeleteContent = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(contents).where(eq(contents.id, data.id));
await writeAudit(admin.id, "content.delete", "content", data.id);
return { ok: true as const };
});
/* ============================= 产品 Products ============================= */
/** # 函数 Server Function:产品列表(分页 + 关键字搜索,附特色图 URL) */
export const adminListProducts = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) {
conds.push(or(ilike(products.title, `%${data.q}%`), ilike(products.sku, `%${data.q}%`), ilike(products.slug, `%${data.q}%`)));
}
const where = conds.length ? and(...conds) : undefined;
const rows = await db
.select({
id: products.id,
sku: products.sku,
slug: products.slug,
title: products.title,
price: products.price,
regularPrice: products.regularPrice,
stock: products.stock,
stockStatus: products.stockStatus,
status: products.status,
featured: products.featured,
thumb: media.url,
updatedAt: products.updatedAt,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(where)
.orderBy(desc(products.updatedAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(products).where(where);
return { items: rows, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:读取产品编辑数据(产品 + 属性树 + 分类 + 已选值/分类)
* 返回值 attributes 内嵌可选值;selectedValueIds / selectedCategoryIds 供表单回显;
* selectedGalleryIds / initialGalleryOptions 供图集选择器回显
*/
export const adminGetProduct = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [product] = await db.select().from(products).where(eq(products.id, data.id));
const attrs = await db
.select()
.from(productAttributes)
.orderBy(asc(productAttributes.sort), asc(productAttributes.name));
const vals = await db
.select()
.from(productAttributeValues)
.orderBy(asc(productAttributeValues.sort), asc(productAttributeValues.value));
const cats = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.taxonomy, "product_cat"))
.orderBy(asc(categories.sort), asc(categories.name));
const selectedValues = await db
.select({ valueId: productAttributeMap.valueId })
.from(productAttributeMap)
.where(eq(productAttributeMap.productId, data.id));
const selectedCats = await db
.select({ categoryId: productTerms.categoryId })
.from(productTerms)
.where(eq(productTerms.productId, data.id));
// 已存图集(url 对象数组)反查媒体 ID,供编辑回显
const galleryArr = Array.isArray(product?.gallery) ? (product!.gallery as { url?: unknown }[]) : [];
const galleryUrls = galleryArr.map((g) => g.url).filter((u): u is string => typeof u === "string" && !!u);
const galleryRows = galleryUrls.length
? await db.select({ id: media.id, path: media.path, url: media.url }).from(media).where(inArray(media.url, galleryUrls))
: [];
return {
product: product ? { ...product, gallery: product.gallery as Json, meta: product.meta as Json } : null,
attributes: attrs.map((a) => ({ ...a, values: vals.filter((v) => v.attributeId === a.id) })),
categories: cats,
selectedValueIds: selectedValues.map((s) => s.valueId),
selectedCategoryIds: selectedCats.map((s) => s.categoryId),
selectedGalleryIds: galleryRows.map((r) => r.id),
initialGalleryOptions: galleryRows,
};
});
/** # 函数 Server Function:媒体搜索(产品编辑页特色图/图集选择器用,按文件名模糊匹配) */
export const adminSearchMedia = createServerFn({ method: "GET" })
.validator((d: { q?: string; limit?: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const limit = Math.min(50, Math.max(1, Math.floor(data.limit ?? 30)));
const where = data.q ? ilike(media.path, `%${data.q}%`) : undefined;
return db
.select({ id: media.id, path: media.path, url: media.url })
.from(media)
.where(where)
.orderBy(desc(media.id))
.limit(limit);
});
/** # 函数 Server Function:保存产品(含重建属性值关联与分类关联、特色图与图集) */
export const adminSaveProduct = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
sku?: string | null;
slug: string;
title: string;
shortDescription?: string;
description?: string;
price?: string | null;
regularPrice?: string | null;
stock?: number | null;
stockStatus?: string;
status?: string;
featured?: boolean;
specUrl?: string | null;
seoTitle?: string | null;
seoDescription?: string | null;
featuredMediaId?: number | null;
galleryIds?: number[];
valueIds: number[];
categoryIds: number[];
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const slug = (data.slug || "").trim() || slugify(data.title);
// 图集媒体 ID 解析为 {url, alt} 对象数组(与导入数据结构一致)
const galleryRows = data.galleryIds?.length
? await db.select({ url: media.url, altText: media.altText }).from(media).where(inArray(media.id, data.galleryIds))
: [];
const values = {
sku: data.sku ?? null,
slug,
title: data.title,
shortDescription: data.shortDescription ?? "",
description: data.description ?? "",
price: data.price ?? null,
regularPrice: data.regularPrice ?? null,
stock: data.stock ?? null,
stockStatus: data.stockStatus || "instock",
status: data.status || "draft",
featured: data.featured ?? false,
specUrl: data.specUrl ?? null,
seoTitle: data.seoTitle ?? null,
seoDescription: data.seoDescription ?? null,
featuredMediaId: data.featuredMediaId ?? null,
gallery: galleryRows.map((r) => ({ url: r.url, alt: r.altText })),
updatedAt: new Date(),
};
let id = data.id;
if (id) {
await db.update(products).set(values).where(eq(products.id, id));
} else {
const [row] = await db.insert(products).values(values).returning({ id: products.id });
id = row!.id;
}
// 重建属性值与分类关联
await db.delete(productAttributeMap).where(eq(productAttributeMap.productId, id));
if (data.valueIds.length) {
await db
.insert(productAttributeMap)
.values(data.valueIds.map((valueId) => ({ productId: id!, valueId })))
.onConflictDoNothing();
}
await db.delete(productTerms).where(eq(productTerms.productId, id));
if (data.categoryIds.length) {
await db
.insert(productTerms)
.values(data.categoryIds.map((categoryId) => ({ productId: id!, categoryId })))
.onConflictDoNothing();
}
await writeAudit(admin.id, "product.save", "product", id!, { title: data.title });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除产品(级联清理关联) */
export const adminDeleteProduct = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(productAttributeMap).where(eq(productAttributeMap.productId, data.id));
await db.delete(productTerms).where(eq(productTerms.productId, data.id));
await db.delete(products).where(eq(products.id, data.id));
await writeAudit(admin.id, "product.delete", "product", data.id);
return { ok: true as const };
});
/* ============================= 订单 Orders ============================= */
/** # 函数 内部工具:从订单账单 JSON 中取客户显示名 */
function billingName(billing: unknown): string {
const b = (billing ?? {}) as Record<string, unknown>;
const name = [b.first_name, b.last_name].filter((v) => typeof v === "string" && v).join(" ").trim();
if (name) return name;
if (typeof b.company === "string" && b.company) return b.company;
return "";
}
/** # 函数 Server Function:订单列表(状态过滤 + 分页) */
export const adminListOrders = createServerFn({ method: "GET" })
.validator((d: PageInput & { status?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.status) conds.push(eq(orders.status, data.status));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: orders.id,
orderNumber: orders.orderNumber,
status: orders.status,
currency: orders.currency,
total: orders.total,
paymentStatus: orders.paymentStatus,
paymentMethod: orders.paymentMethod,
billing: orders.billing,
createdAt: orders.createdAt,
})
.from(orders)
.where(where)
.orderBy(desc(orders.createdAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(orders).where(where);
return {
items: items.map((o) => ({ ...o, customerName: billingName(o.billing), billing: o.billing as Json })),
total: Number(total),
page,
pageSize,
};
});
/** # 函数 Server Function:订单详情(含明细与客户邮箱) */
export const adminGetOrder = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const rows = await db
.select({ order: orders, userEmail: users.email })
.from(orders)
.leftJoin(users, eq(users.id, orders.userId))
.where(eq(orders.id, data.id));
const [row] = rows;
if (!row) return null;
const items = await db.select().from(orderItems).where(eq(orderItems.orderId, data.id));
return {
order: {
...row.order,
customerName: billingName(row.order.billing),
billing: row.order.billing as Json,
shipping: row.order.shipping as Json,
},
userEmail: row.userEmail,
items,
};
});
/** # 函数 Server Function:更新订单状态 */
export const adminUpdateOrderStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(orders).set({ status: data.status, updatedAt: new Date() }).where(eq(orders.id, data.id));
await writeAudit(admin.id, "order.status", "order", data.id, { status: data.status });
return { ok: true as const };
});
/* ============================= 媒体 Media ============================= */
/** # 函数 Server Function:媒体列表(分页 + 文件名搜索) */
export const adminListMedia = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(ilike(media.path, `%${data.q}%`));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select()
.from(media)
.where(where)
.orderBy(desc(media.id))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(media).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:更新媒体 alt/title */
export const adminUpdateMedia = createServerFn({ method: "POST" })
.validator((d: { id: number; altText?: string; title?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const patch: Record<string, string> = {};
if (data.altText !== undefined) patch.altText = data.altText;
if (data.title !== undefined) patch.title = data.title;
if (Object.keys(patch).length) await db.update(media).set(patch).where(eq(media.id, data.id));
await writeAudit(admin.id, "media.update", "media", data.id, patch);
return { ok: true as const };
});
/* ============================= 重定向 Redirects ============================= */
/** # 函数 Server Function:重定向列表 */
export const adminListRedirects = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(or(ilike(redirects.source, `%${data.q}%`), ilike(redirects.target, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db.select().from(redirects).where(where).orderBy(desc(redirects.id)).limit(pageSize).offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(redirects).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:保存重定向(新建或更新) */
export const adminSaveRedirect = createServerFn({ method: "POST" })
.validator(
(d: { id?: number; source: string; target: string; code?: number; isRegex?: boolean; enabled?: boolean }) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const values = {
source: data.source,
target: data.target,
code: data.code || 301,
isRegex: data.isRegex ?? false,
enabled: data.enabled ?? true,
};
let id = data.id;
if (id) {
await db.update(redirects).set(values).where(eq(redirects.id, id));
} else {
const [row] = await db.insert(redirects).values(values).returning({ id: redirects.id });
id = row!.id;
}
await writeAudit(admin.id, "redirect.save", "redirect", id!, { source: data.source });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除重定向 */
export const adminDeleteRedirect = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(redirects).where(eq(redirects.id, data.id));
await writeAudit(admin.id, "redirect.delete", "redirect", data.id);
return { ok: true as const };
});
/* ============================= 筛选配置 Filters ============================= */
/** # 函数 Server Function:筛选配置列表(附属性定义供下拉选择) */
export const adminListFilters = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const items = await db.select().from(filters).orderBy(asc(filters.position), asc(filters.id));
const attributes = await db
.select({ id: productAttributes.id, name: productAttributes.name, slug: productAttributes.slug })
.from(productAttributes)
.orderBy(asc(productAttributes.sort));
return { items, attributes };
});
/** # 函数 Server Function:保存筛选配置 */
export const adminSaveFilter = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
name: string;
slug: string;
type?: string;
attributeId?: number | null;
position?: number;
enabled?: boolean;
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const values = {
name: data.name,
slug: data.slug,
type: data.type || "attribute",
attributeId: data.attributeId ?? null,
position: data.position ?? 0,
enabled: data.enabled ?? true,
};
let id = data.id;
if (id) {
await db.update(filters).set(values).where(eq(filters.id, id));
} else {
const [row] = await db.insert(filters).values(values).returning({ id: filters.id });
id = row!.id;
}
await writeAudit(admin.id, "filter.save", "filter", id!, { name: data.name });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除筛选配置 */
export const adminDeleteFilter = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(filters).where(eq(filters.id, data.id));
await writeAudit(admin.id, "filter.delete", "filter", data.id);
return { ok: true as const };
});
/* ============================= 语言 Languages ============================= */
/** # 函数 Server Function:语言配置列表 */
export const adminListLanguages = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const items = await db.select().from(languages).orderBy(asc(languages.sort), asc(languages.code));
return { items, total: items.length, page: 1, pageSize: items.length };
});
/** # 函数 Server Function:更新语言启用状态 */
export const adminUpdateLanguage = createServerFn({ method: "POST" })
.validator((d: { code: string; enabled: boolean }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(languages).set({ enabled: data.enabled }).where(eq(languages.code, data.code));
await writeAudit(admin.id, "language.update", "language", data.code, { enabled: data.enabled });
return { ok: true as const };
});
/* ============================= 用户 Users ============================= */
/** # 函数 Server Function:用户列表(只读,分页 + 关键字搜索) */
export const adminListUsers = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(or(ilike(users.email, `%${data.q}%`), ilike(users.displayName, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: users.id,
email: users.email,
username: users.username,
displayName: users.displayName,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.where(where)
.orderBy(desc(users.id))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(users).where(where);
return { items, total: Number(total), page, pageSize };
});
/* ============================= Dashboard 统计 ============================= */
/** # 函数 Server Function:Dashboard 统计
* 返回值 各实体计数、待处理订单数、最近 7 天询盘趋势、最新 10 条询盘
*/
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const countOf = async (table: PgTable) => Number(await db.$count(table));
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders] = await Promise.all([
countOf(contents),
countOf(products),
countOf(orders),
countOf(inquiries),
countOf(media),
db
.select({ n: sql<number>`count(*)` })
.from(orders)
.where(or(eq(orders.status, "pending"), eq(orders.status, "on-hold"))),
]);
// 最近 7 天询盘趋势(按天分组,补齐空白天)
const trendRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${inquiries.createdAt}), 'YYYY-MM-DD')`,
n: sql<number>`count(*)`,
})
.from(inquiries)
.where(sql`${inquiries.createdAt} > now() - interval '7 days'`)
.groupBy(sql`1`);
const trendMap = new Map(trendRows.map((r) => [r.day, Number(r.n)]));
const trend: { day: string; count: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400_000);
const key = d.toISOString().slice(0, 10);
trend.push({ day: key, count: trendMap.get(key) ?? 0 });
}
const recentInquiries = await db
.select({
id: inquiries.id,
name: inquiries.name,
email: inquiries.email,
company: inquiries.company,
country: inquiries.country,
productInterest: inquiries.productInterest,
status: inquiries.status,
createdAt: inquiries.createdAt,
})
.from(inquiries)
.orderBy(desc(inquiries.createdAt))
.limit(10);
return {
counts: {
contents: contentsCount,
products: productsCount,
orders: ordersCount,
inquiries: inquiriesCount,
media: mediaCount,
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
},
trend,
recentInquiries,
};
});
+131
View File
@@ -0,0 +1,131 @@
/**
* # 函数 认证与会话模块
* 服务端会话(HttpOnly Secure Cookie)+ 登录校验(bcrypt 与旧版 phpass 双兼容)。
*/
import { db } from "~/db";
import { users, sessions } from "~/db/schema";
import { eq, and, gt } from "drizzle-orm";
import { phpassVerify } from "./phpass";
import { createServerFn } from "@tanstack/react-start";
export const SESSION_COOKIE = "dusun_session";
const SESSION_DAYS = 14;
/** # 函数 创建会话并返回 Cookie 值
* 参数 userId:用户 ID
* 返回值 会话令牌
*/
export async function createSession(userId: number): Promise<string> {
const token = Bun.randomUUIDv7().replace(/-/g, "") + Bun.randomUUIDv7().replace(/-/g, "");
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400_000);
await db.insert(sessions).values({ token, userId, expiresAt });
return token;
}
/** # 函数 注销会话 */
export async function destroySession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.token, token));
}
/** # 函数 登录校验(邮箱 + 密码)
* 兼容两条路径:bcrypt(新账号)与 WordPress phpass(存量客户,验证成功后升级 bcrypt)。
* 返回值 用户对象或 null
*/
export async function verifyLogin(email: string, password: string) {
const [user] = await db.select().from(users).where(eq(users.email, email.toLowerCase().trim()));
if (!user) return null;
let ok = false;
if (user.passwordHash) ok = await Bun.password.verify(password, user.passwordHash);
else if (user.legacyPasswordHash) {
ok = await phpassVerify(password, user.legacyPasswordHash);
if (ok) {
// 透明升级为 bcrypt
await db.update(users).set({ passwordHash: Bun.password.hashSync(password, { algorithm: "bcrypt", cost: 10 }) }).where(eq(users.id, user.id));
}
}
return ok ? user : null;
}
/** # 函数 读取当前会话用户(服务端用)
* 参数 token:Cookie 中的会话令牌
* 返回值 用户(不含密码哈希)或 null
*/
export async function getSessionUser(token: string | undefined) {
if (!token) return null;
const rows = await db
.select({ id: users.id, email: users.email, displayName: users.displayName, role: users.role })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())));
return rows[0] ?? null;
}
/** # 函数 Server Function:获取当前登录用户(前台/后台共用) */
export const getCurrentUser = createServerFn({ method: "GET" }).handler(async () => {
const { getCookie } = await import("@tanstack/react-start/server");
const token = getCookie(SESSION_COOKIE);
return getSessionUser(token);
});
/** # 函数 Server Function:登录 */
export const loginFn = createServerFn({ method: "POST" })
.validator((d: { email: string; password: string }) => d)
.handler(async ({ data }) => {
const { setCookie } = await import("@tanstack/react-start/server");
const user = await verifyLogin(data.email, data.password);
if (!user) return { ok: false as const, error: "邮箱或密码错误" };
const token = await createSession(user.id);
setCookie(SESSION_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: SESSION_DAYS * 86400,
});
return { ok: true as const, role: user.role, displayName: user.displayName };
});
/** # 函数 Server Function:注销 */
export const logoutFn = createServerFn({ method: "POST" }).handler(async () => {
const { getCookie, setCookie } = await import("@tanstack/react-start/server");
const token = getCookie(SESSION_COOKIE);
if (token) await destroySession(token);
setCookie(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return { ok: true as const };
});
/** # 函数 Server Function:注册商城账号 */
export const registerFn = createServerFn({ method: "POST" })
.validator((d: { email: string; password: string; displayName?: string }) => d)
.handler(async ({ data }) => {
const { setCookie } = await import("@tanstack/react-start/server");
const email = data.email.toLowerCase().trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false as const, error: "邮箱格式不正确" };
if (data.password.length < 8) return { ok: false as const, error: "密码至少 8 位" };
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing) return { ok: false as const, error: "该邮箱已注册" };
const [user] = await db
.insert(users)
.values({
email,
displayName: data.displayName || email.split("@")[0]!,
passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }),
role: "customer",
})
.returning();
const token = await createSession(user.id);
setCookie(SESSION_COOKIE, token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: SESSION_DAYS * 86400 });
return { ok: true as const, displayName: user.displayName };
});
/** # 函数 Server Function:后台权限校验(所有 /admin 接口强制调用)
* 返回值 有管理权限的用户;否则抛错(由路由层转 403)
*/
export async function requireAdmin() {
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || !["admin", "editor", "manager"].includes(user.role)) {
throw new Error("FORBIDDEN");
}
return user;
}
+421
View File
@@ -0,0 +1,421 @@
/**
* # 模块 商城服务层(购物车 / 订单)
* 匿名购物车用 Cookie "dusun_cart"(uuid)关联 carts.session_key;
* 登录用户按 carts.user_id 关联,登录时自动合并匿名购物车。
* 订单类接口要求登录;购物车类接口匿名可用。
*/
import { db } from "~/db";
import { carts, cartItems, orders, orderItems, products, media } from "~/db/schema";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { getSessionUser, SESSION_COOKIE } from "./auth";
/** # 成员变量 匿名购物车 Cookie 名 **/
export const CART_COOKIE = "dusun_cart";
/** # 成员变量 匿名购物车 Cookie 有效期(30 天) **/
const CART_COOKIE_MAX_AGE = 30 * 86400;
/** # 类型 购物车明细行 */
export type CartLine = {
/** # 成员变量 明细 ID(cart_items.id) **/
id: number;
/** # 成员变量 产品 ID **/
productId: number;
/** # 成员变量 产品名 **/
title: string;
/** # 成员变量 产品 slug **/
slug: string;
/** # 成员变量 单价(USD 字符串) **/
price: string;
/** # 成员变量 数量 **/
qty: number;
/** # 成员变量 特色图 URL **/
imageUrl: string | null;
/** # 成员变量 库存状态 **/
stockStatus: string;
};
/** # 类型 购物车返回结构 */
export type CartResult = {
items: CartLine[];
/** # 成员变量 小计金额(字符串,保留 2 位) **/
subtotal: string;
/** # 成员变量 商品总件数 **/
count: number;
};
/** # 函数 读取当前会话用户(内部复用) */
async function currentUser() {
return getSessionUser(getCookie(SESSION_COOKIE));
}
/** # 函数 计算购物车明细与汇总(联表产品与特色图) */
async function loadCartLines(cartId: number): Promise<CartLine[]> {
return db
.select({
id: cartItems.id,
productId: products.id,
title: products.title,
slug: products.slug,
price: sql<string>`coalesce(${products.price}, '0')`,
qty: cartItems.qty,
imageUrl: media.url,
stockStatus: products.stockStatus,
})
.from(cartItems)
.innerJoin(products, eq(products.id, cartItems.productId))
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(eq(cartItems.cartId, cartId))
.orderBy(cartItems.id);
}
/** # 函数 汇总购物车(小计 / 件数) */
function summarizeCart(lines: CartLine[]): CartResult {
const subtotal = lines.reduce((s, l) => s + Number(l.price) * l.qty, 0);
return {
items: lines,
subtotal: subtotal.toFixed(2),
count: lines.reduce((s, l) => s + l.qty, 0),
};
}
/** # 类型 ensureCart 返回:购物车 ID + 新建的匿名 sessionKey(如生成) */
type EnsureCartResult = { cartId: number; newSessionKey?: string };
/** # 函数 购物车核心逻辑:按用户或匿名 sessionKey 查找 / 创建 / 合并购物车(无 Cookie 副作用,供测试复用)
* 参数 opts.userId:登录用户 ID(优先);opts.sessionKey:匿名会话键
* 返回值 购物车 ID(必要时新建);若新生成匿名键,通过 newSessionKey 返回由调用方写 Cookie
*/
export async function ensureCart(opts: { userId?: number | null; sessionKey?: string | null }): Promise<EnsureCartResult> {
if (opts.userId) {
const [userCart] = await db.select().from(carts).where(eq(carts.userId, opts.userId));
const anonKey = opts.sessionKey ?? null;
// 匿名购物车合并:有登录购物车时把匿名车商品挪进去
if (anonKey) {
const [anonCart] = await db.select().from(carts).where(eq(carts.sessionKey, anonKey));
if (anonCart && (!userCart || userCart.id !== anonCart.id)) {
if (userCart) {
// 已有用户购物车:逐行合并(同产品累加数量)
const anonLines = await db.select().from(cartItems).where(eq(cartItems.cartId, anonCart.id));
for (const line of anonLines) {
const [same] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.cartId, userCart.id), eq(cartItems.productId, line.productId)));
if (same) {
await db.update(cartItems).set({ qty: same.qty + line.qty }).where(eq(cartItems.id, same.id));
} else {
await db.insert(cartItems).values({ cartId: userCart.id, productId: line.productId, qty: line.qty });
}
}
await db.delete(cartItems).where(eq(cartItems.cartId, anonCart.id));
await db.delete(carts).where(eq(carts.id, anonCart.id));
} else {
// 用户还没有购物车:直接把匿名车过户给用户
await db
.update(carts)
.set({ userId: opts.userId, sessionKey: null, updatedAt: new Date() })
.where(eq(carts.id, anonCart.id));
return { cartId: anonCart.id };
}
}
}
if (userCart) return { cartId: userCart.id };
const [created] = await db.insert(carts).values({ userId: opts.userId }).returning({ id: carts.id });
return { cartId: created!.id };
}
// 匿名路径:沿用传入 sessionKey,否则生成新键(新键由调用方写 Cookie)
if (opts.sessionKey) {
const [existing] = await db.select().from(carts).where(eq(carts.sessionKey, opts.sessionKey));
if (existing) return { cartId: existing.id };
}
const key = opts.sessionKey ?? crypto.randomUUID().replace(/-/g, "");
const [created] = await db.insert(carts).values({ sessionKey: key }).returning({ id: carts.id });
return { cartId: created!.id, newSessionKey: opts.sessionKey ? undefined : key };
}
/** # 函数 获取或创建当前购物车(Cookie 胶水层,内部函数)
* 登录用户按 user_id;匿名按 dusun_cart Cookie 的 session_key;登录时自动合并匿名车。
*/
async function getOrCreateCart(): Promise<{ id: number; userId: number | null }> {
const user = await currentUser();
const anonKey = getCookie(CART_COOKIE);
const res = await ensureCart({ userId: user?.id ?? null, sessionKey: user ? (anonKey ?? null) : null });
if (!user) {
// 匿名:复用已有键或写新 Cookie
if (!anonKey && res.newSessionKey) {
setCookie(CART_COOKIE, res.newSessionKey, { httpOnly: true, sameSite: "lax", path: "/", maxAge: CART_COOKIE_MAX_AGE });
}
return { id: res.cartId, userId: null };
}
if (anonKey) setCookie(CART_COOKIE, "", { path: "/", maxAge: 0 }); // 匿名车已合并/清空
return { id: res.cartId, userId: user.id };
}
/** # 函数 购物车核心逻辑:向购物车加商品(校验已发布 + 库存状态可售,供测试复用)
* 参数 cartId:购物车 ID;productId:产品 ID;qty:数量(1-99)
* 返回值 { ok, count } 或 { ok:false, error }
*/
export async function addProductToCart(
cartId: number,
productId: number,
qty: number,
): Promise<{ ok: true; count: number } | { ok: false; error: string }> {
const q = Math.max(1, Math.min(99, Math.floor(qty || 1)));
const [product] = await db
.select({ id: products.id, status: products.status, stockStatus: products.stockStatus })
.from(products)
.where(eq(products.id, productId));
if (!product || product.status !== "published") return { ok: false, error: "产品不存在或未上架" };
if (!["instock", "onbackorder"].includes(product.stockStatus)) return { ok: false, error: "该商品已售罄" };
const [same] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.cartId, cartId), eq(cartItems.productId, productId)));
if (same) {
await db.update(cartItems).set({ qty: Math.min(99, same.qty + q) }).where(eq(cartItems.id, same.id));
} else {
await db.insert(cartItems).values({ cartId, productId, qty: q });
}
const [{ n }] = await db
.select({ n: sql<number>`coalesce(sum(${cartItems.qty}), 0)` })
.from(cartItems)
.where(eq(cartItems.cartId, cartId));
return { ok: true, count: Number(n) };
}
/** # 函数 Server Function:加入购物车
* 校验产品已发布且库存状态为 instock/onbackorder 才可加。
* 返回值 { ok, count } 或 { ok:false, error }
*/
export const addToCart = createServerFn({ method: "POST" })
.validator((d: { productId: number; qty?: number }) => d)
.handler(async ({ data }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => {
const cart = await getOrCreateCart();
return addProductToCart(cart.id, data.productId, data.qty ?? 1);
});
/** # 函数 Server Function:读取购物车 */
export const getCart = createServerFn({ method: "GET" }).handler(async (): Promise<CartResult> => {
const user = await currentUser();
const anonKey = getCookie(CART_COOKIE);
let cartId: number | null = null;
if (user) {
const [userCart] = await db.select().from(carts).where(eq(carts.userId, user.id));
if (userCart) cartId = userCart.id;
} else if (anonKey) {
const [anonCart] = await db.select().from(carts).where(eq(carts.sessionKey, anonKey));
if (anonCart) cartId = anonCart.id;
}
if (!cartId) return { items: [], subtotal: "0.00", count: 0 };
return summarizeCart(await loadCartLines(cartId));
});
/** # 函数 Server Function:修改购物车明细数量(qty<=0 删除该行) */
export const updateCartItem = createServerFn({ method: "POST" })
.validator((d: { itemId: number; qty: number }) => d)
.handler(async ({ data }): Promise<{ ok: boolean; error?: string }> => {
const cart = await getOrCreateCart();
const [line] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.id, data.itemId), eq(cartItems.cartId, cart.id)));
if (!line) return { ok: false, error: "明细不存在" };
if (data.qty <= 0) {
await db.delete(cartItems).where(eq(cartItems.id, line.id));
} else {
await db.update(cartItems).set({ qty: Math.min(99, Math.floor(data.qty)) }).where(eq(cartItems.id, line.id));
}
return { ok: true };
});
/** # 函数 Server Function:删除购物车明细 */
export const removeCartItem = createServerFn({ method: "POST" })
.validator((d: { itemId: number }) => d)
.handler(async ({ data }): Promise<{ ok: boolean; error?: string }> => {
const cart = await getOrCreateCart();
await db.delete(cartItems).where(and(eq(cartItems.id, data.itemId), eq(cartItems.cartId, cart.id)));
return { ok: true };
});
/** # 类型 账单/收货信息 */
export type BillingInfo = {
/** # 成员变量 名 **/
firstName: string;
/** # 成员变量 姓 **/
lastName: string;
/** # 成员变量 邮箱 **/
email: string;
/** # 成员变量 电话 **/
phone?: string;
/** # 成员变量 街道地址 **/
address: string;
/** # 成员变量 城市 **/
city: string;
/** # 成员变量 国家 **/
country: string;
/** # 成员变量 邮编 **/
postcode?: string;
};
/** # 函数 生成订单号:DS + 时间戳 36 进制 + 随机 2 位 */
function genOrderNumber(): string {
const rand = () => Math.floor(Math.random() * 36).toString(36);
return `DS${Date.now().toString(36)}${rand()}${rand()}`.toUpperCase();
}
/** # 类型 下单入参(server fn 与核心函数共用) */
export type PlaceOrderInput = {
/** # 成员变量 账单信息 **/
billing: BillingInfo;
/** # 成员变量 收货地址是否与账单相同 **/
shippingSame: boolean;
/** # 成员变量 独立收货地址(shippingSame=false 时生效) **/
shipping?: Partial<BillingInfo>;
/** # 成员变量 客户备注 **/
note?: string;
};
/** # 函数 下单核心逻辑(事务,供测试复用)
* 事务内:重校验可售 → 生成订单(bank_transfer / unpaid)→ 明细快照 →
* 扣库存(stock 为 NULL 的产品跳过)→ 清空购物车。
* 参数 userId:下单用户;cartId:购物车;input:账单/收货/备注
* 返回值 订单号;不可售时抛 Error(中文提示)
*/
export async function placeOrderCore(userId: number, cartId: number, input: PlaceOrderInput): Promise<string> {
const lines = await loadCartLines(cartId);
if (lines.length === 0) throw new Error("购物车为空");
const shipping = input.shippingSame ? input.billing : input.shipping ?? input.billing;
const subtotal = lines.reduce((s, l) => s + Number(l.price) * l.qty, 0);
return db.transaction(async (tx) => {
// 事务内重新校验产品可售并锁定价格
const rows = await tx
.select({
id: products.id,
title: products.title,
sku: products.sku,
price: products.price,
stock: products.stock,
status: products.status,
stockStatus: products.stockStatus,
})
.from(products)
.where(inArray(products.id, lines.map((l) => l.productId)));
const byId = new Map(rows.map((r) => [r.id, r]));
for (const line of lines) {
const p = byId.get(line.productId);
if (!p || p.status !== "published") throw new Error("包含不可购买的商品,请更新购物车");
if (p.stockStatus === "outofstock") throw new Error(`「${p.title}」已售罄,请移除后再结算`);
}
let number = genOrderNumber();
// 订单号唯一索引兜底重试
for (let i = 0; i < 3; i++) {
const [dupe] = await tx.select({ id: orders.id }).from(orders).where(eq(orders.orderNumber, number));
if (!dupe) break;
number = genOrderNumber();
}
const [order] = await tx
.insert(orders)
.values({
orderNumber: number,
userId,
status: "pending",
currency: "USD",
subtotal: subtotal.toFixed(2),
total: subtotal.toFixed(2),
paymentMethod: "bank_transfer",
paymentStatus: "unpaid",
billing: input.billing,
shipping,
customerNote: input.note ?? "",
})
.returning({ id: orders.id, orderNumber: orders.orderNumber });
for (const line of lines) {
const p = byId.get(line.productId)!;
const unit = Number(p.price ?? 0);
await tx.insert(orderItems).values({
orderId: order!.id,
productId: p.id,
name: p.title,
sku: p.sku,
qty: line.qty,
price: unit.toFixed(2),
total: (unit * line.qty).toFixed(2),
});
// 扣库存:stock 字段为 NULL 的产品跳过
if (p.stock !== null && p.stock !== undefined) {
await tx
.update(products)
.set({ stock: sql`greatest(${products.stock} - ${line.qty}, 0)`, updatedAt: new Date() })
.where(eq(products.id, p.id));
}
}
// 清空购物车
await tx.delete(cartItems).where(eq(cartItems.cartId, cartId));
return order!.orderNumber;
});
}
/** # 函数 Server Function:由购物车创建订单
* 必须登录。返回值 { ok:true, orderNumber } 或 { ok:false, error }
*/
export const createOrder = createServerFn({ method: "POST" })
.validator((d: PlaceOrderInput) => d)
.handler(async ({ data }): Promise<{ ok: true; orderNumber: string } | { ok: false; error: string }> => {
const user = await currentUser();
if (!user) return { ok: false, error: "请先登录" };
const cart = await getOrCreateCart();
try {
const orderNumber = await placeOrderCore(user.id, cart.id, data);
return { ok: true, orderNumber };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "下单失败" };
}
});
/** # 函数 Server Function:当前用户的订单列表 */
export const getMyOrders = createServerFn({ method: "GET" }).handler(async () => {
const user = await currentUser();
if (!user) return [];
return db
.select({
id: orders.id,
orderNumber: orders.orderNumber,
status: orders.status,
paymentStatus: orders.paymentStatus,
total: orders.total,
createdAt: orders.createdAt,
itemCount: sql<number>`(SELECT coalesce(sum(oi.qty),0) FROM order_items oi WHERE oi.order_id = ${orders.id})`,
})
.from(orders)
.where(eq(orders.userId, user.id))
.orderBy(desc(orders.createdAt))
.limit(50);
});
/** # 函数 Server Function:订单详情(校验属于当前用户) */
export const getOrderDetail = createServerFn({ method: "GET" })
.validator((d: { orderNumber: string }) => d)
.handler(async ({ data }) => {
const user = await currentUser();
if (!user) return null;
const [row] = await db
.select()
.from(orders)
.where(and(eq(orders.orderNumber, data.orderNumber), eq(orders.userId, user.id)));
if (!row) return null;
// jsonb 列推断为 unknown,序列化前显式 cast 为可序列化字段类型
const order = {
...row,
billing: row.billing as Record<string, string | number | boolean | null>,
shipping: row.shipping as Record<string, string | number | boolean | null>,
};
const items = await db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
return { order, items };
});
+562
View File
@@ -0,0 +1,562 @@
/**
* # 函数 前台数据查询层
* 提供菜单树、内容、产品列表(含筛选)、产品详情等 SSR 查询。
* 全部走索引 + 稳定分页,供 Server Function / SSR loader 使用。
*/
import { db } from "~/db";
import {
contents,
categories,
contentTerms,
products,
productTerms,
productAttributes,
productAttributeValues,
productAttributeMap,
menuItems,
menus,
media,
redirects,
inquiries,
inquiryForms,
users,
} from "~/db/schema";
import { and, desc, eq, inArray, sql, asc, like, or, isNotNull, count } from "drizzle-orm";
import { deepRewriteMedia, localSiteUrl } from "~/utils/media";
import { decodeHtmlEntities } from "~/utils/text";
import { slimMetaElementor } from "~/components/elementor/slim";
import { createServerFn } from "@tanstack/react-start";
/** # 类型 菜单项树节点 */
export type MenuNode = {
id: number;
title: string;
url: string;
target: string;
children: MenuNode[];
};
/** # 函数 读取指定位置的菜单树
* 参数 location:菜单位置(menu-1 / menu-2 / max_mega_menu_1 / max_mega_menu_2)
* 返回值 顶层菜单项数组(含子树)
*/
export async function getMenuTree(location: string): Promise<MenuNode[]> {
const [menu] = await db.select().from(menus).where(eq(menus.location, location));
if (!menu) return [];
const items = await db.select().from(menuItems).where(eq(menuItems.menuId, menu.id)).orderBy(asc(menuItems.sort));
const byId = new Map<number, MenuNode>();
const roots: MenuNode[] = [];
// 菜单 URL 导入时多为原站绝对地址,展示层统一改写为本地路径
for (const it of items) byId.set(it.id, { id: it.id, title: it.title, url: localSiteUrl(it.url), target: it.target, children: [] });
for (const it of items) {
const node = byId.get(it.id)!;
if (it.parentId && byId.has(it.parentId)) byId.get(it.parentId)!.children.push(node);
else roots.push(node);
}
return roots;
}
/** # 函数 按别名取已发布内容(页面/文章)
* 返回值 内容与特色图 URL,未发布返回 null
*/
export async function getPublishedContent(slug: string, type?: string) {
const conds = [eq(contents.slug, slug), eq(contents.status, "published")];
if (type) conds.push(eq(contents.type, type));
const [row] = await db
.select({
id: contents.id,
type: contents.type,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
html: contents.html,
template: contents.template,
views: contents.views,
seoTitle: contents.seoTitle,
seoDescription: contents.seoDescription,
meta: contents.meta,
publishedAt: contents.publishedAt,
featuredUrl: media.url,
featuredAlt: media.altText,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.where(and(...conds));
if (!row) return null;
// 内容的分类/标签
const terms = await db
.select({ name: categories.name, slug: categories.slug, taxonomy: categories.taxonomy })
.from(contentTerms)
.innerJoin(categories, eq(categories.id, contentTerms.categoryId))
.where(eq(contentTerms.contentId, row.id));
// 标题/SEO/摘要为纯文本字段,解码 WP 实体后返回
// meta.elementor 先瘦身(脱水体积优化);有 elementor 时 html 不再下发给客户端(路由只按 elementor 渲染)
const meta = slimMetaElementor(row.meta);
const hasElementor = !!(meta && typeof meta === "object" && (meta as Record<string, unknown>).elementor);
return deepRewriteMedia({
...row,
html: hasElementor ? null : row.html,
meta,
title: decodeHtmlEntities(row.title),
seoTitle: row.seoTitle ? decodeHtmlEntities(row.seoTitle) : row.seoTitle,
excerpt: row.excerpt ? decodeHtmlEntities(row.excerpt) : row.excerpt,
terms,
});
}
/** # 函数 首页内容(legacy page id=18) */
export async function getHomePage() {
return getPublishedContent("home", "page");
}
/** # 类型 产品筛选状态:每个筛选字段已选值列表 */
export type FilterState = Record<string, string[]>;
/** # 类型 产品列表返回结构 */
export type ProductListResult = {
items: {
id: number;
slug: string;
title: string;
sku: string | null;
price: string | null;
regularPrice: string | null;
stockStatus: string;
imageUrl: string | null;
imageAlt: string | null;
excerpt: string;
}[];
total: number;
page: number;
pageSize: number;
};
/** # 函数 产品列表(支持筛选 + 分类 + 分页 + 排序)
* 筛选查询:product_attribute_map 复合索引,IN 组合条件取交集(AND 语义)。
* 参数 opts.category:产品分类 slug;opts.filters:{属性slug: [值slug]}
*/
export async function listProducts(opts: {
category?: string;
filters?: FilterState;
q?: string;
page?: number;
pageSize?: number;
sort?: "latest" | "title" | "views";
}): Promise<ProductListResult> {
const page = Math.max(1, opts.page || 1);
const pageSize = Math.min(48, Math.max(8, opts.pageSize || 24));
const conds = [eq(products.status, "published")];
if (opts.category) {
const conds2 = [...conds];
const [cat] = await db.select({ id: categories.id }).from(categories).where(and(eq(categories.taxonomy, "product_cat"), eq(categories.slug, opts.category)));
if (!cat) return { items: [], total: 0, page, pageSize };
conds2.push(sql`EXISTS (SELECT 1 FROM product_terms pt WHERE pt.product_id=${products.id} AND pt.category_id=${cat.id})`);
return runProductQuery(conds2, page, pageSize, opts.sort);
}
if (opts.q) {
conds.push(or(like(products.title, `%${opts.q}%`), like(products.sku, `%${opts.q}%`))!);
}
// 属性筛选:每个字段一个 EXISTS 交集
const filterEntries = Object.entries(opts.filters || {}).filter(([, v]) => v.length > 0);
for (const [attrSlug, valueSlugs] of filterEntries) {
const valueIds = await db
.select({ id: productAttributeValues.id })
.from(productAttributeValues)
.innerJoin(productAttributes, eq(productAttributes.id, productAttributeValues.attributeId))
.where(and(eq(productAttributes.slug, attrSlug), inArray(productAttributeValues.slug, valueSlugs)));
if (valueIds.length === 0) return { items: [], total: 0, page, pageSize };
conds.push(
sql`EXISTS (SELECT 1 FROM product_attribute_map m WHERE m.product_id=${products.id} AND m.value_id IN ${valueIds.map((v) => v.id)})`,
);
}
return runProductQuery(conds, page, pageSize, opts.sort);
}
/** # 函数 产品查询执行(公共部分:JOIN 特色图 + 计数 + 排序分页) */
async function runProductQuery(conds: unknown[], page: number, pageSize: number, sort?: string): Promise<ProductListResult> {
const where = and(...(conds as never[]));
const [{ total }] = await db.select({ total: count() }).from(products).where(where);
const order = sort === "title" ? asc(products.title) : sort === "views" ? desc(products.views) : desc(products.createdAt);
const items = await db
.select({
id: products.id,
slug: products.slug,
title: products.title,
sku: products.sku,
price: products.price,
regularPrice: products.regularPrice,
stockStatus: products.stockStatus,
imageUrl: media.url,
imageAlt: media.altText,
excerpt: products.shortDescription,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(where)
.orderBy(order)
.limit(pageSize)
.offset((page - 1) * pageSize);
return deepRewriteMedia({
items: items.map((it) => ({ ...it, title: decodeHtmlEntities(it.title) })),
total,
page,
pageSize,
});
}
/** # 函数 产品详情(含图廊、属性、分类) */
export async function getProductDetail(slug: string) {
const [row] = await db
.select({
id: products.id,
slug: products.slug,
title: products.title,
sku: products.sku,
shortDescription: products.shortDescription,
description: products.description,
price: products.price,
regularPrice: products.regularPrice,
stockStatus: products.stockStatus,
stock: products.stock,
specUrl: products.specUrl,
seoTitle: products.seoTitle,
seoDescription: products.seoDescription,
meta: products.meta,
views: products.views,
featuredUrl: media.url,
featuredAlt: media.altText,
publishedAt: products.createdAt,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(and(eq(products.slug, slug), eq(products.status, "published")));
if (!row) return null;
const galleryUrls = row.meta && Array.isArray((row.meta as any).gallery)
? await db.select({ url: media.url, alt: media.altText }).from(media).where(inArray(media.id, (row.meta as any).gallery))
: [];
const attributes = await db
.select({ attr: productAttributes.name, attrSlug: productAttributes.slug, value: productAttributeValues.value, valueSlug: productAttributeValues.slug })
.from(productAttributeMap)
.innerJoin(productAttributeValues, eq(productAttributeValues.id, productAttributeMap.valueId))
.innerJoin(productAttributes, eq(productAttributes.id, productAttributeValues.attributeId))
.where(eq(productAttributeMap.productId, row.id))
.orderBy(asc(productAttributes.sort), asc(productAttributeValues.sort));
const cats = await db
.select({ name: categories.name, slug: categories.slug })
.from(productTerms)
.innerJoin(categories, eq(categories.id, productTerms.categoryId))
.where(eq(productTerms.productId, row.id));
// meta.elementor 瘦身;有 elementor 时 description(html) 不下发(路由按 elementor 渲染)
const meta = slimMetaElementor(row.meta);
const hasElementor = !!(meta && typeof meta === "object" && (meta as Record<string, unknown>).elementor);
return deepRewriteMedia({
...row,
description: hasElementor ? null : row.description,
meta,
title: decodeHtmlEntities(row.title),
seoTitle: row.seoTitle ? decodeHtmlEntities(row.seoTitle) : row.seoTitle,
gallery: galleryUrls,
attributes,
categories: cats,
});
}
/** # 函数 筛选器定义(前台侧栏渲染用)+ 每个值的产品计数 */
export async function getFilterConfig() {
const attrs = await db.select().from(productAttributes).where(eq(productAttributes.filterable, true)).orderBy(asc(productAttributes.sort));
const configs: {
name: string;
slug: string;
values: { value: string; slug: string; count: number }[];
}[] = [];
for (const a of attrs) {
const values = await db
.select({ value: productAttributeValues.value, slug: productAttributeValues.slug, count: sql<number>`count(${productAttributeMap.productId})` })
.from(productAttributeValues)
.leftJoin(productAttributeMap, eq(productAttributeMap.valueId, productAttributeValues.id))
.where(eq(productAttributeValues.attributeId, a.id))
.groupBy(productAttributeValues.id)
.orderBy(asc(productAttributeValues.sort), asc(productAttributeValues.value));
configs.push({ name: a.name, slug: a.slug, values: values.filter((v) => v.count > 0) });
}
return configs;
}
/** # 函数 产品分类树(侧栏/导航用,含计数) */
export async function getProductCategoryTree() {
const rows = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug, parentId: categories.parentId, count: sql<number>`(SELECT count(*) FROM product_terms pt WHERE pt.category_id=${categories.id})` })
.from(categories)
.where(eq(categories.taxonomy, "product_cat"))
.orderBy(asc(categories.sort), asc(categories.name));
return rows.filter((r) => Number(r.count) > 0);
}
/** # 函数 文章列表(分页) */
/** # 函数 从内容 meta(elementor 文档或 HTML)中提取第一张上传图片 URL
* 参数 meta 任意 meta JSON
* 返回值 图片 URL(含原站绝对地址,展示层统一改写)或 null
*/
function firstUploadedImage(meta: unknown): string | null {
if (!meta) return null;
const seen = new Set<unknown>();
const stack: unknown[] = [meta];
while (stack.length) {
const cur = stack.pop();
if (cur == null || typeof cur !== "object" || seen.has(cur)) continue;
seen.add(cur);
if (typeof cur === "string") {
const m = /(?:https?:\/\/[^\s"']*\/wp-content\/uploads\/[^\s"'\)]+|\/uploads\/[^\s"'\)]+\.(?:png|jpe?g|webp|gif))/i.exec(cur);
if (m) return m[0];
continue;
}
for (const v of Object.values(cur as Record<string, unknown>)) stack.push(v);
}
return null;
}
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
/** # 成员变量 /solution/ 索引页展示的方案落地页 slug(与原站 SOLUTION 索引卡片一一对应) **/
export const SOLUTION_SLUGS = [
"smart-apartment-solution",
"smart-apartment-gateway-oem",
"oil-gas-management-solution",
"energy-storage-ems-solution",
"smart-robot-controller-solution",
"vehicle-iot-solution",
"smart-building-solution",
"smart-retail-solution",
"smart-home-solution",
"smart-elderly-care-solution",
"ble-positioning-solution",
] as const;
/** # 函数 方案落地页列表(/solution/ 索引卡片:标题 + 摘要 + 图)
* 返回值 按 SOLUTION_SLUGS 顺序的已发布 elementor 方案页(缺省跳过)
*/
export async function listSolutionLandings() {
const rows = await db
.select({
id: contents.id,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
imageUrl: media.url,
imageAlt: media.altText,
hasElementor: sql<boolean>`(meta -> 'elementor') is not null`,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.where(and(eq(contents.type, "post"), eq(contents.status, "published"), inArray(contents.slug, [...SOLUTION_SLUGS])));
const bySlug = new Map(rows.map((r) => [r.slug, r]));
return [...SOLUTION_SLUGS]
.map((s) => bySlug.get(s))
.filter((r) => !!r)
.map((r) => ({ ...r, title: decodeHtmlEntities(r.title) }));
}
export async function listPosts(page = 1, pageSize = 12, categorySlug?: string) {
const conds = [eq(contents.type, "post"), eq(contents.status, "published")];
if (categorySlug) {
const [cat] = await db.select({ id: categories.id }).from(categories).where(and(eq(categories.taxonomy, "category"), eq(categories.slug, categorySlug)));
if (!cat) return { items: [], total: 0, page, pageSize };
conds.push(sql`EXISTS (SELECT 1 FROM content_terms ct WHERE ct.content_id=${contents.id} AND ct.category_id=${cat.id})`);
}
const where = and(...conds);
const [{ total }] = await db.select({ total: count() }).from(contents).where(where);
const items = await db
.select({
id: contents.id,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
publishedAt: contents.publishedAt,
views: contents.views,
imageUrl: media.url,
// 列表卡片元信息:作者显示名 + 第一个分类名(原站卡片带作者·日期与分类徽章)
authorName: users.displayName,
categoryName: sql<string | null>`(SELECT cat.name FROM content_terms ct JOIN categories cat ON cat.id = ct.category_id WHERE ct.content_id = ${contents.id} AND cat.taxonomy = 'category' LIMIT 1)`,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.leftJoin(users, eq(users.id, contents.authorId))
.where(where)
.orderBy(desc(contents.publishedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
// 无特色图的文章:从 elementor 内容里提取第一张上传图片作列表图回退
const needFallback = items.filter((it) => !it.imageUrl);
if (needFallback.length) {
const metas = await db
.select({ id: contents.id, meta: contents.meta })
.from(contents)
.where(inArray(contents.id, needFallback.map((it) => it.id)));
const firstImg = new Map<number, string>();
for (const m of metas) {
const url = firstUploadedImage(m.meta);
if (url) firstImg.set(m.id, url);
}
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
}
return deepRewriteMedia({
items: items.map((it) => ({ ...it, title: decodeHtmlEntities(it.title) })),
total,
page,
pageSize,
});
}
/** # 函数 URL 重定向查询(301/302 兼容旧链接) */
export async function findRedirect(source: string) {
const [exact] = await db.select().from(redirects).where(and(eq(redirects.source, source), eq(redirects.enabled, true)));
return exact ?? null;
}
/** # 函数 URL 重定向链式解析(自指跳过 + 跳数上限)
* 库里同时存在「无斜杠→有斜杠」规范化规则和「路径→真实目标」规则(如
* /dusun-partner-ziroom → /dusun-partner-ziroom/ 与 /dusun-partner-ziroom/ → /partners/...)。
* 只按去斜杠形式精确匹配会命中前者并把浏览器 301 回原路径形成死循环。
* 这里按 请求原样 → 去尾斜杠 → 带尾斜杠 依次精确匹配;目标与当前路径相同视为
* 自指,前进到目标形式继续找下一条;最多跟随 6 跳。
* 参数 path 请求路径
* 返回值 最终应重定向到的 { target, code, id };无命中返回 null
*/
export async function resolveRedirect(path: string): Promise<{ target: string; code: number; id: number } | null> {
const norm = (p: string) => (p === "" || p === "/" ? "/" : "/" + p.replace(/^\/+|\/+$/g, ""));
const origin = norm(path);
const visited = new Set<string>([origin]);
let current = path;
let result: { target: string; code: number; id: number } | null = null;
for (let i = 0; i < 6; i++) {
const k = norm(current);
const rule =
(await findRedirect(current)) ??
(await findRedirect(k)) ??
(k !== "/" ? await findRedirect(k + "/") : null);
if (!rule) break;
const t = norm(rule.target);
if (t === k || t === origin) {
// 自指/回到起点:前进到目标形式,继续查下一条规则
current = rule.target;
continue;
}
if (visited.has(t)) break; // 目标已走过(A→B→A 环):停止跟随,沿用已有结果
visited.add(t);
result = { target: rule.target, code: rule.code, id: rule.id };
current = rule.target;
}
return result;
}
/** # 函数 判断 slug 是否存在已发布内容(page/post 均算,供重定向「内容优先」判断)
* 参数 slug 内容 slug(不含路径前缀)
* 返回值 true=存在已发布内容
*/
export async function hasPublishedSlug(slug: string): Promise<boolean> {
const [row] = await db
.select({ id: contents.id })
.from(contents)
.where(and(eq(contents.slug, slug), eq(contents.status, "published")))
.limit(1);
return !!row;
}
/** # 函数 文章分类列表(博客导航) */
export async function getPostCategories() {
return db
.select({ name: categories.name, slug: categories.slug, count: sql<number>`(SELECT count(*) FROM content_terms ct WHERE ct.category_id=${categories.id})` })
.from(categories)
.where(eq(categories.taxonomy, "category"))
.orderBy(asc(categories.sort), asc(categories.name));
}
/** # 函数 按 slug 取文章分类(WP /%category%/ 归档路径用;仅 taxonomy=category)
* 参数 slug 分类 slug(不含路径前缀)
* 返回值 { id, name, slug };无命中返回 null
*/
export async function getPostCategoryBySlug(slug: string) {
const [row] = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug })
.from(categories)
.where(and(eq(categories.taxonomy, "category"), eq(categories.slug, slug)))
.limit(1);
return row ?? null;
}
/** # 函数 判断产品 slug 是否存在已发布产品(产品规格旧路径改判用)
* 参数 slug 产品 slug
* 返回值 true=存在已发布产品
*/
export async function hasProductSlug(slug: string) {
const [row] = await db.select({ id: products.id }).from(products).where(and(eq(products.slug, slug), eq(products.status, "published"))).limit(1);
return !!row;
}
/** # 函数 按父分类 slug + 子分类 slug 取产品子分类(嵌套分类路径改判用)
* 参数 parentSlug 父分类 slug;childSlug 子分类 slug
* 返回值 子分类 { id, name, slug };无命中返回 null
*/
export async function getProductChildCategory(parentSlug: string, childSlug: string) {
const [row] = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug })
.from(categories)
.where(
and(
eq(categories.taxonomy, "product_cat"),
eq(categories.slug, childSlug),
sql`${categories.parentId} = (SELECT id FROM categories WHERE slug = ${parentSlug} AND taxonomy = 'product_cat' ORDER BY id LIMIT 1)`,
),
)
.limit(1);
return row ?? null;
}
/** # 函数 活跃询盘表单定义(按 key) */
export async function getInquiryForm(formKey: string) {
const [row] = await db.select().from(inquiryForms).where(and(eq(inquiryForms.formKey, formKey), eq(inquiryForms.enabled, true)));
// 表单字段可能内嵌原站绝对地址(CF7 导入内容),展示层统一改写
return row ? deepRewriteMedia(row) : null;
}
/** # 函数 Sitemap 数据:全部已发布 URL */
export async function getSitemapUrls() {
const pages = await db.select({ slug: contents.slug, type: contents.type, publishedAt: contents.publishedAt }).from(contents).where(eq(contents.status, "published"));
const prods = await db.select({ slug: products.slug, updatedAt: products.updatedAt }).from(products).where(eq(products.status, "published"));
const cats = await db.select({ slug: categories.slug }).from(categories).where(eq(categories.taxonomy, "product_cat"));
return { pages, prods, cats };
}
/** # 类型 Ticker/Posts widget 用的轻量条目 */
export type LatestItem = { title: string; url: string };
/** # Server Function 最新产品标题(eael-content-ticker widget,product 类型)
* 取证(2026-10-31 原站实测):ticker 三条 = 最新发布的 3 个产品
* (dsgw-512 → dsom-110r → dsgw-510,与 created_at 降序一致),非按浏览量。
* 参数 data.limit 取条数(原站 posts_per_page=3)
* 返回值 {title,url}[],url 形如 /product/<slug>/
*/
export const getLatestProducts = createServerFn({ method: "GET" })
.validator((d?: { limit?: number }) => d)
.handler(async ({ data }): Promise<LatestItem[]> => {
const rows = await db
.select({ slug: products.slug, title: products.title })
.from(products)
.where(eq(products.status, "published"))
.orderBy(desc(products.createdAt))
.limit(Math.max(1, Math.min(20, data?.limit ?? 8)));
return rows.map((r) => ({ title: r.title, url: "/product/" + r.slug + "/" }));
});
/** # Server Function 最新文章卡片(posts widget / ticker post 类型) */
export const getLatestPosts = createServerFn({ method: "GET" }).handler(async (): Promise<LatestItem[]> => {
const rows = await db
.select({ slug: contents.slug, title: contents.title })
.from(contents)
.where(and(eq(contents.type, "post"), eq(contents.status, "published")))
.orderBy(desc(contents.publishedAt))
.limit(8);
return rows.map((r) => ({ title: r.title, url: "/blog/" + r.slug + "/" }));
});
+114
View File
@@ -0,0 +1,114 @@
/**
* # 函数 询盘模块(Contact Form 7 / Elementor Form 功能等价实现)
* 前台提交 → 入库(inquiries)→ 投递邮件任务(jobs 队列)→ 通知。
* 反垃圾:蜜罐字段 + 提交频率限制 + 简单规则。
*/
import { db } from "~/db";
import { inquiries, inquiryForms, jobs, auditLogs } from "~/db/schema";
import { createServerFn } from "@tanstack/react-start";
import { desc, eq, sql } from "drizzle-orm";
import type { Json } from "~/components/admin/common";
/** # 类型 询盘提交载荷(通用字段 + 任意表单扩展字段) */
export type InquiryPayload = {
formKey: string;
name: string;
email: string;
phone?: string;
company?: string;
country?: string;
message: string;
productInterest?: string;
/** # 成员变量 蜜罐字段:正常用户不会填写 */
_gotcha?: string;
/** # 成员变量 UTM 参数(由提交端透传) */
utm?: Record<string, string>;
/** # 成员变量 其余表单字段(annualdemand / utm 等) */
extra?: Record<string, string>;
};
/** # 函数 Server Function:提交询盘
* 流程:蜜罐检测 → 频率限制(同 IP 每分钟 5 条)→ 入库 → 邮件任务入队。
*/
export const submitInquiry = createServerFn({ method: "POST" })
.validator((d: InquiryPayload) => d)
.handler(async ({ data }) => {
const { getRequest } = await import("@tanstack/react-start/server");
const req = getRequest();
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "";
if (data._gotcha) return { ok: true as const, spam: true }; // 蜜罐命中:假装成功
// 频率限制
const [{ n }] = await db
.select({ n: sql<number>`count(*)` })
.from(inquiries)
.where(sql`${inquiries.ip} = ${ip} AND ${inquiries.createdAt} > now() - interval '1 minute'`);
if (Number(n) >= 5) return { ok: false as const, error: "提交过于频繁,请稍后再试" };
const [form] = await db.select().from(inquiryForms).where(eq(inquiryForms.formKey, data.formKey));
const { utm, _gotcha, extra, ...core } = data;
await db.insert(inquiries).values({
formName: form?.name || "Website Form",
formId: form?.id ?? null,
name: (core.name || "").slice(0, 255),
email: (core.email || "").slice(0, 255),
phone: (core.phone || "").slice(0, 128),
company: (core.company || "").slice(0, 255),
country: (core.country || "").slice(0, 128),
message: core.message || "",
productInterest: (core.productInterest || "").slice(0, 255),
fields: { ...(extra || {}), ...(core.productInterest ? { product: core.productInterest } : {}) },
status: "new",
channel: form?.name || "",
sourcePage: req.headers.get("referer") || "",
ip,
utm: Object.fromEntries(
Object.entries(extra || {})
.filter(([k]) => k.startsWith("utm_") || k === "gclid")
.map(([k, v]) => [k, v]),
),
});
// 邮件通知任务(持久化队列,独立于请求执行)
await db.insert(jobs).values({
type: "email.send",
payload: {
to: form?.mailTo || process.env.INQUIRY_NOTIFY_EMAIL || "sales@dusuniot.com",
subject: `[DusunIoT 询盘] ${core.name || core.email} - ${core.productInterest || "General"}`,
body: JSON.stringify(data),
},
});
return { ok: true as const };
});
/** # 函数 Server Function:询盘列表(后台) */
export const listInquiries = createServerFn({ method: "GET" })
.validator((d: { status?: string; page?: number; pageSize?: number; q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const page = Math.max(1, data.page || 1);
const pageSize = Math.min(100, data.pageSize || 20);
const conds = [];
if (data.status) conds.push(eq(inquiries.status, data.status));
if (data.q) conds.push(sql`(${inquiries.email} ILIKE ${"%" + data.q + "%"} OR ${inquiries.name} ILIKE ${"%" + data.q + "%"} OR ${inquiries.company} ILIKE ${"%" + data.q + "%"})`);
const where = conds.length ? sql.join(conds, sql` AND `) : undefined;
const rows = await db
.select()
.from(inquiries)
.where(where)
.orderBy(desc(inquiries.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(inquiries).where(where);
// fields / utm 为 jsonb(unknown),收窄为 Json 以满足 server fn 可序列化校验
return { items: rows.map((r) => ({ ...r, fields: r.fields as Json, utm: r.utm as Json })), total, page, pageSize };
});
/** # 函数 Server Function:更新询盘状态(后台) */
export const updateInquiryStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(inquiries).set({ status: data.status }).where(eq(inquiries.id, data.id));
await db.insert(auditLogs).values({ actorId: admin.id, action: "inquiry.status", entity: "inquiry", entityId: String(data.id), meta: { status: data.status } });
return { ok: true as const };
});
+80
View File
@@ -0,0 +1,80 @@
import { initDb } from "~/db";
/**
* # 函数 后台任务队列 Worker
* 消费 jobs 表(pending → running → done/failed),带失败重试与可观测性。
* 独立进程运行:bun run scripts/jobs-worker.ts(不依赖 Web 请求触发)。
*/
import { db } from "~/db";
import { jobs, inquiries } from "~/db/schema";
import { and, eq, lte, or, sql } from "drizzle-orm";
// 服务端预热数据库连接(worker/测试直接运行本模块)
await initDb();
/** # 类型 任务处理器映射 */
const handlers: Record<string, (payload: Record<string, unknown>) => Promise<void>> = {
/** 邮件发送:当前为可观测的 stub(落日志),接入 SMTP 时替换实现 */
"email.send": async (payload) => {
console.log(`[email.send] to=${payload.to} subject=${payload.subject}`);
// TODO: 接入 SMTP(原站 wp-mail-smtp 配置迁移后启用)
},
};
/** # 函数 领取一个到期任务(FOR UPDATE SKIP LOCKED 防止多 worker 重复消费) */
async function claimJob() {
const rows = await db.execute(sql`
UPDATE jobs SET status='running', attempts=attempts+1, updated_at=now()
WHERE id = (
SELECT id FROM jobs
WHERE status='pending' AND run_at <= now()
ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED
)
RETURNING id, type, payload, attempts, max_attempts`);
return (rows as any).rows?.[0] ?? (rows as any)[0] ?? null;
}
/** # 函数 处理单个任务(失败按 maxAttempts 重试,指数退避) */
async function processJob(job: { id: number; type: string; payload: unknown; attempts: number; max_attempts: number }) {
const handler = handlers[job.type];
try {
if (!handler) throw new Error(`没有注册处理器: ${job.type}`);
await handler((job.payload || {}) as Record<string, unknown>);
await db.update(jobs).set({ status: "done", updatedAt: new Date() }).where(eq(jobs.id, job.id));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const failed = job.attempts >= job.max_attempts;
const backoff = new Date(Date.now() + Math.min(30 * 60_000, 30_000 * 2 ** job.attempts));
await db
.update(jobs)
.set({ status: failed ? "failed" : "pending", lastError: msg, runAt: backoff, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
console.error(`[worker] job ${job.id} (${job.type}) ${failed ? "最终失败" : "重试中"}: ${msg}`);
}
}
/** # 函数 Worker 主循环 */
export async function runWorkerLoop(stopAfterMs = Number(process.env.WORKER_MAX_MS || 0)) {
const started = Date.now();
console.log("[worker] 启动,轮询间隔 5s");
while (true) {
try {
const job = await claimJob();
if (job) {
await processJob(job);
continue; // 立即看下一个
}
} catch (e) {
console.error("[worker] 轮询错误:", e);
}
if (stopAfterMs && Date.now() - started > stopAfterMs) break;
await Bun.sleep(5000);
}
}
// 直接运行时进入主循环
if (import.meta.main) {
runWorkerLoop().catch((e) => {
console.error("[worker] 致命错误:", e);
process.exit(1);
});
}
+86
View File
@@ -0,0 +1,86 @@
/**
* # 模块 全局请求中间件:旧链接 301/302 重定向
* 在 router 请求阶段查 redirects 表:先精确匹配(findRedirect),
* 再对 is_regex 的条目做正则匹配;命中即返回 301/302 Response。
* 正则条目内存缓存 60 秒,避免每请求全表扫描。
*/
import { createMiddleware } from "@tanstack/react-start";
import { db } from "~/db";
import { redirects } from "~/db/schema";
import { and, eq, sql } from "drizzle-orm";
import { hasPublishedSlug, resolveRedirect } from "./data";
/** # 类型 已启用的正则重定向条目(缓存行) **/
type RegexEntry = { source: string; target: string; code: number };
/** # 成员变量 正则重定向缓存 **/
let regexCache: { entries: RegexEntry[]; fetchedAt: number } | null = null;
const REGEX_CACHE_TTL = 60_000;
/** # 函数 读取启用的正则重定向条目(带缓存) */
async function getRegexEntries(): Promise<RegexEntry[]> {
if (regexCache && Date.now() - regexCache.fetchedAt < REGEX_CACHE_TTL) return regexCache.entries;
const rows = await db
.select({ source: redirects.source, target: redirects.target, code: redirects.code })
.from(redirects)
.where(and(eq(redirects.isRegex, true), eq(redirects.enabled, true)));
regexCache = { entries: rows, fetchedAt: Date.now() };
return rows;
}
/** # 函数 命中计数(fire-and-forget,不阻塞响应) */
function bumpHits(id: number) {
db.update(redirects)
.set({ hits: sql`${redirects.hits} + 1` })
.where(eq(redirects.id, id))
.catch(() => {});
}
/** # 函数 请求重定向中间件
* 仅处理 router 类型请求;路径先带斜杠再无斜杠各查一次精确匹配。
*/
export const redirectMiddleware = createMiddleware({ type: "request" }).server(async ({ request, pathname, next, handlerType }) => {
// server function RPC 与静态资源不查重定向
if (handlerType !== "router") return next();
const path = decodeURI(pathname).replace(/\/+$/, "") || "/";
// 1) 链式解析(原样/规范化/带斜杠变体依次匹配,自指规则跳过防死循环)。
// 内容优先:路径末段能命中已发布内容时直接出内容不重定向(与 sitemap 的
// canonical 一致;导入的重定向表存在 A→B 且 B→A 的矛盾规则对,内容优先可破环)
const resolved = await resolveRedirect(path);
if (resolved) {
const lastSeg = path.split("/").filter(Boolean).pop() ?? "";
if (!(await hasPublishedSlug(decodeURIComponent(lastSeg)).catch(() => false))) {
return toRedirect(request, resolved.target, resolved.code, resolved.id);
}
}
// 2) 正则匹配(目标里的 $1..$9 替换为捕获组;部分条目匹配带查询串的完整路径)
const search = new URL(request.url).search;
const full = path + search;
const entries = await getRegexEntries();
for (const entry of entries) {
try {
const re = new RegExp(entry.source);
const m = re.exec(path) ?? re.exec(pathname) ?? re.exec(full);
if (m) return toRedirect(request, entry.target, entry.code, undefined, m);
} catch {
// 非法正则跳过
}
}
return next();
});
/** # 函数 构造重定向响应(相对目标解析为绝对 URL;$n 替换捕获组) */
function toRedirect(request: Request, target: string, code: number, id?: number, match?: RegExpExecArray): Response {
let dest = target;
if (match) {
dest = target.replace(/\$(\d)/g, (_, d: string) => match[Number(d)] ?? "");
}
if (id) bumpHits(id);
const abs = new URL(dest, request.url).toString();
const headers = new Headers({ location: abs });
return new Response(null, { status: code, headers });
}
+43
View File
@@ -0,0 +1,43 @@
/**
* # 函数 WordPress phpass(Portable Hash)密码校验
* 存量 WooCommerce 客户的密码是 $P$ 开头的 phpass 哈希;
* 迁移后不重置用户密码,登录时先用本函数校验,成功后透明升级为 bcrypt。
* 实现:MD5 迭代(iter + 1 次),与 PHP phpass 算法一致。
*/
const ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/** # 函数 phpass 编码 64 进制(内部用) */
function encode64(input: Buffer, count: number): string {
let out = "";
let i = 0;
do {
const value = input[i]!;
out += ITOA64[value & 0x3f];
i++;
if (i < count) out += ITOA64[((input[i - 1]! << 8) | input[i]!) & 0x3f];
i++;
if (i < count) out += ITOA64[(input[i - 1]! >> 4) & 0x3f];
i++;
if (i < count) out += ITOA64[((input[i - 1]! << 4) | input[i]!) & 0x3f];
i++;
} while (i < count);
return out;
}
/** # 函数 校验明文密码是否匹配 phpass 哈希
* 参数 hash:$P$ 开头的 WordPress 密码哈希
* 返回值 匹配返回 true
*/
export async function phpassVerify(password: string, hash: string): Promise<boolean> {
if (!hash.startsWith("$P$")) return false;
const countLog2 = ITOA64.indexOf(hash[3]!);
if (countLog2 < 7 || countLog2 > 30) return false;
const count = 1 << countLog2;
const salt = hash.slice(4, 12);
// 函数内动态导入:顶层 import 会在客户端路由树求值时崩溃(node:crypto 被
// externalize),服务端运行时由 Bun 原生解析
const { createHash } = await import("node:crypto");
let h = createHash("md5").update(salt + password).digest();
for (let i = 0; i < count; i++) h = createHash("md5").update(h).update(password).digest();
return encode64(h, h.length) === hash.slice(12);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* # 函数 多语言模块(GTranslate 功能等价)
* 三层数据模型:
* 1. 默认语言(en):contents/products 正本。
* 2. 真实翻译:content_translations / product_translations(人工审核后发布,独立 slug/URL/SEO)。
* 3. 机翻层:GTranslate 存量的 104 种语言按 /<lang>/<path>/ URL 动态渲染;
* 仅返回壳层翻译(导航/按钮等 UI 字符串),正文回退默认语言并标注,不生成伪语言静态页。
*/
import { db } from "~/db";
import { languages, contentTranslations, productTranslations, contents, products, media } from "~/db/schema";
import { and, eq, asc } from "drizzle-orm";
/** # 成员变量 机翻语言常用 UI 字符串(壳层翻译,未收录的回退英文) */
const UI_STRINGS: Record<string, Record<string, string>> = {
"zh-CN": { "Get a Quote": "获取报价", "Search": "搜索", "Contact Us": "联系我们", "View Product": "查看产品", "Read More": "阅读更多", "Home": "首页", "Products": "产品", "Blog": "博客", "Shop": "商城", "Cart": "购物车", "Checkout": "结账", "Inquiry Form": "询盘表单", "Send Inquiry": "发送询盘", "Name": "姓名", "Email": "邮箱", "Phone": "电话", "Company": "公司", "Country": "国家", "Message": "留言", "Related Products": "相关产品", "Specifications": "规格参数" },
"zh-TW": { "Get a Quote": "獲取報價", "Search": "搜索", "Contact Us": "聯繫我們", "View Product": "查看產品", "Home": "首頁", "Products": "產品", "Blog": "博客" },
de: { "Get a Quote": "Angebot einholen", "Search": "Suche", "Contact Us": "Kontakt", "View Product": "Produkt ansehen", "Home": "Startseite", "Products": "Produkte", "Blog": "Blog" },
fr: { "Get a Quote": "Obtenir un devis", "Search": "Recherche", "Contact Us": "Contactez-nous", "View Product": "Voir le produit", "Home": "Accueil", "Products": "Produits", "Blog": "Blog" },
es: { "Get a Quote": "Solicitar presupuesto", "Search": "Buscar", "Contact Us": "Contáctenos", "View Product": "Ver producto", "Home": "Inicio", "Products": "Productos", "Blog": "Blog" },
ja: { "Get a Quote": "見積りを依頼", "Search": "検索", "Contact Us": "お問い合わせ", "View Product": "製品を見る", "Home": "ホーム", "Products": "製品", "Blog": "ブログ" },
ru: { "Get a Quote": "Запросить предложение", "Search": "Поиск", "Contact Us": "Связаться", "View Product": "Открыть продукт", "Home": "Главная", "Products": "Продукты", "Blog": "Блог" },
};
/** # 函数 获取启用的语言列表(语言切换器用) */
export async function getEnabledLanguages() {
return db.select().from(languages).where(eq(languages.enabled, true)).orderBy(asc(languages.sort));
}
/** # 函数 获取机翻 UI 字符串表
* 参数 lang:语言代码
* 返回值 字符串映射(未知语言返回空表,前端回退英文)
*/
export function getUiStrings(lang: string): Record<string, string> {
return UI_STRINGS[lang] || UI_STRINGS[lang.split("-")[0]!] || {};
}
/** # 函数 解析 URL 中的语言前缀
* 参数 segments:路径段数组
* 返回值 { lang, rest };lang=null 表示默认语言
*/
export function parseLangPrefix(segments: string[]): { lang: string | null; rest: string[] } {
if (segments.length === 0) return { lang: null, rest: segments };
const first = segments[0]!;
if (first === "en") return { lang: null, rest: segments.slice(1) };
const langPattern = /^(?:[a-z]{2,3}|zh-CN|zh-TW|pt-BR|es-419)$/i;
if (langPattern.test(first)) return { lang: first.toLowerCase(), rest: segments.slice(1) };
return { lang: null, rest: segments };
}
/** # 函数 读取内容的真实翻译(已发布状态)
* 返回值 翻译版本或 null(缺翻译时不生成伪语言页面,由调用方回退)
*/
export async function getPublishedContentTranslation(contentId: number, lang: string) {
const [row] = await db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.contentId, contentId), eq(contentTranslations.lang, lang), eq(contentTranslations.status, "published")));
return row ?? null;
}
/** # 函数 读取产品的真实翻译(已发布状态) */
export async function getPublishedProductTranslation(productId: number, lang: string) {
const [row] = await db
.select()
.from(productTranslations)
.where(and(eq(productTranslations.productId, productId), eq(productTranslations.lang, lang), eq(productTranslations.status, "published")));
return row ?? null;
}
/** # 函数 保存/更新内容翻译(后台用;状态 draft|review|published) */
export async function saveContentTranslation(input: {
contentId: number;
lang: string;
slug: string;
title: string;
excerpt?: string;
html?: string;
seoTitle?: string;
seoDescription?: string;
status: string;
}) {
await db
.insert(contentTranslations)
.values({
contentId: input.contentId,
lang: input.lang,
slug: input.slug,
title: input.title,
excerpt: input.excerpt || "",
html: input.html || "",
seoTitle: input.seoTitle || null,
seoDescription: input.seoDescription || null,
status: input.status,
})
.onConflictDoUpdate({
target: [contentTranslations.contentId, contentTranslations.lang],
set: {
slug: input.slug,
title: input.title,
excerpt: input.excerpt || "",
html: input.html || "",
seoTitle: input.seoTitle || null,
seoDescription: input.seoDescription || null,
status: input.status,
updatedAt: new Date(),
},
});
// 原文更新提醒:若正文与默认语言最近修订差异大,标记 review(简化:发布时强制需要 status=published 显式传入)
return { ok: true as const };
}
/** # 函数 hreflang 交替链接(SEO 模块用)
* 返回值 [{lang, url}],含 x-default 指向默认语言
*/
export async function getHreflangAlternates(path: string) {
const alternates: { lang: string; url: string }[] = [{ lang: "x-default", url: `https://dusuniot.com${path}` }, { lang: "en", url: `https://dusuniot.com${path}` }];
const enabled = await db.select({ code: languages.code }).from(languages).where(eq(languages.enabled, true));
for (const l of enabled) {
if (l.code === "en") continue;
// 真实翻译已发布 → 输出该语言 URL;仅机翻 → 输出 GTranslate 风格前缀 URL
alternates.push({ lang: l.code, url: `https://dusuniot.com/${l.code}${path === "/" ? "/" : path}` });
}
return alternates;
}
/** # 函数 多语言 Sitemap 条目 */
export async function getSitemapTranslationEntries() {
const enabled = await db.select({ code: languages.code }).from(languages).where(eq(languages.enabled, true));
const machine = await db.select({ code: languages.code }).from(languages).where(and(eq(languages.machine, true), eq(languages.enabled, false)));
return { enabled: enabled.map((l) => l.code), machine: machine.map((l) => l.code) };
}