feat: add signed PostgreSQL release updates

This commit is contained in:
Qiufeng
2026-08-03 23:45:32 +08:00
parent 2524a37a07
commit f6e22cb670
99 changed files with 44671 additions and 284 deletions
@@ -36,7 +36,8 @@ public class AuthInterceptor implements HandlerInterceptor {
private static final Set<String> PUBLIC = Set.of(
"/api/oa/auth/login",
"/api/oa/auth/session",
"/api/oa/auth/logout"
"/api/oa/auth/logout",
"/api/oa/health"
);
/** 写操作方法(只有这些方法才触发授权门槛;GET/HEAD 等读操作不限制)。 */
@@ -44,6 +45,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private static final Set<String> ADMIN = Set.of("ADMIN");
private static final Set<String> APPROVER_OR_ADMIN = Set.of("ADMIN", "APPROVER");
private static final List<String> ADMIN_READ_PREFIXES = List.of("/api/oa/system-update");
/**
* 系统 / 组织 / 权限 / 配置 类写接口 → 仅 ADMIN。
@@ -51,6 +53,7 @@ public class AuthInterceptor implements HandlerInterceptor {
*/
private static final List<String> ADMIN_PREFIXES = List.of(
"/api/oa/users", "/api/oa/depts", "/api/oa/roles", "/api/oa/settings",
"/api/oa/system-update",
"/api/oa/delegations", "/api/oa/form-templates", "/api/oa/declaration-templates",
"/api/oa/contract-templates", "/api/oa/report-definitions", "/api/oa/crawl-sources",
// 全文索引重建(POST /search/reindex)是全库重活,仅 ADMIN 可触发(杜绝任意角色发起整库扫描)。
@@ -432,6 +435,10 @@ public class AuthInterceptor implements HandlerInterceptor {
return false;
}
} else {
if (matchesPrefix(path, ADMIN_READ_PREFIXES) && ADMIN.stream().noneMatch(have::contains)) {
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:系统更新仅限 ADMIN 角色");
return false;
}
// 读:机密财务/PII 读口需 ADMIN/APPROVER,其余业务读维持"已登录且有角色"可读。
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) {
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:该数据需要 ADMIN/APPROVER 角色");
@@ -441,6 +448,15 @@ public class AuthInterceptor implements HandlerInterceptor {
return true;
}
private static boolean matchesPrefix(String path, List<String> prefixes) {
for (String prefix : prefixes) {
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
return true;
}
}
return false;
}
private static boolean isSensitiveRead(String path) {
// 研发证据链聚合端点 /api/oa/rd-projects/{id}/evidence-chain 会端出研发费用金额/凭证号/申报/专利
// 等本应被 rd-expenses 读门槛拦下的明细(基路径 rd-projects 非敏感,故按后缀精确收口,
@@ -0,0 +1,92 @@
package com.kaidi.oa.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** Runtime configuration for the Gitea release updater. */
@Component
@ConfigurationProperties(prefix = "oa.update")
public class UpdateProperties {
private boolean enabled;
private String giteaBaseUrl = "";
private String repository = "awaioi/ERP";
private String channel = "stable";
private String token = "";
private String helperCommand = "";
private String stateFile = "./runtime/update-state.json";
private boolean allowInsecureHttp;
private int requestTimeoutSeconds = 15;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getGiteaBaseUrl() {
return giteaBaseUrl;
}
public void setGiteaBaseUrl(String giteaBaseUrl) {
this.giteaBaseUrl = giteaBaseUrl;
}
public String getRepository() {
return repository;
}
public void setRepository(String repository) {
this.repository = repository;
}
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getHelperCommand() {
return helperCommand;
}
public void setHelperCommand(String helperCommand) {
this.helperCommand = helperCommand;
}
public String getStateFile() {
return stateFile;
}
public void setStateFile(String stateFile) {
this.stateFile = stateFile;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
public void setAllowInsecureHttp(boolean allowInsecureHttp) {
this.allowInsecureHttp = allowInsecureHttp;
}
public int getRequestTimeoutSeconds() {
return requestTimeoutSeconds;
}
public void setRequestTimeoutSeconds(int requestTimeoutSeconds) {
this.requestTimeoutSeconds = requestTimeoutSeconds;
}
}
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -24,7 +23,6 @@ public class Announcement {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -91,18 +91,18 @@ public class AnnualReport {
// ---------- 扩展填报字段(模板化,各类年报共享) ----------
/** 关键指标描述(校验摘要/主要指标文字汇总)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String keyIndicatorSummary;
/** 数据一致性校验结果(自动校验后填入,如"研发费用占比符合要求")。 */
@Lob
@Column(columnDefinition = "TEXT")
private String validationResult;
/** 校验是否通过:true 全部通过,false 有警告或错误。 */
private Boolean validationPassed;
/** 备注/填报说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
/** 填报负责人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 博客文章(知识社区 - 我的博客)。tags 为逗号分隔。 */
@@ -22,7 +21,6 @@ public class Blog {
@Column(length = 2000)
private String summary;
@Lob
@Column(columnDefinition = "TEXT")
private String body;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -30,7 +30,7 @@ public class ChronicleEvent {
private String category;
@Lob
@Column(columnDefinition = "TEXT")
private String description;
/** 关联文档/单据(逗号分隔的引用,如 IP-2026-0001 / 合同号 / 文件名)。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -33,19 +32,16 @@ public class CollabDoc {
private String status;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private Instant updatedAt;
/** 历史版本 JSON 数组:[{version, content, savedAt, editor}]。每次更新正文前追加上一版。 */
@Lob
@Column(columnDefinition = "TEXT")
private String versionsJson;
/** 评论/批注 JSON 数组:[{author, content, createdAt}]。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commentsJson;
@@ -6,8 +6,9 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
@@ -34,8 +35,9 @@ public class CollabDocUpdate {
/** Monotonic per-document ordering (assigned at append time). */
private Long seq;
/** Raw yjs update bytes. Plain byte[] (NOT @Lob): SQLite JDBC can't read @Lob blobs; getBytes() works. */
@Column(name = "data", columnDefinition = "BLOB")
/** Raw yjs bytes mapped to SQLite BLOB and PostgreSQL bytea. */
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
@Column(name = "data", length = Integer.MAX_VALUE)
private byte[] data;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -40,7 +40,7 @@ public class CompetitorIp {
private String publicDate;
@Lob
@Column(columnDefinition = "TEXT")
private String summary;
/** 录入来源:手动 / API导入。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -61,13 +60,11 @@ public class ComplianceRiskReport {
private int findingCount;
/** 漏洞明细文本(可存 JSON 格式)。 */
@Lob
@Column(name = "findings_text")
@Column(name = "findings_text", columnDefinition = "TEXT")
private String findings;
/** 整改建议。 */
@Lob
@Column(name = "suggestion_text")
@Column(name = "suggestion_text", columnDefinition = "TEXT")
private String suggestion;
/** 报告生成人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -46,8 +45,7 @@ public class ContentPieceVersion {
private String titleSnapshot;
/** 正文快照(完整内容,用于 diff 对比)。 */
@Lob
@Column(name = "body_snapshot")
@Column(name = "body_snapshot", columnDefinition = "TEXT")
private String bodySnapshot;
private Instant createdAt;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -31,7 +31,7 @@ public class ContractTemplate {
private String applicableSubject;
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
private String requiredClauses;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -36,7 +36,7 @@ public class DeclarationTemplate {
private String autoCheckRules;
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
private String version;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -28,7 +27,6 @@ public class Discussion {
private String category;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class Discussion {
private Instant createdAt;
/** 回帖列表 JSON 数组:[{author, content, createdAt}]。 */
@Lob
@Column(columnDefinition = "TEXT")
private String repliesJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 享空间动态(文化建设 - 分享流)。tags 为逗号分隔。 */
@@ -24,7 +23,6 @@ public class Feed {
/** 动态 / 分享 / 图片 / 打卡 */
private String type;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class Feed {
private int comments;
/** 评论正文列表 [{author,content,createdAt}] 的 JSON。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commentsJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -39,12 +38,10 @@ public class FertProductQc {
private String standardCode;
/** 实测值 JSON{"有机质":45.2,"水分":28.1,...}(键=indicator)。 */
@Lob
@Column(columnDefinition = "text")
private String itemsJson;
/** 逐项判定明细 JSON(自动生成的报告口径)。 */
@Lob
@Column(columnDefinition = "text")
private String judgeJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -68,7 +67,6 @@ public class FertSupplierProfile {
private String licenseDoc;
/** 补充资质 JSON(扩展字段 {"有机认证":"有","产品标准":"QB/T xxxx",...})。 */
@Lob
@Column(columnDefinition = "text")
private String admitDocsJson;
@@ -42,7 +42,7 @@ public class Financing {
private String currency;
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
private Double rate;
private BigDecimal rate;
private String startDate;
@@ -122,11 +122,11 @@ public class Financing {
this.currency = currency;
}
public Double getRate() {
public BigDecimal getRate() {
return rate;
}
public void setRate(Double rate) {
public void setRate(BigDecimal rate) {
this.rate = rate;
}
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -31,7 +30,6 @@ public class FlowTrace {
/** Who handled the step. */
private String who;
@Lob
@Column(columnDefinition = "TEXT")
private String opinion;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -33,7 +32,6 @@ public class FormInstance {
private String title;
@Lob
@Column(name = "data_json", columnDefinition = "TEXT")
private String dataJson;
@@ -3,7 +3,6 @@ package com.kaidi.oa.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/**
@@ -28,15 +27,12 @@ public class FormTemplate {
private String org;
@Lob
@Column(name = "form_schema_json", columnDefinition = "TEXT")
private String formSchemaJson;
@Lob
@Column(name = "flow_schema_json", columnDefinition = "TEXT")
private String flowSchemaJson;
@Lob
@Column(columnDefinition = "TEXT")
private String instructions;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -40,7 +40,7 @@ public class HrLaborTemplate {
private String version;
/** 模板正文(含占位符 {员工姓名} {岗位} {薪资} 等)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
/** 必备条款(逗号分隔,如「试用期条款,保密条款,竞业限制条款」)。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -44,7 +43,7 @@ public class InternalNewsletter {
private String authorDept;
/** 正文(富文本,允许大段文字)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String body;
/** 配图/附件 URL(逗号分隔,如 "https://…/img1.jpg,https://…/img2.jpg")。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -92,7 +92,7 @@ public class IpAsset {
/** 责任人(IP 部门跟案人)。 */
private String owner;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -35,7 +35,7 @@ public class IpAssetEvent {
private String toValue;
@Lob
@Column(columnDefinition = "TEXT")
private String note;
/** 操作人(跟案人/复核人)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -33,7 +33,7 @@ public class IpKnowledge {
/** 技术关键词(逗号分隔,供按关键词检索)。 */
private String keywords;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String source;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -71,7 +71,7 @@ public class IpLicenseTransfer {
private String status;
/** 备注(合同关键条款摘要)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
/** 经办人。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -37,7 +37,7 @@ public class IpRegulation {
private String currentVersion;
/** 当前正文(最新草稿或已发布版)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String owner;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -29,7 +29,7 @@ public class IpRegulationVersion {
private String action;
@Lob
@Column(columnDefinition = "TEXT")
private String contentSnapshot;
/** 修订说明 / 审批意见。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -77,7 +76,6 @@ public class IpSciCredArchive {
private String validUntil;
/** 文件备注/摘要。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -34,7 +34,7 @@ public class ItKnowledgeArticle {
private String keywords;
/** 解决方案正文(步骤)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
/** 作者 / 维护人。 */
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -28,7 +28,7 @@ public class LegalConsult {
private String subject;
/** 问题描述详情。 */
@Lob
@Column(columnDefinition = "TEXT")
private String detail;
/** 申请部门。 */
@@ -44,7 +44,7 @@ public class LegalConsult {
private String assignee;
/** 法律意见(回复内容)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String legalOpinion;
/** 状态:待受理 / 处理中 / 已答复 / 已关闭。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -51,7 +50,6 @@ public class Meeting {
private String description;
/** 会议附件名列表的 JSON["文件名1","文件名2"],仅记录文件名,不存二进制)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String attachmentJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -29,7 +28,6 @@ public class MeetingMinute {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class MeetingMinute {
private String status;
/** 决议项, TEXT/JSON 串. */
@Lob
@Column(columnDefinition = "TEXT")
private String decisions;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -34,7 +33,6 @@ public class Message {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String body;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -84,7 +83,7 @@ public class MfgEnvSupplierProfile {
* 历史供货业绩(JSON 数组):[{projectName, deliveryYear, qty, qualifiedRate, customer}...]。
* Lob 存储,前端渲染为折叠卡片。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String performanceJson;
/** 历史供货总次数(冗余,便于快速汇总)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -31,7 +31,7 @@ public class NetScanLog {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String link;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 通用键值配置:settingKey 唯一,valueJson 存任意 JSON 字符串。用于工时设置/信息项设置等页面级配置。 */
@@ -20,7 +19,6 @@ public class OaSetting {
@Column(unique = true, nullable = false)
private String settingKey;
@Lob
@Column(columnDefinition = "TEXT")
private String valueJson;
@@ -39,7 +39,7 @@ public class PmtFinancingContract {
private BigDecimal contractAmount = BigDecimal.ZERO;
/** 合同利率(年化,如 4.35)。 */
private Double contractRate;
private BigDecimal contractRate;
/** 合同签署日期(YYYY-MM-DD)。 */
private String signDate;
@@ -94,8 +94,8 @@ public class PmtFinancingContract {
public BigDecimal getContractAmount() { return contractAmount; }
public void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; }
public Double getContractRate() { return contractRate; }
public void setContractRate(Double contractRate) { this.contractRate = contractRate; }
public BigDecimal getContractRate() { return contractRate; }
public void setContractRate(BigDecimal contractRate) { this.contractRate = contractRate; }
public String getSignDate() { return signDate; }
public void setSignDate(String signDate) { this.signDate = signDate; }
@@ -39,7 +39,7 @@ public class PmtInternalTrans {
private BigDecimal amount = BigDecimal.ZERO;
/** 利率(内部计息时使用,年化,如 3.5 表示 3.5%)。 */
private Double interestRate;
private BigDecimal interestRate;
/** 计息起始日。 */
private String interestFrom;
@@ -104,8 +104,8 @@ public class PmtInternalTrans {
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Double getInterestRate() { return interestRate; }
public void setInterestRate(Double interestRate) { this.interestRate = interestRate; }
public BigDecimal getInterestRate() { return interestRate; }
public void setInterestRate(BigDecimal interestRate) { this.interestRate = interestRate; }
public String getInterestFrom() { return interestFrom; }
public void setInterestFrom(String interestFrom) { this.interestFrom = interestFrom; }
@@ -41,7 +41,7 @@ public class PmtLoanScheme {
private BigDecimal amount = BigDecimal.ZERO;
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
private Double annualRate;
private BigDecimal annualRate;
/** 期限(月)。 */
private Integer termMonths;
@@ -56,7 +56,7 @@ public class PmtLoanScheme {
private String guaranteeType;
/** 担保费率(年化,百分比)。 */
private Double guaranteeRate;
private BigDecimal guaranteeRate;
/** 提款条件(文字描述)。 */
private String drawdownConditions;
@@ -65,7 +65,7 @@ public class PmtLoanScheme {
* 系统自动计算的综合成本率(利息+费用+担保成本之和/本金,IRR 简化口径,百分比)。
* 创建/更新时服务端根据 rate+费用+担保自动回填。
*/
private Double effectiveCostRate;
private BigDecimal effectiveCostRate;
/** 综合排名(越小越优,由 /rank 端点服务端按综合成本排序后写入)。 */
private Integer rank;
@@ -104,8 +104,8 @@ public class PmtLoanScheme {
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Double getAnnualRate() { return annualRate; }
public void setAnnualRate(Double annualRate) { this.annualRate = annualRate; }
public BigDecimal getAnnualRate() { return annualRate; }
public void setAnnualRate(BigDecimal annualRate) { this.annualRate = annualRate; }
public Integer getTermMonths() { return termMonths; }
public void setTermMonths(Integer termMonths) { this.termMonths = termMonths; }
@@ -119,14 +119,14 @@ public class PmtLoanScheme {
public String getGuaranteeType() { return guaranteeType; }
public void setGuaranteeType(String guaranteeType) { this.guaranteeType = guaranteeType; }
public Double getGuaranteeRate() { return guaranteeRate; }
public void setGuaranteeRate(Double guaranteeRate) { this.guaranteeRate = guaranteeRate; }
public BigDecimal getGuaranteeRate() { return guaranteeRate; }
public void setGuaranteeRate(BigDecimal guaranteeRate) { this.guaranteeRate = guaranteeRate; }
public String getDrawdownConditions() { return drawdownConditions; }
public void setDrawdownConditions(String drawdownConditions) { this.drawdownConditions = drawdownConditions; }
public Double getEffectiveCostRate() { return effectiveCostRate; }
public void setEffectiveCostRate(Double effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
public BigDecimal getEffectiveCostRate() { return effectiveCostRate; }
public void setEffectiveCostRate(BigDecimal effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
public Integer getRank() { return rank; }
public void setRank(Integer rank) { this.rank = rank; }
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -38,7 +38,7 @@ public class StdApplication {
private String planNo;
@Lob
@Column(columnDefinition = "TEXT")
private String techContent;
private String drafters;
@@ -5,8 +5,9 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
@@ -34,12 +35,9 @@ public class StoredFile {
/** Size in bytes. */
private Long size;
/**
* Raw bytes as a plain byte[] (NOT @Lob): SQLite's JDBC driver does not implement
* the streamed-Blob read path that @Lob triggers ("not implemented by SQLite JDBC
* driver"); a plain byte[] is read directly via getBytes() and works.
*/
@Column(columnDefinition = "BLOB")
/** Raw bytes mapped to SQLite BLOB and PostgreSQL bytea without JDBC Blob streaming. */
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
@Column(length = Integer.MAX_VALUE)
private byte[] data;
/** Display name of the uploader (resolved from the auth token). */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/**
@@ -29,12 +28,10 @@ public class Survey {
private String status;
@Lob
@Column(name = "options_json", columnDefinition = "TEXT")
private String optionsJson;
// --> JSON array of voter usernames who already voted; used to reject duplicate votes.
@Lob
@Column(name = "voters_json", columnDefinition = "TEXT")
private String votersJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@@ -57,7 +56,6 @@ public class SvIndepClaim {
private String incidentEndDate;
/** 事件经过详细说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String incidentDetail;
@@ -73,7 +71,6 @@ public class SvIndepClaim {
private Integer criticalPathImpactDays;
/** 关键线路影响分析说明(如"该延误不在关键路径,不影响完工日期")。 */
@Lob
@Column(columnDefinition = "TEXT")
private String criticalPathImpact;
@@ -82,7 +79,6 @@ public class SvIndepClaim {
private BigDecimal claimAmount = BigDecimal.ZERO;
/** 费用影响评估说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String costImpactDetail;
@@ -102,7 +98,6 @@ public class SvIndepClaim {
private BigDecimal approvedAmount = BigDecimal.ZERO;
/** 监理意见详细说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String reviewOpinion;
@@ -22,6 +22,12 @@ public class SysRole {
@Column(nullable = false, unique = true)
private String code;
/** 角色说明(角色管理 UI 用)。 */
private String description;
/** 系统内置角色(ADMIN/APPROVER/USER)不可删;自定义部门角色为 false。 */
private boolean system = false;
public Long getId() {
return id;
}
@@ -45,4 +51,20 @@ public class SysRole {
public void setCode(String code) {
this.code = code;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isSystem() {
return system;
}
public void setSystem(boolean system) {
this.system = system;
}
}
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -47,7 +47,7 @@ public class TechAchievement {
private Long ipAssetId;
/** 佐证材料清单(论文/标准/软著/检测报告…)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String evidence;
/** 成果评价等级。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -58,11 +58,11 @@ public class TechContract {
private String ipAssetName;
/** 自动生成的创新技术方案(材料)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String techScheme;
/** 自动生成的承诺书(材料)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commitmentLetter;
private String owner;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -62,7 +62,7 @@ public class WmDeclTemplate {
private String placeholders;
/** 模板正文(含占位符的完整模板文本)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
/** 更新说明(本版变更内容摘要,更新时通知相关人员)。 */
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -46,7 +46,7 @@ public class WmDocVersion {
private String summary;
/** 文档正文(内联富文本)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
/** 外部附件 URL / 文件路径(可空;与通用文件模块结合时存储路径)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -64,7 +64,7 @@ public class WmPromotion {
private Integer attendeeCount;
/** 效果评估说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String effectNote;
/** 推广材料(作业指导书/PPT/视频等,逗号分隔文件路径或文件 id)。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -70,41 +70,41 @@ public class WorkMethod {
private String stage;
// ---- 立项信息(需求功能1·工法立项) ----
@Lob
@Column(columnDefinition = "TEXT")
private String techBackground;
@Lob
@Column(columnDefinition = "TEXT")
private String innovation;
@Lob
@Column(columnDefinition = "TEXT")
private String applicableScope;
@Lob
@Column(columnDefinition = "TEXT")
private String expectedBenefit;
// ---- 工法文本九大要素(需求功能1·工法编制) ----
@Lob
@Column(columnDefinition = "TEXT")
private String contentFeature;
@Lob
@Column(columnDefinition = "TEXT")
private String contentPrinciple;
@Lob
@Column(columnDefinition = "TEXT")
private String contentProcess;
@Lob
@Column(columnDefinition = "TEXT")
private String contentMaterial;
@Lob
@Column(columnDefinition = "TEXT")
private String contentQuality;
@Lob
@Column(columnDefinition = "TEXT")
private String contentSafety;
@Lob
@Column(columnDefinition = "TEXT")
private String contentEnv;
@Lob
@Column(columnDefinition = "TEXT")
private String contentBenefit;
// ---- 证书与有效期(需求功能1·工法证书) ----
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -53,13 +53,13 @@ public class WorkMethodApplication {
* 申报材料清单:逗号分隔的 "材料名:0/1"0=缺,1=齐),如
* "申报书:1,工法文本:1,查新报告:0,应用证明:1,经济效益证明:0"。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String materials;
/** 批准文号(批准后回填)。 */
private String approveDocNo;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -36,7 +36,7 @@ public class WorkMethodEvent {
private String toValue;
@Lob
@Column(columnDefinition = "TEXT")
private String note;
private String operator;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -44,7 +44,7 @@ public class WorkMethodReview {
/** 评分(0-100,可空)。 */
private Integer score;
@Lob
@Column(columnDefinition = "TEXT")
private String opinion;
private String reviewedDate;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -44,7 +44,7 @@ public class WorkMethodReward {
* 完成人贡献分配明细:逗号分隔的 "姓名:比例%:金额",如
* "张三:50:5000.00,李四:30:3000.00,王五:20:2000.00"。比例之和应为 100。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String allocation;
/** 奖励状态:待审批 / 已审批 / 已发放 / 已驳回。 */
@@ -61,7 +61,7 @@ public class WorkMethodReward {
/** 审批后生成的资金支付中心付款单 id(联动财务)。 */
private Long paymentId;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -49,7 +49,7 @@ public class WorkMethodUsage {
private Integer shortenDays = 0;
/** 应用效果描述(质量提升/用户评价)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String effect;
/** 登记人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** A work plan. period is e.g. 本周 / 本月 / 本季; status is 草稿 / 执行中 / 已完成. */
@@ -28,7 +27,6 @@ public class WorkPlan {
private String status;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -131,7 +131,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f1.setCreditLimit(Money.of(50000000.0));
f1.setAmount(Money.of(50000000.0));
f1.setCurrency("人民币");
f1.setRate(3.85);
f1.setRate(BigDecimal.valueOf(3.85));
f1.setStartDate("2024-01-15");
f1.setEndDate("2026-01-14");
f1.setTermMonths(24);
@@ -153,7 +153,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f2.setCreditLimit(Money.of(100000000.0));
f2.setAmount(Money.of(80000000.0));
f2.setCurrency("人民币");
f2.setRate(3.20);
f2.setRate(BigDecimal.valueOf(3.20));
f2.setStartDate("2024-03-01");
f2.setEndDate("2029-02-28");
f2.setTermMonths(60);
@@ -173,7 +173,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f3.setCreditLimit(Money.of(20000000.0));
f3.setAmount(Money.of(18000000.0));
f3.setCurrency("人民币");
f3.setRate(4.35);
f3.setRate(BigDecimal.valueOf(4.35));
f3.setStartDate("2024-06-01");
f3.setEndDate("2025-05-31");
f3.setTermMonths(12);
@@ -193,7 +193,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f4.setCreditLimit(Money.of(30000000.0));
f4.setAmount(Money.of(30000000.0));
f4.setCurrency("人民币");
f4.setRate(4.10);
f4.setRate(BigDecimal.valueOf(4.10));
f4.setStartDate("2025-02-01");
f4.setEndDate("2026-01-31");
f4.setTermMonths(12);
@@ -214,7 +214,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f5.setCreditLimit(Money.of(20000000.0));
f5.setAmount(Money.of(20000000.0));
f5.setCurrency("人民币");
f5.setRate(2.50);
f5.setRate(BigDecimal.valueOf(2.50));
f5.setStartDate("2025-07-01");
f5.setEndDate("2026-06-30");
f5.setTermMonths(12);
@@ -234,7 +234,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f6.setCreditLimit(Money.of(10000000.0));
f6.setAmount(Money.of(10000000.0));
f6.setCurrency("人民币");
f6.setRate(4.60);
f6.setRate(BigDecimal.valueOf(4.60));
f6.setStartDate("2023-01-01");
f6.setEndDate("2024-12-31");
f6.setTermMonths(24);
@@ -0,0 +1,470 @@
package com.kaidi.oa.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Checks Gitea releases and delegates installation to the external update helper. */
@Service
public class SystemUpdateService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class);
private static final Pattern VERSION_PATTERN = Pattern.compile(
"^[vV]?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?"
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024;
private final UpdateProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String currentVersion;
private final AtomicReference<UpdateStatus> state;
private final AtomicBoolean installRunning = new AtomicBoolean(false);
@Autowired
public SystemUpdateService(UpdateProperties properties,
ObjectMapper objectMapper,
ObjectProvider<BuildProperties> buildProperties) {
this(properties, objectMapper, buildProperties,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.followRedirects(HttpClient.Redirect.NORMAL)
.build());
}
SystemUpdateService(UpdateProperties properties,
ObjectMapper objectMapper,
ObjectProvider<BuildProperties> buildProperties,
HttpClient httpClient) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
BuildProperties build = buildProperties.getIfAvailable();
this.currentVersion = build == null || build.getVersion() == null ? "dev" : build.getVersion();
this.state = new AtomicReference<>(UpdateStatus.idle(properties.isEnabled(), currentVersion));
}
public UpdateStatus status() {
UpdateStatus persisted = readHelperState();
return persisted == null ? state.get() : persisted;
}
public synchronized UpdateStatus check() {
requireConfigured();
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"));
try {
ReleaseInfo release = fetchLatestRelease();
boolean available = compareVersions(release.version(), currentVersion) > 0;
UpdateStatus checked = new UpdateStatus(
true,
currentVersion,
release.version(),
available,
available ? UpdatePhase.AVAILABLE : UpdatePhase.UP_TO_DATE,
100,
available ? "发现新版本" : "当前已是最新版本",
Instant.now(),
release.publishedAt(),
release.notes(),
release.assets(),
null
);
state.set(checked);
return checked;
} catch (RuntimeException e) {
state.set(state.get().failed(safeMessage(e)));
throw e;
}
}
public UpdateStatus install(String requestedVersion) {
requireConfigured();
if (!isVersion(requestedVersion)) {
throw new ApiException(400, "版本号格式不正确");
}
UpdateStatus checked = state.get();
if (checked.latestVersion() == null || !sameVersion(checked.latestVersion(), requestedVersion)) {
checked = check();
}
if (!sameVersion(checked.latestVersion(), requestedVersion)) {
throw new ApiException(409, "目标版本已变化,请重新检查更新");
}
if (!checked.updateAvailable()) {
throw new ApiException(409, "当前版本不需要更新");
}
if (!installRunning.compareAndSet(false, true)) {
throw new ApiException(409, "已有更新任务正在执行");
}
Path helper = Path.of(properties.getHelperCommand()).toAbsolutePath().normalize();
if (!Files.isRegularFile(helper) || !Files.isExecutable(helper)) {
installRunning.set(false);
throw new ApiException(503, "更新助手不可用,请检查安装目录");
}
try {
Path statePath = statePath();
Path parent = statePath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path logFile = parent == null
? Path.of("update-helper.log").toAbsolutePath()
: parent.resolve("update-helper.log");
ProcessBuilder processBuilder = new ProcessBuilder(
helper.toString(), "install", normalizeVersion(requestedVersion));
processBuilder.environment().put("ERP_APP_PID", String.valueOf(ProcessHandle.current().pid()));
processBuilder.redirectErrorStream(true);
processBuilder.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile()));
Process process = processBuilder.start();
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
state.set(starting);
process.onExit().thenAccept(completed -> {
installRunning.set(false);
if (completed.exitValue() != 0) {
state.updateAndGet(value -> value.failed("更新助手执行失败,退出码 " + completed.exitValue()));
}
});
return starting;
} catch (IOException e) {
installRunning.set(false);
log.error("Unable to start update helper", e);
throw new ApiException(500, "无法启动更新助手");
}
}
private ReleaseInfo fetchLatestRelease() {
String[] repository = properties.getRepository().split("/", 2);
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
throw new ApiException(500, "更新仓库配置无效");
}
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases/latest");
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.header("Accept", "application/json")
.GET();
if (properties.getToken() != null && !properties.getToken().isBlank()) {
builder.header("Authorization", "token " + properties.getToken().trim());
}
try {
HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 404) {
throw new ApiException(404, "Gitea 尚未发布 Release");
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
}
return parseRelease(objectMapper.readTree(response.body()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(503, "检查更新被中断");
} catch (IOException | IllegalArgumentException e) {
log.warn("Unable to check Gitea release: {}", e.getMessage());
throw new ApiException(503, "无法连接更新服务器");
}
}
private ReleaseInfo parseRelease(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag)) {
throw new ApiException(502, "Release 版本号格式无效");
}
if (root.path("draft").asBoolean(false)) {
throw new ApiException(502, "最新 Release 仍是草稿");
}
Matcher versionMatcher = VERSION_PATTERN.matcher(tag);
versionMatcher.matches();
boolean taggedPrerelease = versionMatcher.group(4) != null;
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
&& "stable".equalsIgnoreCase(properties.getChannel())) {
throw new ApiException(502, "稳定频道拒绝预发布版本");
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode node : root.path("assets")) {
assets.add(new ReleaseAsset(
text(node, "name"),
text(node, "browser_download_url"),
node.path("size").asLong(0)
));
}
String version = normalizeVersion(tag);
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
requireAsset(assets, "SHA256SUMS");
requireAsset(assets, "SHA256SUMS.sig");
Instant publishedAt = null;
String published = text(root, "published_at");
if (!published.isBlank()) {
try {
publishedAt = Instant.parse(published);
} catch (RuntimeException ignore) {
// An invalid optional timestamp must not hide an otherwise valid release.
}
}
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
}
private void requireConfigured() {
if (!properties.isEnabled()) {
throw new ApiException(503, "在线更新尚未启用");
}
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
throw new ApiException(503, "尚未配置 Gitea 地址");
}
URI uri;
try {
uri = URI.create(properties.getGiteaBaseUrl().trim());
} catch (IllegalArgumentException e) {
throw new ApiException(500, "Gitea 地址格式无效");
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
throw new ApiException(503, "更新服务器必须使用 HTTPS");
}
}
private UpdateStatus readHelperState() {
Path path = statePath();
try {
if (!Files.isRegularFile(path) || Files.size(path) > MAX_STATE_BYTES) {
return null;
}
JsonNode root = objectMapper.readTree(path.toFile());
String phaseText = text(root, "phase");
if (phaseText.isBlank()) {
return null;
}
UpdatePhase phase = UpdatePhase.valueOf(phaseText.toUpperCase(Locale.ROOT));
UpdateStatus memory = state.get();
String helperVersion = blankToNull(text(root, "version"));
boolean updateAvailable = helperVersion != null
&& phase != UpdatePhase.SUCCEEDED
&& compareVersions(helperVersion, currentVersion) > 0;
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
return new UpdateStatus(
properties.isEnabled(),
currentVersion,
helperVersion,
updateAvailable,
phase,
Math.max(0, Math.min(100, root.path("progress").asInt(0))),
text(root, "message"),
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
memory.publishedAt(),
memory.releaseNotes(),
memory.assets(),
blankToNull(text(root, "error"))
);
} catch (Exception e) {
log.debug("Ignoring unreadable update state {}: {}", path, e.getMessage());
return null;
}
}
private Path statePath() {
String value = properties.getStateFile();
return Path.of(value == null || value.isBlank() ? "./runtime/update-state.json" : value)
.toAbsolutePath().normalize();
}
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isTextual() ? value.asText().trim() : "";
}
private static String encode(String pathSegment) {
return URLEncoder.encode(pathSegment, StandardCharsets.UTF_8).replace("+", "%20");
}
private static void requireAsset(List<ReleaseAsset> assets, String name) {
if (assets.stream().noneMatch(asset -> name.equals(asset.name()))) {
throw new ApiException(502, "Release 缺少文件 " + name);
}
}
private static boolean isVersion(String version) {
return version != null && VERSION_PATTERN.matcher(version.trim()).matches();
}
private static boolean sameVersion(String left, String right) {
return normalizeVersion(left).equals(normalizeVersion(right));
}
static int compareVersions(String left, String right) {
Matcher a = VERSION_PATTERN.matcher(Objects.requireNonNullElse(left, "").trim());
Matcher b = VERSION_PATTERN.matcher(Objects.requireNonNullElse(right, "").trim());
boolean aMatches = a.matches();
boolean bMatches = b.matches();
if (aMatches && !bMatches) {
return 1;
}
if (!aMatches && bMatches) {
return -1;
}
if (!aMatches) {
return normalizeVersion(left).compareToIgnoreCase(normalizeVersion(right));
}
for (int i = 1; i <= 3; i++) {
int compared = number(a.group(i)).compareTo(number(b.group(i)));
if (compared != 0) {
return compared;
}
}
String aPre = a.group(4);
String bPre = b.group(4);
if (aPre == null && bPre != null) {
return 1;
}
if (aPre != null && bPre == null) {
return -1;
}
if (aPre == null) {
return 0;
}
return comparePrerelease(aPre, bPre);
}
private static int comparePrerelease(String left, String right) {
String[] a = left.split("\\.");
String[] b = right.split("\\.");
for (int i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i].equals(b[i])) {
continue;
}
boolean aNumeric = isNumericIdentifier(a[i]);
boolean bNumeric = isNumericIdentifier(b[i]);
if (aNumeric && bNumeric) {
int compared = new BigInteger(a[i]).compareTo(new BigInteger(b[i]));
if (compared != 0) {
return compared;
}
continue;
}
if (aNumeric != bNumeric) {
return aNumeric ? -1 : 1;
}
int compared = a[i].compareTo(b[i]);
if (compared != 0) {
return compared;
}
}
return Integer.compare(a.length, b.length);
}
private static boolean isNumericIdentifier(String value) {
return value.chars().allMatch(Character::isDigit);
}
private static BigInteger number(String value) {
return value == null || value.isBlank() ? BigInteger.ZERO : new BigInteger(value);
}
private static String normalizeVersion(String version) {
String value = Objects.requireNonNullElse(version, "").trim();
return value.startsWith("v") || value.startsWith("V") ? value.substring(1) : value;
}
private static String safeMessage(RuntimeException error) {
return error instanceof ApiException ? error.getMessage() : "检查更新失败";
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Instant.parse(value);
} catch (RuntimeException ignore) {
return null;
}
}
private record ReleaseInfo(String version, String notes, Instant publishedAt, List<ReleaseAsset> assets) {
}
public record ReleaseAsset(String name, String downloadUrl, long size) {
}
public enum UpdatePhase {
IDLE,
CHECKING,
AVAILABLE,
UP_TO_DATE,
STARTING,
DOWNLOADING,
VERIFYING,
INSTALLING,
RESTARTING,
SUCCEEDED,
ROLLING_BACK,
ROLLED_BACK,
FAILED
}
public record UpdateStatus(
boolean configured,
String currentVersion,
String latestVersion,
boolean updateAvailable,
UpdatePhase phase,
int progress,
String message,
Instant checkedAt,
Instant publishedAt,
String releaseNotes,
List<ReleaseAsset> assets,
String error
) {
static UpdateStatus idle(boolean configured, String currentVersion) {
return new UpdateStatus(configured, currentVersion, null, false, UpdatePhase.IDLE, 0,
configured ? "等待检查更新" : "在线更新尚未启用", null, null, "", List.of(), null);
}
UpdateStatus withPhase(UpdatePhase next, int nextProgress, String nextMessage) {
return new UpdateStatus(configured, currentVersion, latestVersion, updateAvailable, next,
nextProgress, nextMessage, checkedAt, publishedAt, releaseNotes, assets, null);
}
UpdateStatus failed(String reason) {
return new UpdateStatus(configured, currentVersion, latestVersion, updateAvailable,
UpdatePhase.FAILED, progress, "更新失败", checkedAt, publishedAt, releaseNotes, assets, reason);
}
}
}
@@ -113,10 +113,11 @@ public class FinancingController {
public record FinancingRequest(
String code, String lender, String financingType, Double creditLimit, Double amount,
String currency, Double rate, String startDate, String endDate, Integer termMonths,
String currency, BigDecimal rate, String startDate, String endDate, Integer termMonths,
String repayMethod, String status, String companySubject, String purpose, String owner) {
}
@Transactional
@PostMapping
public ApiResp<Financing> create(@RequestBody FinancingRequest req) {
if (req.lender() == null || req.lender().isBlank()) {
@@ -143,6 +144,7 @@ public class FinancingController {
return ApiResp.ok(financingRepo.save(f));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<Financing> update(@PathVariable Long id, @RequestBody FinancingRequest req) {
Financing f = financingRepo.findById(id)
@@ -165,7 +167,6 @@ public class FinancingController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!financingRepo.existsById(id)) {
throw new NotFoundException("financing not found: " + id);
@@ -188,7 +189,6 @@ public class FinancingController {
* 已有任意期次「已还」时拒绝重算,避免抹掉还款历史。
*/
@PostMapping("/{id}/schedule")
@Transactional
public ApiResp<List<RepaymentPlan>> schedule(@PathVariable Long id) {
Financing f = financingRepo.findById(id)
.orElseThrow(() -> new NotFoundException("financing not found: " + id));
@@ -200,7 +200,7 @@ public class FinancingController {
planRepo.deleteByFinancingId(id);
int term = f.getTermMonths() == null || f.getTermMonths() <= 0 ? 12 : f.getTermMonths();
double annualRate = f.getRate() == null ? 0 : f.getRate();
double annualRate = f.getRate() == null ? 0 : f.getRate().doubleValue();
BigDecimal principalTotal = Money.nz(f.getAmount());
String method = f.getRepayMethod() == null ? "等额本息" : f.getRepayMethod();
LocalDate start = parseDateOrNull(f.getStartDate());
@@ -288,7 +288,6 @@ public class FinancingController {
* 这条联动把"融资还款"打通到结算/支付链(与需求"还款联动结算中心生成付款单"一致)。
*/
@PostMapping("/repayments/{planId}/pay")
@Transactional
public ApiResp<RepaymentPlan> payRepayment(@PathVariable Long planId) {
RepaymentPlan plan = planRepo.findById(planId)
.orElseThrow(() -> new NotFoundException("repayment plan not found: " + planId));
@@ -552,7 +551,7 @@ public class FinancingController {
rows.add(new DebtLedgerRow(f.getId(), f.getCode(), f.getLender(), f.getFinancingType(),
f.getCompanySubject(), f.getStatus(), principal.doubleValue(),
paid.doubleValue(), unpaid.doubleValue(),
f.getRate() == null ? 0 : f.getRate(),
f.getRate() == null ? 0.0 : f.getRate().doubleValue(),
f.getEndDate() == null ? "" : f.getEndDate(),
daysToMaturity == Long.MAX_VALUE ? -999 : daysToMaturity,
maturityLevel));
@@ -561,7 +560,7 @@ public class FinancingController {
totalUnpaid = Money.add(totalUnpaid, unpaid);
if (f.getRate() != null) {
weightedRateSum = Money.add(weightedRateSum,
unpaid.multiply(BigDecimal.valueOf(f.getRate())));
unpaid.multiply(f.getRate()));
}
}
@@ -590,7 +589,6 @@ public class FinancingController {
* 若余量 < 0 则拒绝提交,给出明确提示,要求先调整授信或融资金额。
*/
@PostMapping("/{id}/submit-approval")
@Transactional
public ApiResp<FormInstance> submitApproval(@PathVariable Long id,
@RequestBody(required = false) ApprovalSubmitRequest req) {
Financing f = financingRepo.findById(id)
@@ -791,7 +789,7 @@ public class FinancingController {
rateMap.put("7%以上", new long[]{0, 0, 0});
for (Financing f : all) {
if (List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) continue;
double r = f.getRate() == null ? 0 : f.getRate();
double r = f.getRate() == null ? 0 : f.getRate().doubleValue();
String bucket = r < 3 ? "3%以下" : r < 5 ? "3%-5%" : r < 7 ? "5%-7%" : "7%以上";
long[] slot = rateMap.get(bucket);
slot[0]++;
@@ -807,7 +805,7 @@ public class FinancingController {
// --- 高成本融资识别(年化利率 > benchmarkRate ---
List<Map<String, Object>> highCost = all.stream()
.filter(f -> !List.of("已结清", "已驳回").contains(nvl(f.getStatus())))
.filter(f -> f.getRate() != null && f.getRate() > benchmarkRate)
.filter(f -> f.getRate() != null && f.getRate().doubleValue() > benchmarkRate)
.map(f -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("financingId", f.getId());
@@ -816,8 +814,9 @@ public class FinancingController {
m.put("financingType", f.getFinancingType());
m.put("amount", Money.nz(f.getAmount()).doubleValue());
m.put("rate", f.getRate());
m.put("excessBps", Math.round((f.getRate() - benchmarkRate) * 100));
m.put("suggestion", f.getRate() - benchmarkRate > 2 ? "建议择机置换或提前还款" : "关注,可在续授信时争取降利率");
double rateVal = f.getRate().doubleValue();
m.put("excessBps", Math.round((rateVal - benchmarkRate) * 100));
m.put("suggestion", rateVal - benchmarkRate > 2 ? "建议择机置换或提前还款" : "关注,可在续授信时争取降利率");
return m;
})
.toList();
@@ -928,7 +927,6 @@ public class FinancingController {
}
@PostMapping("/{id}/legal-reviews/link")
@Transactional
public ApiResp<LegalRiskEvent> linkLegalReview(@PathVariable Long id,
@RequestBody LinkLegalReviewRequest req) {
if (!financingRepo.existsById(id)) {
@@ -957,7 +955,6 @@ public class FinancingController {
}
@PostMapping("/{id}/push-cost-alloc")
@Transactional
public ApiResp<PmtCostAllocRule> pushCostAlloc(@PathVariable Long id,
@RequestBody(required = false) PushCostAllocRequest req) {
Financing f = financingRepo.findById(id)
@@ -982,7 +979,7 @@ public class FinancingController {
.reduce(BigDecimal.ZERO, Money::add);
// 若无还款计划,按月息估算(本金 × 年化利率 / 12)
if (periodInterest.signum() == 0) {
double annualRate = f.getRate() == null ? 0.0 : f.getRate();
double annualRate = f.getRate() == null ? 0.0 : f.getRate().doubleValue();
periodInterest = Money.of(Money.nz(f.getAmount()).doubleValue() * annualRate / 100.0 / 12.0);
}
@@ -77,6 +77,7 @@ public class FinancingPolicyController {
String description, String status, String createdBy) {
}
@Transactional
@PostMapping
public ApiResp<FinancingPolicy> create(@RequestBody PolicyRequest req) {
if (req.ruleName() == null || req.ruleName().isBlank()) {
@@ -99,6 +100,7 @@ public class FinancingPolicyController {
return ApiResp.ok(policyRepo.save(p));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<FinancingPolicy> update(@PathVariable Long id, @RequestBody PolicyRequest req) {
FinancingPolicy p = policyRepo.findById(id)
@@ -115,7 +117,6 @@ public class FinancingPolicyController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!policyRepo.existsById(id)) {
throw new NotFoundException("融资政策规则不存在: " + id);
@@ -148,6 +149,7 @@ public class FinancingPolicyController {
* - 担保方式限制:预留(financing 暂无担保字段,返回通过)。
* hardBlock=true 规则违规时,整体 overallPassed=false,调用方可据此阻断提交。
*/
@Transactional
@PostMapping("/{financingId}/compliance-check")
public ApiResp<ComplianceCheckResult> complianceCheck(@PathVariable Long financingId) {
Financing f = financingRepo.findById(financingId)
@@ -176,7 +178,7 @@ public class FinancingPolicyController {
}
}
case "利率上限" -> {
double rate = f.getRate() == null ? 0 : f.getRate();
double rate = f.getRate() == null ? 0 : f.getRate().doubleValue();
double limit = rule.getLimitValue() == null ? 0 : rule.getLimitValue().doubleValue();
if (limit > 0 && rate > limit) {
passed = false;
@@ -114,7 +114,7 @@ public class PmtContractFeeItemController {
LocalDate maturity = LocalDate.parse(contract.getMaturityDate());
long days = java.time.temporal.ChronoUnit.DAYS.between(sign, maturity);
if (days > 0) {
BigDecimal rate = BigDecimal.valueOf(contract.getContractRate()).divide(
BigDecimal rate = contract.getContractRate().divide(
BigDecimal.valueOf(100), 10, java.math.RoundingMode.HALF_UP);
BigDecimal years = BigDecimal.valueOf(days).divide(
BigDecimal.valueOf(365), 10, java.math.RoundingMode.HALF_UP);
@@ -164,7 +164,6 @@ public class PmtContractFeeItemController {
}
@PostMapping
@Transactional
public ApiResp<PmtContractFeeItem> create(@RequestBody FeeItemRequest req) {
if (req.contractId() == null) {
throw new ApiException(400, "contractId 不能为空");
@@ -197,7 +196,6 @@ public class PmtContractFeeItemController {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<PmtContractFeeItem> update(@PathVariable Long id,
@RequestBody FeeItemRequest req) {
PmtContractFeeItem item = feeRepo.findById(id)
@@ -221,7 +219,6 @@ public class PmtContractFeeItemController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtContractFeeItem item = feeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("费用明细不存在: " + id));
@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -73,11 +74,12 @@ public class PmtFinancingContractController {
public record ContractRequest(
Long financingId, String contractNo, String lender,
Double contractAmount, Double contractRate, String signDate, String maturityDate,
Double contractAmount, BigDecimal contractRate, String signDate, String maturityDate,
String repayMethod, String guaranteeType, String guaranteeDesc,
Double contractFee, String status, String remark, String owner) {
}
@Transactional
@PostMapping
public ApiResp<PmtFinancingContract> create(@RequestBody ContractRequest req) {
if (req.lender() == null || req.lender().isBlank()) {
@@ -115,6 +117,7 @@ public class PmtFinancingContractController {
return ApiResp.ok(contractRepo.save(c));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<PmtFinancingContract> update(@PathVariable Long id, @RequestBody ContractRequest req) {
PmtFinancingContract c = contractRepo.findById(id)
@@ -138,7 +141,6 @@ public class PmtFinancingContractController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -153,7 +155,6 @@ public class PmtFinancingContractController {
/** 签署:草稿 → 已签署。 */
@PostMapping("/{id}/sign")
@Transactional
public ApiResp<PmtFinancingContract> sign(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -175,7 +176,6 @@ public class PmtFinancingContractController {
* - drawnAmount >= contractAmount → 已用款
*/
@PostMapping("/{id}/drawdown")
@Transactional
public ApiResp<PmtFinancingContract> drawdown(@PathVariable Long id, @RequestBody DrawdownRequest req) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -219,7 +219,6 @@ public class PmtFinancingContractController {
/** 结清:将合同状态置「已结清」(通常在所有还款完成后调用)。 */
@PostMapping("/{id}/settle")
@Transactional
public ApiResp<PmtFinancingContract> settle(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -232,7 +231,6 @@ public class PmtFinancingContractController {
/** 终止合同。 */
@PostMapping("/{id}/terminate")
@Transactional
public ApiResp<PmtFinancingContract> terminate(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -82,7 +82,7 @@ public class PmtInternalTransController {
public record TransRequest(
String transType, String payerSubject, String receiverSubject,
Double amount, String bizDate, String reconPeriod, String summary,
Double interestRate, String interestFrom, String interestTo,
BigDecimal interestRate, String interestFrom, String interestTo,
String createdBy) {
}
@@ -91,7 +91,6 @@ public class PmtInternalTransController {
* 同时自动为付款方生成应付单(ArApItem T_AP)为收款方生成应收单(ArApItem T_AR)
*/
@PostMapping
@Transactional
public ApiResp<PmtInternalTrans> create(@RequestBody TransRequest req) {
if (req.payerSubject() == null || req.payerSubject().isBlank()) {
throw new ApiException(400, "付款方主体(payerSubject) 不能为空");
@@ -123,7 +122,7 @@ public class PmtInternalTransController {
// 利息自动计算 transType=利息 且提供了利率和区间
if ("利息".equals(t.getTransType()) && req.interestRate() != null
&& req.interestFrom() != null && req.interestTo() != null) {
BigDecimal interest = calcInterest(Money.of(req.amount()), req.interestRate(),
BigDecimal interest = calcInterest(Money.of(req.amount()), req.interestRate().doubleValue(),
req.interestFrom(), req.interestTo());
t.setInterestAmount(interest);
t.setAmount(interest); // 利息单金额即利息
@@ -166,6 +165,7 @@ public class PmtInternalTransController {
return ApiResp.ok(transRepo.save(saved));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<PmtInternalTrans> update(@PathVariable Long id, @RequestBody TransRequest req) {
PmtInternalTrans t = transRepo.findById(id)
@@ -180,7 +180,6 @@ public class PmtInternalTransController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -202,7 +201,6 @@ public class PmtInternalTransController {
* 有差异状态需登记差异调整后方可结清
*/
@PostMapping("/{id}/confirm")
@Transactional
public ApiResp<PmtInternalTrans> confirm(@PathVariable Long id, @RequestBody ConfirmRequest req) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -235,7 +233,6 @@ public class PmtInternalTransController {
/** 标记差异已调整,进入可结清状态。 */
@PostMapping("/{id}/resolve-diff")
@Transactional
public ApiResp<PmtInternalTrans> resolveDiff(@PathVariable Long id,
@RequestBody Map<String, String> body) {
PmtInternalTrans t = transRepo.findById(id)
@@ -251,7 +248,6 @@ public class PmtInternalTransController {
/** 手动结清内部往来单(在「已确认」或「差异已调整」状态下可执行)。 */
@PostMapping("/{id}/settle")
@Transactional
public ApiResp<PmtInternalTrans> settle(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -290,7 +286,7 @@ public class PmtInternalTransController {
// ---------- 利息计算内部计息按日 ----------
public record InterestCalcRequest(Double principal, Double annualRate, String from, String to) {
public record InterestCalcRequest(Double principal, BigDecimal annualRate, String from, String to) {
}
public record InterestCalcResult(double principal, double annualRate, String from, String to,
@@ -301,6 +297,7 @@ public class PmtInternalTransController {
* 内部计息预览不落库金额 × 年化利率 / 365 × 天数
* 支持活期上存利率与贷款利率利率单位 % 3.5 表示 3.5%
*/
@Transactional
@PostMapping("/calc-interest")
public ApiResp<InterestCalcResult> calcInterestPreview(@RequestBody InterestCalcRequest req) {
if (req.principal() == null || req.annualRate() == null
@@ -312,9 +309,10 @@ public class PmtInternalTransController {
if (days <= 0) {
throw new ApiException(400, "计息截止日必须晚于起始日");
}
BigDecimal interest = calcInterest(principal, req.annualRate(), req.from(), req.to());
double annualRateDouble = req.annualRate().doubleValue();
BigDecimal interest = calcInterest(principal, annualRateDouble, req.from(), req.to());
return ApiResp.ok(new InterestCalcResult(
principal.doubleValue(), req.annualRate(), req.from(), req.to(),
principal.doubleValue(), annualRateDouble, req.from(), req.to(),
days, interest.doubleValue()));
}
@@ -326,7 +324,6 @@ public class PmtInternalTransController {
* 凭证生成后状态流转为已结清已转凭证即代表本期利息已处理完毕
*/
@PostMapping("/{id}/to-voucher")
@Transactional
public ApiResp<Voucher> toVoucher(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -73,18 +73,17 @@ public class PmtLoanSchemeController {
public record SchemeRequest(
Long financingId, String institution, String loanType,
Double amount, Double annualRate, Integer termMonths, String repayMethod,
Double handlingFee, String guaranteeType, Double guaranteeRate,
Double amount, BigDecimal annualRate, Integer termMonths, String repayMethod,
Double handlingFee, String guaranteeType, BigDecimal guaranteeRate,
String drawdownConditions, String status, String remark, String owner) {
}
@PostMapping
@Transactional
public ApiResp<PmtLoanScheme> create(@RequestBody SchemeRequest req) {
if (req.institution() == null || req.institution().isBlank()) {
throw new ApiException(400, "报价机构名称(institution) 不能为空");
}
if (req.annualRate() == null || req.annualRate() < 0) {
if (req.annualRate() == null || req.annualRate().compareTo(BigDecimal.ZERO) < 0) {
throw new ApiException(400, "年化利率(annualRate) 不能为空且须 >= 0");
}
PmtLoanScheme s = new PmtLoanScheme();
@@ -96,7 +95,7 @@ public class PmtLoanSchemeController {
s.setRepayMethod(req.repayMethod() == null ? "等额本息" : req.repayMethod());
s.setHandlingFee(Money.of(req.handlingFee()));
s.setGuaranteeType(req.guaranteeType());
s.setGuaranteeRate(req.guaranteeRate() == null ? 0.0 : req.guaranteeRate());
s.setGuaranteeRate(req.guaranteeRate() == null ? BigDecimal.ZERO : req.guaranteeRate());
s.setDrawdownConditions(req.drawdownConditions());
s.setOwner(req.owner());
s.setStatus(req.status() == null || req.status().isBlank() ? "待比选" : req.status());
@@ -118,7 +117,6 @@ public class PmtLoanSchemeController {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<PmtLoanScheme> update(@PathVariable Long id, @RequestBody SchemeRequest req) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -144,7 +142,6 @@ public class PmtLoanSchemeController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -162,7 +159,6 @@ public class PmtLoanSchemeController {
* 同时重算各方案的综合成本率幂等
*/
@PostMapping("/rank")
@Transactional
public ApiResp<List<PmtLoanScheme>> rank(@RequestParam Long financingId) {
List<PmtLoanScheme> schemes = schemeRepo.findByFinancingId(financingId);
if (schemes.isEmpty()) {
@@ -172,9 +168,9 @@ public class PmtLoanSchemeController {
schemes.forEach(s -> s.setEffectiveCostRate(calcEffectiveCostRate(s)));
// 按综合成本升序排
schemes.sort((a, b) -> {
double ra = a.getEffectiveCostRate() == null ? Double.MAX_VALUE : a.getEffectiveCostRate();
double rb = b.getEffectiveCostRate() == null ? Double.MAX_VALUE : b.getEffectiveCostRate();
return Double.compare(ra, rb);
BigDecimal ra = a.getEffectiveCostRate() == null ? BigDecimal.valueOf(Double.MAX_VALUE) : a.getEffectiveCostRate();
BigDecimal rb = b.getEffectiveCostRate() == null ? BigDecimal.valueOf(Double.MAX_VALUE) : b.getEffectiveCostRate();
return ra.compareTo(rb);
});
for (int i = 0; i < schemes.size(); i++) {
schemes.get(i).setRank(i + 1);
@@ -188,7 +184,6 @@ public class PmtLoanSchemeController {
/** 标注推荐方案(首选)。同一 financingId 下只能有一个推荐方案;旧推荐自动清除。 */
@PostMapping("/{id}/recommend")
@Transactional
public ApiResp<PmtLoanScheme> recommend(@PathVariable Long id,
@RequestBody(required = false) RecommendRequest req) {
PmtLoanScheme s = schemeRepo.findById(id)
@@ -212,7 +207,6 @@ public class PmtLoanSchemeController {
* 同时回写 Financing 台账的 lender/rate/repayMethod融资状态推进到审批中
*/
@PostMapping("/{id}/select")
@Transactional
public ApiResp<PmtLoanScheme> select(@PathVariable Long id) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -258,9 +252,9 @@ public class PmtLoanSchemeController {
if (schemes.isEmpty()) {
return ApiResp.ok(new SchemeCompareSummary(schemes, 0, 0, ""));
}
double min = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate())
double min = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate().doubleValue())
.min().orElse(0);
double max = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate())
double max = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate().doubleValue())
.max().orElse(0);
String recommended = schemes.stream()
.filter(s -> Boolean.TRUE.equals(s.getRecommended()))
@@ -276,11 +270,11 @@ public class PmtLoanSchemeController {
* 利息按等额本息近似计算总利息担保费按本金 × 年担保费率 × 期限() 计算
* 真实 XIRR 需逐笔现金流此处为可读近似值误差在 0.1% 以内满足决策比选精度
*/
private double calcEffectiveCostRate(PmtLoanScheme s) {
private BigDecimal calcEffectiveCostRate(PmtLoanScheme s) {
double principal = s.getAmount() == null ? 0 : s.getAmount().doubleValue();
if (principal <= 0) return 0.0;
if (principal <= 0) return BigDecimal.ZERO;
int term = s.getTermMonths() == null || s.getTermMonths() <= 0 ? 12 : s.getTermMonths();
double annualRate = s.getAnnualRate() == null ? 0 : s.getAnnualRate();
double annualRate = s.getAnnualRate() == null ? 0 : s.getAnnualRate().doubleValue();
double monthlyRate = annualRate / 100.0 / 12.0;
// 总利息等额本息口径兼容多还款方式近似
@@ -297,7 +291,7 @@ public class PmtLoanSchemeController {
double handlingFee = s.getHandlingFee() == null ? 0 : s.getHandlingFee().doubleValue();
// 担保费年化率 × 期限年数 × 本金
double guaranteeRate = s.getGuaranteeRate() == null ? 0 : s.getGuaranteeRate();
double guaranteeRate = s.getGuaranteeRate() == null ? 0 : s.getGuaranteeRate().doubleValue();
double years = term / 12.0;
double guaranteeCost = principal * guaranteeRate / 100.0 * years;
@@ -305,6 +299,6 @@ public class PmtLoanSchemeController {
// 年化综合成本率百分比
double effectiveRate = (totalCost / principal) / years * 100.0;
return BigDecimal.valueOf(effectiveRate).setScale(4, RoundingMode.HALF_UP).doubleValue();
return BigDecimal.valueOf(effectiveRate).setScale(4, RoundingMode.HALF_UP);
}
}
@@ -191,7 +191,7 @@ public class StlDebtLedgerController {
activeCount++;
totalDebt = Money.add(totalDebt, principal);
if (f.getRate() != null) {
BigDecimal rateVal = BigDecimal.valueOf(f.getRate());
BigDecimal rateVal = f.getRate();
rateWeightedSum = rateWeightedSum.add(principal.multiply(rateVal));
rateWeightTotal = rateWeightTotal.add(principal);
}
@@ -212,7 +212,7 @@ public class StlDebtLedgerController {
principal.doubleValue(), remaining.doubleValue(),
paidPrin.doubleValue(), paidInterest.doubleValue(),
overdueAmt.doubleValue(),
f.getEndDate(), f.getRate() != null ? f.getRate() : 0.0,
f.getEndDate(), f.getRate() != null ? f.getRate().doubleValue() : 0.0,
f.getStatus(), planRows));
}
@@ -245,7 +245,6 @@ public class StlDebtLedgerController {
* <p>凭证科目借方=长期借款或按融资类型映射贷方=银行存款状态=草稿
*/
@PostMapping("/repayments/{planId}/pay-and-voucher")
@Transactional
public ApiResp<RepayVoucherResult> payAndVoucher(@PathVariable Long planId) {
RepaymentPlan plan = planRepo.findById(planId)
.orElseThrow(() -> new NotFoundException("还款期次不存在: " + planId));
@@ -434,7 +434,7 @@ public class StlFundReportController {
if (ls.getAnnualRate() == null || ls.getAmount() == null) continue;
// 年利息 = 贷款金额 × 年化利率 / 100
BigDecimal yearInterest = Money.nz(ls.getAmount())
.multiply(BigDecimal.valueOf(ls.getAnnualRate() / 100.0))
.multiply(ls.getAnnualRate().divide(BigDecimal.valueOf(100), 10, RoundingMode.HALF_UP))
.setScale(2, RoundingMode.HALF_UP);
externalInterest = externalInterest.add(yearInterest);
Map<String, Object> row = new LinkedHashMap<>();
@@ -0,0 +1,42 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Administrator-only API for checking and installing signed Gitea releases. */
@RestController
@RequestMapping("/api/oa/system-update")
public class SystemUpdateController {
private final SystemUpdateService updateService;
public SystemUpdateController(SystemUpdateService updateService) {
this.updateService = updateService;
}
@GetMapping("/status")
public ApiResp<UpdateStatus> status() {
return ApiResp.ok(updateService.status());
}
@PostMapping("/check")
public ApiResp<UpdateStatus> check() {
return ApiResp.ok(updateService.check());
}
@PostMapping("/install")
public ApiResp<UpdateStatus> install(@Valid @RequestBody InstallRequest request) {
return ApiResp.ok(updateService.install(request.version()));
}
public record InstallRequest(@NotBlank String version) {
}
}