Compare commits

..
11 Commits
Author SHA1 Message Date
amb ff3e2a7273 回收站,软删除(三天自动清空),以及默认链接三十分钟失效 2026-09-02 18:59:42 +08:00
amb bfb7a1a8a2 细节优化 2026-09-02 18:32:56 +08:00
amb 8ec7856bfc 细节优化 2026-09-02 18:04:41 +08:00
amb cecb5f2714 1 2026-09-02 17:08:52 +08:00
amb 6a2de55332 1 2026-09-02 17:00:51 +08:00
amb b4dfc5e711 脚本更新、配置更新 2026-09-02 16:32:26 +08:00
amb d44f8303e7 脚本更新 2026-09-02 14:34:46 +08:00
amb 2a12ad9b7a 脚本更新 2026-09-02 13:56:12 +08:00
amb c54ef991ea 脚本更新 2026-09-02 13:20:48 +08:00
amb feb99b9ba1 232323 2026-09-02 12:58:03 +08:00
amb fc1e2f440b 脚本更新 2026-09-02 12:55:26 +08:00
39 changed files with 2184 additions and 708 deletions
+3 -2
View File
@@ -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')
+35 -17
View File
@@ -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()
+37 -4
View File
@@ -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:
+87
View File
@@ -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}
+16
View File
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
# --- 基础 ---
environment: str = "local" # local / production
secret_key: str = Field(min_length=16)
# Cookie Secure 标记:默认跟随 environmentproduction→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
+7
View File
@@ -83,6 +83,13 @@ class RateLimitedError(AppError):
message = "请求过于频繁,请稍后再试。"
class LinkExpiredError(AppError):
"""AI 链接已过期(HTTP 410 Gone)。"""
status_code = status.HTTP_410_GONE
code = "LINK_EXPIRED"
message = "链接已失效,请重新生成。"
# --- 上传与配额 ---
+1 -1
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -1,7 +1,7 @@
"""SQLAlchemy 2.0 基础模型类与 Mixin。"""
import uuid
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import String, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -13,8 +13,11 @@ def generate_uuid() -> str:
def utcnow_iso() -> str:
"""返回 UTC 当前时间的 ISO8601 字符串。"""
return datetime.now(timezone.utc).isoformat()
"""当前时间,格式:年-月-日 时:分:秒(容器时区,生产为 Asia/Shanghai)。
定长格式保证字符串排序 = 时间排序。
"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Base(DeclarativeBase):
+6
View File
@@ -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,
+6
View File
@@ -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")
+11
View File
@@ -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")
+46 -1
View File
@@ -23,7 +23,7 @@ from fastapi import Path as PathParam
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError
from app.core.errors import LinkExpiredError, NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token
from app.models.document_category import DocumentCategory
@@ -33,6 +33,51 @@ from app.services.kb_public_service import KbPublicService
router = APIRouter(prefix="/k", tags=["public"])
async def link_expired_handler(request: Request, exc: LinkExpiredError) -> Response:
"""链接过期:HTML 路径返回友好失效页,JSON/MD/TXT 返回对应格式的错误信息。"""
path = request.url.path
message = "链接已失效,请联系分享者重新生成链接。"
if path.endswith(".json"):
return Response(
status_code=410,
content=json.dumps({"code": "LINK_EXPIRED", "message": message, "detail": None}, ensure_ascii=False),
media_type="application/json",
)
if path.endswith(".md") or path.endswith(".txt"):
return PlainTextResponse(content=message, status_code=410, media_type="text/plain; charset=utf-8")
return HTMLResponse(
status_code=410,
content=f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>链接已失效</title>
<meta name="robots" content="noindex,nofollow,noarchive">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
margin: 0; background: #f5f7fa; color: #333; text-align: center; }}
.box {{ background: #fff; padding: 50px 40px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 420px; }}
.icon {{ font-size: 52px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 12px; }}
p {{ color: #888; font-size: 14px; line-height: 1.8; margin: 0; }}
</style>
</head>
<body>
<div class="box">
<div class="icon">⏰</div>
<h1>链接无效或已失效</h1>
<p>该知识库链接已过期。<br>请联系分享者重新生成链接后,获取新的访问地址。</p>
</div>
</body>
</html>""",
)
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
"""记录访问日志(best-effort)。"""
try:
+8 -1
View File
@@ -33,7 +33,14 @@ class KbListResponse(BaseModel):
class KbTokenResponse(BaseModel):
"""Token 重置/创建时的完整 URL 响应。"""
"""Token 重置/创建/查询时的完整 URL 响应。"""
token: str
ai_url: str
token_hint: str
expires_at: str | None = None
is_expired: bool = False
class KbSetExpiryRequest(BaseModel):
"""设置链接有效期。expires_in_minutes=null 表示长期有效。"""
expires_in_minutes: int | None = None
+2 -2
View File
@@ -1,6 +1,6 @@
"""访问日志服务。"""
from datetime import datetime, timezone
from datetime import datetime
from app.models.access_log import AccessLog
from sqlalchemy.orm import Session
@@ -24,7 +24,7 @@ class AccessLogService:
knowledge_base_id=knowledge_base_id,
document_id=document_id,
path=path,
accessed_at=datetime.now(timezone.utc).isoformat(),
accessed_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
user_agent=user_agent[:500] if user_agent else None,
request_type=request_type,
)
+19 -1
View File
@@ -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:
+33 -4
View File
@@ -4,7 +4,9 @@ HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询
支持目录树结构和分类过滤。
"""
from app.core.errors import NotFoundError
from datetime import datetime
from app.core.errors import LinkExpiredError, NotFoundError
from app.core.security import hash_token
from app.models.document import Document
from app.models.document_category import DocumentCategory
@@ -14,6 +16,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy import func, select
from sqlalchemy.orm import Session
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
class KbPublicService:
def __init__(self, session: Session) -> None:
@@ -22,18 +26,43 @@ class KbPublicService:
self._doc_repo = DocumentRepository(session)
def get_kb_by_token(self, token: str) -> KnowledgeBase:
"""通过 token 获取知识库。不存在/禁用/删除 → 404。"""
"""通过 token 获取知识库。
不存在/禁用/删除 → 404;存在但已过期 → 410LinkExpiredError)。
"""
token_hash = hash_token(token)
kb = self._kb_repo.get_by_token_hash(token_hash)
if kb is None or not kb.enabled or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
if kb.token_expires_at:
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
expires = None
if expires is not None and datetime.now() > expires:
raise LinkExpiredError()
return kb
@staticmethod
def is_expired(kb: KnowledgeBase) -> bool:
"""判断知识库链接是否已过期(管理端展示用)。"""
if not kb.token_expires_at:
return False
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
return False
return datetime.now() > expires
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。"""
"""获取目录树(含文档数量),排除已删除目录"""
stmt = (
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())
+28 -2
View File
@@ -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:
+338
View File
@@ -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)
+4
View File
@@ -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()
+251
View File
@@ -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"
+28 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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;"]
+19
View File
@@ -0,0 +1,19 @@
# 前端容器内部 nginx —— 负责 SPA 路由回退
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 关键:所有找不到的路径回退到 index.htmlVue Router history 模式必需)
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+4 -2
View File
@@ -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);
+4 -2
View File
@@ -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)
+5
View File
@@ -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>
+5
View File
@@ -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',
+32 -9
View File
@@ -1,37 +1,60 @@
/**
* 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
*
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3
*/
export async function copyToClipboard(text) {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端
// 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return true;
}
catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
/* 权限被拒或非安全上下文,继续降级 */
}
}
// 方法2: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea');
textarea.value = text;
// 防止页面滚动
textarea.setAttribute('readonly', '');
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
// iOS Safari 需要设置 selection range
// iOS Safari 必须手动设置选区
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const success = document.execCommand('copy');
let success = false;
try {
success = document.execCommand('copy');
}
catch {
success = false;
}
document.body.removeChild(textarea);
return success;
if (success)
return true;
}
catch {
return false;
/* 继续降级 */
}
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = window.clipboardData;
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text);
}
catch {
return false;
}
}
return false;
}
+31 -9
View File
@@ -1,24 +1,29 @@
/**
* 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
*
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端
// 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
/* 权限被拒或非安全上下文,继续降级 */
}
}
// 方法2: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
// 防止页面滚动
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.appendChild(textarea)
// iOS Safari 需要设置 selection range
// iOS Safari 必须手动设置选区
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
const success = document.execCommand('copy')
let success = false
try {
success = document.execCommand('copy')
} catch {
success = false
}
document.body.removeChild(textarea)
return success
if (success) return true
} catch {
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
}
+88 -9
View File
@@ -82,10 +82,55 @@ async function loadDocs() {
loading.value = false
}
// 链接有效期
const expiryOptions = [
{ label: '10 分钟', value: 10 },
{ label: '30 分钟', value: 30 },
{ label: '1 小时', value: 60 },
{ label: '3 小时', value: 180 },
{ label: '24 小时', value: 1440 },
{ label: '长期有效', value: 0 },
]
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
expires_at: null,
is_expired: false,
})
const expiryText = computed(() => {
if (linkExpiry.value.is_expired) return '⛔ 已失效'
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
})
async function loadLink() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
aiUrl.value = `${window.location.origin}/k/${data.token}`
linkExpiry.value = {
expires_at: data.expires_at,
is_expired: data.is_expired,
}
} catch { /* ignore */ }
}
// 设置链接有效期(0 = 长期有效,需二次确认)
async function handleExpiryChange(val: number) {
if (val === 0) {
try {
await ElMessageBox.confirm(
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
'⚠️ 泄露风险提示',
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
}
try {
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
expires_in_minutes: val === 0 ? null : val,
})
ElMessage.success('链接有效期已更新。')
loadLink()
} catch { /* ignore */ }
}
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
uploading.value = false
}
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
function openTextDialog() {
textForm.value.category_id = selectedCategoryId.value
showTextDialog.value = true
}
// 创建文本内容
async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
@@ -134,7 +185,7 @@ async function handleCreateText() {
title: textForm.value.title,
content: textForm.value.content,
content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id,
category_id: textForm.value.category_id,
})
ElMessage.success('文本内容已创建!')
showTextDialog.value = false
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
return cat ? cat.name : '未分类'
}
// AI 链接打码:显示域名,token 部分用 • 代替
const maskedAiUrl = computed(() => {
if (!aiUrl.value) return '尚未生成'
const idx = aiUrl.value.indexOf('/k/')
if (idx === -1) return '••••••••'
const prefix = aiUrl.value.slice(0, idx + 3)
const tokenLen = aiUrl.value.length - idx - 3
return prefix + '•'.repeat(tokenLen)
})
</script>
<template>
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
</div>
</div>
<!-- AI 链接 -->
<!-- AI 链接打码显示只能复制获取 -->
<el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span>
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
<template #append>
<el-button @click="handleCopyLink">复制</el-button>
</template>
</el-input>
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
{{ maskedAiUrl }}
</span>
<el-select
:model-value="null"
placeholder="⏱️ 设置有效期"
style="width: 150px"
@change="(val: number) => handleExpiryChange(val)"
>
<el-option
v-for="opt in expiryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
</div>
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
{{ expiryText }}
</span>
<span style="color: #999">🔒 链接即访问凭证已隐藏显示仅可通过复制按钮获取请勿公开传播</span>
</div>
</el-card>
<!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions">
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
<el-button @click="showTextDialog = true" style="flex: 1"> 添加文本</el-button>
<el-button @click="openTextDialog" style="flex: 1"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload>
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
</el-tag>
</span>
<div class="desktop-actions">
<el-button @click="showTextDialog = true"> 添加文本</el-button>
<el-button @click="openTextDialog"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
</el-upload>
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client'
import { copyToClipboard } from '@/utils/clipboard'
const router = useRouter()
const kbs = ref<any[]>([])
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
const url = `${window.location.origin}${data.ai_url}`
await navigator.clipboard.writeText(url)
ElMessage.success('AI 链接已复制到剪贴板!')
const success = await copyToClipboard(url)
if (success) {
ElMessage.success('AI 链接已复制到剪贴板!')
} else {
ElMessage.error('复制失败,请稍后重试。')
}
} catch { /* ignore */ }
}
</script>
+8 -2
View File
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { copyToClipboard } from '@/utils/clipboard';
const router = useRouter();
const kbs = ref([]);
const loading = ref(false);
@@ -55,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 */ }
}
+198
View File
@@ -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>
+41 -17
View File
@@ -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