diff --git a/AGENTS.md b/AGENTS.md index 38aa0f8e..629722d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,9 @@ 2. [docs/README.md](docs/README.md)。 3. 涉及订单、内部 SKU、履约、商品、成本或财务时,必须完整阅读 [docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md)。 -4. 涉及部署、数据库或备份时,再读取对应的运维文档。 -5. 涉及生产升级、数据库迁移、领域模型切换或财务重建时,必须完整阅读 - [docs/生产升级操作手册.md](docs/生产升级操作手册.md)。 -6. `docs/archive/` 只用于追溯,不作为实现依据。 +4. 涉及部署、数据库、备份或生产升级时,先核对 `deploy/` 脚本、`docker-compose*.yml`、 + 迁移文件和当前生产只读事实;不再维护独立运维手册。 +5. `docs/archive/` 只用于追溯,不作为实现依据。 ## 唯一权威 diff --git a/README.md b/README.md index 07bcb60f..2113f8c7 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ docker volume rm order_site_postgres_dev_data docker compose -f docker-compose.dev.yml up -d --build ``` -更多说明见 `docs/部署启动与数据库重建.md`。 +部署与数据库操作的执行入口以 `deploy/` 脚本和上述 Compose 命令为准。 ## 环境变量 @@ -253,7 +253,7 @@ bash deploy/ubuntu-deploy.sh | `--reset-db` | 删除 PostgreSQL 数据卷后按 migrations 全量重建(执行前会自动抓取最后一份备份) | | `--yes` | 跳过 `--reset-db` 的交互确认(等价于 `RESET_DB_CONFIRM=1`) | -`--skip-backup` 也可用环境变量 `SKIP_BACKUP=true bash deploy/ubuntu-deploy.sh` 代替。备份与恢复的完整说明见 `docs/数据库备份与恢复.md`。 +`--skip-backup` 也可用环境变量 `SKIP_BACKUP=true bash deploy/ubuntu-deploy.sh` 代替。备份与恢复命令直接查看 `deploy/backup-db.sh` 和 `deploy/restore-db.sh`。 生产入口示例: diff --git a/apps/backend/src/db/migrations/166_work_order_dynamic_template_routing.sql b/apps/backend/src/db/migrations/166_work_order_dynamic_template_routing.sql new file mode 100644 index 00000000..db348dcf --- /dev/null +++ b/apps/backend/src/db/migrations/166_work_order_dynamic_template_routing.sql @@ -0,0 +1,100 @@ +-- 166_work_order_dynamic_template_routing.sql +-- +-- 接单平台路由只声明“由接单模板匹配”,具体模板必须按真实订单匹配。 +-- 旧版统一渠道配置曾把内部 SKU 名称直接写成 workRuleKey,并生成 +-- tpl:<内部SKU> 草稿商品;包含匹配、SKU 数量系列和店铺绑定因此永远走不到。 +-- +-- 本迁移: +-- 1. 清除不存在模板对应的 workRuleKey 和错误商品关联; +-- 2. 归档统一渠道配置遗留的假 tpl 草稿商品; +-- 3. 用实际接单模板商品补齐正式履约配置; +-- 4. 增加数据库闸门,未来非空 workRuleKey 必须是真实模板 key。 + +WITH invalid_routes AS ( + SELECT route.id + FROM sku_channel_routes route + WHERE route.channel = 'work_order' + AND BTRIM(COALESCE(route.carrier_config_json ->> 'workRuleKey', '')) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM work_product_rules rule + WHERE rule.rule_key = BTRIM(route.carrier_config_json ->> 'workRuleKey') + ) +) +UPDATE sku_channel_routes route +SET carrier_config_json = (route.carrier_config_json - 'workRuleKey') + || '{"templateSelection":"dynamic"}'::jsonb, + canonical_product_id = NULL, + note = CASE + WHEN route.note = '' THEN '迁移 166:清除不存在的接单模板 key' + ELSE LEFT(route.note || ';迁移 166:清除不存在的接单模板 key', 300) + END, + updated_at = NOW() +FROM invalid_routes invalid +WHERE route.id = invalid.id; + +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'archivedAt', NOW(), + 'archiveReason', 'invalid_work_order_draft_product' + ), + updated_at = NOW() +WHERE product.source_kind = 'work_order' + AND product.canonical_sku LIKE 'tpl:%' + AND product.attributes_json ->> 'draft' = 'true' + AND NOT EXISTS ( + SELECT 1 + FROM work_product_rules rule + WHERE rule.canonical_product_id = product.id + ); + +UPDATE fulfillments fulfillment +SET canonical_product_id = rule.canonical_product_id, + updated_at = NOW() +FROM work_orders work_order +JOIN work_product_rules rule ON rule.id = work_order.product_rule_id +WHERE work_order.order_item_id = fulfillment.order_item_id + AND work_order.status <> 'cancelled' + AND rule.canonical_product_id IS NOT NULL + AND fulfillment.canonical_product_id IS DISTINCT FROM rule.canonical_product_id; + +CREATE OR REPLACE FUNCTION assert_work_order_route_template_key() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + template_key TEXT; +BEGIN + IF NEW.channel <> 'work_order' THEN + RETURN NEW; + END IF; + + template_key := BTRIM(COALESCE(NEW.carrier_config_json ->> 'workRuleKey', '')); + IF template_key <> '' AND NOT EXISTS ( + SELECT 1 + FROM work_product_rules rule + WHERE rule.rule_key = template_key + ) THEN + RAISE EXCEPTION + 'work-order route % references missing template key %', + NEW.id, + template_key + USING ERRCODE = '23503', + HINT = 'Leave workRuleKey empty for dynamic template matching or bind an existing rule_key.'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sku_channel_routes_work_order_template_key + ON sku_channel_routes; +CREATE TRIGGER trg_sku_channel_routes_work_order_template_key +BEFORE INSERT OR UPDATE OF channel, carrier_config_json +ON sku_channel_routes +FOR EACH ROW +EXECUTE FUNCTION assert_work_order_route_template_key(); + +COMMENT ON FUNCTION assert_work_order_route_template_key() IS + '接单平台路由的非空 workRuleKey 必须指向真实模板;空值表示按订单动态匹配'; diff --git a/apps/backend/src/deploy-change-detection.test.ts b/apps/backend/src/deploy-change-detection.test.ts index c0383033..7bd1c90f 100644 --- a/apps/backend/src/deploy-change-detection.test.ts +++ b/apps/backend/src/deploy-change-detection.test.ts @@ -61,7 +61,7 @@ test('改动分类:进得了镜像的部署文件要带上对应一端', { ski test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => { assert.equal(classify([]), '') - assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '') + assert.equal(classify(['README.md', 'docs/数据库SQL约束与运维.md']), '') assert.equal( classify([ 'deploy/ubuntu-deploy.sh', diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index c188636b..c78a256f 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -282,14 +282,6 @@ export async function ensureCarrierForOrderItem(input: { const preferredWorkRuleKey = String( channelConfig.rows[0]?.config_json?.workRuleKey || '', ).trim() - if (!preferredWorkRuleKey) { - return { - ready: false, - created: false, - reason: 'work_rule_missing', - message: '接单平台渠道缺少 workRuleKey,请先补全统一 SKU 配置', - } - } const snapshot = parseObject(item.item_snapshot_json) const classification = parseObject(snapshot.classification) const route = parseObject(snapshot.fulfillmentRoute) diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index c332bd69..cd9171bf 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -136,11 +136,9 @@ function resolvePendingReason(item: ChannelCandidateItem): { const carrierIssue = primary.channel === 'manual' && !Number(primaryConfig.manualConfigId || 0) ? 'manual_config_missing' - : primary.channel === 'work_order' && !String(primaryConfig.workRuleKey || '').trim() - ? 'work_rule_missing' - : primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim() - ? 'affiliate_mapping_missing' - : 'carrier_not_attempted' + : primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim() + ? 'affiliate_mapping_missing' + : 'carrier_not_attempted' return { reason: 'no_carrier', detail: { @@ -793,8 +791,11 @@ export async function switchChannelForOrderItems(input: { }) } const targetInternalSku = switchInternalSkus[0]! - const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel) - if (!draftProductId) { + const draftProductId = + input.channel === 'work_order' + ? null + : await ensureDraftProductForChannel(targetInternalSku, input.channel) + if (input.channel !== 'work_order' && !draftProductId) { throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { statusCode: 409, errorCode: 'channel_standard_product_required', @@ -980,6 +981,8 @@ export async function switchChannelForOrderItems(input: { source: 'operator', note: input.note, adminUserId: input.adminUserId, + clearCanonicalProduct: input.channel === 'work_order', + replaceConfig: input.channel === 'work_order', }) } return { @@ -2021,8 +2024,11 @@ export async function assignChannelByInternalSku(input: { ], ) - const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel) - if (!draftProductId) { + const draftProductId = + input.channel === 'work_order' + ? null + : await ensureDraftProductForChannel(internalSku, input.channel) + if (input.channel !== 'work_order' && !draftProductId) { throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { statusCode: 409, errorCode: 'channel_standard_product_required', @@ -2040,6 +2046,8 @@ export async function assignChannelByInternalSku(input: { }, note: input.note, adminUserId: input.adminUserId, + clearCanonicalProduct: input.channel === 'work_order', + replaceConfig: input.channel === 'work_order', }) const ruleCreated = true @@ -2238,6 +2246,7 @@ async function ensureDraftProductForChannel( internalSku: string, channel: FulfillmentChannel, ): Promise { + if (channel === 'work_order') return null const key = canonicalizeInternalSku(internalSku) if (!key) return null const definition = { @@ -2247,7 +2256,6 @@ async function ensureDraftProductForChannel( unit: '件', sourceKind: 'affiliate_dash', }, - work_order: { prefix: 'tpl:', category: '接单', unit: '件', sourceKind: 'work_order' }, manual: { prefix: 'man:', category: '人工', unit: '件', sourceKind: 'manual' }, on_account: { prefix: 'man:', category: '统一结算', unit: '件', sourceKind: 'manual' }, third_party_voucher: { @@ -2342,14 +2350,14 @@ async function resolveInternalSkuCarrierConfig( } } + if (channel === 'work_order') { + // 接单模板按真实订单匹配,不能把内部 SKU 伪造成 workRuleKey。 + // 空配置表示动态匹配;生成工单后再把实际模板商品写回正式履约。 + return { canonicalProductId: null, configJson: { templateSelection: 'dynamic' } } + } + const prefixes = - channel === 'affiliate_dash' - ? ['aff:'] - : channel === 'work_order' - ? ['tpl:', 'wo:'] - : channel === 'on_account' - ? ['man:'] - : ['tpv:'] + channel === 'affiliate_dash' ? ['aff:'] : channel === 'on_account' ? ['man:'] : ['tpv:'] const product = await query<{ id: number; canonical_sku: string }>( `SELECT id, canonical_sku FROM product_catalog @@ -2385,11 +2393,9 @@ async function resolveInternalSkuCarrierConfig( configJson: channel === 'affiliate_dash' ? { affiliateSku: carrierKey } - : channel === 'work_order' - ? { workRuleKey: carrierKey } - : channel === 'on_account' - ? { settlementSku: carrierKey } - : { voucherSku: carrierKey }, + : channel === 'on_account' + ? { settlementSku: carrierKey } + : { voucherSku: carrierKey }, } } @@ -2422,8 +2428,11 @@ export async function upsertChannelRule(input: { }) } - const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel) - if (!draftProductId) { + const draftProductId = + input.channel === 'work_order' + ? null + : await ensureDraftProductForChannel(matchKey, input.channel) + if (input.channel !== 'work_order' && !draftProductId) { throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { statusCode: 409, errorCode: 'channel_standard_product_required', @@ -2443,8 +2452,10 @@ export async function upsertChannelRule(input: { note: String(input.note || '').slice(0, 300), adminUserId: input.adminUserId, replacePriority: true, - canonicalProductId: draftProductId, + canonicalProductId: carrier.canonicalProductId ?? draftProductId, configJson: carrier.configJson, + clearCanonicalProduct: input.channel === 'work_order', + replaceConfig: input.channel === 'work_order', }) return { id } } diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts index 6d01d188..ed389fb7 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -161,6 +161,8 @@ type InternalSkuChannelUpsertInput = { note?: string | undefined adminUserId?: number | null | undefined replacePriority?: boolean + clearCanonicalProduct?: boolean + replaceConfig?: boolean } async function upsertInternalSkuChannelWithExecutor( @@ -202,8 +204,14 @@ async function upsertInternalSkuChannelWithExecutor( END, route_role = EXCLUDED.route_role, enabled = EXCLUDED.enabled, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id), - carrier_config_json = sku_channel_routes.carrier_config_json || EXCLUDED.carrier_config_json, + canonical_product_id = CASE + WHEN $12::boolean THEN NULL + ELSE COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id) + END, + carrier_config_json = CASE + WHEN $13::boolean THEN EXCLUDED.carrier_config_json + ELSE sku_channel_routes.carrier_config_json || EXCLUDED.carrier_config_json + END, source = EXCLUDED.source, note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE sku_channel_routes.note END, created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, sku_channel_routes.created_by_admin_user_id), @@ -222,6 +230,8 @@ async function upsertInternalSkuChannelWithExecutor( String(input.note || '').slice(0, 300), input.adminUserId ?? null, input.replacePriority === true, + input.clearCanonicalProduct === true, + input.replaceConfig === true, ], ) return Number(result.rows[0]?.id || 0) @@ -251,6 +261,8 @@ export async function setInternalSkuPrimaryChannel(input: { configJson?: Record | undefined note?: string | undefined adminUserId?: number | null + clearCanonicalProduct?: boolean + replaceConfig?: boolean }): Promise { const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku) return @@ -267,6 +279,10 @@ export async function setInternalSkuPrimaryChannel(input: { note: input.note, adminUserId: input.adminUserId, replacePriority: true, + ...(input.clearCanonicalProduct === undefined + ? {} + : { clearCanonicalProduct: input.clearCanonicalProduct }), + ...(input.replaceConfig === undefined ? {} : { replaceConfig: input.replaceConfig }), }) } diff --git a/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts index 97fb8ed0..d3b31f9d 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts @@ -123,7 +123,7 @@ test( channel: 'work_order', priority: 10, isFallback: false, - configJson: { workRuleKey: `rule-${suffix}` }, + configJson: { templateSelection: 'dynamic' }, source: 'integration_test', replacePriority: true, }) diff --git a/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts b/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts new file mode 100644 index 00000000..88b33c41 --- /dev/null +++ b/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts @@ -0,0 +1,357 @@ +/** + * 接单平台动态模板匹配集成测试(仅隔离 PostgreSQL)。 + * + * FULFILLMENT_ROUTING_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/services/fulfillment/work-order-dynamic-routing.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim() + +test( + '接单平台空 workRuleKey 走动态匹配,支持包含、SKU 数量系列和真实模板商品', + { skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query, withTransaction } = await import('../../db/client.js') + const { upsertChannelRule, assignPendingOrderItemChannels } = + await import('./channel-assign-service.js') + const { syncWorkerOrdersForSourceOrder } = + await import('../worker-platform/sync-worker-orders-service.js') + const { getOrderById } = await import('../../repositories/order-repo.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const shopId = `it-dynamic-${suffix}` + const internalSkus = [`摄影师约瑟夫-光影【单独紫皮】3个-${suffix}`, `突击兵密钥3个-${suffix}`] + const orderIds: number[] = [] + const orderItemIds: number[] = [] + const ruleIds: number[] = [] + const productIds: number[] = [] + const routeIds: number[] = [] + + async function createOrderItem(internalSku: string, title: string) { + const order = await query<{ id: number }>( + `INSERT INTO orders ( + provider, platform, shop_id, shop_name, platform_order_id, + order_status, pay_status, total_amount, paid_at, created_at, updated_at + ) VALUES ( + '91kaquan', 'kuaishou-91', $1, '动态路由测试店铺', $2, + 'paid', 'paid', 1000, NOW(), NOW(), NOW() + ) RETURNING id`, + [shopId, `IT-DYNAMIC-${suffix}-${orderItemIds.length + 1}`], + ) + const orderId = Number(order.rows[0]!.id) + orderIds.push(orderId) + const item = await query<{ id: number }>( + `INSERT INTO order_items ( + order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ( + $1, $2, $2, 3, $3::jsonb, $3::jsonb, 1000, 'order_total', NOW(), NOW() + ) RETURNING id`, + [ + orderId, + internalSku, + JSON.stringify({ + skuNick: internalSku, + kuaishouSendCode: { + itemTitle: title, + skuNick: internalSku, + sellerId: shopId, + }, + }), + ], + ) + const orderItemId = Number(item.rows[0]!.id) + orderItemIds.push(orderItemId) + return { orderId, orderItemId } + } + + async function createTemplate(input: { + ruleKey: string + productName: string + mappingType: 'product_default' | 'sku_series' + matchValue: string + sellerIds?: string[] + }) { + const product = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, source_kind, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, $2, '接单测试', '件', 'work_order', 'active', + '{"source":"work_product_rule"}'::jsonb, NOW(), NOW() + ) RETURNING id`, + [`tpl:${input.ruleKey}`, input.productName], + ) + const productId = Number(product.rows[0]!.id) + productIds.push(productId) + const rule = await query<{ id: number }>( + `INSERT INTO work_product_rules ( + rule_key, provider, platform, shop_id, sku_code, product_name, match_type, + category_id, enabled, auto_create, reward_amount, unit_price_fen, + requirement_json, sort_order, created_at, updated_at, pricing_mode, + canonical_product_id, sharing_enabled, sharing_auto_from_order + ) VALUES ( + $1, '', '', '', '', $2, 'contains', + NULL, TRUE, TRUE, 0, 100, + '{}'::jsonb, 1, NOW(), NOW(), 'unit', + $3, TRUE, TRUE + ) RETURNING id`, + [input.ruleKey, input.productName, productId], + ) + const ruleId = Number(rule.rows[0]!.id) + ruleIds.push(ruleId) + const itemTitle = input.mappingType === 'product_default' ? input.matchValue : '' + const skuNick = input.mappingType === 'sku_series' ? input.matchValue : '' + await query( + `INSERT INTO work_product_rule_mappings ( + rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick, + mapping_type, enabled, created_at, updated_at, sku_match_mode + ) VALUES ( + $1, $2::jsonb, '', $3, '', $4, + $5, TRUE, NOW(), NOW(), 'fuzzy' + )`, + [ + ruleId, + JSON.stringify(input.sellerIds || [shopId]), + itemTitle, + skuNick, + input.mappingType, + ], + ) + return { ruleId, productId } + } + + try { + const containsItem = await createOrderItem( + internalSkus[0]!, + `摄影师约瑟夫-光影【单独紫皮】3个-${suffix}`, + ) + const seriesItem = await createOrderItem( + internalSkus[1]!, + `和平精英-突击兵密钥秘钥礼盒-${suffix}`, + ) + const containsTemplate = await createTemplate({ + ruleKey: `photo-${suffix}`, + productName: `摄影师约瑟夫-光影【单独紫皮】-${suffix}`, + mappingType: 'product_default', + matchValue: `摄影师约瑟夫-光影【单独紫皮】`, + }) + const seriesTemplate = await createTemplate({ + ruleKey: `aq-${suffix}`, + productName: `突击兵-${suffix}`, + mappingType: 'sku_series', + matchValue: '突击兵密钥', + }) + await createTemplate({ + ruleKey: `aq-wrong-shop-${suffix}`, + productName: `突击兵错误店铺-${suffix}`, + mappingType: 'sku_series', + matchValue: '突击兵密钥', + sellerIds: [`wrong-shop-${suffix}`], + }) + + for (const internalSku of internalSkus) { + const route = await upsertChannelRule({ + matchKeyType: 'internal_sku', + matchKey: internalSku, + channel: 'work_order', + priority: 10, + enabled: true, + note: '动态接单模板集成测试', + adminUserId: null, + }) + routeIds.push(route.id) + } + + const routes = await query<{ + id: number + canonical_product_id: number | null + config_json: Record + }>( + `SELECT id, canonical_product_id, carrier_config_json AS config_json + FROM sku_channel_routes + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [routeIds], + ) + assert.ok(routes.rows.every((route) => route.canonical_product_id === null)) + assert.ok(routes.rows.every((route) => route.config_json.workRuleKey === undefined)) + const fakeProducts = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM product_catalog + WHERE canonical_sku = ANY($1::text[])`, + [internalSkus.map((sku) => `tpl:${sku}`)], + ) + assert.equal(Number(fakeProducts.rows[0]?.count), 0) + + const containsOrder = await getOrderById(containsItem.orderId) + const seriesOrder = await getOrderById(seriesItem.orderId) + assert.ok(containsOrder && seriesOrder) + const containsOrderItem = await query>( + 'SELECT * FROM order_items WHERE id = $1', + [containsItem.orderItemId], + ) + const seriesOrderItem = await query>( + 'SELECT * FROM order_items WHERE id = $1', + [seriesItem.orderItemId], + ) + + const containsResult = await syncWorkerOrdersForSourceOrder( + containsOrder, + [containsOrderItem.rows[0] as never], + { + source: 'integration_test', + autoOnly: true, + skipChannelAssign: true, + }, + ) + const seriesResult = await syncWorkerOrdersForSourceOrder( + seriesOrder, + [seriesOrderItem.rows[0] as never], + { + source: 'integration_test', + autoOnly: true, + skipChannelAssign: true, + }, + ) + assert.equal(containsResult.createdCount, 1, JSON.stringify(containsResult)) + assert.equal(seriesResult.createdCount, 1, JSON.stringify(seriesResult)) + + const workOrders = await query<{ + order_item_id: number + product_rule_id: number + sharing_total_quantity: number + }>( + `SELECT order_item_id, product_rule_id, sharing_total_quantity + FROM work_orders + WHERE order_item_id = ANY($1::bigint[]) + ORDER BY order_item_id`, + [orderItemIds], + ) + assert.deepEqual( + workOrders.rows.map((row) => Number(row.product_rule_id)).sort((a, b) => a - b), + [containsTemplate.ruleId, seriesTemplate.ruleId].sort((a, b) => a - b), + ) + assert.ok( + workOrders.rows.every((row) => Number(row.sharing_total_quantity) === 3), + JSON.stringify(workOrders.rows), + ) + + await assignPendingOrderItemChannels({ + orderItemIds, + batchSize: 10, + maxBatches: 1, + }) + const fulfilled = await query<{ + id: number + fulfillment_status: string + fulfillment_channel: string + canonical_product_id: number | null + }>( + `SELECT id, fulfillment_status, fulfillment_channel, canonical_product_id + FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [orderItemIds], + ) + assert.ok( + fulfilled.rows.every( + (row) => + row.fulfillment_status === 'assigned' && row.fulfillment_channel === 'work_order', + ), + ) + assert.deepEqual( + fulfilled.rows.map((row) => Number(row.canonical_product_id)).sort((a, b) => a - b), + [containsTemplate.productId, seriesTemplate.productId].sort((a, b) => a - b), + ) + const finalFulfillments = await query<{ + order_item_id: number + issue_code: string + pending_reason: string | null + }>( + `SELECT order_item_id, issue_code, pending_reason + FROM fulfillments + WHERE order_item_id = ANY($1::bigint[]) + ORDER BY order_item_id`, + [orderItemIds], + ) + assert.ok( + finalFulfillments.rows.every((row) => row.issue_code === '' && row.pending_reason === null), + ) + + await assert.rejects( + () => + query( + `UPDATE sku_channel_routes + SET carrier_config_json = '{"workRuleKey":"missing-template"}'::jsonb, + updated_at = NOW() + WHERE id = $1`, + [routeIds[0]], + ), + /references missing template key/, + ) + } finally { + await withTransaction(async (client) => { + await client.query("SET LOCAL session_replication_role = 'replica'") + if (orderItemIds.length > 0) { + await client.query('DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])', [ + orderItemIds, + ]) + await client.query( + `DELETE FROM work_order_events + WHERE work_order_id IN ( + SELECT id FROM work_orders WHERE order_item_id = ANY($1::bigint[]) + )`, + [orderItemIds], + ) + await client.query('DELETE FROM work_orders WHERE order_item_id = ANY($1::bigint[])', [ + orderItemIds, + ]) + await client.query('DELETE FROM order_items WHERE id = ANY($1::bigint[])', [orderItemIds]) + } + if (orderIds.length > 0) { + await client.query('DELETE FROM orders WHERE id = ANY($1::bigint[])', [orderIds]) + } + if (ruleIds.length > 0) { + await client.query( + 'DELETE FROM work_product_match_logs WHERE rule_id = ANY($1::bigint[])', + [ruleIds], + ) + await client.query( + 'DELETE FROM work_product_rule_mappings WHERE rule_id = ANY($1::bigint[])', + [ruleIds], + ) + await client.query( + 'DELETE FROM cost_versions WHERE work_product_rule_id = ANY($1::bigint[])', + [ruleIds], + ) + await client.query('DELETE FROM work_product_rules WHERE id = ANY($1::bigint[])', [ + ruleIds, + ]) + } + if (productIds.length > 0) { + await client.query( + 'DELETE FROM cost_snapshots WHERE canonical_product_id = ANY($1::bigint[])', + [productIds], + ) + await client.query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [ + productIds, + ]) + } + if (routeIds.length > 0) { + await client.query('DELETE FROM sku_channel_routes WHERE id = ANY($1::bigint[])', [ + routeIds, + ]) + } + await client.query( + `DELETE FROM internal_skus + WHERE internal_sku_key = ANY($1::text[])`, + [internalSkus.map((sku) => sku.normalize('NFKC').toLowerCase())], + ) + }) + await closeDb() + } + }, +) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts index 9136275d..816dcf23 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts @@ -36,12 +36,34 @@ function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan { } } -test('工单门禁:只有内部 SKU 明确配置 work_order 且提供模板才放行', () => { +test('工单门禁:内部 SKU 配置 work_order 后放行,显式模板 key 可选', () => { assert.deepEqual(resolveWorkOrderChannelGate(plan(channel())), { allowed: true, reason: '', workRuleKey: 'rule-lucky-coin', }) + assert.deepEqual(resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))), { + allowed: true, + reason: '', + workRuleKey: '', + }) + assert.deepEqual( + resolveWorkOrderChannelGate( + plan( + channel({ + config_json: { + templateSelection: 'dynamic', + workRuleKey: 'last-matched-rule', + }, + }), + ), + ), + { + allowed: true, + reason: '', + workRuleKey: '', + }, + ) }) test('工单门禁:其它统一渠道一律不放行', () => { @@ -54,7 +76,7 @@ test('工单门禁:其它统一渠道一律不放行', () => { } }) -test('工单门禁:无配置、冲突、缺模板、模板不一致都不放行', () => { +test('工单门禁:无配置、冲突和显式模板不一致不放行', () => { assert.equal(resolveWorkOrderChannelGate(undefined).reason, 'no_channel_capability') assert.equal( resolveWorkOrderChannelGate({ @@ -65,10 +87,6 @@ test('工单门禁:无配置、冲突、缺模板、模板不一致都不放 }).reason, 'routing_conflict', ) - assert.equal( - resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))).reason, - 'work_rule_missing', - ) assert.equal( resolveWorkOrderChannelGate(plan(channel()), 'another-rule').reason, 'work_rule_mismatch', diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 3630f977..42eb04aa 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -45,6 +45,7 @@ import { } from './mappers.js' import { registerWorkOrderShopName } from './work-order-shop-options-cache.js' import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' import { loadInternalSkuChannelPlans, upsertInternalSkuChannel, @@ -106,9 +107,14 @@ export function resolveWorkOrderChannelGate( workRuleKey: '', } } - const configuredWorkRuleKey = String(plan.primary.config_json.workRuleKey || '').trim() + const primaryConfig = plan.primary.config_json || {} + const configuredWorkRuleKey = String(primaryConfig.workRuleKey || '').trim() + if (primaryConfig.templateSelection === 'dynamic') { + return { allowed: true, reason: '', workRuleKey: '' } + } if (!configuredWorkRuleKey) { - return { allowed: false, reason: 'work_rule_missing', workRuleKey: '' } + // 空 key 是合法配置:按真实订单运行模板匹配,兼容包含匹配、SKU 数量系列等动态规则。 + return { allowed: true, reason: '', workRuleKey: '' } } if (preferredWorkRuleKey && String(preferredWorkRuleKey).trim() !== configuredWorkRuleKey) { return { allowed: false, reason: 'work_rule_mismatch', workRuleKey: '' } @@ -184,14 +190,16 @@ export async function syncWorkerOrdersForSourceOrder( priority: 10, isFallback: false, enabled: true, - configJson: { workRuleKey: matchedDecision.rule.rule_key }, + configJson: {}, source: 'template', note: `自动匹配接单模板 ${matchedDecision.rule.rule_key}`, + clearCanonicalProduct: true, + replaceConfig: true, }) channelGate = { allowed: true, reason: '', - workRuleKey: matchedDecision.rule.rule_key, + workRuleKey: '', } } } @@ -211,29 +219,32 @@ export async function syncWorkerOrdersForSourceOrder( const preferredRule = preferredRuleKey ? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null : null - const match = + const dynamicMatch = matchedDecision || - (!preferredRule + resolveMatchingProductRuleDecision(order, item, rules, ruleMappings, { + legacyFallbackEnabled, + }) + const match = preferredRule + ? { + rule: preferredRule, + mappingId: null, + reason: 'matched' as const, + score: 1, + candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }], + diagnosis: [] as [], + } + : preferredRuleKey ? { - rule: null, - mappingId: null, - reason: 'unmatched' as const, - score: 0, - candidates: [], + ...dynamicMatch, diagnosis: [ { dimension: 'internal_sku_channel', reason: 'configured_work_rule_missing', }, + ...(dynamicMatch.diagnosis || []), ], } - : { - rule: preferredRule, - mappingId: null, - reason: 'matched' as const, - score: 1, - candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }], - }) + : dynamicMatch const matchContext = resolveKuaishouWorkProductMatchContext(item) const snapshot = safeParseJson(item.item_snapshot_json) const sellerId = @@ -318,6 +329,23 @@ export async function syncWorkerOrdersForSourceOrder( continue } const rule = match.rule + if (!preferredRule) { + await upsertInternalSkuChannel({ + internalSku, + channel: 'work_order', + priority: Number(channelPlan?.primary?.priority || 10), + isFallback: false, + enabled: true, + configJson: { + templateSelection: 'dynamic', + workRuleKey: rule.rule_key, + }, + source: 'template', + note: `动态匹配接单模板 ${rule.rule_key}`, + clearCanonicalProduct: true, + replaceConfig: true, + }) + } if (options.autoOnly && !rule.auto_create) { skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' }) decisions.push( @@ -452,6 +480,17 @@ export async function syncWorkerOrdersForSourceOrder( } created.push(workOrder) + if (rule.canonical_product_id) { + await attachFulfillmentConfigurations({ + mappings: [ + { + orderItemId: Number(item.id), + canonicalProductId: Number(rule.canonical_product_id), + }, + ], + onlyWhenCanonicalProductDifferent: true, + }) + } // 店铺第一次产生工单时要能出现在后台「店铺」筛选里;老店铺出单不会动缓存。 registerWorkOrderShopName(order.shop_name) decisions.push( diff --git a/deploy/backup-db.sh b/deploy/backup-db.sh index 00d07d8c..580a2eb8 100755 --- a/deploy/backup-db.sh +++ b/deploy/backup-db.sh @@ -11,7 +11,7 @@ set -Eeuo pipefail # 说明: # - 上传由 backend 镜像内的 dist/db-backup.js 完成;OSS 凭证优先读 .env 的 DB_BACKUP_OSS_*, # 未配置时复用 STORAGE_OSS_*。备份 key 形如 backups/postgres/order_site-<时间戳>.dump。 -# - OSS 端的过期删除请配置桶生命周期规则,见 docs/数据库备份与恢复.md。 +# - OSS 端的过期删除请配置桶生命周期规则。 # - ubuntu-deploy.sh 在执行数据库迁移前会自动调用本脚本(--skip-backup 可跳过)。 PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" diff --git a/deploy/restore-db.sh b/deploy/restore-db.sh index 98c55b01..05d8ff58 100755 --- a/deploy/restore-db.sh +++ b/deploy/restore-db.sh @@ -13,7 +13,7 @@ set -Eeuo pipefail # bash deploy/restore-db.sh --allow-unverified # 仅兼容历史无 manifest 备份 # # 恢复流程:下载备份(校验 sha256)→ 停 backend/web → pg_restore --clean → 执行迁移 → 重启并等待健康检查。 -# 详见 docs/数据库备份与恢复.md。 +# 相关备份入口见 deploy/backup-db.sh。 PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${PROJECT_ROOT}/.env" diff --git a/deploy/ubuntu-deploy.sh b/deploy/ubuntu-deploy.sh index 4d8582e5..408bb0fe 100755 --- a/deploy/ubuntu-deploy.sh +++ b/deploy/ubuntu-deploy.sh @@ -91,7 +91,7 @@ print_usage() { 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。 图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。 - 备份与恢复见 docs/数据库备份与恢复.md。 + 备份与恢复见 deploy/backup-db.sh 与 deploy/restore-db.sh。 EOF } diff --git a/docs/README.md b/docs/README.md index 2bd3183d..2e31b5f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,11 +18,10 @@ | 文档 | 用途 | | --- | --- | -| [生产升级操作手册](生产升级操作手册.md) | 含迁移、履约切换、财务重建的生产升级与回滚步骤 | -| [部署启动与数据库重建](部署启动与数据库重建.md) | 本地与生产部署、数据库重建 | -| [数据库备份与恢复](数据库备份与恢复.md) | 备份、恢复、OSS | | [数据库SQL约束与运维](数据库SQL约束与运维.md) | SQL 护栏与生产观测 | +部署、备份、恢复和生产升级以 `deploy/` 下脚本、Compose 文件和实际迁移为准,不另设文档副本。 + ## 专项参考 专项参考只解释具体平台、工具或历史问题,不能覆盖唯一权威文档。 diff --git a/docs/archive/2026-11-08/成本中心与履约模型.md b/docs/archive/2026-11-08/成本中心与履约模型.md index 8350f70f..5f089df5 100644 --- a/docs/archive/2026-11-08/成本中心与履约模型.md +++ b/docs/archive/2026-11-08/成本中心与履约模型.md @@ -513,4 +513,4 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100 - 接口契约: [成本中心API契约.md](成本中心API契约.md) - 人工支出与备用金: [人工支出与备用金.md](人工支出与备用金.md) - 影子总账: [财务系统集成方案.md](财务系统集成方案.md) -- 运维: [数据库SQL约束与运维.md](数据库SQL约束与运维.md)、[部署启动与数据库重建.md](部署启动与数据库重建.md) +- 运维: [数据库SQL约束与运维.md](数据库SQL约束与运维.md) diff --git a/docs/数据库备份与恢复.md b/docs/数据库备份与恢复.md deleted file mode 100644 index 5a977318..00000000 --- a/docs/数据库备份与恢复.md +++ /dev/null @@ -1,155 +0,0 @@ -# 数据库备份与恢复 - -生产数据库(PostgreSQL,Compose 容器)每日备份到阿里云 OSS,与图片存储共用 OSS 基础设施,但使用独立的 `backups/` 前缀与(建议)独立的 RAM 凭证。 - -## 备份内容与存放 - -| 内容 | 方式 | OSS key 前缀 | -| --- | --- | --- | -| PostgreSQL 全库 | `pg_dump -Fc`(custom 格式,自带压缩,MVCC 快照无需停机) | `backups/postgres/` | -| 平台 JSON 配置(`apps/backend/data`,排除日志/缓存) | `tar -czf` | `backups/configs/` | - -- 每个备份对象附带 `.manifest.json` 清单:大小、sha256、创建时间、数据库版本、已应用迁移列表。没有有效清单的备份默认不会被 `latest` 选择,恢复也会拒绝;只有明确使用 `--allow-unverified` 才兼容历史备份。 -- 不备份的内容:图片(已在 OSS)、Caddy 证书(自动重新签发)、日志(`apps/backend/data/logs`)。 -- PostgreSQL dump 是数据库内部一致快照,但数据库与 OSS 不共享事务;备份时刻附近的新图片可能只存在一侧。恢复后应按 `file_assets.object_key` 抽查 OSS 对象,并为图片桶开启版本控制/保留策略。 -- 服务器本地在 `backups/` 目录保留最近 `DB_BACKUP_KEEP` 份(默认 7),OSS 端过期由桶生命周期规则管理。 -- 涉及脚本:`deploy/backup-db.sh`(备份)、`deploy/restore-db.sh`(恢复/演练)、后端镜像内 `dist/db-backup.js`(OSS 传输与校验)。 - -## 首次开通 - -以下步骤在阿里云控制台与服务器上各执行一次。 - -### 1. RAM 授权(建议独立,可直接复用现有凭证) - -现有 `STORAGE_OSS_*` 的 RAM 用户拥有整个桶的权限,也能直接跑备份。但备份包含全部订单与用户数据,是最敏感的资产,建议单独创建一个 RAM 用户 `order-site-backup`,只授予 `backups/` 前缀,自定义权限策略: - -```json -{ - "Version": "1", - "Statement": [ - { - "Effect": "Allow", - "Action": ["oss:PutObject", "oss:GetObject"], - "Resource": ["acs:oss:*:*:你的桶名/backups/*"] - }, - { - "Effect": "Allow", - "Action": ["oss:HeadBucket"], - "Resource": ["acs:oss:*:*:你的桶名"] - }, - { - "Effect": "Allow", - "Action": ["oss:ListObjects"], - "Resource": ["acs:oss:*:*:你的桶名"], - "Condition": { - "StringLike": { "oss:Prefix": ["backups/", "backups/*"] } - } - } - ] -} -``` - -为该用户创建 AccessKey,填入服务器 `.env` 的 `DB_BACKUP_OSS_ACCESS_KEY_ID` / `DB_BACKUP_OSS_SECRET_ACCESS_KEY`(不填则回退 `STORAGE_OSS_*`)。若用独立备份桶,同时覆盖 `DB_BACKUP_OSS_ENDPOINT` / `DB_BACKUP_OSS_BUCKET` / `DB_BACKUP_OSS_REGION`。 - -### 2. 桶生命周期规则(控制存储成本) - -在 OSS 控制台为目标桶添加生命周期规则: - -- 前缀 `backups/`:最后一次访问 90 天后删除(按需调整,数据库每日一份,90 份足够覆盖一个季度)。 -- 建议同时开启版本控制:误删备份时可找回旧版本;给非当前版本配置 7 天过期,避免版本堆积。 - -### 3. 手动执行一次,验证链路 - -```bash -bash deploy/backup-db.sh -bash deploy/restore-db.sh --list -bash deploy/restore-db.sh --verify -``` - -`--verify` 会把最新带有效清单的备份恢复到一个隔离的临时 PostgreSQL 容器(不接入业务网络,不影响线上库),并打印迁移列表与各表行数。建议之后每月执行一次演练——没验证过的备份等于没有备份。 - -生产升级前的恢复验证是硬门禁,并且必须验证本次生成的指定备份,而不是只验证任意旧备份。 -具体步骤和当前恢复阻断项见 -[生产升级操作手册](生产升级操作手册.md)。恢复验证未通过时禁止执行生产迁移。 - -### 4. 配置每日定时备份 - -```bash -crontab -e -``` - -```cron -# 每天 03:00 备份数据库与配置到 OSS(目录需先存在,首次开通时脚本已创建) -0 3 * * * cd /srv/order_site && bash deploy/backup-db.sh >> backups/backup.log 2>&1 -``` - -把 `/srv/order_site` 换成实际项目目录。日志追加在 `backups/backup.log`,可用 logrotate 管理;备份失败时 cron 不通知,请定期查看该文件,或后续接入告警 webhook。 - -## 日常运维 - -- **部署自动备份**:`bash deploy/ubuntu-deploy.sh` 在每次执行数据库迁移前会自动备份一次;`--reset-db` 会在删除数据卷前抓最后一份旧库备份,并跳过重置后空库备份,避免空库被误选为 latest。备份失败会中止部署,确认为可接受后可用 `--skip-backup` 继续。 -- **手动备份**:`bash deploy/backup-db.sh`(可加 `--keep N` 覆盖本地保留份数)。 -- **查看备份列表**:`bash deploy/restore-db.sh --list`。 -- **恢复演练**:`bash deploy/restore-db.sh --verify`(可加 `--key` 指定备份)。 -- **兼容历史备份**:仅在确认对象来源可信时加 `--allow-unverified`,不要作为生产默认参数。 - -## 恢复 - -### 场景 A:整库恢复到最新备份(最常用) - -```bash -bash deploy/restore-db.sh -``` - -流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → `VACUUM (ANALYZE)` 刷新统计信息与可见性 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。 - -> 恢复后必须 `VACUUM (ANALYZE)`:dump 不带 `pg_statistic`,没有统计信息时订单列表的 `COUNT` 会被规划成 `orders` 全表扫描,41 万单冷缓存实测 14–27 秒(默认语句超时 15 秒会直接 500)。`restore-db.sh` 已自动执行;手工 `pg_restore` 请补跑: -> -> ```bash -> docker compose exec -T postgres psql -U postgres -d order_site -c 'VACUUM (ANALYZE)' -> ``` - -如需同时恢复 `apps/backend/data` 配置: - -```bash -bash deploy/restore-db.sh --restore-configs -``` - -也可以用 `--config-key` 指定配置归档。配置归档会先校验路径,再解包到 data 目录。 - -### 场景 B:恢复到指定时间点 - -```bash -bash deploy/restore-db.sh --list -bash deploy/restore-db.sh --key backups/postgres/order_site-20260823-030000.dump -``` - -### 场景 C:服务器全损(数据卷丢失/换机) - -在新服务器完成部署(`docs/部署启动与数据库重建.md`)后,把旧服务器 `.env`(含 OSS 凭证)恢复到新服务器,再执行场景 A。图片不受影响(在 OSS),平台 JSON 配置可从 `backups/configs/order_site-<时间戳>.tar.gz` 解回 `apps/backend/data/`。 - -### 场景 D:外部数据库(DATABASE_URL 非 postgres 服务) - -`backup-db.sh` 与 `restore-db.sh` 会自动识别 `.env` 的 `DATABASE_URL`:指向外部库时,从 postgres 容器经网络直连执行 pg_dump/pg_restore,因此服务器仍需有可用的 postgres 镜像/容器作为客户端。恢复外部库前会额外告警确认目标。 - -### 恢复后检查 - -- 打开后台登录、近期订单列表,抽查数据到备份时间点为止。 -- `bash deploy/restore-db.sh --list` 确认下一次定时备份已恢复产生新对象。 - -## 故障排查 - -| 现象 | 处理 | -| --- | --- | -| `[db-backup] HeadBucket 403` | RAM 策略缺 `oss:HeadBucket`,按上文策略补齐 | -| `ListObjects 403` | 策略缺 `oss:ListObjects` 或 `oss:Prefix` 条件与实际前缀不匹配 | -| `PutObject 403` | `Resource` 里的桶名/前缀与 `.env` 的 `DB_BACKUP_OSS_BUCKET` / `DB_BACKUP_OSS_PREFIX` 不一致 | -| `缺少 OSS 配置` | `.env` 里 `DB_BACKUP_OSS_*` 与 `STORAGE_OSS_*` 都为空;改完后备份脚本用的是镜像内程序,若同时改了代码需重新构建 | -| 下载校验 `sha256 校验失败` | 备份对象被篡改或截断,换更早的备份重试 | -| `pg_restore` 报版本错误 | 不会发生在本编排内(pg_dump/pg_restore 与库同镜像);仅外部库场景需保证客户端 ≥ 服务端大版本 | - -## 安全注意 - -- 备份 RAM 凭证只进服务器 `.env`(已 gitignore),不要提交到仓库。 -- 备份桶/前缀保持私有,不要开公共读写;`backups/` 下的对象不要通过前端暴露。 -- 若备份桶与图片桶相同,确认图片访问逻辑只读图片前缀,不会枚举 `backups/`。 diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md deleted file mode 100644 index a010dc28..00000000 --- a/docs/生产升级操作手册.md +++ /dev/null @@ -1,717 +0,0 @@ ---- -type: 生产运维操作手册 -description: order_site 生产结构升级、数据切换、验收与回滚的唯一执行步骤。 -tags: [生产, 部署, 数据库, 迁移, 备份, 回滚, 财务] -status: current -version: 1.3 -updated_at: 2026-11-14 ---- - -# 生产升级操作手册 - -> 本手册适用于包含数据库迁移、领域模型切换或财务重建的生产升级。 -> 业务与架构口径仍以 -> [统一订单履约与财务模型-权威版](统一订单履约与财务模型-权威版.md) 为唯一权威。 -> -> 涉及普通文档修改、无迁移的小版本更新,可继续使用 -> [部署启动与数据库重建](部署启动与数据库重建.md)。 -> 只要包含迁移、表结构切换或派生数据重建,必须按本手册执行。 - -> 2026-11-13 增量待发布基线:`dev_11.13` 从生产迁移 `151` 增量到 `161`。 -> 该次变更包含 `152_order_source_facts_immutable.sql`、 -> `158_consignment_worker_name_sync.sql`、`159_consignment_platform.sql`、 -> `160_consignment_auto_match_setting.sql`、`161_semi_auto_fulfillment.sql`。 -> 在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线, -> 不得按旧的 `147–150` 一条路径直接上线。 -> -> 2026-11-14 增量待发布基线:在 `161` 之上依次新增 -> `162_fulfillment_queue_buckets.sql`、`163_work_order_hall_price_version.sql`、 -> `164_template_product_cost_visibility.sql`、`165_work_order_price_sync_scope.sql`。 -> `162` 建立履约待办四桶只读投影、补载体扫描专用索引和新单优先扫描口径; -> `163` 增加接单大厅价格成本版本引用、改价同步候选索引和版本闸门,不重写历史工单金额。 -> `164` 增加接单模板到标准商品的显式关联,修复无订单模板商品落入未路由、无法先配置成本的问题。 -> `165` 将模板改价同步范围收敛到 `open`,未发布工单在发布时物化最新价,避免批量改价扫描数万行。 -> 两项迁移都不改写原始订单、履约尝试或售后事实;仍须完成停写、最终备份、隔离恢复验证、 -> 迁移、健康检查、履约审计和大厅价格审计后才可恢复营业。 - -### 1.1 迁移 `151 → 165` 增量门禁 - -- 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。 -- 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160` - 的库上重新引入 `153–156` 或 `162–165`。 -- `161` 一次新增账号级 Cookie JSON 凭证表、`semi_auto_tasks.credential_id`、 - 最终 Connector 来源约束和兼容保留的 `semi_auto.credential.export` 权限记录; - 该权限不再参与 CK 导出判定,导出由后台登录、禁止缓存和审计控制; - 不迁移或清理订单、退款、资金流水等 A 级事实。 -- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键和索引存在,以及 - `v_fulfillment_queue`、补载体部分索引存在; -- `163` 新增 `work_orders.hall_price_version_id`、模板工单进入 `open` 的版本闸门和 - 改价同步候选索引;正在使用寄卖现货的合同态工单豁免版本闸门。 -- `164` 新增 `work_product_rules.canonical_product_id` 及唯一索引,并重建 - `v_product_current`,让无订单的模板商品明确归属接单平台成本范围。 -- `165` 删除未发布工单的模板改价候选索引;改价 API 只允许扫描并更新 `open` 工单。 -- 用测试库完成 `001 → 165` 空库迁移,并在隔离库验证 `151 → 165`。 -- 迁移后必须验证新建停用模板能在“接单平台”成本页签看到并配置成本,且不出现在“未路由”。 -- 迁移后执行 `npm run audit:work-order-hall-pricing`,`critical` 必须为 0。 -- 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库; - 必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。 - -## 一、本次升级基线 - -以下信息是 2026-11-08 的首次执行基线。后续每次升级必须由发布负责人更新, -旧值只允许保留在发布记录中,不能继续作为执行依据。 - -| 项目 | 值 | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 发布日期 | 2026-11-08 | -| 发布分支 | `dev_11.8` | -| 目标提交 | `d76e8265f806342c670d570e8ee72f5e8ab08fdc` | -| 生产起始迁移 | `146` | -| 目标迁移 | `150` | -| 新增迁移 | `147_internal_sku_product_routing.sql`、`148_fulfillment_execution_truth.sql`、`149_finance_derived_scope.sql`、`150_voucher_consume_order_received_scope.sql` | -| 升级类型 | 履约、内部 SKU、商品路由、财务派生数据硬切 | -| 预计维护窗口 | 2–3 小时 | -| 核心停写时间 | 20–40 分钟 | - -同生产规模本地演练结果: - -- 数据量:订单 `456777`、订单项 `456762`、履约 `456762`。 -- `146 → 150` 单事务迁移:约 `61 秒`。 -- 90 天财务重建:约 `14–17 秒`。 -- 第二次财务重建:`ledger.adjusted = 0`,幂等成立。 -- 严格财务审计:`critical: []`。 -- 原始订单、订单项、收入、退款和资金流水守恒。 - -## 二、绝对禁止 - -1. 禁止使用 `deploy/ubuntu-deploy.sh` 执行本次 `147–150` 升级;它会在旧后端 - 仍运行时执行迁移。 -2. 禁止在生产执行 `deploy/ubuntu-deploy.sh --reset-db`。 -3. 禁止使用 `--skip-backup`。 -4. 禁止在迁移前跳过 `restore-db.sh --verify`。 -5. 禁止在旧后端仍运行时执行 `147–150` 硬切迁移。 -6. 禁止迁移后用旧镜像启动后端;旧代码不兼容 `order_items.legacy_*`。 -7. 禁止为了赶进度绕过硬门禁、数据守恒检查或财务审计。 -8. 禁止自动猜测缺失渠道、商品归属或人工配置。 -9. 禁止删除或重写原始订单、退款、真实工单、资金流水和审计日志。 -10. 禁止在迁移过程中重启 PostgreSQL、强制杀迁移事务或重复启动第二个迁移进程。 -11. 禁止在本轮观察期结束前删除 `legacy_*` 字段和兼容表。 - -## 三、角色与记录 - -执行前至少确定以下角色: - -| 角色 | 职责 | -| ------------ | ---------------------------------------------- | -| 发布负责人 | 冻结提交、确认窗口、决定继续或回滚 | -| 数据库执行人 | 备份、迁移、统计刷新、数据检查 | -| 验收人 | 独立执行审计、核对输出,不复用执行人的口头结论 | -| 业务负责人 | 确认已知待分类项、财务口径和恢复营业 | - -整个过程中必须记录: - -- 升级前后 commit。 -- 升级前后迁移号。 -- 数据库与配置备份 OSS key、sha256、迁移号和文件大小。 -- 每个阶段的开始时间、结束时间和退出码。 -- 三组审计的完整输出。 -- 财务重建 dry-run 与 execute 的完整 JSON。 -- 上线验收 SQL 的 PASS、FAIL、WARN 清单。 -- 继续、暂停或回滚的决定及确认人。 - -## 四、上线前门禁 - -以下条件必须全部满足,否则不得进入维护窗口。 - -### 4.1 代码门禁 - -```bash -cd /srv/order_site # 替换为服务器实际目录 -git fetch --prune -git switch dev_11.8 -git pull --ff-only -git status --short -git rev-parse HEAD -``` - -要求: - -- `git status --short` 无输出。 -- `git rev-parse HEAD` 等于本次批准的提交。 -- 后端 `npm run check` 通过。 -- 前端 `npm run check` 通过。 -- 本次发布没有未提交文件。 - -### 4.2 生产只读基线 - -先执行只读检查,不写数据库: - -```bash -docker compose exec -T postgres \ - sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -f -' <<'SQL' -BEGIN READ ONLY; - -SELECT COALESCE(filename, name) AS migration -FROM schema_migrations -ORDER BY COALESCE(filename, name) DESC -LIMIT 5; - -SELECT - (SELECT count(*) FROM orders) AS orders, - (SELECT count(*) FROM order_items) AS order_items, - (SELECT count(*) FROM order_refunds WHERE status = 'confirmed') AS refunds, - (SELECT COALESCE(sum(amount_fen), 0) FROM order_refunds WHERE status = 'confirmed') AS refund_fen, - (SELECT count(*) FROM work_orders) AS work_orders, - (SELECT count(*) FROM fulfillment_tasks) AS fulfillment_tasks, - (SELECT count(*) FROM fund_entries) AS fund_entries; - -COMMIT; -SQL -``` - -要求: - -- 当前最高迁移必须是 `146`。 -- 订单、订单项、退款和资金流水数量记录到发布单。 -- `docker compose ps`、`/health/ready` 和外网主入口均正常。 - -### 4.3 恢复验证硬门禁 - -这是 P0 门禁。备份文件存在不等于可回滚。 - -```bash -bash deploy/backup-db.sh -bash deploy/restore-db.sh --list -bash deploy/restore-db.sh --verify --key '<本次备份完整 OSS key>' -``` - -必须确认: - -- `backup-db.sh` 退出码为 `0`。 -- OSS manifest、sha256 和文件大小有效。 -- `restore-db.sh --verify` 退出码为 `0`。 -- 临时恢复库最高迁移为升级前迁移。 -- 订单、订单项等关键表数量与生产只读基线一致。 - -> 2026-11-08 审核结论:当前备份恢复到隔离库时,会因 -> `idx_order_items_pending_internal_sku` 依赖函数恢复顺序,以及 `livehub_ro` -> 角色不存在而返回非零。恢复脚本因此会判定备份不可用。 -> -> 在上述问题修复并重新验证通过前,本次升级状态为 **禁止上线**。 -> 不允许使用 `--allow-unverified`、手工忽略 `pg_restore` 错误或跳过此门禁。 - -### 4.4 调度任务门禁 - -升级前在后台关闭全部定时任务: - -```text -后台 → 平台配置 → 监控任务 → 任务总开关 → 关闭 → 保存任务 -``` - -要求: - -- 总开关显示关闭。 -- 所有任务不再排下一次执行。 -- 升级期间不得重新开启,直到财务重建、业务配置和上线验收全部通过。 - -该配置保存在数据库中,会被升级前备份一并保存。 - -## 五、维护窗口执行顺序 - -严格按照以下顺序执行。任何一步失败都立即停止,不得跳步。 - -### 5.1 预构建镜像 - -在停止业务前完成构建,不替换正在运行的容器: - -```bash -docker compose build backend web -docker compose --profile tools build tools -``` - -构建失败时停止,不进入维护窗口。 - -### 5.2 进入维护状态 - -先把公网流量切到维护页,或只允许办公网 IP 访问,并保留管理员内部访问通道。 -迁移后的业务配置需要通过后台 UI 完成,不能全程只启动 backend。 -如果无法在维护期间隔离公网用户和保留管理员入口,暂停升级。 - -通知停止接单后执行: - -```bash -docker compose stop web backend -docker compose ps -``` - -要求: - -- `web`、`backend` 已停止。 -- PostgreSQL 保持运行。 -- 此时开始计算核心停写时间。 - -### 5.3 生成最终升级前备份 - -虽然 4.3 已验证恢复链路,这里仍必须生成停机后的最终备份: - -```bash -# 重新执行 4.2 的只读计数 SQL,记录停机后的最终基线 -bash deploy/backup-db.sh -bash deploy/restore-db.sh --list -``` - -记录: - -- 完整 OSS key。 -- 对应配置备份 OSS key。 -- manifest 中的迁移号,必须为 `146`。 -- sha256 和文件大小。 - -最终停机备份也必须做隔离恢复验证: - -```bash -bash deploy/restore-db.sh --verify --key '<最终备份完整 OSS key>' -``` - -要求: - -- 命令退出码为 `0`。 -- manifest、sha256 和下载校验通过。 -- 临时恢复库最高迁移为 `146`。 -- 订单、订单项和资金流水数与停机后的最终基线一致。 - -最终备份恢复验证未通过时,不得执行迁移。 - -### 5.4 执行数据库迁移 - -必须先确认旧后端已停止,再执行: - -```bash -docker compose run --rm --no-deps backend node dist/db/migrate.js -``` - -预期执行: - -```text -147_internal_sku_product_routing -148_fulfillment_execution_truth -149_finance_derived_scope -150_voucher_consume_order_received_scope -``` - -要求: - -- 命令退出码为 `0`。 -- 不允许中断、重复启动或并行启动。 -- 迁移失败时保持后端停止,不自动重启。 - -迁移后检查: - -```bash -docker compose exec -T postgres sh -lc \ - 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc " - SELECT COALESCE(filename, name) - FROM schema_migrations - ORDER BY COALESCE(filename, name) DESC - LIMIT 5 - "' -``` - -最高迁移必须为 `150`。 - -### 5.5 刷新统计信息 - -迁移后立即执行,否则履约审计可能超时并产生错误计划: - -```bash -docker compose exec -T postgres sh -lc \ - 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c " - VACUUM (ANALYZE) - orders, - order_items, - fulfillments, - fulfillment_attempts, - fulfillment_issues, - internal_skus, - sku_channel_routes, - manual_fulfillment_configs, - product_catalog, - cost_snapshots, - sales_daily_facts, - finance_journal_entries - "' -``` - -命令失败时停止上线;不要用放宽超时替代统计刷新。 - -### 5.6 启动新后端 - -先只启动后端,不启动 Web: - -```bash -docker compose up -d --no-deps --force-recreate backend -docker compose exec -T backend sh -lc \ - 'curl -fsS http://127.0.0.1:3000/health/ready' -``` - -要求: - -- 健康检查返回成功。 -- 后端日志中定时任务系统显示已停用。 -- 不允许用旧镜像执行此步骤。 - -## 六、迁移后审计 - -### 6.1 数据回填与订单级核销补投 - -调度任务保持关闭,执行: - -```bash -bash deploy/post-deploy.sh --only=backfill -``` - -要求: - -- 命令退出码为 `0`。 -- `revenue:backfill` 完成后 `revenue_basis = 'unknown'` 为 `0`。 -- `voucher:backfill-order-received` 完成最近 7 天订单级核销补投,剩余待入队为 `0`。 -- 新任务来源为 `order_received`,`target_key` 使用 `order:<订单ID>`。 -- `VACUUM ANALYZE` 成功。 - -`post-deploy.sh` 末尾仍可能打印旧上线手册链接和“从接单模板初始化”提示; -以本手册为准,不要按旧提示重复初始化商品。 - -### 6.2 商品路由审计 - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run audit:product-routing -- --strict -``` - -以下结构性异常必须为 `0`: - -- 无主路由且无人工配置的 `man:` 商品。 -- 同一内部 SKU 多主路由。 -- `manual / third_party_voucher` 商品缺少内部 SKU。 -- 非法商品来源。 - -`fallback_without_primary`、`missing_manual_route` 和未路由商品可以保留为 -“待分类”,但必须逐类记录数量,交业务负责人在本轮维护窗口内确认。 -严格审计可能因这些已知待分类项返回非零;只有确认失败项全部属于已签字类别、 -且上述结构性异常均为 `0` 时,才允许继续。 - -### 6.3 履约审计 - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run audit:fulfillment -- --strict -``` - -以下完整性指标必须为 `0`: - -- 正式履约缺失。 -- 正式履约状态差异。 -- 执行尝试缺失。 -- 开放异常缺失。 -- 履约事件未关联正式记录。 -- 近 24 小时重复尝试。 - -以下业务异常不得增加,并必须已经进入结构化待办: - -- `missing_internal_sku` -- `missing_channel` -- `manual_config_missing` -- `carrier_missing` -- `state_inconsistent` -- `user_refund` -- `platform_cancelled` - -其中 `state_inconsistent`、售后关闭仍进入履约队列、已分配但无有效载体属于 -恢复营业前必须处理或由业务负责人明确签字的阻塞项。 - -2026-11-08 演练基线: - -- 待处理履约项 `476`。 -- 状态不一致 `7`。 -- 售后关闭仍进入履约队列 `24`。 -- 已分配但无有效载体 `27`。 - -### 6.3.1 接单大厅价格审计 - -迁移 `163`、`164` 后必须执行: - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run audit:work-order-hall-pricing -``` - -要求: - -- `critical` 必须为 `0`。 -- `open` 模板工单的 `hall_price_version_id` 必须对应当前有效成本版本。 -- 普通工单金额和拼单剩余份额单价必须与当前模板一致。 -- 已加入拼单份额的合同金额不得因审计或改价被重写。 -- 寄卖合同态工单不参与模板价审计覆盖。 -- 新建停用模板必须已出现在“接单平台”标准商品范围,并能先配置成本;不得显示为“未路由”。 - -审计只扫描已进入大厅的 `open` 工单。`pending_material / unassigned` 保留旧价格是预期行为, -发布入口会强制物化最新成本版本,不应为这些未发布工单批量补写价格。 - -### 6.4 财务重建 dry-run - -先定义本次财务回看区间: - -```bash -RECONCILE_FROM=2026-08-11 -RECONCILE_TO=2026-11-08 -``` - -执行 dry-run: - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run finance:rebuild-derived -- \ - --date-from="$RECONCILE_FROM" \ - --date-to="$RECONCILE_TO" -``` - -检查: - -- `closedPeriods` 为空;若包含已关账期间,停止并先走财务确认流程。 -- `scopeItems > 0`。 -- `protectedTotals` 与生产基线一致。 -- `plan` 中的 estimated 快照、销售事实和总账调整数量能够解释。 - -### 6.5 业务配置 - -为完成后台操作,启动 Web;此时公网仍必须停留在维护入口,不能对普通用户开放: - -```bash -docker compose up -d --no-deps --force-recreate web -``` - -由业务负责人完成,脚本不能代替: - -1. 钱包管理 → 补建客服备用金账户并按需拨付。 -2. 商品与成本 → 积分钱包 → 发放三平台积分。 -3. 商品与成本 → 标准商品 → 按需执行「按工单回填商品」和「归一人工商品」。 -4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置。 -5. 商品与成本 → 标准商品 → 处理未路由、待分类和人工兜底项。 - -> 迁移 `122` 已负责接单模板商品初始化。本手册不再要求执行旧的 -> “从接单模板初始化”步骤。 - -完成业务配置后重新执行商品路由审计,并记录前后数量变化。 - -### 6.6 财务重建 - -业务配置完成后,重新执行 6.4 的 dry-run,确认 `protectedTotals`、已关账期间和 -计划数量仍符合预期。业务负责人确认后再执行: - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run finance:rebuild-derived -- \ - --date-from="$RECONCILE_FROM" \ - --date-to="$RECONCILE_TO" \ - --execute \ - --confirm=REBUILD_DERIVED -``` - -要求: - -- `protectedTotals` 前后完全一致。 -- 订单、订单项、收入、退款、人工支出和资金流水守恒。 -- 已过账总账只追加反向/补差凭证,不删除历史凭证。 -- 所有成本缺口都进入结构化异常,不做自动猜测。 - -再执行一次相同命令验证幂等: - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run finance:rebuild-derived -- \ - --date-from="$RECONCILE_FROM" \ - --date-to="$RECONCILE_TO" \ - --execute \ - --confirm=REBUILD_DERIVED -``` - -第二次必须满足: - -- `ledger.adjusted = 0`。 -- `protectedTotals` 再次保持完全一致。 - -然后执行: - -```bash -docker compose --profile tools run --rm --no-deps tools \ - npm run audit:finance -- --strict -``` - -要求 `critical` 为空。 - -## 七、上线验收 - -停止调度任务期间执行: - -```bash -bash deploy/post-deploy.sh \ - --only=verify \ - --launch-at='YYYY-MM-DD HH:mm:ss+08' -``` - -要求: - -- 退出码为 `0`。 -- FAIL 必须为 `0`。 -- WARN 必须逐项记录并由业务负责人签字。 -- `audit:finance --strict` 必须同时通过。 - -2026-11-08 演练中,在做完财务重建后仍有: - -- `07 客服备用金账户已补建`:需在后台完成初始化后才可通过。 -- `15 近 7 天非人工成本覆盖`:演练时为 WARN,原因为 `206`,必须确认成本缺口。 - -## 八、恢复营业 - -验收通过后: - -```bash -docker compose up -d --no-deps --force-recreate web -``` - -然后: - -1. 后台 → 平台配置 → 监控任务 → 开启任务总开关 → 保存。 -2. 确认任务运行状态恢复,且下一次执行时间已生成。 -3. 检查主站、打手端、领取页和订单服务链接。 - -必须完成以下冒烟检查: - -- 后台登录。 -- 订单列表、详情和筛选。 -- 接单工单列表、详情和状态操作。 -- 履约渠道页和商品与成本页签数量一致。 -- 人工 SKU、自动发货、接单平台、第三方卡券和统一结算分类正确。 -- 财务总账试算平衡。 -- 打手端登录、订单页和大厅。 -- 领取链接 `/h5/<订单号>`。 -- 订单服务链接 `/o` 和 `/o/<订单号>`。 - -## 九、观察期 - -恢复营业后: - -- 前 60 分钟持续观察错误率、慢查询、定时任务和订单入单。 -- 前 7 天每天检查商品路由、履约和财务严格审计。 -- 一个完整财务期间持续对平后,才允许讨论删除兼容表、`legacy_*` - 字段和旧脚本。 - -观察期内不得删除: - -- `product_channel_capabilities` -- `manual_sku_registry` 派生行 -- `internal_sku_channels` -- `order_items.legacy_*` -- `fulfillments`、`fulfillment_attempts`、`fulfillment_issues`、`fulfillment_state_events` - -## 十、失败与回滚 - -### 10.1 迁移命令失败 - -如果迁移尚未提交: - -1. 保持 backend/web 停止。 -2. 保存完整迁移日志。 -3. 确认 PostgreSQL 中不存在半提交状态。 -4. 修复迁移或取消本次发布。 -5. 取消发布时,先切回升级前 commit 并重新构建 backend/web,再启动旧后端。 -6. 不得在未确认迁移事务已回滚时启动任何一版后端。 - -### 10.2 迁移已提交但新后端失败 - -硬切后的数据库不兼容旧镜像。选择以下一种: - -1. **修复前进**:保持停服,修复新后端,禁止用旧镜像连接迁移 `150`。 -2. **整库回滚**:使用已通过验证的升级前备份恢复。 - -整库回滚: - -回滚前必须先切回旧代码并重建旧镜像。否则 `restore-db.sh` 会用当前新镜像补迁移, -把刚恢复的 `146` 再次升级到 `150`。 - -```bash -git switch --detach '<升级前 commit>' -docker compose build backend web -bash deploy/restore-db.sh \ - --key '<升级前最终备份 OSS key>' \ - --config-key '<对应配置备份 OSS key>' \ - --yes -``` - -恢复后: - -1. 确认最高迁移为 `146`。 -2. 启动 backend/web。 -3. 执行健康检查、登录和近期订单抽查。 -4. 恢复调度任务总开关。 - -> 当前恢复脚本只有在 4.3 硬门禁通过后才可用于回滚。 -> 恢复脚本失败时,数据库可能处于部分恢复状态,必须立即停止业务并人工处理。 - -### 10.3 财务重建失败 - -财务重建是幂等的数据阶段,不应因为一次失败立即整库回滚: - -1. 保持停服和调度关闭。 -2. 保存失败 JSON、日志和当前 protected totals。 -3. 修复数据或脚本后重跑。 -4. 只有原始订单、退款、资金流水发生不守恒或数据库不可用时才考虑整库回滚。 - -### 10.4 验收失败 - -验收 FAIL 或关键审计异常时不得恢复营业: - -1. 保持 backend 健康但 Web 不开放。 -2. 调度保持关闭。 -3. 按失败项修复或走整库回滚。 -4. 修复后从对应阶段重新执行,不跳步。 - -## 十一、发布记录模板 - -```text -发布日期: -发布负责人: -数据库执行人: -验收人: -业务负责人: - -升级前 commit: -目标 commit: -升级前迁移: -目标迁移: - -升级前备份 key: -升级前备份 sha256: -升级前配置备份 key: -恢复验证结果: - -维护开始时间: -迁移开始时间: -迁移结束时间: -统计刷新结束时间: -财务重建结束时间: -验收结束时间: -恢复营业时间: - -商品路由审计: -履约审计: -财务 dry-run: -第一次财务重建: -第二次财务重建: -财务严格审计: -上线验收 FAIL: -上线验收 WARN: - -已知待分类项及负责人: -最终结论:继续 / 暂停 / 回滚 -``` diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index c7939523..b22a63aa 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.21 +version: 2.22 updated_at: 2026-11-14 --- @@ -11,8 +11,8 @@ updated_at: 2026-11-14 > 状态:**唯一权威文档**。 > 最后校准:2026-11-14。 -> 生产基线:迁移 `151`。 -> 开发基线:迁移 `165`(仅本地,尚未部署生产)。 +> 生产基线:迁移 `165`。 +> 开发基线:迁移 `166`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -396,6 +396,20 @@ manual fulfillment - 已有模板商品在迁移 `164` 中按模板生成的标准 SKU 一次性回填关联;删除仍被模板引用的商品时 必须拒绝并提示改为归档。 +### 3.4.5 接单平台动态模板路由 + +接单平台路由负责声明该内部 SKU 走 `work_order`,不应把内部 SKU 名称伪造成模板 key。 + +- `sku_channel_routes.carrier_config_json.workRuleKey` 为空时,按真实订单运行统一模板匹配; + 支持 `contains`、商品默认绑定、SKU 精确、SKU 数量系列、店铺白名单和订单数量解析。 +- `templateSelection = dynamic` 表示动态匹配;可记录最近命中的 `workRuleKey` 供排查, + 但下一次订单仍必须重新运行匹配,不能把最近模板固化。 +- 显式 `workRuleKey` 只能指向真实存在的 `work_product_rules.rule_key`;迁移 `166` 的触发器 + 拒绝未来写入不存在的模板 key。 +- 统一渠道配置不得为接单平台创建 `tpl:<内部 SKU>` 假草稿商品。真实成本商品来自命中的模板, + 工单创建后写入 `fulfillments.canonical_product_id`。 +- 错误 key、历史草稿商品和错误履约商品关联由迁移 `166` 清理;历史订单、模板和成本事实不删除。 + ### 3.5 商品与财务域 标准商品目标结构: @@ -1025,8 +1039,6 @@ docs/README.md docs/统一订单履约与财务模型-权威版.md 唯一业务与架构权威 -docs/部署启动与数据库重建.md -docs/数据库备份与恢复.md docs/数据库SQL约束与运维.md 运维参考 @@ -1043,7 +1055,7 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `165`。迁移 `152` 保护订单来源快照, +截至 2026-11-14,生产基线为 `165`,集成分支开发基线为 `166`。迁移 `152` 保护订单来源快照, 迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置; 迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、 任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。 @@ -1053,12 +1065,14 @@ docs/archive/ 进入接单平台成本范围。 迁移 `165` 删除覆盖未发布工单的改价候选索引,模板改价只刷新 `open` 工单;待完善和未分配 工单在发布时物化最新价格,避免每次改价扫描数万行。 +迁移 `166` 让接单平台路由支持动态模板匹配,清理历史伪造 key 和草稿商品,并拒绝对不存在模板 +的非空 `workRuleKey` 写入。 迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录, 不再作为 CK 导出门禁。 -1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。 +1. 生产升级直接按 `deploy/` 脚本、实际迁移和停写流程执行,不再维护独立升级手册。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 -3. `147 → 165` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、 +3. `165 → 166` 迁移后刷新统计并完成商品、履约、 财务和大厅价格审计;无法确定的数据进入未路由或待分类,不做猜测。 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md deleted file mode 100644 index 883835e8..00000000 --- a/docs/部署启动与数据库重建.md +++ /dev/null @@ -1,376 +0,0 @@ -# 部署启动与数据库重建 - -本文记录本地开发、数据库迁移、重建库和 Ubuntu 服务器部署的常用命令。 - -## 数据库迁移策略 - -项目使用 `node-pg-migrate`,迁移目录: - -```text -apps/backend/src/db/migrations/ - 001_init.sql 空库基线(完整结构) - 002_xxx.sql 后续增量(示例命名) - 003_xxx.sql - ... -``` - -记录表:`schema_migrations`。 - -| 场景 | 行为 | -| --- | --- | -| 全新空库 | 按序号执行 `001_init` → 全部增量 | -| 已有库升级 | 只执行尚未记录的增量文件 | -| 改历史迁移文件内容 | **不会**自动重跑;已应用文件禁止修改 | - -约定: - -1. `001_init.sql` 只作为基线;上线后结构变更不要改它。 -2. 每次 schema 变更新增 `NNN_name.sql`(三位序号)。 -3. 迁移与业务代码同一变更一起提交。 -4. 后端启动会自动 `up`;也可用命令手动执行。 - -### 开发常用命令 - -```bash -# 查看本地文件与库内已应用记录 -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate:status - -# 手动执行未应用迁移 -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate - -# 创建下一份增量迁移模板 -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate:create -- add_column_example -``` - -宿主机直连(`postgres` 主机名仅在 Docker 网络内有效): - -```bash -cd apps/backend -DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site npm run db:migrate:status -DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site npm run db:migrate -``` - -## 本地开发启动 - -首次: - -```bash -cp .env.mac-docker.example .env -docker compose -f docker-compose.dev.yml up -d --build -``` - -日常: - -```bash -docker compose -f docker-compose.dev.yml up -d -``` - -状态与日志: - -```bash -docker compose -f docker-compose.dev.yml ps -docker compose -f docker-compose.dev.yml logs -f backend -docker compose -f docker-compose.dev.yml logs -f frontend -``` - -本地入口: - -- 前端、后台、领取页:`http://localhost` -- 健康检查:`http://localhost/health` -- 就绪检查:`http://localhost/health/ready` - -## 本地重建数据库 - -仅在需要清空开发数据,或本地库与迁移历史严重不一致时使用。 - -只重建开发库 volume: - -```bash -docker compose -f docker-compose.dev.yml down -docker volume rm order_site_postgres_dev_data -docker compose -f docker-compose.dev.yml up -d --build -``` - -volume 名称不确定时: - -```bash -docker volume ls | grep postgres_dev_data -``` - -删除当前 dev compose 全部 volume(会重装 node_modules volume): - -```bash -docker compose -f docker-compose.dev.yml down -v -docker compose -f docker-compose.dev.yml up -d --build -``` - -确认迁移记录: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate:status -``` - -或: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend node --input-type=module -e ' -const { query, closeDb } = await import("./src/db/client.ts"); -try { - const result = await query("SELECT COALESCE(filename, name) AS name FROM schema_migrations ORDER BY COALESCE(filename, name)"); - console.log(result.rows.map((row) => row.name).join("\n")); -} finally { - await closeDb(); -} -' -``` - -## 本地验证 - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm test -docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck -docker compose -f docker-compose.dev.yml exec -T backend npm run build -docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck -docker compose -f docker-compose.dev.yml exec -T frontend npm run build -``` - -## 服务器首次部署 - -建议 Ubuntu + Docker Compose。 - -```bash -cp .env.server.example .env -``` - -至少配置: - -```text -APP_DOMAIN -CADDY_SITE_ADDR -WORKER_SITE_ADDR -CLAIM_BASE_URL -POSTGRES_PASSWORD -DATABASE_URL -ADMIN_SESSION_SECRET -ADMIN_DEFAULT_USERS_JSON -CORS_ALLOWED_ORIGINS -STORAGE_MODE -STORAGE_OSS_ENDPOINT -STORAGE_OSS_BUCKET -STORAGE_OSS_ACCESS_KEY_ID -STORAGE_OSS_SECRET_ACCESS_KEY -STORAGE_OSS_REGION -``` - -其中 `APP_DOMAIN`、`CORS_ALLOWED_ORIGINS` 与 `STORAGE_MODE=oss` / `STORAGE_OSS_*` 为生产必需:`docker-compose.yml:95` 的 `STORAGE_MODE` 生产默认即 `oss`,`apps/backend/src/config/runtime-validation.ts:107-114` 在生产环境强制校验 `STORAGE_OSS_*` 凭据并使用显式 CORS 域名白名单;`.env.server.example` 已给出这些变量的模板值,其中带 `change-me` 的占位会被 `deploy/ubuntu-deploy.sh` 的占位值检查拦住,需替换为真实值。 - -容器内连接 Compose PostgreSQL 时,主机名用 `postgres`: - -```text -DATABASE_URL=postgres://postgres:你的数据库密码@postgres:5432/order_site -``` - -不要写成容器内的 `127.0.0.1`(那会指向后端容器自己)。 - -部署: - -```bash -bash deploy/ubuntu-deploy.sh -``` - -脚本会:检查 Docker、检查 `.env` 占位值、检查 `DATABASE_URL`、修正 `apps/backend/data` 权限、按改动决定重建哪一端、启动、等待健康检查、打印已应用迁移与各阶段耗时。 - -### 按需重建(默认行为) - -脚本把「上次成功部署的 commit + `.env` 指纹」记在项目根的 `.deploy-state`(已 gitignore), -再按 `git diff --name-only 上次commit..HEAD` 判断本次要重建哪一端: - -| 改动 | 结果 | -| --- | --- | -| `apps/backend/**`(含 migrations) | 只重建 backend 镜像与容器 | -| `apps/frontend/**`、`deploy/caddy/**`、前端 Dockerfile | 只重建 web 镜像与容器 | -| `README.md`、`docs/**`、`deploy/*.sh`、`deploy/lib/**`、`scripts/**` | 不重建任何镜像,容器保持运行(只剩迁移 + 健康检查) | -| `docker-compose.yml`、`.env` 变化、未知路径、上次 commit 不在仓库里(force push/回滚) | 两端都重建(保守兜底) | - -不需要重建的一侧既不跑 Docker 构建、也不 `--force-recreate`;如果它恰好没在运行(服务器重启过), -脚本用现有镜像把它拉起来。首次在有 `--skip-backup` 之前用本脚本时没有 `.deploy-state`, -会按"全量重建一次"处理,之后即进入增量。 - -备份策略同理:本次改动不含 `apps/backend/src/db/migrations/**` 时自动跳过迁移前备份 -(生产上 `pg_dump` + 传 OSS 接近 12 分钟);`--backup` 可强制备份,`--skip-backup` 可无条件跳过, -只有"能算出改动范围"时才会自动跳过。 - -常用参数: - -| 参数 | 说明 | -| --- | --- | -| `--dry-run` | 只打印计划后退出:上次部署的 commit、改动文件、要重建哪一端、是否备份、基础镜像 digest | -| `--all` | 强制两端都重建 | -| `--backend-only` / `--web-only` | 只重建指定一端 | -| `--backup` | 强制迁移前备份 | -| `--skip-backup` | 跳过迁移前备份 | -| `--reset-db` / `--yes` | 见下文「服务器重建数据库」 | - -基础镜像也只补缺:`docker compose pull --ignore-buildable --policy missing` -(不再每次部署重新校验 postgres tag,避免顺手把数据库镜像静默升级)。 - -```bash -docker compose ps -docker compose logs -f backend -``` - -## 财务/成本上线后的初始化(历史基线 150) - -代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。 -部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走: - -### 1. 数据回填 - -```bash -bash deploy/post-deploy.sh --dry-run # 先看计划与待回填量 -bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 订单级收单核销补投 -``` - -> 不要在商品目录为空时直接跑 `--only=sync`:财务同步会为每个未归一订单项写一条 -> `product_mapping_missing` 成本异常(生产规模 90 天实测约 36 万条),后台异常队列会被淹没。 - -脚本包含 `revenue:backfill`、财务派生数据重建(`finance:rebuild-derived`)与只读验收;任何一步都能重跑, -`--only=backfill|sync|verify` 可单独执行。 - -> 生产 backend 镜像没有 `tsx` 与 `scripts/`,脚本通过 `tools` 镜像执行 -> ([deploy/docker/tools.Dockerfile](../deploy/docker/tools.Dockerfile),`docker compose --profile tools`)。 - -### 2. 业务配置:后台 UI - -1. 钱包管理 → 补建客服备用金账户 → 按需拨付; -2. 商品与成本 → 积分钱包 → 给客服发放三平台积分; -3. 商品与成本 → 标准商品:迁移 122 会自动补齐接单模板商品;新模板保存时也会自动建商品。按需执行「按工单回填商品」和「归一人工商品」,不再需要从接单模板初始化; -4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。 - -> 第 3 步的工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个 -> 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。 - -### 3. 财务同步 - -商品归一完成后执行: - -```bash -bash deploy/post-deploy.sh --only=sync # ③ 成本快照 / 销售事实 / 总账调整 -``` - -> 工具镜像每次运行都会重建(层缓存下只需几秒),确保 `git pull` 后执行的是当前脚本。 - -### 4. 总账与验收 - -5. 财务 → 总账与对账 → 检查试算平衡;`post-deploy.sh` 已按日追加销售事实调整凭证, - 之后按需关账/导入平台账单。 - -```bash -bash deploy/post-deploy.sh --only=verify # ④ 只读验收 -``` - -退出码:`0` = 完成且验收无 FAIL、`1` = 执行失败、`2` = 验收未通过。 -15 项检查全部 PASS 才算上线完成。若 15 号「近 7 天非人工销售事实成本覆盖」显示 WARN, -说明自动发货/接单平台仍有成本缺口,需要先归一商品并配置成本版本;WARN 不阻断验收, -但会直接影响毛利口径。 - -存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。 - -> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。 -> 定时任务 `cost-finance-sync` 每 5 分钟运行,但**只覆盖最近 7 天**;更早历史靠 `post-deploy.sh` 的分窗同步。 - ---- - -## 服务器日常更新 - -```bash -cd /home/order_site && git pull -bash deploy/ubuntu-deploy.sh # 想看这次会做什么:加 --dry-run -``` - -如果本次包含数据库迁移、履约/商品路由切换或财务派生数据重建,不要直接执行上述命令, -必须按照 [生产升级操作手册](生产升级操作手册.md) 的停写、备份验证、迁移、审计、 -财务重建和验收顺序执行。 - -后端启动时自动执行未应用的增量迁移,一般不必单独跑迁移。 - -确认: - -```bash -docker compose exec -T backend node --input-type=module -e ' -const { query, closeDb } = await import("./dist/db/client.js"); -try { - const result = await query("SELECT COALESCE(filename, name) AS name FROM schema_migrations ORDER BY COALESCE(filename, name)"); - console.log(result.rows.map((row) => row.name).join("\n")); -} finally { - await closeDb(); -} -' -``` - -## 服务器重建数据库 - -**会删除生产数据**。仅适合新环境、测试机或确认可丢数据的场景。 - -```bash -docker compose down -docker volume rm order_site_postgres_data -bash deploy/ubuntu-deploy.sh -``` - -或: - -```bash -bash deploy/ubuntu-deploy.sh --reset-db -``` - -volume 名不确定时: - -```bash -docker volume ls | grep postgres_data -``` - -不要随手: - -```bash -docker compose down -v -``` - -这会连同 `caddy_data` / `caddy_config` 一起删掉,可能触发证书重签。 - -## 常见问题 - -### 后端一直未 ready - -```bash -docker compose ps -docker compose logs --tail=120 backend -``` - -常见原因: - -- `.env` 仍有 `change-me` 占位值 -- `DATABASE_URL` 密码与已有 PostgreSQL volume 初始化密码不一致 -- 容器部署里 `DATABASE_URL` 误写 `127.0.0.1` -- 某次增量迁移 SQL 失败 - -### 改了 `001_init.sql` 但旧库没变 - -已应用过的迁移不会因文件内容变更而重跑。正确做法是 **新增** `002_xxx.sql` 写增量。 - -仅本地可丢数据时,才用重建 volume 的方式对齐。 - -### 如何新增字段 / 索引 - -```bash -docker compose -f docker-compose.dev.yml exec -T backend \ - npm run db:migrate:create -- add_orders_note - -# 编辑生成的 apps/backend/src/db/migrations/00N_add_orders_note.sql -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate -docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate:status -``` - -### 宿主机手动跑迁移连不上 `postgres` - -`.env` 里的 `postgres` 只在 Docker 网络有效。宿主机请临时改为 `127.0.0.1` 并映射端口。