Compare commits

...
Author SHA1 Message Date
Qiufeng 83f1bf82d6 perf: optimize dashboard loading and production assets
Release / release (push) Canceled after 0s
2026-08-19 15:34:11 +08:00
Qiufeng 12d78b4c86 feat: add system operations dashboard
Release / release (push) Canceled after 0s
2026-08-19 13:51:48 +08:00
Qiufeng 8e615b895e feat: add MySQL V073 full-state demo data 2026-08-19 12:08:22 +08:00
Qiufeng 1d45be4e97 fix: synchronize release notes after update restart
Release / release (push) Canceled after 0s
2026-08-19 09:04:33 +08:00
Qiufeng 6b04d8dd0a feat: show signed release changelog in update history
Release / release (push) Canceled after 0s
2026-08-19 08:27:08 +08:00
68 changed files with 5255 additions and 293 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ jobs:
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ steps.release_meta.outputs.ref }} KAIDI_REQUIRE_RELEASE_NOTES: 'true'
KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }} KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }} KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_DIRTY: 'false' KAIDI_SOURCE_DIRTY: 'false'
+36 -12
View File
@@ -70,7 +70,8 @@ PostgreSQL 18 兼容工作继续冻结。
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。 - MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
- 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。 - 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。 - `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。 - 发布归档使用无顶层目录的 `tar.gz`,包含 `app.jar`、`public/`、`ops/`、`VERSION` 和签名绑定的
`release-metadata.json`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
### 32 位 Linux 支持边界 ### 32 位 Linux 支持边界
@@ -83,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。
先在宝塔面板停止并删除当前错误的 Java 项目,再执行: 先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/install.sh | sudo env KAIDI_APP_PORT=18080 bash curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/install.sh | sudo env KAIDI_APP_PORT=18080 bash
``` ```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -96,7 +97,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理: 随后执行一键清理:
```bash ```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
``` ```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
@@ -110,16 +111,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.43 直链与宝塔手动部署 ### Preview.50 直链与宝塔手动部署
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址: 下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/kaidi-finance-1.0.0-preview.46.tar.gz` `https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/kaidi-finance-1.0.0-preview.50.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/SHA256SUMS` 校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
@@ -136,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
VERSION=1.0.0-preview.46 VERSION=1.0.0-preview.50
APP_ROOT=/www/wwwroot/kaidi APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION" RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT" sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -245,12 +246,31 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: `KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash ```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
``` ```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。 否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。
## 全状态演示数据
线上数据库为空时,可导入 [`deploy/demo-data-mysql8-v073.sql`](deploy/demo-data-mysql8-v073.sql)。该文件按
MySQL 8.4 和 Flyway **兼容版本 V073/V074** 编写,并已在 Preview.49 的完整迁移库中验证。它会生成 14 类 OA 表单、
项目阶段、主数据、合同成本、收款发票、付款、记账、档案借阅、报表导出、幂等和审计等演示记录,覆盖主要正常、
待办、退回、驳回、作废、失败、完成和冻结状态。
导入前先备份现有数据库,确认 `/setup` 已完成且 `flyway_schema_history` 当前版本为 `073` 或 `074`。推荐使用 MySQL
命令行客户端执行;命令只建立数据库连接,不安装、不启动也不修改 MySQL 服务:
```bash
mysql --default-character-set=utf8mb4 -h HOST -P PORT -u USER -p DATABASE < deploy/demo-data-mysql8-v073.sql
```
脚本使用单个事务,导入成功后输出各模块数量和状态摘要;完成标记会阻止同一数据库重复导入。它不会创建新的
可登录账号,只会创建 3 个禁用的演示操作人。附件和导出文件只有状态元数据,没有真实文件字节;银行账户也只
提供脱敏展示值,因此文件下载和敏感账号解密不属于本演示数据的验证范围。未来迁移高于 V073 时,应使用与新
迁移版本匹配的数据文件,不要绕过前置检查强行导入。
## Release 与在线更新 ## Release 与在线更新
首次建立发布仓库时生成一次签名密钥: 首次建立发布仓库时生成一次签名密钥:
@@ -268,11 +288,15 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/<version>.md` 写好面向用户的更新日志。
打包器会把同一份内容写入签名 `release-manifest.json`,并把同一份签名元数据随应用制品写入
`release-metadata.json`。后台在线检查优先读取 Release 清单;应用重启后若旧版更新器没有把说明写入状态文件,
则从当前已验签制品中的元数据恢复,再写入终态审计,因此不依赖重启后的 Gitea 网络请求。发布脚本也会用
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.46 git tag v1.0.0-preview.50
git push origin v1.0.0-preview.46 git push origin v1.0.0-preview.50
``` ```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -324,7 +348,7 @@ cat /var/lib/kaidi-update/status.json
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.46 ./scripts/package-release.sh 1.0.0-preview.50
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./scripts/verify-release.sh dist/release
``` ```
@@ -0,0 +1,30 @@
package com.kaidi.finance.dashboard.api;
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/dashboard")
public class DashboardController {
private final DashboardApplicationService dashboardService;
public DashboardController(DashboardApplicationService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/overview")
@Operation(operationId = "getDashboardOverview", summary = "查询当前身份可见的系统仪表盘")
@PreAuthorize("@authorizationService.hasPermission('dashboard:overview:view')")
public ApiResponse<DashboardOverviewView> overview(
@RequestParam(defaultValue = "CNY") String currency
) {
return ApiResponse.ok(dashboardService.overview(currency));
}
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.dashboard.api;
public record DashboardDistributionView(
String code,
String label,
long value,
boolean available,
String targetRoute
) {
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.dashboard.api;
import java.util.List;
public record DashboardGeographyView(
boolean available,
String message,
List<DashboardRegionView> regions
) {
}
@@ -0,0 +1,12 @@
package com.kaidi.finance.dashboard.api;
public record DashboardMetricView(
String code,
String label,
String kind,
String value,
String unit,
boolean available,
String targetRoute
) {
}
@@ -0,0 +1,19 @@
package com.kaidi.finance.dashboard.api;
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
import java.time.Instant;
import java.util.List;
public record DashboardOverviewView(
String title,
Instant refreshedAt,
String currency,
DashboardSystemView system,
List<DashboardMetricView> metrics,
DashboardTrendView trend,
List<DashboardDistributionView> lifecycle,
List<DashboardRiskView> risks,
DashboardGeographyView geography,
List<WorkbenchActivityView> activities
) {
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.dashboard.api;
public record DashboardRegionView(
String regionCode,
String regionName,
long projectCount,
String amount,
String currency
) {
}
@@ -0,0 +1,12 @@
package com.kaidi.finance.dashboard.api;
public record DashboardRiskView(
String code,
String label,
String severity,
long count,
String detail,
boolean available,
String targetRoute
) {
}
@@ -0,0 +1,9 @@
package com.kaidi.finance.dashboard.api;
public record DashboardServiceStatusView(
String code,
String label,
String status,
String detail
) {
}
@@ -0,0 +1,11 @@
package com.kaidi.finance.dashboard.api;
import java.util.List;
public record DashboardSystemView(
String overallStatus,
String currentVersion,
long uptimeSeconds,
List<DashboardServiceStatusView> services
) {
}
@@ -0,0 +1,9 @@
package com.kaidi.finance.dashboard.api;
public record DashboardTrendPointView(
String period,
String receivedAmount,
String paidAmount,
String invoicedAmount
) {
}
@@ -0,0 +1,12 @@
package com.kaidi.finance.dashboard.api;
import java.util.List;
public record DashboardTrendView(
String currency,
boolean receiptsAvailable,
boolean paymentsAvailable,
boolean invoicesAvailable,
List<DashboardTrendPointView> points
) {
}
@@ -0,0 +1,281 @@
package com.kaidi.finance.dashboard.application;
import com.kaidi.finance.dashboard.api.DashboardDistributionView;
import com.kaidi.finance.dashboard.api.DashboardGeographyView;
import com.kaidi.finance.dashboard.api.DashboardMetricView;
import com.kaidi.finance.dashboard.api.DashboardOverviewView;
import com.kaidi.finance.dashboard.api.DashboardRiskView;
import com.kaidi.finance.dashboard.api.DashboardServiceStatusView;
import com.kaidi.finance.dashboard.api.DashboardSystemView;
import com.kaidi.finance.dashboard.api.DashboardTrendPointView;
import com.kaidi.finance.dashboard.api.DashboardTrendView;
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.file.FileStorageProperties;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.application.SystemUpdateProperties;
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
import java.lang.management.ManagementFactory;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DashboardApplicationService {
private static final Set<String> SUPPORTED_CURRENCIES = Set.of("CNY", "USD", "EUR", "HKD", "JPY", "GBP");
private static final List<String> STAGES = List.of(
"INITIATION", "CONTRACTING", "EXECUTION", "ACCEPTANCE", "SETTLEMENT", "CLOSED"
);
private final DashboardMapper mapper;
private final WorkbenchMapper workbenchMapper;
private final IdentityContext identityContext;
private final AuthorizationService authorizationService;
private final SystemUpdateProperties updateProperties;
private final FileStorageProperties storageProperties;
public DashboardApplicationService(
DashboardMapper mapper,
WorkbenchMapper workbenchMapper,
IdentityContext identityContext,
AuthorizationService authorizationService,
SystemUpdateProperties updateProperties,
FileStorageProperties storageProperties
) {
this.mapper = mapper;
this.workbenchMapper = workbenchMapper;
this.identityContext = identityContext;
this.authorizationService = authorizationService;
this.updateProperties = updateProperties;
this.storageProperties = storageProperties;
}
@Transactional(readOnly = true)
public DashboardOverviewView overview(String requestedCurrency) {
Set<String> permissions = authorizationService.activePermissions();
if (!permissions.contains("dashboard:overview:view")) {
authorizationService.requirePermission("dashboard:overview:view");
}
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String currency = normalizeCurrency(requestedCurrency);
boolean canProjects = permissions.contains("project:project:view");
boolean canContracts = permissions.contains("masterdata:contract:view");
boolean canReceipts = permissions.contains("receivable:receipt:view");
boolean canPayments = permissions.contains("payment:request:view");
boolean canInvoices = permissions.contains("receivable:invoice:view");
boolean canWorkflow = permissions.contains("workflow:task:view");
boolean canRisks = permissions.contains("project:risk-flag:view");
boolean canArchives = permissions.contains("archive:package:view");
boolean canFiles = permissions.contains("archive:file:view");
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
BigDecimal contractAmount = amount(canContracts,
() -> mapper.sumContracts(actor.userId(), role, currency));
BigDecimal receiptAmount = amount(canReceipts,
() -> mapper.sumReceipts(actor.userId(), role, currency));
BigDecimal paymentAmount = amount(canPayments,
() -> mapper.sumPayments(actor.userId(), role, currency));
long pendingTasks = count(canWorkflow, () -> workbenchMapper.countPendingTasks(actor.userId(), role));
long activeRisks = count(canRisks, () -> mapper.countActiveRisks(actor.userId(), role));
List<DashboardMetricView> metrics = List.of(
countMetric("ACTIVE_PROJECTS", "进行中项目", projectCount, canProjects, "/projects"),
moneyMetric("CONTRACT_AMOUNT", "合同总额", contractAmount, currency, canContracts,
"/finance/contracts-costs"),
moneyMetric("RECEIPT_AMOUNT", "累计收款", receiptAmount, currency, canReceipts,
"/finance/receipts-invoices?resource=receipts"),
moneyMetric("PAYMENT_AMOUNT", "累计付款", paymentAmount, currency, canPayments,
"/finance/payments"),
countMetric("PENDING_TASKS", "待办审批", pendingTasks, canWorkflow, "/tasks?view=TODO"),
countMetric("ACTIVE_RISKS", "风险事项", activeRisks, canRisks, "/projects?riskStatus=ACTIVE")
);
List<DashboardRiskView> risks = List.of(
risk("PROJECT_RISK", "项目风险", "DANGER", activeRisks, "当前权限范围内的有效风险标记",
canRisks, "/projects?riskStatus=ACTIVE"),
risk("OVERDUE_TASK", "审批超时", "DANGER",
count(canWorkflow, () -> workbenchMapper.countOverdueTasks(actor.userId(), role)),
"超过处理时限的审批任务", canWorkflow, "/tasks?view=TODO"),
risk("PAYMENT_BLOCK", "付款阻断", "WARNING",
count(canPayments, () -> mapper.countPaymentBlocks(actor.userId(), role)),
"付款检查中仍未解除的阻断项", canPayments,
"/finance/payments?tab=finance-check&riskCode=BLOCK"),
risk("ARCHIVE_MISSING", "档案缺件", "WARNING",
count(canArchives, () -> workbenchMapper.countArchiveMissing(actor.userId(), role)),
"待补充的归档材料", canArchives, "/archives/projects?completeness=INCOMPLETE"),
risk("QUARANTINED_FILE", "隔离文件", "INFO",
count(canFiles, () -> workbenchMapper.countFiles("QUARANTINED", actor.userId(), role)),
"等待处理的隔离文件", canFiles, "/archives/files?scanStatus=QUARANTINED")
);
List<WorkbenchActivityView> activities = workbenchMapper.listRecentActivities(actor.publicId(), 8).stream()
.map(row -> new WorkbenchActivityView(row.publicId(), row.actionCode(), row.objectType(),
row.objectPublicId(), row.title(), row.resultCode(), instant(row.occurredAt()),
activityRoute(row.objectType(), row.objectPublicId())))
.toList();
return new DashboardOverviewView(
"系统经营仪表盘",
Instant.now(),
currency,
systemView(),
metrics,
trend(actor.userId(), role, currency, canReceipts, canPayments, canInvoices),
lifecycle(actor.userId(), role, canProjects),
risks,
new DashboardGeographyView(false, "项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。", List.of()),
activities
);
}
private DashboardTrendView trend(long userId, String role, String currency,
boolean canReceipts, boolean canPayments, boolean canInvoices) {
YearMonth firstMonth = YearMonth.now(ZoneOffset.UTC).minusMonths(5);
LocalDate fromDate = firstMonth.atDay(1);
Map<String, BigDecimal> receipts = amountMap(canReceipts
? mapper.receiptTrend(userId, role, currency, fromDate) : List.of());
Map<String, BigDecimal> payments = amountMap(canPayments
? mapper.paymentTrend(userId, role, currency, fromDate) : List.of());
Map<String, BigDecimal> invoices = amountMap(canInvoices
? mapper.invoiceTrend(userId, role, currency, fromDate) : List.of());
List<DashboardTrendPointView> points = new ArrayList<>();
for (int index = 0; index < 6; index++) {
String period = firstMonth.plusMonths(index).toString();
points.add(new DashboardTrendPointView(period, plain(receipts.get(period)),
plain(payments.get(period)), plain(invoices.get(period))));
}
return new DashboardTrendView(currency, canReceipts, canPayments, canInvoices, points);
}
private List<DashboardDistributionView> lifecycle(long userId, String role, boolean available) {
Map<String, Long> counts = new LinkedHashMap<>();
if (available) {
mapper.countProjectStages(userId, role).forEach(row -> counts.put(row.code(), row.itemCount()));
}
return STAGES.stream()
.map(stage -> new DashboardDistributionView(stage, stageLabel(stage), counts.getOrDefault(stage, 0L),
available, "/projects?stage=" + stage))
.toList();
}
private DashboardSystemView systemView() {
List<DashboardServiceStatusView> services = new ArrayList<>();
services.add(new DashboardServiceStatusView("APPLICATION", "应用服务", "UP", "服务运行正常"));
services.add(new DashboardServiceStatusView("DATABASE", "数据库连接", "UP", "业务数据库连接正常"));
String storageStatus = storageStatus();
services.add(new DashboardServiceStatusView("FILE_STORAGE", "文件存储", storageStatus,
"UP".equals(storageStatus) ? "存储目录可读写" : "存储目录待初始化或当前不可写"));
services.add(new DashboardServiceStatusView("UPDATE", "更新服务", updateProperties.enabled() ? "UP" : "DISABLED",
updateProperties.enabled() ? "在线更新服务已启用" : "在线更新服务未启用"));
String overall = services.stream().anyMatch(item -> "DOWN".equals(item.status())) ? "DOWN"
: services.stream().anyMatch(item -> "WARNING".equals(item.status())) ? "WARNING" : "UP";
return new DashboardSystemView(overall, updateProperties.currentVersion(),
ManagementFactory.getRuntimeMXBean().getUptime() / 1000, List.copyOf(services));
}
private String storageStatus() {
try {
Path path = storageProperties.rootPath();
return Files.isDirectory(path) && Files.isReadable(path) && Files.isWritable(path) ? "UP" : "WARNING";
} catch (InvalidPathException | NullPointerException exception) {
return "WARNING";
}
}
private Map<String, BigDecimal> amountMap(List<DashboardMapper.AmountRow> rows) {
Map<String, BigDecimal> values = new LinkedHashMap<>();
rows.forEach(row -> values.merge(row.period(), value(row.amount()), BigDecimal::add));
return values;
}
private DashboardMetricView countMetric(String code, String label, long value, boolean available,
String route) {
return new DashboardMetricView(code, label, "COUNT", available ? Long.toString(value) : "0", "项",
available, available ? route : null);
}
private DashboardMetricView moneyMetric(String code, String label, BigDecimal value, String currency,
boolean available, String route) {
return new DashboardMetricView(code, label, "MONEY", available ? plain(value) : "0", currency,
available, available ? route : null);
}
private DashboardRiskView risk(String code, String label, String severity, long count, String detail,
boolean available, String route) {
return new DashboardRiskView(code, label, severity, count, detail, available, available ? route : null);
}
private long count(boolean available, Supplier<Long> supplier) {
return available ? supplier.get() : 0;
}
private BigDecimal amount(boolean available, Supplier<BigDecimal> supplier) {
return available ? value(supplier.get()) : BigDecimal.ZERO;
}
private BigDecimal value(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private String plain(BigDecimal value) {
return value(value).stripTrailingZeros().toPlainString();
}
private String normalizeCurrency(String requested) {
String currency = requested == null ? "CNY" : requested.trim().toUpperCase(Locale.ROOT);
if (!SUPPORTED_CURRENCIES.contains(currency)) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"币种仅支持 CNY、USD、EUR、HKD、JPY 或 GBP");
}
return currency;
}
private String stageLabel(String stage) {
return switch (stage) {
case "INITIATION" -> "立项";
case "CONTRACTING" -> "合同签订";
case "EXECUTION" -> "执行中";
case "ACCEPTANCE" -> "验收";
case "SETTLEMENT" -> "结算";
case "CLOSED" -> "已关闭";
default -> stage;
};
}
private String activityRoute(String objectType, String objectPublicId) {
if (objectPublicId == null || objectPublicId.isBlank()) return "/reports";
return switch (objectType) {
case "PROJECT" -> "/projects/%s".formatted(objectPublicId);
case "PAYMENT", "PAYMENT_REQUEST" -> "/finance/payments?keyword=%s".formatted(objectPublicId);
case "VOUCHER", "ACCOUNTING" -> "/finance/accounting?keyword=%s".formatted(objectPublicId);
case "ARCHIVE_PACKAGE", "ARCHIVE" -> "/archives/projects";
default -> "/reports";
};
}
private Instant instant(java.time.LocalDateTime value) {
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -0,0 +1,256 @@
package com.kaidi.finance.dashboard.infrastructure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@org.apache.ibatis.annotations.Mapper
public interface DashboardMapper {
@Select("""
SELECT COUNT(*)
FROM md_project project
JOIN md_company company ON company.id = project.company_id
WHERE project.status IN ('ACTIVE', 'SUSPENDED')
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
long countProjects(@Param("userId") long userId, @Param("roleCode") String roleCode);
@Select("""
SELECT COALESCE(SUM(contract.original_amount + contract.approved_change_amount), 0)
FROM md_contract contract
JOIN md_project project ON project.id = contract.project_id
JOIN md_company company ON company.id = contract.company_id
WHERE contract.status NOT IN ('VOID', 'DISABLED')
AND contract.currency = #{currency}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:contract:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
BigDecimal sumContracts(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency);
@Select("""
SELECT COALESCE(SUM(receipt.amount), 0)
FROM fin_receipt receipt
JOIN md_company company ON company.id = receipt.company_id
LEFT JOIN md_project project ON project.id = receipt.project_id
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
AND receipt.currency = #{currency}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
BigDecimal sumReceipts(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency);
@Select("""
SELECT COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0)
FROM fin_payment_request payment
JOIN md_company company ON company.id = payment.company_id
JOIN md_project project ON project.id = payment.project_id
WHERE payment.status = 'PAID'
AND payment.currency = #{currency}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
BigDecimal sumPayments(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency);
@Select("""
SELECT project.stage AS code, COUNT(*) AS item_count
FROM md_project project
JOIN md_company company ON company.id = project.company_id
WHERE project.status IN ('ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED')
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
GROUP BY project.stage
ORDER BY FIELD(project.stage, 'INITIATION', 'CONTRACTING', 'EXECUTION',
'ACCEPTANCE', 'SETTLEMENT', 'CLOSED')
""")
List<CountRow> countProjectStages(@Param("userId") long userId, @Param("roleCode") String roleCode);
@Select("""
SELECT DATE_FORMAT(receipt.receipt_date, '%Y-%m') AS period,
COALESCE(SUM(receipt.amount), 0) AS amount
FROM fin_receipt receipt
JOIN md_company company ON company.id = receipt.company_id
LEFT JOIN md_project project ON project.id = receipt.project_id
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
AND receipt.currency = #{currency}
AND receipt.receipt_date >= #{fromDate}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
GROUP BY DATE_FORMAT(receipt.receipt_date, '%Y-%m')
ORDER BY period
""")
List<AmountRow> receiptTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
@Select("""
SELECT DATE_FORMAT(payment.requested_date, '%Y-%m') AS period,
COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0) AS amount
FROM fin_payment_request payment
JOIN md_company company ON company.id = payment.company_id
JOIN md_project project ON project.id = payment.project_id
WHERE payment.status = 'PAID'
AND payment.currency = #{currency}
AND payment.requested_date >= #{fromDate}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
GROUP BY DATE_FORMAT(payment.requested_date, '%Y-%m')
ORDER BY period
""")
List<AmountRow> paymentTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
@Select("""
SELECT DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m') AS period,
COALESCE(SUM(invoice.amount), 0) AS amount
FROM fin_invoice invoice
JOIN md_company company ON company.id = invoice.company_id
JOIN md_project project ON project.id = invoice.project_id
WHERE invoice.status = 'ISSUED'
AND project.currency = #{currency}
AND COALESCE(invoice.issued_date, invoice.requested_date) >= #{fromDate}
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'receivable:invoice:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
GROUP BY DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m')
ORDER BY period
""")
List<AmountRow> invoiceTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
@Select("""
SELECT COUNT(*)
FROM project_risk_flag risk
JOIN md_project project ON project.id = risk.project_id
JOIN md_company company ON company.id = project.company_id
WHERE risk.status = 'ACTIVE'
AND risk.effective_from <= UTC_TIMESTAMP(3)
AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3))
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'project:risk-flag:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
long countActiveRisks(@Param("userId") long userId, @Param("roleCode") String roleCode);
@Select("""
SELECT COUNT(DISTINCT payment.id)
FROM fin_payment_check payment_check
JOIN fin_payment_request payment ON payment.id = payment_check.payment_id
JOIN md_company company ON company.id = payment.company_id
JOIN md_project project ON project.id = payment.project_id
WHERE payment_check.result = 'BLOCK'
AND payment.status NOT IN ('VOID', 'PAID', 'REFUNDED')
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
""")
long countPaymentBlocks(@Param("userId") long userId, @Param("roleCode") String roleCode);
record CountRow(String code, long itemCount) {
}
record AmountRow(String period, BigDecimal amount) {
}
}
@@ -376,7 +376,7 @@ public class AuthApplicationService {
case "PROJECT_MANAGER" -> "/workbench/project"; case "PROJECT_MANAGER" -> "/workbench/project";
case "FINANCE_MANAGER" -> "/workbench/finance"; case "FINANCE_MANAGER" -> "/workbench/finance";
case "ARCHIVE_MANAGER" -> "/workbench/archive"; case "ARCHIVE_MANAGER" -> "/workbench/archive";
case "SYSTEM_ADMIN" -> "/governance/settings"; case "SYSTEM_ADMIN" -> "/dashboard";
default -> "/role-select"; default -> "/role-select";
}; };
} }
@@ -77,6 +77,18 @@ public class AuditService {
*/ */
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state, public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt) { String targetVersion, String message, Instant updatedAt) {
return recordSystemUpdateTerminal(updateRequestId, updateAction, state, targetVersion, message, updatedAt,
null, null);
}
/**
* Persists a terminal update event together with the release metadata that was verified before the switch.
* Keeping the signed release notes in the audit snapshot lets the history page work after a restart or when
* the release host is temporarily unavailable.
*/
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt,
String releaseNotes, Instant publishedAt) {
String normalizedState = normalizeTerminalState(state); String normalizedState = normalizeTerminalState(state);
String normalizedVersion = clean(targetVersion, 128); String normalizedVersion = clean(targetVersion, 128);
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null; if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
@@ -110,6 +122,9 @@ public class AuditService {
terminal.put("targetVersion", normalizedVersion); terminal.put("targetVersion", normalizedVersion);
terminal.put("message", terminalReason == null ? "" : terminalReason); terminal.put("message", terminalReason == null ? "" : terminalReason);
terminal.put("updatedAt", updatedAt); terminal.put("updatedAt", updatedAt);
String normalizedNotes = clean(releaseNotes, 4000);
if (normalizedNotes != null) terminal.put("releaseNotes", normalizedNotes);
if (publishedAt != null) terminal.put("publishedAt", publishedAt);
if (normalizedAction != null) terminal.put("action", normalizedAction); if (normalizedAction != null) terminal.put("action", normalizedAction);
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId); if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
@@ -10,8 +10,9 @@ import java.security.NoSuchAlgorithmException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -47,6 +48,17 @@ public class AuthorizationService {
} }
} }
/** Loads the active role's permissions once for request-level capability decisions. */
public Set<String> activePermissions() {
FinancePrincipal principal = identityContext.requirePrincipal();
if (principal.mustChangePassword()) {
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_PASSWORD_CHANGE_REQUIRED,
"首次登录必须先修改密码");
}
String role = identityContext.activeRole();
return role == null ? Set.of() : Set.copyOf(userAccountMapper.findPermissions(principal.userId(), role));
}
public void requireGlobalScope(String permissionCode) { public void requireGlobalScope(String permissionCode) {
requireScope(permissionCode, null, null, null); requireScope(permissionCode, null, null, null);
} }
@@ -0,0 +1,43 @@
package com.kaidi.finance.shared.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Adds long-lived browser caching only to fingerprinted frontend assets. */
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class StaticAssetCacheFilter extends OncePerRequestFilter {
static final String IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isStaticAssetRequest(request)) {
// Set this before the response is committed. Spring Security keeps an existing cache
// policy instead of replacing it with its default no-store response headers.
response.setHeader(HttpHeaders.CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL);
}
filterChain.doFilter(request, response);
}
private boolean isStaticAssetRequest(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
return path.startsWith("/assets/");
}
}
@@ -34,6 +34,7 @@ import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -47,6 +48,7 @@ public class SystemUpdateApplicationService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class); private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class);
private static final int MAX_MANIFEST_BYTES = 64 * 1024; private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_METADATA_BYTES = 16 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024; private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024; private static final int MAX_STATUS_BYTES = 64 * 1024;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024; private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
@@ -418,15 +420,71 @@ public class SystemUpdateApplicationService {
} }
private UpdateStatus readStatus() { private UpdateStatus readStatus() {
UpdateStatus resolved;
if (!effectiveEnabled()) { if (!effectiveEnabled()) {
return new UpdateStatus("DISABLED", "在线更新未配置", null, null); resolved = new UpdateStatus("DISABLED", "在线更新未配置", null, null);
} } else {
UpdateStatus persisted = readPersistedStatus(); UpdateStatus persisted = readPersistedStatus();
if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted; if (persisted != null && BUSY_STATES.contains(persisted.state())) {
if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted; resolved = persisted;
} else if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) {
resolved = persisted;
} else {
UpdateStatus queued = pendingStatus(); UpdateStatus queued = pendingStatus();
if (queued != null) return queued; resolved = queued != null
return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted; ? queued
: (persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted);
}
}
return enrichWithEmbeddedReleaseMetadata(resolved);
}
/**
* The updater which performed an older release switch may not have known about release notes yet.
* New artifacts therefore carry the signed notes inside the release directory as well. Reading that
* immutable, artifact-hashed file lets the first application process after an upgrade reconstruct the
* terminal audit snapshot without a network request or a database migration.
*/
private UpdateStatus enrichWithEmbeddedReleaseMetadata(UpdateStatus status) {
if (status == null) return null;
ReleaseMetadata metadata = readEmbeddedReleaseMetadata();
if (metadata == null) return status;
String current = currentVersion();
boolean matchesCurrent = metadata.version().equals(current);
boolean matchesTarget = status.targetVersion() != null && metadata.version().equals(status.targetVersion());
if (!matchesCurrent && !matchesTarget) return status;
String notes = status.releaseNotes() == null || status.releaseNotes().isBlank()
? metadata.releaseNotes() : status.releaseNotes();
Instant publishedAt = status.publishedAt() == null ? metadata.publishedAt() : status.publishedAt();
if (notes.equals(status.releaseNotes()) && Objects.equals(publishedAt, status.publishedAt())) return status;
return new UpdateStatus(status.state(), status.message(), status.targetVersion(), status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), status.requestId(), status.action(), notes, publishedAt);
}
private ReleaseMetadata readEmbeddedReleaseMetadata() {
Path versionFile = properties.currentVersionFile();
if (versionFile == null) return null;
Path releaseDirectory = versionFile.toAbsolutePath().normalize().getParent();
if (releaseDirectory == null) return null;
Path metadataFile = releaseDirectory.resolve("release-metadata.json").normalize();
if (!metadataFile.startsWith(releaseDirectory)
|| !Files.isRegularFile(metadataFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(metadataFile)) {
return null;
}
try {
if (Files.size(metadataFile) > MAX_RELEASE_METADATA_BYTES) return null;
JsonNode root = objectMapper.readTree(Files.readAllBytes(metadataFile));
String version = blank(root.path("version").asText(null));
String notes = boundedText(root, "releaseNotes", 4000);
Instant publishedAt = parseInstant(root.path("publishedAt").asText(null));
if (version == null || !VERSION.matcher(version).matches() || notes == null) return null;
return new ReleaseMetadata(version, notes, publishedAt);
} catch (IOException | RuntimeException exception) {
log.debug("嵌入式 Release 更新日志读取失败:{}", metadataFile, exception);
return null;
}
} }
private UpdateStatus readPersistedStatus() { private UpdateStatus readPersistedStatus() {
@@ -444,7 +502,8 @@ public class SystemUpdateApplicationService {
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300), boundedInteger(root, "restartExpectedSeconds", 0, 300),
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null))); boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)),
boundedText(root, "releaseNotes", 4000), parseInstant(root.path("publishedAt").asText(null)));
} catch (IOException exception) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
} }
@@ -466,9 +525,13 @@ public class SystemUpdateApplicationService {
&& authorizationService.hasPermission("admin:update:execute")) { && authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD"); actions.add(ready ? "INSTALL" : "DOWNLOAD");
} }
String releaseNotes = manifest != null && manifest.releaseNotes() != null && !manifest.releaseNotes().isBlank()
? manifest.releaseNotes() : status.releaseNotes();
Instant publishedAt = manifest != null && manifest.publishedAt() != null
? manifest.publishedAt() : status.publishedAt();
return new SystemUpdateView(enabled, current, candidateVersion, return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), available, status.state(), status.message(), releaseNotes,
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), publishedAt, lastCheckedAt, status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(), status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions)); status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
} }
@@ -672,7 +735,8 @@ public class SystemUpdateApplicationService {
if (fingerprint.equals(lastTerminalAuditFingerprint)) return; if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
try { try {
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(), String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
status.state(), status.targetVersion(), status.message(), status.updatedAt()); status.state(), status.targetVersion(), status.message(), status.updatedAt(),
status.releaseNotes(), status.publishedAt());
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint; if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(), log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
@@ -696,17 +760,21 @@ public class SystemUpdateApplicationService {
String releaseNotes) { String releaseNotes) {
} }
private record ReleaseMetadata(String version, String releaseNotes, Instant publishedAt) {
}
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond, Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds, Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) { String requestId, String action, String releaseNotes, Instant publishedAt) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null); this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null, null, null);
} }
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
String requestId, String action) { String requestId, String action) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action); this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action,
null, null);
} }
} }
@@ -2,6 +2,7 @@ package com.kaidi.setup;
import com.kaidi.finance.setup.SetupProperties; import com.kaidi.finance.setup.SetupProperties;
import com.kaidi.finance.shared.web.SpaForwardFilter; import com.kaidi.finance.shared.web.SpaForwardFilter;
import com.kaidi.finance.shared.web.StaticAssetCacheFilter;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -30,7 +31,7 @@ import org.springframework.context.annotation.Import;
} }
) )
@EnableConfigurationProperties(SetupProperties.class) @EnableConfigurationProperties(SetupProperties.class)
@Import(SpaForwardFilter.class) @Import({SpaForwardFilter.class, StaticAssetCacheFilter.class})
public class SetupApplication { public class SetupApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,40 @@
INSERT INTO iam_permission (code, name)
VALUES ('dashboard:overview:view', '查看系统仪表盘')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- The isolated system administrator is the only role that receives the new
-- entry automatically. Business roles can be granted the permission from
-- "权限与配置" according to the customer's actual responsibility matrix.
INSERT IGNORE INTO iam_role_permission (role_id, permission_id)
SELECT role.id, permission.id
FROM iam_role role
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
WHERE role.code = 'SYSTEM_ADMIN'
AND role.enabled = TRUE;
-- Keep the server-side permission and data-scope model aligned for existing
-- administrator accounts. Front-end super-admin bypasses are not considered
-- an authorization boundary.
INSERT IGNORE INTO iam_scope (
user_id,
role_id,
permission_id,
scope_type,
company_public_id,
project_public_id,
amount_limit,
status
)
SELECT user_role.user_id,
user_role.role_id,
permission.id,
'GLOBAL',
NULL,
NULL,
NULL,
'ACTIVE'
FROM iam_user_role user_role
JOIN iam_role role ON role.id = user_role.role_id
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
WHERE role.code = 'SYSTEM_ADMIN'
AND role.enabled = TRUE;
@@ -0,0 +1,143 @@
package com.kaidi.finance.dashboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.kaidi.finance.dashboard.api.DashboardMetricView;
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.iam.domain.RoleAssignment;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.file.FileStorageProperties;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.application.SystemUpdateProperties;
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DashboardApplicationServiceTest {
@TempDir
Path tempDir;
private DashboardMapper mapper;
private WorkbenchMapper workbenchMapper;
private IdentityContext identity;
private AuthorizationService authorization;
private DashboardApplicationService service;
@BeforeEach
void setUp() {
mapper = mock(DashboardMapper.class);
workbenchMapper = mock(WorkbenchMapper.class);
identity = mock(IdentityContext.class);
authorization = mock(AuthorizationService.class);
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(authorization.activePermissions()).thenReturn(Set.of(
"dashboard:overview:view",
"project:project:view",
"masterdata:contract:view",
"receivable:receipt:view",
"payment:request:view",
"receivable:invoice:view",
"workflow:task:view",
"project:risk-flag:view",
"archive:package:view",
"archive:file:view"
));
when(workbenchMapper.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
when(workbenchMapper.countFiles(anyString(), anyLong(), anyString())).thenReturn(1L);
when(workbenchMapper.listRecentActivities(anyString(), anyInt())).thenReturn(List.of());
when(mapper.countProjectStages(anyLong(), anyString())).thenReturn(List.of(
new DashboardMapper.CountRow("INITIATION", 2L),
new DashboardMapper.CountRow("EXECUTION", 1L)));
when(mapper.receiptTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
when(mapper.paymentTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
when(mapper.invoiceTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
when(mapper.countProjects(anyLong(), anyString())).thenReturn(3L);
when(mapper.sumContracts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("12345.67"));
when(mapper.sumReceipts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("2345.67"));
when(mapper.sumPayments(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("345.67"));
when(mapper.countActiveRisks(anyLong(), anyString())).thenReturn(4L);
when(mapper.countPaymentBlocks(anyLong(), anyString())).thenReturn(1L);
service = new DashboardApplicationService(
mapper,
workbenchMapper,
identity,
authorization,
new SystemUpdateProperties(true, "1.0.0-preview.48", tempDir.resolve("VERSION"),
"https://git.example.invalid/releases", "https://git.example.invalid/api/latest", "",
tempDir.resolve("request.json"), tempDir.resolve("status.json"), Duration.ofSeconds(1),
Duration.ofSeconds(1), false),
new FileStorageProperties(tempDir.toString(), tempDir.resolve("tmp").toString(), 100_000L));
}
@Test
void buildsPermissionAwareOverviewWithMoneyAndLifecycleData() {
var view = service.overview("usd");
assertEquals("USD", view.currency());
assertEquals("1.0.0-preview.48", view.system().currentVersion());
assertEquals("UP", view.system().overallStatus());
assertEquals(6, view.trend().points().size());
assertEquals(2L, view.lifecycle().stream()
.filter(item -> item.code().equals("INITIATION"))
.findFirst().orElseThrow().value());
DashboardMetricView contract = view.metrics().stream()
.filter(item -> item.code().equals("CONTRACT_AMOUNT"))
.findFirst().orElseThrow();
assertEquals("12345.67", contract.value());
assertTrue(contract.available());
assertFalse(view.geography().available());
verify(authorization).activePermissions();
}
@Test
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
when(authorization.activePermissions()).thenReturn(Set.of("dashboard:overview:view"));
var view = service.overview("CNY");
assertTrue(view.metrics().stream().noneMatch(DashboardMetricView::available));
assertTrue(view.lifecycle().stream().noneMatch(item -> item.available()));
assertTrue(view.risks().stream().noneMatch(item -> item.available()));
}
@Test
void rejectsUnsupportedCurrencyBeforeQueryingBusinessData() {
assertThrows(BusinessException.class, () -> service.overview("RMB"));
}
@Test
void requiresTheDedicatedDashboardPermission() {
when(authorization.activePermissions()).thenReturn(Set.of());
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
.when(authorization).requirePermission("dashboard:overview:view");
assertThrows(BusinessException.class, () -> service.overview("CNY"));
}
}
@@ -179,7 +179,7 @@ class AuthIntegrationTest {
.andExpect(jsonPath("$.data.roles.length()").value(1)) .andExpect(jsonPath("$.data.roles.length()").value(1))
.andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN")) .andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN"))
.andExpect(jsonPath("$.data.roles[0].name").value("超级管理员")) .andExpect(jsonPath("$.data.roles[0].name").value("超级管理员"))
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/governance/settings")) .andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/dashboard"))
.andReturn(); .andReturn();
ClientSession session = clientSession(login); ClientSession session = clientSession(login);
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
@@ -57,6 +58,10 @@ class SetupContextSmokeTest {
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class); ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(asset.getBody()).contains("kaidi-spa-fixture"); assertThat(asset.getBody()).contains("kaidi-spa-fixture");
assertThat(asset.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isEqualTo("public, max-age=31536000, immutable");
assertThat(setupPage.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isNotEqualTo("public, max-age=31536000, immutable");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class); ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
@@ -27,7 +27,8 @@ class AuditServiceTest {
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST")) "01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
.thenReturn(new AuditMapper.SystemUpdateAuditSource( .thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN", "01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}", null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\","
+ "\"releaseNotes\":\"Preview update\"}",
"127.0.0.1", "fixture-agent")); "127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1); when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(), AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
@@ -35,9 +36,11 @@ class AuditServiceTest {
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z"); Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
assertEquals(firstKey, secondKey); assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:")); assertTrue(firstKey.startsWith("SYSUPD:"));
@@ -52,6 +55,8 @@ class AuditServiceTest {
assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED")); assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED"));
assertTrue(persisted.afterJson().contains("1.0.0-preview.44")); assertTrue(persisted.afterJson().contains("1.0.0-preview.44"));
assertTrue(persisted.afterJson().contains("SUCCEEDED")); assertTrue(persisted.afterJson().contains("SUCCEEDED"));
assertTrue(persisted.afterJson().contains("Preview update"));
assertTrue(persisted.afterJson().contains("publishedAt"));
} }
@Test @Test
@@ -0,0 +1,55 @@
package com.kaidi.finance.shared.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class StaticAssetCacheFilterTest {
private final StaticAssetCacheFilter filter = new StaticAssetCacheFilter();
@Test
void cachesFingerprintAssetsForOneYear() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/assets/index-a1b2c3.js");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(continued).isTrue();
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void supportsContextPathAndHeadRequests() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("HEAD", "/finance/assets/index-a1b2c3.css");
request.setContextPath("/finance");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void doesNotCacheHtmlApiOrWriteRequests() throws Exception {
assertNotCached("GET", "/index.html");
assertNotCached("GET", "/api/v1/dashboard/overview");
assertNotCached("POST", "/assets/index-a1b2c3.js");
}
private void assertNotCached(String method, String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(method, uri);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull();
}
}
@@ -294,21 +294,26 @@ class SystemUpdateApplicationServiceTest {
Path statusFile = tempDir.resolve("terminal-status.json"); Path statusFile = tempDir.resolve("terminal-status.json");
Files.writeString(statusFile, """ Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44", {"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092", "updatedAt":"2026-08-19T00:10:00Z","releaseNotes":"Preview update",
"publishedAt":"2026-08-16T00:00:00Z","requestId":"01M00000000000000000000092",
"action":"INSTALL"} "action":"INSTALL"}
"""); """);
AuditService auditService = mock(AuditService.class); AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED", when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"))) "1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"),
"Preview update", java.time.Instant.parse("2026-08-16T00:00:00Z")))
.thenReturn("SYSUPD:terminal"); .thenReturn("SYSUPD:terminal");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
assertEquals("Preview update", service.status().releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), service.status().publishedAt());
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
java.time.Instant.parse("2026-08-19T00:10:00Z")); java.time.Instant.parse("2026-08-19T00:10:00Z"), "Preview update",
java.time.Instant.parse("2026-08-16T00:00:00Z"));
} }
@Test @Test
@@ -321,18 +326,54 @@ class SystemUpdateApplicationServiceTest {
"""); """);
AuditService auditService = mock(AuditService.class); AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"))) "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null))
.thenReturn("SYSUPD:legacy"); .thenReturn("SYSUPD:legacy");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")); "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null);
}
@Test
void restoresEmbeddedReleaseNotesAfterLegacyUpdaterSwitch() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("embedded-notes-inbox"));
Path versionFile = tempDir.resolve("current/VERSION");
Files.createDirectories(versionFile.getParent());
Files.writeString(versionFile, "1.0.0-preview.47\n");
Files.writeString(versionFile.resolveSibling("release-metadata.json"), """
{"version":"1.0.0-preview.47","publishedAt":"2026-08-19T00:28:41.701Z",
"releaseNotes":"从已签名制品恢复的更新日志"}
""");
Path statusFile = tempDir.resolve("embedded-notes-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.47 is running",
"targetVersion":"1.0.0-preview.47","updatedAt":"2026-08-19T00:30:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z")))
.thenReturn("SYSUPD:embedded-notes");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService, versionFile);
var status = service.status();
assertEquals("从已签名制品恢复的更新日志", status.releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-19T00:28:41.701Z"), status.publishedAt());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z"));
} }
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) { private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null, return serviceForStatus(inbox, statusFile, auditService, null);
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService,
Path versionFile) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", versionFile,
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile, "https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
Duration.ofSeconds(2), Duration.ofSeconds(2), true); Duration.ofSeconds(2), Duration.ofSeconds(2), true);
AuthorizationService authorization = mock(AuthorizationService.class); AuthorizationService authorization = mock(AuthorizationService.class);
@@ -382,6 +423,8 @@ class SystemUpdateApplicationServiceTest {
var checked = service.check(); var checked = service.check();
assertTrue(checked.updateAvailable()); assertTrue(checked.updateAvailable());
assertEquals("1.0.0-preview.2+build.7", checked.latestVersion()); assertEquals("1.0.0-preview.2+build.7", checked.latestVersion());
assertEquals("Preview update", checked.releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), checked.publishedAt());
assertTrue(checked.allowedActions().contains("DOWNLOAD")); assertTrue(checked.allowedActions().contains("DOWNLOAD"));
assertFalse(checked.allowedActions().contains("INSTALL")); assertFalse(checked.allowedActions().contains("INSTALL"));
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD= FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.46 APP_VERSION=1.0.0-preview.50
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API. # Use either a stable direct asset base URL or the public Gitea latest-release API.
+18
View File
@@ -43,6 +43,8 @@ RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION= TARGET_VERSION=
REQUEST_ID= REQUEST_ID=
REQUEST_ACTION= REQUEST_ACTION=
RELEASE_NOTES=
RELEASE_PUBLISHED_AT=
TERMINAL_STATUS_WRITTEN=false TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID= DOWNLOAD_PID=
DOWNLOAD_PGID= DOWNLOAD_PGID=
@@ -297,6 +299,8 @@ status() {
previous_message= previous_message=
previous_request_id= previous_request_id=
previous_action= previous_action=
previous_release_notes=
previous_published_at=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true) previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true) previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
@@ -304,6 +308,10 @@ status() {
"$STATUS_FILE" 2>/dev/null || true) "$STATUS_FILE" 2>/dev/null || true)
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \ previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true) | select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
previous_release_notes=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$STATUS_FILE" 2>/dev/null || true)
previous_published_at=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$STATUS_FILE" 2>/dev/null || true)
fi fi
status_request_id= status_request_id=
status_action= status_action=
@@ -315,6 +323,8 @@ status() {
fi fi
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id} [ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action} [ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
status_release_notes=${RELEASE_NOTES:-$previous_release_notes}
status_published_at=${RELEASE_PUBLISHED_AT:-$previous_published_at}
tmp="$STATUS_FILE.tmp.$$" tmp="$STATUS_FILE.tmp.$$"
jq -n \ jq -n \
--arg state "$state" \ --arg state "$state" \
@@ -327,11 +337,15 @@ status() {
--arg restartExpectedSeconds "$restart_expected_seconds" \ --arg restartExpectedSeconds "$restart_expected_seconds" \
--arg requestId "$status_request_id" \ --arg requestId "$status_request_id" \
--arg action "$status_action" \ --arg action "$status_action" \
--arg releaseNotes "$status_release_notes" \
--arg publishedAt "$status_published_at" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt} '{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end) + (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end) + (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end) + (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
+ (if ($releaseNotes | length) > 0 then {releaseNotes:$releaseNotes} else {} end)
+ (if ($publishedAt | length) > 0 then {publishedAt:$publishedAt} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end) + (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end) + (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end) + (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
@@ -1119,6 +1133,10 @@ EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-
"$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid" "$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid"
EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \ EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \
"$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid" "$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid"
RELEASE_NOTES=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$WORK_DIR/release-manifest.json") || fail "Release notes are invalid"
RELEASE_PUBLISHED_AT=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$WORK_DIR/release-manifest.json") || fail "Release publish time is invalid"
CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true) CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
@@ -0,0 +1,136 @@
# 系统经营仪表盘设计与验收说明
## 1. 交付目标
本轮新增 `PAGE-24 /dashboard`,作为登录并选择工作身份后的默认业务首页。页面采用“日常经营驾驶舱 + 系统运行监控 + 全屏故事巡航”的混合形态,同时遵循以下边界:
- 仪表盘是独立权限节点 `dashboard:overview:view`;未授权身份看不到菜单,也不能直接访问接口。
- 超级管理员默认拥有仪表盘权限;业务身份由“权限与配置”按岗位需要授予。
- 每个指标仍校验原业务权限和 `GLOBAL / COMPANY / PROJECT` 数据范围,不能因为拥有仪表盘权限而扩大数据可见范围。
- 金额按币种分别查询,不做跨币种相加。
- 当前项目没有可靠省市字段,因此不伪造地图点位;地图区域明确显示数据能力说明,后续补齐项目地域主数据后再接真实地图。
- 大屏使用现有 Vue、TDesign 和 ECharts 实现,不增加不透明的第三方大屏运行时依赖。
## 2. 页面形态
```text
┌──────────────────────────────────────────────────────────────────────┐
│ 系统经营仪表盘 币种[CNY⌄] 刷新数据 进入大屏 │
├──────────────────────────────────────────────────────────────────────┤
│ ● 应用正常 ● 数据库正常 ● 文件存储 ● 更新服务 版本/运行时长 │
├──────────┬──────────┬──────────┬──────────┬──────────┬───────────┤
│进行中项目│ 合同总额 │ 累计收款 │ 累计付款 │ 待办审批 │ 风险事项 │
├──────────────────────────────────┬───────────────────────────────────┤
│ 近六个月资金趋势(收款/付款/开票)│ 项目生命周期分布(环形图) │
├──────────────────────────────────┼───────────────────────────────────┤
│ 风险与异常(项目/审批/付款/档案) │ 地域能力区(无真实地域时明确降级)│
├──────────────────────────────────┴───────────────────────────────────┤
│ 最近业务活动 │
└──────────────────────────────────────────────────────────────────────┘
全屏巡航:
┌──────────────────────────────────────────────────────────────────────┐
│ 系统经营态势 章节 1/3 退出全屏 / Esc │
├──────────────────────────────────────────────────────────────────────┤
│ 第一幕:资金态势 → 第二幕:项目进程 → 第三幕:风险与系统健康 │
│ 每 8 秒自动切换,可手动选择章节;保留真实图表和数据来源说明 │
└──────────────────────────────────────────────────────────────────────┘
```
## 3. 功能清单与实现方式
| 功能 | 最终形态 | 实现方式 |
| --- | --- | --- |
| 默认首页 | 超级管理员及已授权身份进入 `/dashboard` | 后端角色工作台地址、前端回退地址和 Logo 返回地址统一使用仪表盘 |
| 菜单首项 | 左侧菜单第一行“仪表盘” | PAGE-24 路由合同 + `orderNo=1` 单菜单组 |
| 权限节点 | 可按角色授予 `dashboard:overview:view` | V074 写入权限;超级管理员自动获得角色权限和 GLOBAL scope |
| 系统状态条 | 应用、数据库、文件存储、更新服务、版本、运行时长 | 后端聚合只返回允许公开的健康摘要,不暴露 Actuator 明细 |
| 核心指标 | 进行中项目、合同额、收款、付款、待办、风险 | 每项独立检查底层业务权限;无权限时显示不可用状态而不是伪造零值 |
| 资金趋势 | 最近六个月收款、付款、开票折线/柱形组合图 | 服务端按月、币种、授权范围聚合;ECharts 模块化加载 |
| 生命周期 | 立项、合同、执行、验收、结算、关闭分布 | 服务端状态分桶;环形图和可跳转明细 |
| 风险异常 | 项目风险、审批超时、付款阻断、档案缺件、隔离文件 | 复用现有业务口径和权限范围,显示级别、数量和跳转入口 |
| 地图区域 | 高级视觉容器 + 数据能力说明 | 当前 `available=false`,明确不展示虚假地图;预留地域 DTO |
| 最近活动 | 最近 8 条业务审计活动 | 使用当前用户审计活动,只返回必要标题、结果和目标路由 |
| 自动刷新 | 60 秒自动刷新,也可手动刷新 | 页面定时器;离开页面时清理,避免后台重复请求 |
| 大屏巡航 | 三章节全屏故事模式 | 深色全屏层、8 秒巡航、手动切换、Esc 退出、减少动态效果兼容 |
| 响应式 | 1920、1440、1366、窄屏均不横向溢出 | 分段网格、弹性卡片、图表 ResizeObserver、窄屏重排 |
| 图表生命周期 | 主题/尺寸变化后正确重建 | ECharts `ResizeObserver`、销毁 `dispose()`、ARIA 描述 |
## 4. 后端接口合同
```http
GET /api/v1/dashboard/overview?currency=CNY
Permission: dashboard:overview:view
```
响应包含:
- `system`:总体状态、版本、运行时长、服务状态列表;
- `metrics`:六项核心指标,包含 `available` 和目标路由;
- `trend`:六个月资金趋势及各序列可见性;
- `lifecycle`:项目阶段分布;
- `risks`:风险异常列表;
- `geography`:地域可用性、说明和区域数据;
- `activities`:最近业务活动。
币种首期允许 `CNY / USD / EUR / HKD / JPY / GBP`。非法币种返回 422。接口、OpenAPI、前端 operation 合同保持一一对应。
## 5. 状态与异常规则
- 首次加载显示骨架屏;刷新时保留已有内容并显示加载状态。
- 网络或后端失败显示中文错误提示和重试按钮。
- 指标底层权限不足时只隐藏该指标的真实值与跳转,不影响其他已授权模块。
- 无趋势数据时显示真实零序列和空态,不构造演示数值。
- 文件目录不可读写时系统状态显示警告;在线更新关闭时显示“未启用”,不误判为服务宕机。
- 地域数据未配置时展示明确说明,不加载虚假坐标、地图轮廓或项目点位。
## 6. 验收标准
### 6.1 权限与路由
- PAGE-24 存在于 routes、components、operations 三类合同中。
- 仪表盘是左侧第一项;无 `dashboard:overview:view` 时菜单不可见、直达返回 403。
- 业务角色获得该权限后可访问;超级管理员无须额外配置。
- 超级管理员选择身份、点击文字 Logo、结果页返回首页时均进入 `/dashboard`。
### 6.2 数据正确性
- 所有查询同时使用当前用户、当前角色、权限码、有效期和数据范围。
- 没有底层权限的组件返回 `available=false`,不能泄露金额、数量或跳转地址。
- 金额保留数据库精度并按所选币种返回;不得跨币种合计。
- 生命周期固定补齐六个阶段;没有数据的阶段返回 0。
- 非法币种必须返回业务校验错误。
### 6.3 视觉与交互
- 日常驾驶舱、系统状态条、六个 KPI、两类图表、风险区、地域降级区、活动区完整显示。
- 大屏支持自动巡航、手动章节切换、按钮退出和 Esc 退出。
- 1366×768、1440×900、1920×1080 以及 390px 窄屏无页面横向溢出。
- 图表存在可访问名称,容器变化后正确缩放,路由离开后释放实例。
- 严重和致命级可访问性问题为 0,浏览器控制台无页面错误。
### 6.4 工程门禁
- 后端单元测试、架构授权扫描、Maven `verify` 通过。
- 前端 ESLint、Stylelint、Vue 类型检查、Vitest、生产构建和产物卫生扫描通过。
- Playwright 全页面回归、PAGE-24 专项、多视口、可访问性和生产产物冒烟通过。
- OpenAPI 合同检查和 `git diff --check` 通过。
## 7. 本轮自验记录
| 验收项 | 结果 |
| --- | --- |
| 后端 `mvn verify` | 通过;本机无 Docker,Testcontainers 的 MySQL 集成类按项目既有配置跳过 |
| Dashboard 后端单元测试与 Controller 授权扫描 | 通过 |
| 前端 ESLint / Stylelint | 通过 |
| Vitest | 13 个文件、64 项通过 |
| Vue 类型检查 + release build + dist hygiene | 通过 |
| PAGE-24 专项 Playwright | 三个桌面视口通过 |
| PAGE-24 axe / 页面溢出 | 通过 |
| 全页面 Playwright | 246 项均取得通过证据;三并发全量复跑出现 5 项环境性超时,改为单 worker 逐项复测后 5/5 通过 |
| release dist 冒烟 | 1 项通过 |
| OpenAPI / diff whitespace | 通过 |
## 8. 后续数据能力
地图进入真实交付前需为项目增加规范化地域字段(至少省、市、行政区代码),完成历史数据清洗、权限范围验证和坐标映射。地域字段未完成前继续保持当前降级形态,避免用项目名称、地址自由文本或随机坐标推断业务位置。
+1 -1
View File
@@ -77,7 +77,7 @@
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | | 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | | PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 | | PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 |
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release | | Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 10 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 | | Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 |
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | | 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 | | 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
+2 -1
View File
@@ -29,6 +29,7 @@
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] }, { "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] }, { "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] }, { "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] } { "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] },
{ "pageId": "PAGE-24", "required": ["Card", "Statistic", "Tag", "Select", "Timeline", "Empty", "Alert"] }
] ]
} }
+10
View File
@@ -1861,6 +1861,16 @@
"module": "system-update", "module": "system-update",
"export": "installSystemUpdate" "export": "installSystemUpdate"
} }
},
{
"operationId": "getDashboardOverview",
"method": "GET",
"path": "/api/v1/dashboard/overview",
"pages": ["PAGE-24"],
"api": {
"module": "dashboard",
"export": "getDashboardOverview"
}
} }
], ],
"infrastructure": [ "infrastructure": [
+12
View File
@@ -276,6 +276,18 @@
"breadcrumb": ["系统治理", "系统更新"], "breadcrumb": ["系统治理", "系统更新"],
"roles": ["SYSTEM_ADMIN"], "roles": ["SYSTEM_ADMIN"],
"permission": "admin:update:view" "permission": "admin:update:view"
},
{
"pageId": "PAGE-24",
"path": "/dashboard",
"component": "src/pages/overview/DashboardPage.vue",
"pageType": "business",
"pageTemplate": "operations-dashboard",
"requiresAuth": true,
"description": "统一展示经营指标、资金趋势、项目生命周期、风险异常和系统运行状态,并提供大屏巡航模式。",
"breadcrumb": ["仪表盘"],
"roles": [],
"permission": "dashboard:overview:view"
} }
] ]
} }
+2 -1
View File
@@ -21,7 +21,7 @@ const routes = (
).routes; ).routes;
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV'; const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']); const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23', 'PAGE-24']);
const roleNames: Record<string, string> = { const roleNames: Record<string, string> = {
PROJECT_MANAGER: '项目管理人员', PROJECT_MANAGER: '项目管理人员',
@@ -42,6 +42,7 @@ function expectedHeading(route: ContractRoute) {
if (route.pageId === 'PAGE-03') return '个人中心'; if (route.pageId === 'PAGE-03') return '个人中心';
if (route.pageId === 'PAGE-08') return '自动化验收项目'; if (route.pageId === 'PAGE-08') return '自动化验收项目';
if (route.pageId === 'PAGE-17') return '项目档案包'; if (route.pageId === 'PAGE-17') return '项目档案包';
if (route.pageId === 'PAGE-24') return '系统经营仪表盘';
return route.breadcrumb.at(-1) || ''; return route.breadcrumb.at(-1) || '';
} }
+190
View File
@@ -0,0 +1,190 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const adminSession = {
authenticated: true,
user: {
publicId: '01M00000000000000000000001',
username: 'dashboard-e2e',
displayName: '仪表盘验收管理员',
departmentName: '测试组',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
activeRole: 'SYSTEM_ADMIN',
permissions: [
'dashboard:overview:view',
'project:project:view',
'masterdata:contract:view',
'receivable:receipt:view',
'receivable:invoice:view',
'payment:request:view',
'workflow:task:view',
'project:risk-flag:view',
'archive:package:view',
'archive:file:view',
],
};
const dashboard = {
title: '系统经营仪表盘',
refreshedAt: '2026-08-19T04:00:00Z',
currency: 'CNY',
system: {
overallStatus: 'UP',
currentVersion: '1.0.0-preview.48',
uptimeSeconds: 7260,
services: [
{ code: 'APPLICATION', label: '应用服务', status: 'UP', detail: '服务运行正常' },
{ code: 'DATABASE', label: '数据库连接', status: 'UP', detail: '业务数据库连接正常' },
{ code: 'FILE_STORAGE', label: '文件存储', status: 'UP', detail: '存储目录可读写' },
{ code: 'UPDATE', label: '更新服务', status: 'UP', detail: '在线更新服务已启用' },
],
},
metrics: [
{
code: 'ACTIVE_PROJECTS',
label: '进行中项目',
kind: 'COUNT',
value: '8',
unit: '项',
available: true,
targetRoute: '/projects',
},
{
code: 'CONTRACT_AMOUNT',
label: '合同总额',
kind: 'MONEY',
value: '1234567.89',
unit: 'CNY',
available: true,
targetRoute: '/finance/contracts-costs',
},
{
code: 'RECEIPT_AMOUNT',
label: '累计收款',
kind: 'MONEY',
value: '456789.00',
unit: 'CNY',
available: true,
targetRoute: '/finance/receipts-invoices',
},
{
code: 'PAYMENT_AMOUNT',
label: '累计付款',
kind: 'MONEY',
value: '234567.00',
unit: 'CNY',
available: true,
targetRoute: '/finance/payments',
},
{
code: 'PENDING_TASKS',
label: '待办审批',
kind: 'COUNT',
value: '4',
unit: '项',
available: true,
targetRoute: '/tasks',
},
{
code: 'ACTIVE_RISKS',
label: '风险事项',
kind: 'COUNT',
value: '2',
unit: '项',
available: true,
targetRoute: '/projects?riskStatus=ACTIVE',
},
],
trend: {
currency: 'CNY',
receiptsAvailable: true,
paymentsAvailable: true,
invoicesAvailable: true,
points: [
{ period: '2026-03', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' },
{ period: '2026-04', receivedAmount: '200', paidAmount: '120', invoicedAmount: '180' },
{ period: '2026-05', receivedAmount: '320', paidAmount: '180', invoicedAmount: '260' },
{ period: '2026-06', receivedAmount: '410', paidAmount: '220', invoicedAmount: '330' },
{ period: '2026-07', receivedAmount: '500', paidAmount: '280', invoicedAmount: '420' },
{ period: '2026-08', receivedAmount: '620', paidAmount: '360', invoicedAmount: '510' },
],
},
lifecycle: [
{ code: 'INITIATION', label: '立项', value: 2, available: true, targetRoute: '/projects?stage=INITIATION' },
{ code: 'EXECUTION', label: '执行中', value: 4, available: true, targetRoute: '/projects?stage=EXECUTION' },
{ code: 'SETTLEMENT', label: '结算', value: 2, available: true, targetRoute: '/projects?stage=SETTLEMENT' },
],
risks: [
{
code: 'PROJECT_RISK',
label: '项目风险',
severity: 'DANGER',
count: 2,
detail: '当前权限范围内的有效风险标记',
available: true,
targetRoute: '/projects?riskStatus=ACTIVE',
},
],
geography: { available: false, message: '项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。', regions: [] },
activities: [],
};
async function installFixture(page: Page) {
const requests = { setupStatus: 0 };
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
requests.setupStatus += 1;
await route.fulfill({ json: { data: { required: false, locked: true } } });
return;
}
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: adminSession } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: adminSession.user } });
return;
}
if (pathname === '/api/v1/dashboard/overview') {
await route.fulfill({ json: { data: dashboard } });
return;
}
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
});
return requests;
}
test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode', async ({ page }) => {
await installFixture(page);
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
await expect(page.getByText('进行中项目')).toBeVisible();
await expect(page.getByText('项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。')).toBeVisible();
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
await page.getByRole('button', { name: '进入大屏' }).click();
await expect(page.getByText('当前章节:经营总览')).toBeVisible();
await expect(page.getByRole('button', { name: '退出大屏' })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
});
test('installed setup status is reused across authenticated route navigation', async ({ page }) => {
const requests = await installFixture(page);
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
await page.getByText('系统治理', { exact: true }).click();
await page.getByText('权限与配置', { exact: true }).click();
await expect(page).toHaveURL(/\/governance\/settings$/);
expect(requests.setupStatus).toBe(1);
});
+87
View File
@@ -27,3 +27,90 @@ test('release build boots the setup wizard without runtime errors', async ({ pag
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
}); });
test('release build boots the dashboard and initializes charts without runtime errors', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/v1/setup/status') {
await route.fulfill({ json: { data: { required: false, locked: true } } });
return;
}
if (pathname === '/api/v1/auth/session') {
await route.fulfill({
json: {
data: {
authenticated: true,
user: {
publicId: '01M00000000000000000000001',
username: 'dist-dashboard',
displayName: '构建验收管理员',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
activeRole: 'SYSTEM_ADMIN',
permissions: ['dashboard:overview:view'],
},
},
});
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({
json: {
data: {
publicId: '01M00000000000000000000001',
username: 'dist-dashboard',
displayName: '构建验收管理员',
mustChangePassword: false,
},
},
});
return;
}
if (pathname === '/api/v1/dashboard/overview') {
await route.fulfill({
json: {
data: {
title: '系统经营仪表盘',
refreshedAt: '2026-08-19T04:00:00Z',
currency: 'CNY',
system: {
overallStatus: 'UP',
currentVersion: '1.0.0-preview.49',
uptimeSeconds: 60,
services: [],
},
metrics: [],
trend: {
currency: 'CNY',
receiptsAvailable: true,
paymentsAvailable: true,
invoicesAvailable: true,
points: [{ period: '2026-08', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' }],
},
lifecycle: [],
risks: [],
geography: { available: false, message: '暂无地域数据', regions: [] },
activities: [],
},
},
});
return;
}
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
});
const response = await page.goto('/dashboard');
expect(response?.ok()).toBe(true);
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
+17 -9
View File
@@ -138,8 +138,12 @@ async function installFixture(
beforeJson: null, beforeJson: null,
afterJson: JSON.stringify({ afterJson: JSON.stringify({
targetVersion, targetVersion,
...(actionCode === 'SYSTEM_UPDATE_CHECK' ? { releaseNotes: 'Preview update' } : {}), ...(actionCode === 'SYSTEM_UPDATE_CHECK'
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}), ? { releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
: {}),
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED'
? { state: 'SUCCEEDED', releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
: {}),
}), }),
occurredAt: '2026-08-16T00:02:00Z', occurredAt: '2026-08-16T00:02:00Z',
allowedActions: [], allowedActions: [],
@@ -214,6 +218,7 @@ async function installFixture(
const query = new URL(request.url()).searchParams; const query = new URL(request.url()).searchParams;
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z'); expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
expect(query.get('objectType')).toBe('SYSTEM_UPDATE'); expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
expect(query.get('size')).toBe('100');
return route.fulfill({ return route.fulfill({
json: { json: {
data: history, data: history,
@@ -379,7 +384,7 @@ test('brand logo returns the active identity to its workbench', async ({ page })
await page.locator('.brand-logo').click(); await page.locator('.brand-logo').click();
await expect(page).toHaveURL(/\/governance\/settings$/); await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0); await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
}); });
@@ -418,15 +423,18 @@ test('successful installation starts the ten-second automatic refresh countdown'
const historyPanel = page.locator('.history-panel'); const historyPanel = page.locator('.history-panel');
const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]'); const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]');
await expect(timelineItem).toHaveCount(1); await expect(timelineItem).toHaveCount(1);
await expect(timelineItem).toContainText('更新完成'); await expect(timelineItem).toContainText('成功');
await expect(timelineItem).toContainText('新版本已通过健康检查'); await expect(timelineItem).toContainText('新版本已通过健康检查');
await expect(timelineItem).toContainText('Preview update'); await expect(timelineItem).toContainText('Preview update');
await timelineItem.getByRole('button', { name: '查看完整记录', exact: true }).click(); await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click();
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' }); const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
await expect(historyDialog).toContainText('获取版本'); await expect(historyDialog).toContainText('更新日志');
await expect(historyDialog).toContainText('下载更新包'); await expect(historyDialog).toContainText('Preview update');
await expect(historyDialog).toContainText('立即更新并重启'); await expect(historyDialog).toContainText('发布时间');
await expect(historyDialog).toContainText('更新完成'); await expect(historyDialog.locator('.history-detail__steps')).toHaveCount(0);
await expect(historyDialog).not.toContainText('获取版本');
await expect(historyDialog).not.toContainText('下载更新包');
await expect(historyDialog).not.toContainText('立即更新并重启');
}); });
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => { test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
+127
View File
@@ -0,0 +1,127 @@
import { request } from '@/utils/request';
import type { WorkbenchActivity } from './workbench';
export type DashboardStatus = 'UP' | 'WARNING' | 'DOWN' | 'DISABLED';
export interface DashboardServiceStatus {
code: string;
label: string;
status: DashboardStatus;
detail: string;
}
export interface DashboardSystem {
overallStatus: DashboardStatus;
currentVersion: string;
uptimeSeconds: number;
services: DashboardServiceStatus[];
}
export interface DashboardMetric {
code: string;
label: string;
kind: 'COUNT' | 'MONEY';
value: string;
unit: string;
available: boolean;
targetRoute?: string;
}
export interface DashboardTrendPoint {
period: string;
receivedAmount: string;
paidAmount: string;
invoicedAmount: string;
}
export interface DashboardTrend {
currency: string;
receiptsAvailable: boolean;
paymentsAvailable: boolean;
invoicesAvailable: boolean;
points: DashboardTrendPoint[];
}
export interface DashboardDistribution {
code: string;
label: string;
value: number;
available: boolean;
targetRoute?: string;
}
export interface DashboardRisk {
code: string;
label: string;
severity: 'DANGER' | 'WARNING' | 'INFO';
count: number;
detail: string;
available: boolean;
targetRoute?: string;
}
export interface DashboardRegion {
regionCode: string;
regionName: string;
projectCount: number;
amount: string;
currency: string;
}
export interface DashboardGeography {
available: boolean;
message: string;
regions: DashboardRegion[];
}
export interface DashboardOverview {
title: string;
refreshedAt: string;
currency: string;
system: DashboardSystem;
metrics: DashboardMetric[];
trend: DashboardTrend;
lifecycle: DashboardDistribution[];
risks: DashboardRisk[];
geography: DashboardGeography;
activities: WorkbenchActivity[];
}
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
export function normalizeDashboard(view: Partial<DashboardOverview> | null | undefined): DashboardOverview {
return {
title: String(view?.title || '系统经营仪表盘'),
refreshedAt: String(view?.refreshedAt || ''),
currency: String(view?.currency || 'CNY'),
system: {
overallStatus: view?.system?.overallStatus || 'WARNING',
currentVersion: String(view?.system?.currentVersion || '-'),
uptimeSeconds: Number(view?.system?.uptimeSeconds || 0),
services: array(view?.system?.services),
},
metrics: array(view?.metrics),
trend: {
currency: String(view?.trend?.currency || view?.currency || 'CNY'),
receiptsAvailable: Boolean(view?.trend?.receiptsAvailable),
paymentsAvailable: Boolean(view?.trend?.paymentsAvailable),
invoicesAvailable: Boolean(view?.trend?.invoicesAvailable),
points: array(view?.trend?.points),
},
lifecycle: array(view?.lifecycle),
risks: array(view?.risks),
geography: {
available: Boolean(view?.geography?.available),
message: String(view?.geography?.message || '暂无地域数据'),
regions: array(view?.geography?.regions),
},
activities: array(view?.activities),
};
}
export function getDashboardOverview(currency = 'CNY', signal?: AbortSignal) {
return request
.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency }, signal })
.then(normalizeDashboard);
}
@@ -94,6 +94,6 @@ const getPath = (item: ListItemType) => {
return active.value; return active.value;
} }
return item.meta?.single ? item.redirect : item.path; return item.meta?.single ? item.redirect || item.path : item.path;
}; };
</script> </script>
@@ -111,9 +111,9 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>历史更新记录</h2> <h2>历史更新记录</h2>
<p>按版本汇总获取、下载、重启安装和最终执行结果。</p> <p>按版本展示签名 Release 提供的更新日志与最终结果。</p>
</div> </div>
<span>最近 {{ historyTimeline.length }} 个版本 · {{ historyRows.length }} 条记录</span> <span>已记录 {{ historyTimeline.length }} 个版本</span>
</div> </div>
<t-alert v-if="historyError" theme="error" :close-btn="false"> <t-alert v-if="historyError" theme="error" :close-btn="false">
@@ -146,37 +146,19 @@
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag> <t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
</div> </div>
<div class="timeline-card__summary"> <div class="timeline-card__notes">
<span class="timeline-card__label">执行流程</span> <span>更新日志</span>
<span>{{ item.operations.join(' · ') }}</span> <p>{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
<span class="timeline-card__count">{{ item.steps.length }} 条记录</span>
</div>
<p class="timeline-card__reason">{{ item.reason || '已记录本次版本更新操作。' }}</p>
<div v-if="item.releaseNotes" class="timeline-card__notes">
<span>版本说明</span>
<p>{{ item.releaseNotes }}</p>
</div>
<div class="timeline-steps" aria-label="更新步骤">
<div v-for="step in item.steps" :key="step.row.publicId" class="timeline-step">
<span class="timeline-step__dot" :class="`timeline-step__dot--${resultTheme(step.row.resultCode)}`" />
<div class="timeline-step__body">
<div class="timeline-step__heading">
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
<time>{{ formatDateTime(step.row.occurredAt) }}</time>
</div>
<p>{{ step.row.reason || '操作已记录' }} · {{ step.row.username || '系统' }}</p>
</div>
</div> </div>
<p class="timeline-card__reason">{{ item.reason || '更新结果已记录。' }}</p>
<div class="timeline-card__published">
<span v-if="item.publishedAt">发布时间:{{ formatDateTime(item.publishedAt) }}</span>
<span>记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
</div> </div>
<div class="timeline-card__footer"> <div class="timeline-card__footer">
<span>最后更新:{{ formatDateTime(item.occurredAt) }}</span> <span>目标版本 {{ item.version }}</span>
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看完整记录</t-button> <t-button variant="text" size="small" @click="openHistoryDetail(item)">查看更新日志</t-button>
</div> </div>
</div> </div>
</article> </article>
@@ -283,23 +265,17 @@
{{ resultLabel(selectedHistory.resultCode) }} {{ resultLabel(selectedHistory.resultCode) }}
</t-tag> </t-tag>
</div> </div>
<p class="history-detail__reason">{{ selectedHistory.reason || '已记录本次版本更新操作。' }}</p> <div class="history-detail__metadata">
<div v-if="selectedHistory.releaseNotes" class="history-detail__notes"> <span v-if="selectedHistory.publishedAt">发布时间:{{ formatDateTime(selectedHistory.publishedAt) }}</span>
<span>版本说明</span> <span
<p>{{ selectedHistory.releaseNotes }}</p> >记录时间:{{ formatDateTime(selectedHistory.occurredAt) }} · {{ selectedHistory.username || '系统' }}</span
</div> >
<div class="history-detail__steps" aria-label="更新日志">
<div v-for="step in selectedHistory.steps" :key="step.row.publicId" class="history-detail__step">
<div>
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
</div>
<time>{{ formatDateTime(step.row.occurredAt) }} · {{ step.row.username || '系统' }}</time>
<p>{{ step.row.reason || '操作已记录' }}</p>
</div> </div>
<div class="history-detail__notes">
<span>更新日志</span>
<p>{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
</div> </div>
<p class="history-detail__reason">{{ selectedHistory.reason || '更新结果已记录。' }}</p>
</div> </div>
</t-dialog> </t-dialog>
</div> </div>
@@ -322,10 +298,6 @@ import {
defineOptions({ name: 'SystemUpdatePage' }); defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface HistoryTimelineStep {
row: AuditLog;
}
interface HistoryTimelineItem { interface HistoryTimelineItem {
key: string; key: string;
version: string; version: string;
@@ -333,9 +305,8 @@ interface HistoryTimelineItem {
username: string; username: string;
resultCode: string; resultCode: string;
reason: string; reason: string;
operations: string[];
releaseNotes: string; releaseNotes: string;
steps: HistoryTimelineStep[]; publishedAt: string;
} }
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending'; const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
@@ -498,9 +469,8 @@ const historyTimeline = computed<HistoryTimelineItem[]>(() => {
const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode)); const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode));
const representative = terminal || newestFirst[0]; const representative = terminal || newestFirst[0];
const chronological = newestFirst.slice().reverse();
const operations = [...new Set(chronological.map((row) => actionLabel(row.actionCode)).filter(Boolean))];
const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || ''; const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || '';
return { return {
key, key,
@@ -509,9 +479,8 @@ const historyTimeline = computed<HistoryTimelineItem[]>(() => {
username: representative?.username || '', username: representative?.username || '',
resultCode: representative?.resultCode || 'UNKNOWN', resultCode: representative?.resultCode || 'UNKNOWN',
reason: representative?.reason || '', reason: representative?.reason || '',
operations,
releaseNotes, releaseNotes,
steps: chronological.map((row) => ({ row })), publishedAt,
}; };
}) })
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); .sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
@@ -571,7 +540,7 @@ async function loadHistory(silent = false) {
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc', sort: 'occurredAt,desc',
page: 1, page: 1,
size: 20, size: 100,
}); });
historyRows.value = result.items; historyRows.value = result.items;
} catch (error) { } catch (error) {
@@ -859,7 +828,24 @@ function historyVersion(row: AuditLog) {
function historyReleaseNotes(row: AuditLog) { function historyReleaseNotes(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) { for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source); const parsed = parseHistoryPayload(source);
if (typeof parsed?.releaseNotes === 'string' && parsed.releaseNotes.trim()) return parsed.releaseNotes; for (const key of ['releaseNotes', 'changelog', 'updateLog', 'notes']) {
const value = parsed?.[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (Array.isArray(value)) {
const lines = value
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
.map((item) => item.trim());
if (lines.length) return lines.join('\n');
}
}
}
return '';
}
function historyPublishedAt(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
if (typeof parsed?.publishedAt === 'string' && parsed.publishedAt.trim()) return parsed.publishedAt;
} }
return ''; return '';
} }
@@ -869,19 +855,6 @@ function openHistoryDetail(item: HistoryTimelineItem) {
historyDetailVisible.value = true; historyDetailVisible.value = true;
} }
function actionLabel(action: string) {
return (
{
SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
SYSTEM_UPDATE_SUCCEEDED: '更新完成',
SYSTEM_UPDATE_FAILED: '更新失败',
SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复',
}[action] || action
);
}
function resultLabel(result: string) { function resultLabel(result: string) {
return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result; return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result;
} }
@@ -1155,9 +1128,7 @@ onBeforeUnmount(() => {
} }
.timeline-card__header, .timeline-card__header,
.timeline-card__summary,
.timeline-card__footer, .timeline-card__footer,
.timeline-step__heading,
.history-detail__header { .history-detail__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1188,35 +1159,13 @@ onBeforeUnmount(() => {
} }
.timeline-card__identity span, .timeline-card__identity span,
.timeline-card__count,
.timeline-card__footer, .timeline-card__footer,
.timeline-step__heading time,
.history-detail__header span, .history-detail__header span,
.history-detail__step time { .history-detail__metadata {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: 12px; font-size: 12px;
} }
.timeline-card__summary {
flex-wrap: wrap;
margin-top: 12px;
padding: 9px 10px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 20px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 4px;
}
.timeline-card__label {
color: var(--td-text-color-secondary);
}
.timeline-card__count {
margin-left: auto;
white-space: nowrap;
}
.timeline-card__reason, .timeline-card__reason,
.history-detail__reason { .history-detail__reason {
margin: 12px 0 0; margin: 12px 0 0;
@@ -1250,65 +1199,15 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.timeline-steps { .timeline-card__published,
.history-detail__metadata {
display: flex; display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.timeline-step {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
min-width: 0;
}
.timeline-step__dot {
width: 8px;
height: 8px;
margin-top: 7px;
background: var(--td-component-border);
border-radius: 50%;
}
.timeline-step__dot--success {
background: var(--td-success-color);
}
.timeline-step__dot--danger {
background: var(--td-error-color);
}
.timeline-step__dot--warning {
background: var(--td-warning-color);
}
.timeline-step__body {
min-width: 0;
}
.timeline-step__heading {
flex-wrap: wrap; flex-wrap: wrap;
} gap: 6px 16px;
margin-top: 10px;
.timeline-step__heading strong {
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.timeline-step__heading time {
margin-left: auto;
white-space: nowrap;
}
.timeline-step__body p {
margin: 3px 0 0;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
overflow-wrap: anywhere;
} }
.timeline-card__footer { .timeline-card__footer {
@@ -1336,53 +1235,6 @@ onBeforeUnmount(() => {
line-height: 26px; line-height: 26px;
} }
.history-detail__steps {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 420px;
padding: 12px;
overflow: auto;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.history-detail__step {
padding-bottom: 12px;
border-bottom: 1px solid var(--td-component-border);
}
.history-detail__step:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.history-detail__step > div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.history-detail__step strong {
color: var(--td-text-color-primary);
font-size: 13px;
}
.history-detail__step time {
display: block;
margin-top: 4px;
}
.history-detail__step p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
overflow-wrap: anywhere;
}
.alert-content { .alert-content {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@@ -1552,16 +1404,6 @@ onBeforeUnmount(() => {
padding: 12px; padding: 12px;
} }
.timeline-card__count {
width: 100%;
margin-left: 0;
}
.timeline-step__heading time {
width: 100%;
margin-left: 0;
}
.timeline-card__notes, .timeline-card__notes,
.history-detail__notes { .history-detail__notes {
grid-template-columns: 1fr; grid-template-columns: 1fr;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
<template>
<div
ref="container"
class="dashboard-chart"
:style="{ height }"
:aria-label="label"
role="img"
data-testid="dashboard-chart"
></div>
</template>
<script setup lang="ts">
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { AriaComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
option: EChartsCoreOption;
height?: string;
label: string;
dark?: boolean;
}>(),
{ height: '300px', dark: false },
);
echarts.use([
AriaComponent,
BarChart,
CanvasRenderer,
GridComponent,
LegendComponent,
LineChart,
PieChart,
TitleComponent,
TooltipComponent,
]);
const container = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let observer: ResizeObserver | null = null;
let chartDark: boolean | null = null;
const render = async () => {
await nextTick();
if (!container.value) return;
if (!chart) {
chart = echarts.init(container.value, props.dark ? 'dark' : undefined, { renderer: 'canvas' });
chartDark = props.dark;
}
chart.setOption(props.option, { notMerge: true, lazyUpdate: false });
chart.resize();
};
onMounted(() => {
observer = new ResizeObserver(() => chart?.resize());
if (container.value) observer.observe(container.value);
void render();
});
watch(
() => [props.option, props.dark] as const,
() => {
if (chart && chartDark !== props.dark) {
chart.dispose();
chart = null;
chartDark = null;
}
void render();
},
{ deep: true },
);
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
chart?.dispose();
chart = null;
chartDark = null;
});
</script>
<style scoped>
.dashboard-chart {
width: 100%;
min-width: 0;
}
</style>
+1 -8
View File
@@ -21,14 +21,7 @@ defineOptions({
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const workbenchRoute = const workbenchRoute = userStore.workbenchRoute;
userStore.currentRole === 'PROJECT_MANAGER'
? '/workbench/project'
: userStore.currentRole === 'FINANCE_MANAGER'
? '/workbench/finance'
: userStore.currentRole === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: '/governance/settings';
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.result-success { .result-success {
+1 -8
View File
@@ -23,14 +23,7 @@ defineOptions({
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const workbenchRoute = const workbenchRoute = userStore.workbenchRoute;
userStore.currentRole === 'PROJECT_MANAGER'
? '/workbench/project'
: userStore.currentRole === 'FINANCE_MANAGER'
? '/workbench/finance'
: userStore.currentRole === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: '/governance/settings';
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.result-success { .result-success {
+27 -5
View File
@@ -14,17 +14,28 @@ NProgress.configure({ showSpinner: false });
const isPublic = (to: RouteLocationNormalized) => const isPublic = (to: RouteLocationNormalized) =>
to.path === '/login' || to.path === '/result/404' || to.path === '/setup'; to.path === '/login' || to.path === '/result/404' || to.path === '/setup';
const INSTALLED_SETUP_CACHE_MS = 5 * 60 * 1000;
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null; let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
let installedSetupStatus: Awaited<ReturnType<typeof getSetupStatus>> | null = null;
let installedSetupStatusExpiresAt = 0;
function loadSetupStatus() { function loadSetupStatus() {
if (installedSetupStatus && Date.now() < installedSetupStatusExpiresAt) {
return Promise.resolve(installedSetupStatus);
}
if (setupStatusRequest) return setupStatusRequest; if (setupStatusRequest) return setupStatusRequest;
// Share only an in-flight request. Keeping the resolved setup state would // A required setup is intentionally never cached because completion changes
// send the user back to /setup after the installation marker is written. // it to locked during the same browser session. The locked state is stable,
// so a short cache avoids blocking every authenticated route on this probe.
const request = getSetupStatus(); const request = getSetupStatus();
setupStatusRequest = request; setupStatusRequest = request;
request.then( request.then(
() => { (status) => {
if (!status.required) {
installedSetupStatus = status;
installedSetupStatusExpiresAt = Date.now() + INSTALLED_SETUP_CACHE_MS;
}
if (setupStatusRequest === request) setupStatusRequest = null; if (setupStatusRequest === request) setupStatusRequest = null;
}, },
() => { () => {
@@ -38,6 +49,14 @@ router.beforeEach(async (to) => {
NProgress.start(); NProgress.start();
const userStore = useUserStore(); const userStore = useUserStore();
const permissionStore = getPermissionStore(); const permissionStore = getPermissionStore();
const publicRoute = isPublic(to);
const sessionRequest =
!publicRoute && !userStore.authenticated
? userStore.restoreSession().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
)
: null;
let setupRequired = false; let setupRequired = false;
let setupStatusAvailable = false; let setupStatusAvailable = false;
@@ -55,12 +74,15 @@ router.beforeEach(async (to) => {
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup'; if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login'; if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
if (isPublic(to)) { if (publicRoute) {
return true; return true;
} }
try { try {
if (!userStore.authenticated) await userStore.restoreSession(); if (sessionRequest) {
const sessionResult = await sessionRequest;
if (!sessionResult.ok) throw sessionResult.error;
}
permissionStore.initRoutes(); permissionStore.initRoutes();
} catch { } catch {
userStore.clearSession(); userStore.clearSession();
+23
View File
@@ -22,6 +22,7 @@ const reportsPage = () => import('@/pages/reports/ReportsPage.vue');
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue'); const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue'); const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue'); const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
const dashboardPage = () => import('@/pages/overview/DashboardPage.vue');
const workbench = (name: 'project' | 'finance' | 'archive') => { const workbench = (name: 'project' | 'finance' | 'archive') => {
if (name === 'project') return () => import('@/pages/workbench/project.vue'); if (name === 'project') return () => import('@/pages/workbench/project.vue');
if (name === 'finance') return () => import('@/pages/workbench/finance.vue'); if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
@@ -77,6 +78,28 @@ const businessContract = (
}; };
export const financeRoutes: RouteRecordRaw[] = [ export const financeRoutes: RouteRecordRaw[] = [
{
path: '/dashboard',
name: 'Dashboard',
component: LAYOUT,
meta: {
title: { zh_CN: '仪表盘', en_US: 'Dashboard' },
titleText: '仪表盘',
icon: shallowRef(ChartBubbleIcon),
orderNo: 1,
single: true,
},
children: [
businessContract(
'PAGE-24',
'',
'DashboardOverview',
'仪表盘',
{ dataKind: 'dashboard', breadcrumb: ['仪表盘'] },
dashboardPage,
),
],
},
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [ group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
{ {
path: 'project', path: 'project',
+4 -1
View File
@@ -29,7 +29,10 @@ export const useUserStore = defineStore('user', {
getters: { getters: {
displayName: (state) => state.user.displayName || state.user.username, displayName: (state) => state.user.displayName || state.user.username,
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '', roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles), workbenchRoute: (state) =>
hasPermissionAccess(state.currentRole, state.permissions, 'dashboard:overview:view')
? '/dashboard'
: resolveWorkbenchRoute(state.currentRole, state.roles),
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode), hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
hasPermission: (state) => (permission: string) => hasPermission: (state) => (permission: string) =>
hasPermissionAccess(state.currentRole, state.permissions, permission), hasPermissionAccess(state.currentRole, state.permissions, permission),
@@ -4,7 +4,7 @@ const fallbackWorkbenchRoutes: Record<string, string> = {
PROJECT_MANAGER: '/workbench/project', PROJECT_MANAGER: '/workbench/project',
FINANCE_MANAGER: '/workbench/finance', FINANCE_MANAGER: '/workbench/finance',
ARCHIVE_MANAGER: '/workbench/archive', ARCHIVE_MANAGER: '/workbench/archive',
SYSTEM_ADMIN: '/governance/settings', SYSTEM_ADMIN: '/dashboard',
}; };
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string { export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
+3 -3
View File
@@ -171,12 +171,12 @@ describe('frontend machine contracts', () => {
expect(paymentApi).toContain("responseType: 'blob'"); expect(paymentApi).toContain("responseType: 'blob'");
}); });
it('declares every PAGE-01 through PAGE-23 exactly once', () => { it('declares every PAGE-01 through PAGE-24 exactly once', () => {
const expected = Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`); const expected = Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
const routePageIds = routesContract.routes.map((route) => route.pageId); const routePageIds = routesContract.routes.map((route) => route.pageId);
const componentPageIds = componentsContract.pages.map((page) => page.pageId); const componentPageIds = componentsContract.pages.map((page) => page.pageId);
expect([...routePageIds].sort()).toEqual([...expected].sort()); expect([...routePageIds].sort()).toEqual([...expected].sort());
expect(new Set(routePageIds).size).toBe(23); expect(new Set(routePageIds).size).toBe(24);
expect([...componentPageIds].sort()).toEqual([...expected].sort()); expect([...componentPageIds].sort()).toEqual([...expected].sort());
}); });
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { normalizeDashboard } from '../src/api/dashboard';
describe('pAGE-24 dashboard contracts', () => {
const pageSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/DashboardPage.vue'), 'utf8');
const chartSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/components/DashboardChart.vue'), 'utf8');
it('normalizes incomplete server data without creating fake business values', () => {
const view = normalizeDashboard({
system: { services: null } as any,
metrics: null,
trend: { points: null } as any,
lifecycle: undefined,
risks: null,
geography: { available: false, regions: null } as any,
activities: undefined,
} as unknown as Partial<ReturnType<typeof normalizeDashboard>>);
expect(view.metrics).toEqual([]);
expect(view.trend.points).toEqual([]);
expect(view.lifecycle).toEqual([]);
expect(view.risks).toEqual([]);
expect(view.activities).toEqual([]);
expect(view.geography.available).toBe(false);
expect(view.geography.regions).toEqual([]);
});
it('defines the hybrid cockpit and explicit permission-safe geography fallback', () => {
expect(pageSource).toContain('data-scene');
expect(pageSource).toContain('进入大屏');
expect(pageSource).toContain('退出大屏');
expect(pageSource).toContain('dashboard.geography.available');
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
expect(pageSource).toContain('当前身份没有查看仪表盘或相关业务数据的权限');
expect(pageSource).toContain('onActivated(activateDashboard)');
expect(pageSource).toContain('onDeactivated(deactivateDashboard)');
expect(pageSource).toContain('dashboardAbortController?.abort()');
expect(pageSource).toContain('setInterval(() => void loadDashboard(true), 60000)');
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
});
it('uses modular ECharts with resize and disposal lifecycle', () => {
expect(chartSource).toContain("from 'echarts/core'");
expect(chartSource).toContain('ResizeObserver');
expect(chartSource).toContain('chart.dispose()');
expect(chartSource).toContain('aria-label="label"');
});
});
+20 -1
View File
@@ -11,7 +11,7 @@ import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-
describe('router and TDesign menu contracts', () => { describe('router and TDesign menu contracts', () => {
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => { it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual( expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`), Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
); );
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments'); expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory'); expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
@@ -32,12 +32,31 @@ describe('router and TDesign menu contracts', () => {
} }
}; };
collectPaths(allRoutes); collectPaths(allRoutes);
expect(flattenedPaths).toContain('/dashboard');
expect(flattenedPaths).toContain('/projects/'); expect(flattenedPaths).toContain('/projects/');
expect(flattenedPaths).not.toContain('/dashboard/base'); expect(flattenedPaths).not.toContain('/dashboard/base');
expect(flattenedPaths).not.toContain('/detail/base'); expect(flattenedPaths).not.toContain('/detail/base');
expect(flattenedPaths).not.toContain('/list/base'); expect(flattenedPaths).not.toContain('/list/base');
}); });
it('places the dashboard first and hides it without its dedicated permission', () => {
const dashboard = allRoutes.find((route) => route.path === '/dashboard');
expect(dashboard?.meta?.orderNo).toBe(1);
expect(dashboard?.children?.[0]?.meta?.permission).toBe('dashboard:overview:view');
const withoutDashboard = filterMenu(allRoutes, {
currentRole: 'FINANCE_MANAGER',
permissions: ['workflow:task:view'],
});
expect(withoutDashboard.some((route) => route.path === '/dashboard')).toBe(false);
const withDashboard = filterMenu(allRoutes, {
currentRole: 'CUSTOM_ROLE',
permissions: ['dashboard:overview:view'],
});
expect(withDashboard[0]?.path).toBe('/dashboard');
});
it('filters menu leaves with the same role and permission rules used by route access', () => { it('filters menu leaves with the same role and permission rules used by route access', () => {
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] }; const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
const financeIdentity = { const financeIdentity = {
+2 -2
View File
@@ -4,13 +4,13 @@ import type { RoleView } from '../src/api/auth';
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route'; import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
const roles: RoleView[] = [ const roles: RoleView[] = [
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }, { code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/dashboard' },
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }, { code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
]; ];
describe('active identity workbench route', () => { describe('active identity workbench route', () => {
it('uses the route supplied for the active role', () => { it('uses the route supplied for the active role', () => {
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings'); expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/dashboard');
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance'); expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
}); });
+10
View File
@@ -56,6 +56,16 @@ export default ({ mode }: ConfigEnv): UserConfig => {
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/, test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
priority: 30, priority: 30,
}, },
{
// ECharts has internal class inheritance across modules. Keeping
// ECharts and zrender in one chunk avoids Rolldown splitting a
// base class and its derived series into independently evaluated
// chunks, which can fail in a production browser with
// "Class extends value undefined".
name: 'echarts-vendor',
test: /node_modules[\\/](?:echarts|zrender)[\\/]/,
priority: 25,
},
{ {
name: 'vue-vendor', name: 'vue-vendor',
test: /node_modules[\\/](?:vue|vue-router|pinia|vue-i18n|@vueuse)[\\/]/, test: /node_modules[\\/](?:vue|vue-router|pinia|vue-i18n|@vueuse)[\\/]/,
+194
View File
@@ -980,6 +980,16 @@ components:
requestId: requestId:
type: string type: string
type: object type: object
ApiResponseDashboardOverviewView:
properties:
data:
"$ref": "#/components/schemas/DashboardOverviewView"
meta:
additionalProperties: {}
type: object
requestId:
type: string
type: object
ApiResponseWorkflowTaskDetailView: ApiResponseWorkflowTaskDetailView:
properties: properties:
data: data:
@@ -6482,6 +6492,163 @@ components:
title: title:
type: string type: string
type: object type: object
DashboardOverviewView:
properties:
activities:
items:
"$ref": "#/components/schemas/WorkbenchActivityView"
type: array
currency:
type: string
geography:
"$ref": "#/components/schemas/DashboardGeographyView"
lifecycle:
items:
"$ref": "#/components/schemas/DashboardDistributionView"
type: array
metrics:
items:
"$ref": "#/components/schemas/DashboardMetricView"
type: array
refreshedAt:
format: date-time
type: string
risks:
items:
"$ref": "#/components/schemas/DashboardRiskView"
type: array
system:
"$ref": "#/components/schemas/DashboardSystemView"
title:
type: string
trend:
"$ref": "#/components/schemas/DashboardTrendView"
type: object
DashboardSystemView:
properties:
currentVersion:
type: string
overallStatus:
type: string
services:
items:
"$ref": "#/components/schemas/DashboardServiceStatusView"
type: array
uptimeSeconds:
format: int64
type: integer
type: object
DashboardServiceStatusView:
properties:
code:
type: string
detail:
type: string
label:
type: string
status:
type: string
type: object
DashboardMetricView:
properties:
available:
type: boolean
code:
type: string
kind:
type: string
label:
type: string
targetRoute:
type: string
unit:
type: string
value:
type: string
type: object
DashboardTrendView:
properties:
currency:
type: string
invoicesAvailable:
type: boolean
paymentsAvailable:
type: boolean
points:
items:
"$ref": "#/components/schemas/DashboardTrendPointView"
type: array
receiptsAvailable:
type: boolean
type: object
DashboardTrendPointView:
properties:
invoicedAmount:
type: string
paidAmount:
type: string
period:
type: string
receivedAmount:
type: string
type: object
DashboardDistributionView:
properties:
available:
type: boolean
code:
type: string
label:
type: string
targetRoute:
type: string
value:
format: int64
type: integer
type: object
DashboardRiskView:
properties:
available:
type: boolean
code:
type: string
count:
format: int64
type: integer
detail:
type: string
label:
type: string
severity:
type: string
targetRoute:
type: string
type: object
DashboardGeographyView:
properties:
available:
type: boolean
message:
type: string
regions:
items:
"$ref": "#/components/schemas/DashboardRegionView"
type: array
type: object
DashboardRegionView:
properties:
amount:
type: string
currency:
type: string
projectCount:
format: int64
type: integer
regionCode:
type: string
regionName:
type: string
type: object
WorkbenchArchiveItemView: WorkbenchArchiveItemView:
properties: properties:
completeness: completeness:
@@ -14157,6 +14324,33 @@ paths:
description: OK description: OK
tags: tags:
- workbench-controller - workbench-controller
"/api/v1/dashboard/overview":
get:
operationId: getDashboardOverview
parameters:
- in: query
name: currency
required: false
schema:
default: CNY
enum:
- CNY
- USD
- EUR
- HKD
- JPY
- GBP
type: string
responses:
'200':
content:
application/json:
schema:
"$ref": "#/components/schemas/ApiResponseDashboardOverviewView"
description: OK
summary: 查询当前身份可见的系统仪表盘
tags:
- dashboard-controller
"/api/v1/workbenches/finance": "/api/v1/workbenches/finance":
get: get:
operationId: finance operationId: finance
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 47)
本版本聚焦在线更新的可追溯性与发布说明展示,不改变财务业务数据和审批规则。
• 更新历史改为按版本展示 Release 更新日志,不再把获取、下载、重启等内部执行步骤当作历史内容。
• 历史详情直接展示签名 Release 的发布说明、发布时间、目标版本和最终结果。
• 更新器在验签后把 releaseNotes 与 publishedAt 写入状态文件;应用重启后仍能显示完整发布说明。
• 终态审计记录保存发布说明并保持幂等,历史记录不再只显示“更新成功”一条信息。
• 发布打包、验签和 Gitea Release 正文统一读取 release-notes/<version>.md,避免页面、清单和 Release 描述不一致。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 48)
本版本修复在线更新完成后后台看不到 Release 更新日志的问题。
• 更新日志继续以签名 release-manifest.json 为唯一发布来源,Gitea Release 正文与清单使用同一份内容。
• 发布制品新增 release-metadata.json,并由签名制品携带版本、发布时间和完整更新说明。
• 应用重启后会从当前已验签制品恢复更新说明;即使由旧版更新器完成版本切换,也不会再丢失日志。
• 更新完成后的终态审计会保存 Release 更新说明,系统更新页按版本显示完整日志,而不是只显示“更新成功”。
• 增加发布清单与制品内元数据的一致性校验,避免版本、发布时间和更新说明发生漂移。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+14
View File
@@ -0,0 +1,14 @@
更新日志(Preview 49)
本版本新增系统经营仪表盘,并将其设为已授权用户的首要业务入口。
• 新增“仪表盘”菜单和独立权限节点 dashboard:overview:view;超级管理员默认拥有完整访问权限,其他岗位可按角色授权。
• 新增经营驾驶舱,集中展示进行中项目、合同金额、收款、付款、待办审批、风险事项和最近业务活动。
• 新增近六个月资金趋势、项目生命周期分布和风险异常图表;所有指标继续遵守原业务权限与 GLOBAL、COMPANY、PROJECT 数据范围。
• 新增应用、数据库、文件存储和在线更新服务状态摘要,并展示当前版本与运行时长。
• 新增三章节全屏大屏巡航模式,支持自动切换、手动切换和 Esc 退出,同时完成 1366、1440、1920 视口适配。
• 当前业务数据尚无统一省市字段,地图区域采用明确的数据能力降级提示,不生成虚假地域和随机坐标。
• 超级管理员选择身份、点击文字 Logo 或从结果页返回首页时,统一进入新仪表盘,不再默认打开“权限与配置”。
• 演示数据脚本兼容 Flyway V073 与本版本新增的 V074 仪表盘权限迁移。
升级说明:本版本新增 V074 权限迁移,只写入仪表盘权限及超级管理员的授权范围,不安装或管理 MySQL,也不改变现有财务业务记录、审批规则和外部服务配置。
+13
View File
@@ -0,0 +1,13 @@
更新日志(Preview 50)
本版本集中优化线上仪表盘的首次打开速度、重复进入速度和生产构建稳定性。
• 修复生产构建中 ECharts 与 zrender 被错误拆分后可能出现的空白页;图表依赖现在作为完整模块加载,并新增真实发布包启动回归测试。
• 为带内容指纹的 `/assets/` 静态资源启用一年浏览器缓存,重复访问不再重新下载大型 TDesign、ECharts 与页面资源;HTML、接口和安装页面状态继续禁止使用该长期缓存策略。
• 登录会话恢复与安装状态检查改为并行执行,减少首次进入仪表盘前的串行等待。
• 已完成安装的状态在前端短时复用,页面间切换不再每次等待一次安装状态接口;仍需安装的状态不缓存,不影响首次安装向导切换。
• 仪表盘离开页面后会取消未完成请求并清理自动刷新、时钟和大屏巡航定时器,重新进入时按数据新鲜度刷新,避免重复请求和后台资源占用。
• 仪表盘后端改为一次读取当前身份的权限快照,再按权限决定指标可见性,减少重复权限查询,同时保持原角色、数据范围和超级管理员规则不变。
• 补充三档桌面视口的路由缓存验收、静态资源缓存集成测试、仪表盘生产包图表启动测试及后端权限快照测试。
升级说明:本版本不新增数据库迁移,不安装或修改 MySQL,不改变财务业务数据、审批规则、端口和反向代理配置。更新完成后浏览器首次获取新指纹资源,后续访问将直接复用缓存。
+9
View File
@@ -241,6 +241,15 @@ ruby -ryaml -e '
abort "system update response drifted" unless update_response == abort "system update response drifted" unless update_response ==
"#/components/schemas/ApiResponseSystemUpdateView" "#/components/schemas/ApiResponseSystemUpdateView"
dashboard = paths.fetch("/api/v1/dashboard/overview").fetch("get")
abort "dashboard operationId drifted" unless dashboard["operationId"] == "getDashboardOverview"
dashboard_currency = dashboard.fetch("parameters").find { |parameter| parameter["name"] == "currency" }
abort "dashboard currency whitelist drifted" unless dashboard_currency&.dig("schema", "enum") ==
%w[CNY USD EUR HKD JPY GBP]
dashboard_response = dashboard.dig("responses", "200", "content", "application/json", "schema", "$ref")
abort "dashboard response drifted" unless dashboard_response ==
"#/components/schemas/ApiResponseDashboardOverviewView"
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get") governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_parameter_names = %w[keyword page resource size] expected_parameter_names = %w[keyword page resource size]
+28 -2
View File
@@ -94,6 +94,22 @@ if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
exit 1 exit 1
fi fi
RELEASE_NOTES_FILE=${KAIDI_RELEASE_NOTES_FILE:-$ROOT/release-notes/$VERSION.md}
if [ -n "${KAIDI_RELEASE_NOTES+x}" ]; then
RELEASE_NOTES_VALUE=$KAIDI_RELEASE_NOTES
elif [ -f "$RELEASE_NOTES_FILE" ] && [ ! -L "$RELEASE_NOTES_FILE" ]; then
RELEASE_NOTES_VALUE=$(sed 's/\r$//' "$RELEASE_NOTES_FILE")
elif [ "$SOURCE_REF" != local ] || [ "${KAIDI_REQUIRE_RELEASE_NOTES:-false}" = true ]; then
printf 'Release notes file is missing: %s\n' "$RELEASE_NOTES_FILE" >&2
exit 1
else
RELEASE_NOTES_VALUE="Kaidi Finance Preview $VERSION"
fi
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES_VALUE" | wc -c | tr -d '[:space:]')
[ "$RELEASE_NOTES_BYTES" -gt 0 ] && [ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|| { printf 'Release notes must contain 1 to 4000 bytes\n' >&2; exit 1; }
PUBLISHED_AT=$(node -e 'process.stdout.write(new Date().toISOString())')
rm -rf "$OUTPUT_DIR" rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
STAGE="$WORK/stage" STAGE="$WORK/stage"
@@ -112,6 +128,15 @@ cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.servic
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service" cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path" cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh" chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh"
VERSION="$VERSION" RELEASE_NOTES="$RELEASE_NOTES_VALUE" PUBLISHED_AT="$PUBLISHED_AT" \
node <<'NODE' > "$STAGE/release-metadata.json"
const metadata = {
version: process.env.VERSION,
publishedAt: process.env.PUBLISHED_AT,
releaseNotes: process.env.RELEASE_NOTES,
};
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
NODE
if find "$STAGE" -type l -print -quit | grep -q .; then if find "$STAGE" -type l -print -quit | grep -q .; then
printf 'Release stage contains a symbolic link\n' >&2 printf 'Release stage contains a symbolic link\n' >&2
@@ -187,9 +212,9 @@ KAIDI_PURGER_SHA256=$PURGER_SHA256
EOF EOF
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt") BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"}
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \ VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \
RELEASE_NOTES="$RELEASE_NOTES_VALUE" \ RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
PUBLISHED_AT="$PUBLISHED_AT" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \ BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \ BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \ INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \
@@ -202,7 +227,8 @@ const manifest = {
artifact: process.env.ARTIFACT, artifact: process.env.ARTIFACT,
sha256: process.env.SHA256, sha256: process.env.SHA256,
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES), artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
publishedAt: new Date().toISOString(), publishedAt: process.env.PUBLISHED_AT,
releaseMetadata: 'release-metadata.json',
minimumJava: 17, minimumJava: 17,
source: { source: {
revision: process.env.SOURCE_REVISION, revision: process.env.SOURCE_REVISION,
+10 -3
View File
@@ -10,9 +10,7 @@ SOURCE_SHA=${GITEA_SHA:-}
TOKEN=${GITEA_TOKEN:-} TOKEN=${GITEA_TOKEN:-}
RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release} RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release}
RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG} RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG}
RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG RELEASE_BODY=${KAIDI_RELEASE_BODY-}
Source: $SOURCE_SHA}
WORK=$(mktemp -d) WORK=$(mktemp -d)
AUTH_HEADER=$WORK/gitea-auth-header AUTH_HEADER=$WORK/gitea-auth-header
@@ -38,6 +36,15 @@ esac
|| fail 'GITEA_TOKEN is invalid' || fail 'GITEA_TOKEN is invalid'
[ -d "$RELEASE_DIR" ] || fail 'release directory is missing' [ -d "$RELEASE_DIR" ] || fail 'release directory is missing'
if [ -z "${KAIDI_RELEASE_BODY+x}" ]; then
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
"$RELEASE_DIR/release-manifest.json") \
|| fail 'release manifest notes are missing or invalid'
RELEASE_BODY="$RELEASE_NOTES
Source: $SOURCE_SHA"
fi
printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER" printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER"
chmod 0600 "$AUTH_HEADER" chmod 0600 "$AUTH_HEADER"
+1
View File
@@ -29,6 +29,7 @@ for name in \
SHA256SUMS; do SHA256SUMS; do
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name" printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
done done
printf '%s\n' '{"releaseNotes":"Fixture release notes"}' > "$RELEASE_DIR/release-manifest.json"
cat > "$MOCK_BIN/curl" <<'SH' cat > "$MOCK_BIN/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
+1
View File
@@ -50,6 +50,7 @@ mode_of() {
source "$WORK/helpers.sh" source "$WORK/helpers.sh"
# Avoid invoking the host's macOS `log` utility while exercising extracted # Avoid invoking the host's macOS `log` utility while exercising extracted
# installer helpers. # installer helpers.
# shellcheck disable=SC2329 # Referenced by the sourced installer helper functions.
log() { printf '%s\n' "$*" >/dev/null; } log() { printf '%s\n' "$*" >/dev/null; }
export SETUP_WIZARD=true export SETUP_WIZARD=true
+6
View File
@@ -315,6 +315,10 @@ download_and_prepare_install() {
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \ [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \ && [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|| fail 'download phase did not preserve request correlation' || fail 'download phase did not preserve request correlation'
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|| fail 'download phase did not persist the signed release notes'
[ "$(jq -r '.publishedAt' "$fixture/state/status.json")" = 2026-08-16T00:00:00Z ] \
|| fail 'download phase did not persist the signed release publish time'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application' || fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \ [ -s "$fixture/state/cache/$version/release.tar.gz" ] \
@@ -456,6 +460,8 @@ assert_success_case() {
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \ [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \ && [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'success case did not preserve install request correlation' || fail 'success case did not preserve install request correlation'
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|| fail 'success case did not retain the signed release notes'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind' || fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
+26
View File
@@ -30,6 +30,11 @@ openssl dgst -sha256 -verify release-public.pem \
VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json) VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json)
"$ROOT/scripts/check-semver.sh" "$VERSION" \ "$ROOT/scripts/check-semver.sh" "$VERSION" \
|| { printf 'Release version is not valid SemVer\n' >&2; exit 1; } || { printf 'Release version is not valid SemVer\n' >&2; exit 1; }
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' release-manifest.json) \
|| { printf 'Release manifest must contain 1 to 4000 bytes of release notes\n' >&2; exit 1; }
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES" | wc -c | tr -d '[:space:]')
[ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|| { printf 'Release manifest release notes exceed 4000 bytes\n' >&2; exit 1; }
ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json) ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json)
[ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; } [ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; }
[ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \ [ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \
@@ -161,6 +166,27 @@ if tar -tvzf "$ARTIFACT" | grep -Eq '^l'; then
printf 'Release archive contains a symbolic link\n' >&2 printf 'Release archive contains a symbolic link\n' >&2
exit 1 exit 1
fi fi
RELEASE_METADATA_PATH=$(jq -r '.releaseMetadata // empty' release-manifest.json)
if [ -n "$RELEASE_METADATA_PATH" ]; then
[ "$RELEASE_METADATA_PATH" = "release-metadata.json" ] \
|| { printf 'Release metadata path is invalid\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" "./$RELEASE_METADATA_PATH" > "$WORK/release-metadata.json" \
|| { printf 'Release archive is missing embedded release metadata\n' >&2; exit 1; }
METADATA_VERSION=$(jq -er '.version | strings | select(length > 0)' "$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata version is invalid\n' >&2; exit 1; }
METADATA_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
"$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata notes are invalid\n' >&2; exit 1; }
METADATA_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' \
"$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata publish time is invalid\n' >&2; exit 1; }
MANIFEST_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' release-manifest.json) \
|| { printf 'Release manifest publish time is invalid\n' >&2; exit 1; }
[ "$METADATA_VERSION" = "$VERSION" ] \
&& [ "$METADATA_NOTES" = "$RELEASE_NOTES" ] \
&& [ "$METADATA_PUBLISHED_AT" = "$MANIFEST_PUBLISHED_AT" ] \
|| { printf 'Embedded release metadata does not match the signed manifest\n' >&2; exit 1; }
fi
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \ [ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; } || { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar" tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"