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/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
COPY . . COPY . .
@@ -19,4 +19,5 @@ VOLUME /app/data
EXPOSE 8000 EXPOSE 8000
# 启动:先跑迁移,再起服务 # 启动:先跑迁移,再起服务
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"] # 注意:单 worker —— Session 存在进程内存中,多 worker 会导致登录态随机丢失
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]
@@ -0,0 +1,29 @@
"""add_kb_token_expires_at
Revision ID: 5bb03575e2e7
Revises: 4e40432cab9f
Create Date: 2026-09-02 17:49:46.298836
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5bb03575e2e7'
down_revision: Union[str, None] = '4e40432cab9f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('knowledge_bases', sa.Column('token_expires_at', sa.String(length=32), nullable=True, comment='链接过期时间,NULL=长期有效'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('knowledge_bases', 'token_expires_at')
# ### end Alembic commands ###
@@ -0,0 +1,35 @@
"""add_deleted_at_recycle_bin
Revision ID: a3f8c21d94b7
Revises: 5bb03575e2e7
Create Date: 2026-09-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3f8c21d94b7'
down_revision: Union[str, None] = '5bb03575e2e7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('documents', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.add_column('document_categories', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.add_column('knowledge_bases', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.create_index('ix_documents_deleted_at', 'documents', ['deleted_at'])
op.create_index('ix_document_categories_deleted_at', 'document_categories', ['deleted_at'])
op.create_index('ix_knowledge_bases_deleted_at', 'knowledge_bases', ['deleted_at'])
def downgrade() -> None:
op.drop_index('ix_knowledge_bases_deleted_at', table_name='knowledge_bases')
op.drop_index('ix_document_categories_deleted_at', table_name='document_categories')
op.drop_index('ix_documents_deleted_at', table_name='documents')
op.drop_column('knowledge_bases', 'deleted_at')
op.drop_column('document_categories', 'deleted_at')
op.drop_column('documents', 'deleted_at')
+35 -17
View File
@@ -52,10 +52,13 @@ def get_category_tree(
"""获取完整目录树(含文档数量)。""" """获取完整目录树(含文档数量)。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
# 获取所有分类 # 获取所有未删除的分类
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id) .where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.sort_order, DocumentCategory.name) .order_by(DocumentCategory.sort_order, DocumentCategory.name)
) )
all_cats = list(db.scalars(stmt).all()) all_cats = list(db.scalars(stmt).all())
@@ -110,7 +113,10 @@ def list_categories_flat(
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id) .where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.path, DocumentCategory.sort_order) .order_by(DocumentCategory.path, DocumentCategory.sort_order)
) )
cats = list(db.scalars(stmt).all()) cats = list(db.scalars(stmt).all())
@@ -236,32 +242,44 @@ def delete_category(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> None: ) -> None:
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。""" """删除分类 → 进回收站(含全部子分类及其下文档,3 天后自动彻底删除)。"""
from datetime import datetime
from app.models.document import Document
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id) cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id: if cat is None or cat.knowledge_base_id != kb_id or cat.deleted_at:
raise NotFoundError("分类不存在。") raise NotFoundError("分类不存在。")
# 删除所有子分类 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 收集本分类 + 所有存活的后代分类
target_ids = [cat_id]
if cat.path: if cat.path:
stmt = select(DocumentCategory).where( stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id, DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(cat.path), DocumentCategory.path.startswith(cat.path),
DocumentCategory.id != cat_id, DocumentCategory.deleted_at.is_(None),
) )
children = list(db.scalars(stmt).all()) for c in db.scalars(stmt).all():
for child in children: if c.id != cat_id:
db.delete(child) target_ids.append(c.id)
# 关联文档的 category_id 置 NULL # 软删除分类树
from app.models.document import Document for cid in target_ids:
c = db.get(DocumentCategory, cid)
c.deleted_at = now
doc_stmt = select(Document).where(Document.category_id == cat_id) # 软删除目录下的文档(同一批次时间戳,便于整组恢复)
docs = list(db.scalars(doc_stmt).all()) doc_stmt = select(Document).where(
for doc in docs: Document.category_id.in_(target_ids),
doc.category_id = None Document.status != "DELETED",
)
for doc in db.scalars(doc_stmt).all():
doc.status = "DELETED"
doc.deleted_at = now
db.delete(cat)
db.commit() db.commit()
+37 -4
View File
@@ -9,6 +9,7 @@ from app.schemas.knowledge_base import (
KbCreateRequest, KbCreateRequest,
KbListResponse, KbListResponse,
KbResponse, KbResponse,
KbSetExpiryRequest,
KbTokenResponse, KbTokenResponse,
KbUpdateRequest, KbUpdateRequest,
) )
@@ -86,7 +87,10 @@ def regenerate_token(
) -> KbTokenResponse: ) -> KbTokenResponse:
svc = KnowledgeBaseService(db) svc = KnowledgeBaseService(db)
kb, token = svc.regenerate_token(kb_id, user) kb, token = svc.regenerate_token(kb_id, user)
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint) return KbTokenResponse(
token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint,
expires_at=kb.token_expires_at, is_expired=False,
)
@router.post("/{kb_id}/enable", response_model=KbResponse) @router.post("/{kb_id}/enable", response_model=KbResponse)
@@ -117,13 +121,42 @@ def get_knowledge_base_link(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> KbTokenResponse: ) -> KbTokenResponse:
"""获取完整 AI 链接(解密 token)。""" """获取完整 AI 链接(解密 token)+ 有效期信息"""
from app.services.kb_public_service import KbPublicService
svc = KnowledgeBaseService(db) svc = KnowledgeBaseService(db)
kb = svc.get_or_404(kb_id, user) kb = svc.get_or_404(kb_id, user)
token = svc.get_full_token(kb) token = svc.get_full_token(kb)
if token is None: if token is None:
return KbTokenResponse(token="", ai_url="", token_hint=kb.token_hint or "") return KbTokenResponse(
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint or "") token="", ai_url="", token_hint=kb.token_hint or "",
expires_at=None, is_expired=False,
)
return KbTokenResponse(
token=token,
ai_url=f"/k/{token}",
token_hint=kb.token_hint or "",
expires_at=kb.token_expires_at,
is_expired=KbPublicService.is_expired(kb),
)
@router.post("/{kb_id}/set-expiry", response_model=KbResponse)
def set_link_expiry(
kb_id: str,
body: KbSetExpiryRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
"""设置 AI 链接有效期。expires_in_minutes 为 null 表示长期有效。"""
svc = KnowledgeBaseService(db)
kb = svc.set_expiry(kb_id, user, body.expires_in_minutes)
doc_count = svc._kb_repo.count_documents(kb_id)
return KbResponse(
id=kb.id, name=kb.name, description=kb.description, enabled=kb.enabled,
token_hint=kb.token_hint, document_count=doc_count,
created_at=kb.created_at, updated_at=kb.updated_at,
)
def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse: def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse:
+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 environment: str = "local" # local / production
secret_key: str = Field(min_length=16) 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" database_url: str = "mysql+pymysql://admin:Lzcc6-01@47.109.98.44:33306/amb_rag?charset=utf8mb4"
@@ -35,6 +38,12 @@ class Settings(BaseSettings):
rate_limit_per_token_per_min: int = 60 rate_limit_per_token_per_min: int = 60
rate_limit_per_ip_per_min: int = 30 rate_limit_per_ip_per_min: int = 30
# --- 数据保留 ---
# 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐)
access_log_retention_days: int = 90
# 回收站保留天数,超期自动彻底删除(含物理文件)
recycle_bin_retention_days: int = 3
# --- CORS --- # --- CORS ---
frontend_origin: str = "http://localhost:5173" frontend_origin: str = "http://localhost:5173"
@@ -42,6 +51,13 @@ class Settings(BaseSettings):
def is_production(self) -> bool: def is_production(self) -> bool:
return self.environment == "production" return self.environment == "production"
@property
def use_secure_cookie(self) -> bool:
"""HTTPS 站点才应启用 Secure Cookie;未显式配置时跟随 environment。"""
if self.cookie_secure is not None:
return self.cookie_secure
return self.is_production
@property @property
def is_mysql(self) -> bool: def is_mysql(self) -> bool:
return "mysql" in self.database_url return "mysql" in self.database_url
+7
View File
@@ -83,6 +83,13 @@ class RateLimitedError(AppError):
message = "请求过于频繁,请稍后再试。" message = "请求过于频繁,请稍后再试。"
class LinkExpiredError(AppError):
"""AI 链接已过期(HTTP 410 Gone)。"""
status_code = status.HTTP_410_GONE
code = "LINK_EXPIRED"
message = "链接已失效,请重新生成。"
# --- 上传与配额 --- # --- 上传与配额 ---
+1 -1
View File
@@ -68,7 +68,7 @@ def get_cookie_params() -> dict:
"key": SESSION_COOKIE_NAME, "key": SESSION_COOKIE_NAME,
"httponly": True, "httponly": True,
"samesite": "lax", "samesite": "lax",
"secure": settings.is_production, "secure": settings.use_secure_cookie,
"max_age": SESSION_TTL_SECONDS, "max_age": SESSION_TTL_SECONDS,
"path": "/", "path": "/",
} }
+72 -1
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.documents import router as doc_router
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.knowledge_bases import router as kb_router from app.api.knowledge_bases import router as kb_router
from app.api.recycle_bin import router as recycle_router
from app.public.routes import router as public_router from app.public.routes import router as public_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
from app.core.errors import register_exception_handlers from app.core.errors import LinkExpiredError, register_exception_handlers
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -40,6 +41,56 @@ def _seed_free_plan() -> None:
logger.debug("Free plan already exists (id=%s)", plan.id) logger.debug("Free plan already exists (id=%s)", plan.id)
def _cleanup_access_logs() -> int:
"""清理超过保留期的访问日志。返回删除条数。"""
from datetime import datetime, timedelta
from sqlalchemy import delete
from app.core.config import get_settings
from app.models.access_log import AccessLog
settings = get_settings()
retention = settings.access_log_retention_days
if retention <= 0:
return 0
cutoff = (datetime.now() - timedelta(days=retention)).strftime("%Y-%m-%d %H:%M:%S")
factory = get_session_factory()
with factory() as session:
result = session.execute(delete(AccessLog).where(AccessLog.accessed_at < cutoff))
session.commit()
count = result.rowcount or 0
if count:
logger.info("已清理 %d 条过期访问日志(保留 %d 天)", count, retention)
return count
async def _periodic_cleanup() -> None:
"""每小时执行一次数据清理:访问日志 + 回收站过期内容。"""
import asyncio
while True:
await asyncio.sleep(3600)
try:
await asyncio.to_thread(_cleanup_access_logs)
except Exception: # noqa: BLE001
logger.exception("定期清理访问日志失败")
try:
await asyncio.to_thread(_purge_recycle_bin)
except Exception: # noqa: BLE001
logger.exception("定期清理回收站失败")
def _purge_recycle_bin() -> int:
"""彻底删除回收站中超过保留期的内容。"""
from app.services.recycle_bin_service import RecycleBinService
factory = get_session_factory()
with factory() as session:
return RecycleBinService(session).purge_expired()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
@@ -53,8 +104,22 @@ async def lifespan(app: FastAPI):
# Seed:确保 free plan 存在 # Seed:确保 free plan 存在
_seed_free_plan() _seed_free_plan()
# 启动时清理过期数据:访问日志 + 回收站(不阻塞启动)
import asyncio
cleanup_task = asyncio.create_task(_periodic_cleanup())
try:
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
except Exception: # noqa: BLE001
logger.warning("启动时清理访问日志未完成(首次部署属正常)")
try:
await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), timeout=15)
except Exception: # noqa: BLE001
logger.warning("启动时清理回收站未完成(首次部署属正常)")
yield yield
cleanup_task.cancel()
dispose_engine() dispose_engine()
logger.info("Backend shutdown complete") logger.info("Backend shutdown complete")
@@ -80,12 +145,18 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
# 链接过期:HTML 返回友好失效页,JSON/MD/TXT 返回对应格式(HTTP 410
from app.public.routes import link_expired_handler
app.add_exception_handler(LinkExpiredError, link_expired_handler)
# 路由挂载 # 路由挂载
app.include_router(health_router, prefix="/api", tags=["health"]) app.include_router(health_router, prefix="/api", tags=["health"])
app.include_router(auth_router, prefix="/api", tags=["auth"]) app.include_router(auth_router, prefix="/api", tags=["auth"])
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"]) app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
app.include_router(doc_router, prefix="/api", tags=["documents"]) app.include_router(doc_router, prefix="/api", tags=["documents"])
app.include_router(cat_router, prefix="/api", tags=["categories"]) app.include_router(cat_router, prefix="/api", tags=["categories"])
app.include_router(recycle_router, prefix="/api", tags=["recycle-bin"])
app.include_router(public_router, tags=["public"]) app.include_router(public_router, tags=["public"])
return app return app
+6 -3
View File
@@ -1,7 +1,7 @@
"""SQLAlchemy 2.0 基础模型类与 Mixin。""" """SQLAlchemy 2.0 基础模型类与 Mixin。"""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime
from sqlalchemy import String, text from sqlalchemy import String, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -13,8 +13,11 @@ def generate_uuid() -> str:
def utcnow_iso() -> str: def utcnow_iso() -> str:
"""返回 UTC 当前时间的 ISO8601 字符串。""" """当前时间,格式:年-月-日 时:分:秒(容器时区,生产为 Asia/Shanghai)。
return datetime.now(timezone.utc).isoformat()
定长格式保证字符串排序 = 时间排序。
"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Base(DeclarativeBase): class Base(DeclarativeBase):
+6
View File
@@ -126,6 +126,12 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False, nullable=False,
comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)", comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)",
) )
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
error_code: Mapped[str | None] = mapped_column( error_code: Mapped[str | None] = mapped_column(
String(64), String(64),
nullable=True, nullable=True,
+6
View File
@@ -52,6 +52,12 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False, nullable=False,
comment="排序序号", comment="排序序号",
) )
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
# 关系 # 关系
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin") knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
+11
View File
@@ -60,6 +60,17 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=True, nullable=True,
comment="token 末 8 位明文,供后台识别", comment="token 末 8 位明文,供后台识别",
) )
token_expires_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
comment="链接过期时间,NULL=长期有效",
)
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
# 关系 # 关系
user = relationship("User", back_populates="knowledge_bases", lazy="selectin") user = relationship("User", back_populates="knowledge_bases", lazy="selectin")
+46 -1
View File
@@ -23,7 +23,7 @@ from fastapi import Path as PathParam
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError from app.core.errors import LinkExpiredError, NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token from app.core.security import decrypt_token
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
@@ -33,6 +33,51 @@ from app.services.kb_public_service import KbPublicService
router = APIRouter(prefix="/k", tags=["public"]) router = APIRouter(prefix="/k", tags=["public"])
async def link_expired_handler(request: Request, exc: LinkExpiredError) -> Response:
"""链接过期:HTML 路径返回友好失效页,JSON/MD/TXT 返回对应格式的错误信息。"""
path = request.url.path
message = "链接已失效,请联系分享者重新生成链接。"
if path.endswith(".json"):
return Response(
status_code=410,
content=json.dumps({"code": "LINK_EXPIRED", "message": message, "detail": None}, ensure_ascii=False),
media_type="application/json",
)
if path.endswith(".md") or path.endswith(".txt"):
return PlainTextResponse(content=message, status_code=410, media_type="text/plain; charset=utf-8")
return HTMLResponse(
status_code=410,
content=f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>链接已失效</title>
<meta name="robots" content="noindex,nofollow,noarchive">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
margin: 0; background: #f5f7fa; color: #333; text-align: center; }}
.box {{ background: #fff; padding: 50px 40px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 420px; }}
.icon {{ font-size: 52px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 12px; }}
p {{ color: #888; font-size: 14px; line-height: 1.8; margin: 0; }}
</style>
</head>
<body>
<div class="box">
<div class="icon">⏰</div>
<h1>链接无效或已失效</h1>
<p>该知识库链接已过期。<br>请联系分享者重新生成链接后,获取新的访问地址。</p>
</div>
</body>
</html>""",
)
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None: def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
"""记录访问日志(best-effort)。""" """记录访问日志(best-effort)。"""
try: try:
+8 -1
View File
@@ -33,7 +33,14 @@ class KbListResponse(BaseModel):
class KbTokenResponse(BaseModel): class KbTokenResponse(BaseModel):
"""Token 重置/创建时的完整 URL 响应。""" """Token 重置/创建/查询时的完整 URL 响应。"""
token: str token: str
ai_url: str ai_url: str
token_hint: str token_hint: str
expires_at: str | None = None
is_expired: bool = False
class KbSetExpiryRequest(BaseModel):
"""设置链接有效期。expires_in_minutes=null 表示长期有效。"""
expires_in_minutes: int | None = None
+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 app.models.access_log import AccessLog
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -24,7 +24,7 @@ class AccessLogService:
knowledge_base_id=knowledge_base_id, knowledge_base_id=knowledge_base_id,
document_id=document_id, document_id=document_id,
path=path, path=path,
accessed_at=datetime.now(timezone.utc).isoformat(), accessed_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
user_agent=user_agent[:500] if user_agent else None, user_agent=user_agent[:500] if user_agent else None,
request_type=request_type, request_type=request_type,
) )
+19 -1
View File
@@ -1,6 +1,7 @@
"""文档服务:上传、删除、配额管理。""" """文档服务:上传、删除、配额管理。"""
import hashlib import hashlib
from datetime import datetime
from pathlib import Path from pathlib import Path
import filetype import filetype
@@ -153,13 +154,30 @@ class DocumentService:
return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size) return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size)
def delete(self, doc_id: str, user: User) -> None: def delete(self, doc_id: str, user: User) -> None:
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
doc = self.get_or_404(doc_id, user) doc = self.get_or_404(doc_id, user)
file_size = doc.file_size file_size = doc.file_size
self._doc_repo.delete(doc) self._doc_repo.delete(doc)
# 回补配额 doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 配额立即回补(用户预期删除即释放空间)
self._restore_quota(user, file_size) self._restore_quota(user, file_size)
self._session.commit() self._session.commit()
def purge_files(self, doc: Document) -> None:
"""物理删除文档的原始文件与 Markdown 文件(彻底删除时调用)。"""
from app.storage.local_storage import get_storage
storage = get_storage()
for key in (doc.storage_path, doc.markdown_path):
if not key:
continue
try:
storage.delete(key)
except Exception as exc: # noqa: BLE001
import logging
logging.getLogger(__name__).warning("清理文件失败 key=%s: %s", key, exc)
def _check_quota(self, user: User, file_size: int) -> None: def _check_quota(self, user: User, file_size: int) -> None:
settings = get_settings() settings = get_settings()
if user.storage_used + file_size > settings.default_storage_quota: if user.storage_used + file_size > settings.default_storage_quota:
+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.core.security import hash_token
from app.models.document import Document from app.models.document import Document
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
@@ -14,6 +16,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
class KbPublicService: class KbPublicService:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
@@ -22,18 +26,43 @@ class KbPublicService:
self._doc_repo = DocumentRepository(session) self._doc_repo = DocumentRepository(session)
def get_kb_by_token(self, token: str) -> KnowledgeBase: def get_kb_by_token(self, token: str) -> KnowledgeBase:
"""通过 token 获取知识库。不存在/禁用/删除 → 404。""" """通过 token 获取知识库。
不存在/禁用/删除 → 404;存在但已过期 → 410LinkExpiredError)。
"""
token_hash = hash_token(token) token_hash = hash_token(token)
kb = self._kb_repo.get_by_token_hash(token_hash) kb = self._kb_repo.get_by_token_hash(token_hash)
if kb is None or not kb.enabled or kb.status == "DELETED": if kb is None or not kb.enabled or kb.status == "DELETED":
raise NotFoundError("知识库不存在。") raise NotFoundError("知识库不存在。")
if kb.token_expires_at:
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
expires = None
if expires is not None and datetime.now() > expires:
raise LinkExpiredError()
return kb return kb
@staticmethod
def is_expired(kb: KnowledgeBase) -> bool:
"""判断知识库链接是否已过期(管理端展示用)。"""
if not kb.token_expires_at:
return False
try:
expires = datetime.strptime(kb.token_expires_at, _TIME_FMT)
except ValueError:
return False
return datetime.now() > expires
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]: def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。""" """获取目录树(含文档数量),排除已删除目录"""
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb.id) .where(
DocumentCategory.knowledge_base_id == kb.id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.sort_order, DocumentCategory.name) .order_by(DocumentCategory.sort_order, DocumentCategory.name)
) )
all_cats = list(self._session.scalars(stmt).all()) all_cats = list(self._session.scalars(stmt).all())
+28 -2
View File
@@ -1,6 +1,9 @@
"""知识库服务:CRUD + Token 管理 + 默认目录树。""" """知识库服务:CRUD + Token 管理 + 默认目录树。"""
from datetime import datetime, timedelta
from app.core.errors import NotFoundError, PermissionDeniedError from app.core.errors import NotFoundError, PermissionDeniedError
from app.core.logging import get_logger
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base import KnowledgeBase
from app.models.user import User from app.models.user import User
@@ -8,6 +11,8 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
from app.services.token_service import TokenService from app.services.token_service import TokenService
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = get_logger(__name__)
class KnowledgeBaseService: class KnowledgeBaseService:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
@@ -30,6 +35,10 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted, token_encrypted=token_encrypted,
token_hint=token_hint, token_hint=token_hint,
) )
# AI 链接默认 30 分钟有效期
from datetime import datetime, timedelta
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
self._seed_default_categories(kb.id) self._seed_default_categories(kb.id)
self._session.commit() self._session.commit()
return kb, token return kb, token
@@ -37,7 +46,7 @@ class KnowledgeBaseService:
def _seed_default_categories(self, kb_id: str) -> None: def _seed_default_categories(self, kb_id: str) -> None:
"""创建默认目录树结构。""" """创建默认目录树结构。"""
default_tree = [ default_tree = [
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]), ("01 公司层", ["公司基本信息", "经营理念", "四大价值"]),
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]), ("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
("03 部门层", ["企划", "技术", "交付", "市场"]), ("03 部门层", ["企划", "技术", "交付", "市场"]),
("04 岗位/AI角色", []), ("04 岗位/AI角色", []),
@@ -94,12 +103,14 @@ class KnowledgeBaseService:
return kb return kb
def delete(self, kb_id: str, user: User) -> None: def delete(self, kb_id: str, user: User) -> None:
"""删除知识库 → 进回收站(3 天后自动彻底删除)。"""
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
self._kb_repo.delete(kb) self._kb_repo.delete(kb)
kb.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._session.commit() self._session.commit()
def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]: def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]:
"""重新生成 Token。旧链接立即失效。""" """重新生成 Token。旧链接立即失效,新链接默认 30 分钟有效期"""
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair() token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
self._kb_repo.update( self._kb_repo.update(
@@ -108,6 +119,7 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted, token_encrypted=token_encrypted,
token_hint=token_hint, token_hint=token_hint,
) )
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
self._session.commit() self._session.commit()
return kb, token return kb, token
@@ -117,6 +129,20 @@ class KnowledgeBaseService:
self._session.commit() self._session.commit()
return kb return kb
def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase:
"""设置链接有效期。None = 长期有效;负数表示已过期(测试用)。"""
from datetime import timedelta
kb = self.get_or_404(kb_id, user)
if expires_in_minutes is None:
kb.token_expires_at = None
else:
kb.token_expires_at = (datetime.now() + timedelta(minutes=expires_in_minutes)).strftime(
"%Y-%m-%d %H:%M:%S"
)
self._session.commit()
return kb
def get_full_token(self, kb: KnowledgeBase) -> str | None: def get_full_token(self, kb: KnowledgeBase) -> str | None:
"""解密 token 原文(供后台显示完整链接)。""" """解密 token 原文(供后台显示完整链接)。"""
if kb.token_encrypted: if kb.token_encrypted:
+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 from app.core import session as session_module
session_module._store.clear() session_module._store.clear()
# 重置存储服务单例(否则上一个测试的 tmp_path 被复用)
import app.storage.local_storage as ls
monkeypatch.setattr(ls, "_instance", None)
yield yield
engine.dispose() engine.dispose()
+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 #!/bin/bash
# ============================================================ # ============================================================
# AI Knowledge Link — 一键部署脚本 # AI Knowledge Link — 首次部署脚本
# 在服务器项目目录执行:bash deploy.sh # Docker 跑 backend + frontend,服务器本机 MySQL,宝塔 Nginx 反代
# ============================================================ # ============================================================
set -e set -e
@@ -10,48 +10,43 @@ echo "=== AI Knowledge Link 部署 ==="
# 检查 Docker # 检查 Docker
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
echo "错误:未安装 Docker。请先安装:" echo "错误:未安装 Docker"
echo " curl -fsSL https://get.docker.com | sh"
exit 1 exit 1
fi fi
if ! docker compose version &> /dev/null; then # 1. 创建 .env
echo "错误:docker compose 不可用。请检查 Docker 版本。"
exit 1
fi
# 1. 检查 .env
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "创建 .env ..." echo "创建 .env ..."
# 自动生成 SECRET_KEY
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))" 2>/dev/null || openssl rand -base64 48) SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))" 2>/dev/null || openssl rand -base64 48)
cat > .env << EOF cat > .env << EOF
SECRET_KEY=${SECRET} SECRET_KEY=${SECRET}
MYSQL_ROOT_PASSWORD=Lzcc6-01
MYSQL_PASSWORD=Lzcc6-01 MYSQL_PASSWORD=Lzcc6-01
EOF EOF
echo ".env 已创建SECRET_KEY 已自动生成" echo ".env 已创建"
fi fi
# 2. 创建数据目录 # 2. 数据目录
mkdir -p data mkdir -p data
# 3. 构建并启动 # 3. 确保数据库存在(本机 MySQL 33306
echo "检查数据库..."
mysql -h 127.0.0.1 -P 33306 -u admin -pLzcc6-01 \
-e "CREATE DATABASE IF NOT EXISTS amb_rag CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \
2>/dev/null || echo "提示:无法连接本机 MySQL,请确认已启动(不影响后续,backend 启动时会再试)"
# 4. 构建并启动
echo "构建镜像..." echo "构建镜像..."
docker compose build docker compose build
echo "启动服务..." echo "启动服务..."
docker compose up -d docker compose up -d
# 4. 等待 MySQL 就绪
echo "等待 MySQL 就绪..."
sleep 10
# 5. 数据库迁移 # 5. 数据库迁移
echo "执行数据库迁移..." echo "执行数据库迁移..."
sleep 3
docker compose exec -T backend alembic upgrade head docker compose exec -T backend alembic upgrade head
# 6. 创建内部管理员账号(如果不存在)
# 6. 管理员账号
echo "检查管理员账号..." echo "检查管理员账号..."
docker compose exec -T backend python -c " docker compose exec -T backend python -c "
from app.core.db import get_session_factory from app.core.db import get_session_factory
@@ -70,29 +65,26 @@ with factory() as session:
session.add(plan) session.add(plan)
session.flush() session.flush()
admin = User( admin = User(
username='admin', username='admin', email='admin@company.com',
email='admin@company.com',
password_hash=hash_password('Lzcc6-01'), password_hash=hash_password('Lzcc6-01'),
status='active', status='active', role='internal', plan_id=plan.id,
role='internal',
plan_id=plan.id,
) )
session.add(admin) session.add(admin)
session.commit() session.commit()
print('管理员账号已创建: admin / Lzcc6-01') print('管理员已创建: admin / Lzcc6-01')
else: else:
print('管理员账号已存在,跳过') print('管理员已存在')
" "
# 7. 完成
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 部署完成!" echo " 容器部署完成!"
echo "" echo ""
echo " 访问地址: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')" echo " 后端: http://127.0.0.1:8000/api/healthz"
echo " 内部登录: http://$(curl -s ifconfig.me 2>/dev/null || echo '你的服务器IP')/internal-login" echo " 前端: http://127.0.0.1:5173"
echo "" echo ""
echo " 管理员: admin" echo " 下一步:宝塔 Nginx 反代(站点设置 → 反向代理):"
echo " 密码: Lzcc6-01" echo " /api/ → http://127.0.0.1:8000"
echo "" echo " /k/ → http://127.0.0.1:8000"
echo " 更新部署: bash update.sh" echo " / → http://127.0.0.1:5173"
echo "==========================================" echo "=========================================="
+14 -53
View File
@@ -1,45 +1,24 @@
# AI Knowledge Link — Docker Compose (生产) # AI Knowledge Link — Docker Compose
# 只跑 backend + frontend 两个容器,Nginx 用服务器宝塔的(反代到这两个容器)
# #
# 用法: # 部署:bash deploy.sh
# 1. 把代码上传到服务器 # 更新:bash update.sh
# 2. cp .env.example .env && 修改 SECRET_KEY
# 3. docker compose up -d --build
# 4. docker compose exec backend alembic upgrade head (首次)
# 5. 访问 http://服务器IP
#
# 更新部署:
# git pull && docker compose up -d --build
services: services:
# ---------- MySQL ----------
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-Lzcc6-01}
MYSQL_DATABASE: amb_rag
MYSQL_USER: admin
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-Lzcc6-01}
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
ports:
- "33306:3306" # 宿主机 33306 → 容器内 3306
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
# ---------- 后端 ---------- # ---------- 后端 ----------
backend: backend:
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
ENVIRONMENT: production ENVIRONMENT: production
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key} SECRET_KEY: ${SECRET_KEY:-change-me-to-a-real-secret-key}
DATABASE_URL: mysql+pymysql://amb:${MYSQL_PASSWORD:-Lzcc6-01}@mysql:3306/amb_rag?charset=utf8mb4 # 容器内用 3306,外部访问用 33306 # 站点未配置 HTTPS 时必须为 false,否则浏览器丢弃 Cookie(登录后被踢回登录页)
COOKIE_SECURE: "false"
TZ: Asia/Shanghai
DATABASE_URL: mysql+pymysql://admin:${MYSQL_PASSWORD:-Lzcc6-01}@host.docker.internal:33306/amb_rag?charset=utf8mb4
STORAGE_ROOT: /app/data STORAGE_ROOT: /app/data
DEFAULT_STORAGE_QUOTA: "104857600" DEFAULT_STORAGE_QUOTA: "104857600"
DEFAULT_MAX_FILE_SIZE: "20971520" DEFAULT_MAX_FILE_SIZE: "20971520"
@@ -48,39 +27,21 @@ services:
FRONTEND_ORIGIN: http://localhost FRONTEND_ORIGIN: http://localhost
volumes: volumes:
- app_data:/app/data - app_data:/app/data
expose: ports:
- "8000" - "8000:8000"
depends_on:
mysql:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ---------- 前端 ---------- # ---------- 前端(内部 nginx 已配置 SPA 路由回退)----------
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
restart: unless-stopped restart: unless-stopped
expose:
- "80"
# ---------- Nginx ----------
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports: ports:
- "80:80" - "5173:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
volumes: volumes:
mysql_data:
app_data: app_data:
+3 -3
View File
@@ -4,15 +4,15 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm config set registry https://registry.npmmirror.com && npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: serve with nginx # Stage 2: serve with nginx(含 SPA 路由回退配置)
FROM nginx:1.27-alpine AS serve FROM nginx:1.27-alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
# 自定义 nginx 配置由外层 docker-compose 挂载,这里只 serve 静态产物 COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+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; const { status, data } = error.response;
// 未登录 → 跳转登录页 // 未登录 → 跳转登录页
if (status === 401) { if (status === 401) {
// 避免在登录页循环跳转 // 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
if (window.location.pathname !== '/login') { const path = window.location.pathname;
const isLoginPage = path === '/login' || path === '/internal-login';
if (!isLoginPage) {
window.location.href = '/login'; window.location.href = '/login';
} }
return Promise.reject(error); return Promise.reject(error);
+4 -2
View File
@@ -19,8 +19,10 @@ apiClient.interceptors.response.use(
// 未登录 → 跳转登录页 // 未登录 → 跳转登录页
if (status === 401) { if (status === 401) {
// 避免在登录页循环跳转 // 登录页本身(普通/内部)的 401 是"密码错误",留在原地显示提示
if (window.location.pathname !== '/login') { const path = window.location.pathname
const isLoginPage = path === '/login' || path === '/internal-login'
if (!isLoginPage) {
window.location.href = '/login' window.location.href = '/login'
} }
return Promise.reject(error) return Promise.reject(error)
+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> <el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
<template #title>设置</template> <template #title>设置</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/recycle-bin">
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></el-icon>
<template #title>回收站</template>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
@@ -89,6 +93,7 @@ function navigateTo(path: string) {
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')"> 设置</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')"> 设置</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/recycle-bin')">🗑 回收站</div>
</div> </div>
</div> </div>
+5
View File
@@ -37,6 +37,11 @@ const router = createRouter({
name: 'KbDetail', name: 'KbDetail',
component: () => import('@/views/KbDetail.vue'), component: () => import('@/views/KbDetail.vue'),
}, },
{
path: 'recycle-bin',
name: 'RecycleBin',
component: () => import('@/views/RecycleBin.vue'),
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',
+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) { export async function copyToClipboard(text) {
// 方法1: 现代 Clipboard APIChrome/Firefox/Edge 桌面端 // 方法1: 现代 Clipboard API需 HTTPS 或 localhost
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} }
catch { catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback /* 权限被拒或非安全上下文,继续降级 */
} }
} }
// 方法2: 传统 execCommandiOS Safari 兼容方案 // 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try { try {
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = text; textarea.value = text;
// 防止页面滚动 textarea.setAttribute('readonly', '');
// 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed'; textarea.style.position = 'fixed';
textarea.style.left = '-9999px'; textarea.style.left = '-9999px';
textarea.style.top = '-9999px'; textarea.style.top = '-9999px';
textarea.style.opacity = '0'; textarea.style.opacity = '0';
document.body.appendChild(textarea); document.body.appendChild(textarea);
// iOS Safari 需要设置 selection range // iOS Safari 必须手动设置选区
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
textarea.setSelectionRange(0, textarea.value.length); textarea.setSelectionRange(0, textarea.value.length);
const success = document.execCommand('copy'); let success = false;
try {
success = document.execCommand('copy');
}
catch {
success = false;
}
document.body.removeChild(textarea); document.body.removeChild(textarea);
return success; if (success)
return true;
} }
catch { catch {
return false; /* 继续降级 */
} }
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = window.clipboardData;
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text);
}
catch {
return false;
}
}
return false;
} }
+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> { 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') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
return true return true
} catch { } catch {
// Safari 可能抛出 NotAllowedError,继续尝试 fallback /* 权限被拒或非安全上下文,继续降级 */
} }
} }
// 方法2: 传统 execCommandiOS Safari 兼容方案 // 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try { try {
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = text textarea.value = text
textarea.setAttribute('readonly', '')
// 防止页面滚动 // 不可见但可选中;不设 display:noneSafari 会取消选中)
textarea.style.position = 'fixed' textarea.style.position = 'fixed'
textarea.style.left = '-9999px' textarea.style.left = '-9999px'
textarea.style.top = '-9999px' textarea.style.top = '-9999px'
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.appendChild(textarea) document.body.appendChild(textarea)
// iOS Safari 需要设置 selection range // iOS Safari 必须手动设置选区
textarea.focus() textarea.focus()
textarea.select() textarea.select()
textarea.setSelectionRange(0, textarea.value.length) textarea.setSelectionRange(0, textarea.value.length)
const success = document.execCommand('copy') let success = false
try {
success = document.execCommand('copy')
} catch {
success = false
}
document.body.removeChild(textarea) document.body.removeChild(textarea)
return success if (success) return true
} catch { } catch {
return false /* 继续降级 */
} }
// 方法3: IE 专有 API(极老浏览器兜底)
const ieClipboard = (window as unknown as { clipboardData?: { setData: (t: string, v: string) => boolean } }).clipboardData
if (ieClipboard) {
try {
return ieClipboard.setData('Text', text)
} catch {
return false
}
}
return false
} }
+88 -9
View File
@@ -82,10 +82,55 @@ async function loadDocs() {
loading.value = false loading.value = false
} }
// 链接有效期
const expiryOptions = [
{ label: '10 分钟', value: 10 },
{ label: '30 分钟', value: 30 },
{ label: '1 小时', value: 60 },
{ label: '3 小时', value: 180 },
{ label: '24 小时', value: 1440 },
{ label: '长期有效', value: 0 },
]
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
expires_at: null,
is_expired: false,
})
const expiryText = computed(() => {
if (linkExpiry.value.is_expired) return '⛔ 已失效'
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
})
async function loadLink() { async function loadLink() {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`) const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
aiUrl.value = `${window.location.origin}/k/${data.token}` aiUrl.value = `${window.location.origin}/k/${data.token}`
linkExpiry.value = {
expires_at: data.expires_at,
is_expired: data.is_expired,
}
} catch { /* ignore */ }
}
// 设置链接有效期(0 = 长期有效,需二次确认)
async function handleExpiryChange(val: number) {
if (val === 0) {
try {
await ElMessageBox.confirm(
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
'⚠️ 泄露风险提示',
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
}
try {
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
expires_in_minutes: val === 0 ? null : val,
})
ElMessage.success('链接有效期已更新。')
loadLink()
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
uploading.value = false uploading.value = false
} }
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
function openTextDialog() {
textForm.value.category_id = selectedCategoryId.value
showTextDialog.value = true
}
// 创建文本内容 // 创建文本内容
async function handleCreateText() { async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) { if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
@@ -134,7 +185,7 @@ async function handleCreateText() {
title: textForm.value.title, title: textForm.value.title,
content: textForm.value.content, content: textForm.value.content,
content_format: textForm.value.content_format, content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id, category_id: textForm.value.category_id,
}) })
ElMessage.success('文本内容已创建!') ElMessage.success('文本内容已创建!')
showTextDialog.value = false showTextDialog.value = false
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId) const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
return cat ? cat.name : '未分类' return cat ? cat.name : '未分类'
} }
// AI 链接打码:显示域名,token 部分用 • 代替
const maskedAiUrl = computed(() => {
if (!aiUrl.value) return '尚未生成'
const idx = aiUrl.value.indexOf('/k/')
if (idx === -1) return '••••••••'
const prefix = aiUrl.value.slice(0, idx + 3)
const tokenLen = aiUrl.value.length - idx - 3
return prefix + '•'.repeat(tokenLen)
})
</script> </script>
<template> <template>
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
</div> </div>
</div> </div>
<!-- AI 链接 --> <!-- AI 链接打码显示只能复制获取 -->
<el-card style="margin-bottom: 16px" shadow="hover"> <el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap"> <div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span> <span style="font-weight: bold; font-size: 14px">AI 链接</span>
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large"> <span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
<template #append> {{ maskedAiUrl }}
<el-button @click="handleCopyLink">复制</el-button> </span>
</template> <el-select
</el-input> :model-value="null"
placeholder="⏱️ 设置有效期"
style="width: 150px"
@change="(val: number) => handleExpiryChange(val)"
>
<el-option
v-for="opt in expiryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
</div>
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
{{ expiryText }}
</span>
<span style="color: #999">🔒 链接即访问凭证已隐藏显示仅可通过复制按钮获取请勿公开传播</span>
</div> </div>
</el-card> </el-card>
<!-- 手机端目录按钮 + 操作按钮 --> <!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions"> <div class="mobile-actions">
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button> <el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
<el-button @click="showTextDialog = true" style="flex: 1"> 添加文本</el-button> <el-button @click="openTextDialog" style="flex: 1"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1"> <el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button> <el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload> </el-upload>
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
</el-tag> </el-tag>
</span> </span>
<div class="desktop-actions"> <div class="desktop-actions">
<el-button @click="showTextDialog = true"> 添加文本</el-button> <el-button @click="openTextDialog"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf"> <el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">📤 上传文档</el-button> <el-button type="primary" :loading="uploading">📤 上传文档</el-button>
</el-upload> </el-upload>
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 { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client' import apiClient from '@/api/client'
import { copyToClipboard } from '@/utils/clipboard'
const router = useRouter() const router = useRouter()
const kbs = ref<any[]>([]) const kbs = ref<any[]>([])
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`) const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
const url = `${window.location.origin}${data.ai_url}` const url = `${window.location.origin}${data.ai_url}`
await navigator.clipboard.writeText(url) const success = await copyToClipboard(url)
ElMessage.success('AI 链接已复制到剪贴板!') if (success) {
ElMessage.success('AI 链接已复制到剪贴板!')
} else {
ElMessage.error('复制失败,请稍后重试。')
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
</script> </script>
+8 -2
View File
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client'; import apiClient from '@/api/client';
import { copyToClipboard } from '@/utils/clipboard';
const router = useRouter(); const router = useRouter();
const kbs = ref([]); const kbs = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -55,8 +56,13 @@ async function handleCopyLink(kb) {
try { try {
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`); const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`);
const url = `${window.location.origin}${data.ai_url}`; const url = `${window.location.origin}${data.ai_url}`;
await navigator.clipboard.writeText(url); const success = await copyToClipboard(url);
ElMessage.success('AI 链接已复制到剪贴板!'); if (success) {
ElMessage.success('AI 链接已复制到剪贴板!');
}
else {
ElMessage.error('复制失败,请稍后重试。');
}
} }
catch { /* ignore */ } catch { /* ignore */ }
} }
+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 #!/bin/bash
# ============================================================ # ============================================================
# AI Knowledge Link — 一键更新脚本 # AI Knowledge Link — 一键更新脚本
# 在服务器项目目录执行:bash update.sh # 用法: bash update.sh
#
# 功能:
# 1. 拉取最新代码
# 2. 重新构建镜像
# 3. 重启服务(数据库不丢失)
# 4. 执行数据库迁移(如有新迁移)
# ============================================================ # ============================================================
set -e set -e
echo "=== AI Knowledge Link 更新 ===" echo "=== AI Knowledge Link 更新 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
# 1. 拉取最新代码 # 1. 拉取最新代码
echo "[1/4] 拉取最新代码..." echo "[1/4] 拉取最新代码..."
git pull if ! git pull; then
echo ""
echo "错误:git pull 失败。通常是服务器上有本地修改与远程冲突。"
echo "如果确认要放弃服务器上的修改,执行:"
echo " git checkout -- . && git pull"
exit 1
fi
# 2. 重新构建 # 2. 重新构建
echo ""
echo "[2/4] 重新构建镜像..." echo "[2/4] 重新构建镜像..."
docker compose build docker compose build
# 3. 重启服务 # 3. 重启服务
echo ""
echo "[3/4] 重启服务..." echo "[3/4] 重启服务..."
docker compose up -d docker compose up -d
# 4. 数据库迁移 # 4. 等待后端就绪后执行迁移(兜底,容器启动时也会自动迁移)
echo "[4/4] 执行数据库迁移..."
sleep 5
docker compose exec -T backend alembic upgrade head
# 5. 完成
echo "" echo ""
echo "更新完成!$(date '+%H:%M:%S')" echo "[4/4] 等待后端就绪并执行迁移..."
echo "服务状态:" for i in $(seq 1 30); do
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" if docker compose exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')" 2>/dev/null; then
echo "后端已就绪(第 ${i} 次探测)"
break
fi
if [ "$i" -eq 30 ]; then
echo "警告:后端 30 秒内未就绪,跳过迁移步骤(容器启动时会自动迁移)"
fi
sleep 1
done
docker compose exec -T backend alembic upgrade head 2>/dev/null || true
# 5. 结果
echo ""
echo "=========================================="
echo " 更新完成!$(date '+%H:%M:%S')"
echo "=========================================="
echo ""
docker compose ps
echo ""
# 健康检查
if curl -s -f http://127.0.0.1:8000/api/healthz > /dev/null 2>&1; then
echo "✅ 后端健康检查通过"
else
echo "❌ 后端健康检查失败,查看日志:docker compose logs backend --tail 50"
fi