fix:优化数据
This commit is contained in:
300
backend/app/routers/ai_settings.py
Normal file
300
backend/app/routers/ai_settings.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
AI 模型配置:支持多套配置,持久化在 data/ai_configs.json,可选用当前生效配置。
|
||||
GET /settings/ai 当前选用配置;GET /settings/ai/list 列表;POST 新增;PUT /:id 更新;DELETE /:id 删除;POST /:id/activate 选用。
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.app.services.ai_service import get_active_ai_config, test_connection_with_config
|
||||
|
||||
router = APIRouter(prefix="/settings/ai", tags=["ai-settings"])
|
||||
|
||||
CONFIGS_PATH = Path("data/ai_configs.json")
|
||||
LEGACY_CONFIG_PATH = Path("data/ai_config.json")
|
||||
|
||||
DEFAULT_FIELDS: Dict[str, Any] = {
|
||||
"provider": "OpenAI",
|
||||
"api_key": "",
|
||||
"base_url": "",
|
||||
"model_name": "gpt-4o-mini",
|
||||
"temperature": 0.2,
|
||||
"system_prompt_override": "",
|
||||
}
|
||||
|
||||
|
||||
class AIConfigRead(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
provider: str = "OpenAI"
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
model_name: str = "gpt-4o-mini"
|
||||
temperature: float = 0.2
|
||||
system_prompt_override: str = ""
|
||||
|
||||
|
||||
class AIConfigListItem(BaseModel):
|
||||
"""列表项:不含完整 api_key,仅标记是否已配置"""
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
model_name: str
|
||||
base_url: str = ""
|
||||
api_key_configured: bool = False
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class AIConfigCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
name: str = Field("", max_length=64)
|
||||
provider: str = "OpenAI"
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
model_name: str = "gpt-4o-mini"
|
||||
temperature: float = 0.2
|
||||
system_prompt_override: str = ""
|
||||
|
||||
|
||||
class AIConfigUpdate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
name: str | None = Field(None, max_length=64)
|
||||
provider: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
model_name: str | None = None
|
||||
temperature: float | None = None
|
||||
system_prompt_override: str | None = None
|
||||
|
||||
|
||||
def _load_configs_file() -> Dict[str, Any]:
|
||||
if not CONFIGS_PATH.exists():
|
||||
return {"configs": [], "active_id": ""}
|
||||
try:
|
||||
data = json.loads(CONFIGS_PATH.read_text(encoding="utf-8"))
|
||||
return {"configs": data.get("configs", []), "active_id": data.get("active_id", "") or ""}
|
||||
except Exception:
|
||||
return {"configs": [], "active_id": ""}
|
||||
|
||||
|
||||
def _migrate_from_legacy() -> None:
|
||||
if CONFIGS_PATH.exists():
|
||||
return
|
||||
if not LEGACY_CONFIG_PATH.exists():
|
||||
return
|
||||
try:
|
||||
legacy = json.loads(LEGACY_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
cfg = {**DEFAULT_FIELDS, **legacy}
|
||||
new_id = str(uuid.uuid4())[:8]
|
||||
payload = {
|
||||
"configs": [
|
||||
{
|
||||
"id": new_id,
|
||||
"name": "默认配置",
|
||||
"provider": cfg.get("provider", "OpenAI"),
|
||||
"api_key": cfg.get("api_key", ""),
|
||||
"base_url": cfg.get("base_url", ""),
|
||||
"model_name": cfg.get("model_name", "gpt-4o-mini"),
|
||||
"temperature": cfg.get("temperature", 0.2),
|
||||
"system_prompt_override": cfg.get("system_prompt_override", ""),
|
||||
}
|
||||
],
|
||||
"active_id": new_id,
|
||||
}
|
||||
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIGS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _save_configs(configs: List[Dict], active_id: str) -> None:
|
||||
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIGS_PATH.write_text(
|
||||
json.dumps({"configs": configs, "active_id": active_id}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AIConfigRead)
|
||||
async def get_current_ai_settings():
|
||||
"""返回当前选用的 AI 配置(用于编辑表单与兼容旧接口)。"""
|
||||
_migrate_from_legacy()
|
||||
cfg = get_active_ai_config()
|
||||
return AIConfigRead(
|
||||
id=cfg.get("id", ""),
|
||||
name=cfg.get("name", ""),
|
||||
provider=cfg.get("provider", "OpenAI"),
|
||||
api_key=cfg.get("api_key", ""),
|
||||
base_url=cfg.get("base_url", ""),
|
||||
model_name=cfg.get("model_name", "gpt-4o-mini"),
|
||||
temperature=float(cfg.get("temperature", 0.2)),
|
||||
system_prompt_override=cfg.get("system_prompt_override", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[AIConfigListItem])
|
||||
async def list_ai_configs():
|
||||
"""列出所有已配置的模型,方便查看、选用或编辑。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
configs = data.get("configs") or []
|
||||
active_id = data.get("active_id") or ""
|
||||
out = []
|
||||
for c in configs:
|
||||
out.append(
|
||||
AIConfigListItem(
|
||||
id=c.get("id", ""),
|
||||
name=c.get("name", "未命名"),
|
||||
provider=c.get("provider", "OpenAI"),
|
||||
model_name=c.get("model_name", ""),
|
||||
base_url=(c.get("base_url") or "")[:64] or "",
|
||||
api_key_configured=bool((c.get("api_key") or "").strip()),
|
||||
is_active=(c.get("id") == active_id),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{config_id}", response_model=AIConfigRead)
|
||||
async def get_ai_config_by_id(config_id: str):
|
||||
"""获取单条配置(用于编辑)。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
for c in data.get("configs") or []:
|
||||
if c.get("id") == config_id:
|
||||
return AIConfigRead(
|
||||
id=c.get("id", ""),
|
||||
name=c.get("name", ""),
|
||||
provider=c.get("provider", "OpenAI"),
|
||||
api_key=c.get("api_key", ""),
|
||||
base_url=c.get("base_url", ""),
|
||||
model_name=c.get("model_name", "gpt-4o-mini"),
|
||||
temperature=float(c.get("temperature", 0.2)),
|
||||
system_prompt_override=c.get("system_prompt_override", ""),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
|
||||
|
||||
@router.post("", response_model=AIConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ai_config(payload: AIConfigCreate):
|
||||
"""新增一套模型配置。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
configs = list(data.get("configs") or [])
|
||||
active_id = data.get("active_id") or ""
|
||||
new_id = str(uuid.uuid4())[:8]
|
||||
name = (payload.name or "").strip() or f"{payload.provider} - {payload.model_name}"
|
||||
new_cfg = {
|
||||
"id": new_id,
|
||||
"name": name[:64],
|
||||
"provider": payload.provider or "OpenAI",
|
||||
"api_key": payload.api_key or "",
|
||||
"base_url": (payload.base_url or "").strip(),
|
||||
"model_name": (payload.model_name or "gpt-4o-mini").strip(),
|
||||
"temperature": float(payload.temperature) if payload.temperature is not None else 0.2,
|
||||
"system_prompt_override": (payload.system_prompt_override or "").strip(),
|
||||
}
|
||||
configs.append(new_cfg)
|
||||
if not active_id:
|
||||
active_id = new_id
|
||||
_save_configs(configs, active_id)
|
||||
return AIConfigRead(**new_cfg)
|
||||
|
||||
|
||||
@router.put("/{config_id}", response_model=AIConfigRead)
|
||||
async def update_ai_config(config_id: str, payload: AIConfigUpdate):
|
||||
"""更新指定配置。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
configs = data.get("configs") or []
|
||||
for c in configs:
|
||||
if c.get("id") == config_id:
|
||||
if payload.name is not None:
|
||||
c["name"] = (payload.name or "").strip()[:64] or c.get("name", "")
|
||||
if payload.provider is not None:
|
||||
c["provider"] = payload.provider
|
||||
if payload.api_key is not None:
|
||||
c["api_key"] = payload.api_key
|
||||
if payload.base_url is not None:
|
||||
c["base_url"] = (payload.base_url or "").strip()
|
||||
if payload.model_name is not None:
|
||||
c["model_name"] = (payload.model_name or "").strip()
|
||||
if payload.temperature is not None:
|
||||
c["temperature"] = float(payload.temperature)
|
||||
if payload.system_prompt_override is not None:
|
||||
c["system_prompt_override"] = (payload.system_prompt_override or "").strip()
|
||||
_save_configs(configs, data.get("active_id", ""))
|
||||
return AIConfigRead(
|
||||
id=c.get("id", ""),
|
||||
name=c.get("name", ""),
|
||||
provider=c.get("provider", "OpenAI"),
|
||||
api_key=c.get("api_key", ""),
|
||||
base_url=c.get("base_url", ""),
|
||||
model_name=c.get("model_name", "gpt-4o-mini"),
|
||||
temperature=float(c.get("temperature", 0.2)),
|
||||
system_prompt_override=c.get("system_prompt_override", ""),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ai_config(config_id: str):
|
||||
"""删除指定配置;若为当前选用,则改用列表第一项。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
configs = [c for c in (data.get("configs") or []) if c.get("id") != config_id]
|
||||
active_id = data.get("active_id", "")
|
||||
if active_id == config_id:
|
||||
active_id = configs[0].get("id", "") if configs else ""
|
||||
_save_configs(configs, active_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_ai_connection(config_id: str | None = Query(None, description="指定配置 ID,不传则用当前选用")):
|
||||
"""测试连接;不传 config_id 时使用当前选用配置。"""
|
||||
if config_id:
|
||||
data = _load_configs_file()
|
||||
for c in data.get("configs") or []:
|
||||
if c.get("id") == config_id:
|
||||
try:
|
||||
result = await test_connection_with_config(c)
|
||||
return {"status": "ok", "message": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
try:
|
||||
result = await test_connection_with_config(get_active_ai_config())
|
||||
return {"status": "ok", "message": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/{config_id}/activate", response_model=AIConfigRead)
|
||||
async def activate_ai_config(config_id: str):
|
||||
"""选用该配置为当前生效。"""
|
||||
_migrate_from_legacy()
|
||||
data = _load_configs_file()
|
||||
exists = any(c.get("id") == config_id for c in (data.get("configs") or []))
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
_save_configs(data.get("configs", []), config_id)
|
||||
cfg = get_active_ai_config()
|
||||
return AIConfigRead(
|
||||
id=cfg.get("id", ""),
|
||||
name=cfg.get("name", ""),
|
||||
provider=cfg.get("provider", "OpenAI"),
|
||||
api_key=cfg.get("api_key", ""),
|
||||
base_url=cfg.get("base_url", ""),
|
||||
model_name=cfg.get("model_name", "gpt-4o-mini"),
|
||||
temperature=float(cfg.get("temperature", 0.2)),
|
||||
system_prompt_override=cfg.get("system_prompt_override", ""),
|
||||
)
|
||||
139
backend/app/routers/cloud_doc_config.py
Normal file
139
backend/app/routers/cloud_doc_config.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
云文档配置:各平台 API 凭证的存储与读取。
|
||||
飞书 App ID/Secret、语雀 Token、腾讯文档 Client ID/Secret。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter(prefix="/settings/cloud-doc-config", tags=["cloud-doc-config"])
|
||||
|
||||
CONFIG_PATH = Path("data/cloud_doc_credentials.json")
|
||||
|
||||
PLATFORMS = ("feishu", "yuque", "tencent")
|
||||
|
||||
|
||||
class FeishuConfig(BaseModel):
|
||||
app_id: str = Field("", description="飞书应用 App ID")
|
||||
app_secret: str = Field("", description="飞书应用 App Secret")
|
||||
|
||||
|
||||
class YuqueConfig(BaseModel):
|
||||
token: str = Field("", description="语雀 Personal Access Token")
|
||||
default_repo: str = Field("", description="默认知识库 namespace,如 my/repo")
|
||||
|
||||
|
||||
class TencentConfig(BaseModel):
|
||||
client_id: str = Field("", description="腾讯文档应用 Client ID")
|
||||
client_secret: str = Field("", description="腾讯文档应用 Client Secret")
|
||||
|
||||
|
||||
class FeishuConfigRead(BaseModel):
|
||||
app_id: str = ""
|
||||
app_secret_configured: bool = False
|
||||
|
||||
|
||||
class YuqueConfigRead(BaseModel):
|
||||
token_configured: bool = False
|
||||
default_repo: str = ""
|
||||
|
||||
|
||||
class TencentConfigRead(BaseModel):
|
||||
client_id: str = ""
|
||||
client_secret_configured: bool = False
|
||||
|
||||
|
||||
class CloudDocConfigRead(BaseModel):
|
||||
feishu: FeishuConfigRead
|
||||
yuque: YuqueConfigRead
|
||||
tencent: TencentConfigRead
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
if not CONFIG_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_config(data: Dict[str, Any]) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _mask_secrets_for_read(raw: Dict[str, Any]) -> CloudDocConfigRead:
|
||||
f = raw.get("feishu") or {}
|
||||
y = raw.get("yuque") or {}
|
||||
t = raw.get("tencent") or {}
|
||||
return CloudDocConfigRead(
|
||||
feishu=FeishuConfigRead(
|
||||
app_id=f.get("app_id") or "",
|
||||
app_secret_configured=bool((f.get("app_secret") or "").strip()),
|
||||
),
|
||||
yuque=YuqueConfigRead(
|
||||
token_configured=bool((y.get("token") or "").strip()),
|
||||
default_repo=(y.get("default_repo") or "").strip(),
|
||||
),
|
||||
tencent=TencentConfigRead(
|
||||
client_id=t.get("client_id") or "",
|
||||
client_secret_configured=bool((t.get("client_secret") or "").strip()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CloudDocConfigRead)
|
||||
async def get_cloud_doc_config():
|
||||
"""获取云文档配置(凭证以是否已配置返回,不返回明文)。"""
|
||||
raw = _load_config()
|
||||
return _mask_secrets_for_read(raw)
|
||||
|
||||
|
||||
@router.put("", response_model=CloudDocConfigRead)
|
||||
async def update_cloud_doc_config(payload: Dict[str, Any]):
|
||||
"""
|
||||
更新云文档配置。传各平台字段,未传的保留原值。
|
||||
例: { "feishu": { "app_id": "xxx", "app_secret": "yyy" }, "yuque": { "token": "zzz", "default_repo": "a/b" } }
|
||||
"""
|
||||
raw = _load_config()
|
||||
for platform in PLATFORMS:
|
||||
if platform not in payload or not isinstance(payload[platform], dict):
|
||||
continue
|
||||
p = payload[platform]
|
||||
if platform == "feishu":
|
||||
if "app_id" in p and p["app_id"] is not None:
|
||||
raw.setdefault("feishu", {})["app_id"] = str(p["app_id"]).strip()
|
||||
if "app_secret" in p and p["app_secret"] is not None:
|
||||
raw.setdefault("feishu", {})["app_secret"] = str(p["app_secret"]).strip()
|
||||
elif platform == "yuque":
|
||||
if "token" in p and p["token"] is not None:
|
||||
raw.setdefault("yuque", {})["token"] = str(p["token"]).strip()
|
||||
if "default_repo" in p and p["default_repo"] is not None:
|
||||
raw.setdefault("yuque", {})["default_repo"] = str(p["default_repo"]).strip()
|
||||
elif platform == "tencent":
|
||||
if "client_id" in p and p["client_id"] is not None:
|
||||
raw.setdefault("tencent", {})["client_id"] = str(p["client_id"]).strip()
|
||||
if "client_secret" in p and p["client_secret"] is not None:
|
||||
raw.setdefault("tencent", {})["client_secret"] = str(p["client_secret"]).strip()
|
||||
_save_config(raw)
|
||||
return _mask_secrets_for_read(raw)
|
||||
|
||||
|
||||
def get_credentials(platform: str) -> Dict[str, str]:
|
||||
"""供 cloud_doc_service 使用:读取某平台明文凭证。"""
|
||||
raw = _load_config()
|
||||
return (raw.get(platform) or {}).copy()
|
||||
|
||||
|
||||
def get_all_credentials() -> Dict[str, Dict[str, str]]:
|
||||
"""供推送流程使用:读取全部平台凭证(明文)。"""
|
||||
raw = _load_config()
|
||||
return {k: dict(v) for k, v in raw.items() if isinstance(v, dict)}
|
||||
91
backend/app/routers/cloud_docs.py
Normal file
91
backend/app/routers/cloud_docs.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
云文档快捷入口:持久化在 data/cloud_docs.json,支持增删改查。
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter(prefix="/settings/cloud-docs", tags=["cloud-docs"])
|
||||
|
||||
CONFIG_PATH = Path("data/cloud_docs.json")
|
||||
|
||||
|
||||
class CloudDocLinkCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
|
||||
url: str = Field(..., min_length=1, max_length=512, description="登录/入口 URL")
|
||||
|
||||
|
||||
class CloudDocLinkRead(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class CloudDocLinkUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=64)
|
||||
url: str | None = Field(None, min_length=1, max_length=512)
|
||||
|
||||
|
||||
def _load_links() -> List[Dict[str, Any]]:
|
||||
if not CONFIG_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_links(links: List[Dict[str, Any]]) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(links, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[CloudDocLinkRead])
|
||||
async def list_cloud_docs():
|
||||
"""获取所有云文档快捷入口。"""
|
||||
links = _load_links()
|
||||
return [CloudDocLinkRead(**x) for x in links]
|
||||
|
||||
|
||||
@router.post("", response_model=CloudDocLinkRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_cloud_doc(payload: CloudDocLinkCreate):
|
||||
"""新增一条云文档入口。"""
|
||||
links = _load_links()
|
||||
new_id = str(uuid.uuid4())[:8]
|
||||
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
|
||||
links.append(new_item)
|
||||
_save_links(links)
|
||||
return CloudDocLinkRead(**new_item)
|
||||
|
||||
|
||||
@router.put("/{link_id}", response_model=CloudDocLinkRead)
|
||||
async def update_cloud_doc(link_id: str, payload: CloudDocLinkUpdate):
|
||||
"""更新名称或 URL。"""
|
||||
links = _load_links()
|
||||
for item in links:
|
||||
if item.get("id") == link_id:
|
||||
if payload.name is not None:
|
||||
item["name"] = payload.name.strip()
|
||||
if payload.url is not None:
|
||||
item["url"] = payload.url.strip()
|
||||
_save_links(links)
|
||||
return CloudDocLinkRead(**item)
|
||||
raise HTTPException(status_code=404, detail="云文档入口不存在")
|
||||
|
||||
|
||||
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_cloud_doc(link_id: str):
|
||||
"""删除一条云文档入口。"""
|
||||
links = _load_links()
|
||||
new_list = [x for x in links if x.get("id") != link_id]
|
||||
if len(new_list) == len(links):
|
||||
raise HTTPException(status_code=404, detail="云文档入口不存在")
|
||||
_save_links(new_list)
|
||||
@@ -16,9 +16,18 @@ router = APIRouter(prefix="/customers", tags=["customers"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CustomerRead])
|
||||
async def list_customers(db: Session = Depends(get_db)):
|
||||
customers = db.query(models.Customer).order_by(models.Customer.created_at.desc()).all()
|
||||
return customers
|
||||
async def list_customers(
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列表客户,支持 q 按名称、联系方式模糊搜索。"""
|
||||
query = db.query(models.Customer).order_by(models.Customer.created_at.desc())
|
||||
if q and q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.filter(
|
||||
(models.Customer.name.ilike(term)) | (models.Customer.contact_info.ilike(term))
|
||||
)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
|
||||
@@ -26,6 +35,7 @@ async def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)
|
||||
customer = models.Customer(
|
||||
name=payload.name,
|
||||
contact_info=payload.contact_info,
|
||||
tags=payload.tags,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
@@ -53,6 +63,8 @@ async def update_customer(
|
||||
customer.name = payload.name
|
||||
if payload.contact_info is not None:
|
||||
customer.contact_info = payload.contact_info
|
||||
if payload.tags is not None:
|
||||
customer.tags = payload.tags
|
||||
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
183
backend/app/routers/email_configs.py
Normal file
183
backend/app/routers/email_configs.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Email accounts for multi-email finance sync. Stored in data/email_configs.json.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter(prefix="/settings/email", tags=["email-configs"])
|
||||
|
||||
CONFIG_PATH = Path("data/email_configs.json")
|
||||
|
||||
|
||||
class EmailConfigCreate(BaseModel):
|
||||
host: str = Field(..., description="IMAP host")
|
||||
port: int = Field(993, description="IMAP port")
|
||||
user: str = Field(..., description="Email address")
|
||||
password: str = Field(..., description="Password or authorization code")
|
||||
mailbox: str = Field("INBOX", description="Mailbox name")
|
||||
active: bool = Field(True, description="Include in sync")
|
||||
|
||||
|
||||
class EmailConfigRead(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
port: int
|
||||
user: str
|
||||
mailbox: str
|
||||
active: bool
|
||||
|
||||
|
||||
class EmailConfigUpdate(BaseModel):
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
mailbox: str | None = None
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
def _load_configs() -> List[Dict[str, Any]]:
|
||||
if not CONFIG_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_configs(configs: List[Dict[str, Any]]) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(configs, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _to_read(c: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c["id"],
|
||||
"host": c["host"],
|
||||
"port": c["port"],
|
||||
"user": c["user"],
|
||||
"mailbox": c.get("mailbox", "INBOX"),
|
||||
"active": c.get("active", True),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=List[EmailConfigRead])
|
||||
async def list_email_configs():
|
||||
"""List all email account configs (password omitted)."""
|
||||
configs = _load_configs()
|
||||
return [_to_read(c) for c in configs]
|
||||
|
||||
|
||||
@router.post("", response_model=EmailConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_email_config(payload: EmailConfigCreate):
|
||||
"""Add a new email account."""
|
||||
configs = _load_configs()
|
||||
new_id = str(uuid.uuid4())
|
||||
configs.append({
|
||||
"id": new_id,
|
||||
"host": payload.host,
|
||||
"port": payload.port,
|
||||
"user": payload.user,
|
||||
"password": payload.password,
|
||||
"mailbox": payload.mailbox,
|
||||
"active": payload.active,
|
||||
})
|
||||
_save_configs(configs)
|
||||
return _to_read(configs[-1])
|
||||
|
||||
|
||||
@router.put("/{config_id}", response_model=EmailConfigRead)
|
||||
async def update_email_config(config_id: str, payload: EmailConfigUpdate):
|
||||
"""Update an email account (omit password to keep existing)."""
|
||||
configs = _load_configs()
|
||||
for c in configs:
|
||||
if c.get("id") == config_id:
|
||||
if payload.host is not None:
|
||||
c["host"] = payload.host
|
||||
if payload.port is not None:
|
||||
c["port"] = payload.port
|
||||
if payload.user is not None:
|
||||
c["user"] = payload.user
|
||||
if payload.password is not None:
|
||||
c["password"] = payload.password
|
||||
if payload.mailbox is not None:
|
||||
c["mailbox"] = payload.mailbox
|
||||
if payload.active is not None:
|
||||
c["active"] = payload.active
|
||||
_save_configs(configs)
|
||||
return _to_read(c)
|
||||
raise HTTPException(status_code=404, detail="Email config not found")
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_email_config(config_id: str):
|
||||
"""Remove an email account."""
|
||||
configs = _load_configs()
|
||||
new_list = [c for c in configs if c.get("id") != config_id]
|
||||
if len(new_list) == len(configs):
|
||||
raise HTTPException(status_code=404, detail="Email config not found")
|
||||
_save_configs(new_list)
|
||||
|
||||
|
||||
@router.get("/{config_id}/folders")
|
||||
async def list_email_folders(config_id: str):
|
||||
"""
|
||||
List mailbox folders for this account (for choosing custom labels).
|
||||
Returns [{ "raw": "...", "decoded": "收件箱" }, ...]. Use decoded for display and for mailbox config.
|
||||
"""
|
||||
import asyncio
|
||||
from backend.app.services.email_service import list_mailboxes_for_config
|
||||
|
||||
configs = _load_configs()
|
||||
config = next((c for c in configs if c.get("id") == config_id), None)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Email config not found")
|
||||
host = config.get("host")
|
||||
user = config.get("user")
|
||||
password = config.get("password")
|
||||
port = int(config.get("port", 993))
|
||||
if not all([host, user, password]):
|
||||
raise HTTPException(status_code=400, detail="Config missing host/user/password")
|
||||
|
||||
def _fetch():
|
||||
return list_mailboxes_for_config(host, port, user, password)
|
||||
|
||||
try:
|
||||
folders = await asyncio.to_thread(_fetch)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"无法连接邮箱或获取文件夹列表: {e}") from e
|
||||
|
||||
return {"folders": [{"raw": r, "decoded": d} for r, d in folders]}
|
||||
|
||||
|
||||
def get_email_configs_for_sync() -> List[Dict[str, Any]]:
|
||||
"""Return list of configs that are active (for sync). Falls back to env if file empty."""
|
||||
configs = _load_configs()
|
||||
active = [c for c in configs if c.get("active", True)]
|
||||
if active:
|
||||
return active
|
||||
# Fallback to single account from env
|
||||
import os
|
||||
host = os.getenv("IMAP_HOST")
|
||||
user = os.getenv("IMAP_USER")
|
||||
password = os.getenv("IMAP_PASSWORD")
|
||||
if host and user and password:
|
||||
return [{
|
||||
"id": "env",
|
||||
"host": host,
|
||||
"port": int(os.getenv("IMAP_PORT", "993")),
|
||||
"user": user,
|
||||
"password": password,
|
||||
"mailbox": os.getenv("IMAP_MAILBOX", "INBOX"),
|
||||
"active": True,
|
||||
}]
|
||||
return []
|
||||
@@ -1,8 +1,20 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List
|
||||
|
||||
from backend.app.schemas import FinanceSyncResponse, FinanceSyncResult
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db import get_db
|
||||
from backend.app import models
|
||||
from backend.app.schemas import (
|
||||
FinanceRecordRead,
|
||||
FinanceRecordUpdate,
|
||||
FinanceSyncResponse,
|
||||
FinanceSyncResult,
|
||||
FinanceUploadResponse,
|
||||
)
|
||||
from backend.app.services.email_service import create_monthly_zip, sync_finance_emails
|
||||
from backend.app.services.invoice_upload import process_invoice_upload
|
||||
|
||||
|
||||
router = APIRouter(prefix="/finance", tags=["finance"])
|
||||
@@ -13,10 +25,87 @@ async def sync_finance():
|
||||
try:
|
||||
items_raw = await sync_finance_emails()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
items = [FinanceSyncResult(**item) for item in items_raw]
|
||||
return FinanceSyncResponse(items=items)
|
||||
details = [FinanceSyncResult(**item) for item in items_raw]
|
||||
return FinanceSyncResponse(
|
||||
status="success",
|
||||
new_files=len(details),
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/months", response_model=List[str])
|
||||
async def list_finance_months(db: Session = Depends(get_db)):
|
||||
"""List distinct months that have finance records (YYYY-MM), newest first."""
|
||||
from sqlalchemy import distinct
|
||||
|
||||
rows = (
|
||||
db.query(distinct(models.FinanceRecord.month))
|
||||
.order_by(models.FinanceRecord.month.desc())
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
@router.get("/records", response_model=List[FinanceRecordRead])
|
||||
async def list_finance_records(
|
||||
month: str = Query(..., description="YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List finance records for a given month."""
|
||||
records = (
|
||||
db.query(models.FinanceRecord)
|
||||
.filter(models.FinanceRecord.month == month)
|
||||
.order_by(models.FinanceRecord.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FinanceUploadResponse, status_code=201)
|
||||
async def upload_invoice(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload an invoice (PDF or image). Saves to data/finance/{YYYY-MM}/manual/, runs AI OCR for amount/date."""
|
||||
suf = (file.filename or "").lower().split(".")[-1] if "." in (file.filename or "") else ""
|
||||
allowed = {"pdf", "jpg", "jpeg", "png", "webp"}
|
||||
if suf not in allowed:
|
||||
raise HTTPException(400, "仅支持 PDF、JPG、PNG、WEBP 格式")
|
||||
file_name, file_path, month_str, amount, billing_date = await process_invoice_upload(file)
|
||||
record = models.FinanceRecord(
|
||||
month=month_str,
|
||||
type="manual",
|
||||
file_name=file_name,
|
||||
file_path=file_path,
|
||||
amount=amount,
|
||||
billing_date=billing_date,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@router.patch("/records/{record_id}", response_model=FinanceRecordRead)
|
||||
async def update_finance_record(
|
||||
record_id: int,
|
||||
payload: FinanceRecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update amount and/or billing_date of a finance record (e.g. after manual review)."""
|
||||
record = db.query(models.FinanceRecord).get(record_id)
|
||||
if not record:
|
||||
raise HTTPException(404, "记录不存在")
|
||||
if payload.amount is not None:
|
||||
record.amount = payload.amount
|
||||
if payload.billing_date is not None:
|
||||
record.billing_date = payload.billing_date
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/download/{month}")
|
||||
|
||||
91
backend/app/routers/portal_links.py
Normal file
91
backend/app/routers/portal_links.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
快捷门户入口:持久化在 data/portal_links.json,支持增删改查。
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter(prefix="/settings/portal-links", tags=["portal-links"])
|
||||
|
||||
CONFIG_PATH = Path("data/portal_links.json")
|
||||
|
||||
|
||||
class PortalLinkCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
|
||||
url: str = Field(..., min_length=1, max_length=512, description="门户 URL")
|
||||
|
||||
|
||||
class PortalLinkRead(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class PortalLinkUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=64)
|
||||
url: str | None = Field(None, min_length=1, max_length=512)
|
||||
|
||||
|
||||
def _load_links() -> List[Dict[str, Any]]:
|
||||
if not CONFIG_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_links(links: List[Dict[str, Any]]) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(links, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[PortalLinkRead])
|
||||
async def list_portal_links():
|
||||
"""获取所有快捷门户入口。"""
|
||||
links = _load_links()
|
||||
return [PortalLinkRead(**x) for x in links]
|
||||
|
||||
|
||||
@router.post("", response_model=PortalLinkRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_portal_link(payload: PortalLinkCreate):
|
||||
"""新增一条快捷门户入口。"""
|
||||
links = _load_links()
|
||||
new_id = str(uuid.uuid4())[:8]
|
||||
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
|
||||
links.append(new_item)
|
||||
_save_links(links)
|
||||
return PortalLinkRead(**new_item)
|
||||
|
||||
|
||||
@router.put("/{link_id}", response_model=PortalLinkRead)
|
||||
async def update_portal_link(link_id: str, payload: PortalLinkUpdate):
|
||||
"""更新名称或 URL。"""
|
||||
links = _load_links()
|
||||
for item in links:
|
||||
if item.get("id") == link_id:
|
||||
if payload.name is not None:
|
||||
item["name"] = payload.name.strip()
|
||||
if payload.url is not None:
|
||||
item["url"] = payload.url.strip()
|
||||
_save_links(links)
|
||||
return PortalLinkRead(**item)
|
||||
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
|
||||
|
||||
|
||||
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_portal_link(link_id: str):
|
||||
"""删除一条快捷门户入口。"""
|
||||
links = _load_links()
|
||||
new_list = [x for x in links if x.get("id") != link_id]
|
||||
if len(new_list) == len(links):
|
||||
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
|
||||
_save_links(new_list)
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from backend.app import models
|
||||
from backend.app.db import get_db
|
||||
@@ -11,25 +13,35 @@ from backend.app.schemas import (
|
||||
ContractGenerateResponse,
|
||||
ProjectRead,
|
||||
ProjectUpdate,
|
||||
PushToCloudRequest,
|
||||
PushToCloudResponse,
|
||||
QuoteGenerateResponse,
|
||||
RequirementAnalyzeRequest,
|
||||
RequirementAnalyzeResponse,
|
||||
)
|
||||
from backend.app.services.ai_service import analyze_requirement
|
||||
from backend.app.services.cloud_doc_service import CloudDocManager
|
||||
from backend.app.services.doc_service import (
|
||||
generate_contract_word,
|
||||
generate_quote_excel,
|
||||
generate_quote_pdf_from_data,
|
||||
)
|
||||
from backend.app.routers.cloud_doc_config import get_all_credentials
|
||||
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
||||
def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str:
|
||||
"""
|
||||
Convert structured AI analysis JSON into a human-editable Markdown document.
|
||||
Tolerates AI returning a list (e.g. modules only) and normalizes to a dict.
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# 项目方案草稿")
|
||||
lines.append("")
|
||||
@@ -48,7 +60,15 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
||||
if modules:
|
||||
lines.append("## 功能模块与技术实现")
|
||||
for idx, module in enumerate(modules, start=1):
|
||||
name = module.get("name", f"模块 {idx}")
|
||||
if not isinstance(module, dict):
|
||||
# AI sometimes returns strings or other shapes; treat as a single title line
|
||||
raw_name = str(module).strip() if module else ""
|
||||
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
|
||||
lines.append(f"### {idx}. {name}")
|
||||
lines.append("")
|
||||
continue
|
||||
raw_name = (module.get("name") or "").strip()
|
||||
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
|
||||
desc = module.get("description") or ""
|
||||
tech = module.get("technical_approach") or ""
|
||||
hours = module.get("estimated_hours")
|
||||
@@ -83,13 +103,31 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProjectRead])
|
||||
async def list_projects(db: Session = Depends(get_db)):
|
||||
projects = (
|
||||
async def list_projects(
|
||||
customer_tag: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
||||
query = (
|
||||
db.query(models.Project)
|
||||
.options(joinedload(models.Project.customer))
|
||||
.join(models.Customer)
|
||||
.order_by(models.Project.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return projects
|
||||
if customer_tag and customer_tag.strip():
|
||||
tag = customer_tag.strip()
|
||||
# 客户 tags 逗号分隔,按整词匹配
|
||||
from sqlalchemy import or_
|
||||
t = models.Customer.tags
|
||||
query = query.filter(
|
||||
or_(
|
||||
t == tag,
|
||||
t.ilike(f"{tag},%"),
|
||||
t.ilike(f"%,{tag},%"),
|
||||
t.ilike(f"%,{tag}"),
|
||||
)
|
||||
)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectRead)
|
||||
@@ -109,6 +147,8 @@ async def update_project(
|
||||
project = db.query(models.Project).get(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if payload.raw_requirement is not None:
|
||||
project.raw_requirement = payload.raw_requirement
|
||||
if payload.ai_solution_md is not None:
|
||||
project.ai_solution_md = payload.ai_solution_md
|
||||
if payload.status is not None:
|
||||
@@ -123,12 +163,24 @@ async def analyze_project_requirement(
|
||||
payload: RequirementAnalyzeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
logging.getLogger(__name__).info(
|
||||
"收到 AI 解析请求: customer_id=%s, 需求长度=%d",
|
||||
payload.customer_id,
|
||||
len(payload.raw_text or ""),
|
||||
)
|
||||
# Ensure customer exists
|
||||
customer = db.query(models.Customer).get(payload.customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
||||
|
||||
analysis = await analyze_requirement(payload.raw_text)
|
||||
try:
|
||||
analysis = await analyze_requirement(payload.raw_text)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
ai_solution_md = _build_markdown_from_analysis(analysis)
|
||||
|
||||
project = models.Project(
|
||||
@@ -151,6 +203,7 @@ async def analyze_project_requirement(
|
||||
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
||||
async def generate_project_quote(
|
||||
project_id: int,
|
||||
template: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = db.query(models.Project).get(project_id)
|
||||
@@ -167,7 +220,9 @@ async def generate_project_quote(
|
||||
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
|
||||
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
|
||||
|
||||
template_path = Path("templates/quote_template.xlsx")
|
||||
from backend.app.routers.settings import get_quote_template_path
|
||||
|
||||
template_path = get_quote_template_path(template)
|
||||
if not template_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -254,3 +309,61 @@ async def generate_project_contract(
|
||||
|
||||
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))
|
||||
|
||||
|
||||
@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse)
|
||||
async def push_project_to_cloud(
|
||||
project_id: int,
|
||||
payload: PushToCloudRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
将当前项目方案(Markdown)推送到云文档。若该项目此前已推送过该平台,则更新原文档(增量同步)。
|
||||
"""
|
||||
project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
platform = (payload.platform or "").strip().lower()
|
||||
if platform not in ("feishu", "yuque", "tencent"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="platform 须为 feishu / yuque / tencent",
|
||||
)
|
||||
title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}"
|
||||
body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or ""
|
||||
if not body_md.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送",
|
||||
)
|
||||
existing = (
|
||||
db.query(models.ProjectCloudDoc)
|
||||
.filter(
|
||||
models.ProjectCloudDoc.project_id == project_id,
|
||||
models.ProjectCloudDoc.platform == platform,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
existing_doc_id = existing.cloud_doc_id if existing else None
|
||||
credentials = get_all_credentials()
|
||||
manager = CloudDocManager(credentials)
|
||||
try:
|
||||
cloud_doc_id, url = await manager.push_markdown(
|
||||
platform, title, body_md, existing_doc_id=existing_doc_id
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
if existing:
|
||||
existing.cloud_doc_id = cloud_doc_id
|
||||
existing.cloud_url = url
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
record = models.ProjectCloudDoc(
|
||||
project_id=project_id,
|
||||
platform=platform,
|
||||
cloud_doc_id=cloud_doc_id,
|
||||
cloud_url=url,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id)
|
||||
|
||||
|
||||
93
backend/app/routers/settings.py
Normal file
93
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
TEMPLATES_DIR = Path("data/templates")
|
||||
ALLOWED_EXCEL = {".xlsx", ".xltx"}
|
||||
ALLOWED_WORD = {".docx", ".dotx"}
|
||||
ALLOWED_EXTENSIONS = ALLOWED_EXCEL | ALLOWED_WORD
|
||||
|
||||
# Allowed MIME types when client sends Content-Type (validate if present)
|
||||
ALLOWED_MIME_TYPES = frozenset({
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.template", # .xltx
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", # .dotx
|
||||
})
|
||||
|
||||
|
||||
def _ensure_templates_dir() -> Path:
|
||||
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TEMPLATES_DIR
|
||||
|
||||
|
||||
@router.get("/templates", response_model=List[dict])
|
||||
async def list_templates():
|
||||
"""List uploaded template files (name, type, size, mtime)."""
|
||||
_ensure_templates_dir()
|
||||
out: List[dict] = []
|
||||
for f in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if not f.is_file():
|
||||
continue
|
||||
suf = f.suffix.lower()
|
||||
if suf not in ALLOWED_EXTENSIONS:
|
||||
continue
|
||||
st = f.stat()
|
||||
out.append({
|
||||
"name": f.name,
|
||||
"type": "excel" if suf in ALLOWED_EXCEL else "word",
|
||||
"size": st.st_size,
|
||||
"uploaded_at": st.st_mtime,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/templates/upload", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_template(file: UploadFile = File(...)):
|
||||
"""Upload a .xlsx, .xltx, .docx or .dotx template to data/templates/."""
|
||||
suf = Path(file.filename or "").suffix.lower()
|
||||
if suf not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only .xlsx, .xltx, .docx and .dotx files are allowed.",
|
||||
)
|
||||
content_type = (file.content_type or "").strip().split(";")[0].strip().lower()
|
||||
if content_type and content_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid content type. Allowed: .xlsx, .xltx, .docx, .dotx Office formats.",
|
||||
)
|
||||
dir_path = _ensure_templates_dir()
|
||||
dest = dir_path / (file.filename or "template" + suf)
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
return {"name": dest.name, "path": str(dest)}
|
||||
|
||||
|
||||
def get_latest_excel_template() -> Path | None:
|
||||
"""Return path to the most recently modified .xlsx or .xltx in data/templates, or None."""
|
||||
if not TEMPLATES_DIR.exists():
|
||||
return None
|
||||
excel_files = [
|
||||
f for f in TEMPLATES_DIR.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in ALLOWED_EXCEL
|
||||
]
|
||||
if not excel_files:
|
||||
return None
|
||||
return max(excel_files, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def get_quote_template_path(template_filename: str | None) -> Path:
|
||||
"""Resolve quote template path: optional filename in data/templates or latest excel template or default."""
|
||||
if template_filename:
|
||||
candidate = TEMPLATES_DIR / template_filename
|
||||
if candidate.is_file() and candidate.suffix.lower() in ALLOWED_EXCEL:
|
||||
return candidate
|
||||
latest = get_latest_excel_template()
|
||||
if latest:
|
||||
return latest
|
||||
default = Path("templates/quote_template.xlsx")
|
||||
return default
|
||||
Reference in New Issue
Block a user