细节优化
This commit is contained in:
@@ -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 ###
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;存在但已过期 → 410(LinkExpiredError)。
|
||||||
|
"""
|
||||||
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 = (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
/**
|
/**
|
||||||
* 兼容所有浏览器的复制到剪贴板函数
|
* 兼容所有浏览器的复制到剪贴板函数
|
||||||
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
|
*
|
||||||
|
* 兼容矩阵:
|
||||||
|
* - Chrome 66+ / Edge 79+ / Firefox 63+:Clipboard API(方法1)
|
||||||
|
* - Safari 13.1+ / iOS Safari:Clipboard 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 API(Chrome/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: 传统 execCommand(iOS 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:none(Safari 会取消选中)
|
||||||
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 {
|
catch {
|
||||||
return false;
|
/* 继续降级 */
|
||||||
}
|
}
|
||||||
|
// 方法3: IE 专有 API(极老浏览器兜底)
|
||||||
|
const ieClipboard = window.clipboardData;
|
||||||
|
if (ieClipboard) {
|
||||||
|
try {
|
||||||
|
return ieClipboard.setData('Text', text);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 兼容所有浏览器的复制到剪贴板函数
|
* 兼容所有浏览器的复制到剪贴板函数
|
||||||
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
|
*
|
||||||
|
* 兼容矩阵:
|
||||||
|
* - Chrome 66+ / Edge 79+ / Firefox 63+:Clipboard API(方法1)
|
||||||
|
* - Safari 13.1+ / iOS Safari:Clipboard 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 API(Chrome/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: 传统 execCommand(iOS 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:none(Safari 会取消选中)
|
||||||
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 {
|
} catch {
|
||||||
return false
|
/* 继续降级 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 方法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
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+615
-523
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
ElMessage.success('AI 链接已复制到剪贴板!')
|
if (success) {
|
||||||
|
ElMessage.success('AI 链接已复制到剪贴板!')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('复制失败,请稍后重试。')
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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,8 +56,13 @@ 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);
|
||||||
ElMessage.success('AI 链接已复制到剪贴板!');
|
if (success) {
|
||||||
|
ElMessage.success('AI 链接已复制到剪贴板!');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ElMessage.error('复制失败,请稍后重试。');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user