feat: add system operations dashboard
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 13:51:48 +08:00
parent 8e615b895e
commit 12d78b4c86
45 changed files with 2921 additions and 48 deletions
@@ -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,278 @@
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) {
authorizationService.requirePermission("dashboard:overview:view");
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String currency = normalizeCurrency(requestedCurrency);
boolean canProjects = authorizationService.hasPermission("project:project:view");
boolean canContracts = authorizationService.hasPermission("masterdata:contract:view");
boolean canReceipts = authorizationService.hasPermission("receivable:receipt:view");
boolean canPayments = authorizationService.hasPermission("payment:request:view");
boolean canInvoices = authorizationService.hasPermission("receivable:invoice:view");
boolean canWorkflow = authorizationService.hasPermission("workflow:task:view");
boolean canRisks = authorizationService.hasPermission("project:risk-flag:view");
boolean canArchives = authorizationService.hasPermission("archive:package:view");
boolean canFiles = authorizationService.hasPermission("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 "FINANCE_MANAGER" -> "/workbench/finance";
case "ARCHIVE_MANAGER" -> "/workbench/archive";
case "SYSTEM_ADMIN" -> "/governance/settings";
case "SYSTEM_ADMIN" -> "/dashboard";
default -> "/role-select";
};
}
@@ -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,136 @@
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.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 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.hasPermission(anyString())).thenReturn(true);
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());
}
@Test
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
when(authorization.hasPermission("project:project:view")).thenReturn(false);
when(authorization.hasPermission("masterdata:contract:view")).thenReturn(false);
when(authorization.hasPermission("receivable:receipt:view")).thenReturn(false);
when(authorization.hasPermission("payment:request:view")).thenReturn(false);
when(authorization.hasPermission("receivable:invoice:view")).thenReturn(false);
when(authorization.hasPermission("workflow:task:view")).thenReturn(false);
when(authorization.hasPermission("project:risk-flag:view")).thenReturn(false);
when(authorization.hasPermission("archive:package:view")).thenReturn(false);
when(authorization.hasPermission("archive:file:view")).thenReturn(false);
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() {
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[0].code").value("SYSTEM_ADMIN"))
.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();
ClientSession session = clientSession(login);