feat: 方案设计、数据库和接口设计基本完成
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# PostgreSQL 角色与 RLS 执行约束
|
||||
|
||||
## 1. 目标
|
||||
|
||||
数据库迁移和日常应用访问必须使用不同的登录身份,避免应用连接碰巧成为表所有者而静默绕过工作空间 RLS。
|
||||
|
||||
| 身份 | 用途 | 表所有权 | 日常数据访问 |
|
||||
|---|---|---|---|
|
||||
| `atlas_migrator` | 执行 schema migration | 拥有 `atlas` schema 和其中的表 | 仅部署时使用 |
|
||||
| `atlas_app_login` | API、BullMQ Worker 连接数据库 | 不拥有任何业务表 | 通过 `atlas_app` 获得最小 DML 权限,受 RLS 限制 |
|
||||
| `atlas_app` | 无登录的权限组角色 | 不拥有任何业务表 | 承载 API/Worker 的表和序列权限 |
|
||||
|
||||
`atlas_app_login` 和 `atlas_app` 都必须是 `NOBYPASSRLS`,也不能是 PostgreSQL 超级用户。生产环境不得把 `atlas_migrator` 的连接串写入 API 或 Worker 的环境变量。
|
||||
|
||||
## 2. 一次性数据库初始化
|
||||
|
||||
以下由云数据库管理员或基础设施脚本执行;它不属于 NestJS 的业务 migration,因为托管 PostgreSQL 常不允许应用账户创建角色。密码应从密钥管理服务注入,示例中不填写真实密码。
|
||||
|
||||
```sql
|
||||
CREATE ROLE atlas_migrator LOGIN NOINHERIT NOBYPASSRLS PASSWORD '<managed-secret>';
|
||||
CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS;
|
||||
CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD '<managed-secret>';
|
||||
|
||||
GRANT atlas_app TO atlas_app_login;
|
||||
CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator;
|
||||
|
||||
GRANT USAGE ON SCHEMA atlas TO atlas_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA atlas TO atlas_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA atlas TO atlas_app;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO atlas_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO atlas_app;
|
||||
```
|
||||
|
||||
首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。
|
||||
|
||||
## 3. RLS 的运行方式
|
||||
|
||||
迁移为每张带 `workspace_id` 的业务表执行:
|
||||
|
||||
```sql
|
||||
ALTER TABLE atlas.example ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE atlas.example FORCE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
`FORCE` 会让表所有者的普通 `SELECT` / `INSERT` / `UPDATE` / `DELETE` 也遵守策略,避免错误配置在通常情况下被掩盖。PostgreSQL 超级用户和拥有 `BYPASSRLS` 属性的角色仍能绕过它,因此它们只可用于受控运维,不可用于 API 或 Worker。
|
||||
|
||||
API 的数据库事务顺序固定为:验证登录用户与工作空间成员关系 → 开启事务 → `SET LOCAL app.workspace_id = '<workspace UUID>'` → 执行业务 SQL → 提交/回滚。`SET LOCAL` 只在当前事务内有效,连接归还连接池后不会遗留工作空间上下文。
|
||||
|
||||
Worker 没有浏览器会话,也必须先从受控任务记录解析出工作空间,再在每一笔业务事务中设置相同的 `app.workspace_id`。跨工作空间的运维任务使用专门的受控运维连接,不复用应用连接池。
|
||||
|
||||
## 4. 部署检查
|
||||
|
||||
- 使用迁移连接串运行 DDL;使用应用连接串启动 API 与 Worker。
|
||||
- 在 staging 中用应用连接验证:未设置 `app.workspace_id` 时查询不到业务数据;设置 A 工作空间后不能读取 B 的记录。
|
||||
- 检查 `pg_roles.rolbypassrls`,确保应用角色为 `false`;检查 `pg_tables.tableowner`,确保应用角色不是业务表所有者。
|
||||
- 业务服务仍要做成员授权、资源归属和平台/门店校验;RLS 是纵深防御,不是授权逻辑的替代品。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 汇率:数据模型、数据源与更新策略
|
||||
|
||||
> 首期定价只需要 `CNY → RUB`,但表结构按任意法币对设计。汇率是全局参考数据,不属于平台、门店或工作空间。
|
||||
|
||||
## 1. 口径与使用边界
|
||||
|
||||
`exchange_rates.rate` 始终表示:**1 单位 `base_currency_code` 可兑换多少单位 `quote_currency_code`**。例如 CBR 返回 `Nominal=1`、`Value=12.3343` 的 CNY 数据,标准化保存为:
|
||||
|
||||
```text
|
||||
base_currency_code = CNY
|
||||
quote_currency_code = RUB
|
||||
rate = 12.3343
|
||||
```
|
||||
|
||||
这是一种报价参考口径,不等同于卖家实际购汇、物流商结算或提现的成交汇率。因此定价保留三种来源:
|
||||
|
||||
| 报价来源 | 读取方式 | 是否写入 `exchange_rates` |
|
||||
|---|---|---|
|
||||
| 系统汇率 | 读取当前有效的正式参考汇率 | 是。首期为 CBR 的 `official_reference`。 |
|
||||
| 方案固定汇率 | 读取 `pricing_policies.fixed_cny_to_rub_rate` | 否,报价快照记录该固定值。 |
|
||||
| 本次覆盖 | 用户在定价弹窗输入 | 否,报价快照记录覆盖值和原因。 |
|
||||
|
||||
无论哪种来源,`pricing_quotes` 都固化 `fx_cny_to_rub`、`fx_rate_origin`、可空的 `exchange_rate_id` 和完整输入快照。后续系统汇率更新绝不改写已确认报价。
|
||||
|
||||
## 2. 数据库设计
|
||||
|
||||
DDL 位于 [`sql/001_foundation.sql`](./sql/001_foundation.sql) 的 `exchange_rates` 表:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `base_currency_code` / `quote_currency_code` | 币种方向;严禁把 `CNY/RUB` 与 `RUB/CNY` 混用。 |
|
||||
| `rate` | 标准化后的“1 base = rate quote”。使用 `NUMERIC`,不使用浮点数。 |
|
||||
| `kind` | `official_reference`、`market_reference` 或 `manual`。 |
|
||||
| `source` | `cbr`、`exchange_rate_api_open`、`frankfurter`、`manual`。 |
|
||||
| `effective_on` | 数据源声明的适用日期;不是系统抓取日期。 |
|
||||
| `observed_at` / `fetched_at` | 来源观测时间与 Atlas 抓取/录入时间。 |
|
||||
| `source_reference` / `raw_payload` | 可追溯的来源链接、批次说明和原始响应。 |
|
||||
|
||||
同一个“币种方向 + 口径 + 来源 + 适用日期”只能有一条记录。若来源在同一适用日修订汇率,Worker 应在事务中更新原始记录并保留修订审计,或另行建立修订表;首期 CBR 日汇率正常情况下不需要重复插入。
|
||||
|
||||
## 3. 首选数据源:俄罗斯央行 CBR
|
||||
|
||||
首期正式系统汇率使用 CBR 的 CNY 对 RUB 官方参考汇率:
|
||||
|
||||
```text
|
||||
GET https://www.cbr.ru/scripts/XML_daily.asp
|
||||
GET https://www.cbr.ru/scripts/XML_daily.asp?date_req=dd/mm/yyyy
|
||||
```
|
||||
|
||||
- 不传 `date_req` 返回最近一个已发布工作日;传日期可补历史数据。
|
||||
- 解析 XML 中 `CharCode=CNY` 的 `Nominal` 与 `Value`;将逗号小数转换为 `NUMERIC`,按 `Value / Nominal` 标准化。
|
||||
- 记录 `kind=official_reference`、`source=cbr`、来源 XML URL、响应原文、响应对应的适用日期和抓取时间。
|
||||
- CBR 在周末与节假日可能返回上一工作日数据,这是正常结果,不创建重复记录也不标记请求失败。
|
||||
|
||||
CBR 是 Ozon-RU 定价的首选,因为它直接发布 CNY 对 RUB 的官方口径。它不保证等于卖家实际结算汇率,用户仍可选择固定或本次覆盖汇率。
|
||||
|
||||
## 4. 免费备用与未来扩展
|
||||
|
||||
| 服务 | 调用方式 | 适用范围 | 使用规则 |
|
||||
|---|---|---|---|
|
||||
| CBR | `https://www.cbr.ru/scripts/XML_daily.asp` | 首期 CNY→RUB 正式口径 | 主来源。免费、官方、按工作日发布。 |
|
||||
| ExchangeRate-API Open Access | `https://open.er-api.com/v6/latest/CNY` | CNY→多币种可用性备用 | 无 Key、日更、需按其条款归属且有频率限制。只能标记为参考或备用,不能静默覆盖 CBR 正式记录。 |
|
||||
| Frankfurter | `https://api.frankfurter.dev/v2/rate/CNY/RUB` | 后续多币种、历史查询和数据源对照 | 无 Key;默认可能混合多个来源。需要时应记录 provider 元数据,不能把混合口径伪装为 CBR 官方口径。 |
|
||||
|
||||
不要在前端直接调用上述接口,也不要把第三方响应直接当作报价结果。所有请求只由后端 Worker 发起、校验、标准化、持久化后供报价引擎读取。
|
||||
|
||||
## 5. BullMQ 更新与故障策略
|
||||
|
||||
1. 定时 Worker 在俄罗斯工作日每 2 小时请求一次 CBR;不依赖未经确认的固定发布时间。
|
||||
2. 以 `effective_on` 去重:没有新适用日期时直接结束;有新日期时插入一条 `exchange_rates` 记录。
|
||||
3. 每次任务记录 HTTP 状态、解析结果、原始响应摘要和耗时;禁止记录无关敏感信息。
|
||||
4. CBR 请求失败时保留上一条成功汇率并告警;不得自动以备用来源覆盖正式 CBR 数据。
|
||||
5. 若最近 CBR 有效记录距当前超过 72 小时,报价界面显示“系统汇率可能过期”,但仍允许使用上次值、方案固定值或本次覆盖值。
|
||||
6. 定价服务只读取当前有效的 `CNY → RUB + official_reference + cbr` 最新记录;若不存在则不能输出系统汇率报价,应提示选择固定/覆盖汇率或等待更新任务成功。
|
||||
|
||||
## 6. 实施接口边界
|
||||
|
||||
- Worker 内部:`ExchangeRateProvider` 适配器接口,首期实现 `CbrProvider`;备用提供方独立实现,不影响报价公式。
|
||||
- 后台管理:查看当前系统汇率、历史记录、来源、抓取时间、异常与人工录入 `manual` 汇率;人工记录不得冒充 `cbr`。
|
||||
- 定价 API:返回最终汇率、来源、适用日期、抓取时间和是否过期;确认报价时只引用快照,不在请求中重新抓取第三方。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 云端基础模块:数据模型与数据库边界
|
||||
|
||||
> 状态:设计稿。数据库为 PostgreSQL;配套可执行 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。
|
||||
> 状态:设计稿。数据库为 PostgreSQL;基础 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql),采集箱、商品编辑、发布与在线商品 DDL 见 [`sql/002_product-domain.sql`](./sql/002_product-domain.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。
|
||||
|
||||
## 1. 核心边界
|
||||
|
||||
@@ -17,7 +17,7 @@ Logistics provider(全局) ── Rate card ── Product type × transport
|
||||
```
|
||||
|
||||
- **工作空间即租户**:新账户创建一个工作空间,创建者是 `owner`。用户、工作空间、成员关系分离,以支持未来多人协作和一个用户管理多个工作空间。
|
||||
- **平台与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”;每个平台最多一个默认店铺。所有门店业务数据均携带 `workspace_id`。
|
||||
- **平台、市场与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”,且一个门店只能绑定一个平台;每个平台最多一个默认店铺。市场/国家、默认语言、支持语言和支持币种属于平台能力,不属于门店。首期 Ozon 使用平台默认市场 `RU`;未来多市场时由顶部全局上下文选择或使用平台默认值。所有门店业务数据均携带 `workspace_id`。
|
||||
- **两套类目均强绑定平台**:平台商品类目来自 Ozon 等平台的商品分类;平台佣金类目来自该平台的佣金规则树。它们在同一平台内独立维护,并由版本化映射关联;SQL 使用 `platform_id + category_id` 的复合外键阻止跨平台映射,绝不按名称或 ID 猜测。
|
||||
- **CEL 价表是全局规则数据**:由后台维护版本和生效期;报价只引用确定版本。首期仅 Ozon-rFBS + CEL,香港空运不录入首期规则。
|
||||
- **报价是快照**:定价方案可以变,CEL/佣金规则可以更新,但已确认 SKU 报价始终保留当时输入、费率、规则版本和费用拆分。
|
||||
@@ -26,32 +26,39 @@ Logistics provider(全局) ── Rate card ── Product type × transport
|
||||
|
||||
| 领域 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 手机号密码登录;短信验证/风控字段预留但一期不启用。 |
|
||||
| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | API Key 单独加密保存;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 |
|
||||
| 水印 | `media_assets`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 店铺绑定一个当前模板;发布任务未来固化模板版本,而不修改原图。 |
|
||||
| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 邮箱密码登录;首期不校验邮箱,`email_verified_at` 为后续验证预留。手机号、短信验证和风控后续以独立 migration 扩展。 |
|
||||
| 可靠写入与异步投递 | `idempotency_requests`、`outbox_events`、`operations` | 关键写操作按幂等键安全重放;异步业务事务同时写入 Outbox,由 Dispatcher 补投 BullMQ。`operations` 是用户可见的任务状态,不以 Redis 队列作为唯一事实。 |
|
||||
| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | 平台维护支持币种;门店只绑定一个平台,并保存默认计价币种和连接凭据。市场/国家与语言属于待补充的平台能力配置;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 |
|
||||
| 水印与素材 | `media_assets`、`media_upload_intents`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 素材保存 `storage_provider + bucket + object_key` 及元数据,不保存长期访问 URL;浏览器直传先建立持久化上传意图,再校验并落为素材。店铺绑定一个当前模板,发布任务未来固化模板版本,而不修改原图。 |
|
||||
| 类目佣金 | `platform_product_categories`、`commission_categories`、`commission_category_rules`、`platform_product_category_commission_mappings` | 平台商品类目和平台佣金类目都强绑定同一平台;佣金按销售价 RUB 阶梯计费,抓取数据以 `competitor_capture` 来源和待审核版本录入。 |
|
||||
| 跨境物流 | `logistics_providers`、`logistics_rate_cards`、`logistics_product_types`、`logistics_transport_methods`、`logistics_rate_rules` | CEL 产品类型、运输方式、货值/重量/尺寸/抛重规则、费率和版本分开保存。 |
|
||||
| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。 |
|
||||
| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。方案可明确控制“缺少正式佣金规则时是否允许本次临时覆盖”。 |
|
||||
| 汇率 | `exchange_rates` | 全局不可变汇率历史;首期系统汇率为 CBR 的 CNY→RUB 官方参考汇率。固定汇率和本次覆盖仅固化到报价快照。 |
|
||||
|
||||
## 3. 必须由服务端保证的规则
|
||||
|
||||
1. 在每一个请求事务开始后,先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = '<uuid>'`;PostgreSQL RLS 作为第二道隔离。
|
||||
2. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。
|
||||
3. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。
|
||||
4. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。
|
||||
5. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。
|
||||
1. 每个请求事务先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = '<uuid>'`;PostgreSQL RLS 是第二道隔离,不能替代业务授权。
|
||||
2. API 和 Worker 必须使用无表所有权、无 `BYPASSRLS` 属性的专用应用登录角色;迁移只能由表所有者(迁移角色)执行。应用绝不能连接为建表/迁移角色。
|
||||
3. 所有带 `workspace_id` 的业务表同时 `ENABLE` 和 `FORCE ROW LEVEL SECURITY`。这使表所有者的普通数据访问也受策略约束;PostgreSQL 超级用户及显式拥有 `BYPASSRLS` 的角色仍可绕过,不能用于应用连接。
|
||||
4. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。
|
||||
5. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。
|
||||
6. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。
|
||||
7. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。
|
||||
8. 关键可重试写操作使用 `idempotency_requests`;启动异步工作的业务事务同时写入 `outbox_events`,由 Dispatcher 至少一次投递到 BullMQ。完整规则见 [`transactional-outbox-and-idempotency.md`](./transactional-outbox-and-idempotency.md)。
|
||||
|
||||
## 4. 初始数据与迁移顺序
|
||||
|
||||
1. 执行 `001_foundation.sql`。
|
||||
2. 初始化 `OZON` 平台、`CNY` / `RUB` 币种和平台币种支持关系。
|
||||
3. 建立 Ozon 平台商品类目和 Ozon 平台佣金类目树,再人工审核并导入同平台映射与阶梯佣金规则。
|
||||
4. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。
|
||||
5. 商品/SKU 模块落地后,增加 `product_variants` 外键并把 SKU “定价”按钮接到 `pricing_quotes`。
|
||||
4. 配置 CBR CNY→RUB 汇率 Worker,成功写入第一条 `exchange_rates` 后才启用“系统汇率”报价;详见 [`exchange-rates.md`](./exchange-rates.md)。
|
||||
5. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。
|
||||
6. 执行 `002_product-domain.sql`,它会补上 `pricing_quotes.product_variant_id` 的真实外键和 `product_platform_profile_id` 关联。
|
||||
7. 启动 Outbox Dispatcher 与过期幂等记录/已投递事件的清理 Worker;在 staging 按文档模拟投递故障并验证可恢复性。
|
||||
|
||||
## 5. 仍不在此 DDL 内的内容
|
||||
|
||||
- 短信发送、验证码、风控供应商和密码找回流程;
|
||||
- Ozon API 的实际同步 Worker、密钥轮换 Worker 和对象存储上传;
|
||||
- 商品/SPU/SKU、媒体处理任务与真正的上架任务表;
|
||||
- 商品编辑和在线商品页面的具体列表字段、筛选与交互(表结构已预留,需求仍可继续细化);
|
||||
- 平台/物流/佣金规则的后台管理 UI 与导入程序。
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 商品域数据模型与迁移
|
||||
|
||||
> 可执行 PostgreSQL DDL:[`sql/002_product-domain.sql`](./sql/002_product-domain.sql)。必须先执行 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。
|
||||
|
||||
## 1. 两个不能混用的商品域
|
||||
|
||||
```text
|
||||
工作空间
|
||||
├─ 采集箱商品(跨平台 Atlas 草稿)
|
||||
│ collection_items → products → product_variants
|
||||
│ └→ product_platform_profiles(每个平台一份)
|
||||
│ └→ product_variant_platform_listings(每个 SKU 首次导入状态)
|
||||
└─ 在线商品(平台 + 门店独有的同步镜像)
|
||||
platform_product_sync_runs → platform_listings → platform_listing_sync_snapshots
|
||||
```
|
||||
|
||||
`products` / `product_variants` 只服务采集箱商品,保存来源、采购和跨平台可复用资料。`platform_listings` 则是目标平台、目标门店的实际商品镜像;它不能跨平台或跨门店复用,即使它关联了 Atlas SKU。`product_platform_profiles.market_code` 表示当前平台业务上下文,首期取平台默认 `RU`,不从门店派生。
|
||||
|
||||
## 2. 关键关系与状态
|
||||
|
||||
| 表 | 粒度 | 核心约束 |
|
||||
|---|---|---|
|
||||
| `collection_items` | 工作空间的一条采集箱记录 | 来源平台商品按 `workspace + source_platform + source_item_key` 幂等;手动商品无来源平台。`workspaces.collection_item_limit` 默认 1000,触发器在创建或恢复记录时以工作空间行锁保证不超限。 |
|
||||
| `products` / `product_variants` | 采集箱 SPU / SKU | SKU 删除是 Atlas 软删除,不下架任何平台商品。 |
|
||||
| `collection_asset_transfers` | 来源素材转存任务 | 来源 URL 先以持久化任务进入 Worker;成功后才创建 `media_assets` 与商品候选素材。失败可单独重试,不能把未下载素材伪装成资产。 |
|
||||
| `collection_seller_enrichment_observations` | Ozon Seller 本地观察结果 | 记录浏览器同源调用四个网页内部接口后的净化结果或不可用原因;不保存 Cookie、授权头或完整会话信息。 |
|
||||
| `product_platform_profiles` | 采集箱商品 × 目标平台 | 同一采集箱 SPU 在一个平台只有一份配置;A/B 平台的类目、属性、标题、价格和状态独立。 |
|
||||
| `product_variant_platform_listings` | 平台配置 × Atlas SKU | 保存 `offer_id`、首次导入状态、Ozon `product_id`、实际提交时的门店和价格。`imported` 后不可再次从采集箱提交。 |
|
||||
| `platform_listings` | 平台 × 门店 × Ozon 商品 SKU | 以店铺内 Ozon `product_id` / `offer_id` 唯一;保存 `visible`、库存、审核、错误和最新原始响应。 |
|
||||
|
||||
采集箱页面按**当前页头平台**汇总 `product_variant_platform_listings`:无成功导入 SKU 为“未上架”,部分成功为“已部分上架”,全部未删除 SKU 成功为“已上架”。这与 `platform_listings.visible` 完全分离:后者只代表 Ozon 当前是否可购买/可见。
|
||||
|
||||
## 3. 媒体、AI、任务与审计
|
||||
|
||||
- `product_media_candidates` 是来源图、AI 图和手工上传图的候选池。
|
||||
- `product_variant_listing_media` 是当前平台 SKU 最终选图与顺序;Ozon 首期最多 15 张由发布适配器校验,数据库不硬编码该上限,以支持未来平台差异。
|
||||
- `media_derivatives` 记录“编辑图片 → 添加水印”生成的派生资产,固化水印模板版本;原图不被覆盖。
|
||||
- `product_ai_tasks` 保存 AI 文案、类目、套图和尺寸图任务的输入快照、模型信息、输出和错误;`ai_category_suggestions` 保存可供用户确认的类目候选。
|
||||
- `product_publish_jobs` / `product_publish_job_items` 是 BullMQ 之外的持久化发布事实;重试不能仅依赖 Redis/BullMQ 状态。
|
||||
- `platform_product_sync_runs` / `platform_listing_sync_snapshots` 为 Ozon 在线商品全量同步保留运行记录和响应审计。
|
||||
|
||||
## 4. 执行顺序
|
||||
|
||||
1. 在空库依次执行 `001_foundation.sql`、`002_product-domain.sql`。
|
||||
2. 使用 NestJS migration runner 记录迁移版本;生产环境禁止手工在控制台执行。
|
||||
3. 每个请求在成员授权后,在同一事务执行 `SET LOCAL app.workspace_id = '<workspace UUID>'`;API/Worker 使用专用非所有者、无 `BYPASSRLS` 的应用角色。两份迁移对业务表均 `FORCE ROW LEVEL SECURITY`,避免所有者静默绕过。
|
||||
4. 商品发布、图片处理、AI、Ozon 同步均先落事务表,再投递 BullMQ;Worker 应能据表中的未完成任务恢复。
|
||||
@@ -19,14 +19,21 @@ CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_
|
||||
CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown');
|
||||
CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume');
|
||||
CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded');
|
||||
CREATE TYPE exchange_rate_source AS ENUM ('cbr', 'exchange_rate_api_open', 'frankfurter', 'manual');
|
||||
CREATE TYPE exchange_rate_kind AS ENUM ('official_reference', 'market_reference', 'manual');
|
||||
CREATE TYPE fx_rate_origin AS ENUM ('system_rate', 'policy_fixed', 'quote_override');
|
||||
CREATE TYPE operation_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE media_upload_status AS ENUM ('pending', 'uploaded', 'validating', 'ready', 'failed', 'expired');
|
||||
CREATE TYPE idempotency_request_status AS ENUM ('in_progress', 'completed');
|
||||
CREATE TYPE outbox_event_status AS ENUM ('pending', 'dispatching', 'dispatched', 'dead_letter');
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
phone_e164 TEXT NOT NULL UNIQUE CHECK (phone_e164 ~ '^\\+[1-9][0-9]{5,14}$'),
|
||||
email CITEXT NOT NULL UNIQUE CHECK (length(email) <= 254 AND position('@' IN email) > 1),
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
status account_status NOT NULL DEFAULT 'active',
|
||||
phone_verified_at TIMESTAMPTZ,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
@@ -87,6 +94,26 @@ CREATE TABLE platform_currency_support (
|
||||
PRIMARY KEY (platform_id, currency_code)
|
||||
);
|
||||
|
||||
-- Global immutable rate history. rate always means one unit of base_currency_code equals rate units of quote_currency_code.
|
||||
CREATE TABLE exchange_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
base_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
quote_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
rate NUMERIC(24,10) NOT NULL CHECK (rate > 0),
|
||||
kind exchange_rate_kind NOT NULL,
|
||||
source exchange_rate_source NOT NULL,
|
||||
source_reference TEXT,
|
||||
effective_on DATE NOT NULL,
|
||||
observed_at TIMESTAMPTZ,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (base_currency_code <> quote_currency_code),
|
||||
UNIQUE (base_currency_code, quote_currency_code, kind, source, effective_on)
|
||||
);
|
||||
CREATE INDEX ix_exchange_rates_current_lookup
|
||||
ON exchange_rates (base_currency_code, quote_currency_code, kind, effective_on DESC, fetched_at DESC);
|
||||
|
||||
CREATE TABLE stores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
@@ -99,12 +126,16 @@ CREATE TABLE stores (
|
||||
status store_status NOT NULL DEFAULT 'verifying',
|
||||
credential_status credential_status NOT NULL DEFAULT 'unknown',
|
||||
credential_checked_at TIMESTAMPTZ,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
api_key_expires_at TIMESTAMPTZ,
|
||||
product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0),
|
||||
product_limit_checked_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, platform_id),
|
||||
UNIQUE (workspace_id, platform_id, platform_client_id)
|
||||
@@ -153,9 +184,94 @@ CREATE TABLE store_warehouses (
|
||||
UNIQUE (workspace_id, store_id, platform_warehouse_id)
|
||||
);
|
||||
|
||||
-- Durable API-visible operation state. Queue job ids are auxiliary and never the only record.
|
||||
CREATE TABLE operations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL,
|
||||
status operation_status NOT NULL DEFAULT 'queued',
|
||||
resource_type TEXT,
|
||||
resource_id UUID,
|
||||
result JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_operations_workspace_status ON operations (workspace_id, status, created_at DESC);
|
||||
|
||||
-- Durable replay record for authenticated, retryable business writes.
|
||||
-- request_fingerprint is an HMAC of canonical request content, never a raw request body.
|
||||
CREATE TABLE idempotency_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
http_method TEXT NOT NULL CHECK (http_method IN ('POST', 'PUT', 'PATCH', 'DELETE')),
|
||||
request_path TEXT NOT NULL CHECK (request_path ~ '^/api/'),
|
||||
idempotency_key UUID NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
status idempotency_request_status NOT NULL DEFAULT 'in_progress',
|
||||
response_status SMALLINT,
|
||||
response_body JSONB,
|
||||
resource_type TEXT,
|
||||
resource_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '48 hours',
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (
|
||||
(status = 'in_progress' AND completed_at IS NULL AND response_status IS NULL)
|
||||
OR (status = 'completed' AND completed_at IS NOT NULL AND response_status BETWEEN 200 AND 599)
|
||||
),
|
||||
UNIQUE (workspace_id, user_id, http_method, request_path, idempotency_key),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_idempotency_requests_expiry
|
||||
ON idempotency_requests (expires_at);
|
||||
|
||||
-- Transactional Outbox. A dispatcher makes these events available to BullMQ only after their business transaction commits.
|
||||
-- event_payload contains identifiers and immutable snapshots only; it must not contain credentials or access URLs.
|
||||
CREATE TABLE outbox_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
deduplication_key TEXT,
|
||||
status outbox_event_status NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
locked_by TEXT,
|
||||
locked_until TIMESTAMPTZ,
|
||||
dispatched_at TIMESTAMPTZ,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL)
|
||||
OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL)
|
||||
),
|
||||
CHECK (dispatched_at IS NULL OR status = 'dispatched'),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_outbox_events_dispatch
|
||||
ON outbox_events (workspace_id, status, next_attempt_at, created_at)
|
||||
WHERE status IN ('pending', 'dispatching');
|
||||
CREATE INDEX ix_outbox_events_aggregate
|
||||
ON outbox_events (workspace_id, aggregate_type, aggregate_id, created_at DESC);
|
||||
|
||||
CREATE TABLE media_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
storage_provider TEXT NOT NULL, -- local_minio / qiniu_kodo; provider configuration is server-side only.
|
||||
bucket TEXT NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
byte_size BIGINT NOT NULL CHECK (byte_size > 0),
|
||||
@@ -164,10 +280,38 @@ CREATE TABLE media_assets (
|
||||
height_px INTEGER CHECK (height_px IS NULL OR height_px > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, object_key),
|
||||
UNIQUE (workspace_id, storage_provider, bucket, object_key),
|
||||
UNIQUE (workspace_id, checksum_sha256)
|
||||
);
|
||||
|
||||
-- A short-lived, durable record for browser direct uploads. object_key is never returned as public file identity.
|
||||
CREATE TABLE media_upload_intents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
purpose TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
expected_content_type TEXT NOT NULL,
|
||||
expected_byte_size BIGINT NOT NULL CHECK (expected_byte_size > 0),
|
||||
expected_checksum_sha256 TEXT NOT NULL,
|
||||
status media_upload_status NOT NULL DEFAULT 'pending',
|
||||
asset_id UUID,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
CHECK ((status = 'ready') = (asset_id IS NOT NULL)),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (storage_provider, bucket, object_key)
|
||||
);
|
||||
CREATE INDEX ix_media_upload_intents_expiry ON media_upload_intents (status, expires_at);
|
||||
|
||||
CREATE TABLE watermark_fonts (
|
||||
code CITEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
@@ -185,6 +329,7 @@ CREATE TABLE watermark_templates (
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, name)
|
||||
);
|
||||
@@ -206,6 +351,7 @@ CREATE TABLE watermark_template_versions (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, template_id, version),
|
||||
CHECK (
|
||||
(kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL)
|
||||
@@ -415,10 +561,12 @@ CREATE TABLE pricing_policies (
|
||||
other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1),
|
||||
desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0),
|
||||
list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1),
|
||||
allow_commission_override BOOLEAN NOT NULL DEFAULT false,
|
||||
fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')),
|
||||
fixed_cny_to_rub_rate NUMERIC(18,8),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
@@ -455,6 +603,8 @@ CREATE TABLE pricing_quotes (
|
||||
package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0),
|
||||
package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0),
|
||||
fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0),
|
||||
fx_rate_origin fx_rate_origin NOT NULL DEFAULT 'system_rate',
|
||||
exchange_rate_id UUID REFERENCES exchange_rates(id) ON DELETE SET NULL,
|
||||
input_snapshot JSONB NOT NULL,
|
||||
status quote_status NOT NULL DEFAULT 'draft',
|
||||
selected_option_id UUID,
|
||||
@@ -512,12 +662,14 @@ DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'stores', 'store_secrets', 'store_warehouses', 'media_assets',
|
||||
'stores', 'store_secrets', 'store_warehouses', 'operations', 'idempotency_requests', 'outbox_events',
|
||||
'media_assets', 'media_upload_intents',
|
||||
'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings',
|
||||
'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())',
|
||||
table_name
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
-- Atlas Seller Kit: collection box, product editor, publishing and online listing schema
|
||||
-- PostgreSQL 15+. Run once after 001_foundation.sql through the migration tool.
|
||||
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual');
|
||||
CREATE TYPE collection_item_status AS ENUM (
|
||||
'collecting', 'received', 'materializing', 'ready_for_edit',
|
||||
'needs_review', 'failed', 'archived', 'deleted'
|
||||
);
|
||||
CREATE TYPE attribute_scope AS ENUM ('common', 'variant');
|
||||
CREATE TYPE data_confirmation_status AS ENUM ('confirmed', 'ambiguous', 'missing');
|
||||
CREATE TYPE platform_variant_listing_status AS ENUM ('not_listed', 'submitting', 'imported', 'failed');
|
||||
CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail', 'ai_generated', 'manual_upload');
|
||||
CREATE TYPE media_derivative_kind AS ENUM ('watermark');
|
||||
CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE seller_enrichment_observation_status AS ENUM ('observed', 'unavailable', 'failed');
|
||||
CREATE TYPE ai_task_kind AS ENUM ('copy', 'category_suggestion', 'image_suite', 'dimension_image');
|
||||
CREATE TYPE publish_job_status AS ENUM ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE publish_job_item_status AS ENUM ('queued', 'submitting', 'polling', 'imported', 'failed', 'skipped');
|
||||
CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync');
|
||||
CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
|
||||
-- Default can later be changed by subscription/operations policy without changing collection-item rows.
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0);
|
||||
|
||||
-- A collection item is workspace-wide and intentionally has no target platform or store.
|
||||
CREATE TABLE collection_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_kind collection_source_kind NOT NULL,
|
||||
source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||
source_item_key TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
source_title TEXT,
|
||||
source_currency_code CHAR(3) REFERENCES currencies(code),
|
||||
source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0),
|
||||
source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= 0),
|
||||
note TEXT,
|
||||
status collection_item_status NOT NULL DEFAULT 'received',
|
||||
raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
parser_version TEXT,
|
||||
collected_at TIMESTAMPTZ,
|
||||
last_error JSONB,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (
|
||||
(source_kind = 'platform' AND source_platform_id IS NOT NULL)
|
||||
OR (source_kind = 'manual' AND source_platform_id IS NULL)
|
||||
),
|
||||
CHECK (source_max_price IS NULL OR source_min_price IS NULL OR source_max_price >= source_min_price),
|
||||
CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_collection_items_active_source_identity
|
||||
ON collection_items (workspace_id, source_platform_id, source_item_key)
|
||||
WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX ix_collection_items_workspace_list
|
||||
ON collection_items (workspace_id, updated_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ix_collection_items_workspace_source_status
|
||||
ON collection_items (workspace_id, source_platform_id, status, updated_at DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Locking the one workspace row serializes only collection-box creates/restores in that workspace.
|
||||
CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
configured_limit INTEGER;
|
||||
active_count INTEGER;
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL)
|
||||
OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||
SELECT collection_item_limit INTO configured_limit
|
||||
FROM atlas.workspaces
|
||||
WHERE id = NEW.workspace_id
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT count(*) INTO active_count
|
||||
FROM atlas.collection_items
|
||||
WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||
|
||||
IF active_count >= configured_limit THEN
|
||||
RAISE EXCEPTION 'collection item limit (%) reached for workspace %', configured_limit, NEW.workspace_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER enforce_collection_item_limit_before_write
|
||||
BEFORE INSERT OR UPDATE OF deleted_at ON collection_items
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit();
|
||||
|
||||
|
||||
-- products and product_variants are Atlas collection-box drafts, not online products.
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
source_title TEXT,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
barcode TEXT,
|
||||
purchase_url TEXT,
|
||||
supply_note TEXT,
|
||||
default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0),
|
||||
default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0),
|
||||
default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0),
|
||||
default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0),
|
||||
default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0),
|
||||
default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0),
|
||||
default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0),
|
||||
default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0),
|
||||
physical_spec_status data_confirmation_status NOT NULL DEFAULT 'missing',
|
||||
package_spec_status data_confirmation_status NOT NULL DEFAULT 'missing',
|
||||
version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, collection_item_id)
|
||||
REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, collection_item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE product_variants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
source_variant_key TEXT,
|
||||
display_name TEXT,
|
||||
source_sku TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0),
|
||||
specification_values JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0),
|
||||
physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0),
|
||||
physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0),
|
||||
net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0),
|
||||
package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0),
|
||||
package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0),
|
||||
package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0),
|
||||
shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0),
|
||||
physical_spec_status data_confirmation_status,
|
||||
package_spec_status data_confirmation_status,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_variants_source_key
|
||||
ON product_variants (workspace_id, product_id, source_variant_key)
|
||||
WHERE source_variant_key IS NOT NULL;
|
||||
CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Source image URLs are untrusted remote inputs. Transfer state exists before a media_asset can be created.
|
||||
CREATE TABLE collection_asset_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
role media_candidate_role NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
referer_url TEXT,
|
||||
source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
asset_id UUID,
|
||||
error_payload JSONB,
|
||||
task_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
CHECK ((status = 'succeeded') = (asset_id IS NOT NULL)),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
UNIQUE (workspace_id, task_key)
|
||||
);
|
||||
CREATE INDEX ix_collection_asset_transfers_workspace_status
|
||||
ON collection_asset_transfers (workspace_id, status, created_at DESC);
|
||||
|
||||
-- Sanitized results from Ozon Seller web local-observation calls. No Cookie or authorization data is stored.
|
||||
CREATE TABLE collection_seller_enrichment_observations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
interface_code TEXT NOT NULL,
|
||||
status seller_enrichment_observation_status NOT NULL,
|
||||
sanitized_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
unavailable_reason TEXT,
|
||||
parser_version TEXT,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
CHECK ((status = 'observed') = (unavailable_reason IS NULL)),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_seller_enrichment_observations_collection
|
||||
ON collection_seller_enrichment_observations (workspace_id, collection_item_id, observed_at DESC);
|
||||
|
||||
-- One collection draft can have a separate profile for every target platform.
|
||||
CREATE TABLE product_platform_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
target_store_id UUID NOT NULL,
|
||||
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
platform_product_category_id UUID,
|
||||
platform_type_id TEXT,
|
||||
category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')),
|
||||
version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, target_store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, platform_id),
|
||||
UNIQUE (workspace_id, product_id, platform_id)
|
||||
);
|
||||
CREATE INDEX ix_product_platform_profiles_store ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC);
|
||||
|
||||
-- Stores every SKU's first-import state in the current platform profile. Imported rows are never reused for re-import.
|
||||
CREATE TABLE product_variant_platform_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
product_variant_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
offer_id TEXT,
|
||||
currency_code CHAR(3) REFERENCES currencies(code),
|
||||
selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0),
|
||||
list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0),
|
||||
pricing_quote_id UUID,
|
||||
status platform_variant_listing_status NOT NULL DEFAULT 'not_listed',
|
||||
platform_product_id TEXT,
|
||||
first_imported_at TIMESTAMPTZ,
|
||||
last_publish_job_item_id UUID,
|
||||
last_error JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id)
|
||||
REFERENCES product_variants(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, profile_id, product_variant_id),
|
||||
CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price),
|
||||
CHECK (
|
||||
(status = 'imported' AND first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL)
|
||||
OR status <> 'imported'
|
||||
)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer
|
||||
ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id)
|
||||
WHERE offer_id IS NOT NULL;
|
||||
CREATE INDEX ix_variant_platform_listings_profile_status
|
||||
ON product_variant_platform_listings (workspace_id, profile_id, status);
|
||||
|
||||
CREATE TABLE product_attributes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
product_variant_id UUID,
|
||||
scope attribute_scope NOT NULL,
|
||||
platform_attribute_id TEXT NOT NULL,
|
||||
complex_id TEXT NOT NULL DEFAULT '0',
|
||||
values JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
language_code TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
confirmation_status data_confirmation_status NOT NULL DEFAULT 'confirmed',
|
||||
raw_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE,
|
||||
CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_attributes_common
|
||||
ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
|
||||
WHERE scope = 'common';
|
||||
CREATE UNIQUE INDEX ux_product_attributes_variant
|
||||
ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
|
||||
WHERE scope = 'variant';
|
||||
|
||||
CREATE TABLE product_media_candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
asset_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
role media_candidate_role NOT NULL,
|
||||
source_url TEXT,
|
||||
source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (workspace_id, product_id, asset_id, role)
|
||||
);
|
||||
|
||||
-- Selected media is platform-SKU-specific because each platform can have different image limits and watermarked variants.
|
||||
CREATE TABLE product_variant_listing_media (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
variant_listing_id UUID NOT NULL,
|
||||
asset_id UUID NOT NULL,
|
||||
source_asset_id UUID,
|
||||
sort_order SMALLINT NOT NULL CHECK (sort_order >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, variant_listing_id, sort_order),
|
||||
UNIQUE (workspace_id, variant_listing_id, asset_id)
|
||||
);
|
||||
|
||||
CREATE TABLE media_derivatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
kind media_derivative_kind NOT NULL,
|
||||
source_asset_id UUID NOT NULL,
|
||||
output_asset_id UUID,
|
||||
watermark_template_version_id UUID,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
task_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
error_payload JSONB,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, watermark_template_version_id)
|
||||
REFERENCES watermark_template_versions(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, task_key),
|
||||
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE product_ai_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID,
|
||||
product_variant_id UUID,
|
||||
kind ai_task_kind NOT NULL,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
provider_code TEXT,
|
||||
model_code TEXT,
|
||||
input_snapshot JSONB NOT NULL,
|
||||
output_payload JSONB,
|
||||
error_payload JSONB,
|
||||
queue_job_id TEXT,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
CREATE INDEX ix_product_ai_tasks_workspace_status ON product_ai_tasks (workspace_id, status, created_at DESC);
|
||||
|
||||
CREATE TABLE ai_category_suggestions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
ai_task_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
platform_product_category_id UUID NOT NULL,
|
||||
platform_type_id TEXT,
|
||||
confidence NUMERIC(5,4) CHECK (confidence BETWEEN 0 AND 1),
|
||||
rationale TEXT,
|
||||
rank SMALLINT NOT NULL CHECK (rank > 0),
|
||||
applied_at TIMESTAMPTZ,
|
||||
applied_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, ai_task_id) REFERENCES product_ai_tasks(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, ai_task_id, rank)
|
||||
);
|
||||
|
||||
-- Durable publish intent and per-SKU Ozon import outcomes. BullMQ job ids are auxiliary, never the only state.
|
||||
CREATE TABLE product_publish_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
status publish_job_status NOT NULL DEFAULT 'queued',
|
||||
idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
queue_job_id TEXT,
|
||||
request_snapshot JSONB NOT NULL,
|
||||
error_payload JSONB,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, idempotency_key),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE product_publish_job_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
publish_job_id UUID NOT NULL,
|
||||
variant_listing_id UUID NOT NULL,
|
||||
status publish_job_item_status NOT NULL DEFAULT 'queued',
|
||||
platform_task_id TEXT,
|
||||
platform_product_id TEXT,
|
||||
request_snapshot JSONB NOT NULL,
|
||||
response_snapshot JSONB,
|
||||
error_payload JSONB,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, publish_job_id) REFERENCES product_publish_jobs(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, publish_job_id, variant_listing_id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
ALTER TABLE product_variant_platform_listings
|
||||
ADD CONSTRAINT fk_variant_listing_last_publish_job_item
|
||||
FOREIGN KEY (last_publish_job_item_id) REFERENCES product_publish_job_items(id) ON DELETE SET NULL;
|
||||
|
||||
-- Online products are a separate, store-owned Ozon mirror. They never become cross-platform collection-box drafts.
|
||||
CREATE TABLE platform_product_sync_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
status platform_sync_status NOT NULL DEFAULT 'queued',
|
||||
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')),
|
||||
queue_job_id TEXT,
|
||||
cursor_started TEXT,
|
||||
cursor_finished TEXT,
|
||||
listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0),
|
||||
upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0),
|
||||
failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0),
|
||||
error_payload JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX ux_platform_product_sync_runs_one_active_per_store
|
||||
ON platform_product_sync_runs (workspace_id, store_id)
|
||||
WHERE status IN ('queued', 'running');
|
||||
|
||||
CREATE TABLE platform_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
remote_product_id TEXT NOT NULL,
|
||||
offer_id TEXT NOT NULL,
|
||||
remote_sku TEXT,
|
||||
name TEXT,
|
||||
currency_code CHAR(3) REFERENCES currencies(code),
|
||||
selling_price NUMERIC(18,4),
|
||||
list_price NUMERIC(18,4),
|
||||
visible BOOLEAN,
|
||||
remote_state TEXT,
|
||||
moderation_status TEXT,
|
||||
validation_status TEXT,
|
||||
stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
image_summary JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error_payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source platform_listing_source NOT NULL,
|
||||
atlas_variant_listing_id UUID,
|
||||
last_sync_run_id UUID,
|
||||
last_synced_at TIMESTAMPTZ NOT NULL,
|
||||
missing_since TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
raw_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, atlas_variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, remote_product_id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, offer_id)
|
||||
);
|
||||
CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC);
|
||||
CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE platform_listing_sync_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
platform_listing_id UUID NOT NULL,
|
||||
sync_run_id UUID NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, platform_listing_id, sync_run_id)
|
||||
);
|
||||
|
||||
-- The foundation migration intentionally created product_variant_id as a logical FK.
|
||||
ALTER TABLE pricing_quotes
|
||||
ADD COLUMN product_platform_profile_id UUID,
|
||||
ADD CONSTRAINT fk_pricing_quotes_product_variant
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_pricing_quotes_product_profile
|
||||
FOREIGN KEY (workspace_id, product_platform_profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE SET NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_row_updated_at() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'product_platform_profiles',
|
||||
'product_variant_platform_listings', 'product_attributes', 'product_variant_listing_media',
|
||||
'product_publish_jobs', 'product_publish_job_items', 'platform_listings'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER set_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_row_updated_at()', table_name, table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Tenant isolation. app_current_workspace_id() is defined in 001_foundation.sql.
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'collection_seller_enrichment_observations', 'product_platform_profiles',
|
||||
'product_variant_platform_listings', 'product_attributes', 'product_media_candidates',
|
||||
'product_variant_listing_media', 'media_derivatives', 'product_ai_tasks', 'ai_category_suggestions',
|
||||
'product_publish_jobs', 'product_publish_job_items', 'platform_product_sync_runs',
|
||||
'platform_listings', 'platform_listing_sync_snapshots'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())',
|
||||
table_name
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -46,7 +46,7 @@ Redis 是高速的临时基础设施;BullMQ 使用 Redis 保存队列中的任
|
||||
|
||||
实现要求:
|
||||
|
||||
1. 所有长任务先在 PostgreSQL 创建任务记录,再在事务提交后投递队列;后续实施时使用 outbox 模式避免“数据库已提交但任务未投递”的遗漏。
|
||||
1. 所有长任务在同一 PostgreSQL 事务中创建业务任务/`operation` 和 `outbox_events`;事务提交后由独立 Dispatcher 投递 BullMQ,避免“数据库已提交但任务未投递”。详见 [`transactional-outbox-and-idempotency.md`](./transactional-outbox-and-idempotency.md)。
|
||||
2. 任务必须幂等:相同 SKU 的一次发布任务重试,不得重复创建平台商品。
|
||||
3. 重试必须有次数上限、指数退避和可见错误;Ozon 等第三方 API 需按店铺/API 限额控制并发。
|
||||
4. Redis 可丢失或重建,Worker 应能根据 PostgreSQL 中的未完成任务恢复投递;业务结果不只存在于 Redis。
|
||||
@@ -102,6 +102,8 @@ workspaces/{workspaceId}/
|
||||
|
||||
不能把生产数据库作为开发数据库,也不建议所有日常开发直接共用同一云端数据库。数据库结构通过迁移文件保持一致;演示数据通过版本化 seed 脚本保持一致。迁移顺序必须是 local → staging → production,并在生产前备份和验证回滚方案。
|
||||
|
||||
数据库权限分为迁移角色与应用角色:迁移角色拥有表并只在部署迁移时使用;API/Worker 的应用登录角色只获业务表所需的 DML 权限,不拥有表且没有 `BYPASSRLS`。RLS 策略使用每个事务设置的 `app.workspace_id`;业务表启用并强制 RLS。具体初始化和权限清单见 [`database-security.md`](./database-security.md)。
|
||||
|
||||
## 6. AI 能力边界
|
||||
|
||||
首期 AI 智能规划、文案和生图均通过 Provider Adapter 调用云模型 API。NestJS 负责权限、上下文组装、结构化结果校验、任务状态和人工确认;模型不得直接改价、发布或修改门店数据。
|
||||
@@ -115,3 +117,9 @@ workspaces/{workspaceId}/
|
||||
首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。
|
||||
|
||||
在规模、可用性或多机部署出现明确瓶颈前,不引入 Kubernetes。
|
||||
|
||||
## 8. Ozon Seller 浏览器会话边界
|
||||
|
||||
一期浏览器插件可以在用户已登录的 `seller.ozon.ru` 标签页内,以同源请求调用已列白名单的四个网页内部接口,保存净化后的观察结果。该模式使用浏览器自动附带的会话 Cookie,但不提取完整 Cookie、不上传、不在 Atlas 保存,也不能交给云端 Worker 执行。
|
||||
|
||||
未来若要支持加密保存浏览器 Cookie,必须作为独立的二级实验能力:按门店验证会话归属,使用信封加密和可撤销的专用密钥记录,任务队列仅传密钥记录 ID,并逐接口限制用途。此类 Cookie 会话不替代官方 Seller API 的发布、库存或状态同步凭据。
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 幂等请求与 Transactional Outbox
|
||||
|
||||
> 状态:已确认的基础设施设计。对应 DDL 位于 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。首期只接入关键写操作和异步链路,不要求所有接口一次完成。
|
||||
|
||||
## 1. 分别解决什么问题
|
||||
|
||||
`idempotency_requests` 防止同一个用户请求被重复执行;典型原因是重复点击、浏览器超时重试或网络重放。它的结果是:同一个写请求只产生一份门店、发布任务或 AI 任务。
|
||||
|
||||
`outbox_events` 防止业务提交与队列投递之间出现空档。它的结果是:即使 API 在数据库提交后、BullMQ 入队前崩溃,Dispatcher 仍能从 PostgreSQL 找到待投递事件并补投。
|
||||
|
||||
两者互补但不互相替代:幂等解决“重复发起”,Outbox 解决“任务漏投递”。`operations`、发布任务、素材转存任务等业务表仍是用户可见的事实来源;Outbox 只是可靠投递机制。
|
||||
|
||||
## 2. 幂等请求的统一规则
|
||||
|
||||
仅对已认证的业务写接口使用本机制;注册、登录、刷新令牌等认证端点采用各自的限流和安全规则,不保存密码相关请求体。
|
||||
|
||||
### 2.1 调用与保存
|
||||
|
||||
客户端为一次逻辑操作生成 UUID 并放入请求头:
|
||||
|
||||
```http
|
||||
Idempotency-Key: 8f421c70-…
|
||||
```
|
||||
|
||||
服务端在事务中以“工作空间、用户、HTTP 方法、规范化实际路径、键”查找或创建 `idempotency_requests`。`request_fingerprint` 是由服务端密钥计算的 HMAC,输入为规范化请求体和影响语义的请求头;不保存明文请求体,也不用普通哈希,以避免低熵敏感输入被离线猜测。
|
||||
|
||||
- 同一键、相同指纹、记录已完成:返回已保存的 HTTP 状态和安全响应体,不重复执行。
|
||||
- 同一键、不同指纹:返回 `409 IDEMPOTENCY_KEY_REUSED`。
|
||||
- 同一键仍在执行:返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`,客户端稍后以同一键重试。
|
||||
- 业务事务回滚:幂等记录一并回滚,客户端可安全重试。
|
||||
|
||||
成功业务事务在写入资源、操作记录和 Outbox 事件后,将幂等记录标为 `completed` 并保存响应摘要。记录至少保留 24 小时;首期 DDL 默认 48 小时,由清理任务在过期后删除。
|
||||
|
||||
## 3. Outbox 的事务与投递
|
||||
|
||||
任何会启动异步工作的业务请求,必须在**同一 PostgreSQL 事务**内完成:
|
||||
|
||||
```text
|
||||
写入业务资源 / operation 或领域任务
|
||||
+
|
||||
写入 outbox_events(待投递)
|
||||
+
|
||||
完成 idempotency_requests(如该接口要求幂等)
|
||||
↓ commit
|
||||
Dispatcher 投递到 BullMQ
|
||||
↓
|
||||
Worker 执行业务工作并更新领域任务/operation
|
||||
```
|
||||
|
||||
`event_payload` 仅保存事件处理所需的 ID、版本或不可变业务快照。Worker 再在受 RLS 约束的数据库事务内读取完整数据;不得把 API Key、Cookie、密码、对象存储永久密钥或预签名访问 URL 放入载荷或错误信息。
|
||||
|
||||
## 4. Dispatcher 与 Worker 规则
|
||||
|
||||
1. Dispatcher 按工作空间处理待投递事件,并在每笔数据库事务中设置 `app.workspace_id`;应用角色不使用 `BYPASSRLS`。
|
||||
2. 领取事件时使用 `FOR UPDATE SKIP LOCKED`,将状态置为 `dispatching`,记录 `locked_by` 和有限的 `locked_until`。
|
||||
3. 成功调用 BullMQ 后标记 `dispatched`;以 `outbox_events.id` 作为 BullMQ `jobId`。若“已入队但尚未来得及标记”时进程崩溃,锁到期后会再次投递,因此投递语义是**至少一次**。
|
||||
4. 投递失败时增加 `attempt_count`,使用指数退避更新 `next_attempt_at` 并恢复 `pending`;超过规定次数时标记 `dead_letter`,保留脱敏错误供运维处理。
|
||||
5. Worker 必须可重入:以领域任务状态、平台侧幂等能力或自己的去重键阻止重复创建平台商品。不得把 BullMQ 的“只执行一次”当作正确性保证。
|
||||
6. 已投递事件不立即删除;保留一段时间用于排障,再由保留期清理任务删除。`idempotency_requests` 过期后也由独立清理任务删除。
|
||||
|
||||
## 5. 首批接入范围
|
||||
|
||||
| 场景 | 幂等 | Outbox | 说明 |
|
||||
|---|---:|---:|---|
|
||||
| 创建/替换门店授权、凭据验证 | 是 | 是 | 避免重复门店和漏掉验证任务。 |
|
||||
| 上传完成后的媒体校验/处理 | 是 | 是 | 防止同一对象重复派生。 |
|
||||
| 采集素材转存 | 是 | 是 | HTTP 接收成功后可恢复执行转存。 |
|
||||
| 首次上架发布 | 是 | 是 | 避免重复发布请求与发布任务丢失。 |
|
||||
| 在线商品全量同步 | 是 | 是 | 同一门店同时只允许一个活跃同步任务。 |
|
||||
| AI 文本、类目或图片任务 | 是 | 是 | 避免重复调用和重复计费。 |
|
||||
| 普通商品草稿保存 | 否(首期) | 否 | 使用 `revision` / `If-Match` 处理并发即可。 |
|
||||
|
||||
## 6. 实施验收
|
||||
|
||||
- 重放同一 `Idempotency-Key` 不产生第二份资源或第二个业务任务,并返回原响应。
|
||||
- 同一键但请求不同返回 `409`;两个并发相同请求仅一个进入业务执行。
|
||||
- 在“业务事务提交后、BullMQ 投递前”模拟进程崩溃,Dispatcher 能补投。
|
||||
- 模拟 BullMQ 已接受任务但 Dispatcher 未标记成功,重复投递不重复产生平台副作用。
|
||||
- 未设置工作空间 RLS 上下文时,应用角色无法读取或投递任一工作空间的 Outbox 事件。
|
||||
Reference in New Issue
Block a user