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

22 KiB
Raw Blame History

核心代码逐字参考(go-code-reference.md

配套 go.md。这里把核心基础类一套端到端完整范例逐字贴出,新工具照抄即可遵守全部约定。 范例选 RdAnnualBudget(部门年度研发预算,W6 产物)——含状态机/聚合/超支 409/导出,覆盖面广。


A. 核心基础类(每个控制器都依赖)

A.1 common/ApiResp.java(统一响应包装,record22 行,全文)

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→标度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.tsHTTP 客户端,导出对象全文)

// 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.javadefault-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/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 默认)

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,超支 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 + 真实数据,全文要点)

@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 抄)

  1. domain/Xxx.java@Entity @Table(name="xxx"),金额字段 BigDecimal = BigDecimal.ZERO,全 getter/setter。
  2. repository/XxxRepository.javaextends JpaRepository<Xxx, Long>,加派生查询(字段名精确一致,模糊用 Containing)。
  3. web/XxxController.java@RestController @RequestMapping("/api/oa/xxx"),构造注入 repoApiResp.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 Plushttp.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 等)。