feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)

P0 MVP:

- 认证:login/refresh/logout/forgot/reset + tokenVersion

- RBAC:角色到权限映射 + requirePermission

- 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005)

- 报销:create/view/submit + 单号生成

- 工作流:approve + department_manager 解析

P1 扩展:

- 动作:reject/return/withdraw(含 comment)

- transfer:任务转审 + 链路上限 3

- pay:approved -> paid + 发票归档 used_complete

- 动态审批链:approval_rules 驱动

回归脚本:

- smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
2026-02-07 16:43:31 +08:00
当前提交 f527633716
共修改 44 个文件,包含 11100 行新增和 0 行删除
+15
查看文件
@@ -0,0 +1,15 @@
class AppError extends Error {
/**
* @param {{ code: string; message: string; statusCode: number; details?: any }} params
*/
constructor({ code, message, statusCode, details }) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.details = details ?? {};
}
}
module.exports = { AppError };
+17
查看文件
@@ -0,0 +1,17 @@
function buildAuditLogData(request, params) {
const userAgent = request.headers["user-agent"];
return {
request_id: request.id,
actor_id: params.actorId,
action: params.action,
target_type: params.targetType,
target_id: params.targetId ?? null,
ip_address: request.ip,
user_agent: typeof userAgent === "string" ? userAgent : null,
extra: params.extra ?? {},
};
}
module.exports = { buildAuditLogData };
+27
查看文件
@@ -0,0 +1,27 @@
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function randomRefreshToken() {
return crypto.randomBytes(32).toString("base64url");
}
async function hashPassword(password) {
const saltRounds = 12;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash);
}
module.exports = {
hashPassword,
randomRefreshToken,
sha256Hex,
verifyPassword,
};
+24
查看文件
@@ -0,0 +1,24 @@
function currentDateString(timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(new Date());
}
function compactDateString(timeZone) {
return currentDateString(timeZone).replaceAll("-", "");
}
function parseDateOnlyString(value) {
return new Date(`${value}T00:00:00.000Z`);
}
module.exports = {
compactDateString,
currentDateString,
parseDateOnlyString,
};
+21
查看文件
@@ -0,0 +1,21 @@
function validatePassword(password) {
const errors = [];
if (typeof password !== "string" || password.length < 12) {
errors.push("密码长度必须 ≥ 12");
}
if (!/[a-z]/.test(password)) {
errors.push("密码必须包含小写字母");
}
if (!/[A-Z]/.test(password)) {
errors.push("密码必须包含大写字母");
}
if (!/[0-9]/.test(password)) {
errors.push("密码必须包含数字");
}
return errors;
}
module.exports = { validatePassword };
+6
查看文件
@@ -0,0 +1,6 @@
function sendData(reply, request, data) {
return reply.send({ requestId: request.id, data });
}
module.exports = { sendData };
+45
查看文件
@@ -0,0 +1,45 @@
const crypto = require("node:crypto");
function toBase64Url(value) {
const source = typeof value === "string" ? value : JSON.stringify(value);
return Buffer.from(source).toString("base64url");
}
function fromBase64Url(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signPayload(payload, secret) {
const body = toBase64Url(payload);
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyToken(token, secret) {
if (typeof token !== "string") {
return { ok: false, reason: "invalid_format" };
}
const segments = token.split(".");
if (segments.length !== 2) {
return { ok: false, reason: "invalid_format" };
}
const [body, signature] = segments;
const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
if (signature !== expected) {
return { ok: false, reason: "invalid_signature" };
}
try {
const payload = JSON.parse(fromBase64Url(body));
return { ok: true, payload };
} catch {
return { ok: false, reason: "invalid_payload" };
}
}
module.exports = {
signPayload,
verifyToken,
};
+305
查看文件
@@ -0,0 +1,305 @@
const { AppError } = require("./AppError");
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_MANAGER_DEPTH = 10;
function parseWorkflowSteps(definitionJson) {
const steps = definitionJson?.steps;
if (!Array.isArray(steps) || steps.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_steps" },
});
}
return steps;
}
function buildWorkflowStepsFromApprovalRule(approvalRule) {
if (!approvalRule || typeof approvalRule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_approval_rule" },
});
}
const roleCodes = [
approvalRule.level1_role,
approvalRule.level2_role,
approvalRule.level3_role,
].filter(Boolean);
if (roleCodes.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "empty_approval_chain" },
});
}
return roleCodes.map((roleCode, index) => ({
id: `approval_${index + 1}_${roleCode}`,
name: `审批节点${index + 1}`,
type: "approval",
assignee_rule:
roleCode === "manager"
? { type: "department_manager" }
: { type: "role", value: roleCode },
}));
}
async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) {
let currentId = ownerId;
const visited = new Set();
for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) {
if (visited.has(currentId)) {
logger?.warn?.(
{ ownerId, currentId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth - 1,
};
}
visited.add(currentId);
const employee = await tx.employee.findUnique({
where: { id: currentId },
select: {
id: true,
direct_manager_id: true,
},
});
if (!employee) {
return {
assigneeId: null,
reason: "chain_broken",
depthChecked: depth,
};
}
const managerId = employee.direct_manager_id;
if (!managerId) {
return {
assigneeId: null,
reason: "no_manager_found",
depthChecked: depth,
};
}
if (managerId === currentId) {
logger?.warn?.(
{ ownerId, currentId, reason: "self_loop" },
"Self-loop manager reference detected",
);
return {
assigneeId: null,
reason: "self_loop",
depthChecked: depth,
};
}
if (managerId === ownerId) {
currentId = managerId;
continue;
}
if (visited.has(managerId)) {
logger?.warn?.(
{ ownerId, currentId: managerId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth,
};
}
return {
assigneeId: managerId,
reason: null,
depthChecked: depth,
};
}
logger?.warn?.(
{ ownerId, currentId, reason: "max_depth_exceeded" },
"Manager resolution reached max depth",
);
return {
assigneeId: null,
reason: "max_depth_exceeded",
depthChecked: MAX_MANAGER_DEPTH,
};
}
async function resolveAssigneeFromRule(rule, options = {}) {
if (!rule || typeof rule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_assignee_rule" },
});
}
if (rule.type === "role") {
if (typeof rule.value !== "string" || !rule.value) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_role_assignee" },
});
}
return {
assigneeId: null,
assigneeRole: rule.value,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "specific_user") {
if (typeof rule.value !== "string" || !UUID_REGEX.test(rule.value)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_specific_user_assignee" },
});
}
return {
assigneeId: rule.value,
assigneeRole: null,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "department_manager") {
const ownerId = options.ownerId;
const tx = options.tx;
if (!ownerId || !UUID_REGEX.test(ownerId) || !tx) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_department_manager_context" },
});
}
const managerResult = await resolveDepartmentManagerAssignee({
tx,
ownerId,
logger: options.logger,
});
if (!managerResult.assigneeId) {
return {
assigneeId: null,
assigneeRole: null,
skipReason: managerResult.reason,
depthChecked: managerResult.depthChecked,
};
}
return {
assigneeId: managerResult.assigneeId,
assigneeRole: null,
skipReason: null,
depthChecked: managerResult.depthChecked,
};
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "unsupported_assignee_rule_type" },
});
}
async function resolveActionableStep({ tx, steps, startIndex, ownerId, logger }) {
const skipped = [];
for (let index = startIndex; index < steps.length; index += 1) {
const step = steps[index];
const resolved = await resolveAssigneeFromRule(step.assignee_rule, {
tx,
ownerId,
logger,
});
if (resolved.assigneeId || resolved.assigneeRole) {
return {
actionable: {
index,
step,
assigneeId: resolved.assigneeId,
assigneeRole: resolved.assigneeRole,
},
skipped,
};
}
if (resolved.skipReason) {
skipped.push({
index,
step,
reason: resolved.skipReason,
depthChecked: resolved.depthChecked,
});
continue;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_resolved_assignee" },
});
}
return {
actionable: null,
skipped,
};
}
function getWorkflowStepsSnapshot(workflowInstance) {
const snapshot = workflowInstance?.context_data?.workflow_steps_snapshot;
if (Array.isArray(snapshot) && snapshot.length > 0) {
return parseWorkflowSteps({ steps: snapshot });
}
return parseWorkflowSteps(workflowInstance?.workflow_definition?.definition_json);
}
function toMoneyString(value) {
if (value == null) return null;
return typeof value === "string" ? value : value.toString();
}
module.exports = {
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
parseWorkflowSteps,
resolveActionableStep,
resolveAssigneeFromRule,
resolveDepartmentManagerAssignee,
toMoneyString,
};