细节优化

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,
KbListResponse,
KbResponse,
KbSetExpiryRequest,
KbTokenResponse,
KbUpdateRequest,
)
@@ -86,6 +87,8 @@ def regenerate_token(
) -> KbTokenResponse:
svc = KnowledgeBaseService(db)
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)
@@ -117,13 +120,42 @@ def get_knowledge_base_link(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbTokenResponse:
"""获取完整 AI 链接(解密 token)。"""
"""获取完整 AI 链接(解密 token)+ 有效期信息"""
from app.services.kb_public_service import KbPublicService
svc = KnowledgeBaseService(db)
kb = svc.get_or_404(kb_id, user)
token = svc.get_full_token(kb)
if token is None:
return KbTokenResponse(token="", ai_url="", token_hint=kb.token_hint or "")
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint or "")
return KbTokenResponse(
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:
+4
View File
@@ -38,6 +38,10 @@ class Settings(BaseSettings):
rate_limit_per_token_per_min: int = 60
rate_limit_per_ip_per_min: int = 30
# --- 数据保留 ---
# 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐)
access_log_retention_days: int = 90
# --- CORS ---
frontend_origin: str = "http://localhost:5173"
+7
View File
@@ -83,6 +83,13 @@ class RateLimitedError(AppError):
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.core.config import get_settings
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
logger = get_logger(__name__)
@@ -40,6 +40,43 @@ def _seed_free_plan() -> None:
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
async def lifespan(app: FastAPI):
settings = get_settings()
@@ -53,8 +90,18 @@ async def lifespan(app: FastAPI):
# 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
cleanup_task.cancel()
dispose_engine()
logger.info("Backend shutdown complete")
@@ -80,6 +127,11 @@ def create_app() -> FastAPI:
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(auth_router, prefix="/api", tags=["auth"])
+6 -3
View File
@@ -1,7 +1,7 @@
"""SQLAlchemy 2.0 基础模型类与 Mixin。"""
import uuid
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import String, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -13,8 +13,11 @@ def generate_uuid() -> str:
def utcnow_iso() -> str:
"""返回 UTC 当前时间的 ISO8601 字符串。"""
return datetime.now(timezone.utc).isoformat()
"""当前时间,格式:年-月-日 时:分:秒(容器时区,生产为 Asia/Shanghai)。
定长格式保证字符串排序 = 时间排序。
"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Base(DeclarativeBase):
+5
View File
@@ -60,6 +60,11 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=True,
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")
+46 -1
View File
@@ -23,7 +23,7 @@ from fastapi import Path as PathParam
from sqlalchemy.orm import Session
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.security import decrypt_token
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"])
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:
"""记录访问日志(best-effort)。"""
try:
+8 -1
View File
@@ -33,7 +33,14 @@ class KbListResponse(BaseModel):
class KbTokenResponse(BaseModel):
"""Token 重置/创建时的完整 URL 响应。"""
"""Token 重置/创建/查询时的完整 URL 响应。"""
token: str
ai_url: 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 sqlalchemy.orm import Session
@@ -24,7 +24,7 @@ class AccessLogService:
knowledge_base_id=knowledge_base_id,
document_id=document_id,
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,
request_type=request_type,
)
+16
View File
@@ -156,10 +156,26 @@ class DocumentService:
doc = self.get_or_404(doc_id, user)
file_size = doc.file_size
self._doc_repo.delete(doc)
self._delete_doc_files(doc)
# 回补配额
self._restore_quota(user, file_size)
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:
settings = get_settings()
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.models.document import Document
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.orm import Session
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
class KbPublicService:
def __init__(self, session: Session) -> None:
@@ -22,13 +26,35 @@ class KbPublicService:
self._doc_repo = DocumentRepository(session)
def get_kb_by_token(self, token: str) -> KnowledgeBase:
"""通过 token 获取知识库。不存在/禁用/删除 → 404。"""
"""通过 token 获取知识库。
不存在/禁用/删除 → 404;存在但已过期 → 410LinkExpiredError)。
"""
token_hash = hash_token(token)
kb = self._kb_repo.get_by_token_hash(token_hash)
if kb is None or not kb.enabled or kb.status == "DELETED":
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
@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]:
"""获取目录树(含文档数量)。"""
stmt = (
+44
View File
@@ -1,6 +1,7 @@
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
from app.core.errors import NotFoundError, PermissionDeniedError
from app.core.logging import get_logger
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase
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 sqlalchemy.orm import Session
logger = get_logger(__name__)
class KnowledgeBaseService:
def __init__(self, session: Session) -> None:
@@ -96,8 +99,35 @@ class KnowledgeBaseService:
def delete(self, kb_id: str, user: User) -> None:
kb = self.get_or_404(kb_id, user)
self._kb_repo.delete(kb)
self._cleanup_kb_files(kb)
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]:
"""重新生成 Token。旧链接立即失效。"""
kb = self.get_or_404(kb_id, user)
@@ -117,6 +147,20 @@ class KnowledgeBaseService:
self._session.commit()
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:
"""解密 token 原文(供后台显示完整链接)。"""
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}
# 站点未配置 HTTPS 时必须为 false,否则浏览器丢弃 Cookie(登录后被踢回登录页)
COOKIE_SECURE: "false"
TZ: Asia/Shanghai
DATABASE_URL: mysql+pymysql://admin:${MYSQL_PASSWORD:-Lzcc6-01}@host.docker.internal:33306/amb_rag?charset=utf8mb4
STORAGE_ROOT: /app/data
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) {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端
// 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return true;
}
catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
/* 权限被拒或非安全上下文,继续降级 */
}
}
// 方法2: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea');
textarea.value = text;
// 防止页面滚动
textarea.setAttribute('readonly', '');
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
// iOS Safari 需要设置 selection range
// iOS Safari 必须手动设置选区
textarea.focus();
textarea.select();
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);
return success;
if (success)
return true;
}
catch {
/* 继续降级 */
}
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = window.clipboardData;
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text);
}
catch {
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> {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端
// 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
/* 权限被拒或非安全上下文,继续降级 */
}
}
// 方法2: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
// 防止页面滚动
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.appendChild(textarea)
// iOS Safari 需要设置 selection range
// iOS Safari 必须手动设置选区
textarea.focus()
textarea.select()
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)
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 {
return false
}
}
return false
}
+88 -9
View File
@@ -82,10 +82,55 @@ async function loadDocs() {
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() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
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 */ }
}
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
uploading.value = false
}
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
function openTextDialog() {
textForm.value.category_id = selectedCategoryId.value
showTextDialog.value = true
}
// 创建文本内容
async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
@@ -134,7 +185,7 @@ async function handleCreateText() {
title: textForm.value.title,
content: textForm.value.content,
content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id,
category_id: textForm.value.category_id,
})
ElMessage.success('文本内容已创建!')
showTextDialog.value = false
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
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>
<template>
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
</div>
</div>
<!-- AI 链接 -->
<!-- AI 链接打码显示只能复制获取 -->
<el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span>
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
<template #append>
<el-button @click="handleCopyLink">复制</el-button>
</template>
</el-input>
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
{{ maskedAiUrl }}
</span>
<el-select
: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>
</el-card>
<!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions">
<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-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload>
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
</el-tag>
</span>
<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-button type="primary" :loading="uploading">📤 上传文档</el-button>
</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 { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client'
import { copyToClipboard } from '@/utils/clipboard'
const router = useRouter()
const kbs = ref<any[]>([])
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
const url = `${window.location.origin}${data.ai_url}`
await navigator.clipboard.writeText(url)
const success = await copyToClipboard(url)
if (success) {
ElMessage.success('AI 链接已复制到剪贴板!')
} else {
ElMessage.error('复制失败,请稍后重试。')
}
} catch { /* ignore */ }
}
</script>
+7 -1
View File
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { copyToClipboard } from '@/utils/clipboard';
const router = useRouter();
const kbs = ref([]);
const loading = ref(false);
@@ -55,9 +56,14 @@ async function handleCopyLink(kb) {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`);
const url = `${window.location.origin}${data.ai_url}`;
await navigator.clipboard.writeText(url);
const success = await copyToClipboard(url);
if (success) {
ElMessage.success('AI 链接已复制到剪贴板!');
}
else {
ElMessage.error('复制失败,请稍后重试。');
}
}
catch { /* ignore */ }
}
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */