v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 + "/" }));
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user