未命名 2
这种架构设计的核心挑战在于:分清哪些是“静态资产(Asset)”,哪些是“展示行为(Display)”。
你之前之所以容易犯错,是因为在设计表结构时,习惯性地想把“产品”和“分类”连在一起,却忽略了在多租户、多站点系统中,“物理上的产品”和“网页上的分类”之间其实隔着一个“经营权(上架)”的概念。
为了防止以后再掉进这个坑,建议你采用 “三层剥离法” 来思考所有的表设计:
1. 第一层:原子资产层 (Source of Truth)
这一层的数据是物理存在的,不随你在哪个网站卖而改变。
-
代表表:
Product(SPU),SKU,Media,MasterCategory(后端主分类)。 -
设计原则:只记录产品本身的属性(重量、尺寸、材质、物理编码)。它不应该知道任何关于
Site(站点)或SiteCategory(前端分类)的信息。 -
你的错误点回顾:曾经想把
Product直接关联到SiteCategory。
2. 第二层:经营/映射层 (Mapping Layer)
这是最关键的一层,也是你最容易漏掉的一层。它决定了资产如何进入业务流程。
-
代表表:
SiteProduct(站点商品),SiteSku(站点规格)。 -
核心逻辑:
-
Product+Site=SiteProduct(这就是“上架”动作)。 -
一旦有了
SiteProduct,你就可以在这里**覆写(Override)**信息。比如:物理产品叫“iPhone 15”,但在东南亚站你想叫“Apple iPhone 15 Pro Max”,这个名字就该存在SiteProduct里。
-
-
设计原则:它是“资产”与“站点”之间的桥梁。
3. 第三层:展示/交互层 (Presentation Layer)
这一层纯粹是为了给前端用户看的。
-
代表表:
SiteCategory(站点分类),Navigation(导航),HeroCard(轮播)。 -
设计原则:它必须只关联“经营层”的数据,永远不要直接跳过第二层去关联第一层。
-
你的修正点:
SiteProductCategoryTable应该关联SiteProduct,而不是Product。
给你的“防错检查”清单
当你下次新建一张表时,问自己三个问题:
-
数据的生命周期是什么?
- 如果删掉一个站点,这行数据应该消失吗?如果应该,它就不属于“资产层”,必须关联
SiteId。
- 如果删掉一个站点,这行数据应该消失吗?如果应该,它就不属于“资产层”,必须关联
-
是否允许“同物不同价/不同名”?
- 如果同一个产品在不同地方有不同的表现,你绝对不能直接修改
Product表,必须通过SiteProduct这种中间映射表。
- 如果同一个产品在不同地方有不同的表现,你绝对不能直接修改
-
这个关联是“硬关联”还是“逻辑关联”?
-
Product关联MasterCategory是硬关联(手机永远是电子产品)。 -
Product关联SiteCategory是逻辑关联(今天在“新品”分类,明天在“促销”分类),逻辑关联必须通过映射层 ID(SiteProductId)来实现。
-
总结
你现在的架构演进方向是:
Product (物理) $\rightarrow$ SiteProduct (经营) $\rightarrow$ SiteProductCategoryRel (表现) $\leftarrow$ SiteCategory (表现)
这种设计虽然多了一张中间表,但它带来的解耦能力是无限的。哪怕你以后要做:
-
同一个产品在不同站点有不同库存。
-
同一个产品在不同站点展示不同图片。
-
同一个产品在 A 站上架,在 B 站下架。
你现在的架构都能直接接住,而不需要再改表结构。这就是所谓的“架构弹性”。
你现在感觉这套层级逻辑清晰一些了吗?需不需要我帮你把目前最核心的几张表按照这个层级重新排版梳理一遍?
太棒了。我们直接把这套逻辑落地。为了让你彻底告别“逻辑纠缠”,我将你当前的业务结构重组为三个清晰的逻辑层。
你可以对照这张架构图来理解:
第一层:原子资产层 (Core Assets)
特征:这些数据是企业的“硬资产”,不属于任何特定站点。它们描述的是“物”本身。
-
tenantTable: 最高的容器(公司)。 -
productTable: 物理产品(SPU)。记录:物理尺寸、重量、材质。 -
skuTable: 物理规格。记录:条码、出厂成本价、物理规格 JSON。 -
mediaTable: 原始素材池。所有图片、视频的源文件。 -
masterCategoryTable: 工业/标准分类。例如:化工、机械。
第二层:经营/映射层 (Site Operations)
特征:这是你的“上架”逻辑。它决定了哪些资产以什么样的形态出现在哪个站点。
-
siteTable: 站点的定义。 -
siteProductTable: 核心桥梁。-
作用:将
Product引入Site。 -
覆写:站点专有的 SEO 标题、站点专有的产品描述。
-
-
siteSkuTable: 价格控制中心。-
作用:将
SKU引入Site。 -
覆写:决定这个规格在 A 站卖多少钱,在 B 站卖多少钱。
-
第三层:展示/交互层 (Customer Presentation)
特征:这一层完全是“网页装修”。它只和“经营层”的数据打交道,不直接碰物理资产。
-
siteCategoryTable: 站点的装修分类。例如:“2024 夏季新品”、“清仓区”。 -
siteProductCategoryRel: 最终连接器。- 逻辑:它连接的是
siteProductTable.id和siteCategoryTable.id。
- 逻辑:它连接的是
-
heroCardTable/adTable: 站点的轮播图和广告。它们直接关联siteId。
修正后的核心代码清单
为了确保你不再犯错,请务必按照以下关联关系更新你的 Drizzle Schema:
1. 彻底解耦的站点商品关联
TypeScript
// 站点商品 - 站点分类 中间表
export const siteProductCategoryRel = p.pgTable("site_product_category_rel", {
// 注意:这里引用的全是“经营层”的 ID
siteProductId: p.uuid("site_product_id").notNull().references(() => siteProductTable.id, { onDelete: "cascade" }),
siteCategoryId: p.uuid("site_category_id").notNull().references(() => siteCategoryTable.id, { onDelete: "cascade" }),
}, (t) => [
p.primaryKey({ columns: [t.siteProductId, t.siteCategoryId] })
]);
2. 彻底解耦的站点规格关联
TypeScript
// 站点 SKU
export const siteSkuTable = p.pgTable("site_sku", {
id: idUuid,
siteId: p.uuid("site_id").notNull().references(() => siteTable.id),
// 关联经营层商品,而不是物理产品
siteProductId: p.uuid("site_product_id").notNull().references(() => siteProductTable.id, { onDelete: "cascade" }),
// 关联物理 SKU 拿基础属性
skuId: p.uuid("sku_id").notNull().references(() => skuTable.id),
price: p.decimal("price", { precision: 10, scale: 2 }), // 站点定价
isActive: p.boolean("is_active").default(true),
});
避坑黄金法则 (The Golden Rule)
永远不要在 productTable 或 skuTable 里面添加 site_id 字段。
永远不要在 siteCategoryTable 或 inquiryTable 里面直接添加 product_id 字段(应使用 site_product_id 或做快照)。
如果你发现自己正在给一个“资产表”加“站点 ID”,或者给一个“展示表”连“物理 ID”,停下来!你正在制造未来的 Bug。
你想让我帮你把这些核心表的 indices(索引)按照这个新架构重新优化一下吗?这样可以确保前台查询(比如按分类选商品)速度最快。