Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/SoftwareLicenseController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(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>
2026-06-15 19:19:15 +08:00

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;
}
}
}