1855f190f5
- 新增 PromptConfig 模型 + API,支持提示词在线编辑(16条默认) - 调度器动态读取 TaskConfig.schedule,admin 可调执行时间 - 新增 KeywordDomainMap、SensitiveWord、ContentCleanRule、TrendFieldMapping 表 - DOMAINS、TREND_DOMAIN_MAP、PLATFORM_TAGS、china_pains、RSS关键词、priority_weights 全部迁移到 DB - tasks.html 重构:卡片网格+配置/产出/历史/提示词四个Tab,折叠显示 - 清理冗余代码:DEFAULT_PROMPTS死代码、collector.py unreachable代码、compliance_checker bug - strip_thinking_html 改用 DB 规则优先
390 lines
19 KiB
Python
390 lines
19 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel, ConfigDict
|
|
from datetime import datetime
|
|
import json
|
|
|
|
from ..database import get_db
|
|
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
|
|
from .auth import get_current_admin
|
|
|
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
|
|
|
DEFAULT_KEYWORD_DOMAIN_MAP = [
|
|
{"pattern": r"AI|人工智能|大模型|GPT|机器学习|深度学习|聊天机器人|LLM", "domain": "AI工具", "sort_order": 1},
|
|
{"pattern": r"远程|居家办公|自由职业|数字游民|远程协作", "domain": "远程工作", "sort_order": 2},
|
|
{"pattern": r"可持续|环保|低碳|绿色|碳中和|循环|零浪费|垃圾分类|节能", "domain": "可持续生活", "sort_order": 3},
|
|
{"pattern": r"知识管理|笔记|Obsidian|Notion|第二大脑|读书|阅读", "domain": "知识管理", "sort_order": 4},
|
|
{"pattern": r"数字生活|数码|手机|电脑|智能|APP|应用、软件", "domain": "数字生活", "sort_order": 5},
|
|
{"pattern": r"科技|人文|教育|心理|哲学|社会学", "domain": "科技人文", "sort_order": 6},
|
|
]
|
|
|
|
DEFAULT_TREND_FIELD_MAP = [
|
|
{"trend_keyword": "远程工作", "field_name": "未来工作方式", "sort_order": 1},
|
|
{"trend_keyword": "AI工具", "field_name": "AI与效率", "sort_order": 2},
|
|
{"trend_keyword": "可持续生活", "field_name": "可持续生活系统", "sort_order": 3},
|
|
{"trend_keyword": "知识管理", "field_name": "个人知识工厂", "sort_order": 4},
|
|
{"trend_keyword": "数字生活", "field_name": "科技人文交叉", "sort_order": 5},
|
|
{"trend_keyword": "科技人文", "field_name": "科技人文交叉", "sort_order": 6},
|
|
{"trend_keyword": "个人成长", "field_name": "个人成长", "sort_order": 7},
|
|
{"trend_keyword": "副业", "field_name": "个人成长", "sort_order": 8},
|
|
{"trend_keyword": "AI创作", "field_name": "AI与效率", "sort_order": 9},
|
|
{"trend_keyword": "未来工作", "field_name": "未来工作方式", "sort_order": 10},
|
|
{"trend_keyword": "效率工具", "field_name": "AI与效率", "sort_order": 11},
|
|
{"trend_keyword": "家庭教育", "field_name": "科技人文交叉", "sort_order": 12},
|
|
]
|
|
|
|
DEFAULT_CHINA_PAIN_TEMPLATES = {
|
|
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
|
|
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
|
|
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
|
|
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
|
|
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
|
|
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
|
|
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
|
|
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证",
|
|
}
|
|
|
|
DEFAULT_GLOBAL_RSS_KEYWORDS = ['sustainable', 'green', 'eco', 'circular', 'climate', 'carbon', 'zero waste', 'renewable', 'recycle', '环保', '可持续', '碳中和', '循环经济', '零浪费', '低碳', '生态']
|
|
|
|
DEFAULT_PRIORITY_WEIGHTS = {"audience_match": 0.3, "data_availability": 0.25, "uniqueness": 0.2, "executability": 0.15, "brand_fit": 0.1}
|
|
|
|
DEFAULT_DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
|
|
|
|
DEFAULT_SENSITIVE_WORDS = [
|
|
{"word": "国家主席", "category": "political"},
|
|
{"word": "政治局", "category": "political"},
|
|
{"word": "常委", "category": "political"},
|
|
{"word": "军委", "category": "political"},
|
|
{"word": "统战部", "category": "political"},
|
|
{"word": "颠覆国家", "category": "political"},
|
|
{"word": "分裂主义", "category": "political"},
|
|
{"word": "台独", "category": "political"},
|
|
{"word": "疆独", "category": "political"},
|
|
{"word": "藏独", "category": "political"},
|
|
{"word": "赌博", "category": "prohibited"},
|
|
{"word": "毒品", "category": "prohibited"},
|
|
{"word": "迷药", "category": "prohibited"},
|
|
{"word": "枪支", "category": "prohibited"},
|
|
{"word": "炸药", "category": "prohibited"},
|
|
{"word": "色情", "category": "prohibited"},
|
|
{"word": "低俗", "category": "prohibited"},
|
|
{"word": "反动", "category": "prohibited"},
|
|
{"word": "邪教", "category": "prohibited"},
|
|
{"word": "保证赚钱", "category": "misleading"},
|
|
{"word": "一夜暴富", "category": "misleading"},
|
|
{"word": "100%有效", "category": "misleading"},
|
|
{"word": "包治百病", "category": "misleading"},
|
|
{"word": "绝对正确", "category": "misleading"},
|
|
{"word": "国家机密", "category": "legal"},
|
|
{"word": "军事秘密", "category": "legal"},
|
|
{"word": "绝密", "category": "legal"},
|
|
{"word": "迷信", "category": "legal"},
|
|
]
|
|
|
|
DEFAULT_CONTENT_CLEAN_RULES = [
|
|
{"rule_type": "thinking", "pattern": r"^(好的|好的,|好[的,]|我来|让我|我将|我这就).*?(?=\n|$)", "description": "AI思考模式1", "sort_order": 1},
|
|
{"rule_type": "thinking", "pattern": r"^(以下|下面是|这是|为您|根据).*?(?=\n|$)", "description": "AI思考模式2", "sort_order": 2},
|
|
{"rule_type": "preface", "pattern": r"^(基于|\u3010.*?\u3011|这里.*)", "description": "AI前缀模式", "sort_order": 3},
|
|
{"rule_type": "verbosity", "pattern": r"^首先|^其次|^最后", "description": "AI废话-首先其次", "sort_order": 4},
|
|
{"rule_type": "verbosity", "pattern": r"^总的来说$", "description": "AI废话-总的来说", "sort_order": 5},
|
|
{"rule_type": "verbosity", "pattern": r"^值得注意的是$", "description": "AI废话-值得注意的是", "sort_order": 6},
|
|
{"rule_type": "verbosity", "pattern": r"^换句话说$", "description": "AI废话-换句话说", "sort_order": 7},
|
|
{"rule_type": "verbosity", "pattern": r"^总而言之$", "description": "AI废话-总而言之", "sort_order": 8},
|
|
{"rule_type": "verbosity", "pattern": r"^简而言之$", "description": "AI废话-简而言之", "sort_order": 9},
|
|
{"rule_type": "verbosity", "pattern": r"^一言以蔽之$", "description": "AI废话-一言以蔽之", "sort_order": 10},
|
|
{"rule_type": "verbosity", "pattern": r"^可以说$", "description": "AI废话-可以说", "sort_order": 11},
|
|
{"rule_type": "verbosity", "pattern": r"^不难发现$", "description": "AI废话-不难发现", "sort_order": 12},
|
|
{"rule_type": "verbosity", "pattern": r"^由此可见$", "description": "AI废话-由此可见", "sort_order": 13},
|
|
{"rule_type": "verbosity", "pattern": r"^综上所述$", "description": "AI废话-综上所述", "sort_order": 14},
|
|
{"rule_type": "verbosity", "pattern": r"^通过以上", "description": "AI废话-通过以上", "sort_order": 15},
|
|
{"rule_type": "html_thinking", "pattern": r"<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "description": "AI思考-HTML模式", "sort_order": 16},
|
|
]
|
|
|
|
class KeywordDomainMapResponse(BaseModel):
|
|
id: int
|
|
pattern: str
|
|
domain: str
|
|
sort_order: int
|
|
is_active: bool
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class KeywordDomainMapCreate(BaseModel):
|
|
pattern: str
|
|
domain: str
|
|
sort_order: int = 0
|
|
|
|
class KeywordDomainMapUpdate(BaseModel):
|
|
pattern: Optional[str] = None
|
|
domain: Optional[str] = None
|
|
sort_order: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class SensitiveWordResponse(BaseModel):
|
|
id: int
|
|
word: str
|
|
category: str
|
|
is_active: bool
|
|
added_by: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class SensitiveWordCreate(BaseModel):
|
|
word: str
|
|
category: str = "general"
|
|
|
|
class ContentCleanRuleResponse(BaseModel):
|
|
id: int
|
|
rule_type: str
|
|
pattern: str
|
|
description: Optional[str] = None
|
|
is_active: bool
|
|
sort_order: int
|
|
created_at: Optional[datetime] = None
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class ContentCleanRuleCreate(BaseModel):
|
|
rule_type: str
|
|
pattern: str
|
|
description: Optional[str] = None
|
|
sort_order: int = 0
|
|
|
|
class ContentCleanRuleUpdate(BaseModel):
|
|
pattern: Optional[str] = None
|
|
description: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
sort_order: Optional[int] = None
|
|
|
|
class TrendFieldMappingResponse(BaseModel):
|
|
id: int
|
|
trend_keyword: str
|
|
field_name: str
|
|
sort_order: int
|
|
is_active: bool
|
|
created_at: Optional[datetime] = None
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class TrendFieldMappingCreate(BaseModel):
|
|
trend_keyword: str
|
|
field_name: str
|
|
sort_order: int = 0
|
|
|
|
class TrendFieldMappingUpdate(BaseModel):
|
|
trend_keyword: Optional[str] = None
|
|
field_name: Optional[str] = None
|
|
sort_order: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class SystemConfigValueResponse(BaseModel):
|
|
key: str
|
|
value: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
def _ensure_defaults(db: Session):
|
|
if db.query(KeywordDomainMap).count() == 0:
|
|
for item in DEFAULT_KEYWORD_DOMAIN_MAP:
|
|
db.add(KeywordDomainMap(**item))
|
|
if db.query(SensitiveWord).count() == 0:
|
|
for item in DEFAULT_SENSITIVE_WORDS:
|
|
db.add(SensitiveWord(**item))
|
|
if db.query(ContentCleanRule).count() == 0:
|
|
for item in DEFAULT_CONTENT_CLEAN_RULES:
|
|
db.add(ContentCleanRule(**item))
|
|
if db.query(TrendFieldMapping).count() == 0:
|
|
for item in DEFAULT_TREND_FIELD_MAP:
|
|
db.add(TrendFieldMapping(**item))
|
|
for name, pain in DEFAULT_CHINA_PAIN_TEMPLATES.items():
|
|
cat = db.query(CollectorCategory).filter(CollectorCategory.name == name).first()
|
|
if cat and not cat.pain_template:
|
|
cat.pain_template = pain
|
|
db.commit()
|
|
|
|
@router.get("/keyword-domain-map", response_model=List[KeywordDomainMapResponse])
|
|
def list_keyword_domain_map(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
_ensure_defaults(db)
|
|
return db.query(KeywordDomainMap).order_by(KeywordDomainMap.sort_order).all()
|
|
|
|
@router.post("/keyword-domain-map", response_model=KeywordDomainMapResponse)
|
|
def create_keyword_domain_map(data: KeywordDomainMapCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = KeywordDomainMap(**data.model_dump())
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.put("/keyword-domain-map/{item_id}", response_model=KeywordDomainMapResponse)
|
|
def update_keyword_domain_map(item_id: int, data: KeywordDomainMapUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
if v is not None:
|
|
setattr(item, k, v)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.delete("/keyword-domain-map/{item_id}")
|
|
def delete_keyword_domain_map(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "删除成功"}
|
|
|
|
@router.get("/sensitive-words", response_model=List[SensitiveWordResponse])
|
|
def list_sensitive_words(category: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
_ensure_defaults(db)
|
|
q = db.query(SensitiveWord)
|
|
if category:
|
|
q = q.filter(SensitiveWord.category == category)
|
|
return q.order_by(SensitiveWord.id).all()
|
|
|
|
@router.post("/sensitive-words", response_model=SensitiveWordResponse)
|
|
def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = SensitiveWord(**data.model_dump(), added_by=admin_user.username)
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse)
|
|
def update_sensitive_word(item_id: int, enabled: bool = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
if enabled is not None:
|
|
item.is_active = enabled
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.delete("/sensitive-words/{item_id}")
|
|
def delete_sensitive_word(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "删除成功"}
|
|
|
|
@router.get("/content-clean-rules", response_model=List[ContentCleanRuleResponse])
|
|
def list_content_clean_rules(rule_type: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
_ensure_defaults(db)
|
|
q = db.query(ContentCleanRule)
|
|
if rule_type:
|
|
q = q.filter(ContentCleanRule.rule_type == rule_type)
|
|
return q.order_by(ContentCleanRule.sort_order).all()
|
|
|
|
@router.post("/content-clean-rules", response_model=ContentCleanRuleResponse)
|
|
def create_content_clean_rule(data: ContentCleanRuleCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = ContentCleanRule(**data.model_dump())
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.put("/content-clean-rules/{item_id}", response_model=ContentCleanRuleResponse)
|
|
def update_content_clean_rule(item_id: int, data: ContentCleanRuleUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
if v is not None:
|
|
setattr(item, k, v)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.delete("/content-clean-rules/{item_id}")
|
|
def delete_content_clean_rule(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "删除成功"}
|
|
|
|
@router.get("/trend-field-mapping", response_model=List[TrendFieldMappingResponse])
|
|
def list_trend_field_mapping(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
_ensure_defaults(db)
|
|
return db.query(TrendFieldMapping).filter(TrendFieldMapping.is_active == True).order_by(TrendFieldMapping.sort_order).all()
|
|
|
|
@router.post("/trend-field-mapping", response_model=TrendFieldMappingResponse)
|
|
def create_trend_field_mapping(data: TrendFieldMappingCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = TrendFieldMapping(**data.model_dump())
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.put("/trend-field-mapping/{item_id}", response_model=TrendFieldMappingResponse)
|
|
def update_trend_field_mapping(item_id: int, data: TrendFieldMappingUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
if v is not None:
|
|
setattr(item, k, v)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.delete("/trend-field-mapping/{item_id}")
|
|
def delete_trend_field_mapping(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "删除成功"}
|
|
|
|
@router.get("/system-config/{key}", response_model=SystemConfigValueResponse)
|
|
def get_system_config(key: str, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
from ..models import SystemConfig
|
|
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
|
if not cfg:
|
|
default_map = {
|
|
"trend_domains": json.dumps(DEFAULT_DOMAINS),
|
|
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
|
|
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
|
|
}
|
|
if key in default_map:
|
|
return SystemConfigValueResponse(key=key, value=default_map[key], description=f"系统默认配置 - {key}")
|
|
raise HTTPException(status_code=404, detail="未找到")
|
|
return SystemConfigValueResponse(key=cfg.key, value=cfg.value, description=cfg.description)
|
|
|
|
@router.put("/system-config/{key}")
|
|
def update_system_config(key: str, value: str, description: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
from ..models import SystemConfig
|
|
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
|
if cfg:
|
|
cfg.value = value
|
|
if description is not None:
|
|
cfg.description = description
|
|
else:
|
|
cfg = SystemConfig(key=key, value=value, description=description or key)
|
|
db.add(cfg)
|
|
db.commit()
|
|
return {"message": "保存成功", "key": key, "value": value}
|
|
|
|
@router.get("/system-configs")
|
|
def list_system_configs(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
|
from ..models import SystemConfig
|
|
configs = db.query(SystemConfig).order_by(SystemConfig.key).all()
|
|
defaults = {
|
|
"trend_domains": json.dumps(DEFAULT_DOMAINS),
|
|
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
|
|
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
|
|
}
|
|
result = []
|
|
for c in configs:
|
|
result.append({"key": c.key, "value": c.value, "description": c.description, "is_default": False})
|
|
for k, v in defaults.items():
|
|
if not any(x["key"] == k for x in result):
|
|
result.append({"key": k, "value": v, "description": f"系统默认 - {k}", "is_default": True})
|
|
return result |