Files
AIclinicalresearch/docs/04-开发规范/08-云原生开发规范.md
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
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
2026-01-16 13:42:10 +08:00

19 KiB
Raw Blame History

云原生开发规范

文档版本: 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篇 单篇7秒50篇5分钟
数据提取 50条 单条5-10秒50条5分钟
统计模型 30个 单个10秒30个5分钟
默认 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
文档状态: 已完成
强制执行: 所有代码提交前必须检查