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 行删除
+528
查看文件
@@ -0,0 +1,528 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const { sendData } = require("../utils/reply");
function getHeaderString(request, name) {
const value = request.headers[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function getRequestOrigin(request) {
const origin = getHeaderString(request, "origin");
if (origin) return origin;
const referer = getHeaderString(request, "referer");
if (!referer) return null;
try {
return new URL(referer).origin;
} catch {
return null;
}
}
function refreshCookieOptions(config) {
return {
httpOnly: true,
sameSite: "strict",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: config.refreshTtlSeconds,
};
}
async function authRoutes(fastify) {
const loginBodySchema = z.object({
identifier: z.string().min(3),
password: z.string().min(1),
});
fastify.post("/login", async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { identifier, password } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { OR: [{ phone: identifier }, { email: identifier }] },
select: {
id: true,
status: true,
password_hash: true,
token_version: true,
},
});
if (!employee) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
if (employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const ok = await verifyPassword(password, employee.password_hash);
if (!ok) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employee.id,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({
where: { id: { in: idsToRevoke } },
data: { revoked_at: now },
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/refresh", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken !== "string" || !refreshToken) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: {
id: true,
employee_id: true,
device_fingerprint: true,
expires_at: true,
revoked_at: true,
},
});
if (!session) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.device_fingerprint !== deviceFingerprint) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const expectedOrigin = fastify.config.appOrigin;
const requestOrigin = getRequestOrigin(request);
if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
targetType: "employee",
targetId: session.employee_id,
extra: { expectedOrigin, requestOrigin },
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.expires_at <= now) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.revoked_at) {
const rotatedReplacement = await fastify.prisma.authSession.findFirst({
where: {
employee_id: session.employee_id,
device_fingerprint: session.device_fingerprint,
revoked_at: null,
expires_at: { gt: now },
created_at: { gt: session.revoked_at },
},
select: { id: true },
});
if (rotatedReplacement) {
await fastify.prisma.$transaction(async (tx) => {
await tx.authSession.updateMany({
where: { employee_id: session.employee_id, revoked_at: null },
data: { revoked_at: now },
});
await tx.employee.update({
where: { id: session.employee_id },
data: { token_version: { increment: 1 } },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
});
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: session.employee_id },
select: { id: true, status: true, token_version: true },
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const newRefreshToken = randomRefreshToken();
const newRefreshTokenHash = sha256Hex(newRefreshToken);
const newExpiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.authSession.updateMany({
where: {
id: session.id,
revoked_at: null,
expires_at: { gt: now },
},
data: { revoked_at: now, last_seen_at: now },
});
if (updated.count !== 1) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: newRefreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: newExpiresAt,
},
});
});
reply.setCookie(
fastify.config.refreshCookieName,
newRefreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/logout", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken === "string" && refreshToken) {
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: { id: true, employee_id: true, revoked_at: true },
});
await fastify.prisma.authSession.updateMany({
where: { refresh_token_hash: refreshTokenHash, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
if (session?.employee_id) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_LOGOUT",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
}
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
const forgotPasswordBodySchema = z.object({
email: z.string().email(),
});
fastify.post("/forgot-password", async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
const codeHash = sha256Hex(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
await fastify.prisma.passwordResetCode.create({
data: {
employee_id: employee.id,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: request.ip,
created_user_agent: getHeaderString(request, "user-agent"),
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
if (fastify.config.devShowPasswordResetCode) {
fastify.log.info(
{ employeeId: employee.id, email, code, requestId: request.id },
"DEV password reset code",
);
}
return sendData(reply, request, {});
});
const resetPasswordBodySchema = z.object({
email: z.string().email(),
code: z.string().length(6),
newPassword: z.string().min(12),
});
fastify.post("/reset-password", async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
}
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const now = new Date();
const codeHash = sha256Hex(code);
const resetCode = await fastify.prisma.passwordResetCode.findFirst({
where: {
employee_id: employee.id,
code_hash: codeHash,
used_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!resetCode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
const newPasswordHash = await hashPassword(newPassword);
await fastify.prisma.$transaction(async (tx) => {
await tx.passwordResetCode.update({
where: { id: resetCode.id },
data: { used_at: now },
});
await tx.employee.update({
where: { id: employee.id },
data: {
password_hash: newPasswordHash,
token_version: { increment: 1 },
},
});
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_SUCCEEDED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
}
module.exports = authRoutes;