优化任务状态投影和部署文档
This commit is contained in:
@@ -75,6 +75,8 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_platform_order_lookup
|
CREATE INDEX IF NOT EXISTS idx_orders_platform_order_lookup
|
||||||
ON orders(provider, platform, platform_order_id, id DESC);
|
ON orders(provider, platform, platform_order_id, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_created_at_desc
|
||||||
|
ON orders(created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS order_items (
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -127,6 +129,10 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status ON fulfillment_tasks(task_status, delivery_status);
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status ON fulfillment_tasks(task_status, delivery_status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order ON fulfillment_tasks(order_id, order_item_id);
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order ON fulfillment_tasks(order_id, order_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token ON fulfillment_tasks(claim_token);
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token ON fulfillment_tasks(claim_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_platform_order_lookup
|
||||||
|
ON fulfillment_tasks(provider, platform, platform_order_id, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_created_at_desc
|
||||||
|
ON fulfillment_tasks(created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS claim_tokens (
|
CREATE TABLE IF NOT EXISTS claim_tokens (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -169,3 +175,28 @@ CREATE TABLE IF NOT EXISTS task_runtime_contexts (
|
|||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kuaishou_cloud_task_states (
|
||||||
|
task_id BIGINT PRIMARY KEY REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||||
|
ticket_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
ticket_code_masked TEXT NOT NULL DEFAULT '',
|
||||||
|
bind_url TEXT NOT NULL DEFAULT '',
|
||||||
|
vn_phone TEXT NOT NULL DEFAULT '',
|
||||||
|
role_name TEXT NOT NULL DEFAULT '',
|
||||||
|
role_id TEXT NOT NULL DEFAULT '',
|
||||||
|
dispatch_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
consume_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
source_key TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_role_id
|
||||||
|
ON kuaishou_cloud_task_states(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_source_key
|
||||||
|
ON kuaishou_cloud_task_states(source_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_dispatch_status
|
||||||
|
ON kuaishou_cloud_task_states(dispatch_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_consume_status
|
||||||
|
ON kuaishou_cloud_task_states(consume_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_updated_at_desc
|
||||||
|
ON kuaishou_cloud_task_states(updated_at DESC);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { query } from '../db/client.js'
|
||||||
|
import { maskCode } from '../utils/masking.js'
|
||||||
|
import { parseTaskContext } from '../utils/task-json.js'
|
||||||
|
|
||||||
|
type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||||
|
type JsonRecord = Record<string, any>
|
||||||
|
|
||||||
|
export type KuaishouCloudTaskStateSyncInput = {
|
||||||
|
taskId: number | string
|
||||||
|
executorKey?: unknown
|
||||||
|
contextJson?: unknown
|
||||||
|
updatedAt?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncKuaishouCloudTaskStateForTask(
|
||||||
|
input: KuaishouCloudTaskStateSyncInput,
|
||||||
|
executor: QueryExecutor = query,
|
||||||
|
): Promise<void> {
|
||||||
|
const taskId = Number(input.taskId || 0)
|
||||||
|
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(input.executorKey || '').trim() !== 'kuaishou_ct_assisted') {
|
||||||
|
await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = resolveKuaishouCloudFlow(input.contextJson)
|
||||||
|
if (!flow) {
|
||||||
|
await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = asRecord(flow.ticket)
|
||||||
|
const binding = asRecord(flow.binding)
|
||||||
|
const role = asRecord(flow.role)
|
||||||
|
const dispatch = asRecord(flow.dispatch)
|
||||||
|
const consume = asRecord(flow.consume)
|
||||||
|
const roleName = pickFirstNonEmpty([role.name, binding.roleName])
|
||||||
|
const roleId = pickFirstNonEmpty([role.rid, binding.roleId])
|
||||||
|
const updatedAt = String(input.updatedAt || '').trim() || new Date().toISOString()
|
||||||
|
|
||||||
|
await executor(
|
||||||
|
`
|
||||||
|
INSERT INTO kuaishou_cloud_task_states (
|
||||||
|
task_id,
|
||||||
|
ticket_status,
|
||||||
|
ticket_code_masked,
|
||||||
|
bind_url,
|
||||||
|
vn_phone,
|
||||||
|
role_name,
|
||||||
|
role_id,
|
||||||
|
dispatch_status,
|
||||||
|
consume_status,
|
||||||
|
source_key,
|
||||||
|
updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (task_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
ticket_status = EXCLUDED.ticket_status,
|
||||||
|
ticket_code_masked = EXCLUDED.ticket_code_masked,
|
||||||
|
bind_url = EXCLUDED.bind_url,
|
||||||
|
vn_phone = EXCLUDED.vn_phone,
|
||||||
|
role_name = EXCLUDED.role_name,
|
||||||
|
role_id = EXCLUDED.role_id,
|
||||||
|
dispatch_status = EXCLUDED.dispatch_status,
|
||||||
|
consume_status = EXCLUDED.consume_status,
|
||||||
|
source_key = EXCLUDED.source_key,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
taskId,
|
||||||
|
normalizeStatus(ticket.status),
|
||||||
|
maskCode(ticket.code),
|
||||||
|
String(binding.bindUrl || '').trim(),
|
||||||
|
String(binding.vnPhone || '').trim(),
|
||||||
|
roleName,
|
||||||
|
roleId,
|
||||||
|
normalizeStatus(dispatch.status),
|
||||||
|
normalizeStatus(consume.status),
|
||||||
|
String(binding.resolvedSourceKey || '').trim(),
|
||||||
|
updatedAt,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null {
|
||||||
|
const context = parseTaskContext({ context_json: contextJson })
|
||||||
|
const flow = asRecord(context.kuaishouCloudFulfillment)
|
||||||
|
return Object.keys(flow).length > 0 ? flow : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): JsonRecord {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: unknown): string {
|
||||||
|
return String(value || 'pending').trim() || 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstNonEmpty(values: unknown[]): string {
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (normalized) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, withTransaction } from '../db/client.js'
|
import { query, withTransaction } from '../db/client.js'
|
||||||
import type { PoolClient } from 'pg'
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js'
|
||||||
import type {
|
import type {
|
||||||
TaskCreateInput,
|
TaskCreateInput,
|
||||||
TaskListQueryInput,
|
TaskListQueryInput,
|
||||||
@@ -42,13 +43,24 @@ const TASK_FIELDS = `
|
|||||||
ctx.partition_name,
|
ctx.partition_name,
|
||||||
ctx.screenshot_path,
|
ctx.screenshot_path,
|
||||||
ctx.artifacts_json,
|
ctx.artifacts_json,
|
||||||
ctx.state_json
|
ctx.state_json,
|
||||||
|
kcts.ticket_status AS kuaishou_ticket_status,
|
||||||
|
kcts.ticket_code_masked AS kuaishou_ticket_code_masked,
|
||||||
|
kcts.bind_url AS kuaishou_bind_url,
|
||||||
|
kcts.vn_phone AS kuaishou_vn_phone,
|
||||||
|
kcts.role_name AS kuaishou_role_name,
|
||||||
|
kcts.role_id AS kuaishou_role_id,
|
||||||
|
kcts.dispatch_status AS kuaishou_dispatch_status,
|
||||||
|
kcts.consume_status AS kuaishou_consume_status,
|
||||||
|
kcts.source_key AS kuaishou_source_key,
|
||||||
|
kcts.updated_at AS kuaishou_state_updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
const TASK_JOINS = `
|
const TASK_JOINS = `
|
||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
||||||
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||||
|
LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||||
`
|
`
|
||||||
|
|
||||||
function buildTaskSelect(extraFields = ''): string {
|
function buildTaskSelect(extraFields = ''): string {
|
||||||
@@ -138,6 +150,13 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
await upsertTaskRuntimeContextWithClient(client, taskId, input.runtimeContext, input.createdAt)
|
await upsertTaskRuntimeContextWithClient(client, taskId, input.runtimeContext, input.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncKuaishouCloudTaskStateForTask({
|
||||||
|
taskId,
|
||||||
|
executorKey: input.executorKey,
|
||||||
|
contextJson: input.contextJson || '{}',
|
||||||
|
updatedAt: input.updatedAt,
|
||||||
|
}, client.query.bind(client) as TaskQueryExecutor)
|
||||||
|
|
||||||
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -210,6 +229,13 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
|
|||||||
await upsertTaskRuntimeContextWithClient(client, Number(taskId), runtimeContextPatch, patch.updated_at || current.updated_at)
|
await upsertTaskRuntimeContextWithClient(client, Number(taskId), runtimeContextPatch, patch.updated_at || current.updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncKuaishouCloudTaskStateForTask({
|
||||||
|
taskId,
|
||||||
|
executorKey: next.executor_key,
|
||||||
|
contextJson: next.context_json,
|
||||||
|
updatedAt: next.updated_at,
|
||||||
|
}, client.query.bind(client) as TaskQueryExecutor)
|
||||||
|
|
||||||
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -220,47 +246,62 @@ export async function updateTaskStatusIfCurrent(
|
|||||||
patch: TaskUpdatePatch,
|
patch: TaskUpdatePatch,
|
||||||
): Promise<TaskRow | null> {
|
): Promise<TaskRow | null> {
|
||||||
const now = String(patch.updated_at || '').trim()
|
const now = String(patch.updated_at || '').trim()
|
||||||
const result = await query<{ id: number }>(
|
return withTransaction(async (client) => {
|
||||||
`
|
const result = await client.query<{
|
||||||
UPDATE fulfillment_tasks
|
id: number
|
||||||
SET
|
executor_key: string
|
||||||
task_status = $1,
|
context_json: string | Record<string, unknown>
|
||||||
delivery_status = COALESCE($2, delivery_status),
|
updated_at: string
|
||||||
result_code = COALESCE($3, result_code),
|
}>(
|
||||||
result_message = COALESCE($4, result_message),
|
`
|
||||||
user_action_status = COALESCE($5, user_action_status),
|
UPDATE fulfillment_tasks
|
||||||
last_error = COALESCE($6, last_error),
|
SET
|
||||||
context_json = COALESCE($7::jsonb, context_json),
|
task_status = $1,
|
||||||
redeemed_at = COALESCE($8, redeemed_at),
|
delivery_status = COALESCE($2, delivery_status),
|
||||||
updated_at = COALESCE($9, updated_at)
|
result_code = COALESCE($3, result_code),
|
||||||
WHERE id = $10
|
result_message = COALESCE($4, result_message),
|
||||||
AND task_status = $11
|
user_action_status = COALESCE($5, user_action_status),
|
||||||
RETURNING id
|
last_error = COALESCE($6, last_error),
|
||||||
`,
|
context_json = COALESCE($7::jsonb, context_json),
|
||||||
[
|
redeemed_at = COALESCE($8, redeemed_at),
|
||||||
patch.task_status,
|
updated_at = COALESCE($9, updated_at)
|
||||||
patch.delivery_status ?? null,
|
WHERE id = $10
|
||||||
patch.result_code ?? null,
|
AND task_status = $11
|
||||||
patch.result_message ?? null,
|
RETURNING id, executor_key, context_json, updated_at
|
||||||
patch.user_action_status ?? null,
|
`,
|
||||||
patch.last_error ?? null,
|
[
|
||||||
typeof patch.context_json === 'undefined'
|
patch.task_status,
|
||||||
? null
|
patch.delivery_status ?? null,
|
||||||
: typeof patch.context_json === 'string'
|
patch.result_code ?? null,
|
||||||
? patch.context_json
|
patch.result_message ?? null,
|
||||||
: JSON.stringify(patch.context_json || {}),
|
patch.user_action_status ?? null,
|
||||||
patch.redeemed_at ?? null,
|
patch.last_error ?? null,
|
||||||
now || null,
|
typeof patch.context_json === 'undefined'
|
||||||
Number(taskId),
|
? null
|
||||||
currentStatus,
|
: typeof patch.context_json === 'string'
|
||||||
],
|
? patch.context_json
|
||||||
)
|
: JSON.stringify(patch.context_json || {}),
|
||||||
|
patch.redeemed_at ?? null,
|
||||||
|
now || null,
|
||||||
|
Number(taskId),
|
||||||
|
currentStatus,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
if (!result.rows[0]) {
|
const updated = result.rows[0]
|
||||||
return null
|
if (!updated) {
|
||||||
}
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return getTaskById(taskId)
|
await syncKuaishouCloudTaskStateForTask({
|
||||||
|
taskId: updated.id,
|
||||||
|
executorKey: updated.executor_key,
|
||||||
|
contextJson: updated.context_json,
|
||||||
|
updatedAt: updated.updated_at,
|
||||||
|
}, client.query.bind(client) as TaskQueryExecutor)
|
||||||
|
|
||||||
|
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTaskById(taskId: number | string): Promise<TaskRow | null> {
|
export async function getTaskById(taskId: number | string): Promise<TaskRow | null> {
|
||||||
@@ -315,7 +356,7 @@ export async function listTasks({
|
|||||||
|
|
||||||
if (roleId) {
|
if (roleId) {
|
||||||
params.push(`%${roleId}%`)
|
params.push(`%${roleId}%`)
|
||||||
filters.push(`ctx.role_id ILIKE $${params.length}`)
|
filters.push(`COALESCE(NULLIF(kcts.role_id, ''), ctx.role_id) ILIKE $${params.length}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
@@ -335,6 +376,7 @@ export async function listTasks({
|
|||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
|
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
|
||||||
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||||
|
LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { query } from '../../db/client.js'
|
import { query } from '../../db/client.js'
|
||||||
|
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
export async function getAdminDashboardSummary() {
|
export async function getAdminDashboardSummary() {
|
||||||
const todayPrefix = nowIso().slice(0, 10)
|
const todayPrefix = nowIso().slice(0, 10)
|
||||||
|
const paidPendingClaimStatuses = [
|
||||||
|
TASK_STATUS.PAID,
|
||||||
|
TASK_STATUS.LINK_GENERATED,
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
]
|
||||||
|
const claimingStatuses = [
|
||||||
|
TASK_STATUS.CLAIMED,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
]
|
||||||
|
const abnormalStatuses = [
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
]
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -12,26 +28,32 @@ export async function getAdminDashboardSummary() {
|
|||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('paid', 'link_generated', 'pending_binding_prepare', 'waiting_binding')
|
WHERE task_status = ANY($2::text[])
|
||||||
) AS paid_pending_claim,
|
) AS paid_pending_claim,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')
|
WHERE task_status = ANY($3::text[])
|
||||||
) AS claiming_tasks,
|
) AS claiming_tasks,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status = 'redeemed'
|
WHERE task_status = $4
|
||||||
AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1
|
AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1
|
||||||
) AS redeemed_today,
|
) AS redeemed_today,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('retry_pending', 'manual_review')
|
WHERE task_status = ANY($5::text[])
|
||||||
) AS abnormal_tasks
|
) AS abnormal_tasks
|
||||||
`,
|
`,
|
||||||
[todayPrefix],
|
[
|
||||||
|
todayPrefix,
|
||||||
|
paidPendingClaimStatuses,
|
||||||
|
claimingStatuses,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
abnormalStatuses,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
const summary: JsonObject = result.rows[0] || {}
|
const summary: JsonObject = result.rows[0] || {}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getTaskPrimaryClaimTokenId,
|
getTaskPrimaryClaimTokenId,
|
||||||
isManualDispatchTask,
|
isManualDispatchTask,
|
||||||
mapKuaishouCloudFulfillmentContext,
|
mapKuaishouCloudFulfillmentContext,
|
||||||
|
mapKuaishouCloudTaskStateProjection,
|
||||||
mapManualDispatchContext,
|
mapManualDispatchContext,
|
||||||
mapRedeemResolutionContext,
|
mapRedeemResolutionContext,
|
||||||
parseTaskContext,
|
parseTaskContext,
|
||||||
@@ -180,10 +181,11 @@ export async function getAdminTaskDetail(
|
|||||||
const viewerContext = createAdminViewerContext(session)
|
const viewerContext = createAdminViewerContext(session)
|
||||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||||
|
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||||
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(
|
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(
|
||||||
taskContext.kuaishouCloudFulfillment,
|
taskContext.kuaishouCloudFulfillment,
|
||||||
{ cloudSourceLabelMap: buildCloudSourceLabelMap() },
|
{ cloudSourceLabelMap },
|
||||||
)
|
) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
task: mapAdminTaskListItem({
|
task: mapAdminTaskListItem({
|
||||||
|
|||||||
@@ -160,6 +160,105 @@ export function mapKuaishouCloudFulfillmentContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapKuaishouCloudTaskStateProjection(
|
||||||
|
task: TaskLike | null | undefined,
|
||||||
|
options: KuaishouCloudFulfillmentMapOptions = {},
|
||||||
|
): JsonRecord | null {
|
||||||
|
if (!task || String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProjection = [
|
||||||
|
task.kuaishou_ticket_status,
|
||||||
|
task.kuaishou_ticket_code_masked,
|
||||||
|
task.kuaishou_bind_url,
|
||||||
|
task.kuaishou_vn_phone,
|
||||||
|
task.kuaishou_role_name,
|
||||||
|
task.kuaishou_role_id,
|
||||||
|
task.kuaishou_dispatch_status,
|
||||||
|
task.kuaishou_consume_status,
|
||||||
|
task.kuaishou_source_key,
|
||||||
|
].some((value) => String(value || '').trim())
|
||||||
|
|
||||||
|
if (!hasProjection) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceKey = String(task.kuaishou_source_key || '').trim()
|
||||||
|
const roleName = String(task.kuaishou_role_name || '').trim()
|
||||||
|
const roleId = String(task.kuaishou_role_id || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
flowType: 'kuaishou_cloud_fulfillment',
|
||||||
|
configId: '',
|
||||||
|
internalSkuCode: '',
|
||||||
|
internalSkuName: '',
|
||||||
|
ticket: {
|
||||||
|
code: String(task.kuaishou_ticket_code_masked || '').trim(),
|
||||||
|
capturedAt: null,
|
||||||
|
status: String(task.kuaishou_ticket_status || 'pending').trim() || 'pending',
|
||||||
|
verifiedAt: null,
|
||||||
|
goodsTitle: '',
|
||||||
|
leftCount: 0,
|
||||||
|
},
|
||||||
|
binding: {
|
||||||
|
prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending',
|
||||||
|
cloudSourceKeys: sourceKey ? [sourceKey] : [],
|
||||||
|
cloudSourceLabels: sourceKey ? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)] : [],
|
||||||
|
resolvedSourceKey: sourceKey,
|
||||||
|
resolvedSourceLabel: sourceKey ? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap) : '',
|
||||||
|
skuId: 0,
|
||||||
|
skuName: '',
|
||||||
|
vnKey: '',
|
||||||
|
vnId: 0,
|
||||||
|
vnPhone: String(task.kuaishou_vn_phone || '').trim(),
|
||||||
|
bindUrl: String(task.kuaishou_bind_url || '').trim(),
|
||||||
|
bindPreparedAt: null,
|
||||||
|
bindExpiresAt: null,
|
||||||
|
bindProbeAt: null,
|
||||||
|
bindProbeStatus: '',
|
||||||
|
bindProbeMessage: '',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
status: roleName || roleId ? 'ready' : 'pending',
|
||||||
|
name: roleName,
|
||||||
|
rid: roleId,
|
||||||
|
refreshedAt: null,
|
||||||
|
errorMessage: '',
|
||||||
|
rawInfo: null,
|
||||||
|
},
|
||||||
|
purchase: {
|
||||||
|
autoBuyEnabled: true,
|
||||||
|
minAssetReserve: 0,
|
||||||
|
usedKnapsack: false,
|
||||||
|
purchaseTriggered: false,
|
||||||
|
assetBefore: 0,
|
||||||
|
assetAfter: 0,
|
||||||
|
purchaseAt: null,
|
||||||
|
},
|
||||||
|
dispatch: {
|
||||||
|
status: String(task.kuaishou_dispatch_status || 'pending').trim() || 'pending',
|
||||||
|
dispatchAt: null,
|
||||||
|
sendType: 0,
|
||||||
|
note: '',
|
||||||
|
},
|
||||||
|
returnNumber: {
|
||||||
|
status: 'pending',
|
||||||
|
returnedAt: null,
|
||||||
|
autoReturnEnabled: false,
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
status: String(task.kuaishou_consume_status || 'pending').trim() || 'pending',
|
||||||
|
shopId: '',
|
||||||
|
shopName: '',
|
||||||
|
autoConsumeEnabled: false,
|
||||||
|
consumedAt: null,
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMap | undefined) {
|
function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMap | undefined) {
|
||||||
const key = String(sourceKey || '').trim()
|
const key = String(sourceKey || '').trim()
|
||||||
if (!key) return ''
|
if (!key) return ''
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export function mapAdminTaskListItem(
|
|||||||
resourceStatus: fulfillment.resourceStatus,
|
resourceStatus: fulfillment.resourceStatus,
|
||||||
customerStatus: fulfillment.customerStatus,
|
customerStatus: fulfillment.customerStatus,
|
||||||
loginType: task.login_type,
|
loginType: task.login_type,
|
||||||
roleName: task.role_name,
|
roleName: resolveTaskDisplayRoleName(task),
|
||||||
roleId: task.role_id,
|
roleId: resolveTaskDisplayRoleId(task),
|
||||||
runtimeSessionId: task.runtime_session_id,
|
runtimeSessionId: task.runtime_session_id,
|
||||||
claimedAt: task.claimed_at,
|
claimedAt: task.claimed_at,
|
||||||
roleConfirmedAt: task.role_confirmed_at,
|
roleConfirmedAt: task.role_confirmed_at,
|
||||||
@@ -269,3 +269,11 @@ function normalizeRecord(value: unknown): JsonRecord {
|
|||||||
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
||||||
return Number(task?.attempt_count || 0)
|
return Number(task?.attempt_count || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTaskDisplayRoleName(task: TaskRow): string {
|
||||||
|
return String(task.kuaishou_role_name || task.role_name || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskDisplayRoleId(task: TaskRow): string {
|
||||||
|
return String(task.kuaishou_role_id || task.role_id || '').trim()
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../../platforms/kuaishou-eticket/source-config-service.js'
|
} from '../../platforms/kuaishou-eticket/source-config-service.js'
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { nowIso } from '../../../utils/time.js'
|
import { nowIso } from '../../../utils/time.js'
|
||||||
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
parseTaskContext,
|
parseTaskContext,
|
||||||
@@ -200,7 +201,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'waiting_binding',
|
task_status: TASK_STATUS.WAITING_BINDING,
|
||||||
user_action_status: 'pending',
|
user_action_status: 'pending',
|
||||||
claim_token: claimLinkState.token || task.claim_token || '',
|
claim_token: claimLinkState.token || task.claim_token || '',
|
||||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||||
@@ -334,7 +335,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'dispatched_pending_return',
|
task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
delivery_status: 'delivered',
|
delivery_status: 'delivered',
|
||||||
result_code: 'kuaishou_cloud_dispatched',
|
result_code: 'kuaishou_cloud_dispatched',
|
||||||
result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(),
|
result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { updateTask } from '../../../repositories/task-repo.js'
|
|||||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { nowIso } from '../../../utils/time.js'
|
import { nowIso } from '../../../utils/time.js'
|
||||||
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
canViewerCloseTask,
|
canViewerCloseTask,
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
@@ -44,7 +45,7 @@ export async function closeAdminTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'closed',
|
task_status: TASK_STATUS.CLOSED,
|
||||||
delivery_status: 'closed',
|
delivery_status: 'closed',
|
||||||
result_code: 'closed_by_admin',
|
result_code: 'closed_by_admin',
|
||||||
result_message: '任务已关闭,领取链接已失效',
|
result_message: '任务已关闭,领取链接已失效',
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export type TaskRow = {
|
|||||||
retry_count: number
|
retry_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
kuaishou_ticket_status?: string
|
||||||
|
kuaishou_ticket_code_masked?: string
|
||||||
|
kuaishou_bind_url?: string
|
||||||
|
kuaishou_vn_phone?: string
|
||||||
|
kuaishou_role_name?: string
|
||||||
|
kuaishou_role_id?: string
|
||||||
|
kuaishou_dispatch_status?: string
|
||||||
|
kuaishou_consume_status?: string
|
||||||
|
kuaishou_source_key?: string
|
||||||
|
kuaishou_state_updated_at?: string | null
|
||||||
claimed_at: string | null
|
claimed_at: string | null
|
||||||
role_confirmed_at: string | null
|
role_confirmed_at: string | null
|
||||||
redeemed_at: string | null
|
redeemed_at: string | null
|
||||||
|
|||||||
+6
-1
@@ -8,6 +8,7 @@ import type {
|
|||||||
AdminCloudtentaclesValidateSessionResult,
|
AdminCloudtentaclesValidateSessionResult,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { useRememberedCollapse } from '../../composables/useRememberedCollapse'
|
||||||
|
|
||||||
type CloudtentaclesForm = {
|
type CloudtentaclesForm = {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -50,6 +51,10 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const advancedCollapseNames = useRememberedCollapse(
|
||||||
|
'admin:platform-config:cloudtentacles:config:collapse',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -245,7 +250,7 @@ defineProps<Props>()
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-collapse class="advanced-collapse">
|
<el-collapse v-model="advancedCollapseNames" class="advanced-collapse">
|
||||||
<el-collapse-item name="advanced" title="高级参数与调试字段">
|
<el-collapse-item name="advanced" title="高级参数与调试字段">
|
||||||
<el-form label-width="auto" label-position="top">
|
<el-form label-width="auto" label-position="top">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRememberedCollapse } from '../../composables/useRememberedCollapse'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
cloudtentaclesDebugLoading: boolean
|
cloudtentaclesDebugLoading: boolean
|
||||||
handleCloudtentaclesFetchAsset: () => void | Promise<void>
|
handleCloudtentaclesFetchAsset: () => void | Promise<void>
|
||||||
@@ -19,11 +21,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const debugCollapseNames = useRememberedCollapse(
|
||||||
|
'admin:platform-config:cloudtentacles:debug:collapse',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-card class="debug-card">
|
<el-card class="debug-card">
|
||||||
<el-collapse>
|
<el-collapse v-model="debugCollapseNames">
|
||||||
<el-collapse-item name="debug">
|
<el-collapse-item name="debug">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="debug-title">
|
<div class="debug-title">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ref, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useRememberedCollapse(storageKey: string, defaultValue: string[] = []): Ref<string[]> {
|
||||||
|
const activeNames = ref<string[]>(readStoredCollapseNames(storageKey, defaultValue))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
activeNames,
|
||||||
|
(names) => {
|
||||||
|
writeStoredCollapseNames(storageKey, names)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return activeNames
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredCollapseNames(storageKey: string, defaultValue: string[]): string[] {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return [...defaultValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(storageKey)
|
||||||
|
if (!rawValue) {
|
||||||
|
return [...defaultValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(rawValue)
|
||||||
|
return normalizeCollapseNames(parsed, defaultValue)
|
||||||
|
} catch {
|
||||||
|
return [...defaultValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredCollapseNames(storageKey: string, names: string[]): void {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(normalizeCollapseNames(names)))
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用存储时忽略,折叠状态退化为本次会话内有效。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCollapseNames(value: unknown, fallback: string[] = []): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...fallback]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseLocalStorage(): boolean {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||||
|
}
|
||||||
@@ -64,6 +64,23 @@ validate_env_file() {
|
|||||||
site_addr="$(read_env_value CADDY_SITE_ADDR)"
|
site_addr="$(read_env_value CADDY_SITE_ADDR)"
|
||||||
[[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_SITE_ADDR。"
|
[[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_SITE_ADDR。"
|
||||||
HEALTH_URL="${site_addr%/}/health/ready"
|
HEALTH_URL="${site_addr%/}/health/ready"
|
||||||
|
|
||||||
|
validate_database_url_for_compose
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_database_url_for_compose() {
|
||||||
|
local database_url
|
||||||
|
database_url="$(read_env_value DATABASE_URL)"
|
||||||
|
|
||||||
|
if [[ -z "${database_url}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${database_url}" == *"@localhost:"* \
|
||||||
|
|| "${database_url}" == *"@127.0.0.1:"* \
|
||||||
|
|| "${database_url}" == *"@[::1]:"* ]]; then
|
||||||
|
fail ".env 的 DATABASE_URL 当前指向本机地址。Docker 部署时后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名或外部数据库地址。"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_data_dir() {
|
ensure_data_dir() {
|
||||||
@@ -111,6 +128,27 @@ wait_for_health() {
|
|||||||
fail "部署已启动但健康检查未通过,请根据日志排查。"
|
fail "部署已启动但健康检查未通过,请根据日志排查。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print_applied_migrations() {
|
||||||
|
log "已应用数据库迁移"
|
||||||
|
|
||||||
|
local migrations
|
||||||
|
if migrations="$(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 filename FROM schema_migrations ORDER BY filename");
|
||||||
|
console.log(result.rows.map((row) => row.filename).join("\n") || "(无迁移记录)");
|
||||||
|
} finally {
|
||||||
|
await closeDb();
|
||||||
|
}
|
||||||
|
' 2>&1)"; then
|
||||||
|
printf '%s\n' "${migrations}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "读取迁移记录失败:"
|
||||||
|
printf '%s\n' "${migrations}"
|
||||||
|
}
|
||||||
|
|
||||||
print_diagnostics() {
|
print_diagnostics() {
|
||||||
warn "当前容器状态:"
|
warn "当前容器状态:"
|
||||||
docker compose ps || true
|
docker compose ps || true
|
||||||
@@ -128,6 +166,8 @@ print_diagnostics() {
|
|||||||
|
|
||||||
warn "后端 ready 接口:"
|
warn "后端 ready 接口:"
|
||||||
docker compose exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true
|
docker compose exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true
|
||||||
|
|
||||||
|
print_applied_migrations || true
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
@@ -139,6 +179,7 @@ main() {
|
|||||||
ensure_data_dir
|
ensure_data_dir
|
||||||
deploy_stack
|
deploy_stack
|
||||||
wait_for_health
|
wait_for_health
|
||||||
|
print_applied_migrations
|
||||||
|
|
||||||
log "部署完成"
|
log "部署完成"
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# 部署启动与数据库重建
|
||||||
|
|
||||||
|
本文记录本地开发、数据库重建和 Ubuntu 服务器部署的常用命令。
|
||||||
|
|
||||||
|
项目当前数据库迁移以新库初始化为主,主要结构在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/backend/src/db/migrations/001_init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
后端启动时会自动执行数据库迁移。迁移记录保存在数据库表 `schema_migrations`。
|
||||||
|
|
||||||
|
## 本地开发启动
|
||||||
|
|
||||||
|
首次启动:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`
|
||||||
|
|
||||||
|
## 本地重建数据库
|
||||||
|
|
||||||
|
如果迁移基线变化,或者需要清空本地测试数据,可以重建本地开发库。
|
||||||
|
|
||||||
|
只重建开发数据库,保留源码文件:
|
||||||
|
|
||||||
|
```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 不存在,说明本地数据库 volume 名称可能不同,可以先查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep postgres_dev_data
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以直接删除当前 dev compose 创建的所有 volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`down -v` 会同时删除开发环境里的 `postgres_dev_data`、`backend_node_modules`、`frontend_node_modules` 等 volume。下次启动会重新安装依赖。
|
||||||
|
|
||||||
|
确认迁移结果:
|
||||||
|
|
||||||
|
```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 filename FROM schema_migrations ORDER BY filename");
|
||||||
|
console.log(result.rows.map((row) => row.filename).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
|
||||||
|
```
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 lint
|
||||||
|
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
手动执行迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器首次部署
|
||||||
|
|
||||||
|
服务器建议使用 Ubuntu + Docker Compose。
|
||||||
|
|
||||||
|
进入项目目录后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.server.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env`,至少替换这些值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CADDY_SITE_ADDR
|
||||||
|
CLAIM_BASE_URL
|
||||||
|
POSTGRES_PASSWORD
|
||||||
|
DATABASE_URL
|
||||||
|
ADMIN_SESSION_SECRET
|
||||||
|
ADMIN_DEFAULT_USERS_JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
生产 Docker 部署时,`DATABASE_URL` 如果连接 compose 内置 PostgreSQL,主机名应使用 `postgres`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL=postgres://postgres:你的数据库密码@postgres:5432/order_site
|
||||||
|
```
|
||||||
|
|
||||||
|
不要写成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL=postgres://postgres:你的数据库密码@127.0.0.1:5432/order_site
|
||||||
|
```
|
||||||
|
|
||||||
|
因为后端运行在容器内,容器内的 `127.0.0.1` 指向后端容器自己,不是 PostgreSQL 容器。
|
||||||
|
|
||||||
|
执行部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/ubuntu-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:
|
||||||
|
|
||||||
|
- 检查 Docker 和 Docker Compose
|
||||||
|
- 检查 `.env` 是否仍有占位值
|
||||||
|
- 检查容器部署时 `DATABASE_URL` 是否误写成本机地址
|
||||||
|
- 修正 `apps/backend/data` 目录权限
|
||||||
|
- 构建并启动服务
|
||||||
|
- 等待健康检查通过
|
||||||
|
- 打印已应用数据库迁移
|
||||||
|
|
||||||
|
部署完成后查看服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器日常更新
|
||||||
|
|
||||||
|
拉取或上传新代码后,在项目目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/ubuntu-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
后端容器启动时会自动执行迁移,不需要单独手动跑迁移。
|
||||||
|
|
||||||
|
确认迁移记录:
|
||||||
|
|
||||||
|
```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 filename FROM schema_migrations ORDER BY filename");
|
||||||
|
console.log(result.rows.map((row) => row.filename).join("\n"));
|
||||||
|
} finally {
|
||||||
|
await closeDb();
|
||||||
|
}
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器重建数据库
|
||||||
|
|
||||||
|
下面命令会删除生产数据库数据。只适合新开发、测试服务器或确认不需要保留数据的场景。
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
只删除 PostgreSQL 数据 volume,保留 Caddy 证书和配置 volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume rm order_site_postgres_data
|
||||||
|
```
|
||||||
|
|
||||||
|
重新部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/ubuntu-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 volume 名称不确定,可以查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep postgres_data
|
||||||
|
```
|
||||||
|
|
||||||
|
不建议在生产服务器随手执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
因为它会同时删除 `postgres_data`、`caddy_data`、`caddy_config`。这会清空数据库,并可能让 Caddy 重新申请证书。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 后端一直未 ready
|
||||||
|
|
||||||
|
查看诊断:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=120 backend
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因:
|
||||||
|
|
||||||
|
- `.env` 仍有 `change-me` 占位值
|
||||||
|
- `DATABASE_URL` 密码和已有 PostgreSQL volume 初始化密码不一致
|
||||||
|
- `DATABASE_URL` 在容器部署中误写成 `127.0.0.1`
|
||||||
|
- 后端迁移失败
|
||||||
|
|
||||||
|
### 修改了 `001_init.sql` 但旧库没有变化
|
||||||
|
|
||||||
|
`schema_migrations` 只记录迁移文件是否执行过。已经执行过 `001_init.sql` 的数据库,不会因为文件内容变化自动重跑。
|
||||||
|
|
||||||
|
新开发阶段如果不需要保留数据,直接重建数据库即可。
|
||||||
|
|
||||||
|
### 宿主机手动跑迁移连不上 `postgres`
|
||||||
|
|
||||||
|
`.env` 里的 `postgres` 主机名只在 Docker 网络里有效。宿主机直接跑迁移时需要临时使用宿主机地址:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
容器内执行迁移时继续使用 `.env` 中的 `postgres` 服务名即可。
|
||||||
Reference in New Issue
Block a user