修复接单模板动态匹配并移除冗余运维文档
This commit is contained in:
@@ -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/` 只用于追溯,不作为实现依据。
|
||||
|
||||
## 唯一权威
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
生产入口示例:
|
||||
|
||||
|
||||
@@ -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 必须指向真实模板;空值表示按订单动态匹配';
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<number | null> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | undefined
|
||||
note?: string | undefined
|
||||
adminUserId?: number | null
|
||||
clearCanonicalProduct?: boolean
|
||||
replaceConfig?: boolean
|
||||
}): Promise<void> {
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ test(
|
||||
channel: 'work_order',
|
||||
priority: 10,
|
||||
isFallback: false,
|
||||
configJson: { workRuleKey: `rule-${suffix}` },
|
||||
configJson: { templateSelection: 'dynamic' },
|
||||
source: 'integration_test',
|
||||
replacePriority: true,
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}>(
|
||||
`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<Record<string, unknown>>(
|
||||
'SELECT * FROM order_items WHERE id = $1',
|
||||
[containsItem.orderItemId],
|
||||
)
|
||||
const seriesOrderItem = await query<Record<string, unknown>>(
|
||||
'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()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -18,11 +18,10 @@
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| [生产升级操作手册](生产升级操作手册.md) | 含迁移、履约切换、财务重建的生产升级与回滚步骤 |
|
||||
| [部署启动与数据库重建](部署启动与数据库重建.md) | 本地与生产部署、数据库重建 |
|
||||
| [数据库备份与恢复](数据库备份与恢复.md) | 备份、恢复、OSS |
|
||||
| [数据库SQL约束与运维](数据库SQL约束与运维.md) | SQL 护栏与生产观测 |
|
||||
|
||||
部署、备份、恢复和生产升级以 `deploy/` 下脚本、Compose 文件和实际迁移为准,不另设文档副本。
|
||||
|
||||
## 专项参考
|
||||
|
||||
专项参考只解释具体平台、工具或历史问题,不能覆盖唯一权威文档。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
- 每个备份对象附带 `<key>.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/`。
|
||||
@@ -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:
|
||||
|
||||
已知待分类项及负责人:
|
||||
最终结论:继续 / 暂停 / 回滚
|
||||
```
|
||||
+22
-8
@@ -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`,并验证第二次重建幂等。
|
||||
|
||||
@@ -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` 并映射端口。
|
||||
Reference in New Issue
Block a user