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
|
||||
- **后端**:Python 3.10 + FastAPI + SQLAlchemy
|
||||
- **后端**:Python 3.12 + FastAPI + SQLAlchemy
|
||||
- **异步任务**:Celery + Redis
|
||||
- **数据库**:PostgreSQL 18.3
|
||||
- **文档处理**:Aspose.Words (或 Mammoth + python-docx)
|
||||
- **部署**:Docker + Docker Compose
|
||||
- **数据库**:PostgreSQL 16
|
||||
- **文档处理**:Mammoth + python-docx + reportlab
|
||||
- **部署**:Docker + Docker Compose + Nginx
|
||||
## 快速开始
|
||||
### 环境要求
|
||||
- Docker & Docker Compose
|
||||
- Python 3.10+ (本地开发)
|
||||
- Node.js 18+ (本地开发)
|
||||
### 使用 Docker 一键启动
|
||||
- Python 3.12+ (本地开发)
|
||||
- Node.js 22+ (本地开发)
|
||||
### 生产部署(一键启动)
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd doc-forge-reds
|
||||
docker-compose up -d
|
||||
cp .env.example .env # 编辑 SECRET_KEY
|
||||
./deploy.sh # 构建镜像 + 启动 + 数据库迁移
|
||||
```
|
||||
访问 `http://localhost:3000` 进入前端界面。
|
||||
访问 `http://localhost:3000`。
|
||||
### 本地开发
|
||||
1. 启动依赖服务`docker-compose up -d postgres redis`
|
||||
2. 后端`cd backend && python -m venv venv && source venv/bin/activate && pip install -r requirements.txt && uvicorn app.main:app --reload`
|
||||
3. 前端`cd web && npm install && npm run dev`
|
||||
4. Celery Worker`celery -A app.tasks.celery_app worker --loglevel=info`
|
||||
```bash
|
||||
# 1. 启动依赖服务
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# 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.generation_points import router as generation_points_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
|
||||
|
||||
settings = get_settings()
|
||||
|
|
@ -12,3 +13,4 @@ api_router.include_router(models_router)
|
|||
api_router.include_router(templates_router)
|
||||
api_router.include_router(generation_points_router)
|
||||
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.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=["模板管理"])
|
||||
|
||||
|
||||
|
|
@ -78,9 +99,11 @@ async def update_template_html(
|
|||
if not template:
|
||||
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:
|
||||
f.write(docx_bytes)
|
||||
|
||||
|
|
|
|||
|
|
@ -61,4 +61,18 @@
|
|||
- **执行结果**:
|
||||
- 后端 17 个端点 + 前端 TypeScript 编译通过 + Vite 构建成功。
|
||||
- 生产部署:`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 * as settingsApi from "../api/settings";
|
||||
import * as modelApi from "../api/models";
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [models, setModels] = useState<{ label: string; value: string }[]>([]);
|
||||
|
||||
const handleSave = () => {
|
||||
form.validateFields().then(() => {
|
||||
message.success("设置保存成功(此功能为前端演示)");
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
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 (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>系统设置</h2>
|
||||
|
|
@ -17,21 +57,25 @@ export default function Settings() {
|
|||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="default_model" label="全局默认 AI 模型">
|
||||
<Select
|
||||
placeholder="请选择默认模型"
|
||||
placeholder="留空则生成点必须指定模型"
|
||||
allowClear
|
||||
options={[]}
|
||||
disabled
|
||||
notFoundContent="请先在模型管理中创建模型"
|
||||
options={models}
|
||||
notFoundContent="暂无可用模型,请先在模型管理中添加"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="max_concurrency" label="最大并发数" initialValue={3}>
|
||||
<Form.Item name="max_concurrency" label="最大并发数" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} />
|
||||
</Form.Item>
|
||||
<Form.Item name="timeout" label="AI 调用超时(秒)" initialValue={120}>
|
||||
<Form.Item name="timeout" label="AI 调用超时(秒)" rules={[{ required: true }]}>
|
||||
<InputNumber min={10} max={600} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
||||
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
||||
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
MenuOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Editor } from "@tinymce/tinymce-react";
|
||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
||||
|
|
@ -41,6 +42,8 @@ export default function TemplateEditor() {
|
|||
const [generating, setGenerating] = useState(false);
|
||||
const [taskStatus, setTaskStatus] = useState<GenerationTask | 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 () => {
|
||||
if (!id) return;
|
||||
|
|
@ -177,6 +180,37 @@ export default function TemplateEditor() {
|
|||
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) => {
|
||||
try {
|
||||
message.loading({ content: "测试中...", key: "test" });
|
||||
|
|
@ -339,12 +373,20 @@ export default function TemplateEditor() {
|
|||
{points.map((point, index) => {
|
||||
const model = models.find((m) => m.id === point.model_id);
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
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"
|
||||
style={{ marginBottom: 8 }}
|
||||
title={
|
||||
<Space size={4}>
|
||||
<MenuOutlined style={{ color: "#bbb", fontSize: 12, cursor: "grab" }} />
|
||||
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
|
||||
{model && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff" }}>
|
||||
|
|
@ -381,6 +423,7 @@ export default function TemplateEditor() {
|
|||
{point.prompt.length > 80 && "..."}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{points.length === 0 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue