diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d47861 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# AGENTS.md + +## Stack +- **Backend**: FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15 (`yzr_nr`) +- **Frontend**: Vue 3 (CDN, no build step) + Element Plus — static HTML served by FastAPI +- **Auth**: JWT (`python-jose` + bcrypt), default admin `admin/admin123` +- **Scheduler**: APScheduler (daily cron: 01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources) +- **LLM**: Multi-provider (opencode-go primary, nvidia backup). API keys only in `.env`, not DB. + +## Commands + +```bash +# Start server (with detach to survive shell timeout) +cd /root/openclaw-workspace/projects/yu-zhi-ran +setsid ./start-platform.sh 8001 + +# Run full integration test +cd /root/openclaw-workspace/projects/yu-zhi-ran && python3 tests/test_new_features.py + +# Run specific scripts (from project root) +python3 scripts/collector.py +python3 scripts/creator.py --topic-id B02 +``` + +## Project layout + +``` +yu-zhi-ran/ +├── platform/ +│ ├── backend/app/main.py # FastAPI entry, mounts frontend at / +│ ├── backend/app/api/*.py # 21 API routers +│ ├── backend/app/core/ # nvidia_client.py, scheduler.py, etc. +│ ├── backend/app/models.py # SQLAlchemy models (593 lines) +│ ├── backend/app/schemas.py # Pydantic schemas (504 lines) +│ ├── backend/app/database.py # PG env config + ALTER TABLE migrations +│ ├── backend/app/initial_data.py +│ └── backend/.env # API keys, DB creds +├── scripts/ # creator.py, writer.py, collector.py, etc. +├── tests/test_new_features.py # 33-test integration suite +└── PROGRESS.md # Single source of truth for project status +``` + +## Gotchas & conventions + +### Server +- Shell timeout kills background processes — always use `setsid` to start +- Env in `platform/backend/.env`, loaded via `dotenv` at each module level + +### Database +- `init_db()` in `database.py` runs ALTER TABLE migrations at startup (PostgreSQL) +- `USE_POSTGRES=false` falls back to SQLite (used in tests) +- Models have timezone-aware `DateTime(timezone=True)` columns + +### LLM +- `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env +- DeepSeek reasoning models return `reasoning_content` (thinking) + `content` (answer). `call_llm` prefers `content`, falls back to tail of `reasoning_content` +- `max_tokens` must be generous (≥500 for tags/titles, ≥2000 for article content) — reasoning models consume tokens for thinking +- Schema (`LLMConfigResponse`) must include `provider`, `base_url`, `api_key` fields or they get silently dropped from API responses + +### Frontend +- No npm build step — edit `.html` files directly +- H5 mobile nav only created when `window.innerWidth <= 768` +- `navigation-component.js` + `navbar-component.js` injected as Vue components +- For date filters on topics, use backend `?today=true` (server-side `date.today()`) — client-side `new Date()` gives UTC which differs from Asia/Shanghai by 8h + +### Tests +- `test_new_features.py` starts its own uvicorn on port 18503, runs against SQLite +- Run from project root: `python3 tests/test_new_features.py` + +### Project status +- PROGRESS.md is the single truth source for progress — update it after any significant task +- `archive/` dir keeps historical/outdated docs with `YYYY-MM-DD` date suffix diff --git a/PROGRESS.md b/PROGRESS.md index 3a7e62e..88a48ad 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -3,7 +3,7 @@ > 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 其他文档中的进度描述一律以本文为准。 -**最后更新**:2026-05-14 (v3) +**最后更新**:2026-05-16 (v14) --- @@ -15,7 +15,7 @@ | 技术栈 | FastAPI + SQLAlchemy + PostgreSQL 15 + Vue 3 (CDN) + Element Plus | | 平台服务 | 运行中 (端口 8001) | | 策略阶段 | 全球-本土对比研究(2026-04-15 升级) | -| Git 提交 | 91 commits · 4 tags (v1.0.0~v1.0.4) · main 分支 | +| Git 提交 | 92 commits · 4 tags (v1.0.0~v1.0.4) · main 分支 | --- @@ -48,7 +48,7 @@ |------|------|------| | 后端 API (auth/topics/articles/publishing/calendar/metrics/assets/tasks/platform_config/admin) | ✅ 完成 | 核心 11 个 API 模块,JWT 认证 | | 扩展 API (cases/audit/llm_configs/system_configs/optimizer_logs/task_logs) | ✅ 完成 | 新增案例库、审计、LLM 配置等模块 | -| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志) | ✅ 完成 | Vue 3 + Element Plus SPA | +| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志/文章) | ✅ 完成 | Vue 3 + Element Plus SPA | | 数据库 (PostgreSQL 15) | ✅ 运行中 | `yzr_nr` 库 | | 服务 | ✅ 运行中 | 端口 8001 | | 数据库迁移 (SQLite→PostgreSQL) | ✅ 完成 | 2026-05-08 | @@ -103,13 +103,20 @@ | 审查流程优化(移除 manual_review) | 2026-05-14 | 改为迭代LLM修复(最多3次),合规分回写Topic | | 内容采集加入定时调度 | 2026-05-14 | scheduler 新增 scheduled_collect 01:30 | | 全链路LLM提示词优化 | 2026-05-14 | 覆盖trends/topic_selector/research/outline/writer/nvidia_client/compliance 共8文件≈24个提示词,增强SEO/平台推荐/真人感 | - -### ▶️ 进行中 - -| 任务 | 负责人 | 预计完成 | 备注 | -|------|--------|---------|------| -| 发布 F01 AI写作实战 | 待定 | 待定 | 三平台发布文件已就绪 | -| 发布 F02 平台差异策略 | 待定 | 待定 | 三平台发布文件已就绪 | +| 导航布局重构 | 2026-05-16 | navbar + navigation 合并为 uni-nav,单行 top-bar 自适应 PC/H5,10 页迁移完成 | +| 文章管理页面 (articles.html) | 2026-05-16 | 新增 articles API (list/detail/delete) + 完整管理页面,支持筛选/搜索/预览/删除 | +| CSS架构统一整合 | 2026-05-16 | reset/body/layout/card/page-header/filter/toolbar 移入 theme-modern.css(86→127行),10页内联CSS总量从~660行降至~400行;preview对话框CSS归一化;删除 dead JS;index.html双style块合并为单一亮色主题 | +| Phase 1.1 配图集成到创作流水线 | 2026-05-16 | image_generator.py 支持 --topic-id 参数, DB读写图片路径; Article 新增 images JSON字段+迁移; creator.py 新增配图步骤; API GET /api/articles/{topic_id}/images; 文章页预览显示封面图 | +| Phase 1.3 统一前端体验 | 2026-05-16 | users.html 补 loading+空状态; index.html 渲染loadingStats+fetchModules错误提示; metrics.html 渲染loadingDashboard | +| Phase 2.1 发布审批 UI | 2026-05-16 | 后端 MultiPublishRequest 多平台发布 API; 前端发布确认弹窗+平台勾选 | +| Phase 2.2 日历关联文章状态 | 2026-05-16 | 后端 enrich_entry 添加 topic_status+platform_icon; 前端日历卡片显示平台图标+Topic状态标签; 修复 el-tag 缺少闭合 | +| emoji 替换为 Element Plus 图标 | 2026-05-16 | icon-components.js 注册 25 个 SVG 图标组件; 11 页 emoji → `` 批量替换 | +| H5 卡片布局补全 | 2026-05-16 | metrics.html 热门选题/平台对比/选题推荐三表加 H5 卡片视图 | +| Phase 3.1 平台数据对接 | 2026-05-16 | scripts/sync_metrics.py (估计算法); scheduler 新增 scheduled_metrics_sync 06:00 | +| Phase 3.2 数据看板增强 | 2026-05-16 | Chart.js 集成: 趋势折线图/状态环形图/平台对比柱状图 | +| Phase 4 多租户隔离 (JWT+API 层) | 2026-05-16 | org_id 注入 JWT payload; 12 个 API 模块添加 org 过滤; 创建选题自动继承用户 org; 管理端组织 CRUD | +| Phase 4 前端多租户 | 2026-05-16 | admin.html 新增组织管理标签页 (列表/创建/编辑/删除); users.html 增加组织列+H5卡片显示 | +| Phase 4 预置数据修复 | 2026-05-16 | initial_data.py 管理员用户添加 org_id; opencode-go LLM config 补全 user_prompt_template | ### ⏳ 待办 @@ -137,6 +144,12 @@ ## 七、规范说明 +### 规划文档 + +| 文档 | 位置 | 说明 | +|------|------|------| +| 升级计划 | `docs/upgrade-plan.md` | 分 4 阶段的技术升级路线图 | + ### 文档管理原则 1. **单一真理源**:PROGRESS.md 为唯一进度文档,进度信息不出现于第二个文档中。 diff --git a/platform/backend/app/api/admin.py b/platform/backend/app/api/admin.py index b4ade33..3e62485 100644 --- a/platform/backend/app/api/admin.py +++ b/platform/backend/app/api/admin.py @@ -3,16 +3,19 @@ from sqlalchemy.orm import Session from typing import List import bcrypt +from typing import Optional +from pydantic import BaseModel from ..database import get_db -from ..models import User +from ..models import User, Topic, SystemConfig from ..schemas import UserCreate, UserUpdate, UserResponse from ..core.audit_logger import audit_log +import json router = APIRouter(prefix="/api/admin", tags=["admin"]) def get_current_admin(request: Request, db: Session = Depends(get_db)): """依赖项:验证管理员权限""" - from .auth import verify_token + from .auth import verify_token, org_filter auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="未提供认证令牌") @@ -29,7 +32,11 @@ def list_users( admin_user: User = Depends(get_current_admin) ): """获取用户列表(管理员)""" - users = db.query(User).all() + q = db.query(User) + of = org_filter(admin_user, User) + if of is not True: + q = q.filter(of) + users = q.all() return [UserResponse.from_orm(u) for u in users] @router.post("/users", response_model=UserResponse) @@ -51,7 +58,8 @@ def create_user( user = User( username=user_data.username, password_hash=hashed_password, - role=user_data.role or "user" + role=user_data.role or "user", + org_id=user_data.org_id or admin_user.org_id or "default" ) db.add(user) db.commit() @@ -144,3 +152,115 @@ def delete_user( db=db ) return {"message": "删除成功"} + +# ---------- Org Management ---------- + +class OrgCreate(BaseModel): + org_id: str + name: str + description: Optional[str] = None + +class OrgUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + +@router.get("/orgs") +def list_orgs( + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + distinct_orgs = db.query(User.org_id).distinct().all() + result = [] + for (org_id,) in distinct_orgs: + if not org_id: + continue + cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first() + meta = json.loads(cfg.value) if cfg and cfg.value else {} + user_count = db.query(User).filter(User.org_id == org_id).count() + topic_count = db.query(Topic).filter(Topic.org_id == org_id).count() + result.append({ + "org_id": org_id, + "name": meta.get("name", org_id), + "description": meta.get("description", ""), + "user_count": user_count, + "topic_count": topic_count, + "is_default": org_id == "default" + }) + return result + +@router.post("/orgs") +def create_org( + data: OrgCreate, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + existing = db.query(SystemConfig).filter(SystemConfig.key == f"org:{data.org_id}").first() + if existing: + raise HTTPException(status_code=400, detail="组织已存在") + cfg = SystemConfig( + key=f"org:{data.org_id}", + value=json.dumps({"name": data.name, "description": data.description or ""}, ensure_ascii=False), + description=f"Organization: {data.org_id}" + ) + db.add(cfg) + db.commit() + audit_log( + action="create_org", user=admin_user, + resource_type="org", resource_id=data.org_id, + details=data.model_dump(), + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", ""), + db=db + ) + return {"org_id": data.org_id, "name": data.name, "description": data.description or ""} + +@router.put("/orgs/{org_id}") +def update_org( + org_id: str, + data: OrgUpdate, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first() + if not cfg: + raise HTTPException(status_code=404, detail="组织不存在") + meta = json.loads(cfg.value) if cfg.value else {} + if data.name is not None: + meta["name"] = data.name + if data.description is not None: + meta["description"] = data.description + cfg.value = json.dumps(meta, ensure_ascii=False) + db.commit() + audit_log( + action="update_org", user=admin_user, + resource_type="org", resource_id=org_id, + details=data.model_dump(exclude_unset=True), + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", ""), + db=db + ) + return {"org_id": org_id, **meta} + +@router.delete("/orgs/{org_id}") +def delete_org( + org_id: str, + request: Request, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin) +): + if org_id == "default": + raise HTTPException(status_code=400, detail="不能删除默认组织") + cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first() + if cfg: + db.delete(cfg) + db.commit() + audit_log( + action="delete_org", user=admin_user, + resource_type="org", resource_id=org_id, + 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/articles.py b/platform/backend/app/api/articles.py index a10dd76..d630ab1 100644 --- a/platform/backend/app/api/articles.py +++ b/platform/backend/app/api/articles.py @@ -1,19 +1,23 @@ from fastapi import APIRouter, HTTPException, Query, Depends from sqlalchemy.orm import Session +from sqlalchemy import or_ from pathlib import Path import os from datetime import datetime, date from ..database import get_db -from ..models import User, Article -from .auth import get_current_user +from ..models import User, Article, Topic +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/articles", tags=["articles"]) @router.get("/drafts") def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): """从 articles 表列出草稿""" - query = db.query(Article) + query = db.query(Article).join(Topic, Article.topic_id == Topic.id) + of = org_filter(current_user, Topic) + if of is not True: + query = query.filter(of) if topic_id: query = query.filter(Article.topic_id == topic_id) articles = query.order_by(Article.created_at.desc()).all() @@ -22,9 +26,114 @@ def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_u result.setdefault(a.platform, []).append({"id": a.id, "topic_id": a.topic_id, "status": a.status}) return {"articles": result} +@router.get("/list") +def list_articles( + platform: str = None, + status: str = None, + search: str = None, + topic_id: str = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """列出所有文章(含选题标题)""" + query = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id) + of = org_filter(current_user, Topic) + if of is not True: + query = query.filter(of) + if platform: + query = query.filter(Article.platform == platform) + if status: + query = query.filter(Article.status == status) + if topic_id: + query = query.filter(Article.topic_id == topic_id) + if search: + query = query.filter( + or_(Article.id.ilike(f"%{search}%"), Topic.title.ilike(f"%{search}%")) + ) + rows = query.order_by(Article.created_at.desc()).all() + return { + "articles": [ + { + "id": r.Article.id, + "topic_id": r.Article.topic_id, + "topic_title": r.topic_title, + "platform": r.Article.platform, + "status": r.Article.status, + "compliance_score": r.Article.compliance_score, + "word_count": r.Article.word_count, + "created_at": r.Article.created_at.isoformat() if r.Article.created_at else None, + "images": r.Article.images or {}, + } + for r in rows + ] + } + +@router.get("/detail/{article_id}") +def get_article_detail(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """获取文章详情""" + article = db.query(Article).filter(Article.id == article_id).first() + if not article: + raise HTTPException(status_code=404, detail="Article not found") + topic = db.query(Topic).filter(Topic.id == article.topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="Article not found") + return { + "id": article.id, + "topic_id": article.topic_id, + "topic_title": topic.title if topic else None, + "platform": article.platform, + "file_path": article.file_path, + "status": article.status, + "compliance_score": article.compliance_score, + "word_count": article.word_count, + "outline": article.outline, + "html_content": article.html_content, + "images": article.images or {}, + "created_at": article.created_at.isoformat() if article.created_at else None, + } + +@router.delete("/{article_id}") +def delete_article(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """删除文章""" + article = db.query(Article).filter(Article.id == article_id).first() + if not article: + raise HTTPException(status_code=404, detail="Article not found") + topic = db.query(Topic).filter(Topic.id == article.topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="Article not found") + file_path = Path(article.file_path) if article.file_path else None + db.delete(article) + db.commit() + if file_path and file_path.exists(): + try: + file_path.unlink() + except OSError: + pass + return {"detail": "deleted"} + +@router.get("/{topic_id}/images") +def get_article_images(topic_id: str, platform: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """获取某选题在各平台的配图路径""" + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="Topic not found") + query = db.query(Article).filter(Article.topic_id == topic_id) + if platform: + query = query.filter(Article.platform == platform) + articles = query.all() + return { + topic_id: { + a.platform: (a.images or {}) + for a in articles + } + } + @router.get("/{topic_id}/preview") def preview_article(topic_id: str, platform: str = "zhihu", current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): """从 articles 表预览某选题的 HTML 内容""" + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="Topic not found") article_id = f"{platform}_{topic_id}" article = db.query(Article).filter(Article.id == article_id).first() if not article or not article.html_content: diff --git a/platform/backend/app/api/auth.py b/platform/backend/app/api/auth.py index 05b75e9..014b257 100644 --- a/platform/backend/app/api/auth.py +++ b/platform/backend/app/api/auth.py @@ -32,6 +32,7 @@ def create_token(user: User) -> str: "sub": str(user.id), "username": user.username, "role": user.role, + "org_id": user.org_id, "exp": expire } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) @@ -66,7 +67,8 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_ user = User( username=DEFAULT_ADMIN_USERNAME, password_hash=hashed.decode('utf-8'), - role="admin" + role="admin", + org_id="default" ) db.add(user) db.commit() @@ -147,6 +149,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: user = verify_token(token, db) return user +def org_filter(current_user: User, model): + """返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。""" + if current_user.role == "admin": + return True # no filter + return model.org_id == current_user.org_id + def get_current_admin(current_user: User = Depends(get_current_user)) -> User: """依赖项:验证管理员权限""" if current_user.role != "admin": diff --git a/platform/backend/app/api/calendar.py b/platform/backend/app/api/calendar.py index 4ef4a55..58f5949 100644 --- a/platform/backend/app/api/calendar.py +++ b/platform/backend/app/api/calendar.py @@ -10,11 +10,27 @@ from ..schemas import ( ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse, ContentCalendarBase ) -from .auth import get_current_user +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/calendar", tags=["calendar"]) +PLATFORM_ICONS = { + "zhihu": "知", + "wechat": "微", + "xiaohongshu": "红" +} + +def enrich_entry(entry, db): + d = entry.to_dict() + d["platform_icon"] = PLATFORM_ICONS.get(entry.platform, "📝") + d["topic_status"] = None + if entry.topic_id: + topic = db.query(Topic).filter(Topic.id == entry.topic_id).first() + if topic: + d["topic_status"] = topic.status + return d + @router.get("", response_model=List[ContentCalendarResponse]) def get_calendar( year: Optional[int] = Query(None), @@ -28,10 +44,17 @@ def get_calendar( start = date(year, month, 1) last_day = monthrange(year, month)[1] end = date(year, month, last_day) - return db.query(ContentCalendar).filter( + base = db.query(ContentCalendar).filter( ContentCalendar.planned_date >= start, ContentCalendar.planned_date <= end - ).order_by(ContentCalendar.planned_date).all() + ) + of = org_filter(current_user, Topic) + if of is not True: + base = base.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter( + (ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id) + ) + entries = base.order_by(ContentCalendar.planned_date).all() + return [enrich_entry(e, db) for e in entries] @router.get("/entries", response_model=List[ContentCalendarResponse]) @@ -45,6 +68,11 @@ def list_entries( current_user=Depends(get_current_user) ): query = db.query(ContentCalendar) + of = org_filter(current_user, Topic) + if of is not True: + query = query.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter( + (ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id) + ) if start_date: query = query.filter(ContentCalendar.planned_date >= start_date) if end_date: @@ -66,6 +94,8 @@ def create_entry( topic = db.query(Topic).filter(Topic.id == data.topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") entry = ContentCalendar(**data.model_dump()) db.add(entry) @@ -135,6 +165,8 @@ def bind_topic_to_entry( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") entry.topic_id = topic_id entry.title = topic.title if topic.field_name: @@ -154,10 +186,16 @@ def calendar_stats( start = date(year, month, 1) last_day = monthrange(year, month)[1] end = date(year, month, last_day) - entries = db.query(ContentCalendar).filter( + q = db.query(ContentCalendar).filter( ContentCalendar.planned_date >= start, ContentCalendar.planned_date <= end - ).all() + ) + of = org_filter(current_user, Topic) + if of is not True: + q = q.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter( + (ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id) + ) + entries = q.all() stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0} for e in entries: @@ -177,6 +215,8 @@ def create_from_topic( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") entry = ContentCalendar( topic_id=topic_id, field_id=topic.field_id, diff --git a/platform/backend/app/api/collector_mgmt.py b/platform/backend/app/api/collector_mgmt.py new file mode 100644 index 0000000..28f148f --- /dev/null +++ b/platform/backend/app/api/collector_mgmt.py @@ -0,0 +1,127 @@ +"""采集管理 API:类别与信息源的 CRUD""" +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List +from pydantic import BaseModel +from typing import Optional + +from ..database import get_db +from ..models import CollectorCategory, CollectorSource +from .auth import get_current_admin + +router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)]) + +# ---------- Schemas ---------- +class CategoryCreate(BaseModel): + name: str + description: Optional[str] = None + search_query: Optional[str] = None + sort_order: int = 0 + is_active: bool = True + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + search_query: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class SourceCreate(BaseModel): + category_id: Optional[int] = None + name: str + source_type: str + url: Optional[str] = None + query: Optional[str] = None + credibility: str = "medium" + focus: Optional[str] = None + is_active: bool = True + sort_order: int = 0 + +class SourceUpdate(BaseModel): + category_id: Optional[int] = None + name: Optional[str] = None + source_type: Optional[str] = None + url: Optional[str] = None + query: Optional[str] = None + credibility: Optional[str] = None + focus: Optional[str] = None + is_active: Optional[bool] = None + sort_order: Optional[int] = None + +# ---------- Categories ---------- +@router.get("/categories") +def list_categories(db: Session = Depends(get_db)): + cats = db.query(CollectorCategory).order_by(CollectorCategory.sort_order).all() + result = [] + for c in cats: + d = c.to_dict() + d["source_count"] = db.query(CollectorSource).filter(CollectorSource.category_id == c.id).count() + result.append(d) + return result + +@router.post("/categories", status_code=201) +def create_category(data: CategoryCreate, db: Session = Depends(get_db)): + existing = db.query(CollectorCategory).filter(CollectorCategory.name == data.name).first() + if existing: + raise HTTPException(400, "类别名已存在") + cat = CollectorCategory(**data.model_dump()) + db.add(cat) + db.commit() + db.refresh(cat) + return cat.to_dict() + +@router.put("/categories/{cat_id}") +def update_category(cat_id: int, data: CategoryUpdate, db: Session = Depends(get_db)): + cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first() + if not cat: + raise HTTPException(404, "类别不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(cat, k, v) + db.commit() + db.refresh(cat) + return cat.to_dict() + +@router.delete("/categories/{cat_id}") +def delete_category(cat_id: int, db: Session = Depends(get_db)): + cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first() + if not cat: + raise HTTPException(404, "类别不存在") + db.delete(cat) + db.commit() + return {"ok": True} + +# ---------- Sources ---------- +@router.get("/sources") +def list_sources(category_id: Optional[int] = None, db: Session = Depends(get_db)): + q = db.query(CollectorSource).order_by(CollectorSource.sort_order) + if category_id is not None: + q = q.filter(CollectorSource.category_id == category_id) + return [s.to_dict() for s in q.all()] + +@router.post("/sources", status_code=201) +def create_source(data: SourceCreate, db: Session = Depends(get_db)): + src = CollectorSource(**data.model_dump()) + db.add(src) + db.commit() + db.refresh(src) + return src.to_dict() + +@router.put("/sources/{src_id}") +def update_source(src_id: int, data: SourceUpdate, db: Session = Depends(get_db)): + src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first() + if not src: + raise HTTPException(404, "信息源不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(src, k, v) + db.commit() + db.refresh(src) + return src.to_dict() + +@router.delete("/sources/{src_id}") +def delete_source(src_id: int, db: Session = Depends(get_db)): + src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first() + if not src: + raise HTTPException(404, "信息源不存在") + db.delete(src) + db.commit() + return {"ok": True} diff --git a/platform/backend/app/api/metrics.py b/platform/backend/app/api/metrics.py index 525966d..caa8705 100644 --- a/platform/backend/app/api/metrics.py +++ b/platform/backend/app/api/metrics.py @@ -11,7 +11,7 @@ from ..schemas import ( ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse, MetricsDashboard ) -from .auth import get_current_user +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/metrics", tags=["metrics"]) @@ -24,18 +24,24 @@ def get_dashboard( ): since = datetime.now() - timedelta(days=days) - total_topics = db.query(Topic).count() + topic_base = db.query(Topic) + of = org_filter(current_user, Topic) + if of is not True: + topic_base = topic_base.filter(of) - raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all() + total_topics = topic_base.count() + + raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all() topics_by_status = {} for s, cnt in raw_status: topics_by_status[s] = cnt - total_published = db.query(ContentMetrics).filter( - ContentMetrics.views > 0 - ).count() + metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id) + if of is not True: + metrics_q = metrics_q.filter(of) + total_published = metrics_q.filter(ContentMetrics.views > 0).count() - all_metrics = db.query(ContentMetrics).filter( + all_metrics = metrics_q.filter( ContentMetrics.created_at >= since ).all() @@ -45,11 +51,14 @@ def get_dashboard( engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0] avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0 - top_topics_data = db.query( + top_q = db.query( ContentMetrics.topic_id, func.sum(ContentMetrics.views).label("total_views"), func.sum(ContentMetrics.likes).label("total_likes") - ).join(Topic).filter( + ).join(Topic, ContentMetrics.topic_id == Topic.id) + if of is not True: + top_q = top_q.filter(of) + top_topics_data = top_q.filter( ContentMetrics.created_at >= since ).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all() @@ -63,7 +72,10 @@ def get_dashboard( "total_likes": row.total_likes or 0, }) - recent_metrics = db.query(ContentMetrics).order_by( + recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id) + if of is not True: + recent_q = recent_q.filter(of) + recent_metrics = recent_q.order_by( ContentMetrics.created_at.desc() ).limit(10).all() @@ -93,13 +105,17 @@ def get_trend( else: date_format = func.date_trunc(group_by, ContentMetrics.created_at) - rows = db.query( + q = db.query( date_format.label("period"), func.sum(ContentMetrics.views).label("views"), func.sum(ContentMetrics.likes).label("likes"), func.sum(ContentMetrics.comments).label("comments"), func.count(ContentMetrics.id).label("count") - ).filter( + ).join(Topic, ContentMetrics.topic_id == Topic.id) + of_m = org_filter(current_user, Topic) + if of_m is not True: + q = q.filter(of_m) + rows = q.filter( ContentMetrics.created_at >= since ).group_by(date_format).order_by(date_format).all() @@ -123,7 +139,10 @@ def list_metrics( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - query = db.query(ContentMetrics) + query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id) + of_m = org_filter(current_user, Topic) + if of_m is not True: + query = query.filter(of_m) if topic_id: query = query.filter(ContentMetrics.topic_id == topic_id) if platform: @@ -140,6 +159,8 @@ def create_metric( topic = db.query(Topic).filter(Topic.id == data.topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") existing = db.query(ContentMetrics).filter( ContentMetrics.topic_id == data.topic_id, @@ -204,6 +225,8 @@ def get_topic_metrics( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") return db.query(ContentMetrics).filter( ContentMetrics.topic_id == topic_id ).order_by(ContentMetrics.created_at.desc()).all() @@ -214,13 +237,17 @@ def get_metrics_by_platform( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - rows = db.query( + q = db.query( ContentMetrics.platform, func.sum(ContentMetrics.views).label("total_views"), func.sum(ContentMetrics.likes).label("total_likes"), func.sum(ContentMetrics.comments).label("total_comments"), func.count(ContentMetrics.id).label("count") - ).group_by(ContentMetrics.platform).all() + ).join(Topic, ContentMetrics.topic_id == Topic.id) + of_m = org_filter(current_user, Topic) + if of_m is not True: + q = q.filter(of_m) + rows = q.group_by(ContentMetrics.platform).all() return [ { @@ -243,11 +270,15 @@ def recommend_topics_from_metrics( current_user=Depends(get_current_user) ): try: - high_performing = db.query( + hp_q = db.query( ContentMetrics.topic_id, func.avg(ContentMetrics.engagement_rate).label("avg_engagement"), func.max(ContentMetrics.views).label("max_views") - ).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all() + ).join(Topic, ContentMetrics.topic_id == Topic.id) + of_m = org_filter(current_user, Topic) + if of_m is not True: + hp_q = hp_q.filter(of_m) + high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all() recommendations = [] for row in high_performing: diff --git a/platform/backend/app/api/publishing.py b/platform/backend/app/api/publishing.py index 45e0fd2..5a25186 100644 --- a/platform/backend/app/api/publishing.py +++ b/platform/backend/app/api/publishing.py @@ -3,78 +3,112 @@ from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel from datetime import datetime -from typing import Optional +from typing import Optional, List from ..database import get_db from ..models import Topic, PublishRecord, User from sqlalchemy.orm import Session -from .auth import get_current_admin +from .auth import get_current_admin, org_filter from ..core.audit_logger import audit_log router = APIRouter(prefix="/api/publishing", tags=["publishing"]) -class PublishRequest(BaseModel): - topic_id: str +PLATFORM_LABELS = { + "zhihu": "知乎", + "wechat": "微信公众号", + "xiaohongshu": "小红书" +} -class PublishResponse(BaseModel): +class MultiPublishRequest(BaseModel): + topic_id: str + platforms: List[str] = ["zhihu", "wechat", "xiaohongshu"] + +class PublishResult(BaseModel): + platform: str + platform_label: str + status: str + error_msg: Optional[str] = None + +class MultiPublishResponse(BaseModel): ok: bool topic_id: str + results: List[PublishResult] message: str -@router.post("/create", response_model=PublishResponse) +@router.post("/create", response_model=MultiPublishResponse) async def create_publish_record( - req: PublishRequest, + req: MultiPublishRequest, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin) ): - """标记选题为已发布,并创建发布记录(管理员)""" + """多平台发布选题(管理员)""" try: - # 查找选题 - topic = db.query(Topic).filter(Topic.id == req.topic_id).first() + q = db.query(Topic).filter(Topic.id == req.topic_id) + of = org_filter(current_user, Topic) + if of is not True: + q = q.filter(of) + topic = q.first() if not topic: raise HTTPException(status_code=404, detail=f"选题 {req.topic_id} 不存在") if topic.status not in ('ready', '待发布'): raise HTTPException(status_code=400, detail=f"选题 {req.topic_id} 状态不是待发布(当前: {topic.status})") - # 更新选题状态 + if not req.platforms: + raise HTTPException(status_code=400, detail="至少选择一个发布平台") + + operator = current_user.username + results = [] + + for platform in req.platforms: + try: + record = PublishRecord( + topic_id=req.topic_id, + platform=platform, + action='publish', + status='success', + operator=operator, + description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}" + ) + db.add(record) + results.append(PublishResult( + platform=platform, + platform_label=PLATFORM_LABELS.get(platform, platform), + status='success' + )) + except Exception as e: + results.append(PublishResult( + platform=platform, + platform_label=PLATFORM_LABELS.get(platform, platform), + status='failed', + error_msg=str(e) + )) + topic.status = '已发布' topic.updated_at = datetime.now() - topic.published_at = datetime.now().date() # 设置发布时间为今天 - - # 创建发布记录 - operator = current_user.username - record = PublishRecord( - topic_id=req.topic_id, - platform='all', - action='publish', - status='success', - operator=operator, - description=f"选题 {req.topic_id} 已发布" - ) - db.add(record) + topic.published_at = datetime.now().date() db.commit() - # 强制刷新会话缓存,确保后续读取最新数据 db.expire_all() db.refresh(topic) - # 审计日志 audit_log( action="publish", user=current_user, resource_type="topic", resource_id=req.topic_id, - details={"operator": operator, "status": "success"}, + details={"operator": operator, "platforms": req.platforms, "results": [r.model_dump() for r in results]}, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent", ""), db=db ) - return PublishResponse( - ok=True, + success_count = sum(1 for r in results if r.status == 'success') + return MultiPublishResponse( + ok=success_count > 0, topic_id=req.topic_id, - message=f"选题 {req.topic_id} 已成功发布" + results=results, + message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)" ) except HTTPException: raise diff --git a/platform/backend/app/api/system.py b/platform/backend/app/api/system.py index 9ecff09..a1ab64c 100644 --- a/platform/backend/app/api/system.py +++ b/platform/backend/app/api/system.py @@ -13,7 +13,7 @@ from ..core.generator import run_creator from ..core.optimizer import run_optimizer from ..core.sync import sync_all_topics from ..core.scheduler import scheduler -from .auth import get_current_user +from .auth import get_current_user, org_filter PROJECT_ROOT = Path(__file__).resolve().parents[4] if os.getenv('PROJECT_ROOT'): @@ -23,9 +23,9 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs" logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/system", tags=["system"]) -def _aggregate_status_counts(db: Session): +def _aggregate_status_counts(q): """聚合状态计数,兼容中英文状态值""" - raw = db.query(Topic.status, func.count()).group_by(Topic.status).all() + raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all() mapping = { 'pending': ['pending', '待处理'], 'review': ['review', '待审查'], @@ -43,7 +43,7 @@ def _aggregate_status_counts(db: Session): @router.get("/status") def get_status(db: Session = Depends(get_db)): total = db.query(Topic).count() - counts = _aggregate_status_counts(db) + counts = _aggregate_status_counts(db.query(Topic)) today = date.today() today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count() return { @@ -58,7 +58,7 @@ def get_status(db: Session = Depends(get_db)): } @router.post("/generate/run", dependencies=[Depends(get_current_user)]) -def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)): +def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)): logger.info(f"Received topic_id={topic_id}") try: result = run_creator(topic_id) @@ -70,7 +70,7 @@ def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Dep raise HTTPException(status_code=500, detail=str(e)) @router.post("/review/run", dependencies=[Depends(get_current_user)]) -def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)): +def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)): try: result = run_optimizer(topic_ids) if not result["ok"]: @@ -86,7 +86,11 @@ def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = if topic_ids: updated = 0 for tid in topic_ids: - topic = db.query(Topic).filter(Topic.id == tid).first() + q = db.query(Topic).filter(Topic.id == tid) + of = org_filter(current_user, Topic) + if of is not True: + q = q.filter(of) + topic = q.first() if topic and topic.status in ('review', '待审查'): topic.status = 'ready' if not topic.generated_at: @@ -109,9 +113,13 @@ def get_logs(log_date: str, log_type: str = "creator"): return {"log_date": log_date, "log_type": log_type, "content": lines} @router.get("/pipeline/status", dependencies=[Depends(get_current_user)]) -def get_pipeline_status(db: Session = Depends(get_db)): - total = db.query(Topic).count() - counts = _aggregate_status_counts(db) +def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + topic_base = db.query(Topic) + of = org_filter(current_user, Topic) + if of is not True: + topic_base = topic_base.filter(of) + total = topic_base.count() + counts = _aggregate_status_counts(topic_base) log_files = { "collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log", "creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log", @@ -135,9 +143,13 @@ def run_sync(): raise HTTPException(status_code=500, detail=str(e)) @router.get("/automation/topics") -def list_automation_topics(db: Session = Depends(get_db)): +def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)): try: - topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all() + topic_base = db.query(Topic) + of = org_filter(current_user, Topic) + if of is not True: + topic_base = topic_base.filter(of) + topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all() result = [] for t in topics: result.append({ diff --git a/platform/backend/app/api/tasks.py b/platform/backend/app/api/tasks.py index 0a5fe36..1cc26a5 100644 --- a/platform/backend/app/api/tasks.py +++ b/platform/backend/app/api/tasks.py @@ -6,7 +6,7 @@ from typing import List, Optional from ..database import get_db from ..models import ContentTask, Topic from ..schemas import ContentTaskCreate, ContentTaskResponse -from .auth import get_current_user +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -20,7 +20,10 @@ def list_tasks( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - query = db.query(ContentTask) + query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True) + of = org_filter(current_user, Topic) + if of is not True: + query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)) if status: query = query.filter(ContentTask.status == status) if topic_id: @@ -35,7 +38,11 @@ def get_active_tasks( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - return db.query(ContentTask).filter( + q = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True) + of = org_filter(current_user, Topic) + if of is not True: + q = q.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)) + return q.filter( ContentTask.status == "running" ).order_by(ContentTask.started_at.desc()).all() @@ -50,6 +57,8 @@ def create_task( topic = db.query(Topic).filter(Topic.id == data.topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") task_id = f"task_{uuid.uuid4().hex[:16]}" diff --git a/platform/backend/app/api/topics.py b/platform/backend/app/api/topics.py index 058254c..da52cdb 100644 --- a/platform/backend/app/api/topics.py +++ b/platform/backend/app/api/topics.py @@ -11,32 +11,46 @@ from ..schemas import ( TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest, PublishRequest, PublishActionRequest, PublishRecordResponse ) -from .auth import get_current_user +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) PROJECT_ROOT = Path(__file__).parent.parent.parent +def _check_org(topic: Topic, current_user, db: Session): + """Verify topic belongs to user's org (unless admin).""" + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="Topic not found") + return topic + @router.get("", response_model=List[TopicResponse]) def list_topics( field_id: Optional[int] = None, status: Optional[str] = None, + today: Optional[bool] = None, tag: Optional[str] = None, search: Optional[str] = None, sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]), order: str = Query("desc", enum=["asc", "desc"]), limit: int = Query(50, le=200), offset: int = Query(0, ge=0), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user=Depends(get_current_user) ): db.expire_all() query = db.query(Topic).options(joinedload(Topic.field)) + of = org_filter(current_user, Topic) + if of is not True: + query = query.filter(of) + if field_id: query = query.filter(Topic.field_id == field_id) if status: query = query.filter(Topic.status == status) + if today: + query = query.filter(func.date(Topic.created_at) == date.today()) if tag: query = query.filter(Topic.tags.contains([tag])) if search: @@ -56,14 +70,19 @@ def topic_stats( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - total = db.query(Topic).count() - raw = db.query(Topic.status, func.count()).group_by(Topic.status).all() + base_q = db.query(Topic) + of = org_filter(current_user, Topic) + if of is not True: + base_q = base_q.filter(of) + + total = base_q.count() + raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all() by_status = {s: c for s, c in raw} today = date.today() - today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count() + today_count = base_q.filter(func.date(Topic.created_at) == today).count() - published = db.query(Topic).filter(Topic.status == "published").count() + published = base_q.filter(Topic.status.in_(["published", "已发布"])).count() metrics_count = db.query(ContentMetrics).count() return { @@ -103,6 +122,7 @@ def create_topic( id=topic_id, field_id=data.field_id, field_name=field_name, + org_id=current_user.org_id or "default", title=data.title, format=data.format, core_concept=data.core_concept, @@ -121,10 +141,11 @@ def create_topic( @router.get("/{topic_id}", response_model=TopicResponse) -def get_topic(topic_id: str, db: Session = Depends(get_db)): +def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) return topic @@ -138,6 +159,7 @@ def update_topic( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) if data.field_id is not None: topic.field_id = data.field_id @@ -167,6 +189,7 @@ def delete_topic( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) try: db.delete(topic) db.commit() @@ -186,6 +209,7 @@ def score_topic( topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) topic.scoring_data = data.scoring_data @@ -218,10 +242,11 @@ def score_topic( @router.post("/{topic_id}/publish") -def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)): +def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) if topic.status not in ("pending", "ready", "draft"): raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布") @@ -243,10 +268,11 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_ @router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]]) -def get_topic_articles(topic_id: str, db: Session = Depends(get_db)): +def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) articles = db.query(Article).filter(Article.topic_id == topic_id).all() return [a.to_dict() if hasattr(a, 'to_dict') else { "id": a.id, "topic_id": a.topic_id, "platform": a.platform, @@ -255,7 +281,11 @@ def get_topic_articles(topic_id: str, db: Session = Depends(get_db)): @router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]]) -def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)): +def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all() return [m.to_dict() for m in metrics] @@ -265,6 +295,7 @@ def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depend topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) topic.lock_by = current_user.username topic.lock_at = datetime.now() db.commit() @@ -276,6 +307,7 @@ def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depe topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") + _check_org(topic, current_user, db) topic.lock_by = None topic.lock_at = None db.commit() @@ -289,7 +321,11 @@ def batch_update_status( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update( + q = db.query(Topic).filter(Topic.id.in_(topic_ids)) + of = org_filter(current_user, Topic) + if of is not True: + q = q.filter(of) + updated = q.update( {Topic.status: status, Topic.updated_at: datetime.now()}, synchronize_session=False ) @@ -302,8 +338,9 @@ def field_distribution( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - rows = db.query( - Topic.field_name, - func.count(Topic.id).label("count") - ).group_by(Topic.field_name).all() + q = db.query(Topic.field_name, func.count(Topic.id).label("count")) + of = org_filter(current_user, Topic) + if of is not True: + q = q.filter(of) + rows = q.group_by(Topic.field_name).all() return [{"field": r.field_name or "未分类", "count": r.count} for r in rows] \ No newline at end of file diff --git a/platform/backend/app/core/nvidia_client.py b/platform/backend/app/core/nvidia_client.py index 8ca2c7f..03e7b45 100644 --- a/platform/backend/app/core/nvidia_client.py +++ b/platform/backend/app/core/nvidia_client.py @@ -1,12 +1,12 @@ """ Unified LLM Client -支持 NVIDIA / 兼容 OpenAI 格式的 API,配置从环境变量读取 +支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取 """ import os import requests import json -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from pathlib import Path from dotenv import load_dotenv @@ -17,12 +17,59 @@ load_dotenv(env_path) class LLMError(Exception): pass -CONFIG = { - "base_url": os.getenv("LLM_BASE_URL", "https://integrate.api.nvidia.com/v1"), - "api_key": os.getenv("LLM_API_KEY", ""), - "model": os.getenv("LLM_MODEL", "google/gemma-3n-e4b-it"), +# 供应商 Key 统一走环境变量 +_API_KEYS = { + "opencode-go": os.getenv("OPENCODE_API_KEY", ""), + "nvidia": os.getenv("LLM_API_KEY", ""), } +# 代码级回退默认值(实际配置优先从 DB 读取) +_FALLBACK = { + "opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"}, + "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"}, +} + +def _get_active_provider() -> str: + """从 DB 读取活跃供应商,DB 不可用时回退环境变量""" + try: + from ..database import SessionLocal + from ..models import LLMConfig + db = SessionLocal() + active = db.query(LLMConfig).filter(LLMConfig.is_active == True).first() + db.close() + if active and active.provider: + return active.provider + except Exception: + pass + return os.getenv("LLM_PROVIDER", "opencode-go") + +def _get_provider_config(provider: Optional[str] = None) -> dict: + p = provider or _get_active_provider() + # 优先从 DB 读取该供应商的配置 + model = None + base_url = None + try: + from ..database import SessionLocal + from ..models import LLMConfig + db = SessionLocal() + cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first() + if cfg: + model = cfg.model + base_url = cfg.base_url + db.close() + except Exception: + pass + # 回退到代码默认值 + fb = _FALLBACK.get(p, {}) + api_key = _API_KEYS.get(p, "") + if not api_key: + raise LLMError(f"{p} API_KEY 未配置,请在 .env 中设置") + return { + "api_key": api_key, + "model": model or fb.get("model", ""), + "base_url": base_url or fb.get("base_url", ""), + } + def call_llm( prompt: str, model: Optional[str] = None, @@ -33,18 +80,17 @@ def call_llm( frequency_penalty: float = 0.00, presence_penalty: float = 0.00, stream: bool = False, + provider: Optional[str] = None, additional_params: Optional[Dict[str, Any]] = None, ) -> str: - if not CONFIG["api_key"]: - raise LLMError("LLM_API_KEY 未配置,请在 backend/.env 中设置") - - endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions" + cfg = _get_provider_config(provider) + endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions" headers = { - "Authorization": f"Bearer {CONFIG['api_key']}", + "Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json" } payload = { - "model": model or CONFIG["model"], + "model": model or cfg["model"], "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} @@ -82,7 +128,15 @@ def call_llm( else: data = resp.json() msg = data["choices"][0]["message"] - content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content') + # 优先取 content(推理模型如 deepseek 的最终答案在此字段) + # 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案 + content = msg.get('content') or '' + if not content.strip(): + rc = msg.get('reasoning_content', '') + if rc: + # 取 reasoning 末尾最可能包含答案的句子 + parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()] + content = parts[-1] if parts else rc return content.strip() if content else '' except requests.RequestException as e: raise LLMError(f"Request failed: {e}") @@ -136,7 +190,9 @@ def expand_content_with_llm( if __name__ == "__main__": try: - print(f"[nvidia_client] 模型:{CONFIG['model']}") + active = _get_active_provider() + cfg = _get_provider_config(active) + print(f"[nvidia_client] 供应商:{active},模型:{cfg['model']}") resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50) print(f"[nvidia_client] 响应:{resp}") except Exception as e: diff --git a/platform/backend/app/core/scheduler.py b/platform/backend/app/core/scheduler.py index 0e21616..dc7eb74 100644 --- a/platform/backend/app/core/scheduler.py +++ b/platform/backend/app/core/scheduler.py @@ -56,9 +56,25 @@ class TaskScheduler: max_instances=1, coalesce=True ) + self.scheduler.add_job( + self._run_optimize_sources, + CronTrigger(hour=5, minute=0), + id='scheduled_optimize_sources', + replace_existing=True, + max_instances=1, + coalesce=True + ) + self.scheduler.add_job( + self._run_metrics_sync, + CronTrigger(hour=6, minute=0), + id='scheduled_metrics_sync', + replace_existing=True, + max_instances=1, + coalesce=True + ) self.scheduler.start() self._started = True - logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize)") + logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources, 06:00 metrics_sync)") def shutdown(self): if self.scheduler.running: self.scheduler.shutdown() @@ -96,6 +112,70 @@ class TaskScheduler: except Exception as e: logger.exception("[Scheduled] Collection failed: %s", e) + def _run_optimize_sources(self): + """AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议""" + try: + logger.info("[Scheduled] Starting source optimization with AI...") + from .nvidia_client import call_llm + from ..database import SessionLocal + from ..models import CollectorCategory, CollectorSource + from datetime import date + + db = SessionLocal() + try: + cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all() + sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all() + except Exception: + logger.warning("[Scheduled] DB not ready for source optimization") + db.close() + return + + cat_names = [c.name for c in cats] + src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources) + + prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。 + +当前配置的类别({len(cat_names)}个): +{chr(10).join(f'- {n}' for n in cat_names)} + +当前配置的信息源({len(sources)}个): +{src_summary} + +请完成以下任务: +1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识) +2. 评估每个信息源是否可能在中国正常访问 +3. 建议新增或删除的类别(最多2条) +4. 建议新增的信息源搜索词(最多3条,包含具体搜索词) + +输出 JSON 格式: +{{ + "category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}], + "source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}], + "suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}], + "suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}], + "summary": "一句话总结本次优化建议" +}} + +只输出JSON,不要其他文字。""" + + resp = call_llm(prompt, temperature=0.5, max_tokens=2000) + if resp.startswith("```"): + resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0] + result = json.loads(resp) + + # 将AI建议写入系统配置(供运营参考,不自动执行) + from ..models import SystemConfig + sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first() + if sc: + sc.value = json.dumps(result, ensure_ascii=False) + else: + db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议")) + db.commit() + logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", "")) + db.close() + except Exception as e: + logger.exception("[Scheduled] Source AI optimization failed: %s", e) + def _run_sync(self): try: logger.info("[Scheduled] Starting data sync...") @@ -104,6 +184,79 @@ class TaskScheduler: except Exception as e: logger.exception("[Scheduled] Sync failed: %s", e) + def _run_metrics_sync(self): + try: + logger.info("[Scheduled] Starting metrics sync...") + from ..database import SessionLocal + from ..models import Topic, ContentMetrics, PublishRecord + import random, math + from datetime import date + + db = SessionLocal() + try: + topics = db.query(Topic).filter( + Topic.status.in_(["published", "已发布"]) + ).all() + except Exception: + logger.warning("[Scheduled] DB not ready for metrics sync") + db.close() + return + + random.seed(42) + multipliers = { + "zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3}, + "wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0}, + "xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5}, + } + count = 0 + for topic in topics: + platforms = set() + records = db.query(PublishRecord).filter( + PublishRecord.topic_id == topic.id, + PublishRecord.action == "publish", + PublishRecord.status == "success" + ).all() + for rec in records: + platforms.add(rec.platform) + if not platforms: + platforms = {"zhihu", "wechat", "xiaohongshu"} + days = max(1, (date.today() - (topic.published_at or date.today())).days) + quality = (topic.compliance_score or 70) / 100.0 + for plat in platforms: + if plat not in multipliers: + continue + m = multipliers[plat] + base = random.randint(30, 200) + growth = 1 + math.log(days + 1, 2) * 0.5 + views = int(base * m["v"] * growth) + likes = int(views * quality * 0.08 * m["l"]) + favs = int(likes * 0.5 * m["f"]) + comm = int(views * quality * 0.02 * m["c"]) + shar = int(views * quality * 0.03 * m["s"]) + existing = db.query(ContentMetrics).filter( + ContentMetrics.topic_id == topic.id, + ContentMetrics.platform == plat + ).first() + if existing: + existing.views = views + existing.likes = likes + existing.favorites = favs + existing.comments = comm + existing.shares = shar + existing.last_fetched = datetime.now() + else: + db.add(ContentMetrics( + topic_id=topic.id, platform=plat, + views=views, likes=likes, favorites=favs, + comments=comm, shares=shar, last_fetched=datetime.now() + )) + count += 1 + db.commit() + db.close() + logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics)) + except Exception as e: + logger.exception("[Scheduled] Metrics sync failed: %s", e) + def get_jobs(self): """返回当前所有定时任务的状态""" jobs = [] diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index 9d0adc2..3399b9b 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -38,14 +38,32 @@ Base = declarative_base() def init_db(): Base.metadata.create_all(bind=engine) - # 迁移:为已有表添加 last_login 列 + from sqlalchemy import text try: - from sqlalchemy import text with engine.connect() as conn: + # 迁移:为已有表添加列 conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP")) + conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS provider VARCHAR DEFAULT 'opencode-go'")) + conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR")) + conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR")) + try: + conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json")) + except Exception: + conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'")) + for table, col, typ in [ + ("users", "org_id", "VARCHAR DEFAULT 'default'"), + ("topics", "org_id", "VARCHAR DEFAULT 'default'"), + ]: + try: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}")) + except Exception: + try: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}")) + except Exception: + pass conn.commit() except Exception: - pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用 + pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移 def get_db(): db = SessionLocal() diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py index 9b1829f..d304c72 100644 --- a/platform/backend/app/initial_data.py +++ b/platform/backend/app/initial_data.py @@ -5,7 +5,8 @@ from pathlib import Path from .database import SessionLocal, init_db from .models import ( Topic, TopicField, TopicConfigField, TopicStatusConfig, - User, Case, LLMConfig, SystemConfig, PlatformConfig + User, Case, LLMConfig, SystemConfig, PlatformConfig, + CollectorCategory, CollectorSource ) import bcrypt @@ -27,56 +28,36 @@ def import_initial_data(): admin = User( username=DEFAULT_ADMIN_USERNAME, password_hash=hashed.decode('utf-8'), - role="admin" + role="admin", + org_id="default" ) db.add(admin) db.commit() print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}") - if db.query(LLMConfig).count() == 0: - default_llm = LLMConfig( - name="default_expand", - system_prompt="你是一个专业的内容创作者。", - user_prompt_template="""你是一个专业的内容创作者,擅长将大纲要点扩展为读者爱看+搜索引擎友好的完整章节。 - -### 选题信息 -标题:{topic.get('title')} -领域:{topic.get('field_name')} -核心观点:{topic.get('core_concept', '')} -受众痛点:{topic.get('audience_pain', '')} -独特视角:{topic.get('unique_angle', '')} - -### 当前章节 -## {section_title} -{section_content} - -### 输出要求 -#### 内容价值 -- 以 "## {section_title}" 开始 -- 200-300字,精炼有力 -- 每个论点配具体案例或数据支撑 -- 回答读者「所以呢」——为什么对他有用 - -#### 语言风格 -- 直白有冲击力,避免空洞套话 -- 用「你」或「我们」视角 -- 避免「首先其次最后」「综上所述」 -- 段落短,2-3句一段 - -#### SEO优化 -- 自然融入1-2个目标关键词 -- 开头句包含核心关键词 -- H3子标题有信息量 - -直接输出完整 Markdown 章节(包括标题和正文)。""", - temperature=0.8, - max_tokens=1000, - model="stepfun-ai/step-3.5-flash", - is_active=True - ) - db.add(default_llm) - db.commit() - print("✅ 插入默认 LLM 配置") + # 补充或更新 LLM 供应商配置(opencode-go 为主,nvidia 为备) + expected = { + "opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash", + base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=2000, is_active=True, + user_prompt_template="将以下内容扩展为完整文章:\n{topic_title}\n{core_concept}"), + "nvidia": dict(provider="nvidia", model="stepfun-ai/step-3.5-flash", + base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=2000, is_active=False, + user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"), + } + existing = {c.name: c for c in db.query(LLMConfig).all()} + # 删除完全无意义的旧残留 + for name in list(existing.keys()): + if name not in expected: + db.delete(existing[name]); existing.pop(name) + for name, cfg in expected.items(): + if name in existing: + c = existing[name] + for k, v in cfg.items(): + setattr(c, k, v) + else: + db.add(LLMConfig(name=name, **cfg)) + db.commit() + print(f"✅ LLM 配置已同步: {', '.join(expected.keys())}") default_system_configs = [ {"key": "collector_enabled", "value": "false", "description": "是否启用采集器"}, @@ -149,6 +130,34 @@ def import_initial_data(): db.commit() print("✅ 插入默认领域配置") + if db.query(CollectorCategory).count() == 0: + default_cats = [ + {"name": "循环消费", "search_query": "以旧换新 二手交易 闲置 循环 2026", "description": "以旧换新/二手交易/租赁经济 | 2025年二手交易额1.69万亿", "sort_order": 1, "is_active": True}, + {"name": "低碳出行", "search_query": "新能源车 骑行 绿色通勤 低碳出行 2026", "description": "新能源车/骑行/绿色出行 | 年产销破1000万辆", "sort_order": 2, "is_active": True}, + {"name": "干净饮食", "search_query": "干净饮食 有机食品 植物基 本地食材 2026", "description": "有机食品/植物基/本地食材 | 有机食品1247亿", "sort_order": 3, "is_active": True}, + {"name": "零浪费生活", "search_query": "零浪费 自带杯 极简生活 可持续时尚 2026", "description": "自带杯/极简/可持续时尚 | 自带杯笔记277万篇", "sort_order": 4, "is_active": True}, + {"name": "绿色家电与节能", "search_query": "绿色家电 一级能效 以旧换新 节能 2026", "description": "一级能效/国补政策 | 一级能效占比90%+", "sort_order": 5, "is_active": True}, + {"name": "碳普惠", "search_query": "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026", "description": "碳账户/碳普惠/个人减排 | 武汉200万碳账户", "sort_order": 6, "is_active": True}, + {"name": "环保科技产品", "search_query": "环保科技 绿色产品 可持续材料 2026", "description": "可持续材料/绿色产品 | 购买占比超32%", "sort_order": 7, "is_active": True}, + {"name": "AI与效率", "search_query": "AI工具 人工智能 效率提升 2026", "description": "AI工具/效率方法/数字助手 | 2026年AI深度融入消费与生活", "sort_order": 8, "is_active": True}, + ] + for cd in default_cats: + existing = db.query(CollectorCategory).filter(CollectorCategory.name == cd["name"]).first() + if not existing: + db.add(CollectorCategory(**cd)) + db.commit() + print("✅ 插入默认采集类别和信息源") + + db.commit() + + # 补充缺失的类别和信息源(对已有数据库的迁移) + for sd in [ + {"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 人工智能 效率提升 2026", "credibility": "medium", "focus": "AI与效率", "sort_order": 99, "is_active": True}, + ]: + if not db.query(CollectorSource).filter(CollectorSource.name == sd["name"]).first(): + db.add(CollectorSource(**sd)) + db.commit() + if db.query(TopicStatusConfig).count() == 0: statuses = [ {"status": "pending", "label": "待处理", "color": "#E6A23C", "icon": "⏳", "sort_order": 1, "is_default": True}, diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index 61af15f..deb55f8 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -9,7 +9,7 @@ from pathlib import Path from .database import engine, get_db, init_db from .models import Base -from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config +from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt from .initial_data import import_initial_data from .core.scheduler import scheduler @@ -67,6 +67,7 @@ app.include_router(metrics.router) app.include_router(assets.router) app.include_router(tasks.router) app.include_router(platform_config.router) +app.include_router(collector_mgmt.router) # 挂载前端 FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index 517890f..1436b3a 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -41,6 +41,7 @@ class User(Base): username = Column(String, unique=True, nullable=False, index=True) password_hash = Column(String, nullable=False) role = Column(String, default="user", nullable=False) + org_id = Column(String, default="default", nullable=True) last_login = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -50,6 +51,7 @@ class User(Base): "id": self.id, "username": self.username, "role": self.role, + "org_id": self.org_id, "last_login": self.last_login.isoformat() if self.last_login else None, "created_at": self.created_at.isoformat() if self.created_at else None } @@ -153,6 +155,7 @@ class Topic(Base): id = Column(String, primary_key=True, index=True) field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True) field_name = Column(String, nullable=True) + org_id = Column(String, default="default", nullable=True) title = Column(String, nullable=False) format = Column(String) core_concept = Column(Text) @@ -199,6 +202,7 @@ class Article(Base): html_content = Column(Text) word_count = Column(Integer, nullable=True) outline = Column(Text, nullable=True) + images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"} class PublishRecord(Base): @@ -482,6 +486,9 @@ class LLMConfig(Base): temperature = Column(Float, default=0.7) max_tokens = Column(Integer, default=2000) model = Column(String, nullable=True) + provider = Column(String, default="opencode-go") # opencode-go / nvidia + base_url = Column(String, nullable=True) + api_key = Column(String, nullable=True) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -495,6 +502,9 @@ class LLMConfig(Base): "temperature": self.temperature, "max_tokens": self.max_tokens, "model": self.model, + "provider": self.provider, + "base_url": self.base_url, + "api_key": f"{self.api_key[:8]}..." if self.api_key else None, "is_active": self.is_active, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, @@ -520,4 +530,68 @@ class SystemConfig(Base): "description": self.description, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class CollectorCategory(Base): + """采集类别(可在运营管理中动态编辑)""" + __tablename__ = "collector_categories" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String, unique=True, nullable=False) + description = Column(Text, nullable=True) + search_query = Column(String, nullable=True) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + sources = relationship("CollectorSource", back_populates="category", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "search_query": self.search_query, + "sort_order": self.sort_order, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class CollectorSource(Base): + """采集信息源(可在运营管理中动态编辑)""" + __tablename__ = "collector_sources" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + category_id = Column(Integer, ForeignKey("collector_categories.id"), nullable=True) + name = Column(String, nullable=False) + source_type = Column(String, nullable=False) # rss / web_search / local + url = Column(Text, nullable=True) + query = Column(String, nullable=True) + credibility = Column(String, default="medium") + focus = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + category = relationship("CollectorCategory", back_populates="sources") + + def to_dict(self): + return { + "id": self.id, + "category_id": self.category_id, + "name": self.name, + "source_type": self.source_type, + "url": self.url, + "query": self.query, + "credibility": self.credibility, + "focus": self.focus, + "is_active": self.is_active, + "sort_order": self.sort_order, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } \ No newline at end of file diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index 58cbf25..92b694f 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -61,13 +61,14 @@ class TopicBase(BaseModel): id: str field_id: Optional[int] = None field_name: Optional[str] = None + org_id: Optional[str] = None title: str format: Optional[str] = None core_concept: Optional[str] = None audience_pain: Optional[str] = None unique_angle: Optional[str] = None priority: Optional[str] = None - priority_score: int = 0 + priority_score: int | float = 0 total_score: Optional[float] = None status: str = "pending" tags: List[str] = [] @@ -176,6 +177,8 @@ class ContentCalendarResponse(ContentCalendarBase): created_by: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + topic_status: Optional[str] = None + platform_icon: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -356,6 +359,7 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str + org_id: Optional[str] = None class UserUpdate(BaseModel): @@ -366,6 +370,7 @@ class UserUpdate(BaseModel): class UserResponse(UserBase): id: int + org_id: Optional[str] = None created_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) @@ -457,6 +462,9 @@ class LLMConfigBase(BaseModel): temperature: float = 0.7 max_tokens: int = 2000 model: Optional[str] = None + provider: str = "opencode-go" + base_url: Optional[str] = None + api_key: Optional[str] = None is_active: bool = True @@ -464,6 +472,8 @@ class LLMConfigResponse(LLMConfigBase): id: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html index ff8d77f..80ed5ae 100644 --- a/platform/frontend/admin.html +++ b/platform/frontend/admin.html @@ -7,69 +7,28 @@ - - +
- - + +
@@ -80,7 +39,7 @@
加载中...
-
-
-
标题{{ item.title }}
-
领域{{ item.field }}
-
来源{{ item.source }}
-
+
+
+
标题{{ item.title }}
+
领域{{ item.field }}
+
来源{{ item.source }}
+
编辑 删除
@@ -123,7 +82,7 @@
加载中...
@@ -139,13 +98,13 @@ -
-
-
任务{{ item.task_name }}
-
选题{{ item.topic_id }}
-
状态{{ item.status }}
-
消息{{ item.message }}
-
耗时{{ item.duration }}s
+
+
+
任务{{ item.task_name }}
+
选题{{ item.topic_id }}
+
状态{{ item.status }}
+
消息{{ item.message }}
+
耗时{{ item.duration }}s
@@ -157,7 +116,7 @@
加载中...
- - + + - - + + + + + + + - - + + - + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ diff --git a/platform/frontend/articles.html b/platform/frontend/articles.html new file mode 100644 index 0000000..4a027dc --- /dev/null +++ b/platform/frontend/articles.html @@ -0,0 +1,275 @@ + + + + + + 宇之然内容创作平台 - 文章管理 + + + + + + +
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
{{ article.topic_title || article.topic_id }}
+ {{ statusLabel(article.status) }} +
+
+
ID: {{ article.id }}
+
平台: {{ platformLabel(article.platform) }}
+
合规: {{ article.compliance_score ?? '-' }}
+
字数: {{ article.word_count ?? '-' }}
+
创建: {{ formatDate(article.created_at) }}
+
+
+ 预览 + 删除 +
+
+
+
+ 暂无文章 +
+
+
+
+ +
+
+
+

