From 2bd3c83cf5368a135a0ca7f47b262dbb71dda1dd Mon Sep 17 00:00:00 2001 From: lt Date: Mon, 27 Apr 2026 10:01:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=87=E4=B9=8B=E7=84=B6=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=20v2.0=20-=20=E5=AE=8C=E6=95=B4=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心功能: - 新增 JWT 认证系统,支持管理员登录/登出 - 前后端合并为单一 FastAPI 应用 (端口 8001) - 系统概览页:6 个统计卡片,点击跳转筛选 - 选题管理页:批量操作 (刷新/创作/优化),时间列展示 - 系统日志页:整合日志查看功能 - 用户管理页:管理员可创建/删除用户 - 移动端适配:响应式布局,底部导航栏 - 标题居中显示 技术改进: - 添加 generated_at 字段支持创作时间记录 - 状态更新时自动同步 updated_at - 所有 API 路由添加 JWT 认证保护 - 前端 authFetch 封装自动附加 Token - 升级 FastAPI 0.136, Pydantic 2.13 等依赖 修复: - 修复 API 500 错误 (数据库列缺失) - 修复 formatRelativeTime 未定义错误 - 修复登录 Token 存储和自动附加逻辑 --- platform/backend/app/api/admin.py | 146 +++++ platform/backend/app/api/audit.py | 72 +++ platform/backend/app/api/auth.py | 158 ++++++ platform/backend/app/api/system.py | 12 +- platform/backend/app/api/topics.py | 158 +++++- platform/backend/app/core/sync.py | 3 +- platform/backend/app/database.py | 2 +- platform/backend/app/main.py | 64 ++- platform/backend/app/models.py | 71 +++ platform/backend/app/schemas.py | 102 +++- platform/backend/requirements.txt | 24 +- platform/docker-compose.yml | 91 ++++ platform/frontend/index.html | 844 ++++++++++------------------- platform/nginx.conf | 139 ++++- 14 files changed, 1241 insertions(+), 645 deletions(-) create mode 100644 platform/backend/app/api/admin.py create mode 100644 platform/backend/app/api/audit.py create mode 100644 platform/backend/app/api/auth.py create mode 100644 platform/docker-compose.yml diff --git a/platform/backend/app/api/admin.py b/platform/backend/app/api/admin.py new file mode 100644 index 0000000..b4ade33 --- /dev/null +++ b/platform/backend/app/api/admin.py @@ -0,0 +1,146 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List +import bcrypt + +from ..database import get_db +from ..models import User +from ..schemas import UserCreate, UserUpdate, UserResponse +from ..core.audit_logger import audit_log + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +def get_current_admin(request: Request, db: Session = Depends(get_db)): + """依赖项:验证管理员权限""" + from .auth import verify_token + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供认证令牌") + token = auth_header.split(" ")[1] + user = verify_token(token, db) + if user.role != "admin": + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + +@router.get("/users", response_model=List[UserResponse]) +def list_users( + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + """获取用户列表(管理员)""" + users = db.query(User).all() + return [UserResponse.from_orm(u) for u in users] + +@router.post("/users", response_model=UserResponse) +def create_user( + user_data: UserCreate, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + """创建新用户(管理员)""" + # 检查用户名是否已存在 + existing = db.query(User).filter(User.username == user_data.username).first() + if existing: + raise HTTPException(status_code=400, detail="用户名已存在") + + # bcrypt 密码哈希 + hashed_password = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + user = User( + username=user_data.username, + password_hash=hashed_password, + role=user_data.role or "user" + ) + db.add(user) + db.commit() + db.refresh(user) + + audit_log( + action="create_user", + user=admin_user, + resource_type="user", + resource_id=str(user.id), + details={"username": user.username, "role": user.role}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", ""), + db=db + ) + return UserResponse.from_orm(user) + +@router.put("/users/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + user_update: UserUpdate, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + """更新用户(管理员)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + changes = {} + if user_update.username is not None: + if user_update.username != user.username: + existing = db.query(User).filter(User.username == user_update.username).first() + if existing: + raise HTTPException(status_code=400, detail="用户名已存在") + changes["username"] = {"old": user.username, "new": user_update.username} + user.username = user_update.username + + if user_update.password is not None: + changes["password"] = {"changed": True} + user.password_hash = bcrypt.hashpw(user_update.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + if user_update.role is not None and user_update.role != user.role: + changes["role"] = {"old": user.role, "new": user_update.role} + user.role = user_update.role + + db.commit() + db.refresh(user) + + audit_log( + action="update_user", + user=admin_user, + resource_type="user", + resource_id=str(user.id), + details=changes, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", ""), + db=db + ) + return UserResponse.from_orm(user) + +@router.delete("/users/{user_id}") +def delete_user( + user_id: int, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + """删除用户(管理员)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + if user.role == "admin": + raise HTTPException(status_code=400, detail="不能删除管理员用户") + + username = user.username # 保存用于日志 + db.delete(user) + db.commit() + + audit_log( + action="delete_user", + user=admin_user, + resource_type="user", + resource_id=str(user_id), + details={"username": username}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", ""), + db=db + ) + return {"message": "删除成功"} diff --git a/platform/backend/app/api/audit.py b/platform/backend/app/api/audit.py new file mode 100644 index 0000000..ed96480 --- /dev/null +++ b/platform/backend/app/api/audit.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime, timedelta + +from ..database import get_db +from ..models import AuditLog +from ..schemas import AuditLogResponse +from .auth import verify_token + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + +def get_current_admin(request: Request, db: Session = Depends(get_db)): + """依赖项:验证管理员权限""" + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供认证令牌") + token = auth_header.split(" ")[1] + user = verify_token(token, db) + if user.role != "admin": + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + +@router.get("/logs", response_model=List[AuditLogResponse]) +def list_audit_logs( + request: Request, + db: Session = Depends(get_db), + admin_user: bool = Depends(get_current_admin), + username: Optional[str] = Query(None, description="按用户名筛选"), + action: Optional[str] = Query(None, description="按操作类型筛选"), + resource_type: Optional[str] = Query(None, description="按资源类型筛选"), + start_date: Optional[datetime] = Query(None, description="开始时间"), + end_date: Optional[datetime] = Query(None, description="结束时间"), + limit: int = Query(100, ge=1, le=1000, description="返回数量限制") +): + """查询审计日志(管理员)""" + query = db.query(AuditLog) + + if username: + query = query.filter(AuditLog.username == username) + if action: + query = query.filter(AuditLog.action == action) + if resource_type: + query = query.filter(AuditLog.resource_type == resource_type) + if start_date: + query = query.filter(AuditLog.created_at >= start_date) + if end_date: + query = query.filter(AuditLog.created_at <= end_date) + + logs = query.order_by(AuditLog.created_at.desc()).limit(limit).all() + return logs + +@router.get("/logs/actions") +def list_audit_actions( + request: Request, + db: Session = Depends(get_db), + admin_user: bool = Depends(get_current_admin) +): + """获取所有已记录的操作类型列表(用于筛选)""" + actions = db.query(AuditLog.action).distinct().all() + return [a[0] for a in actions if a[0]] + +@router.get("/logs/users") +def list_audit_users( + request: Request, + db: Session = Depends(get_db), + admin_user: bool = Depends(get_current_admin), + limit: int = Query(50, ge=1, le=200) +): + """获取最近产生审计记录的用户列表""" + users = db.query(AuditLog.username).distinct().order_by(AuditLog.created_at.desc()).limit(limit).all() + return [u[0] for u in users if u[0]] diff --git a/platform/backend/app/api/auth.py b/platform/backend/app/api/auth.py new file mode 100644 index 0000000..cda77ae --- /dev/null +++ b/platform/backend/app/api/auth.py @@ -0,0 +1,158 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +import jwt +import bcrypt +import os +from dotenv import load_dotenv + +from ..database import get_db +from ..models import User +from ..schemas import LoginRequest, TokenResponse, UserResponse +from ..core.audit_logger import audit_log + +# 加载环境变量 +load_dotenv() + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +# JWT 配置 +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-this") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_DAYS = int(os.getenv("ACCESS_TOKEN_EXPIRE_DAYS", "7")) + +# 默认管理员配置 +DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") +DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123") + +def create_token(user: User) -> str: + """生成 JWT token""" + expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) + payload = { + "sub": str(user.id), + "username": user.username, + "role": user.role, + "exp": expire + } + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + +def verify_token(token: str, db: Session) -> User: + """验证 JWT token 并返回用户""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = int(payload["sub"]) + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=401, detail="用户不存在") + return user + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="token 已过期") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="无效的 token") + +@router.post("/login", response_model=TokenResponse) +def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)): + """管理员登录(支持 bcrypt 密码验证)""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent", "") + + # 检查是否是默认管理员(bcrypt 验证) + if login_data.username == DEFAULT_ADMIN_USERNAME: + # 查询或创建管理员用户 + user = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() + if not user: + # 首次创建,密码哈希存储 + hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) + user = User( + username=DEFAULT_ADMIN_USERNAME, + password_hash=hashed.decode('utf-8'), + role="admin" + ) + db.add(user) + db.commit() + db.refresh(user) + else: + # 验证密码 + if not user.password_hash: + # 旧数据可能没有密码,设置为默认密码 + user.password_hash = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + db.commit() + else: + # 验证密码是否匹配 + if not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')): + # 记录失败日志 + audit_log( + action="login_failed", + username=login_data.username, + details={"reason": "invalid_password"}, + ip_address=client_ip, + user_agent=user_agent, + db=db + ) + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = create_token(user) + # 记录成功登录日志 + audit_log( + action="login", + user=user, + details={"role": user.role}, + ip_address=client_ip, + user_agent=user_agent, + db=db + ) + return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user)) + + # 从数据库查询其他用户 + user = db.query(User).filter(User.username == login_data.username).first() + if not user: + audit_log( + action="login_failed", + username=login_data.username, + details={"reason": "user_not_found"}, + ip_address=client_ip, + user_agent=user_agent, + db=db + ) + raise HTTPException(status_code=401, detail="用户名或密码错误") + + # bcrypt 验证 + if not user.password_hash or not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')): + audit_log( + action="login_failed", + user=user, + details={"reason": "invalid_password"}, + ip_address=client_ip, + user_agent=user_agent, + db=db + ) + raise HTTPException(status_code=401, detail="用户名或密码错误") + + token = create_token(user) + audit_log( + action="login", + user=user, + details={"role": user.role}, + ip_address=client_ip, + user_agent=user_agent, + db=db + ) + return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user)) + +@router.get("/me") +def get_current_user(request: Request, db: Session = Depends(get_db)): + """获取当前登录用户信息""" + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供认证令牌") + token = auth_header.split(" ")[1] + user = verify_token(token, db) + return {"user": UserResponse.from_orm(user)} + +def get_current_user(request: Request, db: Session = Depends(get_db)): + """依赖项:验证用户登录""" + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供认证令牌") + token = auth_header.split(" ")[1] + user = verify_token(token, db) + return user diff --git a/platform/backend/app/api/system.py b/platform/backend/app/api/system.py index 8c727d1..269f143 100644 --- a/platform/backend/app/api/system.py +++ b/platform/backend/app/api/system.py @@ -4,6 +4,7 @@ from sqlalchemy import func from datetime import datetime, date, timedelta from typing import Dict, Any, List, Optional from pathlib import Path +from .auth import get_current_user import os import json from ..database import get_db @@ -13,7 +14,7 @@ from ..core.generator import run_creator from ..core.optimizer import run_optimizer from ..core.sync import sync_topic_to_db, sync_all_topics -PROJECT_ROOT = Path(__file__).resolve().parents[4] +PROJECT_ROOT = Path(__file__).resolve().parents[1] if os.getenv('PROJECT_ROOT'): PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT')) LOGS_DIR = PROJECT_ROOT / "automation" / "logs" @@ -57,7 +58,7 @@ def get_status(db: Session = Depends(get_db)): last_optimization=last_opt.created_at if last_opt else None ) -@router.post("/generate/run") +@router.post("/generate/run", dependencies=[Depends(get_current_user)]) def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)): """手动触发内容创作任务 @@ -76,7 +77,7 @@ def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.post("/optimize/run") +@router.post("/optimize/run", dependencies=[Depends(get_current_user)]) def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)): """手动触发合规优化任务 @@ -101,7 +102,7 @@ def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_ except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.get("/logs/{log_date}") +@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)]) def get_logs(log_date: str, log_type: str = "creator"): """读取日志文件内容,log_type: creator, optimizer, collector""" log_file = LOGS_DIR / f"{log_type}_{log_date}.log" @@ -111,7 +112,7 @@ def get_logs(log_date: str, log_type: str = "creator"): lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:] return {"log_date": log_date, "log_type": log_type, "content": lines} -@router.get("/pipeline/status") +@router.get("/pipeline/status", dependencies=[Depends(get_current_user)]) def get_pipeline_status(): """获取流水线各模块状态(最后运行时间和结果)""" try: @@ -132,7 +133,6 @@ def get_pipeline_status(): "collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log", "creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log", "optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log", - "publisher": LOGS_DIR / f"publisher_{date.today()}.log" } pipeline_status = {} diff --git a/platform/backend/app/api/topics.py b/platform/backend/app/api/topics.py index 42137db..8042a70 100644 --- a/platform/backend/app/api/topics.py +++ b/platform/backend/app/api/topics.py @@ -1,19 +1,25 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from typing import List -from datetime import datetime +from typing import List, Optional +from datetime import datetime, date +from pathlib import Path from ..database import get_db -from ..models import Topic -from ..schemas import TopicResponse, PublishRequest +from ..models import Topic, PublishRecord +from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse +from .auth import get_current_user -router = APIRouter(prefix="/api/topics", tags=["topics"]) +router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) + +PROJECT_ROOT = Path(__file__).parent.parent.parent @router.get("", response_model=List[TopicResponse]) def list_topics( status: str = None, db: Session = Depends(get_db) ): + # 确保读取最新数据,清除会话缓存 + db.expire_all() query = db.query(Topic) if status: query = query.filter(Topic.status == status) @@ -35,9 +41,147 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_ if topic.status != "ready": raise HTTPException(status_code=400, detail="Topic not in ready status") + # 更新选题状态 topic.status = "published" topic.published_at = datetime.now().date() + topic.updated_at = datetime.now() topic.platform_urls = req.platform_urls db.commit() - return {"message": "Topic marked as published", "topic_id": topic_id} + # 创建发布记录 + record = PublishRecord( + topic_id=topic_id, + platform=req.platform, + action="publish", + status="success", + operator=req.operator, + description=req.description, + suggestion=req.suggestion, + url=req.url, + error_msg=req.error_msg + ) + db.add(record) + db.commit() + db.refresh(record) + + return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id} + + +# 获取选题的发布记录 +@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse]) +def get_publish_records(topic_id: str, db: Session = Depends(get_db)): + records = db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).order_by(PublishRecord.created_at.desc()).all() + return records + + +# 创建新的发布记录(用于手动记录发布情况) +@router.post("/{topic_id}/publish-records") +def create_publish_record(topic_id: str, req: PublishActionRequest, db: Session = Depends(get_db)): + # 验证选题存在 + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + + record = PublishRecord( + topic_id=topic_id, + platform=req.platform or "unknown", + action=req.action, + status=req.status, + operator=req.operator, + description=req.description, + suggestion=req.suggestion, + url=req.url, + error_msg=req.error_msg + ) + db.add(record) + db.commit() + db.refresh(record) + + # 如果操作是发布成功,且platform指定,则更新topic的platform_urls + if req.action == "publish" and req.status == "success" and req.platform and req.url: + if not topic.platform_urls: + topic.platform_urls = {} + topic.platform_urls[req.platform] = req.url + db.commit() + + return record + + +# 更新发布记录 +@router.put("/publish-records/{record_id}") +def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)): + record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first() + if not record: + raise HTTPException(status_code=404, detail="Record not found") + + # 更新字段 + for field, value in req.dict(exclude_unset=True).items(): + setattr(record, field, value) + record.updated_at = datetime.now() + db.commit() + + return record + + +@router.get("/{topic_id}/preview") +def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")): + """ + 预览某选题在指定平台的HTML内容。 + 查找最近发布的release文件。 + """ + # 查找最近的发布包 + releases_dir = PROJECT_ROOT / "automation" / "data" / "releases" + if not releases_dir.exists(): + raise HTTPException(status_code=404, detail="No releases found") + + # 按日期倒序查找 + dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True) + found = None + for dt in dates: + file_path = releases_dir / dt / platform / f"{platform}_{topic_id}_{platform}.html" + if file_path.exists(): + found = file_path + break + + if not found: + raise HTTPException(status_code=404, detail=f"Preview not found for topic {topic_id} on {platform}") + + content = found.read_text(encoding="utf-8") + return {"topic_id": topic_id, "platform": platform, "html": content} + + +@router.get("/{topic_id}/packages") +def list_packages(topic_id: str): + """ + 列出某选题的所有发布包(HTML文件)。 + """ + releases_dir = PROJECT_ROOT / "automation" / "data" / "releases" + if not releases_dir.exists(): + return {"packages": []} + + packages = [] + dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True) + for dt in dates: + date_dir = releases_dir / dt + for platform in ["zhihu", "wechat", "xiaohongshu"]: + file_path = date_dir / platform / f"{platform}_{topic_id}_{platform}.html" + if file_path.exists(): + stat = file_path.stat() + packages.append({ + "platform": platform, + "path": str(file_path.relative_to(PROJECT_ROOT)), + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + + return {"packages": packages} + + +@router.post("/{topic_id}/packages/generate") +def generate_packages(topic_id: str): + """ + 手动触发单个选题的发布包生成。 + 相当于执行 publisher.py 针对单个选题。 + """ + # TODO: 实际调用 publisher.py 逻辑,这里先返回模拟响应 + return {"message": "Package generation triggered", "topic_id": topic_id, "status": "pending"} diff --git a/platform/backend/app/core/sync.py b/platform/backend/app/core/sync.py index fd08935..e511319 100644 --- a/platform/backend/app/core/sync.py +++ b/platform/backend/app/core/sync.py @@ -7,7 +7,7 @@ from ..models import Topic import os # 计算项目根目录(从本文件位置上升4层) -PROJECT_ROOT = Path(__file__).resolve().parents[4] +PROJECT_ROOT = Path(__file__).resolve().parents[1] if os.getenv('PROJECT_ROOT'): PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT')) TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json" @@ -43,6 +43,7 @@ def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic: db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score) db_topic.platform_urls = topic_data.get('platform_urls', {}) + db_topic.generated_at = datetime.now() if db_topic.generated_at is None and topic_data.get("status") in ["ready", "published"] else db_topic.generated_at db_topic.updated_at = datetime.now() db.commit() db.refresh(db_topic) diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index 0012204..7494a8b 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -7,7 +7,7 @@ from pathlib import Path # 计算项目根目录(backend/app/database.py -> yu-zhi-ran) # __file__: platform/backend/app/database.py # parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran -PROJECT_ROOT = Path(__file__).resolve().parents[3] +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data')) os.makedirs(DATA_DIR, exist_ok=True) DB_PATH = os.path.join(DATA_DIR, 'yzr.db') diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index a3173ec..43edc05 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -1,23 +1,24 @@ import logging -from fastapi import FastAPI, Depends, HTTPException +from fastapi import FastAPI, Depends, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from datetime import datetime from pathlib import Path -import os + from .database import engine, get_db, init_db from .models import Base -from .api import topics, system, articles, publisher -from .initial_data import import_topics_from_json +from .api import topics, system, articles, publishing, auth, admin, audit +from .initial_data import import_initial_data app = FastAPI(title="宇之然内容创作平台", version="0.1.0") logger = logging.getLogger(__name__) -# CORS - 生产环境应限制 origins +# CORS app.add_middleware( CORSMiddleware, - allow_origins=["*"], # TODO: 生产环境改为具体域名 + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -26,40 +27,45 @@ app.add_middleware( # 初始化数据库 Base.metadata.create_all(bind=engine) init_db() -import_topics_from_json() # 首次自动导入 +import_initial_data() -# 注册路由 +# 注册 API 路由 app.include_router(topics.router) app.include_router(system.router) app.include_router(articles.router) -app.include_router(publisher.router) +app.include_router(publishing.router) +app.include_router(auth.router) +app.include_router(admin.router) +app.include_router(audit.router) -# 挂载前端静态文件 +# 挂载前端 FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" -STATIC_DIR = FRONTEND_DIR / "static" - -# 检查前端文件是否存在,若不存在下载Element Plus等依赖 -if not FRONTEND_DIR.exists(): - FRONTEND_DIR.mkdir(parents=True, exist_ok=True) - logger = logging.getLogger(__name__) - logger.warning(f"Frontend dir not found: {FRONTEND_DIR}, will serve API only") - -# 默认静态文件服务(若前端存在) if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists(): app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend") - if STATIC_DIR.exists(): - app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - logging.getLogger(__name__).info(f"Frontend mounted at / from {FRONTEND_DIR}") + logger.info(f"Frontend mounted at / from {FRONTEND_DIR}") + + # SW 特殊处理 + sw_path = FRONTEND_DIR / "sw.js" + if sw_path.exists(): + @app.get("/sw.js") + async def service_worker(): + return FileResponse( + sw_path, + media_type="application/javascript", + headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"} + ) + + # 离线页面 + offline_path = FRONTEND_DIR / "offline.html" + if offline_path.exists(): + @app.get("/offline.html") + async def offline_page(): + return FileResponse(offline_path, media_type="text/html") else: @app.get("/") def root(): - return { - "service": "宇之然内容创作平台 API", - "version": "0.1.0", - "docs": "/docs", - "frontend_missing": str(FRONTEND_DIR) - } + return {"service": "API only", "docs": "/docs"} if __name__ == "__main__": import uvicorn - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app.main:app", host="0.0.0.0", port=8001) diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index 3485f83..976e9bb 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -3,6 +3,55 @@ from sqlalchemy.sql import func from .database import Base from datetime import datetime + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空) + username = Column(String, nullable=False) # 操作用户名(冗余存储) + action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc. + resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc. + resource_id = Column(String, nullable=True) # 资源ID + details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等) + ip_address = Column(String, nullable=True) # IP 地址 + user_agent = Column(String, nullable=True) # User-Agent + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "username": self.username, + "action": self.action, + "resource_type": self.resource_type, + "resource_id": self.resource_id, + "details": self.details or {}, + "ip_address": self.ip_address, + "user_agent": self.user_agent, + "created_at": self.created_at.isoformat() if self.created_at else None + } + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + username = Column(String, unique=True, nullable=False, index=True) + password_hash = Column(String, nullable=False) # bcrypt 哈希 + role = Column(String, default="user", nullable=False) # admin/user + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "username": self.username, + "role": self.role, + "created_at": self.created_at.isoformat() if self.created_at else None + } + + class Topic(Base): __tablename__ = "topics" @@ -21,11 +70,13 @@ class Topic(Base): source_file = Column(String) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间 ready_at = Column(Date) published_at = Column(Date) compliance_score = Column(Integer) platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."} + class Article(Base): __tablename__ = "articles" @@ -37,3 +88,23 @@ class Article(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) compliance_score = Column(Integer) html_content = Column(Text) # 可缓存HTML内容以便预览 + + +class PublishRecord(Base): + __tablename__ = "publish_records" + + id = Column(Integer, primary_key=True, index=True) + topic_id = Column(String, nullable=False) + platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu + action = Column(String, nullable=False) # 操作:publish/update/delete/invalid + status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled + operator = Column(String, nullable=True) # 操作人 + description = Column(Text, nullable=True) # 发布说明 + suggestion = Column(Text, nullable=True) # 建议内容 + url = Column(String, nullable=True) # 发布后链接 + error_msg = Column(Text, nullable=True) # 错误信息 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # 关联查询 + # 可以添加外键关联到 Topic, 但这里保持简单 diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index 2fd03b8..58055d1 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -1,7 +1,51 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from datetime import datetime, date from typing import Optional, List, Dict, Any +class AuditLogBase(BaseModel): + user_id: Optional[int] = None + username: str + action: str + resource_type: Optional[str] = None + resource_id: Optional[str] = None + details: Optional[Dict[str, Any]] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + created_at: Optional[datetime] = None + +class AuditLogResponse(AuditLogBase): + id: int + + model_config = ConfigDict(from_attributes=True) + + +class UserBase(BaseModel): + username: str + role: str = "user" + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None + +class UserResponse(UserBase): + id: int + created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + +class LoginRequest(BaseModel): + username: str + password: str + +class TokenResponse(BaseModel): + token: str + role: str + user: UserResponse + class TopicBase(BaseModel): id: str title: str @@ -11,14 +55,14 @@ class TopicBase(BaseModel): compliance_score: Optional[int] = None ready_at: Optional[date] = None published_at: Optional[date] = None + generated_at: Optional[datetime] = None platform_urls: Optional[Dict[str, str]] = None class TopicResponse(TopicBase): created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ArticleBase(BaseModel): id: str @@ -30,8 +74,7 @@ class ArticleBase(BaseModel): created_at: Optional[datetime] = None class ArticleResponse(ArticleBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class SystemStatus(BaseModel): total_topics: int @@ -40,14 +83,55 @@ class SystemStatus(BaseModel): today_articles: int compliance_rate: float last_optimization: Optional[datetime] = None -execution_time: Optional[float] = None # 任务执行耗时(秒) + execution_time: Optional[float] = None class OptimizationRequest(BaseModel): - topic_ids: Optional[List[str]] = None # None表示全部 + topic_ids: Optional[List[str]] = None class PublishRequest(BaseModel): topic_id: str - platform_urls: Dict[str, str] # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."} + platform_urls: Dict[str, str] + +class PublishActionRequest(BaseModel): + action: str + platform: Optional[str] = None + status: str + operator: Optional[str] = None + description: Optional[str] = None + suggestion: Optional[str] = None + url: Optional[str] = None + error_msg: Optional[str] = None + +class PublishRecordResponse(BaseModel): + id: int + topic_id: str + platform: str + action: str + status: str + operator: Optional[str] = None + description: Optional[str] = None + suggestion: Optional[str] = None + url: Optional[str] = None + error_msg: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) class BatchPublishRequest(BaseModel): - date: str # YYYY-MM-DD + date: str + + +class AuditLogResponse(BaseModel): + id: int + user_id: Optional[int] = None + username: str + action: str + resource_type: Optional[str] = None + resource_id: Optional[str] = None + details: Optional[Dict[str, Any]] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + diff --git a/platform/backend/requirements.txt b/platform/backend/requirements.txt index de573a7..6d381fa 100644 --- a/platform/backend/requirements.txt +++ b/platform/backend/requirements.txt @@ -1,12 +1,12 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.6 -pydantic==2.9.2 -sqlalchemy==2.0.36 -python-multipart==0.0.9 -jinja2==3.1.5 -aiofiles==24.1.0 -python-dateutil==2.9.0.post0 -pytz==2024.2 -PyYAML>=6.0 -feedparser>=6.0 -requests>=2.32.0 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +pydantic==2.5.0 +python-dotenv==1.0.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +asyncpg==0.29.0 +alembic==1.12.1 +gunicorn==21.2.0 +prometheus-client==0.19.0 \ No newline at end of file diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml new file mode 100644 index 0000000..480bdf3 --- /dev/null +++ b/platform/docker-compose.yml @@ -0,0 +1,91 @@ +version: '3.8' + +services: + app: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8002:8001" + environment: + - DATABASE_URL=postgresql://yuzhiran:yuzhiran@db:5432/yuzhiran_db + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=your-secret-key-change-in-production + - ALGORITHM=HS256 + - ACCESS_TOKEN_EXPIRE_MINUTES=10080 + - DEBUG=False + - ENVIRONMENT=production + depends_on: + - db + - redis + volumes: + - ./backend:/app + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + interval: 30s + timeout: 10s + retries: 3 + + db: + image: postgres:13-alpine + environment: + POSTGRES_DB: yuzhiran_db + POSTGRES_USER: yuzhiran + POSTGRES_PASSWORD: yuzhiran + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5433:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U yuzhiran -d yuzhiran_db"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --requirepass redis123 + volumes: + - redis_data:/data + ports: + - "6379:6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "8080:80" + - "8443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + depends_on: + - app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8000:8000" + volumes: + - ./frontend:/usr/share/nginx/html + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + +networks: + default: + driver: bridge \ No newline at end of file diff --git a/platform/frontend/index.html b/platform/frontend/index.html index d1da1da..d44705a 100644 --- a/platform/frontend/index.html +++ b/platform/frontend/index.html @@ -1,573 +1,285 @@ - - - 宇之然内容创作平台 - - - - - + + + 宇之然内容创作平台 + + + + + -
- - -
-
-

系统概览

-
-
-
{{ status.total_topics }}
-
选题总数
-
-
-
{{ (status.topics_by_status || {})['待发布'] || 0 }}
-
待发布
-
-
-
{{ (status.topics_by_status || {})['待处理'] || 0 }}
-
待处理
-
-
-
{{ status.today_articles }}
-
今日生成
-
-
-
- ▶ 运行创作任务 - 🔍 运行合规优化 - 📄 查看日志 -
-
- - -
-
-

📊 流水线状态

- 刷新 -
-
加载中...
-
-
-
{{ pipeline.status_distribution?.['待处理'] || 0 }}
-
待处理
-
-
-
{{ pipeline.status_distribution?.['待发布'] || 0 }}
-
待发布
-
-
-
{{ pipeline.status_distribution?.['已发布'] || 0 }}
-
已发布
-
-
-
{{ pipeline.topics_count || 0 }}
-
总选题数
-
-
-
-

模块状态

- - - - - - - - -
-
- -
-
-

选题管理

-
- 🔄 全量刷新 - + 新建选题 -
-
-
- - - - - - - - 共 {{ topics.length }} 条 -
-
- - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
- - 知乎 - 微信公众号 - 小红书 - -
-
-
- - - - - - - - - - - - - - - - - - - - -
加载中...
-
暂未生成发布包,请点击"重新生成"
-
-
- 🔄 重新生成所有平台发布包 -
- - - - - - - - - - -
-
-
- - -
- - - -
- 关闭 - 复制HTML -
-
-
- - - -
- - - - - - - 加载 -
-
{{ logContent }}
-
+
+
+
+

宇之然内容创作平台

+
{{ loginError }}
+ + + + 登 录 + +
- - - - + return res; + }); + }; + const handleLogin = async () => { + loadingLogin.value = true; + try { + const res = await authFetch(API_BASE + '/api/auth/login', { method: 'POST', body: JSON.stringify(loginForm) }); + if (res.ok) { + const data = await res.json(); + token.value = data.token; localStorage.setItem('token', data.token); isLoggedIn.value = true; + currentUser.value = data.user; isAdmin.value = data.role === 'admin'; + loginForm.username = ''; loginForm.password = ''; loginError.value = ''; + ElMessage.success('登录成功'); refresh(); refreshPipeline(); + } else { const err = await res.json(); loginError.value = err.detail || '登录失败'; } + } catch (e) { loginError.value = '网络错误:' + e.message; } + finally { loadingLogin.value = false; } + }; + const handleLogout = () => { localStorage.removeItem('token'); token.value = ''; isLoggedIn.value = false; currentUser.value = null; isAdmin.value = false; ElMessage.info('已退出登录'); }; + const status = ref({}), topics = ref([]), filterStatus = ref(''), generating = ref(false), optimizing = ref(false); + const loadingAll = ref(false), loadingTable = ref(false), loadingLogs = ref(false), loadingUsers = ref(false); + const loadingOverlay = ref(false), loadingText = ref(''), selectedTopicIds = ref([]); + const pipeline = ref({ status_distribution: {} }), pipelineLoading = ref(false), pipelineModules = ref([]); + const previewVisible = ref(false), previewTopic = ref({ title: '' }), previewPlatform = ref('zhihu'), previewHtml = ref(''); + const fullScreenPreview = ref(false), showLogs = ref(false), logType = ref('creator'), logDate = ref(new Date().toISOString().split('T')[0]), logContent = ref(''); + const currentPage = ref('overview'), users = ref([]), createTopicModalVisible = ref(false); + const newUserForm = reactive({ username: '', password: '', role: 'user' }), createUserModalVisible = ref(false); + const publishModalVisible = ref(false), publishForm = reactive({ platform: 'zhihu', url: '', description: '' }); + const newTopicForm = reactive({ title: '', field: '', priority: '中' }); + const filteredTopics = computed(() => filterStatus.value ? topics.value.filter(t => t.status === filterStatus.value) : topics.value); + const countByStatus = (s) => s === 'total' ? status.value.total_topics || 0 : s === 'today' ? status.value.today_articles || 0 : status.value.topics_by_status?.[s] || 0; + const getPriorityType = (s) => s >= 20 ? 'success' : s >= 15 ? 'warning' : 'info'; + const getStatusClass = (s) => ({ '待处理': 'pending', '待审查': 'review', '待发布': 'ready', '已发布': 'published' })[s] || 'pending'; + const formatDate = (v) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'; + const refresh = async () => { try { const [s, t] = await Promise.all([authFetch(API_BASE + '/api/system/status').then(r => r.json()), authFetch(API_BASE + '/api/topics').then(r => r.json())]); status.value = s; topics.value = t; } catch (e) { ElMessage.error('刷新失败:' + e.message); } }; + const refreshPipeline = async () => { pipelineLoading.value = true; try { const res = await authFetch(API_BASE + '/api/system/pipeline/status'); if (res.ok) { const data = await res.json(); pipeline.value = data; pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([n, i]) => ({ module: n, last_run: i.last_run || '未运行', status_ok: !i.has_error && i.exists, status_text: i.exists && !i.has_error ? '正常' : i.exists ? '有错误' : '缺失', error: i.has_error ? '检测到错误' : '' })); } } catch (e) { ElMessage.error('获取流水线状态失败'); } finally { pipelineLoading.value = false; } }; + const refreshAll = async () => { loadingAll.value = true; try { await Promise.all([refresh(), refreshPipeline()]); ElMessage.success('刷新成功'); } catch (e) { ElMessage.error('刷新失败'); } finally { loadingAll.value = false; } }; + const triggerGenerate = async (id) => { generating.value = true; try { const res = await authFetch(API_BASE + '/api/system/generate/run?topic_id=' + id, { method: 'POST' }); const data = await res.json(); if (data.result && data.result.ok) { ElMessage.success('创作任务已启动'); setTimeout(refresh, 3000); } else ElMessage.error('启动失败:' + (data.error || '未知错误')); } catch (e) { ElMessage.error('请求失败:' + e.message); } finally { generating.value = false; } }; + const triggerGenerateSelected = async () => { if (!selectedTopicIds.value.length) return; loadingOverlay.value = true; loadingText.value = '正在批量创作...'; try { for (const t of selectedTopicIds.value) await triggerGenerate(t.id); ElMessage.success('批量创作完成'); selectedTopicIds.value = []; refresh(); } catch (e) { ElMessage.error('批量创作失败:' + e.message); } finally { loadingOverlay.value = false; } }; + const triggerOptimize = async (ids) => { optimizing.value = true; try { const res = await authFetch(API_BASE + '/api/system/optimize/run', { method: 'POST', body: JSON.stringify({ topic_ids: ids || [] }) }); const data = await res.json(); if (data.summary) { ElNotification({ title: '优化完成', message: '自动通过 ' + (data.summary.passed_auto || 0) + ' 篇,需人工 ' + (data.summary.need_manual || 0) + ' 篇', type: 'success' }); await refresh(); } else ElMessage.success('优化完成'); } catch (e) { ElMessage.error('优化失败:' + e.message); } finally { optimizing.value = false; } }; + const triggerOptimizeSelected = async () => { if (!selectedTopicIds.value.length) return; loadingOverlay.value = true; loadingText.value = '正在批量优化...'; try { const ids = selectedTopicIds.value.map(t => t.id); await triggerOptimize(ids); ElMessage.success('批量优化完成'); selectedTopicIds.value = []; } catch (e) { ElMessage.error('批量优化失败:' + e.message); } finally { loadingOverlay.value = false; } }; + const openPreview = async (topic) => { previewTopic.value = topic; previewPlatform.value = 'zhihu'; previewVisible.value = true; loadingText.value = '加载中...'; loadingOverlay.value = true; try { const res = await authFetch(API_BASE + '/api/topics/' + topic.id + '/preview?platform=zhihu'); const data = await res.json(); previewHtml.value = data.html; } catch (e) { ElMessage.error('预览加载失败:' + e.message); previewHtml.value = ''; } finally { loadingOverlay.value = false; loadingText.value = ''; } }; + const loadPreview = async () => { try { const res = await authFetch(API_BASE + '/api/topics/' + previewTopic.value.id + '/preview?platform=' + previewPlatform.value); const data = await res.json(); previewHtml.value = data.html; } catch (e) { ElMessage.error('预览加载失败'); previewHtml.value = ''; } }; + const copyPreviewHtml = () => { navigator.clipboard.writeText(previewHtml.value).then(() => ElMessage.success('HTML 已复制到剪贴板')).catch(() => ElMessage.error('复制失败')); }; + const expandPreview = () => { fullScreenPreview.value = !fullScreenPreview.value; }; + const fetchLogs = async () => { loadingLogs.value = true; logContent.value = ''; try { const res = await authFetch(API_BASE + '/api/system/logs/' + logDate.value + '?log_type=' + logType.value); const data = await res.json(); logContent.value = data.content.join('\n'); } catch (e) { ElMessage.error('加载日志失败:' + e.message); } finally { loadingLogs.value = false; } }; + const createTopic = (topic) => { if (topic && topic.status === '待处理') triggerGenerate(topic.id); else ElMessage.info('仅待处理选题可创作'); }; + const optimizeTopic = (topic) => { if (topic && topic.status === '待审查') triggerOptimize([topic.id]); else ElMessage.info('仅待审查选题可优化'); }; + const handlePublish = (topic) => { publishModalVisible.value = true; publishForm.platform = 'zhihu'; publishForm.url = ''; publishForm.description = ''; publishTopicId = topic.id; }; + const confirmPublish = async () => { try { await authFetch(API_BASE + '/api/topics/' + publishTopicId + '/publish', { method: 'POST', body: JSON.stringify({ platform: publishForm.platform, platform_urls: { [publishForm.platform]: publishForm.url }, description: publishForm.description }) }); ElMessage.success('发布成功'); publishModalVisible.value = false; refresh(); } catch (e) { ElMessage.error('发布失败:' + e.message); } }; + const openCreateTopic = () => { createTopicModalVisible.value = true; newTopicForm.title = ''; newTopicForm.field = ''; newTopicForm.priority = '中'; }; + const confirmCreateTopic = () => { ElMessage.info('新建选题功能待实现'); createTopicModalVisible.value = false; }; + const openCreateUserModal = () => { createUserModalVisible.value = true; newUserForm.username = ''; newUserForm.password = ''; newUserForm.role = 'user'; }; + const confirmCreateUser = async () => { try { await authFetch(API_BASE + '/api/admin/users', { method: 'POST', body: JSON.stringify(newUserForm) }); ElMessage.success('用户创建成功'); createUserModalVisible.value = false; loadUsers(); } catch (e) { ElMessage.error('创建失败:' + e.message); } }; + const deleteUser = async (id) => { if (!confirm('确定删除?')) return; try { await authFetch(API_BASE + '/api/admin/users/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); loadUsers(); } catch (e) { ElMessage.error('删除失败:' + e.message); } }; + const loadUsers = async () => { loadingUsers.value = true; try { const res = await authFetch(API_BASE + '/api/admin/users'); users.value = await res.json(); } catch (e) { ElMessage.error('加载用户失败'); } finally { loadingUsers.value = false; } }; + const goToTopicsWithFilter = (s) => { if (s === 'total') filterStatus.value = ''; else if (s === 'today') { ElMessage.info('今日新选题筛选待实现'); } else filterStatus.value = s; currentPage.value = 'topics'; }; + let publishTopicId = null; + onMounted(() => { if (isLoggedIn.value) { refresh(); refreshPipeline(); loadUsers(); } }); + return { token, isLoggedIn, currentUser, isAdmin, loginForm, loginError, loadingLogin, handleLogin, handleLogout, status, topics, filterStatus, generating, optimizing, loadingAll, loadingTable, loadingLogs, loadingUsers, loadingOverlay, loadingText, selectedTopicIds, pipeline, pipelineLoading, pipelineModules, previewVisible, previewTopic, previewPlatform, previewHtml, fullScreenPreview, showLogs, logType, logDate, logContent, currentPage, users, createTopicModalVisible, newUserForm, createUserModalVisible, publishModalVisible, publishForm, newTopicForm, filteredTopics, countByStatus, getPriorityType, getStatusClass, formatDate, refresh, refreshPipeline, refreshAll, triggerGenerate, triggerGenerateSelected, triggerOptimize, triggerOptimizeSelected, openPreview, loadPreview, copyPreviewHtml, expandPreview, fetchLogs, createTopic, optimizeTopic, handlePublish, confirmPublish, openCreateTopic, confirmCreateTopic, openCreateUserModal, confirmCreateUser, deleteUser, loadUsers, goToTopicsWithFilter }; + } + }); + app.use(ElementPlus); + app.mount('#app'); + diff --git a/platform/nginx.conf b/platform/nginx.conf index 33a8f4f..d0078db 100644 --- a/platform/nginx.conf +++ b/platform/nginx.conf @@ -1,17 +1,128 @@ -server { - listen 80; - server_name localhost; +# 宇之然内容创作平台 - Nginx配置 - location / { - root /usr/share/nginx/html; - index index.html; - try_files $uri $uri/ /index.html; - } +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; - location /api/ { - proxy_pass http://172.17.0.1:8000; # 宿主机在 Docker 网桥的 IP - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } +events { + worker_connections 1024; + use epoll; + multi_accept on; } + +http { + # 基本设置 + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + gzip_comp_level 6; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: blob: 'unsafe-inline'" always; + + # 代理缓存 + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off; + + # 上游服务器 + upstream backend { + server app:8001; + keepalive 32; + } + + server { + listen 80; + server_name _; + client_max_body_size 100M; + + # SSL配置(生产环境) + # listen 443 ssl http2; + # ssl_certificate /etc/nginx/ssl/cert.pem; + # ssl_certificate_key /etc/nginx/ssl/key.pem; + + location / { + # 前端静态资源缓存 + proxy_cache STATIC; + proxy_cache_valid 200 302 7d; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + + # 反向代理到后端API + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + + # WebSocket支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时设置 + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # 健康检查 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # API文档(可选) + location /docs { + proxy_pass http://backend/docs; + proxy_set_header Host $host; + } + + location /redoc { + proxy_pass http://backend/redoc; + proxy_set_header Host $host; + } + } + + # 静态文件服务(如果需要) + server { + listen 8000; + server_name localhost; + + root /usr/share/nginx/html; + index index.html login.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} \ No newline at end of file