恢复点(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>
22 KiB
核心代码逐字参考(go-code-reference.md)
配套
go.md。这里把核心基础类和一套端到端完整范例逐字贴出,新工具照抄即可遵守全部约定。 范例选RdAnnualBudget(部门年度研发预算,W6 产物)——含状态机/聚合/超支 409/导出,覆盖面广。
A. 核心基础类(每个控制器都依赖)
A.1 common/ApiResp.java(统一响应包装,record,22 行,全文)
package com.kaidi.oa.common;
/** code==0 成功;非0为错误,message 中文原因;data 载荷(错误时 null)。 */
public record ApiResp<T>(int code, String message, T data) {
public static <T> ApiResp<T> ok(T data) { return new ApiResp<>(0, "ok", data); }
public static <T> ApiResp<T> ok() { return new ApiResp<>(0, "ok", null); }
public static <T> ApiResp<T> error(int code, String message) { return new ApiResp<>(code, message, null); }
}
取数据用
.data()(record 访问器),不是.getData()。控制器统一return ApiResp.ok(payload);。
A.2 异常类(控制器抛这两个,GlobalExceptionHandler 统一转 HTTP 状态)
// common/ApiException.java —— 业务错误:new ApiException(400, "中文原因") / (409, "冲突原因")
// common/NotFoundException.java —— 资源不存在:抛出后 GlobalExceptionHandler 映射 404
用法(见下面控制器):throw new ApiException(409, "已关闭的预算不可修改");、throw new NotFoundException("xxx不存在:" + id);
A.3 common/Money.java(金额工具,全文要点)
public final class Money {
public static final int SCALE = 2; // 标度2(分)
public static final BigDecimal ZERO = ...; // 标度2的0
public static BigDecimal of(Double v); // 装箱Double→标度2;null当0;NaN/Inf→400
public static BigDecimal of(double v);
public static BigDecimal of(BigDecimal v); // = nz(v)
public static BigDecimal nz(BigDecimal v); // null安全:null→0
public static BigDecimal add(BigDecimal a, BigDecimal b); // 都当0再加,结果标度2
public static BigDecimal sub(BigDecimal a, BigDecimal b);
public static boolean gt(BigDecimal a, BigDecimal b); // a>b(null当0)
public static boolean lte0(BigDecimal a); // a<=0
}
铁律:所有"钱"用
BigDecimal,控制器里收前端传来的金额一律Money.of(req.amount())(req.amount() 是 Double),算术用Money.add/sub,比较用Money.gt/lte0。科学量/数量(不是钱)可用 double。
A.4 前端 oa/api/http.ts(HTTP 客户端,导出对象全文)
// token 存 localStorage 键 'oa.token';自动带 Authorization: Bearer + ngrok-skip-browser-warning;
// 自动解包 {code,message,data} 取 data;非2xx/code!=0 抛 OaApiError;401 派发 OA_AUTH_REQUIRED_EVENT。
export interface RequestOptions {
method?: 'GET'|'POST'|'PUT'|'DELETE'|'PATCH'
query?: Record<string, string|number|boolean|undefined|null> // 查询参数;undefined/null 自动丢弃
body?: unknown
timeoutMs?: number
signal?: AbortSignal
}
export const http = {
get: <T>(path, query?, opts?) => request<T>(path, { ...opts, method:'GET', query }), // 第2参=query对象
post: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'POST', body }),
put: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'PUT', body }),
patch: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'PATCH', body }),
del: <T>(path, opts?) => request<T>(path, { ...opts, method:'DELETE' }), // 删除是 del 不是 delete
}
调用:
await http.get<Row[]>('/rd-annual-budgets', { fiscalYear: 2026 })、await http.post('/x', {...body})、await http.del(\/x/${id}`)。路径不带/api/oa` 前缀(客户端自动拼)。
B. 安全:config/AuthInterceptor.java(default-deny 分级,核心逻辑全文)
四张前缀表(List<String>,元素是 /api/oa/xxx):
ADMIN_PREFIXES(行52起):仅 ADMIN。FINANCE_PREFIXES(行67起):财务/主数据;写需 ADMIN/APPROVER,且读侧自动同门禁(见 isSensitiveRead)。SENSITIVE_READ_PREFIXES(行130起):敏感读前缀(读也要 ADMIN/APPROVER)。新增返回金额/PII/机密聚合的读端点,把它的前缀加这里。SELF_SERVICE_WRITE_PREFIXES(行382起):个人/协作类,任意已授角色可写(对象级属主校验在控制器内)。
鉴权决策(逐字):
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) return true; // CORS 预检放行
String path = normalizePath(request.getRequestURI()); // 规范化防绕过(见下)
if (PUBLIC.contains(path)) return true; // 登录等公开端点
SysUser user = currentUser.resolve(request);
if (user == null) { writeError(401,"未登录或会话已过期"); return false; }
Set<String> have = authz.roleCodesOf(user.getId());
if (have.isEmpty()) { writeError(403,"无权限:账号未分配任何角色"); return false; } // 无角色=零权限
boolean isWrite = WRITE_METHODS.contains(request.getMethod().toUpperCase());
if (isWrite) {
Set<String> required = requiredRolesFor(path); // 写:查所需角色
if (required != null && required.stream().noneMatch(have::contains)) { writeError(403,...); return false; }
} else {
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) { writeError(403,...); return false; }
}
return true;
}
// 写口角色判定:ADMIN前缀→ADMIN;自助白名单→任意角色(null);其余一切默认→ADMIN/APPROVER(default-deny翻转)
private static Set<String> requiredRolesFor(String path) {
if (path.endsWith("/convert-bid") || (path.startsWith("/api/oa/projects/") && path.endsWith("/advance")))
return APPROVER_OR_ADMIN; // 侧写财务副作用端点特判
if (path.endsWith("/vote")) return null; // 问卷投票公共动作
for (String p : ADMIN_PREFIXES) if (hit(path,p)) return ADMIN;
for (String p : SELF_SERVICE_WRITE_PREFIXES) if (hit(path,p)) return null;
for (String p : FINANCE_PREFIXES) if (hit(path,p)) return APPROVER_OR_ADMIN;
return APPROVER_OR_ADMIN; // ★默认需 ADMIN/APPROVER
}
// 读口敏感判定:后缀特判 + SENSITIVE_READ_PREFIXES + FINANCE_PREFIXES(写收了读也收)
private static boolean isSensitiveRead(String path) {
if (path.endsWith("/evidence-chain")) return true; // 研发证据链含金额明细
if (path.endsWith("/water-quality/trace")) return true; // 水质溯源含客户主数据
for (String p : SENSITIVE_READ_PREFIXES) if (hit(path,p)) return true;
for (String p : FINANCE_PREFIXES) if (hit(path,p)) return true;
return false;
}
// 规范化防越权绕过:URL解码(%75→u)+剥矩阵参数(;k=v)+折叠斜杠+去尾斜杠+转小写
static String normalizePath(String uri) {
String p = URLDecoder.decode(uri, UTF_8); // 异常则保留原串(宁多拦不漏拦)
p = p.replaceAll(";[^/]*","").replaceAll("/{2,}","/");
if (p.length()>1 && p.endsWith("/")) p = p.substring(0,p.length()-1);
return p.toLowerCase();
}
新建控制器默认就受保护(写口默认档=ADMIN/APPROVER)。普通业务读默认放行给"有角色的登录用户";返回敏感数据的读口必须登记进 SENSITIVE_READ_PREFIXES(或落在 FINANCE_PREFIXES 内)。
C. 一套端到端完整范例(控制器+实体+仓库+Seeder+Vue页)
业务:部门年度研发费用预算。资源路径
/api/oa/rd-annual-budgets,表rd_annual_budget。 新建一个功能就照这 5 个文件的样子复制。
C.1 实体 domain/RdAnnualBudget.java(@Entity,金额 BigDecimal=ZERO 默认)
package com.kaidi.oa.domain;
import java.math.BigDecimal; import java.time.Instant;
import jakarta.persistence.*;
@Entity
@Table(name = "rd_annual_budget") // 物理表名,camelCase→snake_case;保留字列要 @Column(name=)
public class RdAnnualBudget {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer fiscalYear; // 预算年度
private String dept;
private String budgetType; // 研发费用 / 知识产权费用
private String expenseItem; // 人工费/材料费/.../合计
private BigDecimal amount = BigDecimal.ZERO; // ★金额默认 BigDecimal.ZERO(防 NOT NULL 500)
private BigDecimal actualUsed = BigDecimal.ZERO; // 实际已用
private String rdProjectCodes; // 逗号分隔的立项编号
private String status; // 编制中/已批准/已关闭
private String owner;
private String remark;
private Instant createdAt;
private Instant updatedAt;
// —— 每个字段标准 getter/setter(省略,必须全有,控制器靠 setXxx/getXxx 读写)——
}
C.2 仓库 repository/RdAnnualBudgetRepository.java(派生查询,属性名必须和实体字段精确一致)
package com.kaidi.oa.repository;
import com.kaidi.oa.domain.RdAnnualBudget;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface RdAnnualBudgetRepository extends JpaRepository<RdAnnualBudget, Long> {
List<RdAnnualBudget> findByFiscalYear(Integer fiscalYear);
List<RdAnnualBudget> findByFiscalYearAndDept(Integer fiscalYear, String dept);
List<RdAnnualBudget> findByFiscalYearAndBudgetType(Integer fiscalYear, String budgetType);
List<RdAnnualBudget> findByFiscalYearAndDeptAndBudgetType(Integer fiscalYear, String dept, String budgetType);
}
派生查询
findBy<字段名>:字段名必须和实体字段精确一致(大小写敏感、单复数一致),否则 Spring 启动期才报No property 'xxx'。模糊查用Containing(别用 ContainingIgnoreCase,见 go.md 坑 9.3)。
C.3 控制器 web/RdAnnualBudgetController.java(全文,覆盖所有约定)
@RestController
@RequestMapping("/api/oa/rd-annual-budgets")
public class RdAnnualBudgetController {
private final RdAnnualBudgetRepository repo;
public RdAnnualBudgetController(RdAnnualBudgetRepository repo) { this.repo = repo; } // 构造注入
private static final List<String> STATUSES = List.of("编制中", "已批准", "已关闭");
// 列表:多条件可选过滤
@GetMapping
public ApiResp<List<RdAnnualBudget>> list(@RequestParam(required=false) Integer fiscalYear,
@RequestParam(required=false) String dept,
@RequestParam(required=false) String budgetType) {
if (fiscalYear != null) return ApiResp.ok(repo.findByFiscalYear(fiscalYear));
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<RdAnnualBudget> get(@PathVariable Long id) { return ApiResp.ok(find(id)); }
public record BudgetReq(Integer fiscalYear, String dept, String budgetType, String expenseItem,
Double amount, String rdProjectCodes, String owner, String remark) {} // ★record 请求体
@PostMapping
@Transactional // ★写操作/状态迁移加 @Transactional
public ApiResp<RdAnnualBudget> create(@RequestBody BudgetReq req) {
if (req.fiscalYear() == null) throw new ApiException(400, "预算年度不能为空"); // 校验→400中文
RdAnnualBudget b = new RdAnnualBudget();
b.setFiscalYear(req.fiscalYear());
b.setAmount(Money.of(req.amount())); // ★金额收口 Money.of(Double)
b.setActualUsed(Money.ZERO);
b.setStatus("编制中");
b.setCreatedAt(Instant.now()); b.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(b));
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<RdAnnualBudget> update(@PathVariable Long id, @RequestBody BudgetReq req) {
RdAnnualBudget b = find(id);
if ("已关闭".equals(b.getStatus())) throw new ApiException(409, "已关闭的预算不可修改"); // 状态冲突→409
if (req.amount() != null) b.setAmount(Money.of(req.amount())); // PATCH:非null才改
b.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(b));
}
public record StatusReq(String status) {}
@PostMapping("/{id}/status") // 状态机端点
@Transactional
public ApiResp<RdAnnualBudget> setStatus(@PathVariable Long id, @RequestBody StatusReq req) {
RdAnnualBudget b = find(id);
String to = req.status() == null ? "" : req.status().trim();
if (!STATUSES.contains(to)) throw new ApiException(400, "未知状态:" + to);
b.setStatus(to); b.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(b));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
RdAnnualBudget b = find(id);
if (Money.gt(b.getActualUsed(), Money.ZERO)) // 业务校验:有占用不可删
throw new ApiException(409, "已有费用占用,不可删除");
repo.deleteById(id);
return ApiResp.ok(null);
}
// 费用归集写入:找对应预算行扣减 actualUsed,超支 409(force=true 强占)
public record OccupyReq(Integer fiscalYear, String dept, String budgetType, String expenseItem,
Double amount, boolean force) {}
@PostMapping("/occupy")
@Transactional
public ApiResp<RdAnnualBudget> occupy(@RequestBody OccupyReq req) {
if (req.amount() == null || req.amount() <= 0) throw new ApiException(400, "占用金额必须大于零");
RdAnnualBudget b = ...; // 按年度/部门/类型/科目查
BigDecimal newUsed = Money.add(b.getActualUsed(), Money.of(req.amount()));
if (Money.gt(newUsed, b.getAmount()) && !req.force())
throw new ApiException(409, "超支预警:... 如需强制占用请带 force=true");
b.setActualUsed(newUsed); b.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(b));
}
// 聚合端点:执行汇总(record 出参,前端直接渲染)
public record YearDeptSummary(int fiscalYear, String dept, String budgetType,
double totalBudget, double totalUsed, double totalBalance,
double rate, List<ItemLine> byItem) {}
@GetMapping("/summary")
public ApiResp<List<YearDeptSummary>> summary(@RequestParam int fiscalYear, ...) { ... }
private RdAnnualBudget find(Long id) {
return repo.findById(id).orElseThrow(() -> new NotFoundException("研发年度预算行不存在:" + id));
}
}
(完整 328 行在源码 oa-backend/src/main/java/com/kaidi/oa/web/RdAnnualBudgetController.java。)
C.4 演示种子 seed/CslDemoSeeder.java(@Order + 幂等守卫 + Money + 真实数据,全文要点)
@Component
@Order(7) // 排序;DataSeeder 是 @Order(1)
public class CslDemoSeeder implements ApplicationRunner {
private final CslProjectRepository projectRepo;
private final CslTimesheetRepository timesheetRepo;
public CslDemoSeeder(CslProjectRepository p, CslTimesheetRepository t) { this.projectRepo=p; this.timesheetRepo=t; }
@Override @Transactional
public void run(ApplicationArguments args) { seedProjects(); }
private void seedProjects() {
if (projectRepo.findAll().stream().noneMatch(p -> "CSL-2025-001".equals(p.getCode()))) { // ★幂等守卫
CslProject p1 = new CslProject();
p1.setCode("CSL-2025-001");
p1.setName("某市地铁5号线可行性研究");
p1.setContractAmount(Money.of(new BigDecimal("1280000.00"))); // ★金额 Money.of
p1.setStage("执行"); p1.setProgress(62);
p1.setPlanStart("2025-03-01"); p1.setPlanEnd("2025-09-30"); // 日期用 String
p1.setCreatedAt(Instant.now()); p1.setUpdatedAt(Instant.now());
CslProject saved = projectRepo.save(p1);
seedTimesheets(saved.getId(), "CSL-2025-001"); // 关联子表
}
// ... 项目2、项目3 ...
}
private void save(Long projectId, String person, String role, String workDate, double hours,
double rate, String workContent, String status) {
CslTimesheet t = new CslTimesheet();
t.setProjectId(projectId); t.setPerson(person); t.setHours(hours);
BigDecimal rateBd = Money.of(rate);
t.setRate(rateBd);
t.setLaborCost(Money.of(rateBd.multiply(BigDecimal.valueOf(hours)))); // 人工成本=费率×工时
t.setStatus(status); t.setCreatedAt(Instant.now());
timesheetRepo.save(t);
}
}
空表补种子是把"功能在但无数据演示"的 PARTIAL 推到可演示 MET 的最廉价手段(go-database.md 列了所有空表)。守卫必须有(重启不重复插)。
C.5 前端页 oa/pages/rd/rdannualbudget.vue(<script setup> + Element Plus,全文要点)
<script setup lang="ts">
import { onMounted, ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, DataAnalysis, Download } from '@element-plus/icons-vue' // 图标必须真存在
import { http } from '../../api/http'
interface RdAnnualBudget { id:number; fiscalYear:number; dept:string; amount:number; actualUsed:number; status:string /*...*/ }
const list = ref<RdAnnualBudget[]>([])
const loading = ref(false)
const form = reactive<Record<string, any>>({ fiscalYear: new Date().getFullYear(), amount: 0 /*...*/ }) // query/form 用 Record<string,any>
const money = (v: unknown) => '¥' + Number(v||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})
async function load() {
loading.value = true
try {
const params: Record<string, any> = { fiscalYear: 2026 }
list.value = await http.get<RdAnnualBudget[]>('/rd-annual-budgets', params) // 第2参直接是 query
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
}
async function save() {
try { await http.post('/rd-annual-budgets', { ...form }); ElMessage.success('已创建'); await load() }
catch { ElMessage.error('创建失败') }
}
async function del(id: number) {
await ElMessageBox.confirm('确定删除?', '警告', { type:'warning' })
await http.del(`/rd-annual-budgets/${id}`); await load() // 删除 http.del
}
onMounted(() => { load() })
</script>
<template>
<div style="padding:20px">
<el-button type="primary" :icon="Plus" @click="createVisible=true">新增</el-button>
<el-table :data="list" :loading="loading" border size="small">
<el-table-column prop="dept" label="部门" width="140" />
<el-table-column label="预算金额" width="130">
<template #default="{ row }">{{ money(row.amount) }}</template> <!-- 插槽写 #default="{ row }" -->
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status==='编制中'" size="small" type="success" @click="setStatus(row,'已批准')">批准</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="createVisible" title="新增预算行" width="540px">
<el-form :model="form" label-width="110px" size="small"> <!-- 表单项... --> </el-form>
<template #footer> <!-- footer 是 el-dialog 直接子节点,绝不嵌 div -->
<el-button @click="createVisible=false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
(完整 323 行在源码 app/src/oa/pages/rd/rdannualbudget.vue。)
C.6 接入导航 data/oaModules.ts(页面建好后加一行,路由自动发现)
在 id: 'rd' 模块的 children 数组里加:
{ key: 'rdannualbudget', label: '年度研发费用预算', kind: 'list', path: '/rd/rdannualbudget' },
// ↑ key 对应文件名 rdannualbudget.vue kind 只能是枚举(list/board/report/...),没有 'page'
路由由
import.meta.glob自动发现(页面在pages/rd/rdannualbudget.vue+ nav 这行 → 自动可访问/rd/rdannualbudget,不用改 router)。
D. 从 0 新建一个功能的标准步骤(照 C 抄)
domain/Xxx.java:@Entity @Table(name="xxx"),金额字段BigDecimal = BigDecimal.ZERO,全 getter/setter。repository/XxxRepository.java:extends JpaRepository<Xxx, Long>,加派生查询(字段名精确一致,模糊用Containing)。web/XxxController.java:@RestController @RequestMapping("/api/oa/xxx"),构造注入 repo,ApiResp.ok(),record 请求体,Money.of收金额,写操作@Transactional,校验/冲突抛ApiException(400/409,"中文"),找不到抛NotFoundException。- (可选)
seed/XxxSeeder.java:@Component @Order(n) implements ApplicationRunner,幂等守卫,播真实演示数据。 pages/<module>/<key>.vue:<script setup>+ Element Plus,http.get/post/del,插槽#default="{ row }",图标真存在,footer 不嵌 div。data/oaModules.ts:对应模块 children 加{ key, label, kind, path }(kind 非 'page')。- 若端点返回金额/PII:把
/api/oa/xxx加进AuthInterceptor.SENSITIVE_READ_PREFIXES。 - 构建发布:
npm run build(8G堆) →bootJar→ 换 8091 进程 →(若改了既有实体字段)跑 schema-sync。
改代码前务必读 go.md 第 9 节"血泪坑大全"(Map.of≤10 / SQLite 保留字+漏列 schema-sync / ContainingIgnoreCase / 中文引号嵌套 / Vue 插槽嵌 div / kind 没 'page' / 派生查询属性名 / OOM 等)。