细节优化

This commit is contained in:
amb
2026-09-02 18:04:41 +08:00
parent cecb5f2714
commit 8ec7856bfc
21 changed files with 1260 additions and 569 deletions
@@ -0,0 +1,29 @@
"""add_kb_token_expires_at
Revision ID: 5bb03575e2e7
Revises: 4e40432cab9f
Create Date: 2026-09-02 17:49:46.298836
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5bb03575e2e7'
down_revision: Union[str, None] = '4e40432cab9f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('knowledge_bases', sa.Column('token_expires_at', sa.String(length=32), nullable=True, comment='链接过期时间,NULL=长期有效'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('knowledge_bases', 'token_expires_at')
# ### end Alembic commands ###
+35 -3
View File
@@ -9,6 +9,7 @@ from app.schemas.knowledge_base import (
KbCreateRequest, KbCreateRequest,
KbListResponse, KbListResponse,
KbResponse, KbResponse,
KbSetExpiryRequest,
KbTokenResponse, KbTokenResponse,
KbUpdateRequest, KbUpdateRequest,
) )
@@ -86,6 +87,8 @@ def regenerate_token(
) -> KbTokenResponse: ) -> KbTokenResponse:
svc = KnowledgeBaseService(db) svc = KnowledgeBaseService(db)
kb, token = svc.regenerate_token(kb_id, user) kb, token = svc.regenerate_token(kb_id, user)
# 重新生成 = 全新链接,有效期重置为长期
svc.set_expiry(kb_id, user, None)
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint) return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint)
@@ -117,13 +120,42 @@ def get_knowledge_base_link(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> KbTokenResponse: ) -> KbTokenResponse:
"""获取完整 AI 链接(解密 token)。""" """获取完整 AI 链接(解密 token)+ 有效期信息"""
from app.services.kb_public_service import KbPublicService
svc = KnowledgeBaseService(db) svc = KnowledgeBaseService(db)
kb = svc.get_or_404(kb_id, user) kb = svc.get_or_404(kb_id, user)
token = svc.get_full_token(kb) token = svc.get_full_token(kb)
if token is None: if token is None:
return KbTokenResponse(token="", ai_url="", token_hint=kb.token_hint or "") return KbTokenResponse(
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint or "") token="", ai_url="", token_hint=kb.token_hint or "",
expires_at=None, is_expired=False,
)
return KbTokenResponse(
token=token,
ai_url=f"/k/{token}",
token_hint=kb.token_hint or "",
expires_at=kb.token_expires_at,
is_expired=KbPublicService.is_expired(kb),
)
@router.post("/{kb_id}/set-expiry", response_model=KbResponse)
def set_link_expiry(
kb_id: str,
body: KbSetExpiryRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
"""设置 AI 链接有效期。expires_in_minutes 为 null 表示长期有效。"""
svc = KnowledgeBaseService(db)
kb = svc.set_expiry(kb_id, user, body.expires_in_minutes)
doc_count = svc._kb_repo.count_documents(kb_id)
return KbResponse(
id=kb.id, name=kb.name, description=kb.description, enabled=kb.enabled,
token_hint=kb.token_hint, document_count=doc_count,
created_at=kb.created_at, updated_at=kb.updated_at,
)
def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse: def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse:
+4
View File
@@ -38,6 +38,10 @@ class Settings(BaseSettings):
rate_limit_per_token_per_min: int = 60 rate_limit_per_token_per_min: int = 60
rate_limit_per_ip_per_min: int = 30 rate_limit_per_ip_per_min: int = 30
# --- 数据保留 ---
# 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐)
access_log_retention_days: int = 90
# --- CORS --- # --- CORS ---
frontend_origin: str = "http://localhost:5173" frontend_origin: str = "http://localhost:5173"
+7
View File
@@ -83,6 +83,13 @@ class RateLimitedError(AppError):
message = "请求过于频繁,请稍后再试。" message = "请求过于频繁,请稍后再试。"
class LinkExpiredError(AppError):
"""AI 链接已过期(HTTP 410 Gone)。"""
status_code = status.HTTP_410_GONE
code = "LINK_EXPIRED"
message = "链接已失效,请重新生成。"
# --- 上传与配额 --- # --- 上传与配额 ---
+53 -1
View File
@@ -18,7 +18,7 @@ from app.api.knowledge_bases import router as kb_router
from app.public.routes import router as public_router from app.public.routes import router as public_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
from app.core.errors import register_exception_handlers from app.core.errors import LinkExpiredError, register_exception_handlers
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -40,6 +40,43 @@ def _seed_free_plan() -> None:
logger.debug("Free plan already exists (id=%s)", plan.id) logger.debug("Free plan already exists (id=%s)", plan.id)
def _cleanup_access_logs() -> int:
"""清理超过保留期的访问日志。返回删除条数。"""
from datetime import datetime, timedelta
from sqlalchemy import delete
from app.core.config import get_settings
from app.models.access_log import AccessLog
settings = get_settings()
retention = settings.access_log_retention_days
if retention <= 0:
return 0
cutoff = (datetime.now() - timedelta(days=retention)).strftime("%Y-%m-%d %H:%M:%S")
factory = get_session_factory()
with factory() as session:
result = session.execute(delete(AccessLog).where(AccessLog.accessed_at < cutoff))
session.commit()
count = result.rowcount or 0
if count:
logger.info("已清理 %d 条过期访问日志(保留 %d 天)", count, retention)
return count
async def _periodic_log_cleanup() -> None:
"""每 24 小时清理一次过期访问日志(在线程池执行同步 DB 操作)。"""
import asyncio
while True:
await asyncio.sleep(24 * 3600)
try:
await asyncio.to_thread(_cleanup_access_logs)
except Exception: # noqa: BLE001
logger.exception("定期清理访问日志失败")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
@@ -53,8 +90,18 @@ async def lifespan(app: FastAPI):
# Seed:确保 free plan 存在 # Seed:确保 free plan 存在
_seed_free_plan() _seed_free_plan()
# 启动时清理过期访问日志(不阻塞启动)
import asyncio
cleanup_task = asyncio.create_task(_periodic_log_cleanup())
try:
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
except Exception: # noqa: BLE001
logger.warning("启动时清理访问日志未完成(首次部署属正常)")
yield yield
cleanup_task.cancel()
dispose_engine() dispose_engine()
logger.info("Backend shutdown complete") logger.info("Backend shutdown complete")
@@ -80,6 +127,11 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
# 链接过期:HTML 返回友好失效页,JSON/MD/TXT 返回对应格式(HTTP 410
from app.public.routes import link_expired_handler
app.add_exception_handler(LinkExpiredError, link_expired_handler)
# 路由挂载 # 路由挂载
app.include_router(health_router, prefix="/api", tags=["health"]) app.include_router(health_router, prefix="/api", tags=["health"])
app.include_router(auth_router, prefix="/api", tags=["auth"]) app.include_router(auth_router, prefix="/api", tags=["auth"])
+6 -3
View File
@@ -1,7 +1,7 @@
"""SQLAlchemy 2.0 基础模型类与 Mixin。""" """SQLAlchemy 2.0 基础模型类与 Mixin。"""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime
from sqlalchemy import String, text from sqlalchemy import String, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -13,8 +13,11 @@ def generate_uuid() -> str:
def utcnow_iso() -> str: def utcnow_iso() -> str:
"""返回 UTC 当前时间的 ISO8601 字符串。""" """当前时间,格式:年-月-日 时:分:秒(容器时区,生产为 Asia/Shanghai)。
return datetime.now(timezone.utc).isoformat()
定长格式保证字符串排序 = 时间排序。
"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Base(DeclarativeBase): class Base(DeclarativeBase):
+5
View File
@@ -60,6 +60,11 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=True, nullable=True,
comment="token 末 8 位明文,供后台识别", comment="token 末 8 位明文,供后台识别",
) )
token_expires_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
comment="链接过期时间,NULL=长期有效",
)
# 关系 # 关系
user = relationship("User", back_populates="knowledge_bases", lazy="selectin") user = relationship("User", back_populates="knowledge_bases", lazy="selectin")
+46 -1
View File
@@ -23,7 +23,7 @@ from fastapi import Path as PathParam
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError from app.core.errors import LinkExpiredError, NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token from app.core.security import decrypt_token
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
@@ -33,6 +33,51 @@ from app.services.kb_public_service import KbPublicService
router = APIRouter(prefix="/k", tags=["public"]) router = APIRouter(prefix="/k", tags=["public"])
async def link_expired_handler(request: Request, exc: LinkExpiredError) -> Response:
"""链接过期:HTML 路径返回友好失效页,JSON/MD/TXT 返回对应格式的错误信息。"""
path = request.url.path
message = "链接已失效,请联系分享者重新生成链接。"
if path.endswith(".json"):
return Response(
status_code=410,
content=json.dumps({"code": "LINK_EXPIRED", "message": message, "detail": None}, ensure_ascii=False),
media_type="application/json",
)
if path.endswith(".md") or path.endswith(".txt"):
return PlainTextResponse(content=message, status_code=410, media_type="text/plain; charset=utf-8")
return HTMLResponse(
status_code=410,
content=f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>链接已失效</title>
<meta name="robots" content="noindex,nofollow,noarchive">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
margin: 0; background: #f5f7fa; color: #333; text-align: center; }}
.box {{ background: #fff; padding: 50px 40px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 420px; }}
.icon {{ font-size: 52px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 12px; }}
p {{ color: #888; font-size: 14px; line-height: 1.8; margin: 0; }}
</style>
</head>
<body>
<div class="box">
<div class="icon">⏰</div>
<h1>链接无效或已失效</h1>
<p>该知识库链接已过期。<br>请联系分享者重新生成链接后,获取新的访问地址。</p>
</div>
</body>
</html>""",
)
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None: def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
"""记录访问日志(best-effort)。""" """记录访问日志(best-effort)。"""
try: try:
+8 -1
View File
@@ -33,7 +33,14 @@ class KbListResponse(BaseModel):
class KbTokenResponse(BaseModel): class KbTokenResponse(BaseModel):
"""Token 重置/创建时的完整 URL 响应。""" """Token 重置/创建/查询时的完整 URL 响应。"""
token: str token: str
ai_url: str ai_url: str
token_hint: str token_hint: str
expires_at: str | None = None
is_expired: bool = False
class KbSetExpiryRequest(BaseModel):
"""设置链接有效期。expires_in_minutes=null 表示长期有效。"""
expires_in_minutes: int | None = None
+2 -2
View File
@@ -1,6 +1,6 @@
"""访问日志服务。""" """访问日志服务。"""
from datetime import datetime, timezone from datetime import datetime
from app.models.access_log import AccessLog from app.models.access_log import AccessLog
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -24,7 +24,7 @@ class AccessLogService:
knowledge_base_id=knowledge_base_id, knowledge_base_id=knowledge_base_id,
document_id=document_id, document_id=document_id,
path=path, path=path,
accessed_at=datetime.now(timezone.utc).isoformat(), accessed_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
user_agent=user_agent[:500] if user_agent else None, user_agent=user_agent[:500] if user_agent else None,
request_type=request_type, request_type=request_type,
) )
+16
View File
@@ -156,10 +156,26 @@ class DocumentService:
doc = self.get_or_404(doc_id, user) doc = self.get_or_404(doc_id, user)
file_size = doc.file_size file_size = doc.file_size
self._doc_repo.delete(doc) self._doc_repo.delete(doc)
self._delete_doc_files(doc)
# 回补配额 # 回补配额
self._restore_quota(user, file_size) self._restore_quota(user, file_size)
self._session.commit() self._session.commit()
def _delete_doc_files(self, doc: Document) -> None:
"""物理删除文档的原始文件与 Markdown 文件(软删后调用)。"""
from app.storage.local_storage import get_storage
storage = get_storage()
for key in (doc.storage_path, doc.markdown_path):
if not key:
continue
try:
storage.delete(key)
except Exception as exc: # noqa: BLE001
import logging
logging.getLogger(__name__).warning("清理文件失败 key=%s: %s", key, exc)
def _check_quota(self, user: User, file_size: int) -> None: def _check_quota(self, user: User, file_size: int) -> None:
settings = get_settings() settings = get_settings()
if user.storage_used + file_size > settings.default_storage_quota: if user.storage_used + file_size > settings.default_storage_quota:
+28 -2
View File
@@ -4,7 +4,9 @@ HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询
支持目录树结构和分类过滤。 支持目录树结构和分类过滤。
""" """
from app.core.errors import NotFoundError from datetime import datetime
from app.core.errors import LinkExpiredError, NotFoundError
from app.core.security import hash_token from app.core.security import hash_token
from app.models.document import Document from app.models.document import Document
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
@@ -14,6 +16,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
class KbPublicService: class KbPublicService:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
@@ -22,13 +26,35 @@ class KbPublicService:
self._doc_repo = DocumentRepository(session) self._doc_repo = DocumentRepository(session)
def get_kb_by_token(self, token: str) -> KnowledgeBase: def get_kb_by_token(self, token: str) -> KnowledgeBase:
"""通过 token 获取知识库。不存在/禁用/删除 → 404。""" """通过 token 获取知识库。
不存在/禁用/删除 → 404;存在但已过期 → 410LinkExpiredError)。
"""
token_hash = hash_token(token) token_hash = hash_token(token)
kb = self._kb_repo.get_by_token_hash(token_hash) kb = self._kb_repo.get_by_token_hash(token_hash)
if kb is None or not kb.enabled or kb.status == "DELETED": if kb is None or not kb.enabled or kb.status == "DELETED":
raise NotFoundError("知识库不存在。") raise NotFoundError("知识库不存在。")
if kb.token_expires_at:
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
expires = None
if expires is not None and datetime.now() > expires:
raise LinkExpiredError()
return kb return kb
@staticmethod
def is_expired(kb: KnowledgeBase) -> bool:
"""判断知识库链接是否已过期(管理端展示用)。"""
if not kb.token_expires_at:
return False
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
return False
return datetime.now() > expires
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]: def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。""" """获取目录树(含文档数量)。"""
stmt = ( stmt = (
+44
View File
@@ -1,6 +1,7 @@
"""知识库服务:CRUD + Token 管理 + 默认目录树。""" """知识库服务:CRUD + Token 管理 + 默认目录树。"""
from app.core.errors import NotFoundError, PermissionDeniedError from app.core.errors import NotFoundError, PermissionDeniedError
from app.core.logging import get_logger
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base import KnowledgeBase
from app.models.user import User from app.models.user import User
@@ -8,6 +9,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
from app.services.token_service import TokenService from app.services.token_service import TokenService
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = get_logger(__name__)
class KnowledgeBaseService: class KnowledgeBaseService:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
@@ -96,8 +99,35 @@ class KnowledgeBaseService:
def delete(self, kb_id: str, user: User) -> None: def delete(self, kb_id: str, user: User) -> None:
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
self._kb_repo.delete(kb) self._kb_repo.delete(kb)
self._cleanup_kb_files(kb)
self._session.commit() self._session.commit()
def _cleanup_kb_files(self, kb: KnowledgeBase) -> None:
"""物理删除知识库下所有文档的原始文件与 Markdown 文件(软删后调用)。
文件删除失败不阻塞删除流程(记录日志,可由后续清理兜底)。
"""
from sqlalchemy import select
from app.models.document import Document
from app.storage.local_storage import get_storage
stmt = select(Document).where(Document.knowledge_base_id == kb.id)
docs = list(self._session.scalars(stmt).all())
storage = get_storage()
removed = 0
for doc in docs:
for key in (doc.storage_path, doc.markdown_path):
if not key:
continue
try:
storage.delete(key)
removed += 1
except Exception as exc: # noqa: BLE001
logger.warning("清理文件失败 key=%s: %s", key, exc)
if removed:
logger.info("KB %s 软删,已物理清理 %d 个文件", kb.id, removed)
def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]: def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]:
"""重新生成 Token。旧链接立即失效。""" """重新生成 Token。旧链接立即失效。"""
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
@@ -117,6 +147,20 @@ class KnowledgeBaseService:
self._session.commit() self._session.commit()
return kb return kb
def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase:
"""设置链接有效期。None = 长期有效;负数表示已过期(测试用)。"""
from datetime import datetime, timedelta
kb = self.get_or_404(kb_id, user)
if expires_in_minutes is None:
kb.token_expires_at = None
else:
kb.token_expires_at = (datetime.now() + timedelta(minutes=expires_in_minutes)).strftime(
"%Y-%m-%d %H:%M:%S"
)
self._session.commit()
return kb
def get_full_token(self, kb: KnowledgeBase) -> str | None: def get_full_token(self, kb: KnowledgeBase) -> str | None:
"""解密 token 原文(供后台显示完整链接)。""" """解密 token 原文(供后台显示完整链接)。"""
if kb.token_encrypted: if kb.token_encrypted:
+193
View File
@@ -0,0 +1,193 @@
"""链接有效期功能测试。"""
import io
import zipfile
from fastapi.testclient import TestClient
from app.main import app
def _client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
def _setup_kb(client: TestClient) -> tuple[str, str]:
"""注册+创建知识库,返回 (kb_id, token)。"""
client.post("/api/auth/register", json={
"username": "expiry_user", "email": "expiry@example.com", "password": "password123",
})
resp = client.post("/api/knowledge-bases", json={"name": "Expiry Test KB"})
kb_id = resp.json()["id"]
resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
return kb_id, resp.json()["token"]
def test_default_permanent() -> None:
with _client() as client:
kb_id, token = _setup_kb(client)
resp = client.get(f"/k/{token}")
assert resp.status_code == 200
# link 接口返回长期有效
resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.json()["expires_at"] is None
assert resp.json()["is_expired"] is False
def test_set_expiry_future() -> None:
with _client() as client:
kb_id, token = _setup_kb(client)
resp = client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": 10})
assert resp.status_code == 200
# 未过期仍可访问
assert client.get(f"/k/{token}").status_code == 200
# link 接口返回过期时间
resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.json()["expires_at"] is not None
assert resp.json()["is_expired"] is False
def test_expired_link_html() -> None:
with _client() as client:
kb_id, token = _setup_kb(client)
# 设为过去时间(负数分钟)→ 立即过期
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1})
resp = client.get(f"/k/{token}")
assert resp.status_code == 410
assert "已失效" in resp.text
assert "noindex" in resp.text
def test_expired_link_json_md_txt() -> None:
with _client() as client:
kb_id, token = _setup_kb(client)
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1})
resp = client.get(f"/k/{token}.json")
assert resp.status_code == 410
assert resp.json()["code"] == "LINK_EXPIRED"
resp = client.get(f"/k/{token}.md")
assert resp.status_code == 410
assert "已失效" in resp.text
resp = client.get(f"/k/{token}.txt")
assert resp.status_code == 410
def test_expired_doc_page() -> None:
"""过期后文档页也不可访问。"""
with _client() as client:
kb_id, token = _setup_kb(client)
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1})
resp = client.get(f"/k/{token}/doc/whatever-token")
assert resp.status_code == 410
def test_restore_permanent() -> None:
"""过期后重新设为长期有效可恢复访问。"""
with _client() as client:
kb_id, token = _setup_kb(client)
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1})
assert client.get(f"/k/{token}").status_code == 410
# 恢复
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": None})
assert client.get(f"/k/{token}").status_code == 200
def test_regenerate_resets_expiry() -> None:
with _client() as client:
kb_id, old_token = _setup_kb(client)
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1})
# 重新生成 → 新链接长期有效
resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token")
new_token = resp.json()["token"]
assert new_token != old_token
assert client.get(f"/k/{new_token}").status_code == 200
resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.json()["expires_at"] is None
def _make_docx_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types></Types>')
zf.writestr("_rels/.rels", '<?xml version="1.0"?><Relationships></Relationships>')
zf.writestr("word/document.xml", '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Test</w:t></w:r></w:p></w:body></w:document>')
return buf.getvalue()
def test_doc_delete_removes_files() -> None:
"""删除文档后物理文件应被清理。"""
with _client() as client:
client.post("/api/auth/register", json={
"username": "cleanup_user", "email": "cleanup@example.com", "password": "password123",
})
resp = client.post("/api/knowledge-bases", json={"name": "Cleanup KB"})
kb_id = resp.json()["id"]
files = {"file": ("t.docx", _make_docx_bytes(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
resp = client.get(f"/api/documents?kb_id={kb_id}")
doc = resp.json()["items"][0]
storage_path = doc["storage_path"] if "storage_path" in doc else None
doc_id = doc["id"]
# 删除文档
resp = client.delete(f"/api/documents/{doc_id}")
assert resp.status_code == 204
# 验证物理文件已删除
from app.core.config import get_settings
from pathlib import Path
# 通过数据库查询 storage_path(响应里没有)
from app.core.db import get_session_factory
from app.models.document import Document
factory = get_session_factory()
with factory() as session:
d = session.get(Document, doc_id)
storage_path = d.storage_path
if storage_path:
full = Path(get_settings().storage_root_path) / storage_path
assert not full.exists(), f"文件未被清理: {full}"
def test_kb_delete_removes_doc_files() -> None:
"""删除知识库后其下所有文档文件应被清理。"""
with _client() as client:
client.post("/api/auth/register", json={
"username": "kb_cleanup_user", "email": "kbcleanup@example.com", "password": "password123",
})
resp = client.post("/api/knowledge-bases", json={"name": "KB Cleanup KB"})
kb_id = resp.json()["id"]
files = {"file": ("t.docx", _make_docx_bytes(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
from app.core.db import get_session_factory
from app.models.document import Document
from app.core.config import get_settings
from pathlib import Path
factory = get_session_factory()
with factory() as session:
docs = list(session.query(Document).filter_by(knowledge_base_id=kb_id).all())
paths = [d.storage_path for d in docs if d.storage_path]
# 删除知识库
resp = client.delete(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 204
root = Path(get_settings().storage_root_path)
for p in paths:
assert not (root / p).exists(), f"文件未被清理: {p}"
+1
View File
@@ -17,6 +17,7 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key} SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key}
# 站点未配置 HTTPS 时必须为 false,否则浏览器丢弃 Cookie(登录后被踢回登录页) # 站点未配置 HTTPS 时必须为 false,否则浏览器丢弃 Cookie(登录后被踢回登录页)
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
TZ: Asia/Shanghai
DATABASE_URL: mysql+pymysql://admin:${MYSQL_PASSWORD:-Lzcc6-01}@host.docker.internal:33306/amb_rag?charset=utf8mb4 DATABASE_URL: mysql+pymysql://admin:${MYSQL_PASSWORD:-Lzcc6-01}@host.docker.internal:33306/amb_rag?charset=utf8mb4
STORAGE_ROOT: /app/data STORAGE_ROOT: /app/data
DEFAULT_STORAGE_QUOTA: "104857600" DEFAULT_STORAGE_QUOTA: "104857600"
+31 -8
View File
@@ -1,37 +1,60 @@
/** /**
* 兼容所有浏览器的复制到剪贴板函数 * 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题 *
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3
*/ */
export async function copyToClipboard(text) { export async function copyToClipboard(text) {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端 // 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} }
catch { catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback /* 权限被拒或非安全上下文,继续降级 */
} }
} }
// 方法2: 传统 execCommandiOS Safari 兼容方案 // 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try { try {
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = text; textarea.value = text;
// 防止页面滚动 textarea.setAttribute('readonly', '');
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed'; textarea.style.position = 'fixed';
textarea.style.left = '-9999px'; textarea.style.left = '-9999px';
textarea.style.top = '-9999px'; textarea.style.top = '-9999px';
textarea.style.opacity = '0'; textarea.style.opacity = '0';
document.body.appendChild(textarea); document.body.appendChild(textarea);
// iOS Safari 需要设置 selection range // iOS Safari 必须手动设置选区
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
textarea.setSelectionRange(0, textarea.value.length); textarea.setSelectionRange(0, textarea.value.length);
const success = document.execCommand('copy'); let success = false;
try {
success = document.execCommand('copy');
}
catch {
success = false;
}
document.body.removeChild(textarea); document.body.removeChild(textarea);
return success; if (success)
return true;
}
catch {
/* 继续降级 */
}
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = window.clipboardData;
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text);
} }
catch { catch {
return false; return false;
} }
}
return false;
} }
+30 -8
View File
@@ -1,24 +1,29 @@
/** /**
* 兼容所有浏览器的复制到剪贴板函数 * 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题 *
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3
*/ */
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端 // 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
return true return true
} catch { } catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback /* 权限被拒或非安全上下文,继续降级 */
} }
} }
// 方法2: 传统 execCommandiOS Safari 兼容方案 // 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try { try {
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = text textarea.value = text
textarea.setAttribute('readonly', '')
// 防止页面滚动 // 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed' textarea.style.position = 'fixed'
textarea.style.left = '-9999px' textarea.style.left = '-9999px'
textarea.style.top = '-9999px' textarea.style.top = '-9999px'
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.appendChild(textarea) document.body.appendChild(textarea)
// iOS Safari 需要设置 selection range // iOS Safari 必须手动设置选区
textarea.focus() textarea.focus()
textarea.select() textarea.select()
textarea.setSelectionRange(0, textarea.value.length) textarea.setSelectionRange(0, textarea.value.length)
const success = document.execCommand('copy') let success = false
try {
success = document.execCommand('copy')
} catch {
success = false
}
document.body.removeChild(textarea) document.body.removeChild(textarea)
return success if (success) return true
} catch {
/* 继续降级 */
}
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = (window as unknown as { clipboardData?: { setData: (t: string, v: string) => boolean } }).clipboardData
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text)
} catch { } catch {
return false return false
} }
}
return false
} }
+88 -9
View File
@@ -82,10 +82,55 @@ async function loadDocs() {
loading.value = false loading.value = false
} }
// 链接有效期
const expiryOptions = [
{ label: '10 分钟', value: 10 },
{ label: '30 分钟', value: 30 },
{ label: '1 小时', value: 60 },
{ label: '3 小时', value: 180 },
{ label: '24 小时', value: 1440 },
{ label: '长期有效', value: 0 },
]
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
expires_at: null,
is_expired: false,
})
const expiryText = computed(() => {
if (linkExpiry.value.is_expired) return '⛔ 已失效'
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
})
async function loadLink() { async function loadLink() {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`) const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
aiUrl.value = `${window.location.origin}/k/${data.token}` aiUrl.value = `${window.location.origin}/k/${data.token}`
linkExpiry.value = {
expires_at: data.expires_at,
is_expired: data.is_expired,
}
} catch { /* ignore */ }
}
// 设置链接有效期(0 = 长期有效,需二次确认)
async function handleExpiryChange(val: number) {
if (val === 0) {
try {
await ElMessageBox.confirm(
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
'⚠️ 泄露风险提示',
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
}
try {
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
expires_in_minutes: val === 0 ? null : val,
})
ElMessage.success('链接有效期已更新。')
loadLink()
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
uploading.value = false uploading.value = false
} }
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
function openTextDialog() {
textForm.value.category_id = selectedCategoryId.value
showTextDialog.value = true
}
// 创建文本内容 // 创建文本内容
async function handleCreateText() { async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) { if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
@@ -134,7 +185,7 @@ async function handleCreateText() {
title: textForm.value.title, title: textForm.value.title,
content: textForm.value.content, content: textForm.value.content,
content_format: textForm.value.content_format, content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id, category_id: textForm.value.category_id,
}) })
ElMessage.success('文本内容已创建!') ElMessage.success('文本内容已创建!')
showTextDialog.value = false showTextDialog.value = false
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId) const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
return cat ? cat.name : '未分类' return cat ? cat.name : '未分类'
} }
// AI 链接打码:显示域名,token 部分用 • 代替
const maskedAiUrl = computed(() => {
if (!aiUrl.value) return '尚未生成'
const idx = aiUrl.value.indexOf('/k/')
if (idx === -1) return '••••••••'
const prefix = aiUrl.value.slice(0, idx + 3)
const tokenLen = aiUrl.value.length - idx - 3
return prefix + '•'.repeat(tokenLen)
})
</script> </script>
<template> <template>
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
</div> </div>
</div> </div>
<!-- AI 链接 --> <!-- AI 链接打码显示只能复制获取 -->
<el-card style="margin-bottom: 16px" shadow="hover"> <el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap"> <div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span> <span style="font-weight: bold; font-size: 14px">AI 链接</span>
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large"> <span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
<template #append> {{ maskedAiUrl }}
<el-button @click="handleCopyLink">复制</el-button> </span>
</template> <el-select
</el-input> :model-value="null"
placeholder="⏱️ 设置有效期"
style="width: 150px"
@change="(val: number) => handleExpiryChange(val)"
>
<el-option
v-for="opt in expiryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
</div>
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
{{ expiryText }}
</span>
<span style="color: #999">🔒 链接即访问凭证已隐藏显示仅可通过复制按钮获取请勿公开传播</span>
</div> </div>
</el-card> </el-card>
<!-- 手机端目录按钮 + 操作按钮 --> <!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions"> <div class="mobile-actions">
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button> <el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
<el-button @click="showTextDialog = true" style="flex: 1"> 添加文本</el-button> <el-button @click="openTextDialog" style="flex: 1"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1"> <el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button> <el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload> </el-upload>
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
</el-tag> </el-tag>
</span> </span>
<div class="desktop-actions"> <div class="desktop-actions">
<el-button @click="showTextDialog = true"> 添加文本</el-button> <el-button @click="openTextDialog"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf"> <el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">📤 上传文档</el-button> <el-button type="primary" :loading="uploading">📤 上传文档</el-button>
</el-upload> </el-upload>
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client' import apiClient from '@/api/client'
import { copyToClipboard } from '@/utils/clipboard'
const router = useRouter() const router = useRouter()
const kbs = ref<any[]>([]) const kbs = ref<any[]>([])
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`) const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
const url = `${window.location.origin}${data.ai_url}` const url = `${window.location.origin}${data.ai_url}`
await navigator.clipboard.writeText(url) const success = await copyToClipboard(url)
if (success) {
ElMessage.success('AI 链接已复制到剪贴板!') ElMessage.success('AI 链接已复制到剪贴板!')
} else {
ElMessage.error('复制失败,请稍后重试。')
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
</script> </script>
+7 -1
View File
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client'; import apiClient from '@/api/client';
import { copyToClipboard } from '@/utils/clipboard';
const router = useRouter(); const router = useRouter();
const kbs = ref([]); const kbs = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -55,9 +56,14 @@ async function handleCopyLink(kb) {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`); const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`);
const url = `${window.location.origin}${data.ai_url}`; const url = `${window.location.origin}${data.ai_url}`;
await navigator.clipboard.writeText(url); const success = await copyToClipboard(url);
if (success) {
ElMessage.success('AI 链接已复制到剪贴板!'); ElMessage.success('AI 链接已复制到剪贴板!');
} }
else {
ElMessage.error('复制失败,请稍后重试。');
}
}
catch { /* ignore */ } catch { /* ignore */ }
} }
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ debugger; /* PartiallyEnd: #3632/scriptSetup.vue */