Files
delivery-desk/api/routes/projects.ts
yuzhe b0c498fbb6 feat(auth): 添加认证模块和图片批注功能(项目初始化)
- 实现用户登录、登出、密码修改等认证功能
- 添加会话管理和权限控制中间件
- 创建图片批注组件和相关API路由
- 实现批注的增删改查功能
- 添加Docker和Git忽略配置文件
- 创建系统架构文档和开发约定说明
- 集成认证模块到前端应用路由中
2026-07-21 15:28:55 +08:00

86 lines
8.8 KiB
TypeScript

import { Router, type Response } from 'express';
import { database } from '../database.js';
import { audit, canWriteProject, hashPassword, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import type { Project, WorkCollection } from '../../shared/types.js';
const router = Router();
const reader = requireRole('platform_admin', 'group_admin', 'operator');
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
function projectSelect(where: string) {
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
p.customer_access_enabled, p.access_expires_at,
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
COALESCE(cc.collection_count, 0) AS collection_count,
COALESCE(wc.work_count, 0) AS work_count
FROM projects p
JOIN operation_groups g ON g.id = p.group_id
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
${where}`;
}
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
router.get('/', reader, async (req: AuthRequest, res: Response) => {
const platform = req.authUser?.role === 'platform_admin';
const where = platform ? 'WHERE p.status != ?' : 'WHERE p.group_id = ? AND p.status != ?';
const params = platform ? ['archived'] : [req.authUser?.group_id, 'archived'];
res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson));
});
router.post('/', requireWriter, async (req: AuthRequest, res: Response) => {
const name=String(req.body?.name??'').trim(); const slug=String(req.body?.slug??'').trim().toLowerCase(); const description=String(req.body?.client_description??'').trim();
if(!name||!/^[a-z0-9-]+$/.test(slug)){res.status(400).json({error:'请填写项目名称,项目标识仅支持小写字母、数字和连字符'});return}
if(req.apiKey&&req.apiKey.scope!=='platform'){res.status(403).json({error:'项目级 API Key 不能创建项目'});return}
const groupId=req.authUser?.group_id??Number(req.body?.groupId??req.body?.group_id);
if(!await database.one('SELECT id FROM operation_groups WHERE id = ? AND status = ?', [groupId,'active'])){res.status(400).json({error:'请选择有效的运营组'});return}
let id: number;
try {
id=await database.insertId('INSERT INTO projects (name, slug, client_description, group_id) VALUES (?, ?, ?, ?)',[name,slug,description,groupId]);
} catch {
res.status(409).json({error:'项目标识已存在'});return;
}
await audit(req,'project.create','project',id,{name,slug});
res.status(201).json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.get('/:projectId', reader, async (req: AuthRequest,res:Response)=>{
const id=Number(req.params.projectId); if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const row=await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]); if(!row){res.status(404).json({error:'项目不存在'});return} res.json(projectJson(row));
});
router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'项目名称不能为空'});return}
if(!(await database.execute('UPDATE projects SET name = ?, client_description = ? WHERE id = ?',[name,description,id])).changes){res.status(404).json({error:'项目不存在'});return}
await audit(req,'project.update','project',id,{name});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const rows=await database.all<CollectionRow>(`SELECT c.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
});
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
const enabled=req.body?.enabled===true;const password=String(req.body?.password??'');const expiresAt=String(req.body?.expires_at??'').trim()||null;
const current=await database.one<{access_password_hash:string}>('SELECT access_password_hash FROM projects WHERE id = ?',[id]);if(!current){res.status(404).json({error:'项目不存在'});return}
if(password&&password.length<6){res.status(400).json({error:'客户访问密码至少 6 位'});return}if(enabled&&!password&&!current.access_password_hash){res.status(400).json({error:'启用客户访问前请设置访问密码'});return}if(expiresAt&&!Number.isFinite(new Date(expiresAt).getTime())){res.status(400).json({error:'到期时间格式无效'});return}
await database.execute('UPDATE projects SET customer_access_enabled = ?, access_password_hash = ?, access_expires_at = ? WHERE id = ?',[enabled,password?hashPassword(password):current.access_password_hash,expiresAt,id]);if(!enabled)await database.execute('DELETE FROM customer_sessions WHERE project_id = ?',[id]);
await audit(req,'project.customer_access_update','project',id,{enabled,passwordReset:Boolean(password),expiresAt});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
export default router;