From d271ac5b135173891871a9c9b777af861e374863 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Sun, 12 Jul 2026 13:52:22 +0800 Subject: [PATCH] =?UTF-8?q?webadmin=E7=AB=AF=E5=A2=9E=E5=8A=A0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E5=92=8C=E9=83=A8=E7=BD=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/api.ts | 16 +++ apps/admin-web/src/pages/DashboardPage.tsx | 103 +++++++++++++++++- deploy/auto-release.env.example | 3 + deploy/deploy.sh | 2 + deploy/remote-release.sh | 6 + deploy/webhook-server.mjs | 5 +- packages/shared-types/src/ops.ts | 16 +++ server/dukang-api/.env.example | 4 + server/dukang-api/prisma/schema.prisma | 13 +++ .../scripts/record-system-version.cjs | 68 ++++++++++++ .../hq-operation/hq-operation.constants.ts | 2 + .../modules/ops/admin-dashboard.controller.ts | 5 + .../modules/ops/admin-dashboard.service.ts | 16 +++ .../modules/ops/admin-deploy.controller.ts | 22 ++++ .../src/modules/ops/admin-deploy.service.ts | 49 +++++++++ .../dukang-api/src/modules/ops/ops.module.ts | 4 + 16 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 server/dukang-api/scripts/record-system-version.cjs create mode 100644 server/dukang-api/src/modules/ops/admin-deploy.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-deploy.service.ts diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index f327cca..0e6138a 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -72,6 +72,22 @@ export type DashboardStats = { ordersByStatus: Array<{ status: string; count: number }>; }; +export type SystemVersion = { + id: string; + gitTag: string | null; + commitId: string; + commitMessage: string; + branch: string | null; + deployedBy: string | null; + deployedAt: string; +}; + +export type DeployTriggerResult = { + accepted: boolean; + started?: boolean; + message: string; +}; + export type AdminUserRow = { id: string; userNo: string; diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx index 8270b5d..06b08ba 100644 --- a/apps/admin-web/src/pages/DashboardPage.tsx +++ b/apps/admin-web/src/pages/DashboardPage.tsx @@ -1,6 +1,16 @@ -import { useEffect, useState } from 'react'; -import { Card, Col, Row, Statistic, Table, Typography } from 'antd'; -import { request, type DashboardStats } from '../lib/api'; +import { useCallback, useEffect, useState } from 'react'; +import { + Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, +} from 'antd'; +import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { + request, + type DashboardStats, + type DeployTriggerResult, + type HqProfile, + type SystemVersion, +} from '../lib/api'; +import { fmtTime } from '../lib/constants'; const STATUS_LABELS: Record = { PENDING_PAY: '待付款', @@ -14,19 +24,104 @@ const STATUS_LABELS: Record = { REFUNDED: '已退款', }; +const DEPLOYED_BY_LABELS: Record = { + webhook: 'Webhook', + manual: '手动脚本', + admin: 'Admin 发布', +}; + export default function DashboardPage() { const [stats, setStats] = useState(null); + const [version, setVersion] = useState(null); const [loading, setLoading] = useState(true); + const [versionLoading, setVersionLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [deploying, setDeploying] = useState(false); + const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; + + const loadVersion = useCallback(() => { + setVersionLoading(true); + return request('/admin/dashboard/version') + .then(setVersion) + .catch(() => setVersion(null)) + .finally(() => setVersionLoading(false)); + }, []); useEffect(() => { request('/admin/dashboard/stats') .then(setStats) .finally(() => setLoading(false)); - }, []); + void loadVersion(); + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, [loadVersion]); + + function handleDeploy() { + Modal.confirm({ + title: '确认发布更新?', + content: '将触发服务器 webhook,拉取 auto-release.env 中配置的 GIT_BRANCH 并执行发版。发版通常需要数分钟,完成后可刷新版本信息。', + okText: '开始发布', + cancelText: '取消', + onOk: async () => { + setDeploying(true); + try { + const result = await request('/admin/deploy/trigger', { method: 'POST' }); + message.success(result.message || '已触发发布'); + } catch (e) { + message.error(e instanceof Error ? e.message : '触发发布失败'); + throw e; + } finally { + setDeploying(false); + } + }, + }); + } + + const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—'; return (
数据概览 + + + + {isSuperAdmin && ( + + )} + + } + > + {version ? ( + + {version.branch || '—'} + {version.gitTag || '—'} + + {shortSha} + + {version.commitMessage} + {fmtTime(version.deployedAt)} + + {DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'} + + + ) : ( + 尚未记录发版信息 + )} + + diff --git a/deploy/auto-release.env.example b/deploy/auto-release.env.example index a3593b9..18fa72d 100644 --- a/deploy/auto-release.env.example +++ b/deploy/auto-release.env.example @@ -16,3 +16,6 @@ DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock # Prisma db push 遇到 schema 变更警告时自动继续(开发/测试环境可开) PRISMA_ACCEPT_DATA_LOSS=true + +# 注意:Admin「发布更新」读取的是 server/dukang-api/.env.production 中的 +# DEPLOY_WEBHOOK_URL / DEPLOY_WEBHOOK_SECRET,须与下方 DEPLOY_WEBHOOK_SECRET 一致 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 0f82bfd..ce2e450 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -100,6 +100,8 @@ APP_ROOT="$APP_ROOT" GIT_REMOTE="$GIT_REMOTE" GIT_BRANCH="$GIT_BRANCH" RELEASE_ARGS="$REMOTE_RELEASE_ARGS" +export DEPLOY_TRIGGER=manual +export APP_ROOT cd "\$APP_ROOT" diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh index 90b6cdf..ef64231 100644 --- a/deploy/remote-release.sh +++ b/deploy/remote-release.sh @@ -92,4 +92,10 @@ curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/api/v1/heal || curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/ \ || echo "api: FAIL" +echo "==> 7. 记录系统版本" +cd "$APP_ROOT/server/dukang-api" +APP_ROOT="$APP_ROOT" DEPLOY_TRIGGER="${DEPLOY_TRIGGER:-manual}" \ + node scripts/with-api-env.cjs node scripts/record-system-version.cjs \ + || echo "WARN: system_version 写入失败" + echo "==> 发版完成" diff --git a/deploy/webhook-server.mjs b/deploy/webhook-server.mjs index eca2873..8b3c74a 100644 --- a/deploy/webhook-server.mjs +++ b/deploy/webhook-server.mjs @@ -121,13 +121,16 @@ const server = http.createServer(async (req, res) => { }); } - const started = triggerRelease(ref || 'manual'); + const source = typeof payload.source === 'string' ? payload.source : ''; + const trigger = source === 'admin' ? 'admin' : ref || 'manual'; + const started = triggerRelease(trigger); return json(res, 202, { ok: true, accepted: true, started, message: started ? 'deploy started' : 'deploy debounced (duplicate webhook)', ref: ref || ALLOWED_REF, + source: trigger, }); }); diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index ca65437..5720d96 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -13,3 +13,19 @@ export interface AdminPageResult { export interface ExportRequest { format?: 'csv'; } + +export interface SystemVersionDto { + id: string; + gitTag: string | null; + commitId: string; + commitMessage: string; + branch: string | null; + deployedBy: string | null; + deployedAt: string; +} + +export interface DeployTriggerResult { + accepted: boolean; + started?: boolean; + message: string; +} diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index b28133d..0b25d94 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -88,3 +88,7 @@ SHIP_FROM_ADDRESS=河南省郑州市金水区 SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓 SHIP_FROM_LNG=113.665 SHIP_FROM_LAT=34.757 + +# Admin 概览「发布更新」:调用本机 deploy webhook(与 deploy/auto-release.env 中 SECRET 一致) +# DEPLOY_WEBHOOK_URL=http://127.0.0.1:8095/deploy +# DEPLOY_WEBHOOK_SECRET=change-me-to-a-long-random-string diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 6635044..3e49d08 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -263,6 +263,19 @@ enum BenefitLedgerType { // ─── COMMON ─────────────────────────────────────────── +model SystemVersion { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + gitTag String? @map("git_tag") @db.VarChar(128) + commitId String @map("commit_id") @db.VarChar(64) + commitMessage String @map("commit_message") @db.VarChar(512) + branch String? @db.VarChar(64) + deployedBy String? @map("deployed_by") @db.VarChar(32) + deployedAt DateTime @default(now()) @map("deployed_at") @db.DateTime(3) + + @@index([deployedAt]) + @@map("system_version") +} + model CommonWxAppConfig { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt clientApp ClientApp @unique @map("client_app") diff --git a/server/dukang-api/scripts/record-system-version.cjs b/server/dukang-api/scripts/record-system-version.cjs new file mode 100644 index 0000000..ac55da6 --- /dev/null +++ b/server/dukang-api/scripts/record-system-version.cjs @@ -0,0 +1,68 @@ +/** + * 发版成功后写入 system_version。 + * 须经 with-api-env.cjs 加载 DATABASE_URL;失败不阻断发版(exit 0)。 + */ +const { execSync } = require('child_process'); +const { resolve } = require('path'); + +const apiRoot = resolve(__dirname, '..'); +const appRoot = process.env.APP_ROOT || resolve(apiRoot, '../..'); + +function git(cmd) { + try { + return execSync(cmd, { cwd: appRoot, encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +async function main() { + const commitId = git('git rev-parse HEAD'); + if (!commitId) { + console.warn('[record-system-version] WARN: 无法读取 git HEAD,跳过'); + return; + } + + let commitMessage = git('git log -1 --pretty=%s') || ''; + if (commitMessage.length > 512) { + commitMessage = commitMessage.slice(0, 512); + } + + const gitTag = git('git describe --tags --exact-match') || null; + const branchRaw = git('git rev-parse --abbrev-ref HEAD'); + const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null; + + const trigger = (process.env.DEPLOY_TRIGGER || '').trim(); + let deployedBy = 'manual'; + if (trigger) { + if (trigger === 'manual' || trigger === 'admin') { + deployedBy = trigger; + } else { + deployedBy = 'webhook'; + } + } + + const { PrismaClient } = require('@prisma/client'); + const prisma = new PrismaClient(); + try { + const row = await prisma.systemVersion.create({ + data: { + gitTag, + commitId, + commitMessage: commitMessage || '(no message)', + branch, + deployedBy, + }, + }); + console.log( + `[record-system-version] ok id=${row.id} commit=${commitId.slice(0, 7)} tag=${gitTag || '-'} by=${deployedBy}`, + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err) => { + console.warn('[record-system-version] WARN:', err instanceof Error ? err.message : err); + process.exit(0); +}); diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 0b3a8c9..a7b1981 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -52,6 +52,7 @@ export const HqOperationAction = { PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS', REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE', REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT', + DEPLOY_TRIGGER: 'DEPLOY_TRIGGER', } as const; export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction]; @@ -109,6 +110,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停', [HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销', [HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回', + [HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布', STORE_PAYOUT: '门店打款确认', }; diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts index 3ce0cd3..f33a39e 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts @@ -11,4 +11,9 @@ export class AdminDashboardController { stats() { return this.dashboardService.getStats(); } + + @Get('version') + version() { + return this.dashboardService.getLatestVersion(); + } } diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 7c3c97b..95babe4 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -65,4 +65,20 @@ export class AdminDashboardService { })), }; } + + async getLatestVersion() { + const row = await this.prisma.systemVersion.findFirst({ + orderBy: { deployedAt: 'desc' }, + }); + if (!row) return null; + return { + id: row.id.toString(), + gitTag: row.gitTag, + commitId: row.commitId, + commitMessage: row.commitMessage, + branch: row.branch, + deployedBy: row.deployedBy, + deployedAt: row.deployedAt.toISOString(), + }; + } } diff --git a/server/dukang-api/src/modules/ops/admin-deploy.controller.ts b/server/dukang-api/src/modules/ops/admin-deploy.controller.ts new file mode 100644 index 0000000..de8cec3 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-deploy.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Post, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { AdminDeployService } from './admin-deploy.service'; + +@Controller('admin/deploy') +@UseGuards(HqAuthGuard, SuperAdminGuard) +export class AdminDeployController { + constructor(private readonly deployService: AdminDeployService) {} + + @Post('trigger') + @HqOperation({ + action: HqOperationAction.DEPLOY_TRIGGER, + refType: 'DEPLOY', + batch: true, + }) + trigger() { + return this.deployService.triggerDeploy(); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-deploy.service.ts b/server/dukang-api/src/modules/ops/admin-deploy.service.ts new file mode 100644 index 0000000..bc7f661 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-deploy.service.ts @@ -0,0 +1,49 @@ +import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'; + +@Injectable() +export class AdminDeployService { + async triggerDeploy() { + const url = (process.env.DEPLOY_WEBHOOK_URL || 'http://127.0.0.1:8095/deploy').trim(); + const secret = (process.env.DEPLOY_WEBHOOK_SECRET || '').trim(); + if (!secret) { + throw new ServiceUnavailableException('未配置 DEPLOY_WEBHOOK_SECRET,无法触发发布'); + } + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Deploy-Token': secret, + }, + body: JSON.stringify({ source: 'admin' }), + }); + } catch (err) { + throw new ServiceUnavailableException( + `无法连接部署 webhook:${err instanceof Error ? err.message : 'network error'}`, + ); + } + + const text = await res.text(); + let data: { ok?: boolean; accepted?: boolean; started?: boolean; message?: string; skipped?: boolean } = {}; + try { + data = text ? (JSON.parse(text) as typeof data) : {}; + } catch { + throw new ServiceUnavailableException(`部署 webhook 返回非 JSON(HTTP ${res.status})`); + } + + if (res.status === 403) { + throw new BadRequestException(data.message || '部署 webhook 鉴权失败'); + } + if (!res.ok && res.status !== 202) { + throw new ServiceUnavailableException(data.message || `部署 webhook 失败(HTTP ${res.status})`); + } + + return { + accepted: true, + started: data.started !== false && !data.skipped, + message: data.message || (data.started === false ? 'deploy debounced' : 'deploy started'), + }; + } +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 4d3d09e..f45df89 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -49,11 +49,14 @@ import { AdminWechatBindingsController } from './admin-wechat-bindings.controlle import { AdminWechatBindingsService } from './admin-wechat-bindings.service'; import { AdminHqPermissionsController } from './admin-hq-permissions.controller'; import { AdminHqPermissionsService } from './admin-hq-permissions.service'; +import { AdminDeployController } from './admin-deploy.controller'; +import { AdminDeployService } from './admin-deploy.service'; @Module({ imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], controllers: [ AdminDashboardController, + AdminDeployController, AdminUsersController, AdminOrdersController, AdminStoresController, @@ -104,6 +107,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service'; AdminRedeemDebugService, AdminWechatBindingsService, AdminHqPermissionsService, + AdminDeployService, SuperAdminGuard, ], exports: [CityScopeModule],