Compare commits
11
Commits
08af45d77e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3e2a7273 | ||
|
|
bfb7a1a8a2 | ||
|
|
8ec7856bfc | ||
|
|
cecb5f2714 | ||
|
|
6a2de55332 | ||
|
|
b4dfc5e711 | ||
|
|
d44f8303e7 | ||
|
|
2a12ad9b7a | ||
|
|
c54ef991ea | ||
|
|
feb99b9ba1 | ||
|
|
fc1e2f440b |
+3
-2
@@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -19,4 +19,5 @@ VOLUME /app/data
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 启动:先跑迁移,再起服务
|
# 启动:先跑迁移,再起服务
|
||||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
# 注意:单 worker —— Session 存在进程内存中,多 worker 会导致登录态随机丢失
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add_deleted_at_recycle_bin
|
||||||
|
|
||||||
|
Revision ID: a3f8c21d94b7
|
||||||
|
Revises: 5bb03575e2e7
|
||||||
|
Create Date: 2026-09-02
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a3f8c21d94b7'
|
||||||
|
down_revision: Union[str, None] = '5bb03575e2e7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('documents', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
|
||||||
|
op.add_column('document_categories', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
|
||||||
|
op.add_column('knowledge_bases', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
|
||||||
|
op.create_index('ix_documents_deleted_at', 'documents', ['deleted_at'])
|
||||||
|
op.create_index('ix_document_categories_deleted_at', 'document_categories', ['deleted_at'])
|
||||||
|
op.create_index('ix_knowledge_bases_deleted_at', 'knowledge_bases', ['deleted_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_knowledge_bases_deleted_at', table_name='knowledge_bases')
|
||||||
|
op.drop_index('ix_document_categories_deleted_at', table_name='document_categories')
|
||||||
|
op.drop_index('ix_documents_deleted_at', table_name='documents')
|
||||||
|
op.drop_column('knowledge_bases', 'deleted_at')
|
||||||
|
op.drop_column('document_categories', 'deleted_at')
|
||||||
|
op.drop_column('documents', 'deleted_at')
|
||||||
@@ -52,10 +52,13 @@ def get_category_tree(
|
|||||||
"""获取完整目录树(含文档数量)。"""
|
"""获取完整目录树(含文档数量)。"""
|
||||||
_check_kb_owner(kb_id, user, db)
|
_check_kb_owner(kb_id, user, db)
|
||||||
|
|
||||||
# 获取所有分类
|
# 获取所有未删除的分类
|
||||||
stmt = (
|
stmt = (
|
||||||
select(DocumentCategory)
|
select(DocumentCategory)
|
||||||
.where(DocumentCategory.knowledge_base_id == kb_id)
|
.where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb_id,
|
||||||
|
DocumentCategory.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
||||||
)
|
)
|
||||||
all_cats = list(db.scalars(stmt).all())
|
all_cats = list(db.scalars(stmt).all())
|
||||||
@@ -110,7 +113,10 @@ def list_categories_flat(
|
|||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(DocumentCategory)
|
select(DocumentCategory)
|
||||||
.where(DocumentCategory.knowledge_base_id == kb_id)
|
.where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb_id,
|
||||||
|
DocumentCategory.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.order_by(DocumentCategory.path, DocumentCategory.sort_order)
|
.order_by(DocumentCategory.path, DocumentCategory.sort_order)
|
||||||
)
|
)
|
||||||
cats = list(db.scalars(stmt).all())
|
cats = list(db.scalars(stmt).all())
|
||||||
@@ -236,32 +242,44 @@ def delete_category(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。"""
|
"""删除分类 → 进回收站(含全部子分类及其下文档,3 天后自动彻底删除)。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.document import Document
|
||||||
|
|
||||||
_check_kb_owner(kb_id, user, db)
|
_check_kb_owner(kb_id, user, db)
|
||||||
cat = db.get(DocumentCategory, cat_id)
|
cat = db.get(DocumentCategory, cat_id)
|
||||||
if cat is None or cat.knowledge_base_id != kb_id:
|
if cat is None or cat.knowledge_base_id != kb_id or cat.deleted_at:
|
||||||
raise NotFoundError("分类不存在。")
|
raise NotFoundError("分类不存在。")
|
||||||
|
|
||||||
# 删除所有子分类
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# 收集本分类 + 所有存活的后代分类
|
||||||
|
target_ids = [cat_id]
|
||||||
if cat.path:
|
if cat.path:
|
||||||
stmt = select(DocumentCategory).where(
|
stmt = select(DocumentCategory).where(
|
||||||
DocumentCategory.knowledge_base_id == kb_id,
|
DocumentCategory.knowledge_base_id == kb_id,
|
||||||
DocumentCategory.path.startswith(cat.path),
|
DocumentCategory.path.startswith(cat.path),
|
||||||
DocumentCategory.id != cat_id,
|
DocumentCategory.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
children = list(db.scalars(stmt).all())
|
for c in db.scalars(stmt).all():
|
||||||
for child in children:
|
if c.id != cat_id:
|
||||||
db.delete(child)
|
target_ids.append(c.id)
|
||||||
|
|
||||||
# 关联文档的 category_id 置 NULL
|
# 软删除分类树
|
||||||
from app.models.document import Document
|
for cid in target_ids:
|
||||||
|
c = db.get(DocumentCategory, cid)
|
||||||
|
c.deleted_at = now
|
||||||
|
|
||||||
doc_stmt = select(Document).where(Document.category_id == cat_id)
|
# 软删除目录下的文档(同一批次时间戳,便于整组恢复)
|
||||||
docs = list(db.scalars(doc_stmt).all())
|
doc_stmt = select(Document).where(
|
||||||
for doc in docs:
|
Document.category_id.in_(target_ids),
|
||||||
doc.category_id = None
|
Document.status != "DELETED",
|
||||||
|
)
|
||||||
|
for doc in db.scalars(doc_stmt).all():
|
||||||
|
doc.status = "DELETED"
|
||||||
|
doc.deleted_at = now
|
||||||
|
|
||||||
db.delete(cat)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.schemas.knowledge_base import (
|
|||||||
KbCreateRequest,
|
KbCreateRequest,
|
||||||
KbListResponse,
|
KbListResponse,
|
||||||
KbResponse,
|
KbResponse,
|
||||||
|
KbSetExpiryRequest,
|
||||||
KbTokenResponse,
|
KbTokenResponse,
|
||||||
KbUpdateRequest,
|
KbUpdateRequest,
|
||||||
)
|
)
|
||||||
@@ -86,7 +87,10 @@ 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)
|
||||||
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,
|
||||||
|
expires_at=kb.token_expires_at, is_expired=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{kb_id}/enable", response_model=KbResponse)
|
@router.post("/{kb_id}/enable", response_model=KbResponse)
|
||||||
@@ -117,13 +121,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:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""回收站 API 路由。"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.recycle_bin_service import RecycleBinService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/recycle-bin", tags=["recycle-bin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_recycle_bin(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""回收站内容列表。"""
|
||||||
|
return RecycleBinService(db).list_items(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/{doc_id}/restore", status_code=204)
|
||||||
|
def restore_document(
|
||||||
|
doc_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
RecycleBinService(db).restore_document(doc_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/documents/{doc_id}", status_code=204)
|
||||||
|
def purge_document(
|
||||||
|
doc_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""彻底删除单个文档(文件+记录)。"""
|
||||||
|
RecycleBinService(db).purge_document(doc_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories/{cat_id}/restore", status_code=204)
|
||||||
|
def restore_category(
|
||||||
|
cat_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""恢复目录(连同同批删除的子目录与文档)。"""
|
||||||
|
RecycleBinService(db).restore_category(cat_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{cat_id}", status_code=204)
|
||||||
|
def purge_category(
|
||||||
|
cat_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""彻底删除目录(连同同批内容)。"""
|
||||||
|
RecycleBinService(db).purge_category(cat_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-bases/{kb_id}/restore", status_code=204)
|
||||||
|
def restore_kb(
|
||||||
|
kb_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
RecycleBinService(db).restore_kb(kb_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-bases/{kb_id}", status_code=204)
|
||||||
|
def purge_kb(
|
||||||
|
kb_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""彻底删除知识库(全部文件+记录)。"""
|
||||||
|
RecycleBinService(db).purge_kb(kb_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purge-expired")
|
||||||
|
def purge_expired(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发:彻底清理所有已过保留期的内容。"""
|
||||||
|
count = RecycleBinService(db).purge_expired()
|
||||||
|
return {"purged": count}
|
||||||
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
|||||||
# --- 基础 ---
|
# --- 基础 ---
|
||||||
environment: str = "local" # local / production
|
environment: str = "local" # local / production
|
||||||
secret_key: str = Field(min_length=16)
|
secret_key: str = Field(min_length=16)
|
||||||
|
# Cookie Secure 标记:默认跟随 environment(production→Secure)。
|
||||||
|
# 站点还是 HTTP 时必须显式设为 false,否则浏览器丢弃 Cookie 导致登录后立即被踢回。
|
||||||
|
cookie_secure: bool | None = None
|
||||||
|
|
||||||
# --- 数据库 ---
|
# --- 数据库 ---
|
||||||
database_url: str = "mysql+pymysql://admin:Lzcc6-01@47.109.98.44:33306/amb_rag?charset=utf8mb4"
|
database_url: str = "mysql+pymysql://admin:Lzcc6-01@47.109.98.44:33306/amb_rag?charset=utf8mb4"
|
||||||
@@ -35,6 +38,12 @@ 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
|
||||||
|
# 回收站保留天数,超期自动彻底删除(含物理文件)
|
||||||
|
recycle_bin_retention_days: int = 3
|
||||||
|
|
||||||
# --- CORS ---
|
# --- CORS ---
|
||||||
frontend_origin: str = "http://localhost:5173"
|
frontend_origin: str = "http://localhost:5173"
|
||||||
|
|
||||||
@@ -42,6 +51,13 @@ class Settings(BaseSettings):
|
|||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
return self.environment == "production"
|
return self.environment == "production"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_secure_cookie(self) -> bool:
|
||||||
|
"""HTTPS 站点才应启用 Secure Cookie;未显式配置时跟随 environment。"""
|
||||||
|
if self.cookie_secure is not None:
|
||||||
|
return self.cookie_secure
|
||||||
|
return self.is_production
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_mysql(self) -> bool:
|
def is_mysql(self) -> bool:
|
||||||
return "mysql" in self.database_url
|
return "mysql" in self.database_url
|
||||||
|
|||||||
@@ -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 = "链接已失效,请重新生成。"
|
||||||
|
|
||||||
|
|
||||||
# --- 上传与配额 ---
|
# --- 上传与配额 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def get_cookie_params() -> dict:
|
|||||||
"key": SESSION_COOKIE_NAME,
|
"key": SESSION_COOKIE_NAME,
|
||||||
"httponly": True,
|
"httponly": True,
|
||||||
"samesite": "lax",
|
"samesite": "lax",
|
||||||
"secure": settings.is_production,
|
"secure": settings.use_secure_cookie,
|
||||||
"max_age": SESSION_TTL_SECONDS,
|
"max_age": SESSION_TTL_SECONDS,
|
||||||
"path": "/",
|
"path": "/",
|
||||||
}
|
}
|
||||||
+72
-1
@@ -15,10 +15,11 @@ from app.api.categories import router as cat_router
|
|||||||
from app.api.documents import router as doc_router
|
from app.api.documents import router as doc_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.knowledge_bases import router as kb_router
|
from app.api.knowledge_bases import router as kb_router
|
||||||
|
from app.api.recycle_bin import router as recycle_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 +41,56 @@ 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_cleanup() -> None:
|
||||||
|
"""每小时执行一次数据清理:访问日志 + 回收站过期内容。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_cleanup_access_logs)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("定期清理访问日志失败")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_purge_recycle_bin)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("定期清理回收站失败")
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_recycle_bin() -> int:
|
||||||
|
"""彻底删除回收站中超过保留期的内容。"""
|
||||||
|
from app.services.recycle_bin_service import RecycleBinService
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return RecycleBinService(session).purge_expired()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -53,8 +104,22 @@ 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_cleanup())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("启动时清理访问日志未完成(首次部署属正常)")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), 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,12 +145,18 @@ 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"])
|
||||||
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
|
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
|
||||||
app.include_router(doc_router, prefix="/api", tags=["documents"])
|
app.include_router(doc_router, prefix="/api", tags=["documents"])
|
||||||
app.include_router(cat_router, prefix="/api", tags=["categories"])
|
app.include_router(cat_router, prefix="/api", tags=["categories"])
|
||||||
|
app.include_router(recycle_router, prefix="/api", tags=["recycle-bin"])
|
||||||
app.include_router(public_router, tags=["public"])
|
app.include_router(public_router, tags=["public"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)",
|
comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)",
|
||||||
)
|
)
|
||||||
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
|
)
|
||||||
error_code: Mapped[str | None] = mapped_column(
|
error_code: Mapped[str | None] = mapped_column(
|
||||||
String(64),
|
String(64),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
comment="排序序号",
|
comment="排序序号",
|
||||||
)
|
)
|
||||||
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
|
)
|
||||||
|
|
||||||
# 关系
|
# 关系
|
||||||
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
|
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ 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=长期有效",
|
||||||
|
)
|
||||||
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
|
)
|
||||||
|
|
||||||
# 关系
|
# 关系
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""文档服务:上传、删除、配额管理。"""
|
"""文档服务:上传、删除、配额管理。"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import filetype
|
import filetype
|
||||||
@@ -153,13 +154,30 @@ class DocumentService:
|
|||||||
return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size)
|
return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size)
|
||||||
|
|
||||||
def delete(self, doc_id: str, user: User) -> None:
|
def delete(self, doc_id: str, user: User) -> None:
|
||||||
|
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
|
||||||
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)
|
||||||
# 回补配额
|
doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
# 配额立即回补(用户预期删除即释放空间)
|
||||||
self._restore_quota(user, file_size)
|
self._restore_quota(user, file_size)
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
|
|
||||||
|
def purge_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,18 +26,43 @@ 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 = (
|
||||||
select(DocumentCategory)
|
select(DocumentCategory)
|
||||||
.where(DocumentCategory.knowledge_base_id == kb.id)
|
.where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb.id,
|
||||||
|
DocumentCategory.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
||||||
)
|
)
|
||||||
all_cats = list(self._session.scalars(stmt).all())
|
all_cats = list(self._session.scalars(stmt).all())
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
|
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
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 +11,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:
|
||||||
@@ -30,6 +35,10 @@ class KnowledgeBaseService:
|
|||||||
token_encrypted=token_encrypted,
|
token_encrypted=token_encrypted,
|
||||||
token_hint=token_hint,
|
token_hint=token_hint,
|
||||||
)
|
)
|
||||||
|
# AI 链接默认 30 分钟有效期
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
self._seed_default_categories(kb.id)
|
self._seed_default_categories(kb.id)
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
return kb, token
|
return kb, token
|
||||||
@@ -37,7 +46,7 @@ class KnowledgeBaseService:
|
|||||||
def _seed_default_categories(self, kb_id: str) -> None:
|
def _seed_default_categories(self, kb_id: str) -> None:
|
||||||
"""创建默认目录树结构。"""
|
"""创建默认目录树结构。"""
|
||||||
default_tree = [
|
default_tree = [
|
||||||
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]),
|
("01 公司层", ["公司基本信息", "经营理念", "四大价值"]),
|
||||||
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
|
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
|
||||||
("03 部门层", ["企划", "技术", "交付", "市场"]),
|
("03 部门层", ["企划", "技术", "交付", "市场"]),
|
||||||
("04 岗位/AI角色", []),
|
("04 岗位/AI角色", []),
|
||||||
@@ -94,12 +103,14 @@ class KnowledgeBaseService:
|
|||||||
return kb
|
return kb
|
||||||
|
|
||||||
def delete(self, kb_id: str, user: User) -> None:
|
def delete(self, kb_id: str, user: User) -> None:
|
||||||
|
"""删除知识库 → 进回收站(3 天后自动彻底删除)。"""
|
||||||
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)
|
||||||
|
kb.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
|
|
||||||
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。旧链接立即失效,新链接默认 30 分钟有效期。"""
|
||||||
kb = self.get_or_404(kb_id, user)
|
kb = self.get_or_404(kb_id, user)
|
||||||
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
|
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
|
||||||
self._kb_repo.update(
|
self._kb_repo.update(
|
||||||
@@ -108,6 +119,7 @@ class KnowledgeBaseService:
|
|||||||
token_encrypted=token_encrypted,
|
token_encrypted=token_encrypted,
|
||||||
token_hint=token_hint,
|
token_hint=token_hint,
|
||||||
)
|
)
|
||||||
|
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
return kb, token
|
return kb, token
|
||||||
|
|
||||||
@@ -117,6 +129,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 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,338 @@
|
|||||||
|
"""回收站服务:目录/文档/知识库删除后的暂存、恢复与彻底删除。
|
||||||
|
|
||||||
|
- 删除 = 软删除(deleted_at 时间戳),文件保留
|
||||||
|
- 恢复 = 清除删除标记(目录恢复时整批恢复同批文档)
|
||||||
|
- 彻底删除 = 物理删除文件 + 删除数据库记录
|
||||||
|
- 超过保留期(默认 3 天)由定时任务自动彻底删除
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import NotFoundError
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.models.document_category import DocumentCategory
|
||||||
|
from app.models.knowledge_base import KnowledgeBase
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.doc_service import DocumentService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
_DOC_RESTORE_STATUS = "READY" # 恢复文档时的状态(文件未删,可直接就绪)
|
||||||
|
|
||||||
|
|
||||||
|
class RecycleBinService:
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._doc_svc = DocumentService(session)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 查询
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_items(self, user: User) -> dict:
|
||||||
|
"""列出回收站内容(按用户隔离),分三类返回。"""
|
||||||
|
settings = get_settings()
|
||||||
|
now = datetime.now()
|
||||||
|
deadline = now - timedelta(days=settings.recycle_bin_retention_days)
|
||||||
|
|
||||||
|
def days_left(deleted_at: str) -> int:
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(deleted_at, _TIME_FMT)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
return max(0, (d - deadline).days)
|
||||||
|
|
||||||
|
# 文档
|
||||||
|
doc_stmt = (
|
||||||
|
select(Document)
|
||||||
|
.join(KnowledgeBase, Document.knowledge_base_id == KnowledgeBase.id)
|
||||||
|
.where(
|
||||||
|
Document.status == "DELETED",
|
||||||
|
Document.deleted_at.is_not(None),
|
||||||
|
Document.user_id == user.id,
|
||||||
|
)
|
||||||
|
.order_by(Document.deleted_at.desc())
|
||||||
|
)
|
||||||
|
documents = []
|
||||||
|
for d in self._session.scalars(doc_stmt).all():
|
||||||
|
documents.append({
|
||||||
|
"id": d.id,
|
||||||
|
"name": d.title or d.original_filename,
|
||||||
|
"kb_id": d.knowledge_base_id,
|
||||||
|
"kb_name": d.knowledge_base.name if d.knowledge_base else "",
|
||||||
|
"deleted_at": d.deleted_at,
|
||||||
|
"days_left": days_left(d.deleted_at),
|
||||||
|
"file_size": d.file_size,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 分类
|
||||||
|
cat_stmt = (
|
||||||
|
select(DocumentCategory)
|
||||||
|
.join(KnowledgeBase, DocumentCategory.knowledge_base_id == KnowledgeBase.id)
|
||||||
|
.where(
|
||||||
|
DocumentCategory.deleted_at.is_not(None),
|
||||||
|
KnowledgeBase.user_id == user.id,
|
||||||
|
)
|
||||||
|
.order_by(DocumentCategory.deleted_at.desc())
|
||||||
|
)
|
||||||
|
categories = []
|
||||||
|
for c in self._session.scalars(cat_stmt).all():
|
||||||
|
categories.append({
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"kb_id": c.knowledge_base_id,
|
||||||
|
"kb_name": c.knowledge_base.name if c.knowledge_base else "",
|
||||||
|
"deleted_at": c.deleted_at,
|
||||||
|
"days_left": days_left(c.deleted_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 知识库
|
||||||
|
kb_stmt = (
|
||||||
|
select(KnowledgeBase)
|
||||||
|
.where(
|
||||||
|
KnowledgeBase.status == "DELETED",
|
||||||
|
KnowledgeBase.deleted_at.is_not(None),
|
||||||
|
KnowledgeBase.user_id == user.id,
|
||||||
|
)
|
||||||
|
.order_by(KnowledgeBase.deleted_at.desc())
|
||||||
|
)
|
||||||
|
knowledge_bases = []
|
||||||
|
for kb in self._session.scalars(kb_stmt).all():
|
||||||
|
knowledge_bases.append({
|
||||||
|
"id": kb.id,
|
||||||
|
"name": kb.name,
|
||||||
|
"deleted_at": kb.deleted_at,
|
||||||
|
"days_left": days_left(kb.deleted_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"documents": documents,
|
||||||
|
"categories": categories,
|
||||||
|
"knowledge_bases": knowledge_bases,
|
||||||
|
"retention_days": settings.recycle_bin_retention_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 恢复
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def restore_document(self, doc_id: str, user: User) -> None:
|
||||||
|
doc = self._get_deleted_doc(doc_id, user)
|
||||||
|
doc.status = _DOC_RESTORE_STATUS
|
||||||
|
doc.deleted_at = None
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
def restore_category(self, cat_id: str, user: User) -> None:
|
||||||
|
"""恢复分类(连同同批删除的后代分类与文档)。"""
|
||||||
|
cat = self._get_deleted_category(cat_id, user)
|
||||||
|
batch_time = cat.deleted_at
|
||||||
|
|
||||||
|
# 后代分类中同批删除的一并恢复
|
||||||
|
if cat.path:
|
||||||
|
stmt = select(DocumentCategory).where(
|
||||||
|
DocumentCategory.knowledge_base_id == cat.knowledge_base_id,
|
||||||
|
DocumentCategory.path.startswith(cat.path),
|
||||||
|
DocumentCategory.deleted_at == batch_time,
|
||||||
|
)
|
||||||
|
for c in self._session.scalars(stmt).all():
|
||||||
|
c.deleted_at = None
|
||||||
|
cat.deleted_at = None
|
||||||
|
|
||||||
|
# 同批删除的文档一并恢复
|
||||||
|
doc_stmt = select(Document).where(
|
||||||
|
Document.deleted_at == batch_time,
|
||||||
|
Document.user_id == user.id,
|
||||||
|
)
|
||||||
|
for d in self._session.scalars(doc_stmt).all():
|
||||||
|
if d.category_id and self._in_category_tree(d.category_id, cat):
|
||||||
|
d.status = _DOC_RESTORE_STATUS
|
||||||
|
d.deleted_at = None
|
||||||
|
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
def restore_kb(self, kb_id: str, user: User) -> None:
|
||||||
|
kb = self._get_deleted_kb(kb_id, user)
|
||||||
|
kb.status = "active"
|
||||||
|
kb.deleted_at = None
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 彻底删除
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def purge_document(self, doc_id: str, user: User) -> None:
|
||||||
|
doc = self._get_deleted_doc(doc_id, user)
|
||||||
|
self._purge_doc_rows(doc)
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
def _purge_doc_rows(self, doc: Document) -> None:
|
||||||
|
"""物理删除文件 + 关联访问日志 + 记录。"""
|
||||||
|
from app.models.access_log import AccessLog
|
||||||
|
|
||||||
|
self._doc_svc.purge_files(doc)
|
||||||
|
self._session.execute(
|
||||||
|
sa_delete(AccessLog).where(AccessLog.document_id == doc.id)
|
||||||
|
)
|
||||||
|
self._session.delete(doc)
|
||||||
|
|
||||||
|
def purge_category(self, cat_id: str, user: User) -> None:
|
||||||
|
"""彻底删除分类:连同同批删除的后代分类与文档(文件+记录)。"""
|
||||||
|
cat = self._get_deleted_category(cat_id, user)
|
||||||
|
batch_time = cat.deleted_at
|
||||||
|
kb_id = cat.knowledge_base_id
|
||||||
|
|
||||||
|
# 收集同批删除的分类 ID 集合(自身 + 后代)
|
||||||
|
cat_ids = [cat_id]
|
||||||
|
if cat.path:
|
||||||
|
stmt = select(DocumentCategory).where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb_id,
|
||||||
|
DocumentCategory.path.startswith(cat.path),
|
||||||
|
DocumentCategory.deleted_at == batch_time,
|
||||||
|
)
|
||||||
|
cat_ids += [c.id for c in self._session.scalars(stmt).all() if c.id != cat_id]
|
||||||
|
|
||||||
|
# 同批删除的文档:物理删文件 + 删记录
|
||||||
|
doc_stmt = select(Document).where(
|
||||||
|
Document.deleted_at == batch_time,
|
||||||
|
Document.category_id.in_(cat_ids),
|
||||||
|
Document.user_id == user.id,
|
||||||
|
)
|
||||||
|
for d in self._session.scalars(doc_stmt).all():
|
||||||
|
self._purge_doc_rows(d)
|
||||||
|
|
||||||
|
# 其他仍引用这些分类的文档(各自单独删除的):解除关联,留在回收站
|
||||||
|
ref_docs = select(Document).where(Document.category_id.in_(cat_ids))
|
||||||
|
for d in self._session.scalars(ref_docs).all():
|
||||||
|
d.category_id = None
|
||||||
|
|
||||||
|
# 删除分类记录
|
||||||
|
for cid in cat_ids:
|
||||||
|
c = self._session.get(DocumentCategory, cid)
|
||||||
|
if c:
|
||||||
|
self._session.delete(c)
|
||||||
|
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
def purge_kb(self, kb_id: str, user: User) -> None:
|
||||||
|
"""彻底删除知识库:其下所有文档文件 + 全部记录。"""
|
||||||
|
from app.models.access_log import AccessLog
|
||||||
|
|
||||||
|
kb = self._get_deleted_kb(kb_id, user)
|
||||||
|
|
||||||
|
doc_stmt = select(Document).where(Document.knowledge_base_id == kb_id)
|
||||||
|
for d in self._session.scalars(doc_stmt).all():
|
||||||
|
self._purge_doc_rows(d)
|
||||||
|
|
||||||
|
cat_stmt = select(DocumentCategory).where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb_id
|
||||||
|
)
|
||||||
|
for c in self._session.scalars(cat_stmt).all():
|
||||||
|
self._session.delete(c)
|
||||||
|
|
||||||
|
# 访问日志
|
||||||
|
self._session.execute(sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb_id))
|
||||||
|
|
||||||
|
self._session.delete(kb)
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 自动清理(定时任务调用)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def purge_expired(self) -> int:
|
||||||
|
"""彻底删除所有超过保留期的回收站内容。返回清理的条目数。"""
|
||||||
|
settings = get_settings()
|
||||||
|
cutoff = (datetime.now() - timedelta(days=settings.recycle_bin_retention_days)).strftime(_TIME_FMT)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# 过期文档
|
||||||
|
doc_stmt = select(Document).where(
|
||||||
|
Document.status == "DELETED",
|
||||||
|
Document.deleted_at.is_not(None),
|
||||||
|
Document.deleted_at < cutoff,
|
||||||
|
)
|
||||||
|
for d in self._session.scalars(doc_stmt).all():
|
||||||
|
self._purge_doc_rows(d)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# 过期分类(解除所有仍引用它的文档关联,再删除)
|
||||||
|
cat_stmt = select(DocumentCategory).where(
|
||||||
|
DocumentCategory.deleted_at.is_not(None),
|
||||||
|
DocumentCategory.deleted_at < cutoff,
|
||||||
|
)
|
||||||
|
expired_cats = list(self._session.scalars(cat_stmt).all())
|
||||||
|
for c in expired_cats:
|
||||||
|
ref_docs = select(Document).where(Document.category_id == c.id)
|
||||||
|
for d in self._session.scalars(ref_docs).all():
|
||||||
|
d.category_id = None
|
||||||
|
self._session.delete(c)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# 过期知识库
|
||||||
|
from app.models.access_log import AccessLog
|
||||||
|
|
||||||
|
kb_stmt = select(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.status == "DELETED",
|
||||||
|
KnowledgeBase.deleted_at.is_not(None),
|
||||||
|
KnowledgeBase.deleted_at < cutoff,
|
||||||
|
)
|
||||||
|
for kb in self._session.scalars(kb_stmt).all():
|
||||||
|
doc_all = select(Document).where(Document.knowledge_base_id == kb.id)
|
||||||
|
for d in self._session.scalars(doc_all).all():
|
||||||
|
self._purge_doc_rows(d)
|
||||||
|
cat_all = select(DocumentCategory).where(
|
||||||
|
DocumentCategory.knowledge_base_id == kb.id
|
||||||
|
)
|
||||||
|
for c in self._session.scalars(cat_all).all():
|
||||||
|
self._session.delete(c)
|
||||||
|
self._session.execute(
|
||||||
|
sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb.id)
|
||||||
|
)
|
||||||
|
self._session.delete(kb)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count:
|
||||||
|
self._session.commit()
|
||||||
|
logger.info("回收站自动清理:%d 项已彻底删除", count)
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 内部工具
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_deleted_doc(self, doc_id: str, user: User) -> Document:
|
||||||
|
doc = self._session.get(Document, doc_id)
|
||||||
|
if doc is None or doc.user_id != user.id or doc.status != "DELETED":
|
||||||
|
raise NotFoundError("回收站中不存在该文档。")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def _get_deleted_category(self, cat_id: str, user: User) -> DocumentCategory:
|
||||||
|
cat = self._session.get(DocumentCategory, cat_id)
|
||||||
|
if cat is None or cat.deleted_at is None:
|
||||||
|
raise NotFoundError("回收站中不存在该目录。")
|
||||||
|
kb = self._session.get(KnowledgeBase, cat.knowledge_base_id)
|
||||||
|
if kb is None or kb.user_id != user.id:
|
||||||
|
raise NotFoundError("回收站中不存在该目录。")
|
||||||
|
return cat
|
||||||
|
|
||||||
|
def _get_deleted_kb(self, kb_id: str, user: User) -> KnowledgeBase:
|
||||||
|
kb = self._session.get(KnowledgeBase, kb_id)
|
||||||
|
if kb is None or kb.user_id != user.id or kb.status != "DELETED":
|
||||||
|
raise NotFoundError("回收站中不存在该知识库。")
|
||||||
|
return kb
|
||||||
|
|
||||||
|
def _in_category_tree(self, category_id: str, root: DocumentCategory) -> bool:
|
||||||
|
"""判断分类是否位于 root 的子树内(含自身)。"""
|
||||||
|
if category_id == root.id:
|
||||||
|
return True
|
||||||
|
c = self._session.get(DocumentCategory, category_id)
|
||||||
|
if c is None or not c.path or not root.path:
|
||||||
|
return False
|
||||||
|
return c.path.startswith(root.path)
|
||||||
@@ -57,6 +57,10 @@ def _isolate_db(tmp_path, monkeypatch):
|
|||||||
from app.core import session as session_module
|
from app.core import session as session_module
|
||||||
session_module._store.clear()
|
session_module._store.clear()
|
||||||
|
|
||||||
|
# 重置存储服务单例(否则上一个测试的 tmp_path 被复用)
|
||||||
|
import app.storage.local_storage as ls
|
||||||
|
monkeypatch.setattr(ls, "_instance", None)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""链接有效期功能测试。"""
|
||||||
|
|
||||||
|
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_30min() -> None:
|
||||||
|
"""新建知识库链接默认 30 分钟有效期。"""
|
||||||
|
with _client() as client:
|
||||||
|
kb_id, token = _setup_kb(client)
|
||||||
|
resp = client.get(f"/k/{token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
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_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})
|
||||||
|
# 重新生成 → 新链接默认 30 分钟有效期
|
||||||
|
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 not None
|
||||||
|
assert resp.json()["is_expired"] is False
|
||||||
|
|
||||||
|
|
||||||
|
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_goes_to_recycle_bin() -> 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_id = resp.json()["items"][0]["id"]
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
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
|
||||||
|
|
||||||
|
# 删除 → 进回收站,文件仍存在
|
||||||
|
resp = client.delete(f"/api/documents/{doc_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
full = Path(get_settings().storage_root_path) / storage_path
|
||||||
|
assert full.exists(), "回收站阶段文件不应被删除"
|
||||||
|
|
||||||
|
# 回收站列表可见
|
||||||
|
resp = client.get("/api/recycle-bin")
|
||||||
|
assert any(item["id"] == doc_id for item in resp.json()["documents"])
|
||||||
|
|
||||||
|
# 彻底删除 → 文件清理
|
||||||
|
resp = client.delete(f"/api/recycle-bin/documents/{doc_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert not full.exists(), "彻底删除后文件应被清理"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kb_delete_goes_to_recycle_bin() -> 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 pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models.document import Document
|
||||||
|
|
||||||
|
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 (root / p).exists(), "回收站阶段文件不应被删除"
|
||||||
|
|
||||||
|
# 回收站可见
|
||||||
|
resp = client.get("/api/recycle-bin")
|
||||||
|
assert any(item["id"] == kb_id for item in resp.json()["knowledge_bases"])
|
||||||
|
|
||||||
|
# 彻底删除 → 文件清理
|
||||||
|
resp = client.delete(f"/api/recycle-bin/knowledge-bases/{kb_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
for p in paths:
|
||||||
|
assert not (root / p).exists(), "彻底删除后文件应被清理"
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_delete_and_restore() -> None:
|
||||||
|
"""删除目录 → 目录+子目录+文档进回收站;恢复后全部回来。"""
|
||||||
|
with _client() as client:
|
||||||
|
client.post("/api/auth/register", json={
|
||||||
|
"username": "cat_rb_user", "email": "catrb@example.com", "password": "password123",
|
||||||
|
})
|
||||||
|
resp = client.post("/api/knowledge-bases", json={"name": "Cat RB KB"})
|
||||||
|
kb_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# 在"01 公司层"下创建文本文档
|
||||||
|
cats = client.get(f"/api/knowledge-bases/{kb_id}/categories").json()
|
||||||
|
target = next(c for c in cats if c["name"] == "公司基本信息")
|
||||||
|
|
||||||
|
client.post("/api/documents/create-text", json={
|
||||||
|
"kb_id": kb_id, "title": "Cat Doc", "content": "hello",
|
||||||
|
"category_id": target["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 删除目录
|
||||||
|
resp = client.delete(f"/api/knowledge-bases/{kb_id}/categories/{target['id']}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# 文档不可见
|
||||||
|
docs = client.get(f"/api/documents?kb_id={kb_id}").json()
|
||||||
|
assert docs["total"] == 0
|
||||||
|
|
||||||
|
# 回收站里有目录和文档
|
||||||
|
rb = client.get("/api/recycle-bin").json()
|
||||||
|
assert any(c["name"] == "公司基本信息" for c in rb["categories"])
|
||||||
|
assert any(d["name"] == "Cat Doc" for d in rb["documents"])
|
||||||
|
|
||||||
|
# 恢复目录 → 同批文档恢复
|
||||||
|
cat_id = next(c["id"] for c in rb["categories"] if c["name"] == "公司基本信息")
|
||||||
|
resp = client.post(f"/api/recycle-bin/categories/{cat_id}/restore")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
docs = client.get(f"/api/documents?kb_id={kb_id}").json()
|
||||||
|
assert docs["total"] == 1
|
||||||
|
assert docs["items"][0]["title"] == "Cat Doc"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# AI Knowledge Link — 一键部署脚本
|
# AI Knowledge Link — 首次部署脚本
|
||||||
# 在服务器项目目录执行:bash deploy.sh
|
# Docker 跑 backend + frontend,服务器本机 MySQL,宝塔 Nginx 反代
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -10,48 +10,43 @@ echo "=== AI Knowledge Link 部署 ==="
|
|||||||
|
|
||||||
# 检查 Docker
|
# 检查 Docker
|
||||||
if ! command -v docker &> /dev/null; then
|
if ! command -v docker &> /dev/null; then
|
||||||
echo "错误:未安装 Docker。请先安装:"
|
echo "错误:未安装 Docker"
|
||||||
echo " curl -fsSL https://get.docker.com | sh"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker compose version &> /dev/null; then
|
# 1. 创建 .env
|
||||||
echo "错误:docker compose 不可用。请检查 Docker 版本。"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. 检查 .env
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "创建 .env ..."
|
echo "创建 .env ..."
|
||||||
|
|
||||||
# 自动生成 SECRET_KEY
|
|
||||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))" 2>/dev/null || openssl rand -base64 48)
|
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))" 2>/dev/null || openssl rand -base64 48)
|
||||||
|
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
SECRET_KEY=${SECRET}
|
SECRET_KEY=${SECRET}
|
||||||
MYSQL_ROOT_PASSWORD=Lzcc6-01
|
|
||||||
MYSQL_PASSWORD=Lzcc6-01
|
MYSQL_PASSWORD=Lzcc6-01
|
||||||
EOF
|
EOF
|
||||||
echo ".env 已创建,SECRET_KEY 已自动生成"
|
echo ".env 已创建"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. 创建数据目录
|
# 2. 数据目录
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 确保数据库存在(本机 MySQL 33306)
|
||||||
|
echo "检查数据库..."
|
||||||
|
mysql -h 127.0.0.1 -P 33306 -u admin -pLzcc6-01 \
|
||||||
|
-e "CREATE DATABASE IF NOT EXISTS amb_rag CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \
|
||||||
|
2>/dev/null || echo "提示:无法连接本机 MySQL,请确认已启动(不影响后续,backend 启动时会再试)"
|
||||||
|
|
||||||
|
# 4. 构建并启动
|
||||||
echo "构建镜像..."
|
echo "构建镜像..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
echo "启动服务..."
|
echo "启动服务..."
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 4. 等待 MySQL 就绪
|
|
||||||
echo "等待 MySQL 就绪..."
|
|
||||||
sleep 10
|
|
||||||
# 5. 数据库迁移
|
# 5. 数据库迁移
|
||||||
echo "执行数据库迁移..."
|
echo "执行数据库迁移..."
|
||||||
|
sleep 3
|
||||||
docker compose exec -T backend alembic upgrade head
|
docker compose exec -T backend alembic upgrade head
|
||||||
# 6. 创建内部管理员账号(如果不存在)
|
|
||||||
|
# 6. 管理员账号
|
||||||
echo "检查管理员账号..."
|
echo "检查管理员账号..."
|
||||||
docker compose exec -T backend python -c "
|
docker compose exec -T backend python -c "
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
@@ -70,29 +65,26 @@ with factory() as session:
|
|||||||
session.add(plan)
|
session.add(plan)
|
||||||
session.flush()
|
session.flush()
|
||||||
admin = User(
|
admin = User(
|
||||||
username='admin',
|
username='admin', email='admin@company.com',
|
||||||
email='admin@company.com',
|
|
||||||
password_hash=hash_password('Lzcc6-01'),
|
password_hash=hash_password('Lzcc6-01'),
|
||||||
status='active',
|
status='active', role='internal', plan_id=plan.id,
|
||||||
role='internal',
|
|
||||||
plan_id=plan.id,
|
|
||||||
)
|
)
|
||||||
session.add(admin)
|
session.add(admin)
|
||||||
session.commit()
|
session.commit()
|
||||||
print('管理员账号已创建: admin / Lzcc6-01')
|
print('管理员已创建: admin / Lzcc6-01')
|
||||||
else:
|
else:
|
||||||
print('管理员账号已存在,跳过')
|
print('管理员已存在')
|
||||||
"
|
"
|
||||||
# 7. 完成
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " 部署完成!"
|
echo " 容器部署完成!"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 访问地址: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')"
|
echo " 后端: http://127.0.0.1:8000/api/healthz"
|
||||||
echo " 内部登录: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')/internal-login"
|
echo " 前端: http://127.0.0.1:5173"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 管理员: admin"
|
echo " 下一步:宝塔 Nginx 反代(站点设置 → 反向代理):"
|
||||||
echo " 密码: Lzcc6-01"
|
echo " /api/ → http://127.0.0.1:8000"
|
||||||
echo ""
|
echo " /k/ → http://127.0.0.1:8000"
|
||||||
echo " 更新部署: bash update.sh"
|
echo " / → http://127.0.0.1:5173"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
+14
-53
@@ -1,45 +1,24 @@
|
|||||||
# AI Knowledge Link — Docker Compose (生产)
|
# AI Knowledge Link — Docker Compose
|
||||||
|
# 只跑 backend + frontend 两个容器,Nginx 用服务器宝塔的(反代到这两个容器)
|
||||||
#
|
#
|
||||||
# 用法:
|
# 部署:bash deploy.sh
|
||||||
# 1. 把代码上传到服务器
|
# 更新:bash update.sh
|
||||||
# 2. cp .env.example .env && 修改 SECRET_KEY
|
|
||||||
# 3. docker compose up -d --build
|
|
||||||
# 4. docker compose exec backend alembic upgrade head (首次)
|
|
||||||
# 5. 访问 http://服务器IP
|
|
||||||
#
|
|
||||||
# 更新部署:
|
|
||||||
# git pull && docker compose up -d --build
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ---------- MySQL ----------
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-Lzcc6-01}
|
|
||||||
MYSQL_DATABASE: amb_rag
|
|
||||||
MYSQL_USER: admin
|
|
||||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-Lzcc6-01}
|
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
ports:
|
|
||||||
- "33306:3306" # 宿主机 33306 → 容器内 3306
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# ---------- 后端 ----------
|
# ---------- 后端 ----------
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key}
|
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key}
|
||||||
DATABASE_URL: mysql+pymysql://amb:${MYSQL_PASSWORD:-Lzcc6-01}@mysql:3306/amb_rag?charset=utf8mb4 # 容器内用 3306,外部访问用 33306
|
# 站点未配置 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
|
STORAGE_ROOT: /app/data
|
||||||
DEFAULT_STORAGE_QUOTA: "104857600"
|
DEFAULT_STORAGE_QUOTA: "104857600"
|
||||||
DEFAULT_MAX_FILE_SIZE: "20971520"
|
DEFAULT_MAX_FILE_SIZE: "20971520"
|
||||||
@@ -48,39 +27,21 @@ services:
|
|||||||
FRONTEND_ORIGIN: http://localhost
|
FRONTEND_ORIGIN: http://localhost
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/app/data
|
- app_data:/app/data
|
||||||
expose:
|
ports:
|
||||||
- "8000"
|
- "8000:8000"
|
||||||
depends_on:
|
|
||||||
mysql:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ---------- 前端 ----------
|
# ---------- 前端(内部 nginx 已配置 SPA 路由回退)----------
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
|
||||||
- "80"
|
|
||||||
|
|
||||||
# ---------- Nginx ----------
|
|
||||||
nginx:
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "5173:80"
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
frontend:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
|
||||||
app_data:
|
app_data:
|
||||||
+3
-3
@@ -4,15 +4,15 @@ FROM node:20-alpine AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve with nginx
|
# Stage 2: serve with nginx(含 SPA 路由回退配置)
|
||||||
FROM nginx:1.27-alpine AS serve
|
FROM nginx:1.27-alpine AS serve
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
# 自定义 nginx 配置由外层 docker-compose 挂载,这里只 serve 静态产物
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 前端容器内部 nginx —— 负责 SPA 路由回退
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 关键:所有找不到的路径回退到 index.html(Vue Router history 模式必需)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location /assets/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,10 @@ apiClient.interceptors.response.use((response) => response, (error) => {
|
|||||||
const { status, data } = error.response;
|
const { status, data } = error.response;
|
||||||
// 未登录 → 跳转登录页
|
// 未登录 → 跳转登录页
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 避免在登录页循环跳转
|
// 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
|
||||||
if (window.location.pathname !== '/login') {
|
const path = window.location.pathname;
|
||||||
|
const isLoginPage = path === '/login' || path === '/internal-login';
|
||||||
|
if (!isLoginPage) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
// 未登录 → 跳转登录页
|
// 未登录 → 跳转登录页
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 避免在登录页循环跳转
|
// 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
|
||||||
if (window.location.pathname !== '/login') {
|
const path = window.location.pathname
|
||||||
|
const isLoginPage = path === '/login' || path === '/internal-login'
|
||||||
|
if (!isLoginPage) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ function navigateTo(path: string) {
|
|||||||
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
|
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
|
||||||
<template #title>设置</template>
|
<template #title>设置</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/recycle-bin">
|
||||||
|
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></el-icon>
|
||||||
|
<template #title>回收站</template>
|
||||||
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
@@ -89,6 +93,7 @@ function navigateTo(path: string) {
|
|||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
|
||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
|
||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')">⚙️ 设置</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')">⚙️ 设置</div>
|
||||||
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/recycle-bin')">🗑️ 回收站</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const router = createRouter({
|
|||||||
name: 'KbDetail',
|
name: 'KbDetail',
|
||||||
component: () => import('@/views/KbDetail.vue'),
|
component: () => import('@/views/KbDetail.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'recycle-bin',
|
||||||
|
name: 'RecycleBin',
|
||||||
|
component: () => import('@/views/RecycleBin.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import apiClient from '@/api/client'
|
||||||
|
|
||||||
|
const data = ref<{
|
||||||
|
documents: any[]
|
||||||
|
categories: any[]
|
||||||
|
knowledge_bases: any[]
|
||||||
|
retention_days: number
|
||||||
|
}>({
|
||||||
|
documents: [],
|
||||||
|
categories: [],
|
||||||
|
knowledge_bases: [],
|
||||||
|
retention_days: 3,
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
const isMobile = ref(window.innerWidth <= 768)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data: d } = await apiClient.get('/recycle-bin')
|
||||||
|
data.value = d
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreDoc(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/documents/${item.id}/restore`)
|
||||||
|
ElMessage.success('文档已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeDoc(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除「${item.name}」?文件与记录将不可恢复。`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/documents/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCategory(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/categories/${item.id}/restore`)
|
||||||
|
ElMessage.success('目录及同批内容已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeCategory(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除目录「${item.name}」及其同批删除的全部内容?`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/categories/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreKb(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/knowledge-bases/${item.id}/restore`)
|
||||||
|
ElMessage.success('知识库已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeKb(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除知识库「${item.name}」及其全部文档?不可恢复。`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/knowledge-bases/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(item: any) {
|
||||||
|
if (item.days_left <= 0) return '即将自动清理'
|
||||||
|
return `${item.days_left} 天后自动清理`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px">
|
||||||
|
<h1 style="margin: 0; font-size: 22px">🗑️ 回收站</h1>
|
||||||
|
<span style="color: #999; font-size: 13px">
|
||||||
|
删除的内容保留 {{ data.retention_days }} 天,超期自动彻底删除
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文档 -->
|
||||||
|
<el-card shadow="hover" style="margin-bottom: 16px">
|
||||||
|
<template #header><span style="font-weight: bold">📄 文档({{ data.documents.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.documents.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.documents" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
所属:{{ item.kb_name || '未知知识库' }} · {{ formatSize(item.file_size) }} ·
|
||||||
|
删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreDoc(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeDoc(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 目录 -->
|
||||||
|
<el-card shadow="hover" style="margin-bottom: 16px">
|
||||||
|
<template #header><span style="font-weight: bold">📁 目录({{ data.categories.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.categories.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.categories" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
所属:{{ item.kb_name || '未知知识库' }} · 删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreCategory(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeCategory(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 知识库 -->
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header><span style="font-weight: bold">📚 知识库({{ data.knowledge_bases.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.knowledge_bases.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.knowledge_bases" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreKb(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeKb(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rb-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-meta {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,39 +1,63 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# AI Knowledge Link — 一键更新脚本
|
# AI Knowledge Link — 一键更新脚本
|
||||||
# 在服务器项目目录执行:bash update.sh
|
# 用法: bash update.sh
|
||||||
#
|
|
||||||
# 功能:
|
|
||||||
# 1. 拉取最新代码
|
|
||||||
# 2. 重新构建镜像
|
|
||||||
# 3. 重启服务(数据库不丢失)
|
|
||||||
# 4. 执行数据库迁移(如有新迁移)
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "=== AI Knowledge Link 更新 ==="
|
echo "=== AI Knowledge Link 更新 ==="
|
||||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# 1. 拉取最新代码
|
# 1. 拉取最新代码
|
||||||
echo "[1/4] 拉取最新代码..."
|
echo "[1/4] 拉取最新代码..."
|
||||||
git pull
|
if ! git pull; then
|
||||||
|
echo ""
|
||||||
|
echo "错误:git pull 失败。通常是服务器上有本地修改与远程冲突。"
|
||||||
|
echo "如果确认要放弃服务器上的修改,执行:"
|
||||||
|
echo " git checkout -- . && git pull"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. 重新构建
|
# 2. 重新构建
|
||||||
|
echo ""
|
||||||
echo "[2/4] 重新构建镜像..."
|
echo "[2/4] 重新构建镜像..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
# 3. 重启服务
|
# 3. 重启服务
|
||||||
|
echo ""
|
||||||
echo "[3/4] 重启服务..."
|
echo "[3/4] 重启服务..."
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 4. 数据库迁移
|
# 4. 等待后端就绪后执行迁移(兜底,容器启动时也会自动迁移)
|
||||||
echo "[4/4] 执行数据库迁移..."
|
|
||||||
sleep 5
|
|
||||||
docker compose exec -T backend alembic upgrade head
|
|
||||||
|
|
||||||
# 5. 完成
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "更新完成!$(date '+%H:%M:%S')"
|
echo "[4/4] 等待后端就绪并执行迁移..."
|
||||||
echo "服务状态:"
|
for i in $(seq 1 30); do
|
||||||
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
if docker compose exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')" 2>/dev/null; then
|
||||||
|
echo "后端已就绪(第 ${i} 次探测)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 30 ]; then
|
||||||
|
echo "警告:后端 30 秒内未就绪,跳过迁移步骤(容器启动时会自动迁移)"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
docker compose exec -T backend alembic upgrade head 2>/dev/null || true
|
||||||
|
|
||||||
|
# 5. 结果
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 更新完成!$(date '+%H:%M:%S')"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
docker compose ps
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
if curl -s -f http://127.0.0.1:8000/api/healthz > /dev/null 2>&1; then
|
||||||
|
echo "✅ 后端健康检查通过"
|
||||||
|
else
|
||||||
|
echo "❌ 后端健康检查失败,查看日志:docker compose logs backend --tail 50"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user