Files
ERP/go-code-reference.md
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

396 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 核心代码逐字参考(go-code-reference.md
> 配套 `go.md`。这里把**核心基础类**和**一套端到端完整范例**逐字贴出,新工具照抄即可遵守全部约定。
> 范例选 `RdAnnualBudget`(部门年度研发预算,W6 产物)——含状态机/聚合/超支 409/导出,覆盖面广。
---
## A. 核心基础类(每个控制器都依赖)
### A.1 `common/ApiResp.java`(统一响应包装,**record**22 行,全文)
```java
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 状态)
```java
// 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`(金额工具,全文要点)
```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→标度2null当0NaN/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>bnull当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 客户端,导出对象全文)
```ts
// token 存 localStorage 键 'oa.token';自动带 Authorization: Bearer + ngrok-skip-browser-warning
// 自动解包 {code,message,data} 取 data;非2xx/code!=0 抛 OaApiError401 派发 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起):个人/协作类,任意已授角色可写(对象级属主校验在控制器内)。
鉴权决策(逐字):
```java
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/APPROVERdefault-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 默认)
```java
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`(派生查询,属性名必须和实体字段精确一致)
```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`(全文,覆盖所有约定)
```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,超支 409force=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 + 真实数据,全文要点)
```java
@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,全文要点)
```vue
<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` 数组里加:
```ts
{ 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 抄)
1. `domain/Xxx.java``@Entity @Table(name="xxx")`,金额字段 `BigDecimal = BigDecimal.ZERO`,全 getter/setter。
2. `repository/XxxRepository.java``extends JpaRepository<Xxx, Long>`,加派生查询(字段名精确一致,模糊用 `Containing`)。
3. `web/XxxController.java``@RestController @RequestMapping("/api/oa/xxx")`,构造注入 repo`ApiResp.ok()`record 请求体,`Money.of` 收金额,写操作 `@Transactional`,校验/冲突抛 `ApiException(400/409,"中文")`,找不到抛 `NotFoundException`
4. (可选)`seed/XxxSeeder.java``@Component @Order(n) implements ApplicationRunner`,幂等守卫,播真实演示数据。
5. `pages/<module>/<key>.vue``<script setup>` + Element Plus`http.get/post/del`,插槽 `#default="{ row }"`,图标真存在,footer 不嵌 div。
6. `data/oaModules.ts`:对应模块 children 加 `{ key, label, kind, path }`kind 非 'page')。
7. 若端点返回金额/PII:把 `/api/oa/xxx` 加进 `AuthInterceptor.SENSITIVE_READ_PREFIXES`
8. 构建发布:`npm run build`(8G堆) → `bootJar` → 换 8091 进程 →(若改了既有实体字段)跑 schema-sync。
**改代码前务必读 `go.md` 第 9 节"血泪坑大全"**Map.of≤10 / SQLite 保留字+漏列 schema-sync / ContainingIgnoreCase / 中文引号嵌套 / Vue 插槽嵌 div / kind 没 'page' / 派生查询属性名 / OOM 等)。