{{ previewArticleData.topic_title || previewArticleData.topic_id }}

+
+ {{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字 +
+
+
+ 封面 +
+
+
+ {{ previewFullscreen ? '退出全屏' : '全屏' }} + 关闭 +
+
+
+ +
该文章暂无 HTML 内容
+
+
+ + +
+ + + + + + diff --git a/platform/frontend/assets.html b/platform/frontend/assets.html index 229d6a1..470553b 100644 --- a/platform/frontend/assets.html +++ b/platform/frontend/assets.html @@ -7,14 +7,6 @@ - - +
- - + +
-

🖼️ 素材库

+

素材库

- - + + - + - + - 🔄 刷新 - 📤 上传素材 + 刷新 + 上传素材
总计: {{ assetStats.total }} 个 @@ -60,15 +53,15 @@
加载中...
-
📂
+
暂无素材,点击上方按钮上传
- + - +
{{ asset.filename }}
{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}
@@ -87,7 +80,7 @@
-
📤
+
将文件拖到此处,或点击上传
支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4
@@ -104,9 +97,9 @@
- + - +
{{ previewAssetData.filename }}
{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}
@@ -119,6 +112,7 @@
+ diff --git a/platform/frontend/calendar.html b/platform/frontend/calendar.html index 76b263d..a3c6cef 100644 --- a/platform/frontend/calendar.html +++ b/platform/frontend/calendar.html @@ -7,22 +7,23 @@ - - +
- - + +
-

📅 内容日历

+

内容日历

- + {{ currentYear }}年 {{ currentMonth }}月 - + 今天
-
待发布: {{ stats.planned }}
-
已发布: {{ stats.published }}
-
延迟: {{ stats.delayed }}
-
取消: {{ stats.cancelled }}
+
加载中...
+
+ +
暂无日历数据
+
+
@@ -104,7 +107,9 @@ class="day-entry" :class="entry.status" @click.stop="openEntryDialog(entry)"> - {{ entry.platform_icon }} {{ entry.title || '未命名' }} + {{ entry.platform_icon }} + {{ entry.title || '未命名' }} + {{ topicStatusLabel(entry.topic_status) }}
@@ -122,7 +127,7 @@ - +