Features - User Management (Phase 4.1): - Database: Add user_modules table for fine-grained module permissions - Database: Add 4 user permissions (view/create/edit/delete) to role_permissions - Backend: UserService (780 lines) - CRUD with tenant isolation - Backend: UserController + UserRoutes (648 lines) - 13 API endpoints - Backend: Batch import users from Excel - Frontend: UserListPage (412 lines) - list/filter/search/pagination - Frontend: UserFormPage (341 lines) - create/edit with module config - Frontend: UserDetailPage (393 lines) - details/tenant/module management - Frontend: 3 modal components (592 lines) - import/assign/configure - API: GET/POST/PUT/DELETE /api/admin/users/* endpoints Architecture Upgrade - Module Permission System: - Backend: Add getUserModules() method in auth.service - Backend: Login API returns modules array in user object - Frontend: AuthContext adds hasModule() method - Frontend: Navigation filters modules based on user.modules - Frontend: RouteGuard checks requiredModule instead of requiredVersion - Frontend: Remove deprecated version-based permission system - UX: Only show accessible modules in navigation (clean UI) - UX: Smart redirect after login (avoid 403 for regular users) Fixes: - Fix UTF-8 encoding corruption in ~100 docs files - Fix pageSize type conversion in userService (String to Number) - Fix authUser undefined error in TopNavigation - Fix login redirect logic with role-based access check - Update Git commit guidelines v1.2 with UTF-8 safety rules Database Changes: - CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled) - ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code) - INSERT 4 permissions + role assignments - UPDATE PUBLIC tenant with 8 module subscriptions Technical: - Backend: 5 new files (~2400 lines) - Frontend: 10 new files (~2500 lines) - Docs: 1 development record + 2 status updates + 1 guideline update - Total: ~4900 lines of code Status: User management 100% complete, module permission system operational
19 KiB
19 KiB
云原生开发规范
文档版本: V1.1
创建日期: 2025-11-16
最后更新: 2025-12-13 🏆 Postgres-Only 架构规范新增
适用对象: 所有开发人员
强制性: ✅ 必须遵守
维护者: 架构团队
📋 文档说明
本文档定义云原生环境(Serverless SAE + RDS + OSS)下的代码规范,所有业务模块(ASL、AIA、PKB等)必须遵守。
阅读时间:10 分钟
检查频率:每次代码提交前
🌟 核心原则:复用平台能力
⭐ 重要提示(2025-11-16 更新):平台已提供完整的基础设施服务
详细文档:平台基础设施规划
平台已提供的服务
业务模块(ASL/AIA/PKB/DC等)应该复用以下平台能力,禁止重复实现:
| 服务 | 导入方式 | 用途 | 文档 |
|---|---|---|---|
| 存储服务 | import { storage } from '@/common/storage' |
文件上传下载 | ✅ 平台级 |
| 日志系统 | import { logger } from '@/common/logging' |
标准化日志 | ✅ 平台级 |
| 异步任务 | import { jobQueue } from '@/common/jobs' |
长时间任务 | ✅ 平台级 |
| 缓存服务 | import { cache } from '@/common/cache' |
分布式缓存 | ✅ 平台级 |
| 🏆 断点续传 | import { CheckpointService } from '@/common/jobs' |
任务断点管理 | ✅ 平台级(新) |
| 数据库 | import { prisma } from '@/config/database' |
数据库操作 | ✅ 平台级 |
| LLM能力 | import { LLMFactory } from '@/common/llm' |
LLM调用 | ✅ 平台级 |
示例:正确使用平台服务
// ✅ 正确:直接导入平台服务
import { storage } from '@/common/storage'
import { logger } from '@/common/logging'
import { jobQueue } from '@/common/jobs'
import { prisma } from '@/config/database'
export class LiteratureService {
async uploadPDF(projectId: string, pdfBuffer: Buffer) {
// 1. 使用平台存储服务
const key = `asl/projects/${projectId}/pdfs/${Date.now()}.pdf`
const url = await storage.upload(key, pdfBuffer)
// 2. 使用平台日志系统
logger.info('PDF uploaded', { projectId, url })
// 3. 使用平台数据库
const literature = await prisma.aslLiterature.create({
data: { projectId, pdfUrl: url }
})
return literature
}
}
❌ 错误:重复实现平台能力
// ❌ 错误:在业务模块中自己实现存储
// backend/src/modules/asl/storage/LocalStorage.ts ← 不应该存在!
export class LocalStorage {
async upload(file: Buffer) {
await fs.writeFile('./uploads/file.pdf', file) // ❌ 重复实现
}
}
// ❌ 错误:在业务模块中自己实现日志
// backend/src/modules/asl/logger/logger.ts ← 不应该存在!
export const logger = winston.createLogger({...}) // ❌ 重复实现
原因:
- ❌ 重复代码,难以维护
- ❌ 不同模块实现不一致
- ❌ 无法统一切换环境(本地/云端)
- ❌ 浪费开发时间
✅ 推荐做法(DO)
1. 文件存储 ✅
// ✅ 正确:使用存储抽象层
import { storage } from '@/common/storage/StorageFactory'
export async function uploadFile(file: Buffer, filename: string) {
const key = `asl/pdfs/${Date.now()}-${filename}`
const url = await storage.upload(key, file)
return url
}
// ✅ 正确:Excel 直接从内存解析
import * as xlsx from 'xlsx'
export async function importExcel(buffer: Buffer) {
const workbook = xlsx.read(buffer, { type: 'buffer' }) // 内存解析
const data = xlsx.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]])
return data
}
理由:
- 容器重启不会丢失文件
- 本地开发和生产环境代码一致
- 自动根据环境变量切换存储方式
2. 数据库连接 ✅
// ✅ 正确:使用全局 Prisma Client
import { prisma } from '@/config/database'
export async function createProject(data: any) {
return await prisma.aslScreeningProject.create({ data })
}
// ✅ 正确:批量操作使用事务
export async function importLiteratures(literatures: any[]) {
return await prisma.$transaction(async (tx) => {
return await tx.aslLiterature.createMany({ data: literatures })
})
}
理由:
- 全局实例复用连接
- 避免连接数耗尽
- 事务保证数据一致性
3. 环境变量配置 ✅
// ✅ 正确:统一配置管理
// backend/src/config/env.ts
export const config = {
llm: {
apiKey: process.env.LLM_API_KEY!,
baseUrl: process.env.LLM_BASE_URL!,
},
oss: {
region: process.env.OSS_REGION!,
bucket: process.env.OSS_BUCKET!,
},
database: {
url: process.env.DATABASE_URL!,
}
}
// ✅ 正确:使用配置对象
import { config } from '@/config/env'
const apiKey = config.llm.apiKey
理由:
- 配置集中管理
- 类型安全
- 便于切换环境
4. 长时间任务处理 ✅
// ✅ 正确:异步任务 + 进度轮询
export async function startScreening(req, res) {
// 1. 创建任务记录
const task = await prisma.aslScreeningTask.create({
data: {
projectId: req.params.projectId,
status: 'pending',
totalItems: 100,
}
})
// 2. 立即返回任务ID
res.send({ success: true, taskId: task.id })
// 3. 后台异步执行(不阻塞请求)
processScreeningAsync(task.id).catch(err => {
console.error('筛选任务失败:', err)
})
}
// 前端轮询进度
export async function getTaskProgress(req, res) {
const task = await prisma.aslScreeningTask.findUnique({
where: { id: req.params.taskId }
})
res.send({
status: task.status,
progress: Math.round((task.completedItems / task.totalItems) * 100)
})
}
理由:
- 避免请求超时(SAE默认30秒)
- 用户体验更好
- 支持批量任务
✨ 完整实践参考: 详见 Postgres-Only异步任务处理指南(基于DC Tool C完整实践)
5. 日志输出 ✅
// ✅ 正确:使用 logger 输出到 stdout
import pino from 'pino'
const logger = pino({
level: process.env.LOG_LEVEL || 'info'
})
export async function uploadFile(req, res) {
logger.info({
userId: req.userId,
filename: req.file.filename
}, 'File uploaded')
// ... 上传逻辑
}
// ✅ 正确:结构化日志
logger.error({
error: err.message,
stack: err.stack,
userId: req.userId
}, 'Upload failed')
理由:
- SAE 自动采集 stdout 日志
- 结构化便于查询分析
- 集中查看,不会丢失
6. 错误处理 ✅
// ✅ 正确:统一错误处理
export async function uploadPdf(req, res) {
try {
const file = await req.file()
const buffer = await file.toBuffer()
const url = await storage.upload(key, buffer)
res.send({ success: true, url })
} catch (error) {
logger.error({ error }, 'PDF upload failed')
res.status(500).send({
success: false,
error: {
code: 'UPLOAD_FAILED',
message: '文件上传失败,请重试'
}
})
}
}
理由:
- 用户看到友好错误信息
- 日志记录详细错误
- 不暴露内部实现
7. 临时文件处理 ✅
// ✅ 正确:/tmp 目录用完立即删除
import fs from 'fs/promises'
import path from 'path'
export async function extractPdfText(ossKey: string): Promise<string> {
const tmpPath = path.join('/tmp', `${Date.now()}.pdf`)
try {
// 1. 从 OSS 下载到 /tmp
await storage.download(ossKey, tmpPath)
// 2. 提取文本
const text = await extractWithNougat(tmpPath)
return text
} finally {
// 3. 立即删除临时文件(无论成功失败)
try {
await fs.unlink(tmpPath)
} catch (err) {
logger.warn({ tmpPath }, 'Failed to delete temp file')
}
}
}
理由:
- /tmp 容量有限(512MB)
- 容器重启会清空
- 避免磁盘占满
🏆 Postgres-Only 架构规范(2025-12-13 新增)
核心理念
Platform-Only 模式:所有任务管理信息统一存储在 platform_schema.job.data,业务表只存储业务信息。
任务管理的正确做法
✅ DO: 使用 job.data 存储任务管理信息
// ✅ 正确:任务拆分和断点信息存储在 job.data
import { jobQueue } from '@/common/jobs';
import { CheckpointService } from '@/common/jobs/CheckpointService';
// 推送任务时,包含完整信息
await jobQueue.push('asl:screening:batch', {
// 业务信息
taskId: 'xxx',
projectId: 'yyy',
literatureIds: [...],
// ✅ 任务拆分信息(存储在 job.data)
batchIndex: 3,
totalBatches: 20,
startIndex: 150,
endIndex: 200,
// ✅ 进度追踪
processedCount: 0,
successCount: 0,
failedCount: 0,
});
// Worker 中使用 CheckpointService
const checkpointService = new CheckpointService(prisma);
// 保存断点到 job.data
await checkpointService.saveCheckpoint(job.id, {
currentBatchIndex: 5,
currentIndex: 250,
processedBatches: 5,
totalBatches: 20
});
// 加载断点从 job.data
const checkpoint = await checkpointService.loadCheckpoint(job.id);
if (checkpoint) {
resumeFrom = checkpoint.currentIndex;
}
❌ DON'T: 在业务表中存储任务管理信息
// ❌ 错误:在业务表的 Schema 中添加任务管理字段
model AslScreeningTask {
id String @id
projectId String
// ❌ 不要添加这些字段!
totalBatches Int // ← 应该在 job.data 中
processedBatches Int // ← 应该在 job.data 中
currentIndex Int // ← 应该在 job.data 中
checkpointData Json? // ← 应该在 job.data 中
}
// ❌ 错误:自己实现断点服务
class MyCheckpointService {
async save(taskId: string) {
await prisma.aslScreeningTask.update({
where: { id: taskId },
data: { checkpointData: {...} } // ❌ 不要这样做!
});
}
}
为什么不对?
- ❌ 每个模块都要添加相同的字段(代码重复)
- ❌ 违反 DRY 原则
- ❌ 违反 3 层架构原则
- ❌ 维护困难(修改逻辑需要改多处)
智能阈值判断的规范
✅ DO: 实现智能双模式处理
const QUEUE_THRESHOLD = 50; // 推荐阈值
export async function startTask(items: any[]) {
const useQueue = items.length >= QUEUE_THRESHOLD;
if (useQueue) {
// 队列模式:大任务(≥50条)
const chunks = splitIntoChunks(items, 50);
for (const chunk of chunks) {
await jobQueue.push('task:batch', {...});
}
} else {
// 直接模式:小任务(<50条)
processDirectly(items); // 快速响应
}
}
为什么这样做?
- ✅ 小任务快速响应(无队列延迟)
- ✅ 大任务高可靠(支持断点续传)
- ✅ 性能与可靠性平衡
❌ DON'T: 所有任务都走队列
// ❌ 错误:即使1条记录也使用队列
export async function startTask(items: any[]) {
// 无论多少数据,都推送到队列
await jobQueue.push('task:batch', items); // ❌ 小任务会有延迟
}
为什么不对?
- ❌ 小任务响应慢(队列有轮询间隔)
- ❌ 浪费队列资源
- ❌ 用户体验差
阈值推荐值
| 任务类型 | 推荐阈值 | 理由 |
|---|---|---|
| 文献筛选 | 50篇 | 单篇 |
| 数据提取 | 50条 | 单条 |
| 统计模型 | 30个 | 单个 |
| 默认 | 50条 | 通用推荐值 |
❌ 禁止做法(DON'T)
1. 本地文件存储 ❌
// ❌ 禁止:本地文件系统存储
import fs from 'fs'
export async function uploadFile(req, res) {
const file = await req.file()
const buffer = await file.toBuffer()
// ❌ 错误:容器重启丢失
fs.writeFileSync('./uploads/file.pdf', buffer)
res.send({ url: '/uploads/file.pdf' })
}
// ❌ 禁止:依赖本地路径
const uploadDir = '/var/app/uploads'
const filePath = path.join(uploadDir, filename)
问题:
- 容器重启或扩容后文件丢失
- 多实例间无法共享文件
- 磁盘空间有限
正确做法:使用 storage.upload() 上传到 OSS
2. 内存缓存 ❌
// ❌ 禁止:内存缓存(多实例不共享)
const cache = new Map<string, any>()
export async function getProject(id: string) {
// ❌ 错误:扩容后其他实例读不到缓存
if (cache.has(id)) {
return cache.get(id)
}
const project = await prisma.aslScreeningProject.findUnique({ where: { id } })
cache.set(id, project)
return project
}
// ❌ 禁止:全局变量存储状态
let taskStatus = {} // 多实例不同步
问题:
- 多实例间数据不同步
- 扩容后缓存失效
- 内存占用不可控
正确做法:使用 Redis 或数据库
3. 硬编码配置 ❌
// ❌ 禁止:硬编码
const LLM_API_KEY = 'sk-xxx'
const DB_HOST = '192.168.1.100'
const OSS_BUCKET = 'my-bucket'
// ❌ 禁止:写死端口
app.listen(3001)
// ❌ 禁止:写死域名
const baseUrl = 'https://api.example.com'
问题:
- 无法切换环境
- 安全风险(密钥泄露)
- 部署困难
正确做法:
// ✅ 使用环境变量
const apiKey = process.env.LLM_API_KEY
const port = process.env.PORT || 3001
app.listen(port)
4. 同步长任务 ❌
// ❌ 禁止:同步处理长任务
export async function screenLiteratures(req, res) {
const literatures = await prisma.aslLiterature.findMany({...})
// ❌ 错误:100篇可能超过30秒
for (const lit of literatures) {
await llmScreening(lit) // 每篇2-3秒
}
res.send({ success: true }) // 可能已经超时
}
// ❌ 禁止:没有超时保护
const result = await axios.get(url) // 可能永久等待
问题:
- SAE 请求超时 30 秒
- 前端等待时间过长
- 无法显示进度
正确做法:异步任务 + 进度轮询(见 DO 第4条)
5. 本地日志文件 ❌
// ❌ 禁止:写入本地文件
import fs from 'fs'
export function logError(error: Error) {
// ❌ 错误:容器重启丢失,无法集中查看
fs.appendFileSync('/var/log/app.log', error.message + '\n')
}
// ❌ 禁止:使用 console.log 而不用 logger
console.log('User logged in') // 无结构化,难以查询
问题:
- 容器重启日志丢失
- 多实例日志分散
- 无法集中分析
正确做法:
// ✅ 输出到 stdout,使用 logger
logger.info({ userId, action: 'login' }, 'User logged in')
6. 新建数据库连接 ❌
// ❌ 禁止:每次请求新建连接
import { PrismaClient } from '@prisma/client'
export async function getProjects(req, res) {
// ❌ 错误:每次新建,连接数暴增
const prisma = new PrismaClient()
const projects = await prisma.aslScreeningProject.findMany()
await prisma.$disconnect()
res.send(projects)
}
// ❌ 禁止:直接使用 pg 或其他驱动
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
问题:
- 连接数快速耗尽(RDS限制 400 连接)
- 性能低下(连接建立耗时)
- 资源浪费
正确做法:
// ✅ 使用全局 Prisma Client
import { prisma } from '@/config/database'
const projects = await prisma.aslScreeningProject.findMany()
7. 忽略错误 ❌
// ❌ 禁止:空的 catch
try {
await storage.upload(key, buffer)
} catch (error) {
// ❌ 错误被吞掉,无法排查
}
// ❌ 禁止:不处理 Promise rejection
processAsync(taskId) // 没有 .catch()
// ❌ 禁止:返回模糊错误
catch (error) {
res.status(500).send({ error: 'Something went wrong' })
// 用户不知道什么错了,如何解决
}
问题:
- 错误无法追踪
- 用户体验差
- 排查困难
正确做法:
// ✅ 记录日志 + 友好错误信息
try {
await storage.upload(key, buffer)
} catch (error) {
logger.error({ error, key }, 'Upload failed')
res.status(500).send({
success: false,
error: {
code: 'UPLOAD_FAILED',
message: '文件上传失败,请检查网络后重试'
}
})
}
🔍 代码审查检查清单
在提交代码前,请逐项检查:
文件存储
- 是否使用
storage.upload()而非fs.writeFile()? - Excel 是否从内存解析,而非保存到本地?
- PDF 提取后是否立即删除临时文件?
数据库
- 是否使用全局
prisma实例? - 是否避免在循环中执行单条查询?(应该批量操作)
- 批量操作是否使用事务?
配置管理
- 是否所有配置都从
process.env读取? - 是否没有硬编码的 IP、域名、密钥?
.env.example是否已更新?
长时间任务
- 超过 10 秒的任务是否改为异步?
- 是否提供了进度查询接口?
- 前端是否有轮询或 WebSocket 获取进度?
日志
- 是否使用
logger而非console.log? - 日志是否结构化(JSON格式)?
- 是否记录了关键操作(userId、action)?
错误处理
- 所有 async 函数是否有 try-catch?
- 是否记录了详细错误日志?
- 是否返回了友好的错误信息?
临时文件
/tmp目录使用后是否立即删除?- 是否在
finally块中清理? - 是否避免长期依赖
/tmp?
🎯 快速自检(5分钟)
运行以下命令,检查代码中是否有违规:
# 检查是否有本地文件存储
grep -r "fs.writeFile\|fs.appendFile" backend/src/modules/
# 检查是否有硬编码配置
grep -r "sk-\|http://\|192.168" backend/src/modules/
# 检查是否有新建 Prisma 连接
grep -r "new PrismaClient" backend/src/modules/
# 检查是否有 console.log
grep -r "console.log" backend/src/modules/
预期结果:所有检查应该返回 0 个匹配
📚 参考文档
📝 更新日志
| 日期 | 版本 | 变更内容 | 维护者 |
|---|---|---|---|
| 2025-11-16 | V1.0 | 创建文档,定义云原生开发规范 | 架构团队 |
文档维护者: 架构团队
最后更新: 2025-11-16
文档状态: ✅ 已完成
强制执行: ✅ 所有代码提交前必须检查