恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
Java
242 lines
11 KiB
Java
package com.kaidi.oa.web;
|
|
|
|
import com.kaidi.oa.common.ApiException;
|
|
import com.kaidi.oa.common.ApiResp;
|
|
import com.kaidi.oa.common.Money;
|
|
import com.kaidi.oa.common.NotFoundException;
|
|
import com.kaidi.oa.domain.SoftwareLicense;
|
|
import com.kaidi.oa.repository.SoftwareLicenseRepository;
|
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
import org.springframework.web.bind.annotation.PatchMapping;
|
|
import org.springframework.web.bind.annotation.PathVariable;
|
|
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.RequestParam;
|
|
import org.springframework.web.bind.annotation.RestController;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.Instant;
|
|
import java.time.LocalDate;
|
|
import java.time.format.DateTimeParseException;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* 信息部·软件资产授权(需求 §1 软件资产台账)。授权台账 CRUD、席位分配(assign-seat / release-seat
|
|
* 校验不超授权数量)、到期分级预警(订阅类到期前 N 天)、席位超许可预警、年费成本汇总。
|
|
*
|
|
* <p>到期预警 level:已过期 / 紧急(≤7天) / 临近(≤15天) / 关注(≤days);席位告警:assignedSeats > seats。
|
|
* 写口含金额(年费)默认受 default-deny(ADMIN/APPROVER) 保护;读侧含激活码与年费,登记 SENSITIVE_READ_PREFIXES。</p>
|
|
*/
|
|
@RestController
|
|
@RequestMapping("/api/oa/software-licenses")
|
|
public class SoftwareLicenseController {
|
|
|
|
private final SoftwareLicenseRepository repo;
|
|
|
|
public SoftwareLicenseController(SoftwareLicenseRepository repo) {
|
|
this.repo = repo;
|
|
}
|
|
|
|
// ---------- 授权台账 CRUD ----------
|
|
|
|
@GetMapping
|
|
public ApiResp<List<SoftwareLicense>> list(@RequestParam(required = false) String category,
|
|
@RequestParam(required = false) String status,
|
|
@RequestParam(required = false) String licenseType) {
|
|
if (category != null && !category.isBlank()) {
|
|
return ApiResp.ok(repo.findByCategory(category));
|
|
}
|
|
if (status != null && !status.isBlank()) {
|
|
return ApiResp.ok(repo.findByStatus(status));
|
|
}
|
|
if (licenseType != null && !licenseType.isBlank()) {
|
|
return ApiResp.ok(repo.findByLicenseType(licenseType));
|
|
}
|
|
return ApiResp.ok(repo.findAll());
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public ApiResp<SoftwareLicense> get(@PathVariable Long id) {
|
|
return ApiResp.ok(require(id));
|
|
}
|
|
|
|
public record LicenseRequest(
|
|
String licenseNo, String softwareName, String version, String category,
|
|
String licenseType, String vendor, Integer seats, Integer assignedSeats,
|
|
String activationKey, String purchaseDate, String expiryDate, Double annualFee,
|
|
String status, String dept) {
|
|
}
|
|
|
|
@PostMapping
|
|
public ApiResp<SoftwareLicense> create(@RequestBody LicenseRequest req) {
|
|
if (req.softwareName() == null || req.softwareName().isBlank()) {
|
|
throw new ApiException(400, "软件名称(softwareName) 不能为空");
|
|
}
|
|
SoftwareLicense a = new SoftwareLicense();
|
|
a.setLicenseNo(req.licenseNo() == null || req.licenseNo().isBlank()
|
|
? "SW-" + (repo.count() + 1) : req.licenseNo());
|
|
a.setSoftwareName(req.softwareName());
|
|
a.setVersion(req.version());
|
|
a.setCategory(req.category() == null || req.category().isBlank() ? "其他" : req.category());
|
|
a.setLicenseType(req.licenseType() == null || req.licenseType().isBlank() ? "订阅" : req.licenseType());
|
|
a.setVendor(req.vendor());
|
|
a.setSeats(req.seats() == null ? 1 : req.seats());
|
|
a.setAssignedSeats(req.assignedSeats() == null ? 0 : req.assignedSeats());
|
|
a.setActivationKey(req.activationKey());
|
|
a.setPurchaseDate(req.purchaseDate());
|
|
a.setExpiryDate(req.expiryDate());
|
|
a.setAnnualFee(Money.of(req.annualFee()));
|
|
a.setStatus(req.status() == null || req.status().isBlank() ? "在用" : req.status());
|
|
a.setDept(req.dept());
|
|
a.setCreatedAt(Instant.now());
|
|
return ApiResp.ok(repo.save(a));
|
|
}
|
|
|
|
@PatchMapping("/{id}")
|
|
public ApiResp<SoftwareLicense> update(@PathVariable Long id, @RequestBody LicenseRequest req) {
|
|
SoftwareLicense a = require(id);
|
|
if (req.softwareName() != null && !req.softwareName().isBlank()) a.setSoftwareName(req.softwareName());
|
|
if (req.version() != null) a.setVersion(req.version());
|
|
if (req.category() != null && !req.category().isBlank()) a.setCategory(req.category());
|
|
if (req.licenseType() != null && !req.licenseType().isBlank()) a.setLicenseType(req.licenseType());
|
|
if (req.vendor() != null) a.setVendor(req.vendor());
|
|
if (req.seats() != null) a.setSeats(req.seats());
|
|
if (req.assignedSeats() != null) a.setAssignedSeats(req.assignedSeats());
|
|
if (req.activationKey() != null) a.setActivationKey(req.activationKey());
|
|
if (req.purchaseDate() != null) a.setPurchaseDate(req.purchaseDate());
|
|
if (req.expiryDate() != null) a.setExpiryDate(req.expiryDate());
|
|
if (req.annualFee() != null) a.setAnnualFee(Money.of(req.annualFee()));
|
|
if (req.status() != null && !req.status().isBlank()) a.setStatus(req.status());
|
|
if (req.dept() != null) a.setDept(req.dept());
|
|
return ApiResp.ok(repo.save(a));
|
|
}
|
|
|
|
@DeleteMapping("/{id}")
|
|
public ApiResp<Void> delete(@PathVariable Long id) {
|
|
if (!repo.existsById(id)) {
|
|
throw new NotFoundException("software license not found: " + id);
|
|
}
|
|
repo.deleteById(id);
|
|
return ApiResp.ok(null);
|
|
}
|
|
|
|
// ---------- 席位分配(校验不超授权)----------
|
|
|
|
/** 分配一个席位:assignedSeats+1,超授权数量拒绝(软件许可合规)。 */
|
|
@PostMapping("/{id}/assign-seat")
|
|
public ApiResp<SoftwareLicense> assignSeat(@PathVariable Long id) {
|
|
SoftwareLicense a = require(id);
|
|
int seats = a.getSeats() == null ? 0 : a.getSeats();
|
|
int used = a.getAssignedSeats() == null ? 0 : a.getAssignedSeats();
|
|
if (used + 1 > seats) {
|
|
throw new ApiException(409, "授权席位已用尽:授权 " + seats + ",已分配 " + used + ",请先扩容许可");
|
|
}
|
|
a.setAssignedSeats(used + 1);
|
|
return ApiResp.ok(repo.save(a));
|
|
}
|
|
|
|
/** 回收一个席位:assignedSeats-1(不小于 0)。 */
|
|
@PostMapping("/{id}/release-seat")
|
|
public ApiResp<SoftwareLicense> releaseSeat(@PathVariable Long id) {
|
|
SoftwareLicense a = require(id);
|
|
int used = a.getAssignedSeats() == null ? 0 : a.getAssignedSeats();
|
|
a.setAssignedSeats(Math.max(0, used - 1));
|
|
return ApiResp.ok(repo.save(a));
|
|
}
|
|
|
|
// ---------- 到期 / 席位 分级预警 ----------
|
|
|
|
public record LicenseAlert(Long id, String licenseNo, String softwareName, String licenseType,
|
|
String expiryDate, long daysToExpiry, Integer seats, Integer assignedSeats,
|
|
String alertType, String level) {
|
|
}
|
|
|
|
/**
|
|
* 软件授权预警:到期预警(订阅类有 expiryDate 且落在 [今天-逾期, 今天+days])+ 席位超许可预警。
|
|
* alertType:到期 / 超许可;level:已过期 / 紧急 / 临近 / 关注(席位超许可统一为「紧急」)。
|
|
*/
|
|
@GetMapping("/alerts/expiry")
|
|
public ApiResp<List<LicenseAlert>> expiryAlerts(@RequestParam(required = false, defaultValue = "30") int days) {
|
|
LocalDate today = LocalDate.now();
|
|
List<LicenseAlert> out = new ArrayList<>();
|
|
for (SoftwareLicense a : repo.findAll()) {
|
|
int seats = a.getSeats() == null ? 0 : a.getSeats();
|
|
int used = a.getAssignedSeats() == null ? 0 : a.getAssignedSeats();
|
|
// 席位超许可预警(任何授权类型)。
|
|
if (used > seats) {
|
|
out.add(new LicenseAlert(a.getId(), a.getLicenseNo(), a.getSoftwareName(),
|
|
a.getLicenseType(), a.getExpiryDate(), Long.MIN_VALUE, seats, used, "超许可", "紧急"));
|
|
}
|
|
// 到期预警(有到期日的授权)。
|
|
LocalDate expiry = parseDateOrNull(a.getExpiryDate());
|
|
if (expiry == null) {
|
|
continue;
|
|
}
|
|
long daysTo = ChronoUnit.DAYS.between(today, expiry);
|
|
if (daysTo > days) {
|
|
continue;
|
|
}
|
|
String level = daysTo < 0 ? "已过期" : daysTo <= 7 ? "紧急" : daysTo <= 15 ? "临近" : "关注";
|
|
out.add(new LicenseAlert(a.getId(), a.getLicenseNo(), a.getSoftwareName(),
|
|
a.getLicenseType(), a.getExpiryDate(), daysTo, seats, used, "到期", level));
|
|
}
|
|
out.sort((x, y) -> Long.compare(x.daysToExpiry(), y.daysToExpiry()));
|
|
return ApiResp.ok(out);
|
|
}
|
|
|
|
// ---------- 年费成本汇总 ----------
|
|
|
|
public record CostSummary(double totalAnnualFee, int totalLicenses, int totalSeats,
|
|
int totalAssigned, int expiringSoon, int overSeat) {
|
|
}
|
|
|
|
/** 软件授权年费成本与席位利用概览(供 IT 预算编制与软件资产盘点)。 */
|
|
@GetMapping("/cost/summary")
|
|
public ApiResp<CostSummary> costSummary() {
|
|
LocalDate today = LocalDate.now();
|
|
BigDecimal totalFee = BigDecimal.ZERO;
|
|
int totalSeats = 0;
|
|
int totalAssigned = 0;
|
|
int expiringSoon = 0;
|
|
int overSeat = 0;
|
|
List<SoftwareLicense> all = repo.findAll();
|
|
for (SoftwareLicense a : all) {
|
|
totalFee = Money.add(totalFee, a.getAnnualFee());
|
|
totalSeats += a.getSeats() == null ? 0 : a.getSeats();
|
|
int used = a.getAssignedSeats() == null ? 0 : a.getAssignedSeats();
|
|
totalAssigned += used;
|
|
if (used > (a.getSeats() == null ? 0 : a.getSeats())) {
|
|
overSeat++;
|
|
}
|
|
LocalDate expiry = parseDateOrNull(a.getExpiryDate());
|
|
if (expiry != null && ChronoUnit.DAYS.between(today, expiry) <= 30) {
|
|
expiringSoon++;
|
|
}
|
|
}
|
|
return ApiResp.ok(new CostSummary(totalFee.doubleValue(), all.size(),
|
|
totalSeats, totalAssigned, expiringSoon, overSeat));
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
private SoftwareLicense require(Long id) {
|
|
return repo.findById(id)
|
|
.orElseThrow(() -> new NotFoundException("software license not found: " + id));
|
|
}
|
|
|
|
private static LocalDate parseDateOrNull(String s) {
|
|
if (s == null || s.isBlank()) {
|
|
return null;
|
|
}
|
|
try {
|
|
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
|
|
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|