diff --git a/apps/backend/data/.gitkeep b/apps/backend/data/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/backend/data/cloudtentacles-sources.example.json b/apps/backend/data/cloudtentacles-sources.example.json deleted file mode 100644 index 3e5b3f88..00000000 --- a/apps/backend/data/cloudtentacles-sources.example.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "enabled": true, - "sources": [ - { - "key": "default", - "label": "默认账号", - "enabled": true, - "baseUrl": "https://example.com", - "username": "", - "password": "", - "phone": "", - "deviceId": "08bc9d8c-fd15-48ea-bc00-8d754076cafc", - "deviceType": 1 - } - ] -} diff --git a/apps/backend/data/notification-config.example.json b/apps/backend/data/notification-config.example.json deleted file mode 100644 index 1f1a0785..00000000 --- a/apps/backend/data/notification-config.example.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "enabled": true, - "channels": { - "bark": { - "enabled": true, - "serverUrl": "https://api.day.app", - "recipients": [] - }, - "wpush": { - "enabled": true, - "recipients": [] - } - } -} diff --git a/apps/backend/data/scheduled-jobs.example.json b/apps/backend/data/scheduled-jobs.example.json deleted file mode 100644 index ac9517a1..00000000 --- a/apps/backend/data/scheduled-jobs.example.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "enabled": true, - "jobs": [ - { - "id": "cloudtentacles-health", - "type": "cloudtentacles_health", - "enabled": false, - "intervalSeconds": 300, - "cooldownSeconds": 1800, - "config": { - "assetThreshold": 500, - "accounts": [ - { - "sourceKey": "default", - "label": "默认账号", - "enabled": true, - "assetThreshold": 500 - } - ] - } - } - ] -} diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index 5b851cc9..0879dfde 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -81,7 +81,7 @@ router.post( router.post( '/kuaishou-industry/vouchers/check-available', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => checkAdminKuaishouIndustryVoucherAvailable( @@ -98,7 +98,7 @@ router.post( router.post( '/kuaishou-industry/vouchers/reverse', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => reverseAdminKuaishouIndustryVoucher(req.body as AdminKuaishouIndustryVoucherReverseRouteBody), @@ -113,7 +113,7 @@ router.post( router.post( '/kuaishou-industry/vouchers/consume', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => consumeAdminKuaishouIndustryVoucherByCode( @@ -130,7 +130,7 @@ router.post( router.post( '/kuaishou-industry/vouchers/resend-code', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => resendAdminKuaishouIndustryVoucherCode( diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 34a5868d..d23520eb 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -34,7 +34,7 @@ router.get('/orders/:orderId', createJsonHandler( router.post( '/orders/:orderId/kuaishou-industry/resend-code', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => resendAdminOrderKuaishouIndustryVoucherCodes( diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index a97aec39..6602ea2b 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -10,9 +10,9 @@ import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js"; const router = Router(); +router.use("/platform-config", kuaishouIndustryRouter); router.use("/platform-config", requireAdminRoles(["admin"])); router.use("/platform-config", notificationsRouter); -router.use("/platform-config", kuaishouIndustryRouter); router.use("/platform-config", kuaishouFeifeiRouter); router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", fulfillmentRoutingRouter); diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts index 376a8bcf..56104a8c 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts @@ -10,13 +10,14 @@ import type { AdminKuaishouIndustryAuthorizationCodeRouteBody, AdminKuaishouIndustrySourceConfigRouteBody, } from '../../../types/admin/route-inputs.js' -import { createJsonHandler } from '../session.js' +import { createJsonHandler, requireAdminRoles } from '../session.js' import type { JsonRecord } from '../../../types/json.js' const router = Router() router.get( '/kuaishou-industry-source', + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(() => getAdminKuaishouIndustrySourceConfig(), { successMessage: 'ok', errorMessage: '读取快手行业电子凭证配置失败', @@ -26,6 +27,7 @@ router.get( router.post( '/kuaishou-industry-source', + requireAdminRoles(['admin']), createJsonHandler( (req) => updateAdminKuaishouIndustrySourceConfig( @@ -57,6 +59,7 @@ router.post( router.post( '/kuaishou-industry-source/refresh-token', + requireAdminRoles(['admin']), createJsonHandler((req) => refreshAdminKuaishouIndustryAccessToken(req.body as JsonRecord), { successMessage: '快手行业电子凭证 accessToken 已刷新', errorMessage: '刷新快手行业电子凭证 accessToken 失败', @@ -80,6 +83,7 @@ router.post( router.post( '/kuaishou-industry-source/exchange-code', + requireAdminRoles(['admin']), createJsonHandler( (req) => exchangeAdminKuaishouIndustryAuthorizationCode( diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index d1bfd28e..fb7792a5 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -149,7 +149,7 @@ router.post( router.post( '/tasks/:taskId/kuaishou-industry/resend-code', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => resendAdminTaskKuaishouIndustryVoucherCode(getTaskId(req), req.adminSession || null), @@ -164,7 +164,7 @@ router.post( router.post( '/tasks/:taskId/kuaishou-industry/consume', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( (req) => consumeAdminTaskKuaishouIndustryVoucher( diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 52a9f969..bf9832a8 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -154,7 +154,7 @@ export async function getAdminOrderDetail( kuaishouIndustryVouchers: kuaishouIndustryVouchers.map(mapAdminKuaishouIndustryVoucher), operations: { canResendKuaishouIndustryVoucherCode: - viewerContext.canManageTaskLifecycle && kuaishouIndustryVouchers.length > 0, + viewerContext.canOperateKuaishouIndustryVoucher && kuaishouIndustryVouchers.length > 0, }, } } @@ -360,9 +360,9 @@ export async function getAdminTaskDetail( String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && canReturnKuaishouCloudFulfillmentStatus(task.task_status), canResendKuaishouIndustryVoucherCode: - viewerContext.canManageTaskLifecycle && hasKuaishouIndustryVoucher, + viewerContext.canOperateKuaishouIndustryVoucher && hasKuaishouIndustryVoucher, canConsumeKuaishouIndustryVoucher: - viewerContext.canManageTaskLifecycle && + viewerContext.canOperateKuaishouIndustryVoucher && hasKuaishouIndustryVoucher && kuaishouIndustryVoucherStatus !== 'CONSUMED' && kuaishouIndustryVoucherSendCallbackStatus === 'success', diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts index 5a1496ae..9c6f9b30 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts @@ -39,6 +39,13 @@ test('canViewerCloseTask allows support to close active task but not redeemed ta assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false) }) +test('createAdminViewerContext lets support operate vouchers without lifecycle permissions', () => { + const viewerContext = createAdminViewerContext({ role: 'support' }) + + assert.equal(viewerContext.canOperateKuaishouIndustryVoucher, true) + assert.equal(viewerContext.canManageTaskLifecycle, false) +}) + test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => { const flow = mapKuaishouCloudFulfillmentContext( { diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 05ccda0e..46a137e8 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -25,6 +25,7 @@ export type AdminViewerContext = { canViewSensitiveTaskData: boolean canManageTaskLifecycle: boolean canOperateAssistedTask: boolean + canOperateKuaishouIndustryVoucher: boolean } type RedeemResolutionAttempt = { @@ -370,6 +371,7 @@ export function createAdminViewerContext( canViewSensitiveTaskData: role === 'admin' || role === 'operator', canManageTaskLifecycle: role === 'admin' || role === 'operator', canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support', + canOperateKuaishouIndustryVoucher: role === 'admin' || role === 'operator' || role === 'support', } } diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 0d0f4d90..8d9277e1 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -369,7 +369,7 @@ export async function resendAdminTaskKuaishouIndustryVoucherCode( const now = nowIso() const viewerContext = createAdminViewerContext(session) - if (!viewerContext.canManageTaskLifecycle) { + if (!viewerContext.canOperateKuaishouIndustryVoucher) { throw createHttpError('当前账号没有权限重发电子凭证发码回调', { statusCode: 403, errorCode: 'admin_task_kuaishou_industry_resend_forbidden', @@ -446,7 +446,7 @@ export async function resendAdminOrderKuaishouIndustryVoucherCodes( const viewerContext = createAdminViewerContext(session) const now = nowIso() - if (!viewerContext.canManageTaskLifecycle) { + if (!viewerContext.canOperateKuaishouIndustryVoucher) { throw createHttpError('当前账号没有权限重发电子凭证发码回调', { statusCode: 403, errorCode: 'admin_order_kuaishou_industry_resend_forbidden', @@ -519,7 +519,7 @@ export async function consumeAdminTaskKuaishouIndustryVoucher( const now = nowIso() const viewerContext = createAdminViewerContext(session) - if (!viewerContext.canManageTaskLifecycle) { + if (!viewerContext.canOperateKuaishouIndustryVoucher) { throw createHttpError('当前账号没有权限执行电子凭证核销', { statusCode: 403, errorCode: 'admin_task_kuaishou_industry_consume_forbidden', diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 3fb8c52e..48317392 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -995,9 +995,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1015,9 +1012,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1035,9 +1029,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1055,9 +1046,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1075,9 +1063,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1095,9 +1080,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1991,9 +1973,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2015,9 +1994,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2039,9 +2015,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2063,9 +2036,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx index 43504a3b..136c4cb1 100644 --- a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx +++ b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx @@ -20,7 +20,7 @@ import { Tag, Typography, } from 'antd' -import type { TableColumnsType } from 'antd' +import type { TableColumnsType, TabsProps } from 'antd' import dayjs from 'dayjs' import type { Dayjs } from 'dayjs' import { useEffect, useMemo, useState } from 'react' @@ -47,6 +47,7 @@ import type { AdminKuaishouIndustryVoucher, AdminKuaishouIndustryVoucherToolPayload, } from '@/types/admin' +import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { stringifyDisplayJson } from '@/utils/date-time' @@ -78,6 +79,7 @@ const DEFAULT_ETICKET_TYPE = 'DINING_OPEN_TICKET' export default function AdminKuaishouIndustryPage() { const [activeTab, setActiveTab] = useState('vouchers') + const canManageRefund = hasAdminRole('operator') const [voucherLoading, setVoucherLoading] = useState(false) const [actionLoading, setActionLoading] = useState('') const [vouchers, setVouchers] = useState([]) @@ -475,30 +477,39 @@ export default function AdminKuaishouIndustryPage() { showSuccess('已填入退款操作区') } - return ( -
- - - setActiveTab(key as IndustryTab)} - items={[ - { - key: 'vouchers', - label: '券码操作', - children: renderVoucherTab(), - }, + const tabItems: TabsProps['items'] = [ + { + key: 'vouchers', + label: '券码操作', + children: renderVoucherTab(), + }, + ...(canManageRefund + ? [ { key: 'refunds', label: '售后退款', children: renderRefundTab(), }, - { - key: 'result', - label: '接口结果', - children: renderResultTab(), - }, - ]} + ] + : []), + { + key: 'result', + label: '接口结果', + children: renderResultTab(), + }, + ] + + return ( +
+ + + setActiveTab(key as IndustryTab)} + items={tabItems} />
) diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 5ed4c9cc..c8d41cb7 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -87,6 +87,7 @@ export default function AdminTaskDetailPage() { }) const detail = query.data?.data const canManageTaskLifecycle = hasAdminRole('operator') + const canOperateIndustryVoucher = hasAdminRole('support') const canCloseTasks = hasAdminRole('support') const claimTokenStatus = String(detail?.claimToken?.status || '').trim() const isExternalClaimLink = claimTokenStatus.toLowerCase() === 'external' @@ -266,6 +267,7 @@ export default function AdminTaskDetailPage() { @@ -490,6 +492,7 @@ export default function AdminTaskDetailPage() { function TaskActionPanel({ detail, canManageTaskLifecycle, + canOperateIndustryVoucher, canCloseTasks, actionLoadingKey, onRetry, @@ -504,6 +507,7 @@ function TaskActionPanel({ }: { detail: AdminTaskDetail canManageTaskLifecycle: boolean + canOperateIndustryVoucher: boolean canCloseTasks: boolean actionLoadingKey: string onRetry: () => void @@ -607,7 +611,7 @@ function TaskActionPanel({ ) : null} - {canManageTaskLifecycle && hasIndustryVoucherActions ? ( + {canOperateIndustryVoucher && hasIndustryVoucherActions ? (
电子凭证 @@ -648,7 +652,7 @@ function TaskActionPanel({
) : null} - {!canManageTaskLifecycle && !canCloseTasks && !hasCloudActions ? ( + {!canManageTaskLifecycle && !canOperateIndustryVoucher && !canCloseTasks && !hasCloudActions ? ( 当前账号没有可执行的任务操作 ) : null}
diff --git a/deploy/mac-dev.sh b/deploy/mac-dev.sh new file mode 100755 index 00000000..d0318b14 --- /dev/null +++ b/deploy/mac-dev.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Mac 本机 Docker 开发环境启动脚本。 +# 用法: +# bash deploy/mac-dev.sh +# bash deploy/mac-dev.sh --reset-db + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${PROJECT_ROOT}/.env" +ENV_EXAMPLE="${PROJECT_ROOT}/.env.mac-docker.example" +COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.dev.yml" +BACKEND_DATA_DIR="${PROJECT_ROOT}/apps/backend/data" +READY_TIMEOUT="${READY_TIMEOUT:-120}" +HEALTH_URL="${DEV_HEALTH_URL:-http://localhost/health/ready}" +RESET_DB=false +RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}" +COMPOSE_CMD=(docker compose -f "${COMPOSE_FILE}") + +log() { + printf '\033[1;34m[mac-dev]\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33m[mac-dev]\033[0m %s\n' "$*" +} + +fail() { + printf '\033[1;31m[mac-dev]\033[0m %s\n' "$*" >&2 + exit 1 +} + +print_usage() { + cat <<'EOF' +用法: + bash deploy/mac-dev.sh + bash deploy/mac-dev.sh --reset-db + +参数: + --reset-db 删除 dev Compose PostgreSQL 数据卷 postgres_dev_data,并用当前迁移重新初始化数据库。 + --yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。 + -h, --help 显示帮助。 + +环境变量: + READY_TIMEOUT 等待容器和健康检查的秒数,默认 120。 + DEV_HEALTH_URL 本机健康检查地址,默认 http://localhost/health/ready。 + +注意: + --reset-db 会删除本机开发库里的订单、任务、后台用户、审计日志和迁移记录。 + 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 node_modules 缓存 volume。 +EOF +} + +parse_args() { + while (($# > 0)); do + case "$1" in + --reset-db) + RESET_DB=true + ;; + --yes) + RESET_DB_CONFIRM=1 + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + fail "未知参数:$1。执行 bash deploy/mac-dev.sh --help 查看用法。" + ;; + esac + shift + done +} + +read_env_value() { + local key="$1" + awk -F= -v key="${key}" ' + $0 !~ /^[[:space:]]*#/ && $1 == key { + value = substr($0, index($0, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + gsub(/^"|"$/, "", value) + gsub(/^'\''|'\''$/, "", value) + print value + exit + } + ' "${ENV_FILE}" +} + +ensure_docker() { + command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先安装并启动 Docker Desktop for Mac。" + docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请升级 Docker Desktop。" + command -v curl >/dev/null 2>&1 || fail "未找到 curl,请先安装 curl。" +} + +ensure_env_file() { + if [[ -f "${ENV_FILE}" ]]; then + return + fi + + [[ -f "${ENV_EXAMPLE}" ]] || fail "未找到本机开发环境模板:${ENV_EXAMPLE}" + cp "${ENV_EXAMPLE}" "${ENV_FILE}" + log "未找到 .env,已从 .env.mac-docker.example 生成本机开发配置。" +} + +validate_env_file() { + [[ "${READY_TIMEOUT}" =~ ^[1-9][0-9]*$ ]] || fail "READY_TIMEOUT 必须是正整数秒数,当前值:${READY_TIMEOUT}" + + validate_database_url_for_dev_compose +} + +validate_database_url_for_dev_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 dev 后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名。" + fi +} + +validate_reset_database_target() { + local database_url + database_url="$(read_env_value DATABASE_URL)" + + if [[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]; then + fail "--reset-db 只支持重置本脚本管理的 dev Compose PostgreSQL。当前 DATABASE_URL 未指向 postgres 服务,请手动确认外部数据库后再处理。" + fi +} + +confirm_reset_database() { + if [[ "${RESET_DB}" != "true" ]]; then + return + fi + + validate_reset_database_target + + warn "即将重置 Mac 本机开发 PostgreSQL 数据库:会删除订单、任务、后台用户、审计日志和所有迁移记录。" + warn "不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 node_modules 缓存 volume。" + + if [[ "${RESET_DB_CONFIRM}" == "1" ]]; then + warn "检测到 RESET_DB_CONFIRM=1 或 --yes,跳过交互确认。" + return + fi + + if [[ ! -t 0 ]]; then + fail "当前不是交互式终端。若确认要重置数据库,请执行:RESET_DB_CONFIRM=1 bash deploy/mac-dev.sh --reset-db" + fi + + local answer + printf '请输入 RESET 确认重置数据库:' + read -r answer + [[ "${answer}" == "RESET" ]] || fail "未确认重置数据库,已停止。" +} + +ensure_data_dir() { + mkdir -p "${BACKEND_DATA_DIR}" +} + +check_compose_config() { + log "检查 Docker Compose dev 配置" + "${COMPOSE_CMD[@]}" config >/dev/null || fail "Docker Compose dev 配置校验失败。" +} + +resolve_compose_project_name() { + local project_name + project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)" + + if [[ -z "${project_name}" ]]; then + project_name="$(basename "${PROJECT_ROOT}")" + fi + + printf '%s\n' "${project_name}" +} + +resolve_postgres_volume_name() { + local container_id volume_name project_name expected_volume + + container_id="$("${COMPOSE_CMD[@]}" ps -aq postgres 2>/dev/null | head -n 1 || true)" + if [[ -n "${container_id}" ]]; then + volume_name="$( + docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' "${container_id}" 2>/dev/null || true + )" + if [[ -n "${volume_name}" ]]; then + printf '%s\n' "${volume_name}" + return + fi + fi + + project_name="$(resolve_compose_project_name)" + expected_volume="${project_name}_postgres_dev_data" + if docker volume inspect "${expected_volume}" >/dev/null 2>&1; then + printf '%s\n' "${expected_volume}" + return + fi + + volume_name="$( + docker volume ls --format '{{.Name}}' \ + | awk '$0 ~ /(^|_)postgres_dev_data$/ { print; exit }' || true + )" + if [[ -n "${volume_name}" ]]; then + printf '%s\n' "${volume_name}" + return + fi + + printf '%s\n' "${expected_volume}" +} + +reset_database() { + if [[ "${RESET_DB}" != "true" ]]; then + return + fi + + local volume_name + volume_name="$(resolve_postgres_volume_name)" + + warn "开始重置本机开发数据库,将删除 Docker volume:${volume_name}" + "${COMPOSE_CMD[@]}" stop web frontend backend postgres >/dev/null 2>&1 || true + "${COMPOSE_CMD[@]}" rm -f -s postgres >/dev/null 2>&1 || true + + if docker volume inspect "${volume_name}" >/dev/null 2>&1; then + docker volume rm "${volume_name}" >/dev/null || fail "删除 PostgreSQL 开发数据卷失败:${volume_name}" + log "已删除 PostgreSQL 开发数据卷:${volume_name}" + return + fi + + warn "未找到 PostgreSQL 开发数据卷:${volume_name},将按空库继续启动。" +} + +wait_for_service() { + local service="$1" + local label="$2" + local attempt container_id status health_status + + log "等待 ${label} 就绪" + + for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do + container_id="$("${COMPOSE_CMD[@]}" ps -q "${service}" 2>/dev/null || true)" + if [[ -n "${container_id}" ]]; then + status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)" + health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)" + + if [[ -n "${health_status}" && "${health_status}" == "healthy" ]]; then + log "${label} 已就绪" + return + fi + + if [[ -z "${health_status}" && "${status}" == "running" ]]; then + log "${label} 已启动" + return + fi + + if [[ "${status}" == "exited" || "${status}" == "dead" ]]; then + break + fi + fi + + sleep 1 + done + + print_diagnostics + fail "${label} 启动超时,请根据上面的日志排查。" +} + +start_dev_stack() { + log "启动 Mac 本机 Docker 开发环境" + if "${COMPOSE_CMD[@]}" up -d --build; then + wait_for_service postgres "PostgreSQL" + wait_for_service backend "后端" + wait_for_service frontend "前端" + wait_for_service web "Web 入口" + return + fi + + print_diagnostics + fail "Mac 本机 Docker 开发环境启动失败,请根据上面的日志排查。" +} + +wait_for_health() { + log "等待开发入口健康检查:${HEALTH_URL}" + + for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do + if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then + log "开发环境已就绪" + return + fi + sleep 1 + done + + print_diagnostics + fail "开发环境已启动但健康检查未通过,请根据日志排查。" +} + +print_applied_migrations() { + log "已应用数据库迁移" + + local sql migrations + sql='SELECT COALESCE(filename, name) FROM schema_migrations ORDER BY COALESCE(filename, name);' + + if migrations="$("${COMPOSE_CMD[@]}" exec -T postgres sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "$1"' sh "${sql}" 2>&1)"; then + printf '%s\n' "${migrations:-"(无迁移记录)"}" + return + fi + + warn "读取迁移记录失败:" + printf '%s\n' "${migrations}" +} + +print_diagnostics() { + warn "当前 dev 容器状态:" + "${COMPOSE_CMD[@]}" ps || true + + warn "后端最近日志:" + local backend_logs + backend_logs="$("${COMPOSE_CMD[@]}" logs --tail=120 backend 2>&1 || true)" + printf '%s\n' "${backend_logs}" + + if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then + warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_dev_data 已用旧密码初始化,而 .env 改成了新密码。" + warn "无本地数据保留需求时可执行:RESET_DB_CONFIRM=1 bash deploy/mac-dev.sh --reset-db" + fi + + warn "前端最近日志:" + "${COMPOSE_CMD[@]}" logs --tail=80 frontend || true + + warn "后端 ready 接口:" + "${COMPOSE_CMD[@]}" exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true + + print_applied_migrations || true +} + +print_endpoints() { + log "常用入口" + printf '%s\n' " 前端、后台、领取页:http://localhost" + printf '%s\n' " 后端健康检查:http://localhost/health" + printf '%s\n' " 后端 ready 检查:http://localhost/health/ready" +} + +main() { + parse_args "$@" + + cd "${PROJECT_ROOT}" + + ensure_docker + ensure_env_file + validate_env_file + confirm_reset_database + ensure_data_dir + check_compose_config + reset_database + start_dev_stack + wait_for_health + print_applied_migrations + + log "Mac 本机开发环境启动完成" + "${COMPOSE_CMD[@]}" ps + print_endpoints +} + +main "$@" diff --git a/docs/发货平台-2/2.6-接口-店铺订单取消.md b/docs/发货平台-2/2.6-接口-店铺订单取消.md new file mode 100644 index 00000000..87cd17d7 --- /dev/null +++ b/docs/发货平台-2/2.6-接口-店铺订单取消.md @@ -0,0 +1,38 @@ +店铺订单取消 +对订单列表中的店铺订单发起取消。仅需传平台单号(platform_order_no)。取消规则比商户后台更严格:仅待绑定可取消,充值中不可「申请取消」。 + +#接口地址 +code +POST /api/open/v1/orders/cancel +#请求方式 +POST + +#请求参数 +参数名 类型 必填 说明 +platform_order_no string 是 店铺平台单号 +#响应参数 +| 参数名 | 类型 | 说明 | | --- | --- | --- | --- | | data.order.order_no | string | 平台内部单号 | | data.order.platform_order_no | string | 平台单号 | | data.order.recharge_status | number | 充值状态码 | | data.order.recharge_status_label | string | 充值状态中文 | | data.order.is_verifi | number | 核销状态(卡券单时有值) | | data.order.updated_at | string | 更新时间 | + +#响应示例 +json +{ + "code": 10000, + "message": "订单已取消", + "data": { + "order": { + "order_no": "202601011200001234", + "platform_order_no": "2617600561780683", + "recharge_status": 60, + "recharge_status_label": "已取消" + } + } +} +#常见失败 +message 说明 +店铺订单不存在 单号不存在或非店铺订单 +仅待绑定状态的订单可取消 订单已进入充值中等后续状态 +#说明 +鉴权与签名同 鉴权与签名。 +仅待绑定状态且走供货充值履约的店铺订单可取消;充值中、失败、人工等状态不可取消。 +取消后立即完成并退积分(按平台配置)。 +与商户端 POST /api/merchant/orders/cancel 相比:开放 API 不支持充值中「申请取消」,也不支持失败/人工介入等状态的取消;商户后台在上述状态仍可操作(充值中为软取消)。 \ No newline at end of file