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/*
|
||||
|
||||
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 . .
|
||||
|
||||
@@ -19,4 +19,5 @@ VOLUME /app/data
|
||||
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)
|
||||
|
||||
# 获取所有分类
|
||||
# 获取所有未删除的分类
|
||||
stmt = (
|
||||
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)
|
||||
)
|
||||
all_cats = list(db.scalars(stmt).all())
|
||||
@@ -110,7 +113,10 @@ def list_categories_flat(
|
||||
|
||||
stmt = (
|
||||
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)
|
||||
)
|
||||
cats = list(db.scalars(stmt).all())
|
||||
@@ -236,32 +242,44 @@ def delete_category(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。"""
|
||||
"""删除分类 → 进回收站(含全部子分类及其下文档,3 天后自动彻底删除)。"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.document import Document
|
||||
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
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("分类不存在。")
|
||||
|
||||
# 删除所有子分类
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 收集本分类 + 所有存活的后代分类
|
||||
target_ids = [cat_id]
|
||||
if cat.path:
|
||||
stmt = select(DocumentCategory).where(
|
||||
DocumentCategory.knowledge_base_id == kb_id,
|
||||
DocumentCategory.path.startswith(cat.path),
|
||||
DocumentCategory.id != cat_id,
|
||||
DocumentCategory.deleted_at.is_(None),
|
||||
)
|
||||
children = list(db.scalars(stmt).all())
|
||||
for child in children:
|
||||
db.delete(child)
|
||||
for c in db.scalars(stmt).all():
|
||||
if c.id != cat_id:
|
||||
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())
|
||||
for doc in docs:
|
||||
doc.category_id = None
|
||||
# 软删除目录下的文档(同一批次时间戳,便于整组恢复)
|
||||
doc_stmt = select(Document).where(
|
||||
Document.category_id.in_(target_ids),
|
||||
Document.status != "DELETED",
|
||||
)
|
||||
for doc in db.scalars(doc_stmt).all():
|
||||
doc.status = "DELETED"
|
||||
doc.deleted_at = now
|
||||
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.schemas.knowledge_base import (
|
||||
KbCreateRequest,
|
||||
KbListResponse,
|
||||
KbResponse,
|
||||
KbSetExpiryRequest,
|
||||
KbTokenResponse,
|
||||
KbUpdateRequest,
|
||||
)
|
||||
@@ -86,7 +87,10 @@ def regenerate_token(
|
||||
) -> KbTokenResponse:
|
||||
svc = KnowledgeBaseService(db)
|
||||
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)
|
||||
@@ -117,13 +121,42 @@ def get_knowledge_base_link(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> KbTokenResponse:
|
||||
"""获取完整 AI 链接(解密 token)。"""
|
||||
"""获取完整 AI 链接(解密 token)+ 有效期信息。"""
|
||||
from app.services.kb_public_service import KbPublicService
|
||||
|
||||
svc = KnowledgeBaseService(db)
|
||||
kb = svc.get_or_404(kb_id, user)
|
||||
token = svc.get_full_token(kb)
|
||||
if token is None:
|
||||
return KbTokenResponse(token="", ai_url="", token_hint=kb.token_hint or "")
|
||||
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint or "")
|
||||
return KbTokenResponse(
|
||||
token="", ai_url="", token_hint=kb.token_hint or "",
|
||||
expires_at=None, is_expired=False,
|
||||
)
|
||||
return KbTokenResponse(
|
||||
token=token,
|
||||
ai_url=f"/k/{token}",
|
||||
token_hint=kb.token_hint or "",
|
||||
expires_at=kb.token_expires_at,
|
||||
is_expired=KbPublicService.is_expired(kb),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{kb_id}/set-expiry", response_model=KbResponse)
|
||||
def set_link_expiry(
|
||||
kb_id: str,
|
||||
body: KbSetExpiryRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> KbResponse:
|
||||
"""设置 AI 链接有效期。expires_in_minutes 为 null 表示长期有效。"""
|
||||
svc = KnowledgeBaseService(db)
|
||||
kb = svc.set_expiry(kb_id, user, body.expires_in_minutes)
|
||||
doc_count = svc._kb_repo.count_documents(kb_id)
|
||||
return KbResponse(
|
||||
id=kb.id, name=kb.name, description=kb.description, enabled=kb.enabled,
|
||||
token_hint=kb.token_hint, document_count=doc_count,
|
||||
created_at=kb.created_at, updated_at=kb.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse:
|
||||
|
||||
@@ -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
|
||||
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"
|
||||
@@ -35,6 +38,12 @@ class Settings(BaseSettings):
|
||||
rate_limit_per_token_per_min: int = 60
|
||||
rate_limit_per_ip_per_min: int = 30
|
||||
|
||||
# --- 数据保留 ---
|
||||
# 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐)
|
||||
access_log_retention_days: int = 90
|
||||
# 回收站保留天数,超期自动彻底删除(含物理文件)
|
||||
recycle_bin_retention_days: int = 3
|
||||
|
||||
# --- CORS ---
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
|
||||
@@ -42,6 +51,13 @@ class Settings(BaseSettings):
|
||||
def is_production(self) -> bool:
|
||||
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
|
||||
def is_mysql(self) -> bool:
|
||||
return "mysql" in self.database_url
|
||||
|
||||
@@ -83,6 +83,13 @@ class RateLimitedError(AppError):
|
||||
message = "请求过于频繁,请稍后再试。"
|
||||
|
||||
|
||||
class LinkExpiredError(AppError):
|
||||
"""AI 链接已过期(HTTP 410 Gone)。"""
|
||||
status_code = status.HTTP_410_GONE
|
||||
code = "LINK_EXPIRED"
|
||||
message = "链接已失效,请重新生成。"
|
||||
|
||||
|
||||
# --- 上传与配额 ---
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def get_cookie_params() -> dict:
|
||||
"key": SESSION_COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": settings.is_production,
|
||||
"secure": settings.use_secure_cookie,
|
||||
"max_age": SESSION_TTL_SECONDS,
|
||||
"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.health import router as health_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.core.config import get_settings
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.core.errors import LinkExpiredError, register_exception_handlers
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -40,6 +41,56 @@ def _seed_free_plan() -> None:
|
||||
logger.debug("Free plan already exists (id=%s)", plan.id)
|
||||
|
||||
|
||||
def _cleanup_access_logs() -> int:
|
||||
"""清理超过保留期的访问日志。返回删除条数。"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.access_log import AccessLog
|
||||
|
||||
settings = get_settings()
|
||||
retention = settings.access_log_retention_days
|
||||
if retention <= 0:
|
||||
return 0
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=retention)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
result = session.execute(delete(AccessLog).where(AccessLog.accessed_at < cutoff))
|
||||
session.commit()
|
||||
count = result.rowcount or 0
|
||||
if count:
|
||||
logger.info("已清理 %d 条过期访问日志(保留 %d 天)", count, retention)
|
||||
return count
|
||||
|
||||
|
||||
async def _periodic_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
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
@@ -53,8 +104,22 @@ async def lifespan(app: FastAPI):
|
||||
# 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
|
||||
|
||||
cleanup_task.cancel()
|
||||
dispose_engine()
|
||||
logger.info("Backend shutdown complete")
|
||||
|
||||
@@ -80,12 +145,18 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
# 链接过期:HTML 返回友好失效页,JSON/MD/TXT 返回对应格式(HTTP 410)
|
||||
from app.public.routes import link_expired_handler
|
||||
|
||||
app.add_exception_handler(LinkExpiredError, link_expired_handler)
|
||||
|
||||
# 路由挂载
|
||||
app.include_router(health_router, prefix="/api", tags=["health"])
|
||||
app.include_router(auth_router, prefix="/api", tags=["auth"])
|
||||
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
|
||||
app.include_router(doc_router, prefix="/api", tags=["documents"])
|
||||
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"])
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""SQLAlchemy 2.0 基础模型类与 Mixin。"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
@@ -13,8 +13,11 @@ def generate_uuid() -> str:
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
"""返回 UTC 当前时间的 ISO8601 字符串。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
"""当前时间,格式:年-月-日 时:分:秒(容器时区,生产为 Asia/Shanghai)。
|
||||
|
||||
定长格式保证字符串排序 = 时间排序。
|
||||
"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
|
||||
@@ -126,6 +126,12 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
nullable=False,
|
||||
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(
|
||||
String(64),
|
||||
nullable=True,
|
||||
|
||||
@@ -52,6 +52,12 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
nullable=False,
|
||||
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")
|
||||
|
||||
@@ -60,6 +60,17 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
nullable=True,
|
||||
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")
|
||||
|
||||
@@ -23,7 +23,7 @@ from fastapi import Path as PathParam
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.errors import NotFoundError, RateLimitedError
|
||||
from app.core.errors import LinkExpiredError, NotFoundError, RateLimitedError
|
||||
from app.core.rate_limit import check_rate_limit
|
||||
from app.core.security import decrypt_token
|
||||
from app.models.document_category import DocumentCategory
|
||||
@@ -33,6 +33,51 @@ from app.services.kb_public_service import KbPublicService
|
||||
router = APIRouter(prefix="/k", tags=["public"])
|
||||
|
||||
|
||||
async def link_expired_handler(request: Request, exc: LinkExpiredError) -> Response:
|
||||
"""链接过期:HTML 路径返回友好失效页,JSON/MD/TXT 返回对应格式的错误信息。"""
|
||||
path = request.url.path
|
||||
message = "链接已失效,请联系分享者重新生成链接。"
|
||||
|
||||
if path.endswith(".json"):
|
||||
return Response(
|
||||
status_code=410,
|
||||
content=json.dumps({"code": "LINK_EXPIRED", "message": message, "detail": None}, ensure_ascii=False),
|
||||
media_type="application/json",
|
||||
)
|
||||
if path.endswith(".md") or path.endswith(".txt"):
|
||||
return PlainTextResponse(content=message, status_code=410, media_type="text/plain; charset=utf-8")
|
||||
|
||||
return HTMLResponse(
|
||||
status_code=410,
|
||||
content=f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>链接已失效</title>
|
||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh;
|
||||
margin: 0; background: #f5f7fa; color: #333; text-align: center; }}
|
||||
.box {{ background: #fff; padding: 50px 40px; border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 420px; }}
|
||||
.icon {{ font-size: 52px; margin-bottom: 16px; }}
|
||||
h1 {{ font-size: 20px; margin: 0 0 12px; }}
|
||||
p {{ color: #888; font-size: 14px; line-height: 1.8; margin: 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="icon">⏰</div>
|
||||
<h1>链接无效或已失效</h1>
|
||||
<p>该知识库链接已过期。<br>请联系分享者重新生成链接后,获取新的访问地址。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>""",
|
||||
)
|
||||
|
||||
|
||||
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
|
||||
"""记录访问日志(best-effort)。"""
|
||||
try:
|
||||
|
||||
@@ -33,7 +33,14 @@ class KbListResponse(BaseModel):
|
||||
|
||||
|
||||
class KbTokenResponse(BaseModel):
|
||||
"""Token 重置/创建时的完整 URL 响应。"""
|
||||
"""Token 重置/创建/查询时的完整 URL 响应。"""
|
||||
token: str
|
||||
ai_url: str
|
||||
token_hint: str
|
||||
expires_at: str | None = None
|
||||
is_expired: bool = False
|
||||
|
||||
|
||||
class KbSetExpiryRequest(BaseModel):
|
||||
"""设置链接有效期。expires_in_minutes=null 表示长期有效。"""
|
||||
expires_in_minutes: int | None = None
|
||||
@@ -1,6 +1,6 @@
|
||||
"""访问日志服务。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.access_log import AccessLog
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -24,7 +24,7 @@ class AccessLogService:
|
||||
knowledge_base_id=knowledge_base_id,
|
||||
document_id=document_id,
|
||||
path=path,
|
||||
accessed_at=datetime.now(timezone.utc).isoformat(),
|
||||
accessed_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
user_agent=user_agent[:500] if user_agent else None,
|
||||
request_type=request_type,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""文档服务:上传、删除、配额管理。"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
def delete(self, doc_id: str, user: User) -> None:
|
||||
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
|
||||
doc = self.get_or_404(doc_id, user)
|
||||
file_size = doc.file_size
|
||||
self._doc_repo.delete(doc)
|
||||
# 回补配额
|
||||
doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
# 配额立即回补(用户预期删除即释放空间)
|
||||
self._restore_quota(user, file_size)
|
||||
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:
|
||||
settings = get_settings()
|
||||
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.models.document import Document
|
||||
from app.models.document_category import DocumentCategory
|
||||
@@ -14,6 +16,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
class KbPublicService:
|
||||
def __init__(self, session: Session) -> None:
|
||||
@@ -22,18 +26,43 @@ class KbPublicService:
|
||||
self._doc_repo = DocumentRepository(session)
|
||||
|
||||
def get_kb_by_token(self, token: str) -> KnowledgeBase:
|
||||
"""通过 token 获取知识库。不存在/禁用/删除 → 404。"""
|
||||
"""通过 token 获取知识库。
|
||||
|
||||
不存在/禁用/删除 → 404;存在但已过期 → 410(LinkExpiredError)。
|
||||
"""
|
||||
token_hash = hash_token(token)
|
||||
kb = self._kb_repo.get_by_token_hash(token_hash)
|
||||
if kb is None or not kb.enabled or kb.status == "DELETED":
|
||||
raise NotFoundError("知识库不存在。")
|
||||
|
||||
if kb.token_expires_at:
|
||||
try:
|
||||
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
|
||||
except ValueError:
|
||||
expires = None
|
||||
if expires is not None and datetime.now() > expires:
|
||||
raise LinkExpiredError()
|
||||
return kb
|
||||
|
||||
@staticmethod
|
||||
def is_expired(kb: KnowledgeBase) -> bool:
|
||||
"""判断知识库链接是否已过期(管理端展示用)。"""
|
||||
if not kb.token_expires_at:
|
||||
return False
|
||||
try:
|
||||
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
|
||||
except ValueError:
|
||||
return False
|
||||
return datetime.now() > expires
|
||||
|
||||
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
|
||||
"""获取目录树(含文档数量)。"""
|
||||
"""获取目录树(含文档数量),排除已删除目录。"""
|
||||
stmt = (
|
||||
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)
|
||||
)
|
||||
all_cats = list(self._session.scalars(stmt).all())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError
|
||||
from app.core.logging import get_logger
|
||||
from app.models.document_category import DocumentCategory
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.models.user import User
|
||||
@@ -8,6 +11,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
|
||||
from app.services.token_service import TokenService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KnowledgeBaseService:
|
||||
def __init__(self, session: Session) -> None:
|
||||
@@ -30,6 +35,10 @@ class KnowledgeBaseService:
|
||||
token_encrypted=token_encrypted,
|
||||
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._session.commit()
|
||||
return kb, token
|
||||
@@ -37,7 +46,7 @@ class KnowledgeBaseService:
|
||||
def _seed_default_categories(self, kb_id: str) -> None:
|
||||
"""创建默认目录树结构。"""
|
||||
default_tree = [
|
||||
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]),
|
||||
("01 公司层", ["公司基本信息", "经营理念", "四大价值"]),
|
||||
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
|
||||
("03 部门层", ["企划", "技术", "交付", "市场"]),
|
||||
("04 岗位/AI角色", []),
|
||||
@@ -94,12 +103,14 @@ class KnowledgeBaseService:
|
||||
return kb
|
||||
|
||||
def delete(self, kb_id: str, user: User) -> None:
|
||||
"""删除知识库 → 进回收站(3 天后自动彻底删除)。"""
|
||||
kb = self.get_or_404(kb_id, user)
|
||||
self._kb_repo.delete(kb)
|
||||
kb.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._session.commit()
|
||||
|
||||
def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]:
|
||||
"""重新生成 Token。旧链接立即失效。"""
|
||||
"""重新生成 Token。旧链接立即失效,新链接默认 30 分钟有效期。"""
|
||||
kb = self.get_or_404(kb_id, user)
|
||||
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
|
||||
self._kb_repo.update(
|
||||
@@ -108,6 +119,7 @@ class KnowledgeBaseService:
|
||||
token_encrypted=token_encrypted,
|
||||
token_hint=token_hint,
|
||||
)
|
||||
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._session.commit()
|
||||
return kb, token
|
||||
|
||||
@@ -117,6 +129,20 @@ class KnowledgeBaseService:
|
||||
self._session.commit()
|
||||
return kb
|
||||
|
||||
def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase:
|
||||
"""设置链接有效期。None = 长期有效;负数表示已过期(测试用)。"""
|
||||
from datetime import timedelta
|
||||
|
||||
kb = self.get_or_404(kb_id, user)
|
||||
if expires_in_minutes is None:
|
||||
kb.token_expires_at = None
|
||||
else:
|
||||
kb.token_expires_at = (datetime.now() + timedelta(minutes=expires_in_minutes)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
self._session.commit()
|
||||
return kb
|
||||
|
||||
def get_full_token(self, kb: KnowledgeBase) -> str | None:
|
||||
"""解密 token 原文(供后台显示完整链接)。"""
|
||||
if kb.token_encrypted:
|
||||
|
||||
@@ -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
|
||||
session_module._store.clear()
|
||||
|
||||
# 重置存储服务单例(否则上一个测试的 tmp_path 被复用)
|
||||
import app.storage.local_storage as ls
|
||||
monkeypatch.setattr(ls, "_instance", None)
|
||||
|
||||
yield
|
||||
|
||||
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
|
||||
# ============================================================
|
||||
# AI Knowledge Link — 一键部署脚本
|
||||
# 在服务器项目目录执行:bash deploy.sh
|
||||
# AI Knowledge Link — 首次部署脚本
|
||||
# Docker 跑 backend + frontend,服务器本机 MySQL,宝塔 Nginx 反代
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
@@ -10,48 +10,43 @@ echo "=== AI Knowledge Link 部署 ==="
|
||||
|
||||
# 检查 Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "错误:未安装 Docker。请先安装:"
|
||||
echo " curl -fsSL https://get.docker.com | sh"
|
||||
echo "错误:未安装 Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo "错误:docker compose 不可用。请检查 Docker 版本。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 检查 .env
|
||||
# 1. 创建 .env
|
||||
if [ ! -f .env ]; then
|
||||
echo "创建 .env ..."
|
||||
|
||||
# 自动生成 SECRET_KEY
|
||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))" 2>/dev/null || openssl rand -base64 48)
|
||||
|
||||
cat > .env << EOF
|
||||
SECRET_KEY=${SECRET}
|
||||
MYSQL_ROOT_PASSWORD=Lzcc6-01
|
||||
MYSQL_PASSWORD=Lzcc6-01
|
||||
EOF
|
||||
echo ".env 已创建,SECRET_KEY 已自动生成"
|
||||
echo ".env 已创建"
|
||||
fi
|
||||
|
||||
# 2. 创建数据目录
|
||||
# 2. 数据目录
|
||||
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 "构建镜像..."
|
||||
docker compose build
|
||||
|
||||
echo "启动服务..."
|
||||
docker compose up -d
|
||||
|
||||
# 4. 等待 MySQL 就绪
|
||||
echo "等待 MySQL 就绪..."
|
||||
sleep 10
|
||||
# 5. 数据库迁移
|
||||
echo "执行数据库迁移..."
|
||||
sleep 3
|
||||
docker compose exec -T backend alembic upgrade head
|
||||
# 6. 创建内部管理员账号(如果不存在)
|
||||
|
||||
# 6. 管理员账号
|
||||
echo "检查管理员账号..."
|
||||
docker compose exec -T backend python -c "
|
||||
from app.core.db import get_session_factory
|
||||
@@ -70,29 +65,26 @@ with factory() as session:
|
||||
session.add(plan)
|
||||
session.flush()
|
||||
admin = User(
|
||||
username='admin',
|
||||
email='admin@company.com',
|
||||
username='admin', email='admin@company.com',
|
||||
password_hash=hash_password('Lzcc6-01'),
|
||||
status='active',
|
||||
role='internal',
|
||||
plan_id=plan.id,
|
||||
status='active', role='internal', plan_id=plan.id,
|
||||
)
|
||||
session.add(admin)
|
||||
session.commit()
|
||||
print('管理员账号已创建: admin / Lzcc6-01')
|
||||
print('管理员已创建: admin / Lzcc6-01')
|
||||
else:
|
||||
print('管理员账号已存在,跳过')
|
||||
print('管理员已存在')
|
||||
"
|
||||
# 7. 完成
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 部署完成!"
|
||||
echo " 容器部署完成!"
|
||||
echo ""
|
||||
echo " 访问地址: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')"
|
||||
echo " 内部登录: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')/internal-login"
|
||||
echo " 后端: http://127.0.0.1:8000/api/healthz"
|
||||
echo " 前端: http://127.0.0.1:5173"
|
||||
echo ""
|
||||
echo " 管理员: admin"
|
||||
echo " 密码: Lzcc6-01"
|
||||
echo ""
|
||||
echo " 更新部署: bash update.sh"
|
||||
echo " 下一步:宝塔 Nginx 反代(站点设置 → 反向代理):"
|
||||
echo " /api/ → http://127.0.0.1:8000"
|
||||
echo " /k/ → http://127.0.0.1:8000"
|
||||
echo " / → http://127.0.0.1:5173"
|
||||
echo "=========================================="
|
||||
+14
-53
@@ -1,45 +1,24 @@
|
||||
# AI Knowledge Link — Docker Compose (生产)
|
||||
# AI Knowledge Link — Docker Compose
|
||||
# 只跑 backend + frontend 两个容器,Nginx 用服务器宝塔的(反代到这两个容器)
|
||||
#
|
||||
# 用法:
|
||||
# 1. 把代码上传到服务器
|
||||
# 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
|
||||
# 部署:bash deploy.sh
|
||||
# 更新:bash update.sh
|
||||
|
||||
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:
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
ENVIRONMENT: production
|
||||
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
|
||||
DEFAULT_STORAGE_QUOTA: "104857600"
|
||||
DEFAULT_MAX_FILE_SIZE: "20971520"
|
||||
@@ -48,39 +27,21 @@ services:
|
||||
FRONTEND_ORIGIN: http://localhost
|
||||
volumes:
|
||||
- app_data:/app/data
|
||||
expose:
|
||||
- "8000"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ---------- 前端 ----------
|
||||
# ---------- 前端(内部 nginx 已配置 SPA 路由回退)----------
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
# ---------- Nginx ----------
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
- "5173:80"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
app_data:
|
||||
+3
-3
@@ -4,15 +4,15 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve with nginx
|
||||
# Stage 2: serve with nginx(含 SPA 路由回退配置)
|
||||
FROM nginx:1.27-alpine AS serve
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 自定义 nginx 配置由外层 docker-compose 挂载,这里只 serve 静态产物
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
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;
|
||||
// 未登录 → 跳转登录页
|
||||
if (status === 401) {
|
||||
// 避免在登录页循环跳转
|
||||
if (window.location.pathname !== '/login') {
|
||||
// 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
|
||||
const path = window.location.pathname;
|
||||
const isLoginPage = path === '/login' || path === '/internal-login';
|
||||
if (!isLoginPage) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -19,8 +19,10 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// 未登录 → 跳转登录页
|
||||
if (status === 401) {
|
||||
// 避免在登录页循环跳转
|
||||
if (window.location.pathname !== '/login') {
|
||||
// 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
|
||||
const path = window.location.pathname
|
||||
const isLoginPage = path === '/login' || path === '/internal-login'
|
||||
if (!isLoginPage) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
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>
|
||||
<template #title>设置</template>
|
||||
</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-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('/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('/recycle-bin')">🗑️ 回收站</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ const router = createRouter({
|
||||
name: 'KbDetail',
|
||||
component: () => import('@/views/KbDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'recycle-bin',
|
||||
name: 'RecycleBin',
|
||||
component: () => import('@/views/RecycleBin.vue'),
|
||||
},
|
||||
{
|
||||
path: '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) {
|
||||
// 方法1: 现代 Clipboard API(Chrome/Firefox/Edge 桌面端)
|
||||
// 方法1: 现代 Clipboard API(需 HTTPS 或 localhost)
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
|
||||
/* 权限被拒或非安全上下文,继续降级 */
|
||||
}
|
||||
}
|
||||
// 方法2: 传统 execCommand(iOS Safari 兼容方案)
|
||||
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari)
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
// 防止页面滚动
|
||||
textarea.setAttribute('readonly', '');
|
||||
// 不可见但可选中;不设 display:none(Safari 会取消选中)
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
// iOS Safari 需要设置 selection range
|
||||
// iOS Safari 必须手动设置选区
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const success = document.execCommand('copy');
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
}
|
||||
catch {
|
||||
success = false;
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
return success;
|
||||
if (success)
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
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> {
|
||||
// 方法1: 现代 Clipboard API(Chrome/Firefox/Edge 桌面端)
|
||||
// 方法1: 现代 Clipboard API(需 HTTPS 或 localhost)
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
|
||||
/* 权限被拒或非安全上下文,继续降级 */
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: 传统 execCommand(iOS Safari 兼容方案)
|
||||
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari)
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
|
||||
// 防止页面滚动
|
||||
// 不可见但可选中;不设 display:none(Safari 会取消选中)
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
// iOS Safari 需要设置 selection range
|
||||
// iOS Safari 必须手动设置选区
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
|
||||
const success = document.execCommand('copy')
|
||||
let success = false
|
||||
try {
|
||||
success = document.execCommand('copy')
|
||||
} catch {
|
||||
success = false
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
return success
|
||||
if (success) return true
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
|
||||
// 链接有效期
|
||||
const expiryOptions = [
|
||||
{ label: '10 分钟', value: 10 },
|
||||
{ label: '30 分钟', value: 30 },
|
||||
{ label: '1 小时', value: 60 },
|
||||
{ label: '3 小时', value: 180 },
|
||||
{ label: '24 小时', value: 1440 },
|
||||
{ label: '长期有效', value: 0 },
|
||||
]
|
||||
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
|
||||
expires_at: null,
|
||||
is_expired: false,
|
||||
})
|
||||
const expiryText = computed(() => {
|
||||
if (linkExpiry.value.is_expired) return '⛔ 已失效'
|
||||
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
|
||||
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
|
||||
})
|
||||
|
||||
async function loadLink() {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
|
||||
aiUrl.value = `${window.location.origin}/k/${data.token}`
|
||||
linkExpiry.value = {
|
||||
expires_at: data.expires_at,
|
||||
is_expired: data.is_expired,
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 设置链接有效期(0 = 长期有效,需二次确认)
|
||||
async function handleExpiryChange(val: number) {
|
||||
if (val === 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
|
||||
'⚠️ 泄露风险提示',
|
||||
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
|
||||
expires_in_minutes: val === 0 ? null : val,
|
||||
})
|
||||
ElMessage.success('链接有效期已更新。')
|
||||
loadLink()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
|
||||
function openTextDialog() {
|
||||
textForm.value.category_id = selectedCategoryId.value
|
||||
showTextDialog.value = true
|
||||
}
|
||||
|
||||
// 创建文本内容
|
||||
async function handleCreateText() {
|
||||
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
|
||||
@@ -134,7 +185,7 @@ async function handleCreateText() {
|
||||
title: textForm.value.title,
|
||||
content: textForm.value.content,
|
||||
content_format: textForm.value.content_format,
|
||||
category_id: selectedCategoryId.value || textForm.value.category_id,
|
||||
category_id: textForm.value.category_id,
|
||||
})
|
||||
ElMessage.success('文本内容已创建!')
|
||||
showTextDialog.value = false
|
||||
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
|
||||
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
|
||||
return cat ? cat.name : '未分类'
|
||||
}
|
||||
|
||||
// AI 链接打码:显示域名,token 部分用 • 代替
|
||||
const maskedAiUrl = computed(() => {
|
||||
if (!aiUrl.value) return '尚未生成'
|
||||
const idx = aiUrl.value.indexOf('/k/')
|
||||
if (idx === -1) return '••••••••'
|
||||
const prefix = aiUrl.value.slice(0, idx + 3)
|
||||
const tokenLen = aiUrl.value.length - idx - 3
|
||||
return prefix + '•'.repeat(tokenLen)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 链接 -->
|
||||
<!-- AI 链接(打码显示,只能复制获取) -->
|
||||
<el-card style="margin-bottom: 16px" shadow="hover">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
|
||||
<span style="font-weight: bold; font-size: 14px">AI 链接:</span>
|
||||
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
|
||||
<template #append>
|
||||
<el-button @click="handleCopyLink">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
|
||||
{{ maskedAiUrl }}
|
||||
</span>
|
||||
<el-select
|
||||
:model-value="null"
|
||||
placeholder="⏱️ 设置有效期"
|
||||
style="width: 150px"
|
||||
@change="(val: number) => handleExpiryChange(val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in expiryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
|
||||
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
|
||||
{{ expiryText }}
|
||||
</span>
|
||||
<span style="color: #999">🔒 链接即访问凭证,已隐藏显示;仅可通过复制按钮获取。请勿公开传播。</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 手机端目录按钮 + 操作按钮 -->
|
||||
<div class="mobile-actions">
|
||||
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
|
||||
<el-button @click="showTextDialog = true" style="flex: 1">✏️ 添加文本</el-button>
|
||||
<el-button @click="openTextDialog" style="flex: 1">✏️ 添加文本</el-button>
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
|
||||
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
|
||||
</el-upload>
|
||||
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
|
||||
</el-tag>
|
||||
</span>
|
||||
<div class="desktop-actions">
|
||||
<el-button @click="showTextDialog = true">✏️ 添加文本</el-button>
|
||||
<el-button @click="openTextDialog">✏️ 添加文本</el-button>
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
|
||||
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
|
||||
</el-upload>
|
||||
|
||||
+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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import apiClient from '@/api/client'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const router = useRouter()
|
||||
const kbs = ref<any[]>([])
|
||||
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
|
||||
const url = `${window.location.origin}${data.ai_url}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
ElMessage.success('AI 链接已复制到剪贴板!')
|
||||
const success = await copyToClipboard(url)
|
||||
if (success) {
|
||||
ElMessage.success('AI 链接已复制到剪贴板!')
|
||||
} else {
|
||||
ElMessage.error('复制失败,请稍后重试。')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import apiClient from '@/api/client';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
const router = useRouter();
|
||||
const kbs = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -55,8 +56,13 @@ async function handleCopyLink(kb) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`);
|
||||
const url = `${window.location.origin}${data.ai_url}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
ElMessage.success('AI 链接已复制到剪贴板!');
|
||||
const success = await copyToClipboard(url);
|
||||
if (success) {
|
||||
ElMessage.success('AI 链接已复制到剪贴板!');
|
||||
}
|
||||
else {
|
||||
ElMessage.error('复制失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
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
|
||||
# ============================================================
|
||||
# AI Knowledge Link — 一键更新脚本
|
||||
# 在服务器项目目录执行:bash update.sh
|
||||
#
|
||||
# 功能:
|
||||
# 1. 拉取最新代码
|
||||
# 2. 重新构建镜像
|
||||
# 3. 重启服务(数据库不丢失)
|
||||
# 4. 执行数据库迁移(如有新迁移)
|
||||
# 用法: bash update.sh
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== AI Knowledge Link 更新 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
|
||||
# 1. 拉取最新代码
|
||||
echo "[1/4] 拉取最新代码..."
|
||||
git pull
|
||||
if ! git pull; then
|
||||
echo ""
|
||||
echo "错误:git pull 失败。通常是服务器上有本地修改与远程冲突。"
|
||||
echo "如果确认要放弃服务器上的修改,执行:"
|
||||
echo " git checkout -- . && git pull"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 重新构建
|
||||
echo ""
|
||||
echo "[2/4] 重新构建镜像..."
|
||||
docker compose build
|
||||
|
||||
# 3. 重启服务
|
||||
echo ""
|
||||
echo "[3/4] 重启服务..."
|
||||
docker compose up -d
|
||||
|
||||
# 4. 数据库迁移
|
||||
echo "[4/4] 执行数据库迁移..."
|
||||
sleep 5
|
||||
docker compose exec -T backend alembic upgrade head
|
||||
|
||||
# 5. 完成
|
||||
# 4. 等待后端就绪后执行迁移(兜底,容器启动时也会自动迁移)
|
||||
echo ""
|
||||
echo "更新完成!$(date '+%H:%M:%S')"
|
||||
echo "服务状态:"
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||
echo "[4/4] 等待后端就绪并执行迁移..."
|
||||
for i in $(seq 1 30); do
|
||||
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