fix:优化数据

This commit is contained in:
丹尼尔
2026-03-15 16:38:59 +08:00
parent a609f81a36
commit 3aa1a586e5
43 changed files with 14565 additions and 294 deletions

View 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", ""),
)

View 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)}

View 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)

View File

@@ -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)

View 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 []

View File

@@ -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}")

View 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)

View File

@@ -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)

View 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