feat: 补充完善 - 拖拽排序 + 系统设置 + 安全加固 + 文档更新
- 生成点拖拽排序: HTML5 Drag & Drop + batch-order API 持久化 - 系统设置 API: GET/PUT /api/v1/settings,前端接入 system_config 表 - 安全加固: bleach HTML 净化(XSS防护),文件类型白名单,上传大小限制 - 修复 Fernet 密钥: SHA256 派生合法 32 字节密钥 - README 更新: 修正技术栈版本,补充生产/开发部署步骤
This commit is contained in:
parent
c4c2b44434
commit
aeff8753ec
43
README.md
43
README.md
|
|
@ -8,28 +8,41 @@ Doc Forge Reds 是一款外部 Web 工具,支持上传 Word 模板、在线编
|
||||||
- ⚙️ 异步任务生成,状态追踪,结果下载
|
- ⚙️ 异步任务生成,状态追踪,结果下载
|
||||||
## 技术栈
|
## 技术栈
|
||||||
- **前端**:React 18 + TypeScript + Ant Design + TinyMCE
|
- **前端**:React 18 + TypeScript + Ant Design + TinyMCE
|
||||||
- **后端**:Python 3.10 + FastAPI + SQLAlchemy
|
- **后端**:Python 3.12 + FastAPI + SQLAlchemy
|
||||||
- **异步任务**:Celery + Redis
|
- **异步任务**:Celery + Redis
|
||||||
- **数据库**:PostgreSQL 18.3
|
- **数据库**:PostgreSQL 16
|
||||||
- **文档处理**:Aspose.Words (或 Mammoth + python-docx)
|
- **文档处理**:Mammoth + python-docx + reportlab
|
||||||
- **部署**:Docker + Docker Compose
|
- **部署**:Docker + Docker Compose + Nginx
|
||||||
## 快速开始
|
## 快速开始
|
||||||
### 环境要求
|
### 环境要求
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Python 3.10+ (本地开发)
|
- Python 3.12+ (本地开发)
|
||||||
- Node.js 18+ (本地开发)
|
- Node.js 22+ (本地开发)
|
||||||
### 使用 Docker 一键启动
|
### 生产部署(一键启动)
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
cp .env.example .env # 编辑 SECRET_KEY
|
||||||
cd doc-forge-reds
|
./deploy.sh # 构建镜像 + 启动 + 数据库迁移
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
访问 `http://localhost:3000` 进入前端界面。
|
访问 `http://localhost:3000`。
|
||||||
### 本地开发
|
### 本地开发
|
||||||
1. 启动依赖服务`docker-compose up -d postgres redis`
|
```bash
|
||||||
2. 后端`cd backend && python -m venv venv && source venv/bin/activate && pip install -r requirements.txt && uvicorn app.main:app --reload`
|
# 1. 启动依赖服务
|
||||||
3. 前端`cd web && npm install && npm run dev`
|
docker compose up -d postgres redis
|
||||||
4. Celery Worker`celery -A app.tasks.celery_app worker --loglevel=info`
|
|
||||||
|
# 2. 后端
|
||||||
|
cd backend
|
||||||
|
python -m venv venv && source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# 3. Celery Worker(新终端)
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
celery -A app.tasks.celery_app worker --loglevel=info
|
||||||
|
|
||||||
|
# 4. 前端(新终端)
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
```
|
||||||
## 项目目录结构
|
## 项目目录结构
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from app.api.models import router as models_router
|
||||||
from app.api.templates import router as templates_router
|
from app.api.templates import router as templates_router
|
||||||
from app.api.generation_points import router as generation_points_router
|
from app.api.generation_points import router as generation_points_router
|
||||||
from app.api.tasks import router as tasks_router
|
from app.api.tasks import router as tasks_router
|
||||||
|
from app.api.settings import router as settings_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -12,3 +13,4 @@ api_router.include_router(models_router)
|
||||||
api_router.include_router(templates_router)
|
api_router.include_router(templates_router)
|
||||||
api_router.include_router(generation_points_router)
|
api_router.include_router(generation_points_router)
|
||||||
api_router.include_router(tasks_router, prefix="")
|
api_router.include_router(tasks_router, prefix="")
|
||||||
|
api_router.include_router(settings_router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_all_settings(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(SystemConfig))
|
||||||
|
configs = result.scalars().all()
|
||||||
|
return {c.key: c.value for c in configs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key}")
|
||||||
|
async def update_setting(key: str, body: dict, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
value = body.get("value", "")
|
||||||
|
description = body.get("description", "")
|
||||||
|
|
||||||
|
if config:
|
||||||
|
config.value = value
|
||||||
|
if description:
|
||||||
|
config.description = description
|
||||||
|
else:
|
||||||
|
config = SystemConfig(key=key, value=value, description=description)
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return {"key": key, "value": value}
|
||||||
|
|
@ -10,6 +10,27 @@ from app.schemas.template import TemplateResponse, TemplateListItem, HTMLContent
|
||||||
from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR
|
from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR
|
||||||
from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes
|
from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes
|
||||||
|
|
||||||
|
ALLOWED_TAGS = [
|
||||||
|
"p", "div", "span", "br", "hr",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"a", "img", "table", "thead", "tbody", "tr", "td", "th",
|
||||||
|
"b", "i", "u", "strong", "em", "del", "sub", "sup",
|
||||||
|
"pre", "code", "blockquote",
|
||||||
|
]
|
||||||
|
ALLOWED_ATTRS = {
|
||||||
|
"a": ["href", "title", "target"],
|
||||||
|
"img": ["src", "alt", "width", "height"],
|
||||||
|
"td": ["colspan", "rowspan"],
|
||||||
|
"th": ["colspan", "rowspan"],
|
||||||
|
"p": ["style"],
|
||||||
|
"span": ["style"],
|
||||||
|
"div": ["style"],
|
||||||
|
"table": ["style"],
|
||||||
|
}
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
|
||||||
router = APIRouter(prefix="/templates", tags=["模板管理"])
|
router = APIRouter(prefix="/templates", tags=["模板管理"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -78,9 +99,11 @@ async def update_template_html(
|
||||||
if not template:
|
if not template:
|
||||||
raise HTTPException(status_code=404, detail="模板不存在")
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
template.html_content = data.html_content
|
template.html_content = bleach.clean(
|
||||||
|
data.html_content, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True
|
||||||
|
)
|
||||||
|
|
||||||
docx_bytes = html_to_docx_bytes(data.html_content)
|
docx_bytes = html_to_docx_bytes(template.html_content)
|
||||||
with open(template.file_path, "wb") as f:
|
with open(template.file_path, "wb") as f:
|
||||||
f.write(docx_bytes)
|
f.write(docx_bytes)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,18 @@
|
||||||
- **执行结果**:
|
- **执行结果**:
|
||||||
- 后端 17 个端点 + 前端 TypeScript 编译通过 + Vite 构建成功。
|
- 后端 17 个端点 + 前端 TypeScript 编译通过 + Vite 构建成功。
|
||||||
- 生产部署:`docker compose up -d` 即可一键启动 5 个服务。
|
- 生产部署:`docker compose up -d` 即可一键启动 5 个服务。
|
||||||
- Git 提交:1 次提交(017a195)。总计 11 次提交。
|
- Git 提交:1 次提交(017a195)。总计 14 次提交。
|
||||||
|
|
||||||
|
## 会话 ID: 4
|
||||||
|
- [2026-07-06 17:10]
|
||||||
|
- **执行原因**: 补充完善剩余任务(拖拽排序、系统设置、安全加固、Celery验证、文档)
|
||||||
|
- **执行过程**:
|
||||||
|
1. Celery Worker 启动验证:连接 Redis,`generate_document` 任务已注册并可通过 inspector 检测。
|
||||||
|
2. 生成点拖拽排序:HTML5 原生 Drag & Drop + `MenuOutlined` 拖拽手柄,拖拽后自动调用 `/batch-order` API。
|
||||||
|
3. 系统设置 API:新增 `/api/v1/settings` GET/PUT,前端 Settings 实时加载模型列表并持久化配置。
|
||||||
|
4. 安全加固:`bleach` HTML 净化过滤 script/onerror 等危险标签,文件类型白名单校验,请求大小限制中间件。
|
||||||
|
5. 修复 Fernet 加密:SHA256(SECRET_KEY) → base64url 派生合法 32 字节密钥。
|
||||||
|
6. 更新 README:修正技术栈版本,补充生产部署 + 本地开发完整步骤。
|
||||||
|
- **执行结果**:
|
||||||
|
- API 端点 19 个,所有核心功能模块完成。
|
||||||
|
- 可通过 http://localhost:3000 进行端到端测试。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import request from "./request";
|
||||||
|
|
||||||
|
export async function getAllSettings() {
|
||||||
|
const { data } = await request.get<Record<string, string>>("/settings");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, value: string, description?: string) {
|
||||||
|
await request.put(`/settings/${key}`, { value, description });
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,55 @@
|
||||||
import { Card, Form, Select, InputNumber, Button, Space, message, Divider } from "antd";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, Form, Select, InputNumber, Button, message, Spin } from "antd";
|
||||||
import { SaveOutlined } from "@ant-design/icons";
|
import { SaveOutlined } from "@ant-design/icons";
|
||||||
|
import * as settingsApi from "../api/settings";
|
||||||
|
import * as modelApi from "../api/models";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [models, setModels] = useState<{ label: string; value: string }[]>([]);
|
||||||
|
|
||||||
const handleSave = () => {
|
useEffect(() => {
|
||||||
form.validateFields().then(() => {
|
(async () => {
|
||||||
message.success("设置保存成功(此功能为前端演示)");
|
try {
|
||||||
|
const [settings, modelList] = await Promise.all([
|
||||||
|
settingsApi.getAllSettings(),
|
||||||
|
modelApi.listModels({ enabled: true }),
|
||||||
|
]);
|
||||||
|
form.setFieldsValue({
|
||||||
|
default_model: settings.default_model || undefined,
|
||||||
|
max_concurrency: parseInt(settings.max_concurrency || "3", 10),
|
||||||
|
timeout: parseInt(settings.timeout || "120", 10),
|
||||||
});
|
});
|
||||||
|
setModels(modelList.map((m) => ({ label: m.name, value: m.id })));
|
||||||
|
} catch {
|
||||||
|
message.error("加载设置失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await Promise.all([
|
||||||
|
settingsApi.updateSetting("default_model", values.default_model || ""),
|
||||||
|
settingsApi.updateSetting("max_concurrency", String(values.max_concurrency)),
|
||||||
|
settingsApi.updateSetting("timeout", String(values.timeout)),
|
||||||
|
]);
|
||||||
|
message.success("设置保存成功");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error) message.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>系统设置</h2>
|
<h2 style={{ marginBottom: 16 }}>系统设置</h2>
|
||||||
|
|
@ -17,21 +57,25 @@ export default function Settings() {
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="default_model" label="全局默认 AI 模型">
|
<Form.Item name="default_model" label="全局默认 AI 模型">
|
||||||
<Select
|
<Select
|
||||||
placeholder="请选择默认模型"
|
placeholder="留空则生成点必须指定模型"
|
||||||
allowClear
|
allowClear
|
||||||
options={[]}
|
options={models}
|
||||||
disabled
|
notFoundContent="暂无可用模型,请先在模型管理中添加"
|
||||||
notFoundContent="请先在模型管理中创建模型"
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="max_concurrency" label="最大并发数" initialValue={3}>
|
<Form.Item name="max_concurrency" label="最大并发数" rules={[{ required: true }]}>
|
||||||
<InputNumber min={1} max={10} />
|
<InputNumber min={1} max={10} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="timeout" label="AI 调用超时(秒)" initialValue={120}>
|
<Form.Item name="timeout" label="AI 调用超时(秒)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={10} max={600} />
|
<InputNumber min={10} max={600} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
保存设置
|
保存设置
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
||||||
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
||||||
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||||
|
MenuOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Editor } from "@tinymce/tinymce-react";
|
import { Editor } from "@tinymce/tinymce-react";
|
||||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
import type { Editor as TinyMCEEditor } from "tinymce";
|
||||||
|
|
@ -41,6 +42,8 @@ export default function TemplateEditor() {
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [taskStatus, setTaskStatus] = useState<GenerationTask | null>(null);
|
const [taskStatus, setTaskStatus] = useState<GenerationTask | null>(null);
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const dragItem = useRef<number | null>(null);
|
||||||
|
const dragOverItem = useRef<number | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
@ -177,6 +180,37 @@ export default function TemplateEditor() {
|
||||||
setPoints(updated);
|
setPoints(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (_e: React.DragEvent, index: number) => {
|
||||||
|
dragItem.current = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (_e: React.DragEvent, index: number) => {
|
||||||
|
dragOverItem.current = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async () => {
|
||||||
|
if (dragItem.current === null || dragOverItem.current === null) return;
|
||||||
|
if (dragItem.current === dragOverItem.current) return;
|
||||||
|
|
||||||
|
const newPoints = [...points];
|
||||||
|
const [moved] = newPoints.splice(dragItem.current, 1);
|
||||||
|
newPoints.splice(dragOverItem.current!, 0, moved);
|
||||||
|
|
||||||
|
const reordered = newPoints.map((p, i) => ({ ...p, order: i }));
|
||||||
|
setPoints(reordered);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pointApi.batchUpdateOrder(
|
||||||
|
reordered.map((p) => ({ id: p.id, order: p.order }))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
message.error("排序更新失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
dragItem.current = null;
|
||||||
|
dragOverItem.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestPoint = async (pointId: string) => {
|
const handleTestPoint = async (pointId: string) => {
|
||||||
try {
|
try {
|
||||||
message.loading({ content: "测试中...", key: "test" });
|
message.loading({ content: "测试中...", key: "test" });
|
||||||
|
|
@ -339,12 +373,20 @@ export default function TemplateEditor() {
|
||||||
{points.map((point, index) => {
|
{points.map((point, index) => {
|
||||||
const model = models.find((m) => m.id === point.model_id);
|
const model = models.find((m) => m.id === point.model_id);
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
key={point.id}
|
key={point.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragEnter={(e) => handleDragEnter(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
style={{ marginBottom: 8, cursor: "grab" }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
title={
|
title={
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
<MenuOutlined style={{ color: "#bbb", fontSize: 12, cursor: "grab" }} />
|
||||||
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
|
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
|
||||||
{model && (
|
{model && (
|
||||||
<span style={{ fontSize: 12, color: "#1677ff" }}>
|
<span style={{ fontSize: 12, color: "#1677ff" }}>
|
||||||
|
|
@ -381,6 +423,7 @@ export default function TemplateEditor() {
|
||||||
{point.prompt.length > 80 && "..."}
|
{point.prompt.length > 80 && "..."}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{points.length === 0 && (
|
{points.length === 0 && (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue