Compare commits
4 Commits
92b062c048
...
6ae91679e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae91679e2 | |||
| 6c6f690788 | |||
| f92eff6f65 | |||
| e2e8e6829d |
+10
-3
@@ -7,12 +7,14 @@ APP_PORT=8000
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# --- 嵌入模型 ---
|
# --- 嵌入模型 ---
|
||||||
# openai | local
|
# openai | local。默认走本地 Ollama 的 BGE-M3(多语言榜首、dense+sparse 一体,零 API 成本)
|
||||||
EMBEDDING_PROVIDER=openai
|
EMBEDDING_PROVIDER=local
|
||||||
|
# 以下为 EMBEDDING_PROVIDER=openai 时启用的 OpenAI 兼容配置(本地模式可留空)
|
||||||
OPENAI_API_KEY=sk-xxx
|
OPENAI_API_KEY=sk-xxx
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
EMBEDDING_MODEL=text-embedding-3-small
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
EMBEDDING_DIMENSION=1536
|
# 维度需与所选嵌入模型一致:BGE-M3 为 1024;切换 OpenAI 时改回 1536
|
||||||
|
EMBEDDING_DIMENSION=1024
|
||||||
|
|
||||||
# --- Qdrant ---
|
# --- Qdrant ---
|
||||||
QDRANT_PORT=6333
|
QDRANT_PORT=6333
|
||||||
@@ -28,6 +30,11 @@ OLLAMA_MODEL=qwen2.5:1.5b
|
|||||||
# EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型
|
# EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型
|
||||||
OLLAMA_EMBEDDING_MODEL=bge-m3
|
OLLAMA_EMBEDDING_MODEL=bge-m3
|
||||||
|
|
||||||
|
# --- 重排模型(cross-encoder,经 Ollama /api/rerank 对 chunk 候选精排)---
|
||||||
|
# 启用后显著提升最终 Top-K 相关性;需 Ollama 已拉取 RERANKER_MODEL(见 docker-compose)
|
||||||
|
RERANKER_ENABLED=true
|
||||||
|
RERANKER_MODEL=qwen3-reranker:0.6b
|
||||||
|
|
||||||
# --- NAS 持久化 ---
|
# --- NAS 持久化 ---
|
||||||
NAS_DATA_DIR=./data
|
NAS_DATA_DIR=./data
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,7 @@ logs/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
|
# Project memory & Trae IDE spec (切勿提交)
|
||||||
|
.workbuddy/
|
||||||
|
.trae/
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ QMDSearch/
|
|||||||
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 |
|
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 |
|
||||||
| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 |
|
| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 |
|
||||||
| GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 |
|
| GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 |
|
||||||
|
| GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 |
|
||||||
| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer |
|
| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer |
|
||||||
| POST | `/api/v1/auth/login` | 用户名密码登录,签发 session token(TTL 12h) | 免登录 |
|
| POST | `/api/v1/auth/login` | 用户名密码登录,签发 session token(TTL 12h) | 免登录 |
|
||||||
| POST | `/api/v1/auth/logout` | 退出登录(删除当前 session) | Bearer |
|
| POST | `/api/v1/auth/logout` | 退出登录(删除当前 session) | Bearer |
|
||||||
@@ -102,6 +103,7 @@ QMDSearch/
|
|||||||
| GET | `/api/v1/auth/me` | 当前登录用户信息(脱敏) | Bearer |
|
| GET | `/api/v1/auth/me` | 当前登录用户信息(脱敏) | Bearer |
|
||||||
| GET | `/api/v1/auth/users` | 用户列表(脱敏) | Bearer + admin |
|
| GET | `/api/v1/auth/users` | 用户列表(脱敏) | Bearer + admin |
|
||||||
| POST | `/api/v1/auth/users` | 创建用户(重名/非法用户名/弱密码 1001) | Bearer + admin |
|
| POST | `/api/v1/auth/users` | 创建用户(重名/非法用户名/弱密码 1001) | Bearer + admin |
|
||||||
|
| PATCH | `/api/v1/auth/users/{username}` | 更新用户角色/启用状态(仅 admin) | Bearer + admin |
|
||||||
| POST | `/api/v1/auth/users/{username}/password` | 重置指定用户密码(成功后清除其全部 session) | Bearer + admin |
|
| POST | `/api/v1/auth/users/{username}/password` | 重置指定用户密码(成功后清除其全部 session) | Bearer + admin |
|
||||||
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin) | Bearer + admin |
|
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin) | Bearer + admin |
|
||||||
| GET | `/admin` | 管理页面 | 页面登录门禁 |
|
| GET | `/admin` | 管理页面 | 页面登录门禁 |
|
||||||
@@ -112,16 +114,17 @@ QMDSearch/
|
|||||||
|
|
||||||
## 管理页面
|
## 管理页面
|
||||||
|
|
||||||
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含七个区块:概览、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含创建/重置密码/删除)。
|
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含八个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。
|
||||||
|
|
||||||
## 认证与用户
|
## 认证与用户
|
||||||
|
|
||||||
会话制认证:登录签发 session token(Redis 持久化,TTL 12h;Redis 不可用时降级为进程内存,重启失效),请求经 `Authorization: Bearer <token>` 携带,鉴权依赖见 `app/api/deps.py`。
|
会话制认证:登录签发 session token(Redis 持久化,TTL 12h;Redis 不可用时降级为进程内存,重启失效),请求经 `Authorization: Bearer <token>` 携带,鉴权依赖见 `app/api/deps.py`。
|
||||||
|
|
||||||
- **角色与权限边界**: `admin` 拥有全部权限(含 /auth/users* 用户管理);`user` 可登录并调用变更类文档端点(入库/上传/删除),访问用户管理端点返回 1006
|
- **角色与权限边界**: `admin` 拥有全部权限(含 /auth/users* 用户管理);`user` 可登录并调用变更类文档端点(入库/上传/删除),访问用户管理端点返回 1006
|
||||||
|
- **启用状态与 PATCH 端点**: 用户记录含 `enabled` 字段(默认 true);admin 可经 `PATCH /auth/users/{username}` 更新角色或启用状态(请求体至少一项字段,空 body 1001;role 非法 1001;用户不存在 1004)。禁用用户时清除其全部 session(旧 token 立即失效 1005),被禁用用户登录拒绝 1005("账号已禁用");最后一个 admin 禁止降级 role 或被禁用(1001)。角色变更不清 session,同一 token 即时反映新角色
|
||||||
- **初始 admin 引导**: 空库启动时 `bootstrap_admin` 自动创建 admin 账号,随机明文密码仅在启动日志中打印一次(must_change_password=true),首次登录后须先经 POST /auth/password 改密,改密前访问其他端点返回 1006
|
- **初始 admin 引导**: 空库启动时 `bootstrap_admin` 自动创建 admin 账号,随机明文密码仅在启动日志中打印一次(must_change_password=true),首次登录后须先经 POST /auth/password 改密,改密前访问其他端点返回 1006
|
||||||
- **免登录端点**: 查询类端点(POST /search、GET /documents*、GET /knowledge/*、GET /health)不需要 token
|
- **免登录端点**: 查询类端点(POST /search、GET /documents*、GET /knowledge/*、GET /health)不需要 token
|
||||||
- **相关错误码**: 1005 未认证或凭证无效,1006 权限不足/首次登录须先改密
|
- **相关错误码**: 1005 未认证或凭证无效/账号已禁用,1006 权限不足/首次登录须先改密
|
||||||
|
|
||||||
## 编码规范
|
## 编码规范
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -80,9 +80,12 @@ async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
|
|||||||
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
|
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
|
||||||
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
|
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
|
||||||
|
|
||||||
must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
|
禁用用户被拦截(1005);must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
|
||||||
|
注:logout/password 端点经 _resolve_user 自解析,不在此拦截范围内(改密是已登录用户唯一可用接口)。
|
||||||
"""
|
"""
|
||||||
user, _ = await _resolve_user(authorization)
|
user, _ = await _resolve_user(authorization)
|
||||||
|
if not user.enabled:
|
||||||
|
raise ApiError(1005, "账号已禁用")
|
||||||
if user.must_change_password:
|
if user.must_change_password:
|
||||||
raise ApiError(1006, "首次登录须先修改密码")
|
raise ApiError(1006, "首次登录须先修改密码")
|
||||||
return user
|
return user
|
||||||
|
|||||||
+58
-2
@@ -10,12 +10,12 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.api.response import ApiError, ok
|
from app.api.response import ApiError, ok
|
||||||
from app.core.sessions import SessionStoreError
|
from app.core.sessions import SessionStoreError
|
||||||
from app.core.users import UserExistsError, UserRecord, UserStoreError
|
from app.core.users import UserExistsError, UserNotFoundError, UserRecord, UserStoreError
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -50,12 +50,26 @@ class PasswordResetRequest(BaseModel):
|
|||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
"""管理员更新用户角色/启用状态(至少一项)"""
|
||||||
|
|
||||||
|
role: Literal["admin", "user"] | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _at_least_one(self) -> "UserUpdateRequest":
|
||||||
|
if self.role is None and self.enabled is None:
|
||||||
|
raise ValueError("至少需要提供一项更新字段(role 或 enabled)")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
def _public_user(user: UserRecord) -> dict[str, Any]:
|
def _public_user(user: UserRecord) -> dict[str, Any]:
|
||||||
"""用户记录脱敏:剔除 password_hash/salt"""
|
"""用户记录脱敏:剔除 password_hash/salt"""
|
||||||
return {
|
return {
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"must_change_password": user.must_change_password,
|
"must_change_password": user.must_change_password,
|
||||||
|
"enabled": user.enabled,
|
||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +83,8 @@ async def login(req: LoginRequest) -> dict[str, Any]:
|
|||||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ApiError(1005, "用户名或密码错误")
|
raise ApiError(1005, "用户名或密码错误")
|
||||||
|
if not user.enabled:
|
||||||
|
raise ApiError(1005, "账号已禁用")
|
||||||
try:
|
try:
|
||||||
token = await deps._get_session_store().create(user.username, user.role)
|
token = await deps._get_session_store().create(user.username, user.role)
|
||||||
except SessionStoreError as exc:
|
except SessionStoreError as exc:
|
||||||
@@ -151,6 +167,46 @@ async def create_user(
|
|||||||
return ok(_public_user(user))
|
return ok(_public_user(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{username}")
|
||||||
|
async def update_user(
|
||||||
|
username: str,
|
||||||
|
req: UserUpdateRequest,
|
||||||
|
admin: UserRecord = Depends(deps.require_admin),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""更新用户角色或启用状态
|
||||||
|
|
||||||
|
用户不存在 1004;role 非法 1001;最后一个 admin 降级 role 或被禁用 1001;
|
||||||
|
禁用用户时清除其全部 session(已登录态立即失效)。
|
||||||
|
"""
|
||||||
|
store = deps._get_user_store()
|
||||||
|
try:
|
||||||
|
target = await store.get(username)
|
||||||
|
if target is None:
|
||||||
|
raise ApiError(1004, "用户不存在")
|
||||||
|
# 最后 admin 保护:目标当前是唯一 admin 且本次会使其失去 admin 身份或被禁用
|
||||||
|
if target.role == "admin" and await store.count_admins() <= 1:
|
||||||
|
will_lose_admin = (req.role is not None and req.role != "admin") or (req.enabled is False)
|
||||||
|
if will_lose_admin:
|
||||||
|
raise ApiError(1001, "禁止降级或禁用最后一个管理员")
|
||||||
|
updated = await store.update_user(username, role=req.role, enabled=req.enabled)
|
||||||
|
except UserStoreError as exc:
|
||||||
|
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||||
|
except UserNotFoundError as exc:
|
||||||
|
raise ApiError(1004, "用户不存在") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ApiError(1001, str(exc)) from exc
|
||||||
|
# 禁用用户 → 清除其全部 session(已登录态立即失效)
|
||||||
|
if req.enabled is False:
|
||||||
|
try:
|
||||||
|
await deps._get_session_store().delete_by_username(username)
|
||||||
|
except SessionStoreError as exc:
|
||||||
|
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||||
|
logger.info(
|
||||||
|
"管理员更新用户", username=username, operator=admin.username, role=req.role, enabled=req.enabled
|
||||||
|
)
|
||||||
|
return ok(_public_user(updated))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{username}/password")
|
@router.post("/users/{username}/password")
|
||||||
async def reset_user_password(
|
async def reset_user_password(
|
||||||
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
|
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
|
||||||
|
|||||||
+28
-1
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.api.response import ApiError, ok
|
from app.api.response import ApiError, ok
|
||||||
@@ -219,6 +219,33 @@ async def get_document(doc_id: str) -> dict[str, Any]:
|
|||||||
return ok(detail)
|
return ok(detail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{doc_id}/file")
|
||||||
|
async def download_document_file(doc_id: str) -> FileResponse:
|
||||||
|
"""下载文档关联的原始文件(免登录)
|
||||||
|
|
||||||
|
从 L1 metadata 读取 raw_file_path,校验路径位于 upload_dir 之内后返回 FileResponse;
|
||||||
|
文档不存在 / 无关联文件 / 文件缺失 / 路径越界统一返回 1004。
|
||||||
|
"""
|
||||||
|
meta = await _get_qdrant().get_l1_metadata(doc_id)
|
||||||
|
if not meta:
|
||||||
|
raise ApiError(1004, "文档不存在或未关联文件")
|
||||||
|
raw_path = meta.get("raw_file_path", "")
|
||||||
|
if not raw_path:
|
||||||
|
raise ApiError(1004, "文档未关联文件")
|
||||||
|
path = Path(raw_path).resolve()
|
||||||
|
# 路径越界校验:只允许读取 upload_dir 下的文件
|
||||||
|
try:
|
||||||
|
upload_dir = Path(settings.upload_dir).resolve()
|
||||||
|
path.relative_to(upload_dir)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("文件路径越界", doc_id=doc_id, raw_path=raw_path)
|
||||||
|
raise ApiError(1004, "文件不存在") from None
|
||||||
|
if not path.is_file():
|
||||||
|
raise ApiError(1004, "文件不存在")
|
||||||
|
filename = meta.get("original_filename", path.name)
|
||||||
|
return FileResponse(path, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/documents/{doc_id}")
|
@router.delete("/documents/{doc_id}")
|
||||||
async def delete_document(
|
async def delete_document(
|
||||||
doc_id: str, user: UserRecord = Depends(get_current_user)
|
doc_id: str, user: UserRecord = Depends(get_current_user)
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class Settings(BaseSettings):
|
|||||||
ollama_model: str = "qwen2.5:1.5b" # 备选: qwen2.5:3b
|
ollama_model: str = "qwen2.5:1.5b" # 备选: qwen2.5:3b
|
||||||
ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型
|
ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型
|
||||||
|
|
||||||
|
# 重排模型(cross-encoder,经 Ollama /api/rerank,对 chunk 候选做最终精排)
|
||||||
|
# 关闭时检索链路退化为纯 RRF 融合结果,行为不变
|
||||||
|
reranker_enabled: bool = False
|
||||||
|
reranker_model: str = "qwen3-reranker:0.6b"
|
||||||
|
reranker_timeout: float = 30.0
|
||||||
|
|
||||||
# Qdrant
|
# Qdrant
|
||||||
qdrant_host: str = "localhost"
|
qdrant_host: str = "localhost"
|
||||||
qdrant_port: int = 6333
|
qdrant_port: int = 6333
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@
|
|||||||
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
|
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
@@ -67,7 +67,7 @@ def verify_password(password: str, hashed: str) -> bool:
|
|||||||
|
|
||||||
def create_access_token(username: str, role: str) -> tuple[str, int]:
|
def create_access_token(username: str, role: str) -> tuple[str, int]:
|
||||||
"""签发 JWT,返回 (token, expires_in_seconds)"""
|
"""签发 JWT,返回 (token, expires_in_seconds)"""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(timezone.utc)
|
||||||
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
|
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
@@ -136,7 +136,7 @@ class UserStore:
|
|||||||
user = StoredUser(
|
user = StoredUser(
|
||||||
username=username,
|
username=username,
|
||||||
role=role,
|
role=role,
|
||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(timezone.utc),
|
||||||
hashed_password=hash_password(password),
|
hashed_password=hash_password(password),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -203,7 +203,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
|
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
|
||||||
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
|
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
|
||||||
return AuthUser(username=username, role=role, created_at=datetime.now(UTC))
|
return AuthUser(username=username, role=role, created_at=datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
|
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ class Ingester:
|
|||||||
tags=category.tags,
|
tags=category.tags,
|
||||||
dense_vector=l1_vector,
|
dense_vector=l1_vector,
|
||||||
sparse_vector=l1_sparse,
|
sparse_vector=l1_sparse,
|
||||||
|
metadata=doc.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
# L2/L3 大纲节点(为空时跳过对应集合的 upsert)
|
# L2/L3 大纲节点(为空时跳过对应集合的 upsert)
|
||||||
|
|||||||
+26
-3
@@ -16,7 +16,7 @@ from qdrant_client import models
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.embeddings import EmbeddingService, create_embedding_service
|
from app.core.embeddings import EmbeddingService, create_embedding_service
|
||||||
from app.core.query_parser import QueryParser, RouteDecision
|
from app.core.query_parser import QueryParser, RouteDecision
|
||||||
from app.core.ranker import finalize, rrf_fuse
|
from app.core.ranker import rrf_fuse
|
||||||
from app.core.result_summarizer import ResultSummarizer
|
from app.core.result_summarizer import ResultSummarizer
|
||||||
from app.core.sparse import SparseEncoder
|
from app.core.sparse import SparseEncoder
|
||||||
from app.models.knowledge import load_taxonomy
|
from app.models.knowledge import load_taxonomy
|
||||||
@@ -30,6 +30,7 @@ from app.services.qdrant import (
|
|||||||
SPARSE_COLLECTIONS,
|
SPARSE_COLLECTIONS,
|
||||||
QdrantService,
|
QdrantService,
|
||||||
)
|
)
|
||||||
|
from app.services.reranker import RerankerService, create_reranker_service
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ class Retriever:
|
|||||||
embedding: EmbeddingService | None = None,
|
embedding: EmbeddingService | None = None,
|
||||||
sparse_encoder: SparseEncoder | None = None,
|
sparse_encoder: SparseEncoder | None = None,
|
||||||
result_summarizer: ResultSummarizer | None = None,
|
result_summarizer: ResultSummarizer | None = None,
|
||||||
|
reranker: RerankerService | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.qdrant = qdrant or QdrantService()
|
self.qdrant = qdrant or QdrantService()
|
||||||
self.query_parser = query_parser or QueryParser(
|
self.query_parser = query_parser or QueryParser(
|
||||||
@@ -58,6 +60,7 @@ class Retriever:
|
|||||||
self.embedding = embedding or create_embedding_service()
|
self.embedding = embedding or create_embedding_service()
|
||||||
self.sparse_encoder = sparse_encoder or SparseEncoder()
|
self.sparse_encoder = sparse_encoder or SparseEncoder()
|
||||||
self.result_summarizer = result_summarizer or ResultSummarizer()
|
self.result_summarizer = result_summarizer or ResultSummarizer()
|
||||||
|
self.reranker = reranker if reranker is not None else create_reranker_service()
|
||||||
|
|
||||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||||
"""分层检索主流程"""
|
"""分层检索主流程"""
|
||||||
@@ -77,7 +80,7 @@ class Retriever:
|
|||||||
# L1 无候选文档 → 全库 chunk 兜底
|
# L1 无候选文档 → 全库 chunk 兜底
|
||||||
chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None)
|
chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None)
|
||||||
logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits))
|
logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits))
|
||||||
hits = self._to_hits(finalize(chunk_hits, self._final_k(request)))
|
hits = self._to_hits(await self._rerank(query_text, chunk_hits, self._final_k(request)))
|
||||||
return await self._build_response(request, route, hits, fallback=True)
|
return await self._build_response(request, route, hits, fallback=True)
|
||||||
|
|
||||||
doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits)
|
doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits)
|
||||||
@@ -123,7 +126,7 @@ class Retriever:
|
|||||||
)
|
)
|
||||||
logger.info("chunk 检索完成", hits=len(chunk_hits))
|
logger.info("chunk 检索完成", hits=len(chunk_hits))
|
||||||
|
|
||||||
final_points = finalize(rrf_fuse([chunk_hits]), self._final_k(request))
|
final_points = await self._rerank(query_text, chunk_hits, self._final_k(request))
|
||||||
hits = self._to_hits(final_points)
|
hits = self._to_hits(final_points)
|
||||||
return await self._build_response(request, route, hits, fallback=route.fallback)
|
return await self._build_response(request, route, hits, fallback=route.fallback)
|
||||||
|
|
||||||
@@ -145,6 +148,26 @@ class Retriever:
|
|||||||
"""最终返回数:请求指定优先,否则用配置默认值"""
|
"""最终返回数:请求指定优先,否则用配置默认值"""
|
||||||
return request.top_k or settings.retrieval_final_k
|
return request.top_k or settings.retrieval_final_k
|
||||||
|
|
||||||
|
async def _rerank(
|
||||||
|
self, query_text: str, points: list[models.ScoredPoint], final_k: int
|
||||||
|
) -> list[models.ScoredPoint]:
|
||||||
|
"""对 chunk 候选池做语义精排(cross-encoder)
|
||||||
|
|
||||||
|
重排未启用、候选为空或调用异常时,退化为 RRF 融合的原始顺序(取前 final_k),
|
||||||
|
保证检索链路在任何情况下都不因重排失败而中断。
|
||||||
|
"""
|
||||||
|
if self.reranker is None or not points:
|
||||||
|
return points[:final_k]
|
||||||
|
documents = [(p.payload or {}).get("text", "") for p in points]
|
||||||
|
try:
|
||||||
|
scores = await self.reranker.rerank(query_text, documents)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("重排调用失败,退化为 RRF 融合顺序", exc_info=True)
|
||||||
|
return points[:final_k]
|
||||||
|
# 按相关性分数降序重排,写回 score 字段为相关性分数
|
||||||
|
ordered = sorted(range(len(points)), key=lambda i: scores[i], reverse=True)
|
||||||
|
return [points[i].model_copy(update={"score": scores[i]}) for i in ordered[:final_k]]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_hits(points: list[models.ScoredPoint]) -> list[SearchHit]:
|
def _to_hits(points: list[models.ScoredPoint]) -> list[SearchHit]:
|
||||||
"""chunk 点组装为 SearchHit,字段取自 chunk payload(doc_summary 仅上下文标注)"""
|
"""chunk 点组装为 SearchHit,字段取自 chunk payload(doc_summary 仅上下文标注)"""
|
||||||
|
|||||||
+44
-4
@@ -11,7 +11,7 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from redis import asyncio as redis_async
|
from redis import asyncio as redis_async
|
||||||
@@ -41,6 +41,10 @@ class UserStoreError(Exception):
|
|||||||
"""用户存储后端读写异常"""
|
"""用户存储后端读写异常"""
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotFoundError(Exception):
|
||||||
|
"""用户不存在"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UserRecord:
|
class UserRecord:
|
||||||
"""用户记录(不含明文密码)"""
|
"""用户记录(不含明文密码)"""
|
||||||
@@ -50,7 +54,9 @@ class UserRecord:
|
|||||||
password_hash: str # hex
|
password_hash: str # hex
|
||||||
salt: str # hex, 16 字节
|
salt: str # hex, 16 字节
|
||||||
must_change_password: bool
|
must_change_password: bool
|
||||||
created_at: str # UTC ISO8601
|
enabled: bool = True
|
||||||
|
# created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||||
|
|
||||||
|
|
||||||
class UserStore:
|
class UserStore:
|
||||||
@@ -76,6 +82,7 @@ class UserStore:
|
|||||||
password: str,
|
password: str,
|
||||||
role: str = "user",
|
role: str = "user",
|
||||||
must_change_password: bool = False,
|
must_change_password: bool = False,
|
||||||
|
enabled: bool = True,
|
||||||
) -> UserRecord:
|
) -> UserRecord:
|
||||||
"""创建用户
|
"""创建用户
|
||||||
|
|
||||||
@@ -94,6 +101,7 @@ class UserStore:
|
|||||||
password_hash=self.hash_password(password, salt),
|
password_hash=self.hash_password(password, salt),
|
||||||
salt=salt.hex(),
|
salt=salt.hex(),
|
||||||
must_change_password=must_change_password,
|
must_change_password=must_change_password,
|
||||||
|
enabled=enabled,
|
||||||
created_at=datetime.now(UTC).isoformat(),
|
created_at=datetime.now(UTC).isoformat(),
|
||||||
)
|
)
|
||||||
await self._write(record)
|
await self._write(record)
|
||||||
@@ -104,7 +112,7 @@ class UserStore:
|
|||||||
raw = await self._read_raw(username)
|
raw = await self._read_raw(username)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
return UserRecord(**json.loads(raw))
|
return self._from_raw(raw)
|
||||||
|
|
||||||
async def list(self) -> list[UserRecord]:
|
async def list(self) -> list[UserRecord]:
|
||||||
"""列出全部用户(扫描 user:* 键)"""
|
"""列出全部用户(扫描 user:* 键)"""
|
||||||
@@ -115,7 +123,15 @@ class UserStore:
|
|||||||
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
|
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise UserStoreError("列出用户失败") from e
|
raise UserStoreError("列出用户失败") from e
|
||||||
return [UserRecord(**json.loads(raw)) for raw in raws if raw is not None]
|
return [self._from_raw(raw) for raw in raws if raw is not None]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _from_raw(raw: str) -> UserRecord:
|
||||||
|
"""从 JSON 反序列化 UserRecord;存量记录无 enabled 字段时按 True 兼容"""
|
||||||
|
data = json.loads(raw)
|
||||||
|
if "enabled" not in data:
|
||||||
|
data["enabled"] = True
|
||||||
|
return UserRecord(**data)
|
||||||
|
|
||||||
async def delete(self, username: str) -> bool:
|
async def delete(self, username: str) -> bool:
|
||||||
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
|
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
|
||||||
@@ -158,6 +174,30 @@ class UserStore:
|
|||||||
"""统计 admin 角色用户数"""
|
"""统计 admin 角色用户数"""
|
||||||
return sum(1 for record in await self.list() if record.role == "admin")
|
return sum(1 for record in await self.list() if record.role == "admin")
|
||||||
|
|
||||||
|
async def update_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
) -> UserRecord:
|
||||||
|
"""更新用户角色或启用状态
|
||||||
|
|
||||||
|
用户不存在抛 UserNotFoundError;role 非 admin/user 抛 ValueError。
|
||||||
|
role/enabled 为 None 表示不修改对应字段。更新后写回并返回最新记录。
|
||||||
|
"""
|
||||||
|
record = await self.get(username)
|
||||||
|
if record is None:
|
||||||
|
raise UserNotFoundError(f"用户不存在: {username}")
|
||||||
|
if role is not None and role not in ("admin", "user"):
|
||||||
|
raise ValueError(f"非法角色: {role!r}(须为 admin/user)")
|
||||||
|
if role is not None:
|
||||||
|
record.role = role
|
||||||
|
if enabled is not None:
|
||||||
|
record.enabled = enabled
|
||||||
|
await self._write(record)
|
||||||
|
return record
|
||||||
|
|
||||||
async def _read_raw(self, username: str) -> str | None:
|
async def _read_raw(self, username: str) -> str | None:
|
||||||
if self._redis is None:
|
if self._redis is None:
|
||||||
return self._memory.get(username)
|
return self._memory.get(username)
|
||||||
|
|||||||
+53
-3
@@ -9,6 +9,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -103,18 +104,50 @@ class QdrantService:
|
|||||||
tags: list[str],
|
tags: list[str],
|
||||||
dense_vector: list[float],
|
dense_vector: list[float],
|
||||||
sparse_vector: SparseVectorTuple | None = None,
|
sparse_vector: SparseVectorTuple | None = None,
|
||||||
|
metadata: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)"""
|
"""写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)/metadata
|
||||||
|
|
||||||
|
metadata 默认 None 时存空 dict,保证字段始终存在;存量旧文档读取时按缺失处理。
|
||||||
|
"""
|
||||||
vector: dict[str, Any] = {VECTOR_DENSE: dense_vector}
|
vector: dict[str, Any] = {VECTOR_DENSE: dense_vector}
|
||||||
if sparse_vector is not None:
|
if sparse_vector is not None:
|
||||||
vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1])
|
vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1])
|
||||||
point = models.PointStruct(
|
point = models.PointStruct(
|
||||||
id=_point_id(f"{doc_id}:l1"),
|
id=_point_id(f"{doc_id}:l1"),
|
||||||
vector=vector,
|
vector=vector,
|
||||||
payload={"doc_id": doc_id, "title": title, "category": category, "tags": tags, "text": summary},
|
payload={
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"title": title,
|
||||||
|
"category": category,
|
||||||
|
"tags": tags,
|
||||||
|
"text": summary,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
await self._client.upsert(collection_name=COLLECTION_L1, points=[point])
|
await self._client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||||
|
|
||||||
|
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||||||
|
"""按 doc_id 查 L1 点,返回其 payload.metadata
|
||||||
|
|
||||||
|
文档不存在或 payload 无 metadata 字段时返回 None。
|
||||||
|
"""
|
||||||
|
doc_filter = self.build_filter(doc_ids=[doc_id])
|
||||||
|
records, _ = await self._client.scroll(
|
||||||
|
collection_name=COLLECTION_L1,
|
||||||
|
scroll_filter=doc_filter,
|
||||||
|
limit=1,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
payload = records[0].payload or {}
|
||||||
|
meta = payload.get("metadata")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return None
|
||||||
|
return meta
|
||||||
|
|
||||||
async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None:
|
async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None:
|
||||||
"""批量写入 L2/L3 大纲节点
|
"""批量写入 L2/L3 大纲节点
|
||||||
|
|
||||||
@@ -252,7 +285,12 @@ class QdrantService:
|
|||||||
return items, str(next_offset) if next_offset is not None else None
|
return items, str(next_offset) if next_offset is not None else None
|
||||||
|
|
||||||
async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None:
|
async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None:
|
||||||
"""获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量,文档不存在返回 None"""
|
"""获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量 + file 文件信息
|
||||||
|
|
||||||
|
file 字段从 L1 payload.metadata 提取(raw_file_path/original_filename/original_size_bytes):
|
||||||
|
有 raw_file_path 时返回 {filename, size_bytes, url},否则 None。
|
||||||
|
文档不存在返回 None。
|
||||||
|
"""
|
||||||
doc_filter = self.build_filter(doc_ids=[doc_id])
|
doc_filter = self.build_filter(doc_ids=[doc_id])
|
||||||
l1_records, _ = await self._client.scroll(
|
l1_records, _ = await self._client.scroll(
|
||||||
collection_name=COLLECTION_L1,
|
collection_name=COLLECTION_L1,
|
||||||
@@ -271,11 +309,23 @@ class QdrantService:
|
|||||||
count_filter=doc_filter,
|
count_filter=doc_filter,
|
||||||
exact=True,
|
exact=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 从 L1 metadata 提取原始文件信息,无 raw_file_path 时 file=None
|
||||||
|
meta = (l1_records[0].payload or {}).get("metadata") or {}
|
||||||
|
raw_path = meta.get("raw_file_path", "")
|
||||||
|
file_info: dict[str, Any] | None = None
|
||||||
|
if raw_path:
|
||||||
|
file_info = {
|
||||||
|
"filename": meta.get("original_filename", Path(raw_path).name),
|
||||||
|
"size_bytes": int(meta.get("original_size_bytes", "0") or "0"),
|
||||||
|
"url": f"/api/v1/documents/{doc_id}/file",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"l1": l1_records[0].payload or {},
|
"l1": l1_records[0].payload or {},
|
||||||
"l2_nodes": l2_nodes,
|
"l2_nodes": l2_nodes,
|
||||||
"l3_nodes": l3_nodes,
|
"l3_nodes": l3_nodes,
|
||||||
"chunks_count": chunks_count.count,
|
"chunks_count": chunks_count.count,
|
||||||
|
"file": file_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""语义重排服务(cross-encoder reranker)
|
||||||
|
|
||||||
|
调用 Ollama 的 `/api/rerank` 端点(Qwen3-Reranker 等重排模型),对检索召回的
|
||||||
|
chunk 候选池做精排,提升最终 Top-K 的相关性。相比纯向量/RRF 融合,cross-encoder
|
||||||
|
以 (query, doc) 联合编码,能捕捉字词不匹配的语义关联,典型带来约 10% 的精度提升。
|
||||||
|
|
||||||
|
工厂 `create_reranker_service()` 按 `settings.reranker_enabled` 返回实例或 None:
|
||||||
|
关闭时上层检索链路退化为原有的 RRF 融合结果,行为完全不变。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RerankerService(Protocol):
|
||||||
|
"""重排服务统一接口"""
|
||||||
|
|
||||||
|
async def rerank(self, query: str, documents: list[str]) -> list[float]:
|
||||||
|
"""对候选文档按与 query 的相关性打分
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 查询文本
|
||||||
|
documents: 候选文档文本列表(按输入顺序对齐)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
与 documents 等长的相关性分数列表(分数越高越相关)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaRerankerService:
|
||||||
|
"""基于 Ollama /api/rerank 的重排服务"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, timeout: float = 30.0) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def rerank(self, query: str, documents: list[str]) -> list[float]:
|
||||||
|
if not documents:
|
||||||
|
return []
|
||||||
|
url = f"{self.base_url}/api/rerank"
|
||||||
|
payload = {"model": self.model, "query": query, "documents": documents}
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
# 按 Ollama 返回的 index 映射 relevance_score;缺失项补 0,保证输出与输入等长
|
||||||
|
scores: list[float] = [0.0] * len(documents)
|
||||||
|
for item in results:
|
||||||
|
idx = item.get("index")
|
||||||
|
score = float(item.get("relevance_score", 0.0))
|
||||||
|
if idx is not None and 0 <= idx < len(documents):
|
||||||
|
scores[idx] = score
|
||||||
|
logger.debug("重排完成", model=self.model, candidates=len(documents))
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def create_reranker_service() -> RerankerService | None:
|
||||||
|
"""按 settings.reranker_enabled 创建重排服务实例
|
||||||
|
|
||||||
|
关闭时返回 None,上层据此跳过精排、退化为 RRF 融合结果。
|
||||||
|
"""
|
||||||
|
if not settings.reranker_enabled:
|
||||||
|
return None
|
||||||
|
return OllamaRerankerService(
|
||||||
|
base_url=settings.ollama_base_url,
|
||||||
|
model=settings.reranker_model,
|
||||||
|
timeout=settings.reranker_timeout,
|
||||||
|
)
|
||||||
+614
-56
@@ -107,6 +107,10 @@
|
|||||||
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; }
|
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; }
|
||||||
.fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; }
|
.fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; }
|
||||||
.toolbar { margin: 12px 0; }
|
.toolbar { margin: 12px 0; }
|
||||||
|
.toolbar.users-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.users-toolbar input[type="text"], .users-toolbar select { width: auto; }
|
||||||
|
.users-toolbar input[type="text"] { min-width: 200px; }
|
||||||
|
.user-role-select { width: auto; display: inline-block; margin-right: 4px; }
|
||||||
.muted { color: #6b7280; font-size: 12px; }
|
.muted { color: #6b7280; font-size: 12px; }
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-block; border-radius: 3px; padding: 1px 8px;
|
display: inline-block; border-radius: 3px; padding: 1px 8px;
|
||||||
@@ -133,6 +137,20 @@
|
|||||||
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
|
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
|
||||||
}
|
}
|
||||||
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
|
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
|
||||||
|
/* 通用 overlay 弹窗组件:固定全屏遮罩 + 居中卡片 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 200;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
background: #fff; border-radius: 8px; padding: 20px 24px; width: 380px;
|
||||||
|
max-width: calc(100vw - 32px); box-shadow: 0 6px 20px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.modal-card .modal-title { font-size: 16px; margin: 0 0 14px; }
|
||||||
|
.modal-card .modal-content { font-size: 13px; }
|
||||||
|
.modal-card .modal-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.modal-card .modal-strength-hint { margin-top: -6px; margin-bottom: 12px; font-size: 12px; }
|
||||||
|
.modal-card .modal-confirm-text { margin-bottom: 10px; }
|
||||||
.extracted-info {
|
.extracted-info {
|
||||||
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
|
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
|
||||||
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
|
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
|
||||||
@@ -227,6 +245,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 通用弹窗挂载点:openModal 动态注入 .modal-overlay/.modal-card -->
|
||||||
|
<div id="modal-root"></div>
|
||||||
<header>
|
<header>
|
||||||
<h1>知识库管理后台</h1>
|
<h1>知识库管理后台</h1>
|
||||||
<div id="user-area" class="user-area hidden">
|
<div id="user-area" class="user-area hidden">
|
||||||
@@ -242,6 +262,7 @@
|
|||||||
<button type="button" data-target="section-search">检索测试台</button>
|
<button type="button" data-target="section-search">检索测试台</button>
|
||||||
<button type="button" data-target="section-categories">类目列表</button>
|
<button type="button" data-target="section-categories">类目列表</button>
|
||||||
<button type="button" data-target="section-api-guide">API 指南</button>
|
<button type="button" data-target="section-api-guide">API 指南</button>
|
||||||
|
<button type="button" data-target="section-profile" id="nav-profile">个人中心</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -256,6 +277,32 @@
|
|||||||
<div id="overview-categories"></div>
|
<div id="overview-categories"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="section-profile" class="hidden">
|
||||||
|
<h2>个人中心</h2>
|
||||||
|
<div class="error-bar hidden" id="error-profile"></div>
|
||||||
|
<h3 style="font-size:14px;">账号信息</h3>
|
||||||
|
<div class="cards" id="profile-cards"></div>
|
||||||
|
<h3 style="font-size:14px; margin-top:24px;">修改密码</h3>
|
||||||
|
<form id="profile-password-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-old-password">旧密码</label>
|
||||||
|
<input type="password" id="profile-old-password" name="old_password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-new-password">新密码(至少 8 位)</label>
|
||||||
|
<input type="password" id="profile-new-password" name="new_password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-confirm-password">确认新密码</label>
|
||||||
|
<input type="password" id="profile-confirm-password" name="confirm_password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="muted" id="profile-strength-hint"></div>
|
||||||
|
<button type="submit" class="primary" id="btn-profile-password-submit">确认修改</button>
|
||||||
|
</form>
|
||||||
|
<h3 style="font-size:14px; margin-top:24px;">会话</h3>
|
||||||
|
<button type="button" class="action danger" id="btn-profile-logout">退出登录</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="section-docs" class="hidden">
|
<section id="section-docs" class="hidden">
|
||||||
<h2>文档管理</h2>
|
<h2>文档管理</h2>
|
||||||
<div class="error-bar hidden" id="error-docs"></div>
|
<div class="error-bar hidden" id="error-docs"></div>
|
||||||
@@ -351,12 +398,19 @@
|
|||||||
<section id="section-users" class="hidden">
|
<section id="section-users" class="hidden">
|
||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
<div class="error-bar hidden" id="error-users"></div>
|
<div class="error-bar hidden" id="error-users"></div>
|
||||||
<div class="toolbar">
|
<div class="toolbar users-toolbar">
|
||||||
|
<input type="text" id="user-search" placeholder="按用户名搜索" autocomplete="off">
|
||||||
|
<select id="user-role-filter">
|
||||||
|
<option value="all" selected>全部角色</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="user">user</option>
|
||||||
|
</select>
|
||||||
<button type="button" class="action" id="btn-refresh-users">刷新</button>
|
<button type="button" class="action" id="btn-refresh-users">刷新</button>
|
||||||
|
<span class="muted" id="users-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>操作</th></tr>
|
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>启用</th><th>操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="users-tbody"></tbody>
|
<tbody id="users-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -398,6 +452,15 @@ function clearChildren(node) {
|
|||||||
while (node.firstChild) { node.removeChild(node.firstChild); }
|
while (node.firstChild) { node.removeChild(node.firstChild); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
var n = Number(bytes);
|
||||||
|
if (!n || n <= 0) { return "0 B"; }
|
||||||
|
var units = ["B", "KB", "MB", "GB"];
|
||||||
|
var i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n = n / 1024; i++; }
|
||||||
|
return (i === 0 ? n : n.toFixed(1)) + " " + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
function showError(boxId, err) {
|
function showError(boxId, err) {
|
||||||
var box = document.getElementById(boxId);
|
var box = document.getElementById(boxId);
|
||||||
clearChildren(box);
|
clearChildren(box);
|
||||||
@@ -445,7 +508,8 @@ function showLogin() {
|
|||||||
unmountUsersSection();
|
unmountUsersSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块) */
|
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块)
|
||||||
|
admin 默认进概览,user 角色默认进个人中心(user 无需访问概览统计) */
|
||||||
function enterApp(user) {
|
function enterApp(user) {
|
||||||
document.getElementById("login-overlay").classList.add("hidden");
|
document.getElementById("login-overlay").classList.add("hidden");
|
||||||
document.getElementById("password-overlay").classList.add("hidden");
|
document.getElementById("password-overlay").classList.add("hidden");
|
||||||
@@ -457,10 +521,11 @@ function enterApp(user) {
|
|||||||
loadedOnce = {};
|
loadedOnce = {};
|
||||||
if (user.role === "admin") {
|
if (user.role === "admin") {
|
||||||
mountUsersSection();
|
mountUsersSection();
|
||||||
|
activateSection("section-overview");
|
||||||
} else {
|
} else {
|
||||||
unmountUsersSection();
|
unmountUsersSection();
|
||||||
|
activateSection("section-profile");
|
||||||
}
|
}
|
||||||
activateSection("section-overview");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
|
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
|
||||||
@@ -498,14 +563,17 @@ document.getElementById("login-form").addEventListener("submit", function (event
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("btn-logout").addEventListener("click", function () {
|
/* 退出登录:调用后端 logout 端点,失败不阻塞本地登出(顶栏 + 个人中心共用) */
|
||||||
|
function doLogout() {
|
||||||
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
|
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
|
||||||
/* 服务端退出失败不阻塞本地登出 */
|
/* 服务端退出失败不阻塞本地登出 */
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
showLogin();
|
showLogin();
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-logout").addEventListener("click", doLogout);
|
||||||
|
|
||||||
/* ---------- 修改密码 ---------- */
|
/* ---------- 修改密码 ---------- */
|
||||||
|
|
||||||
@@ -566,6 +634,191 @@ document.getElementById("password-form").addEventListener("submit", function (ev
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- 个人中心 ---------- */
|
||||||
|
|
||||||
|
/* 进入区块时加载当前账号信息:GET /auth/me 返回 username/role/must_change_password/enabled/created_at */
|
||||||
|
function loadProfile() {
|
||||||
|
hideError("error-profile");
|
||||||
|
api("/api/v1/auth/me").then(function (user) {
|
||||||
|
renderProfile(user);
|
||||||
|
}).catch(function (err) { showError("error-profile", err); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 账号信息卡片:用户名 / 角色徽章 / 创建时间 / 须改密 / 启用状态 */
|
||||||
|
function profileCard(label, value, valueClassName) {
|
||||||
|
var card = el("div", null, "card");
|
||||||
|
var num = el("div", null, "num");
|
||||||
|
if (valueClassName) {
|
||||||
|
var span = el("span", value, valueClassName);
|
||||||
|
num.appendChild(span);
|
||||||
|
} else {
|
||||||
|
num.textContent = value;
|
||||||
|
}
|
||||||
|
card.appendChild(num);
|
||||||
|
card.appendChild(el("div", label, "label"));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile(user) {
|
||||||
|
var container = document.getElementById("profile-cards");
|
||||||
|
clearChildren(container);
|
||||||
|
container.appendChild(profileCard("用户名", user.username || ""));
|
||||||
|
var roleBadgeClass = "role-badge" + (user.role === "admin" ? " role-admin" : "");
|
||||||
|
container.appendChild(profileCard("角色", user.role || "", roleBadgeClass));
|
||||||
|
container.appendChild(profileCard("创建时间", user.created_at || ""));
|
||||||
|
container.appendChild(profileCard("须改密", user.must_change_password ? "是" : "否"));
|
||||||
|
container.appendChild(profileCard("启用状态", user.enabled ? "启用" : "禁用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强度提示:复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强,纯文案) */
|
||||||
|
function updateProfileStrength() {
|
||||||
|
var pwd = document.getElementById("profile-new-password").value || "";
|
||||||
|
var level = "";
|
||||||
|
if (pwd.length > 0) {
|
||||||
|
if (pwd.length < 8) { level = "弱"; }
|
||||||
|
else if (pwd.length >= 12) { level = "强"; }
|
||||||
|
else { level = "中"; }
|
||||||
|
}
|
||||||
|
document.getElementById("profile-strength-hint").textContent = level ? "密码强度:" + level : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("profile-new-password").addEventListener("input", updateProfileStrength);
|
||||||
|
|
||||||
|
/* 个人中心改密表单:与顶栏改密弹窗同一端点 POST /auth/password
|
||||||
|
- 两次不一致本地拦截
|
||||||
|
- 旧密码错误 1005、新密码 <8 位 1001 由后端返回,统一进错误条
|
||||||
|
- 成功后:更新本地 user 状态(清除 must_change_password)、清空表单、刷新账号信息、成功提示 */
|
||||||
|
document.getElementById("profile-password-form").addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
hideError("error-profile");
|
||||||
|
var newPwd = document.getElementById("profile-new-password").value;
|
||||||
|
if (newPwd !== document.getElementById("profile-confirm-password").value) {
|
||||||
|
showError("error-profile", { code: "VALIDATION", message: "两次输入的新密码不一致" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var submitBtn = document.getElementById("btn-profile-password-submit");
|
||||||
|
var origText = submitBtn.textContent;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "提交中…";
|
||||||
|
api("/api/v1/auth/password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: document.getElementById("profile-old-password").value,
|
||||||
|
new_password: newPwd
|
||||||
|
})
|
||||||
|
}).then(function () {
|
||||||
|
/* 改密成功:更新本地 user 状态(清除 must_change_password 标志) */
|
||||||
|
var user = getStoredUser();
|
||||||
|
if (user && user.must_change_password) {
|
||||||
|
user.must_change_password = false;
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
/* 清空表单与强度提示 */
|
||||||
|
document.getElementById("profile-password-form").reset();
|
||||||
|
document.getElementById("profile-strength-hint").textContent = "";
|
||||||
|
/* 刷新账号信息展示 */
|
||||||
|
loadProfile();
|
||||||
|
alert("密码修改成功");
|
||||||
|
}).catch(function (err) {
|
||||||
|
showError("error-profile", err);
|
||||||
|
}).finally(function () {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = origText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 个人中心退出按钮:复用顶栏 doLogout 逻辑 */
|
||||||
|
document.getElementById("btn-profile-logout").addEventListener("click", doLogout);
|
||||||
|
|
||||||
|
/* ---------- 通用 overlay 弹窗组件 ----------
|
||||||
|
openModal(id, title, contentBuilder):在 #modal-root 下动态构造 .modal-overlay>.modal-card
|
||||||
|
- 关闭路径:点遮罩 / 点取消按钮 / ESC(提交中 data-submitting=true 时全部禁用)
|
||||||
|
- 提交 loading 态:setModalSubmitting 禁用按钮 + 文案改「提交中…」
|
||||||
|
- 动态内容全 textContent,无 innerHTML */
|
||||||
|
function openModal(id, title, contentBuilder) {
|
||||||
|
closeModal(id);
|
||||||
|
var overlay = el("div", null, "modal-overlay");
|
||||||
|
overlay.id = "modal-" + id;
|
||||||
|
overlay.setAttribute("data-modal-id", id);
|
||||||
|
overlay.setAttribute("data-submitting", "false");
|
||||||
|
var card = el("div", null, "modal-card");
|
||||||
|
card.appendChild(el("h3", title, "modal-title"));
|
||||||
|
var content = el("div", null, "modal-content");
|
||||||
|
contentBuilder(content);
|
||||||
|
card.appendChild(content);
|
||||||
|
overlay.appendChild(card);
|
||||||
|
/* 点遮罩关闭(提交中不可关闭) */
|
||||||
|
overlay.addEventListener("click", function (e) {
|
||||||
|
if (e.target === overlay && overlay.getAttribute("data-submitting") !== "true") {
|
||||||
|
closeModal(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("modal-root").appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
var existing = document.getElementById("modal-" + id);
|
||||||
|
if (existing && existing.parentNode) {
|
||||||
|
existing.parentNode.removeChild(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModalSubmitting(id) {
|
||||||
|
var overlay = document.getElementById("modal-" + id);
|
||||||
|
return !!(overlay && overlay.getAttribute("data-submitting") === "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提交 loading 态:data-submitting 标记 + 按钮禁用 + 文案改「提交中…」;
|
||||||
|
失败回滚时恢复按钮原始文案与可点状态 */
|
||||||
|
function setModalSubmitting(id, submitting, submitBtn) {
|
||||||
|
var overlay = document.getElementById("modal-" + id);
|
||||||
|
if (!overlay) { return; }
|
||||||
|
overlay.setAttribute("data-submitting", submitting ? "true" : "false");
|
||||||
|
if (submitBtn) {
|
||||||
|
if (submitting) {
|
||||||
|
if (!submitBtn.getAttribute("data-original-text")) {
|
||||||
|
submitBtn.setAttribute("data-original-text", submitBtn.textContent);
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "提交中…";
|
||||||
|
} else {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
var orig = submitBtn.getAttribute("data-original-text");
|
||||||
|
submitBtn.textContent = orig || submitBtn.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModelError(id, message) {
|
||||||
|
var overlay = document.getElementById("modal-" + id);
|
||||||
|
if (!overlay) { return; }
|
||||||
|
var box = overlay.querySelector(".error-bar");
|
||||||
|
if (!box) { return; }
|
||||||
|
box.textContent = message || "";
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModelError(id) {
|
||||||
|
var overlay = document.getElementById("modal-" + id);
|
||||||
|
if (!overlay) { return; }
|
||||||
|
var box = overlay.querySelector(".error-bar");
|
||||||
|
if (!box) { return; }
|
||||||
|
box.classList.add("hidden");
|
||||||
|
box.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ESC 关闭最顶层弹窗(提交中不可关闭) */
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key !== "Escape") { return; }
|
||||||
|
var modals = document.querySelectorAll(".modal-overlay");
|
||||||
|
if (modals.length === 0) { return; }
|
||||||
|
var top = modals[modals.length - 1];
|
||||||
|
if (top.getAttribute("data-submitting") === "true") { return; }
|
||||||
|
var mid = top.getAttribute("data-modal-id");
|
||||||
|
if (mid) { closeModal(mid); }
|
||||||
|
});
|
||||||
|
|
||||||
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
|
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
|
||||||
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
|
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
|
||||||
function api(path, options) {
|
function api(path, options) {
|
||||||
@@ -620,6 +873,7 @@ function activateSection(targetId) {
|
|||||||
if (targetId === "section-categories") { loadCategories(); }
|
if (targetId === "section-categories") { loadCategories(); }
|
||||||
if (targetId === "section-users") { loadUsers(); }
|
if (targetId === "section-users") { loadUsers(); }
|
||||||
if (targetId === "section-api-guide") { renderApiGuide(); }
|
if (targetId === "section-api-guide") { renderApiGuide(); }
|
||||||
|
if (targetId === "section-profile") { loadProfile(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +1022,20 @@ function renderDocDetail(data) {
|
|||||||
meta3.appendChild(el("span", data.chunks_count));
|
meta3.appendChild(el("span", data.chunks_count));
|
||||||
panel.appendChild(meta3);
|
panel.appendChild(meta3);
|
||||||
|
|
||||||
|
/* 原始文件:有关联文件时展示文件名 + 大小 + 下载链接(textContent 防 XSS) */
|
||||||
|
if (data.file) {
|
||||||
|
var fileRow = el("div", null, "kv");
|
||||||
|
fileRow.appendChild(el("span", "原始文件", "k"));
|
||||||
|
fileRow.appendChild(
|
||||||
|
el("span", (data.file.filename || "") + "(" + formatFileSize(data.file.size_bytes) + ")")
|
||||||
|
);
|
||||||
|
var dlLink = el("a", "下载");
|
||||||
|
dlLink.href = location.origin + (data.file.url || "");
|
||||||
|
dlLink.setAttribute("download", data.file.filename || "");
|
||||||
|
fileRow.appendChild(dlLink);
|
||||||
|
panel.appendChild(fileRow);
|
||||||
|
}
|
||||||
|
|
||||||
panel.appendChild(el("h3", "L1 全文"));
|
panel.appendChild(el("h3", "L1 全文"));
|
||||||
panel.appendChild(el("pre", l1.text || ""));
|
panel.appendChild(el("pre", l1.text || ""));
|
||||||
|
|
||||||
@@ -791,18 +1059,72 @@ function renderNodes(panel, nodes) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 删除文档弹窗:复用通用弹窗组件,输入 doc_id 匹配后才可提交;
|
||||||
|
与用户管理删除保持一致的二次确认体验(不再使用原生弹窗) */
|
||||||
function deleteDocument(docId, title) {
|
function deleteDocument(docId, title) {
|
||||||
if (!confirm("确定删除文档「" + (title || docId) + "」(" + docId + ") 吗?该操作将删除四层集合中的全部数据,不可恢复。")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hideError("error-docs");
|
hideError("error-docs");
|
||||||
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
|
openModal("delete-doc", "删除文档", function (content) {
|
||||||
document.getElementById("doc-detail").classList.add("hidden");
|
var warn = el("div", null, "fallback-flag");
|
||||||
loadDocuments(true);
|
warn.textContent = "此操作不可恢复,将删除四层集合中的全部数据";
|
||||||
loadOverview();
|
content.appendChild(warn);
|
||||||
var status = document.getElementById("docs-status");
|
|
||||||
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
|
var metaRow = el("div", null, "modal-confirm-text");
|
||||||
}).catch(function (err) { showError("error-docs", err); });
|
metaRow.textContent = "文档:" + (title || docId) + "(doc_id=" + docId + ")";
|
||||||
|
content.appendChild(metaRow);
|
||||||
|
|
||||||
|
var confirmText = el("div", null, "modal-confirm-text");
|
||||||
|
confirmText.textContent = "请输入 doc_id " + docId + " 以确认";
|
||||||
|
content.appendChild(confirmText);
|
||||||
|
|
||||||
|
var field = el("div", null, "field");
|
||||||
|
var input = el("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.id = "modal-delete-doc-input";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
field.appendChild(input);
|
||||||
|
content.appendChild(field);
|
||||||
|
|
||||||
|
var errBox = el("div", null, "error-bar hidden");
|
||||||
|
errBox.id = "modal-delete-doc-error";
|
||||||
|
content.appendChild(errBox);
|
||||||
|
|
||||||
|
var actions = el("div", null, "modal-actions");
|
||||||
|
var submitBtn = el("button", "删除", "action danger");
|
||||||
|
submitBtn.type = "button";
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
var cancelBtn = el("button", "取消", "action");
|
||||||
|
cancelBtn.type = "button";
|
||||||
|
cancelBtn.addEventListener("click", function () {
|
||||||
|
if (!isModalSubmitting("delete-doc")) { closeModal("delete-doc"); }
|
||||||
|
});
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
submitBtn.disabled = (input.value !== docId);
|
||||||
|
});
|
||||||
|
submitBtn.addEventListener("click", function () {
|
||||||
|
if (input.value !== docId) {
|
||||||
|
showModelError("delete-doc", "输入的 doc_id 不匹配");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideModelError("delete-doc");
|
||||||
|
setModalSubmitting("delete-doc", true, submitBtn);
|
||||||
|
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
|
||||||
|
closeModal("delete-doc");
|
||||||
|
document.getElementById("doc-detail").classList.add("hidden");
|
||||||
|
loadDocuments(true);
|
||||||
|
loadOverview();
|
||||||
|
var status = document.getElementById("docs-status");
|
||||||
|
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
|
||||||
|
}).catch(function (err) {
|
||||||
|
showModelError("delete-doc", (err && err.message) ? err.message : String(err));
|
||||||
|
setModalSubmitting("delete-doc", false, submitBtn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
content.appendChild(actions);
|
||||||
|
|
||||||
|
setTimeout(function () { input.focus(); }, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
|
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
|
||||||
@@ -1102,6 +1424,9 @@ function mountUsersSection() {
|
|||||||
document.getElementById("nav").appendChild(navBtn);
|
document.getElementById("nav").appendChild(navBtn);
|
||||||
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
|
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
|
||||||
document.getElementById("user-create-form").addEventListener("submit", createUser);
|
document.getElementById("user-create-form").addEventListener("submit", createUser);
|
||||||
|
/* 搜索框 input + 角色筛选 change:仅本地重新过滤渲染,不发后端请求 */
|
||||||
|
document.getElementById("user-search").addEventListener("input", applyUsersFilter);
|
||||||
|
document.getElementById("user-role-filter").addEventListener("change", applyUsersFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unmountUsersSection() {
|
function unmountUsersSection() {
|
||||||
@@ -1118,33 +1443,139 @@ function loadUsers() {
|
|||||||
}).catch(function (err) { showError("error-users", err); });
|
}).catch(function (err) { showError("error-users", err); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var usersCache = [];
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
|
usersCache = users || [];
|
||||||
|
applyUsersFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表搜索筛选:按用户名(包含、不区分大小写)+ 角色下拉过滤,仅本地重新渲染不发请求 */
|
||||||
|
function applyUsersFilter() {
|
||||||
var tbody = document.getElementById("users-tbody");
|
var tbody = document.getElementById("users-tbody");
|
||||||
clearChildren(tbody);
|
clearChildren(tbody);
|
||||||
users.forEach(function (user) {
|
var keyword = (document.getElementById("user-search").value || "").trim().toLowerCase();
|
||||||
var tr = el("tr");
|
var roleFilter = document.getElementById("user-role-filter").value;
|
||||||
tr.appendChild(el("td", user.username));
|
var currentUser = getStoredUser();
|
||||||
var roleTd = el("td");
|
usersCache.forEach(function (user) {
|
||||||
roleTd.appendChild(el("span", user.role, "role-badge" + (user.role === "admin" ? " role-admin" : "")));
|
if (keyword && (user.username || "").toLowerCase().indexOf(keyword) === -1) { return; }
|
||||||
tr.appendChild(roleTd);
|
if (roleFilter !== "all" && user.role !== roleFilter) { return; }
|
||||||
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
|
tbody.appendChild(buildUserRow(user, currentUser));
|
||||||
tr.appendChild(el("td", user.created_at || ""));
|
|
||||||
|
|
||||||
var opsTd = el("td");
|
|
||||||
var resetBtn = el("button", "重置密码", "action");
|
|
||||||
resetBtn.type = "button";
|
|
||||||
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
|
|
||||||
var deleteBtn = el("button", "删除", "action danger");
|
|
||||||
deleteBtn.type = "button";
|
|
||||||
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
|
|
||||||
opsTd.appendChild(resetBtn);
|
|
||||||
opsTd.appendChild(deleteBtn);
|
|
||||||
tr.appendChild(opsTd);
|
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 单行渲染:行内角色 select + 保存按钮、启用开关;当前登录 admin 自身行禁用防自锁降级 */
|
||||||
|
function buildUserRow(user, currentUser) {
|
||||||
|
var isSelf = !!(currentUser && currentUser.username === user.username);
|
||||||
|
var tr = el("tr");
|
||||||
|
tr.appendChild(el("td", user.username));
|
||||||
|
|
||||||
|
/* 角色:行内 select + 保存按钮(自身行 select disabled + 无保存按钮) */
|
||||||
|
var roleTd = el("td");
|
||||||
|
var roleSelect = el("select", null, "user-role-select");
|
||||||
|
var adminOpt = el("option", "admin");
|
||||||
|
adminOpt.value = "admin";
|
||||||
|
var userOpt = el("option", "user");
|
||||||
|
userOpt.value = "user";
|
||||||
|
roleSelect.appendChild(userOpt);
|
||||||
|
roleSelect.appendChild(adminOpt);
|
||||||
|
roleSelect.value = user.role;
|
||||||
|
if (isSelf) {
|
||||||
|
roleSelect.disabled = true;
|
||||||
|
} else {
|
||||||
|
var saveRoleBtn = el("button", "保存", "action user-role-save");
|
||||||
|
saveRoleBtn.type = "button";
|
||||||
|
saveRoleBtn.disabled = true;
|
||||||
|
saveRoleBtn.addEventListener("click", function () {
|
||||||
|
updateUserRole(user.username, roleSelect.value, saveRoleBtn);
|
||||||
|
});
|
||||||
|
roleSelect.addEventListener("change", function () {
|
||||||
|
saveRoleBtn.disabled = (roleSelect.value === user.role);
|
||||||
|
});
|
||||||
|
roleTd.appendChild(saveRoleBtn);
|
||||||
|
}
|
||||||
|
roleTd.appendChild(roleSelect);
|
||||||
|
tr.appendChild(roleTd);
|
||||||
|
|
||||||
|
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
|
||||||
|
tr.appendChild(el("td", user.created_at || ""));
|
||||||
|
|
||||||
|
/* 启用开关:当前状态文字 + 样式区分;自身行 disabled 不可点 */
|
||||||
|
var enabledTd = el("td");
|
||||||
|
var isEnabled = !!user.enabled;
|
||||||
|
var toggleBtn = el(
|
||||||
|
"button",
|
||||||
|
isEnabled ? "启用" : "禁用",
|
||||||
|
"action user-enabled-toggle" + (isEnabled ? "" : " danger")
|
||||||
|
);
|
||||||
|
toggleBtn.type = "button";
|
||||||
|
if (isSelf) {
|
||||||
|
toggleBtn.disabled = true;
|
||||||
|
toggleBtn.title = "不能禁用当前登录账号";
|
||||||
|
} else {
|
||||||
|
toggleBtn.addEventListener("click", function () {
|
||||||
|
toggleUserEnabled(user.username, isEnabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
enabledTd.appendChild(toggleBtn);
|
||||||
|
tr.appendChild(enabledTd);
|
||||||
|
|
||||||
|
/* 操作:重置密码 + 删除(均走通用弹窗组件,无 prompt/confirm 调用) */
|
||||||
|
var opsTd = el("td");
|
||||||
|
var resetBtn = el("button", "重置密码", "action");
|
||||||
|
resetBtn.type = "button";
|
||||||
|
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
|
||||||
|
var deleteBtn = el("button", "删除", "action danger");
|
||||||
|
deleteBtn.type = "button";
|
||||||
|
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
|
||||||
|
opsTd.appendChild(resetBtn);
|
||||||
|
opsTd.appendChild(deleteBtn);
|
||||||
|
tr.appendChild(opsTd);
|
||||||
|
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUsersStatus(text) {
|
||||||
|
var span = document.getElementById("users-status");
|
||||||
|
if (!span) { return; }
|
||||||
|
span.textContent = text || "";
|
||||||
|
if (text) {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (span.textContent === text) { span.textContent = ""; }
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行内角色保存:PATCH {role},成功刷新列表 + 状态提示;失败错误条 */
|
||||||
|
function updateUserRole(username, newRole, btn) {
|
||||||
|
hideError("error-users");
|
||||||
|
if (btn) { btn.disabled = true; }
|
||||||
|
api("/api/v1/auth/users/" + encodeURIComponent(username), {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role: newRole })
|
||||||
|
}).then(function () {
|
||||||
|
showUsersStatus("已更新 " + username + " 的角色");
|
||||||
|
loadUsers();
|
||||||
|
}).catch(function (err) {
|
||||||
|
showError("error-users", err);
|
||||||
|
if (btn) { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行内启用/禁用开关:PATCH {enabled: !当前},成功刷新列表 + 状态提示;失败错误条 */
|
||||||
|
function toggleUserEnabled(username, currentEnabled) {
|
||||||
|
hideError("error-users");
|
||||||
|
api("/api/v1/auth/users/" + encodeURIComponent(username), {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: !currentEnabled })
|
||||||
|
}).then(function () {
|
||||||
|
showUsersStatus("已" + (!currentEnabled ? "启用" : "禁用") + " 用户 " + username);
|
||||||
|
loadUsers();
|
||||||
|
}).catch(function (err) { showError("error-users", err); });
|
||||||
|
}
|
||||||
|
|
||||||
function createUser(event) {
|
function createUser(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
hideError("error-users");
|
hideError("error-users");
|
||||||
@@ -1163,31 +1594,158 @@ function createUser(event) {
|
|||||||
}).catch(function (err) { showError("error-users", err); });
|
}).catch(function (err) { showError("error-users", err); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 重置密码弹窗:新密码 + 确认密码 + 强度提示(<8 弱 / ≥8 中 / ≥12 强,纯文案)+
|
||||||
|
两次不一致本地拦截;提交按钮 loading 态;成功关闭弹窗 + 刷新列表 + 状态提示 */
|
||||||
function resetUserPassword(username) {
|
function resetUserPassword(username) {
|
||||||
var newPwd = prompt("为用户「" + username + "」设置新密码(至少 8 位):");
|
|
||||||
if (newPwd === null) { return; }
|
|
||||||
if (!newPwd) {
|
|
||||||
showError("error-users", { code: "VALIDATION", message: "新密码不能为空" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hideError("error-users");
|
hideError("error-users");
|
||||||
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
|
openModal("reset-password", "重置用户「" + username + "」密码", function (content) {
|
||||||
method: "POST",
|
var newField = el("div", null, "field");
|
||||||
headers: { "Content-Type": "application/json" },
|
newField.appendChild(el("label", "新密码(至少 8 位)"));
|
||||||
body: JSON.stringify({ new_password: newPwd })
|
var newInput = el("input");
|
||||||
}).then(function () {
|
newInput.type = "password";
|
||||||
loadUsers();
|
newInput.id = "modal-reset-new";
|
||||||
}).catch(function (err) { showError("error-users", err); });
|
newInput.setAttribute("autocomplete", "new-password");
|
||||||
|
newField.appendChild(newInput);
|
||||||
|
content.appendChild(newField);
|
||||||
|
|
||||||
|
var confirmField = el("div", null, "field");
|
||||||
|
confirmField.appendChild(el("label", "确认新密码"));
|
||||||
|
var confirmInput = el("input");
|
||||||
|
confirmInput.type = "password";
|
||||||
|
confirmInput.id = "modal-reset-confirm";
|
||||||
|
confirmInput.setAttribute("autocomplete", "new-password");
|
||||||
|
confirmField.appendChild(confirmInput);
|
||||||
|
content.appendChild(confirmField);
|
||||||
|
|
||||||
|
var hint = el("div", null, "modal-strength-hint muted");
|
||||||
|
hint.id = "modal-reset-strength";
|
||||||
|
content.appendChild(hint);
|
||||||
|
|
||||||
|
var errBox = el("div", null, "error-bar hidden");
|
||||||
|
errBox.id = "modal-reset-error";
|
||||||
|
content.appendChild(errBox);
|
||||||
|
|
||||||
|
/* 强度提示:纯文案,<8 弱 / ≥8 中 / ≥12 强 */
|
||||||
|
function updateStrength() {
|
||||||
|
var pwd = newInput.value || "";
|
||||||
|
var level = "";
|
||||||
|
if (pwd.length > 0) {
|
||||||
|
if (pwd.length < 8) { level = "弱"; }
|
||||||
|
else if (pwd.length >= 12) { level = "强"; }
|
||||||
|
else { level = "中"; }
|
||||||
|
}
|
||||||
|
hint.textContent = level ? "密码强度:" + level : "";
|
||||||
|
}
|
||||||
|
newInput.addEventListener("input", updateStrength);
|
||||||
|
|
||||||
|
var actions = el("div", null, "modal-actions");
|
||||||
|
var submitBtn = el("button", "确认重置", "primary");
|
||||||
|
submitBtn.type = "button";
|
||||||
|
var cancelBtn = el("button", "取消", "action");
|
||||||
|
cancelBtn.type = "button";
|
||||||
|
cancelBtn.addEventListener("click", function () {
|
||||||
|
if (!isModalSubmitting("reset-password")) { closeModal("reset-password"); }
|
||||||
|
});
|
||||||
|
submitBtn.addEventListener("click", function () {
|
||||||
|
var newPwd = newInput.value;
|
||||||
|
var confirmPwd = confirmInput.value;
|
||||||
|
if (!newPwd) {
|
||||||
|
showModelError("reset-password", "新密码不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* 两次不一致本地拦截 */
|
||||||
|
if (newPwd !== confirmPwd) {
|
||||||
|
showModelError("reset-password", "两次输入的新密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideModelError("reset-password");
|
||||||
|
setModalSubmitting("reset-password", true, submitBtn);
|
||||||
|
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ new_password: newPwd })
|
||||||
|
}).then(function () {
|
||||||
|
closeModal("reset-password");
|
||||||
|
loadUsers();
|
||||||
|
showUsersStatus("已重置 " + username + " 的密码");
|
||||||
|
}).catch(function (err) {
|
||||||
|
showModelError("reset-password", (err && err.message) ? err.message : String(err));
|
||||||
|
setModalSubmitting("reset-password", false, submitBtn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
content.appendChild(actions);
|
||||||
|
|
||||||
|
setTimeout(function () { newInput.focus(); }, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 删除用户弹窗:警告文案 + 「请输入用户名 {username} 以确认」+ 文本输入框;
|
||||||
|
输入 !== username 时删除按钮禁用;匹配后启用;提交按钮 loading 态 */
|
||||||
function deleteUser(username, role) {
|
function deleteUser(username, role) {
|
||||||
if (!confirm("确定删除用户「" + username + "」(角色 " + role + ")吗?该操作不可恢复。")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hideError("error-users");
|
hideError("error-users");
|
||||||
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
|
openModal("delete-user", "删除用户", function (content) {
|
||||||
loadUsers();
|
var warn = el("div", null, "fallback-flag");
|
||||||
}).catch(function (err) { showError("error-users", err); });
|
warn.textContent = "此操作不可恢复,将清除该用户及其全部会话";
|
||||||
|
content.appendChild(warn);
|
||||||
|
|
||||||
|
var metaRow = el("div", null, "modal-confirm-text");
|
||||||
|
metaRow.textContent = "目标用户:" + username + "(角色 " + role + ")";
|
||||||
|
content.appendChild(metaRow);
|
||||||
|
|
||||||
|
var confirmText = el("div", null, "modal-confirm-text");
|
||||||
|
confirmText.textContent = "请输入用户名 " + username + " 以确认";
|
||||||
|
content.appendChild(confirmText);
|
||||||
|
|
||||||
|
var field = el("div", null, "field");
|
||||||
|
var input = el("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.id = "modal-delete-input";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
field.appendChild(input);
|
||||||
|
content.appendChild(field);
|
||||||
|
|
||||||
|
var errBox = el("div", null, "error-bar hidden");
|
||||||
|
errBox.id = "modal-delete-error";
|
||||||
|
content.appendChild(errBox);
|
||||||
|
|
||||||
|
var actions = el("div", null, "modal-actions");
|
||||||
|
var submitBtn = el("button", "删除", "action danger");
|
||||||
|
submitBtn.type = "button";
|
||||||
|
/* 初始禁用:输入匹配后才启用 */
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
var cancelBtn = el("button", "取消", "action");
|
||||||
|
cancelBtn.type = "button";
|
||||||
|
cancelBtn.addEventListener("click", function () {
|
||||||
|
if (!isModalSubmitting("delete-user")) { closeModal("delete-user"); }
|
||||||
|
});
|
||||||
|
/* 输入 !== username 时删除按钮禁用;匹配后启用 */
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
submitBtn.disabled = (input.value !== username);
|
||||||
|
});
|
||||||
|
submitBtn.addEventListener("click", function () {
|
||||||
|
if (input.value !== username) {
|
||||||
|
showModelError("delete-user", "输入的用户名不匹配");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideModelError("delete-user");
|
||||||
|
setModalSubmitting("delete-user", true, submitBtn);
|
||||||
|
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
|
||||||
|
closeModal("delete-user");
|
||||||
|
loadUsers();
|
||||||
|
showUsersStatus("已删除用户 " + username);
|
||||||
|
}).catch(function (err) {
|
||||||
|
showModelError("delete-user", (err && err.message) ? err.message : String(err));
|
||||||
|
setModalSubmitting("delete-user", false, submitBtn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
content.appendChild(actions);
|
||||||
|
|
||||||
|
setTimeout(function () { input.focus(); }, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 7. API 指南 ---------- */
|
/* ---------- 7. API 指南 ---------- */
|
||||||
|
|||||||
+30
-4
@@ -1,6 +1,23 @@
|
|||||||
|
# ============================================================
|
||||||
|
# QMDSearch — 部署 compose(相对路径,可任意目录部署)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 数据库数据 / 配置 / 代码 / 前端 全部从本地目录挂载,
|
||||||
|
# 日常更新(改代码或前端)只需改文件 + `docker compose restart app`,
|
||||||
|
# 无需重新 build 镜像。
|
||||||
|
#
|
||||||
|
# 说明:
|
||||||
|
# - 相对路径相对于本 compose 文件所在目录(即项目根目录)解析,
|
||||||
|
# 因此直接把整个项目目录放到 NAS(或任意主机)即可部署。
|
||||||
|
# - app 服务复用已构建的 qmdsearch-app 镜像作为「带依赖的运行时」
|
||||||
|
# (Python 依赖装在镜像内,代码与前端构建产物走挂载)。
|
||||||
|
# - 仅当 Python 依赖(pyproject.toml)变化时,才需 `docker build -t qmdsearch-app .` 一次。
|
||||||
|
# - 数据目录默认 ./data,可通过 .env 的 NAS_DATA_DIR 覆盖到其他磁盘。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
# 不再 build 项目代码,改用已含依赖的运行时镜像;代码与前端均走下方挂载
|
||||||
|
image: qmdsearch-app:latest
|
||||||
container_name: qmdsearch-app
|
container_name: qmdsearch-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -13,7 +30,7 @@ services:
|
|||||||
# 针对 16 线程 / 61GB 内存的 NAS 调优:放宽入库并发
|
# 针对 16 线程 / 61GB 内存的 NAS 调优:放宽入库并发
|
||||||
- INGEST_MAX_CONCURRENCY=${INGEST_MAX_CONCURRENCY:-4}
|
- INGEST_MAX_CONCURRENCY=${INGEST_MAX_CONCURRENCY:-4}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./.env
|
||||||
depends_on:
|
depends_on:
|
||||||
qdrant:
|
qdrant:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -22,6 +39,14 @@ services:
|
|||||||
ollama:
|
ollama:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
|
# ---- 代码(本地,改后 restart 即生效,免 build)----
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./scripts:/app/scripts
|
||||||
|
# ---- 前端构建产物(本地 frontend/dist,改前端后 build 到此目录 + restart)----
|
||||||
|
- ./frontend/dist:/app/app/static/admin
|
||||||
|
# ---- 配置(显式文件映射,只读)----
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
# ---- 数据 / 日志(持久化)----
|
||||||
- ${NAS_DATA_DIR:-./data}/logs:/app/logs
|
- ${NAS_DATA_DIR:-./data}/logs:/app/logs
|
||||||
- ${NAS_DATA_DIR:-./data}/uploads:/app/uploads
|
- ${NAS_DATA_DIR:-./data}/uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
@@ -68,10 +93,10 @@ services:
|
|||||||
# 针对 Ryzen 9 7940HS(16 线程)的 CPU 推理调优:
|
# 针对 Ryzen 9 7940HS(16 线程)的 CPU 推理调优:
|
||||||
# 并行推理任务数、常驻模型数、单请求线程上限、KV 缓存量化以省内存
|
# 并行推理任务数、常驻模型数、单请求线程上限、KV 缓存量化以省内存
|
||||||
- OLLAMA_NUM_PARALLEL=4
|
- OLLAMA_NUM_PARALLEL=4
|
||||||
- OLLAMA_MAX_LOADED_MODELS=2
|
- OLLAMA_MAX_LOADED_MODELS=3
|
||||||
- OLLAMA_NUM_THREADS=16
|
- OLLAMA_NUM_THREADS=16
|
||||||
- OLLAMA_KV_CACHE_TYPE=q8_0
|
- OLLAMA_KV_CACHE_TYPE=q8_0
|
||||||
# 首次启动自动拉取所需模型(qwen2.5:1.5b 总结 + bge-m3 嵌入),
|
# 首次启动自动拉取所需模型(qwen2.5:1.5b 总结 + bge-m3 嵌入 + qwen3-reranker 重排),
|
||||||
# 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。
|
# 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。
|
||||||
entrypoint: /bin/bash
|
entrypoint: /bin/bash
|
||||||
command:
|
command:
|
||||||
@@ -82,6 +107,7 @@ services:
|
|||||||
sleep 6
|
sleep 6
|
||||||
ollama pull qwen2.5:1.5b
|
ollama pull qwen2.5:1.5b
|
||||||
ollama pull bge-m3
|
ollama pull bge-m3
|
||||||
|
ollama pull qwen3-reranker:0.6b
|
||||||
wait $$SERVE_PID
|
wait $$SERVE_PID
|
||||||
networks:
|
networks:
|
||||||
- qmdsearch
|
- qmdsearch
|
||||||
|
|||||||
@@ -17,3 +17,49 @@ export function login(username, password) {
|
|||||||
export function me() {
|
export function me() {
|
||||||
return http.get('/api/v1/auth/me')
|
return http.get('/api/v1/auth/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出全部用户(admin 专用)
|
||||||
|
* @returns {Promise<Array<{username:string, role:string, enabled:boolean, must_change_password:boolean, created_at:string}>>}
|
||||||
|
*/
|
||||||
|
export function listUsers() {
|
||||||
|
return http.get('/api/v1/auth/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户(admin 专用)
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @param {'admin'|'user'} [role='user']
|
||||||
|
*/
|
||||||
|
export function createUser(username, password, role = 'user') {
|
||||||
|
return http.post('/api/v1/auth/users', { username, password, role })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户角色 / 启用状态(admin 专用,至少传一项)
|
||||||
|
* @param {string} username
|
||||||
|
* @param {{ role?: 'admin'|'user', enabled?: boolean }} payload
|
||||||
|
*/
|
||||||
|
export function updateUser(username, payload) {
|
||||||
|
return http.patch(`/api/v1/auth/users/${encodeURIComponent(username)}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置指定用户密码(admin 专用)
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} newPassword
|
||||||
|
*/
|
||||||
|
export function resetPassword(username, newPassword) {
|
||||||
|
return http.post(`/api/v1/auth/users/${encodeURIComponent(username)}/password`, {
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(admin 专用)
|
||||||
|
* @param {string} username
|
||||||
|
*/
|
||||||
|
export function deleteUser(username) {
|
||||||
|
return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
DatabaseOutlined
|
DatabaseOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
TeamOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
@@ -30,14 +32,21 @@ const selectedKeys = computed(() => {
|
|||||||
|
|
||||||
const openKeys = ref(['main'])
|
const openKeys = ref(['main'])
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = computed(() => {
|
||||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
|
const items = [
|
||||||
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
|
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
|
||||||
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
|
||||||
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
|
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
||||||
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
|
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
|
||||||
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' }
|
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
|
||||||
]
|
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
|
||||||
|
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
|
||||||
|
]
|
||||||
|
if (authStore.isAdmin) {
|
||||||
|
items.push({ key: 'users', icon: () => h(TeamOutlined), label: '用户管理' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
function handleMenuClick({ key }) {
|
function handleMenuClick({ key }) {
|
||||||
if (key && key !== route.name) {
|
if (key && key !== route.name) {
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ const routes = [
|
|||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/Settings.vue'),
|
component: () => import('@/views/Settings.vue'),
|
||||||
meta: { title: '设置', requiresAuth: true }
|
meta: { title: '设置', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'api-docs',
|
||||||
|
name: 'api-docs',
|
||||||
|
component: () => import('@/views/ApiDocs.vue'),
|
||||||
|
meta: { title: 'API 说明', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'users',
|
||||||
|
component: () => import('@/views/Users.vue'),
|
||||||
|
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -81,6 +93,10 @@ router.beforeEach((to) => {
|
|||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta?.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
return { name: 'overview' }
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'login' && authStore.isAuthenticated) {
|
if (to.name === 'login' && authStore.isAuthenticated) {
|
||||||
return { name: 'overview' }
|
return { name: 'overview' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
// 以下内容整理自项目 README.md 的「API 文档」章节
|
||||||
|
const apiList = [
|
||||||
|
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
|
||||||
|
{ method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭)', auth: false },
|
||||||
|
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false },
|
||||||
|
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true },
|
||||||
|
{ method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true },
|
||||||
|
{ method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
|
||||||
|
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart,202 异步)', auth: true },
|
||||||
|
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true },
|
||||||
|
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true },
|
||||||
|
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true },
|
||||||
|
{ method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true },
|
||||||
|
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true },
|
||||||
|
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true },
|
||||||
|
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
const methodColor = {
|
||||||
|
GET: 'green',
|
||||||
|
POST: 'blue',
|
||||||
|
DELETE: 'red',
|
||||||
|
PUT: 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
|
||||||
|
|
||||||
|
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"username": "admin", "password": "your-password"}'`
|
||||||
|
|
||||||
|
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer <token>" \\
|
||||||
|
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
|
||||||
|
|
||||||
|
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||||
|
-H "Authorization: Bearer <token>" \\
|
||||||
|
-F "file=@document.pdf"`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="apidocs page-section">
|
||||||
|
<div class="apidocs__header">
|
||||||
|
<h2 class="page-title">
|
||||||
|
<ApiOutlined /> API 使用说明
|
||||||
|
</h2>
|
||||||
|
<span class="text-muted">接口前缀:{{ baseUrl }}/api/v1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
class="apidocs__tip"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 统一响应格式 -->
|
||||||
|
<section class="apidocs__block">
|
||||||
|
<h3 class="section-subtitle">
|
||||||
|
<CodeOutlined /> 统一响应格式
|
||||||
|
</h3>
|
||||||
|
<ul class="apidocs__ul">
|
||||||
|
<li>所有接口返回统一 JSON 结构:<code>code</code> / <code>data</code> / <code>message</code>。</li>
|
||||||
|
<li>错误码:<code>0</code> 成功,<code>1xxx</code> 客户端错误,<code>2xxx</code> 服务端错误。</li>
|
||||||
|
</ul>
|
||||||
|
<pre class="code-block">{
|
||||||
|
"code": 0,
|
||||||
|
"data": { ... },
|
||||||
|
"message": "ok"
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- API 列表 -->
|
||||||
|
<section class="apidocs__block">
|
||||||
|
<h3 class="section-subtitle">接口列表</h3>
|
||||||
|
<a-table
|
||||||
|
:columns="[
|
||||||
|
{ title: '方法', dataIndex: 'method', key: 'method', width: 90, align: 'center' },
|
||||||
|
{ title: '路径', dataIndex: 'path', key: 'path', width: 320, ellipsis: true },
|
||||||
|
{ title: '说明', dataIndex: 'desc', key: 'desc' },
|
||||||
|
{ title: '认证', dataIndex: 'auth', key: 'auth', width: 80, align: 'center' }
|
||||||
|
]"
|
||||||
|
:data-source="apiList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="path"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, text, record }">
|
||||||
|
<template v-if="column.key === 'method'">
|
||||||
|
<a-tag :color="methodColor[record.method]">{{ record.method }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'path'">
|
||||||
|
<code class="api-path">{{ text }}</code>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'auth'">
|
||||||
|
<a-tag :color="record.auth ? 'volcano' : 'default'">
|
||||||
|
{{ record.auth ? '需鉴权' : '否' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 认证说明 -->
|
||||||
|
<section class="apidocs__block">
|
||||||
|
<h3 class="section-subtitle">
|
||||||
|
<SafetyCertificateOutlined /> 获取 Token
|
||||||
|
</h3>
|
||||||
|
<p class="apidocs__p">先调用登录接口获取 JWT,再在后续请求的 Header 中携带:</p>
|
||||||
|
<pre class="code-block">{{ loginExample }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 检索示例 -->
|
||||||
|
<section class="apidocs__block">
|
||||||
|
<h3 class="section-subtitle">
|
||||||
|
<CodeOutlined /> 检索示例
|
||||||
|
</h3>
|
||||||
|
<pre class="code-block">{{ searchExample }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 文件上传示例 -->
|
||||||
|
<section class="apidocs__block">
|
||||||
|
<h3 class="section-subtitle">
|
||||||
|
<CodeOutlined /> 文件上传示例
|
||||||
|
</h3>
|
||||||
|
<pre class="code-block">{{ uploadExample }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.apidocs__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title :deep(.anticon) {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apidocs__tip {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apidocs__block {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle :deep(.anticon) {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apidocs__ul {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apidocs__ul code,
|
||||||
|
.api-path {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0341d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-path {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1677ff;
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
border-color: rgba(22, 119, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apidocs__p {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { FileOutlined } from '@ant-design/icons-vue'
|
||||||
import {
|
import {
|
||||||
list as fetchDocuments,
|
list as fetchDocuments,
|
||||||
detail as fetchDocumentDetail,
|
detail as fetchDocumentDetail,
|
||||||
@@ -8,6 +9,13 @@ import {
|
|||||||
} from '@/api/documents'
|
} from '@/api/documents'
|
||||||
import { truncate } from '@/utils/format'
|
import { truncate } from '@/utils/format'
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes == null) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isLoadingDetail = ref(false)
|
const isLoadingDetail = ref(false)
|
||||||
const isDeleting = ref(false)
|
const isDeleting = ref(false)
|
||||||
@@ -204,6 +212,15 @@ onMounted(() => {
|
|||||||
<a-descriptions-item label="chunks_count">
|
<a-descriptions-item label="chunks_count">
|
||||||
{{ detailData.chunks_count ?? 0 }}
|
{{ detailData.chunks_count ?? 0 }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="原文文件" v-if="detailData.file">
|
||||||
|
<a :href="detailData.file.url" target="_blank" rel="noopener">
|
||||||
|
<FileOutlined />
|
||||||
|
{{ detailData.file.filename }}
|
||||||
|
<span class="text-muted" v-if="detailData.file.size_bytes">
|
||||||
|
({{ formatSize(detailData.file.size_bytes) }})
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
<h3 class="documents__section-title">L1 全文</h3>
|
<h3 class="documents__section-title">L1 全文</h3>
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
CheckCircleOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import {
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
resetPassword,
|
||||||
|
deleteUser
|
||||||
|
} from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentUsername = computed(() => authStore.user?.username || '')
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const users = ref([])
|
||||||
|
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const createForm = reactive({ username: '', password: '', role: 'user' })
|
||||||
|
|
||||||
|
const resetVisible = ref(false)
|
||||||
|
const resetting = ref(false)
|
||||||
|
const resetTarget = ref('')
|
||||||
|
const resetForm = reactive({ new_password: '' })
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||||
|
{ title: '角色', dataIndex: 'role', key: 'role', width: 100, align: 'center' },
|
||||||
|
{ title: '状态', key: 'enabled', width: 90, align: 'center' },
|
||||||
|
{ title: '强制改密', key: 'must_change_password', width: 90, align: 'center' },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
|
{ title: '操作', key: 'action', width: 230, fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
users.value = await listUsers()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err?.message || '加载用户列表失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (Number.isNaN(d.getTime())) return String(ts)
|
||||||
|
return d.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不能对当前登录账号做删除/禁用(后端亦会拦截)
|
||||||
|
function isSelf(username) {
|
||||||
|
return username === currentUsername.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm.username = ''
|
||||||
|
createForm.password = ''
|
||||||
|
createForm.role = 'user'
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.username.trim()) {
|
||||||
|
message.warning('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (createForm.password.length < 8) {
|
||||||
|
message.warning('密码至少 8 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
await createUser(createForm.username.trim(), createForm.password, createForm.role)
|
||||||
|
message.success(`已创建用户 ${createForm.username.trim()}`)
|
||||||
|
createVisible.value = false
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err?.message || '创建用户失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReset(username) {
|
||||||
|
resetTarget.value = username
|
||||||
|
resetForm.new_password = ''
|
||||||
|
resetVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
if (resetForm.new_password.length < 8) {
|
||||||
|
message.warning('新密码至少 8 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetting.value = true
|
||||||
|
try {
|
||||||
|
await resetPassword(resetTarget.value, resetForm.new_password)
|
||||||
|
message.success(`已重置 ${resetTarget.value} 的密码`)
|
||||||
|
resetVisible.value = false
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err?.message || '重置密码失败')
|
||||||
|
} finally {
|
||||||
|
resetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(record) {
|
||||||
|
const enabled = !record.enabled
|
||||||
|
try {
|
||||||
|
await updateUser(record.username, { enabled })
|
||||||
|
message.success(`${record.username} 已${enabled ? '启用' : '禁用'}`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err?.message || '更新状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(record) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除用户',
|
||||||
|
content: `确定删除用户「${record.username}」吗?该操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await deleteUser(record.username)
|
||||||
|
message.success(`已删除 ${record.username}`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err?.message || '删除用户失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="users page-section">
|
||||||
|
<div class="users__header">
|
||||||
|
<h2 class="page-title">
|
||||||
|
<TeamOutlined /> 用户管理
|
||||||
|
</h2>
|
||||||
|
<a-space>
|
||||||
|
<a-button :loading="isLoading" @click="loadUsers">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="openCreate">
|
||||||
|
<template #icon><UserAddOutlined /></template>
|
||||||
|
新建用户
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="users"
|
||||||
|
:pagination="false"
|
||||||
|
:loading="isLoading"
|
||||||
|
row-key="username"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'username'">
|
||||||
|
<span>{{ record.username }}</span>
|
||||||
|
<a-tag v-if="isSelf(record.username)" color="gold" style="margin-left: 6px">当前</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'role'">
|
||||||
|
<a-tag :color="record.role === 'admin' ? 'green' : 'blue'">{{ record.role }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<a-tag :color="record.enabled ? 'success' : 'default'">
|
||||||
|
{{ record.enabled ? '启用' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'must_change_password'">
|
||||||
|
<a-tag v-if="record.must_change_password" color="orange">需改密</a-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
<span class="text-muted">{{ formatTime(record.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="openReset(record.username)">
|
||||||
|
<template #icon><KeyOutlined /></template>
|
||||||
|
重置密码
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
:disabled="isSelf(record.username)"
|
||||||
|
@click="toggleEnabled(record)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<StopOutlined v-if="record.enabled" />
|
||||||
|
<CheckCircleOutlined v-else />
|
||||||
|
</template>
|
||||||
|
{{ record.enabled ? '禁用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
:disabled="isSelf(record.username)"
|
||||||
|
@click="handleDelete(record)"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 新建用户 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="createVisible"
|
||||||
|
title="新建用户"
|
||||||
|
ok-text="创建"
|
||||||
|
cancel-text="取消"
|
||||||
|
:confirm-loading="creating"
|
||||||
|
@ok="handleCreate"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="用户名" required>
|
||||||
|
<a-input v-model:value="createForm.username" placeholder="登录用户名" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="密码" required>
|
||||||
|
<a-input-password v-model:value="createForm.password" placeholder="至少 8 位" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="角色">
|
||||||
|
<a-radio-group v-model:value="createForm.role">
|
||||||
|
<a-radio value="user">普通用户 (user)</a-radio>
|
||||||
|
<a-radio value="admin">管理员 (admin)</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 重置密码 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="resetVisible"
|
||||||
|
title="重置密码"
|
||||||
|
ok-text="重置"
|
||||||
|
cancel-text="取消"
|
||||||
|
:confirm-loading="resetting"
|
||||||
|
@ok="handleReset"
|
||||||
|
>
|
||||||
|
<p class="text-muted">目标用户:{{ resetTarget }}</p>
|
||||||
|
<a-form-item label="新密码" required>
|
||||||
|
<a-input-password v-model:value="resetForm.new_password" placeholder="至少 8 位" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.users__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title :deep(.anticon) {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,11 +15,6 @@ dependencies = [
|
|||||||
"openai>=1.58.0",
|
"openai>=1.58.0",
|
||||||
"pyjwt>=2.13.0",
|
"pyjwt>=2.13.0",
|
||||||
"bcrypt>=5.0.0",
|
"bcrypt>=5.0.0",
|
||||||
"python-multipart>=0.0.20",
|
|
||||||
"pypdf>=5.1.0",
|
|
||||||
"python-docx>=1.1.2",
|
|
||||||
"pypdfium2>=4.0.0", # PDF 渲染为图片供 OCR 使用
|
|
||||||
"rapidocr-onnxruntime>=1.3.8", # 扫描件 PDF OCR 降级(首次调用时下载约 25MB 模型)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -204,11 +204,13 @@ class TestAdminClosedLoop:
|
|||||||
assert item_b["category"] == DOC_B_CATEGORY
|
assert item_b["category"] == DOC_B_CATEGORY
|
||||||
assert item_b["tags"] == []
|
assert item_b["tags"] == []
|
||||||
|
|
||||||
# 2. 详情:l1/l2_nodes/l3_nodes/chunks_count 与写入一致
|
# 2. 详情:l1/l2_nodes/l3_nodes/chunks_count/file 与写入一致
|
||||||
resp = client.get(f"/api/v1/documents/{DOC_A}")
|
resp = client.get(f"/api/v1/documents/{DOC_A}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()["data"]
|
data = resp.json()["data"]
|
||||||
assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count"}
|
assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count", "file"}
|
||||||
|
# 文本入库无 metadata → file=None
|
||||||
|
assert data["file"] is None
|
||||||
l1 = data["l1"]
|
l1 = data["l1"]
|
||||||
assert l1["doc_id"] == DOC_A
|
assert l1["doc_id"] == DOC_A
|
||||||
assert l1["title"] == DOC_A_TITLE
|
assert l1["title"] == DOC_A_TITLE
|
||||||
|
|||||||
+179
-9
@@ -5,11 +5,11 @@
|
|||||||
2. 五区块可识别标记(文案与 section id)
|
2. 五区块可识别标记(文案与 section id)
|
||||||
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
|
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
|
||||||
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*)
|
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*)
|
||||||
5. 删除操作的 confirm() 二次确认逻辑
|
5. 删除/重置操作的通用 overlay 弹窗组件(替换原生 prompt/confirm)
|
||||||
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
|
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
|
||||||
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
|
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
|
||||||
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
|
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
|
||||||
9. 用户管理区块:列表/创建表单/角色下拉/重置/删除 confirm、admin 角色门禁
|
9. 用户管理区块:列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗、admin 角色门禁
|
||||||
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
|
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
|
||||||
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
|
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
|
||||||
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
|
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
|
||||||
@@ -140,9 +140,10 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
|
|||||||
assert "disabled" in admin_html
|
assert "disabled" in admin_html
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_has_confirm(admin_html: str) -> None:
|
def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None:
|
||||||
"""删除操作包含 confirm() 二次确认逻辑"""
|
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
|
||||||
assert "confirm(" in admin_html
|
assert "prompt(" not in admin_html
|
||||||
|
assert "confirm(" not in admin_html
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_login_card(admin_html: str) -> None:
|
def test_admin_page_login_card(admin_html: str) -> None:
|
||||||
@@ -193,8 +194,52 @@ def test_admin_page_password_form(admin_html: str) -> None:
|
|||||||
assert "passwordForced" in admin_html
|
assert "passwordForced" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_profile_section(admin_html: str) -> None:
|
||||||
|
"""个人中心区块:导航按钮、section、账号信息卡片、改密表单、退出按钮、
|
||||||
|
user 角色默认进入、GET /auth/me 与 POST /auth/password 路径"""
|
||||||
|
# section 与导航按钮(不限 admin,所有登录用户可见)
|
||||||
|
assert 'id="section-profile"' in admin_html
|
||||||
|
assert 'id="nav-profile"' in admin_html
|
||||||
|
assert 'data-target="section-profile"' in admin_html
|
||||||
|
assert "个人中心" in admin_html
|
||||||
|
# 账号信息卡片字段:用户名 / 角色 / 创建时间 / 须改密 / 启用状态
|
||||||
|
assert 'id="profile-cards"' in admin_html
|
||||||
|
for label in ("用户名", "角色", "创建时间", "须改密", "启用状态"):
|
||||||
|
assert label in admin_html
|
||||||
|
# 改密表单:旧密码 / 新密码 / 确认 / 强度提示 / 提交按钮
|
||||||
|
assert 'id="profile-password-form"' in admin_html
|
||||||
|
assert 'id="profile-old-password"' in admin_html
|
||||||
|
assert 'id="profile-new-password"' in admin_html
|
||||||
|
assert 'id="profile-confirm-password"' in admin_html
|
||||||
|
assert 'id="profile-strength-hint"' in admin_html
|
||||||
|
assert 'id="btn-profile-password-submit"' in admin_html
|
||||||
|
# 强度提示复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强)
|
||||||
|
assert "密码强度:" in admin_html
|
||||||
|
for level in ("弱", "中", "强"):
|
||||||
|
assert level in admin_html
|
||||||
|
# 两次不一致本地拦截
|
||||||
|
assert "两次输入的新密码不一致" in admin_html
|
||||||
|
# 提交按钮 loading 态
|
||||||
|
assert "提交中…" in admin_html
|
||||||
|
# 退出登录按钮(复用现有 logout 逻辑)
|
||||||
|
assert 'id="btn-profile-logout"' in admin_html
|
||||||
|
assert "退出登录" in admin_html
|
||||||
|
assert "doLogout" in admin_html
|
||||||
|
# 数据来源:GET /auth/me 加载账号信息;POST /auth/password 改密
|
||||||
|
assert "/api/v1/auth/me" in admin_html
|
||||||
|
assert "/api/v1/auth/password" in admin_html
|
||||||
|
# 个人中心加载与渲染函数
|
||||||
|
assert "loadProfile" in admin_html
|
||||||
|
assert "renderProfile" in admin_html
|
||||||
|
# user 角色默认进个人中心(admin 仍默认进概览)
|
||||||
|
assert 'activateSection("section-profile")' in admin_html
|
||||||
|
assert 'activateSection("section-overview")' in admin_html
|
||||||
|
# activateSection 触发 loadProfile
|
||||||
|
assert 'targetId === "section-profile"' in admin_html
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_users_section(admin_html: str) -> None:
|
def test_admin_page_users_section(admin_html: str) -> None:
|
||||||
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码/删除 confirm/角色门禁"""
|
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗/角色门禁"""
|
||||||
assert 'id="section-users"' in admin_html
|
assert 'id="section-users"' in admin_html
|
||||||
assert 'id="users-tbody"' in admin_html
|
assert 'id="users-tbody"' in admin_html
|
||||||
# 表头列:用户名/角色/须改密/创建时间/操作
|
# 表头列:用户名/角色/须改密/创建时间/操作
|
||||||
@@ -208,10 +253,10 @@ def test_admin_page_users_section(admin_html: str) -> None:
|
|||||||
assert '<option value="admin"' in admin_html
|
assert '<option value="admin"' in admin_html
|
||||||
assert '<option value="user"' in admin_html
|
assert '<option value="user"' in admin_html
|
||||||
assert "创建用户" in admin_html
|
assert "创建用户" in admin_html
|
||||||
# 操作列:重置密码(弹输入)与删除(confirm 二次确认)
|
# 操作列:重置密码 + 删除均走通用弹窗组件(无 prompt/confirm 调用)
|
||||||
assert "重置密码" in admin_html
|
assert "重置密码" in admin_html
|
||||||
assert "prompt(" in admin_html
|
assert "resetUserPassword" in admin_html
|
||||||
assert "确定删除用户" in admin_html
|
assert "deleteUser" in admin_html
|
||||||
# 用户管理端点
|
# 用户管理端点
|
||||||
assert "/api/v1/auth/users" in admin_html
|
assert "/api/v1/auth/users" in admin_html
|
||||||
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
|
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
|
||||||
@@ -220,6 +265,115 @@ def test_admin_page_users_section(admin_html: str) -> None:
|
|||||||
assert "unmountUsersSection" in admin_html
|
assert "unmountUsersSection" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_users_filter_and_inline_edit(admin_html: str) -> None:
|
||||||
|
"""用户管理:列表搜索筛选 + 行内角色编辑 + 启用开关 + 当前 admin 自身行防降级"""
|
||||||
|
# 列表搜索筛选:用户名搜索框 + 角色筛选下拉
|
||||||
|
assert 'id="user-search"' in admin_html
|
||||||
|
assert 'id="user-role-filter"' in admin_html
|
||||||
|
assert '<option value="all"' in admin_html
|
||||||
|
# 输入/变更事件触发本地过滤(不重新请求后端)
|
||||||
|
assert 'addEventListener("input", applyUsersFilter)' in admin_html
|
||||||
|
assert 'addEventListener("change", applyUsersFilter)' in admin_html
|
||||||
|
assert "applyUsersFilter" in admin_html
|
||||||
|
# 表头加「启用」列
|
||||||
|
assert "启用" in admin_html
|
||||||
|
# 行内角色编辑:select + 保存按钮(仅当值改变时启用)
|
||||||
|
assert "user-role-select" in admin_html
|
||||||
|
assert "user-role-save" in admin_html
|
||||||
|
assert "updateUserRole" in admin_html
|
||||||
|
# 行内启用/禁用开关:按钮文字 + 样式区分
|
||||||
|
assert "user-enabled-toggle" in admin_html
|
||||||
|
assert "toggleUserEnabled" in admin_html
|
||||||
|
# PATCH /auth/users/{username} 端点调用(role 与 enabled 两条分支)
|
||||||
|
assert 'method: "PATCH"' in admin_html
|
||||||
|
assert "encodeURIComponent(username)" in admin_html
|
||||||
|
# 当前登录 admin 自身行防自锁降级:role select disabled + 启用开关 disabled
|
||||||
|
assert "isSelf" in admin_html
|
||||||
|
assert "不能禁用当前登录账号" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_modal_component(admin_html: str) -> None:
|
||||||
|
"""通用 overlay 弹窗组件:modal-overlay/modal-card 结构 + openModal/closeModal API"""
|
||||||
|
# CSS 类
|
||||||
|
assert "modal-overlay" in admin_html
|
||||||
|
assert "modal-card" in admin_html
|
||||||
|
assert "modal-title" in admin_html
|
||||||
|
assert "modal-content" in admin_html
|
||||||
|
assert "modal-actions" in admin_html
|
||||||
|
# 挂载点容器
|
||||||
|
assert 'id="modal-root"' in admin_html
|
||||||
|
# 通用 JS API
|
||||||
|
assert "function openModal(" in admin_html
|
||||||
|
assert "function closeModal(" in admin_html
|
||||||
|
assert "function isModalSubmitting(" in admin_html
|
||||||
|
assert "function setModalSubmitting(" in admin_html
|
||||||
|
assert "function showModelError(" in admin_html
|
||||||
|
assert "function hideModelError(" in admin_html
|
||||||
|
# 提交中标记属性
|
||||||
|
assert "data-submitting" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_modal_cancel_and_loading(admin_html: str) -> None:
|
||||||
|
"""弹窗可取消(点遮罩/取消按钮/ESC)+ 提交中不可取消 + loading 态(提交中禁用)"""
|
||||||
|
# 点遮罩关闭:e.target === overlay
|
||||||
|
assert "e.target === overlay" in admin_html
|
||||||
|
# 取消按钮存在
|
||||||
|
assert "取消" in admin_html
|
||||||
|
# ESC 键关闭最顶层弹窗
|
||||||
|
assert "Escape" in admin_html
|
||||||
|
# 提交中不可关闭:遮罩点击与 ESC 均检查 data-submitting !== "true"
|
||||||
|
assert 'data-submitting") !== "true"' in admin_html
|
||||||
|
assert 'data-submitting") === "true"' in admin_html
|
||||||
|
# 提交 loading 文案 + 按钮禁用
|
||||||
|
assert "提交中…" in admin_html
|
||||||
|
assert 'submitBtn.disabled = true' in admin_html
|
||||||
|
# isModalSubmitting 守卫:取消按钮在提交中不响应
|
||||||
|
assert "isModalSubmitting(" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_reset_password_modal(admin_html: str) -> None:
|
||||||
|
"""重置密码弹窗:新密码/确认/强度提示/两次不一致本地拦截/loading 态"""
|
||||||
|
# 弹窗标题含用户名占位
|
||||||
|
assert "重置用户" in admin_html
|
||||||
|
assert "密码" in admin_html
|
||||||
|
# 新密码 + 确认密码输入框(动态创建,id 经 JS 属性赋值)
|
||||||
|
assert 'newInput.id = "modal-reset-new"' in admin_html
|
||||||
|
assert 'confirmInput.id = "modal-reset-confirm"' in admin_html
|
||||||
|
# 密码强度提示节点
|
||||||
|
assert 'hint.id = "modal-reset-strength"' in admin_html
|
||||||
|
assert "modal-strength-hint" in admin_html
|
||||||
|
# 强度三档纯文案:<8 弱 / ≥8 中 / ≥12 强
|
||||||
|
assert "密码强度:" in admin_html
|
||||||
|
for level in ("弱", "中", "强"):
|
||||||
|
assert level in admin_html
|
||||||
|
# 两次不一致本地拦截
|
||||||
|
assert "两次输入的新密码不一致" in admin_html
|
||||||
|
# 重置密码端点
|
||||||
|
assert "/api/v1/auth/users/" in admin_html
|
||||||
|
assert "/password" in admin_html
|
||||||
|
# 提交按钮 loading 态
|
||||||
|
assert "setModalSubmitting" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_delete_user_modal(admin_html: str) -> None:
|
||||||
|
"""删除确认弹窗:警告文案 + 输入用户名匹配才可提交 + loading 态"""
|
||||||
|
# 弹窗标题
|
||||||
|
assert "删除用户" in admin_html
|
||||||
|
# 警告文案
|
||||||
|
assert "此操作不可恢复,将清除该用户及其全部会话" in admin_html
|
||||||
|
# 「请输入用户名 {username} 以确认」+ 文本输入框(动态创建,id 经 JS 属性赋值)
|
||||||
|
assert "请输入用户名" in admin_html
|
||||||
|
assert "以确认" in admin_html
|
||||||
|
assert 'input.id = "modal-delete-input"' in admin_html
|
||||||
|
# 输入 !== username 时删除按钮禁用;匹配后启用
|
||||||
|
assert "submitBtn.disabled = true" in admin_html
|
||||||
|
assert "input.value !== username" in admin_html
|
||||||
|
# 删除端点
|
||||||
|
assert 'method: "DELETE"' in admin_html
|
||||||
|
# 提交按钮 loading 态
|
||||||
|
assert "setModalSubmitting" in admin_html
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_auth_interceptor(admin_html: str) -> None:
|
def test_admin_page_auth_interceptor(admin_html: str) -> None:
|
||||||
"""请求拦截:统一注入 Authorization Bearer;1005 回登录;1006 错误条提示"""
|
"""请求拦截:统一注入 Authorization Bearer;1005 回登录;1006 错误条提示"""
|
||||||
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
|
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
|
||||||
@@ -231,6 +385,22 @@ def test_admin_page_auth_interceptor(admin_html: str) -> None:
|
|||||||
assert "1006" in admin_html
|
assert "1006" in admin_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_doc_detail_file_link(admin_html: str) -> None:
|
||||||
|
"""文档详情区:有关联文件时展示原始文件行与下载链接(textContent 防 XSS)"""
|
||||||
|
# 条件判断与字段访问
|
||||||
|
assert "data.file" in admin_html
|
||||||
|
assert "file.filename" in admin_html
|
||||||
|
assert "file.size_bytes" in admin_html
|
||||||
|
# 大小格式化辅助
|
||||||
|
assert "formatFileSize" in admin_html
|
||||||
|
# 下载链接用 el("a") 创建,设 href 与 download 属性,不 innerHTML
|
||||||
|
assert 'el("a"' in admin_html
|
||||||
|
assert "download" in admin_html
|
||||||
|
# 行标签文案
|
||||||
|
assert "原始文件" in admin_html
|
||||||
|
assert "下载" in admin_html
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_api_guide_section(admin_html: str) -> None:
|
def test_admin_page_api_guide_section(admin_html: str) -> None:
|
||||||
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
|
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
|
||||||
assert 'data-target="section-api-guide"' in admin_html
|
assert 'data-target="section-api-guide"' in admin_html
|
||||||
|
|||||||
+168
-2
@@ -245,8 +245,9 @@ class TestUsersCrud:
|
|||||||
users = body["data"]
|
users = body["data"]
|
||||||
assert len(users) == 1
|
assert len(users) == 1
|
||||||
admin = users[0]
|
admin = users[0]
|
||||||
assert set(admin.keys()) == {"username", "role", "must_change_password", "created_at"}
|
assert set(admin.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
|
||||||
assert admin["username"] == "admin"
|
assert admin["username"] == "admin"
|
||||||
|
assert admin["enabled"] is True
|
||||||
assert "password_hash" not in admin
|
assert "password_hash" not in admin
|
||||||
assert "salt" not in admin
|
assert "salt" not in admin
|
||||||
|
|
||||||
@@ -271,10 +272,11 @@ class TestUsersCrud:
|
|||||||
|
|
||||||
assert body["code"] == 0
|
assert body["code"] == 0
|
||||||
data = body["data"]
|
data = body["data"]
|
||||||
assert set(data.keys()) == {"username", "role", "must_change_password", "created_at"}
|
assert set(data.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
|
||||||
assert data["username"] == "carol"
|
assert data["username"] == "carol"
|
||||||
assert data["role"] == "user"
|
assert data["role"] == "user"
|
||||||
assert data["must_change_password"] is False
|
assert data["must_change_password"] is False
|
||||||
|
assert data["enabled"] is True
|
||||||
# 新用户可登录
|
# 新用户可登录
|
||||||
assert _login(client, "carol", "carol-pass-123")["code"] == 0
|
assert _login(client, "carol", "carol-pass-123")["code"] == 0
|
||||||
|
|
||||||
@@ -386,6 +388,170 @@ class TestUsersCrud:
|
|||||||
assert body["code"] == 1001
|
assert body["code"] == 1001
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateUser:
|
||||||
|
"""PATCH /api/v1/auth/users/{username}:更新角色/启用状态"""
|
||||||
|
|
||||||
|
def test_update_role_success(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/users",
|
||||||
|
json={"username": "carol", "password": "carol-pass-123"},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/carol", json={"role": "admin"}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 0
|
||||||
|
assert body["data"]["role"] == "admin"
|
||||||
|
assert body["data"]["enabled"] is True
|
||||||
|
|
||||||
|
def test_update_enabled_success(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
user_store, _ = auth_stores
|
||||||
|
_create_user(user_store, "dave", "dave-pass-123", role="user")
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/dave", json={"enabled": False}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 0
|
||||||
|
assert body["data"]["enabled"] is False
|
||||||
|
assert body["data"]["role"] == "user"
|
||||||
|
|
||||||
|
def test_update_both_role_and_enabled(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
user_store, _ = auth_stores
|
||||||
|
_create_user(user_store, "erin", "erin-pass-123", role="user")
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/erin",
|
||||||
|
json={"role": "admin", "enabled": False},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 0
|
||||||
|
assert body["data"]["role"] == "admin"
|
||||||
|
assert body["data"]["enabled"] is False
|
||||||
|
|
||||||
|
def test_at_least_one_field_required(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/admin", json={}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1001
|
||||||
|
assert body["data"] is None
|
||||||
|
|
||||||
|
def test_user_not_found(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/ghost", json={"role": "user"}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1004
|
||||||
|
assert body["data"] is None
|
||||||
|
|
||||||
|
def test_invalid_role(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/admin", json={"role": "superuser"}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1001
|
||||||
|
|
||||||
|
def test_last_admin_demote_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
# 唯一 admin 降级为 user → 1001
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/admin", json={"role": "user"}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1001
|
||||||
|
# 未生效
|
||||||
|
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
|
||||||
|
|
||||||
|
def test_last_admin_disable_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
|
||||||
|
# 唯一 admin 禁用 → 1001
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/admin", json={"enabled": False}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1001
|
||||||
|
|
||||||
|
def test_non_admin_forbidden(self, client: TestClient, auth_stores) -> None:
|
||||||
|
user_store, session_store = auth_stores
|
||||||
|
_create_user(user_store, "bob", "bob-pass-123", role="user")
|
||||||
|
headers = _headers(session_store, "bob", "user")
|
||||||
|
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/bob", json={"role": "admin"}, headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1006
|
||||||
|
|
||||||
|
def test_demote_when_multiple_admins_ok(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
# 存在第二个 admin 时,可降级其中一个
|
||||||
|
user_store, _ = auth_stores
|
||||||
|
_create_user(user_store, "admin2", "admin2-pass-123", role="admin")
|
||||||
|
body = client.patch(
|
||||||
|
"/api/v1/auth/users/admin2", json={"role": "user"}, headers=admin_headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["code"] == 0
|
||||||
|
assert body["data"]["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisabledUser:
|
||||||
|
"""禁用用户:登录拦截 + 旧 token 失效 + 业务端点拦截"""
|
||||||
|
|
||||||
|
def test_disabled_user_login_blocked(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
user_store, _ = auth_stores
|
||||||
|
_create_user(user_store, "bob", "bob-pass-123", role="user")
|
||||||
|
# 禁用 bob
|
||||||
|
client.patch(
|
||||||
|
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
|
||||||
|
)
|
||||||
|
# 登录 → 1005 账号已禁用
|
||||||
|
body = _login(client, "bob", "bob-pass-123")
|
||||||
|
|
||||||
|
assert body["code"] == 1005
|
||||||
|
assert body["data"] is None
|
||||||
|
|
||||||
|
def test_disabled_user_old_token_invalidated(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
user_store, _ = auth_stores
|
||||||
|
_create_user(user_store, "bob", "bob-pass-123", role="user")
|
||||||
|
# bob 登录拿到 token
|
||||||
|
login_body = _login(client, "bob", "bob-pass-123")
|
||||||
|
assert login_body["code"] == 0
|
||||||
|
bob_headers = {"Authorization": f"Bearer {login_body['data']['token']}"}
|
||||||
|
# 禁用 bob → session 清除
|
||||||
|
client.patch(
|
||||||
|
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
|
||||||
|
)
|
||||||
|
# 旧 token 调业务端点 → 1005(session 已清)
|
||||||
|
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1005
|
||||||
|
|
||||||
|
def test_disabled_user_blocked_even_with_valid_session(
|
||||||
|
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
"""禁用用户即使持有效 session(直接签发绕过登录),业务端点仍拦截 1005"""
|
||||||
|
user_store, session_store = auth_stores
|
||||||
|
_create_user(user_store, "bob", "bob-pass-123", role="user")
|
||||||
|
# 禁用 bob → session 清除
|
||||||
|
client.patch(
|
||||||
|
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
|
||||||
|
)
|
||||||
|
# 直接为 bob 签发新 session 绕过登录与清理,验证 get_current_user 的 enabled 拦截
|
||||||
|
bob_headers = _headers(session_store, "bob", "user")
|
||||||
|
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
|
||||||
|
|
||||||
|
assert body["code"] == 1005
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentAuth:
|
class TestDocumentAuth:
|
||||||
"""文档端点鉴权:变更类需登录,GET 系列免登录"""
|
"""文档端点鉴权:变更类需登录,GET 系列免登录"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""GET /api/v1/documents/{doc_id}/file 文件下载端点测试(TestClient + FakeQdrant,不真实联网)
|
||||||
|
|
||||||
|
覆盖分支:成功下载、文档不存在、无关联文件、文件已从磁盘删除、路径越界、免登录访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import document as document_module
|
||||||
|
from app.main import app
|
||||||
|
from app.services.qdrant import QdrantService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQdrant:
|
||||||
|
"""假 QdrantService:仅实现 get_l1_metadata,按 doc_id 返回固定 metadata 或 None"""
|
||||||
|
|
||||||
|
def __init__(self, meta: dict[str, str] | None = None) -> None:
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||||||
|
return self.meta
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[TestClient]:
|
||||||
|
"""TestClient,Qdrant 集合初始化空操作;upload_dir 指向临时目录
|
||||||
|
|
||||||
|
不挂 admin_headers:下载端点免登录,验证无 token 可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||||
|
monkeypatch.setattr(document_module.settings, "upload_dir", str(tmp_path / "uploads"))
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None:
|
||||||
|
"""将 _get_qdrant 单例替换为假服务"""
|
||||||
|
monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_success(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""文档存在且有关联文件 → 200 + 文件内容正确 + content-disposition 含文件名"""
|
||||||
|
upload_dir = tmp_path / "uploads"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = upload_dir / "doc-1_notes.txt"
|
||||||
|
content = b"hello world file content"
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
fake = FakeQdrant(
|
||||||
|
meta={
|
||||||
|
"raw_file_path": str(target),
|
||||||
|
"original_filename": "notes.txt",
|
||||||
|
"original_size_bytes": str(len(content)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == content
|
||||||
|
disposition = resp.headers.get("content-disposition", "")
|
||||||
|
assert "attachment" in disposition
|
||||||
|
assert "notes.txt" in disposition
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_doc_not_found(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""文档不存在(metadata 为 None)→ 1004"""
|
||||||
|
fake = FakeQdrant(meta=None)
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/missing/file")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["code"] == 1004
|
||||||
|
assert "文件" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_no_associated_file(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""文档存在但 raw_file_path 为空 → 1004"""
|
||||||
|
fake = FakeQdrant(meta={"raw_file_path": "", "original_filename": "x.txt"})
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["code"] == 1004
|
||||||
|
assert "未关联文件" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_missing_on_disk(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""文件已从磁盘删除(path.is_file() False)→ 1004"""
|
||||||
|
missing = tmp_path / "uploads" / "gone.txt"
|
||||||
|
fake = FakeQdrant(meta={"raw_file_path": str(missing), "original_filename": "gone.txt"})
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["code"] == 1004
|
||||||
|
assert "文件不存在" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_path_traversal_rejected(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""raw_file_path 指向 upload_dir 之外 → 1004(路径越界校验)"""
|
||||||
|
outside = tmp_path / "secret.txt"
|
||||||
|
outside.write_bytes(b"secret")
|
||||||
|
|
||||||
|
fake = FakeQdrant(meta={"raw_file_path": str(outside), "original_filename": "secret.txt"})
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["code"] == 1004
|
||||||
|
assert "文件不存在" in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_no_auth_required(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""免登录:不携带 Authorization 头可正常下载(端点不挂鉴权依赖)"""
|
||||||
|
upload_dir = tmp_path / "uploads"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = upload_dir / "doc-1_open.txt"
|
||||||
|
target.write_bytes(b"open content")
|
||||||
|
|
||||||
|
fake = FakeQdrant(meta={"raw_file_path": str(target), "original_filename": "open.txt"})
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == b"open content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_file_uses_original_filename_when_missing(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""metadata 缺少 original_filename 时回退到 path.name 作为下载文件名"""
|
||||||
|
upload_dir = tmp_path / "uploads"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = upload_dir / "fallback_name.txt"
|
||||||
|
target.write_bytes(b"fb")
|
||||||
|
|
||||||
|
fake = FakeQdrant(meta={"raw_file_path": str(target)})
|
||||||
|
_install_fake(monkeypatch, fake)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/documents/doc-1/file")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "fallback_name.txt" in resp.headers.get("content-disposition", "")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""文件链接集成验证
|
||||||
|
|
||||||
|
在真实内存 Qdrant + FakeOllama + 真实临时 upload_dir 环境下,验证文件上传到下载的全链路闭环:
|
||||||
|
POST /documents/upload → 入库 wait_done → GET /documents/{id}(file 字段)→ GET /documents/{id}/file;
|
||||||
|
并覆盖纯文本入库无文件关联的对照路径(详情 file=None、下载端点 1004)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from qdrant_client import AsyncQdrantClient
|
||||||
|
|
||||||
|
from app.api.v1 import document as document_module
|
||||||
|
from app.config import Settings
|
||||||
|
from app.core.chunker import Chunker
|
||||||
|
from app.core.classifier import Classifier
|
||||||
|
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
|
||||||
|
from app.core.ingestion import Ingester
|
||||||
|
from app.core.sparse import SparseEncoder
|
||||||
|
from app.core.summarizer import Summarizer
|
||||||
|
from app.main import app
|
||||||
|
from app.models.knowledge import load_taxonomy
|
||||||
|
from app.services.qdrant import QdrantService
|
||||||
|
from tests.test_e2e_integration import (
|
||||||
|
FAKE_CATEGORY,
|
||||||
|
DeterministicEmbedding,
|
||||||
|
FakeOllama,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_env() -> tuple[QdrantService, Ingester]:
|
||||||
|
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
|
||||||
|
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||||
|
await qdrant.ensure_collections()
|
||||||
|
ollama = FakeOllama()
|
||||||
|
taxonomy = load_taxonomy()
|
||||||
|
embedding = DeterministicEmbedding()
|
||||||
|
ingester = Ingester(
|
||||||
|
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
|
||||||
|
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
|
||||||
|
chunker=Chunker(),
|
||||||
|
embedding=embedding,
|
||||||
|
sparse=SparseEncoder(),
|
||||||
|
qdrant=qdrant,
|
||||||
|
)
|
||||||
|
return qdrant, ingester
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_app(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
manager: IngestTaskManager,
|
||||||
|
qdrant: QdrantService,
|
||||||
|
upload_dir: str,
|
||||||
|
) -> None:
|
||||||
|
"""替换模块级单例:任务管理器 / Qdrant / upload_dir,lifespan 建集合改空操作"""
|
||||||
|
|
||||||
|
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||||
|
monkeypatch.setattr(document_module, "_task_manager", manager)
|
||||||
|
monkeypatch.setattr(document_module, "_qdrant", qdrant)
|
||||||
|
monkeypatch.setattr(document_module.settings, "upload_dir", upload_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentFileIntegration:
|
||||||
|
"""文件链接集成:上传闭环 + 文本入库无文件对照"""
|
||||||
|
|
||||||
|
async def test_upload_full_loop(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
admin_headers: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""文件上传闭环:upload → wait_done done → 详情含 file → 下载返回原文"""
|
||||||
|
qdrant, ingester = await _make_env()
|
||||||
|
manager = IngestTaskManager(ingester, None, Settings())
|
||||||
|
_patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads"))
|
||||||
|
|
||||||
|
filename = "上传测试.md"
|
||||||
|
original_content = "上传原文测试内容"
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# 1. multipart 上传 .md 文件(变更类端点需 admin 认证头)
|
||||||
|
resp_upload = client.post(
|
||||||
|
"/api/v1/documents/upload",
|
||||||
|
files={"file": (filename, original_content.encode("utf-8"), "text/markdown")},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert resp_upload.status_code == 202
|
||||||
|
body_upload = resp_upload.json()
|
||||||
|
assert body_upload["code"] == 0
|
||||||
|
assert body_upload["data"]["status"] == "pending"
|
||||||
|
task_id = body_upload["data"]["task_id"]
|
||||||
|
assert task_id
|
||||||
|
|
||||||
|
# 2. 等待入库终态:done + 结果完整
|
||||||
|
final = await manager.wait_done(task_id)
|
||||||
|
assert final["status"] == IngestTaskStatus.DONE
|
||||||
|
result = final["result"]
|
||||||
|
document_id = result["document_id"]
|
||||||
|
assert document_id
|
||||||
|
assert result["category"] == FAKE_CATEGORY
|
||||||
|
|
||||||
|
# 3. 文档详情:file 字段非空,含原文件名 / url / size
|
||||||
|
resp_detail = client.get(f"/api/v1/documents/{document_id}")
|
||||||
|
body_detail = resp_detail.json()
|
||||||
|
assert body_detail["code"] == 0
|
||||||
|
file_info = body_detail["data"]["file"]
|
||||||
|
assert file_info is not None
|
||||||
|
assert filename in file_info["filename"]
|
||||||
|
assert file_info["url"] == f"/api/v1/documents/{document_id}/file"
|
||||||
|
assert file_info["size_bytes"] > 0
|
||||||
|
|
||||||
|
# 4. 下载:200 + 响应体含上传原文 + Content-Disposition 含原文件名(中文按 RFC 5987 百分号编码,需 unquote)
|
||||||
|
resp_file = client.get(f"/api/v1/documents/{document_id}/file")
|
||||||
|
assert resp_file.status_code == 200
|
||||||
|
assert original_content in resp_file.content.decode("utf-8")
|
||||||
|
disposition = resp_file.headers.get("content-disposition", "")
|
||||||
|
assert "attachment" in disposition
|
||||||
|
assert filename in unquote(disposition)
|
||||||
|
|
||||||
|
async def test_text_ingest_no_file(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
admin_headers: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""纯文本入库(无 metadata):详情 file=None,下载端点返回 1004"""
|
||||||
|
qdrant, ingester = await _make_env()
|
||||||
|
manager = IngestTaskManager(ingester, None, Settings())
|
||||||
|
_patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads"))
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# 1. 纯文本入库(无 metadata)
|
||||||
|
resp_post = client.post(
|
||||||
|
"/api/v1/documents",
|
||||||
|
json={"text": "纯文本入库无附件的对照内容", "title": "无文件文档"},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert resp_post.status_code == 202
|
||||||
|
body_post = resp_post.json()
|
||||||
|
assert body_post["code"] == 0
|
||||||
|
task_id = body_post["data"]["task_id"]
|
||||||
|
|
||||||
|
# 2. 等待终态 done
|
||||||
|
final = await manager.wait_done(task_id)
|
||||||
|
assert final["status"] == IngestTaskStatus.DONE
|
||||||
|
document_id = final["result"]["document_id"]
|
||||||
|
|
||||||
|
# 3. 详情:file 为 None
|
||||||
|
resp_detail = client.get(f"/api/v1/documents/{document_id}")
|
||||||
|
body_detail = resp_detail.json()
|
||||||
|
assert body_detail["code"] == 0
|
||||||
|
assert body_detail["data"]["file"] is None
|
||||||
|
|
||||||
|
# 4. 下载端点:文档未关联文件 → 1004
|
||||||
|
resp_file = client.get(f"/api/v1/documents/{document_id}/file")
|
||||||
|
body_file = resp_file.json()
|
||||||
|
assert body_file["code"] == 1004
|
||||||
+80
-4
@@ -3,13 +3,27 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from qdrant_client import AsyncQdrantClient
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.core.chunker import Chunker
|
from app.core.chunker import Chunker
|
||||||
from app.core.ingestion import Ingester, IngestionError
|
from app.core.ingestion import Ingester, IngestionError
|
||||||
from app.core.sparse import SparseEncoder
|
from app.core.sparse import SparseEncoder
|
||||||
from app.models.document import DocumentInput, DocumentSummary, SummaryLevel
|
from app.models.document import DocumentInput, DocumentSummary, SummaryLevel
|
||||||
from app.models.knowledge import CategoryResult
|
from app.models.knowledge import CategoryResult
|
||||||
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3
|
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService
|
||||||
|
|
||||||
|
_DIM = settings.embedding_dimension
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_vec(index: int, dim: int) -> list[float]:
|
||||||
|
"""构造确定性伪向量:第 0 维放 index 标识,其余补 0,长度对齐 dim"""
|
||||||
|
vec = [0.0] * dim
|
||||||
|
if dim > 0:
|
||||||
|
vec[0] = float(index)
|
||||||
|
if dim > 1:
|
||||||
|
vec[1] = 1.0
|
||||||
|
return vec
|
||||||
|
|
||||||
|
|
||||||
class FakeSummarizer:
|
class FakeSummarizer:
|
||||||
@@ -37,14 +51,18 @@ class FakeClassifier:
|
|||||||
|
|
||||||
|
|
||||||
class FakeEmbedding:
|
class FakeEmbedding:
|
||||||
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本"""
|
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本
|
||||||
|
|
||||||
def __init__(self) -> None:
|
dim 默认 2(FakeQdrant 不校验维度);接真实 Qdrant 时需传 settings.embedding_dimension。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dim: int = 2) -> None:
|
||||||
|
self.dim = dim
|
||||||
self.calls: list[list[str]] = []
|
self.calls: list[list[str]] = []
|
||||||
|
|
||||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
self.calls.append(list(texts))
|
self.calls.append(list(texts))
|
||||||
return [[float(i), 1.0] for i in range(len(texts))]
|
return [_fake_vec(i, self.dim) for i in range(len(texts))]
|
||||||
|
|
||||||
|
|
||||||
class FakeQdrant:
|
class FakeQdrant:
|
||||||
@@ -65,6 +83,7 @@ class FakeQdrant:
|
|||||||
tags: list[str],
|
tags: list[str],
|
||||||
dense_vector: list[float],
|
dense_vector: list[float],
|
||||||
sparse_vector: Any = None,
|
sparse_vector: Any = None,
|
||||||
|
metadata: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.fail_on == "l1":
|
if self.fail_on == "l1":
|
||||||
raise RuntimeError("qdrant down")
|
raise RuntimeError("qdrant down")
|
||||||
@@ -77,6 +96,7 @@ class FakeQdrant:
|
|||||||
"tags": tags,
|
"tags": tags,
|
||||||
"dense_vector": dense_vector,
|
"dense_vector": dense_vector,
|
||||||
"sparse_vector": sparse_vector,
|
"sparse_vector": sparse_vector,
|
||||||
|
"metadata": metadata,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,3 +291,59 @@ class TestQdrantFailure:
|
|||||||
assert len(qdrant.l1_calls) == 1
|
assert len(qdrant.l1_calls) == 1
|
||||||
assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3}
|
assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3}
|
||||||
assert qdrant.chunks_calls == []
|
assert qdrant.chunks_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataIntegration:
|
||||||
|
"""L1 metadata 端到端:真实内存 Qdrant + 假总结/分类/向量化,验证 metadata 透传与 get_doc_detail.file"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def real_service(self) -> QdrantService:
|
||||||
|
svc = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||||
|
await svc.ensure_collections()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
def _make_real_ingester(self, service: QdrantService, summary: DocumentSummary) -> Ingester:
|
||||||
|
return Ingester(
|
||||||
|
summarizer=FakeSummarizer(summary), # type: ignore[arg-type]
|
||||||
|
classifier=FakeClassifier(_category()), # type: ignore[arg-type]
|
||||||
|
chunker=Chunker(),
|
||||||
|
embedding=FakeEmbedding(dim=_DIM), # type: ignore[arg-type]
|
||||||
|
sparse=SparseEncoder(),
|
||||||
|
qdrant=service,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_ingest_with_metadata_populates_file_field(self, real_service: QdrantService) -> None:
|
||||||
|
"""DocumentInput 带 metadata 入库后,get_doc_detail.file 含正确信息"""
|
||||||
|
doc = DocumentInput(
|
||||||
|
text="这是一段用于测试 metadata 透传的正文内容。" * 5,
|
||||||
|
title="带文件元数据的文档",
|
||||||
|
metadata={
|
||||||
|
"raw_file_path": "/data/uploads/spec.md",
|
||||||
|
"original_filename": "spec.md",
|
||||||
|
"original_size_bytes": "5120",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ingester = self._make_real_ingester(real_service, _structured_summary())
|
||||||
|
result = await ingester.ingest(doc)
|
||||||
|
|
||||||
|
# FakeQdrant 已被真实 service 取代:l1_calls 不再可用,直接查 Qdrant
|
||||||
|
detail = await real_service.get_doc_detail(result.document_id)
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"] == {
|
||||||
|
"filename": "spec.md",
|
||||||
|
"size_bytes": 5120,
|
||||||
|
"url": f"/api/v1/documents/{result.document_id}/file",
|
||||||
|
}
|
||||||
|
# L1 payload 也应带 metadata 字段
|
||||||
|
assert detail["l1"]["metadata"] == doc.metadata
|
||||||
|
|
||||||
|
async def test_ingest_without_metadata_file_is_none(self, real_service: QdrantService) -> None:
|
||||||
|
"""文本入库(metadata 为空 dict)→ get_doc_detail.file=None"""
|
||||||
|
doc = DocumentInput(text="纯文本入库,无文件元数据。" * 5, title="纯文本")
|
||||||
|
ingester = self._make_real_ingester(real_service, _structured_summary())
|
||||||
|
result = await ingester.ingest(doc)
|
||||||
|
|
||||||
|
detail = await real_service.get_doc_detail(result.document_id)
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"] is None
|
||||||
|
assert detail["l1"]["metadata"] == {}
|
||||||
|
|||||||
@@ -234,3 +234,152 @@ async def test_search_hybrid_rrf(service: QdrantService) -> None:
|
|||||||
assert len(filtered) == 1
|
assert len(filtered) == 1
|
||||||
assert filtered[0].payload is not None
|
assert filtered[0].payload is not None
|
||||||
assert filtered[0].payload["doc_id"] == "doc-h2"
|
assert filtered[0].payload["doc_id"] == "doc-h2"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- L1 metadata 存储与读取 ----------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_l1_metadata_roundtrip(service: QdrantService) -> None:
|
||||||
|
"""upsert_l1 写入 metadata 后,get_l1_metadata 返回写入的 dict"""
|
||||||
|
meta = {
|
||||||
|
"raw_file_path": "/data/uploads/report.pdf",
|
||||||
|
"original_filename": "年报.pdf",
|
||||||
|
"original_size_bytes": "2048",
|
||||||
|
}
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-meta",
|
||||||
|
title="带元数据文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
|
assert await service.get_l1_metadata("doc-meta") == meta
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_l1_without_metadata_stores_empty_dict(service: QdrantService) -> None:
|
||||||
|
"""upsert_l1 不传 metadata(旧签名)→ payload 存空 dict,get_l1_metadata 返回 {}"""
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-no-meta",
|
||||||
|
title="无元数据文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
)
|
||||||
|
assert await service.get_l1_metadata("doc-no-meta") == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_l1_metadata_doc_not_found(service: QdrantService) -> None:
|
||||||
|
"""文档不存在 → get_l1_metadata 返回 None"""
|
||||||
|
assert await service.get_l1_metadata("doc-missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_l1_metadata_legacy_payload_without_field(service: QdrantService) -> None:
|
||||||
|
"""存量旧文档:payload 完全没有 metadata 字段(绕过 upsert_l1 直接写点)→ 返回 None 不报错"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from qdrant_client import models
|
||||||
|
|
||||||
|
point = models.PointStruct(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-doc:l1")),
|
||||||
|
vector={"dense": _dense(0.4)},
|
||||||
|
payload={"doc_id": "legacy-doc", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
|
||||||
|
)
|
||||||
|
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||||
|
assert await service.get_l1_metadata("legacy-doc") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- get_doc_detail file 字段 ----------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_doc_detail_file_field_with_raw_path(service: QdrantService) -> None:
|
||||||
|
"""L1 metadata 含 raw_file_path → file 字段含 filename/size_bytes/url"""
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-file",
|
||||||
|
title="文件文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
metadata={
|
||||||
|
"raw_file_path": "/data/uploads/report.pdf",
|
||||||
|
"original_filename": "年报.pdf",
|
||||||
|
"original_size_bytes": "4096",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
detail = await service.get_doc_detail("doc-file")
|
||||||
|
assert detail is not None
|
||||||
|
file_info = detail["file"]
|
||||||
|
assert file_info == {
|
||||||
|
"filename": "年报.pdf",
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"url": "/api/v1/documents/doc-file/file",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_doc_detail_file_field_filename_fallback_to_path_name(service: QdrantService) -> None:
|
||||||
|
"""metadata 缺 original_filename 时,filename 回退为 raw_file_path 的 Path.name"""
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-file-fb",
|
||||||
|
title="文件文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
metadata={"raw_file_path": "/data/uploads/notes.md", "original_size_bytes": "100"},
|
||||||
|
)
|
||||||
|
detail = await service.get_doc_detail("doc-file-fb")
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"]["filename"] == "notes.md"
|
||||||
|
assert detail["file"]["size_bytes"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_doc_detail_file_none_when_no_metadata(service: QdrantService) -> None:
|
||||||
|
"""无 metadata(文本入库,存空 dict)→ file=None"""
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-text",
|
||||||
|
title="纯文本文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
)
|
||||||
|
detail = await service.get_doc_detail("doc-text")
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_doc_detail_file_none_when_no_raw_path(service: QdrantService) -> None:
|
||||||
|
"""metadata 无 raw_file_path → file=None"""
|
||||||
|
await service.upsert_l1(
|
||||||
|
doc_id="doc-meta-no-path",
|
||||||
|
title="文档",
|
||||||
|
summary="总结",
|
||||||
|
category="tech",
|
||||||
|
tags=["x"],
|
||||||
|
dense_vector=_dense(0.9),
|
||||||
|
metadata={"original_filename": "x.pdf", "original_size_bytes": "10"},
|
||||||
|
)
|
||||||
|
detail = await service.get_doc_detail("doc-meta-no-path")
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_doc_detail_file_none_for_legacy_doc(service: QdrantService) -> None:
|
||||||
|
"""存量旧文档(payload 无 metadata 字段,绕过 upsert_l1 直接写点)→ file=None 不报错"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from qdrant_client import models
|
||||||
|
|
||||||
|
point = models.PointStruct(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-detail:l1")),
|
||||||
|
vector={"dense": _dense(0.3)},
|
||||||
|
payload={"doc_id": "legacy-detail", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
|
||||||
|
)
|
||||||
|
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||||
|
detail = await service.get_doc_detail("legacy-detail")
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["file"] is None
|
||||||
|
assert detail["l1"]["doc_id"] == "legacy-detail"
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""RerankerService 单元测试(mock httpx,不发起真实网络请求)"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.reranker import OllamaRerankerService, create_reranker_service
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(status_code: int, data: dict | None = None) -> httpx.Response:
|
||||||
|
"""构造带 request 上下文的 httpx.Response(raise_for_status 依赖 request)"""
|
||||||
|
request = httpx.Request("POST", "http://localhost:11434/api/rerank")
|
||||||
|
if data is None:
|
||||||
|
return httpx.Response(status_code, request=request)
|
||||||
|
return httpx.Response(status_code, json=data, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
"""httpx.AsyncClient 替代品:记录请求参数,返回预设响应或抛出预设异常"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
captured: dict,
|
||||||
|
response: httpx.Response | None = None,
|
||||||
|
error: Exception | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._captured = captured
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
captured["timeout"] = kwargs.get("timeout")
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url: str, json: dict | None = None) -> httpx.Response:
|
||||||
|
self._captured["url"] = url
|
||||||
|
self._captured["json"] = json
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
assert self._response is not None
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch: pytest.MonkeyPatch, captured: dict, **kw: Any) -> None:
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: _FakeAsyncClient(captured, **k, **kw))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRerank:
|
||||||
|
async def test_returns_scores_aligned_to_documents(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: dict = {}
|
||||||
|
_client(
|
||||||
|
monkeypatch,
|
||||||
|
captured,
|
||||||
|
response=_make_response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{"index": 0, "relevance_score": 0.2},
|
||||||
|
{"index": 1, "relevance_score": 0.9},
|
||||||
|
{"index": 2, "relevance_score": 0.5},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service = OllamaRerankerService(base_url="http://localhost:11434/", model="qwen3-reranker:0.6b")
|
||||||
|
|
||||||
|
scores = await service.rerank("查询", ["doc-a", "doc-b", "doc-c"])
|
||||||
|
|
||||||
|
assert scores == [0.2, 0.9, 0.5]
|
||||||
|
assert captured["url"] == "http://localhost:11434/api/rerank"
|
||||||
|
assert captured["json"] == {
|
||||||
|
"model": "qwen3-reranker:0.6b",
|
||||||
|
"query": "查询",
|
||||||
|
"documents": ["doc-a", "doc-b", "doc-c"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_empty_documents_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: dict = {}
|
||||||
|
_client(monkeypatch, captured, response=_make_response(200, {"results": []}))
|
||||||
|
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||||
|
|
||||||
|
scores = await service.rerank("查询", [])
|
||||||
|
|
||||||
|
assert scores == []
|
||||||
|
# 空文档不发起请求
|
||||||
|
assert "url" not in captured
|
||||||
|
|
||||||
|
async def test_partial_results_zero_fill_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: dict = {}
|
||||||
|
# 仅返回 index 1,index 0/2 缺失 → 补 0
|
||||||
|
_client(
|
||||||
|
monkeypatch,
|
||||||
|
captured,
|
||||||
|
response=_make_response(200, {"results": [{"index": 1, "relevance_score": 0.7}]}),
|
||||||
|
)
|
||||||
|
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||||
|
|
||||||
|
scores = await service.rerank("查询", ["a", "b", "c"])
|
||||||
|
|
||||||
|
assert scores == [0.0, 0.7, 0.0]
|
||||||
|
|
||||||
|
async def test_http_error_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_client(monkeypatch, {}, response=_make_response(500))
|
||||||
|
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await service.rerank("查询", ["a", "b"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateRerankerService:
|
||||||
|
def test_disabled_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "reranker_enabled", False)
|
||||||
|
assert create_reranker_service() is None
|
||||||
|
|
||||||
|
def test_enabled_returns_service(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "reranker_enabled", True)
|
||||||
|
monkeypatch.setattr(settings, "reranker_model", "qwen3-reranker:0.6b")
|
||||||
|
monkeypatch.setattr(settings, "ollama_base_url", "http://ollama:11434")
|
||||||
|
monkeypatch.setattr(settings, "reranker_timeout", 30.0)
|
||||||
|
service = create_reranker_service()
|
||||||
|
assert isinstance(service, OllamaRerankerService)
|
||||||
|
assert service.model == "qwen3-reranker:0.6b"
|
||||||
+64
-1
@@ -82,15 +82,29 @@ def _route(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision) -> Retriever:
|
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision, reranker: Any = None) -> Retriever:
|
||||||
return Retriever(
|
return Retriever(
|
||||||
qdrant=qdrant, # type: ignore[arg-type]
|
qdrant=qdrant, # type: ignore[arg-type]
|
||||||
query_parser=FakeParser(route), # type: ignore[arg-type]
|
query_parser=FakeParser(route), # type: ignore[arg-type]
|
||||||
embedding=FakeEmbedding(),
|
embedding=FakeEmbedding(),
|
||||||
sparse_encoder=SparseEncoder(),
|
sparse_encoder=SparseEncoder(),
|
||||||
|
reranker=reranker,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReranker:
|
||||||
|
"""假 RerankerService:返回预设分数或抛出预设异常"""
|
||||||
|
|
||||||
|
def __init__(self, scores: list[float] | None = None, exc: Exception | None = None) -> None:
|
||||||
|
self.scores = scores if scores is not None else []
|
||||||
|
self.exc = exc
|
||||||
|
|
||||||
|
async def rerank(self, query: str, documents: list[str]) -> list[float]:
|
||||||
|
if self.exc is not None:
|
||||||
|
raise self.exc
|
||||||
|
return self.scores
|
||||||
|
|
||||||
|
|
||||||
def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]:
|
def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]:
|
||||||
return [c for c in qdrant.calls if c["collection"] == collection]
|
return [c for c in qdrant.calls if c["collection"] == collection]
|
||||||
|
|
||||||
@@ -348,3 +362,52 @@ class TestSearchApi:
|
|||||||
resp = client.get("/api/v1/health")
|
resp = client.get("/api/v1/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json() == {"status": "ok"}
|
assert resp.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReranker:
|
||||||
|
"""重排启用时对 chunk 候选精排;关闭或失败则退化为 RRF 顺序"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _full_qdrant() -> FakeQdrant:
|
||||||
|
return FakeQdrant(
|
||||||
|
{
|
||||||
|
COLLECTION_L1: [[_point("l1a", "d1")]],
|
||||||
|
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
|
||||||
|
COLLECTION_L3: [[_point("l3a", "d1", "章节A")]],
|
||||||
|
COLLECTION_CHUNKS: [
|
||||||
|
[
|
||||||
|
_point("c1", "d1", "章节A"),
|
||||||
|
_point("c2", "d1", "章节A"),
|
||||||
|
_point("c3", "d1", "章节A"),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_enabled_reorders_by_relevance(self):
|
||||||
|
"""重排按相关性分数降序重排,并将 score 写回相关性分数"""
|
||||||
|
reranker = FakeReranker(scores=[0.1, 0.9, 0.5]) # c1<c3<c2
|
||||||
|
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=reranker)
|
||||||
|
|
||||||
|
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||||
|
|
||||||
|
# 重排后顺序为 c2, c3, c1
|
||||||
|
assert [h.text for h in resp.hits] == ["text-c2", "text-c3", "text-c1"]
|
||||||
|
assert resp.hits[0].score == 0.9
|
||||||
|
|
||||||
|
async def test_disabled_keeps_rrf_order(self):
|
||||||
|
"""重排未注入(关闭)时保持 RRF 融合原始顺序"""
|
||||||
|
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=None)
|
||||||
|
|
||||||
|
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||||
|
|
||||||
|
assert [h.text for h in resp.hits] == ["text-c1", "text-c2", "text-c3"]
|
||||||
|
|
||||||
|
async def test_failure_falls_back_to_rrf(self):
|
||||||
|
"""重排调用异常时退化为 RRF 顺序,检索不中断"""
|
||||||
|
reranker = FakeReranker(exc=RuntimeError("ollama 不可用"))
|
||||||
|
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=reranker)
|
||||||
|
|
||||||
|
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||||
|
|
||||||
|
assert [h.text for h in resp.hits] == ["text-c1", "text-c2", "text-c3"]
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Spec Task 5:用户管理增强(PATCH 端点 + enabled 字段)全链路集成验证
|
||||||
|
|
||||||
|
内存 UserStore/SessionStore 经 conftest.auth_stores 夹具注入 app.api.deps 单例,
|
||||||
|
通过 TestClient 走真实 HTTP 链路(不跑 lifespan,无需真实 Redis/Qdrant),覆盖:
|
||||||
|
- 改角色生效:admin 创建 user → PATCH 改 admin → GET /auth/me 与重新登录均反映新角色
|
||||||
|
- 禁用用户:旧 token 立即失效(session 已清,1005);重新登录 1005("账号已禁用")
|
||||||
|
- 重新启用:PATCH enabled=True → login 恢复成功
|
||||||
|
- 最后 admin 保护:唯一 admin 降级/禁用自己 → 1001(且自身状态未变)
|
||||||
|
- PATCH 校验:空 body / 非法 role → 1001;不存在用户 → 1004;非 admin 调用 → 1006
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient, username: str, password: str) -> dict[str, Any]:
|
||||||
|
"""调登录接口并返回响应体"""
|
||||||
|
return client.post("/api/v1/auth/login", json={"username": username, "password": password}).json()
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer(token: str) -> dict[str, str]:
|
||||||
|
"""构造 Authorization Bearer 请求头"""
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_login(client: TestClient) -> dict[str, str]:
|
||||||
|
"""以 auth_stores 预置的 admin(admin/admin-pass-123)登录,返回 Bearer 请求头"""
|
||||||
|
resp = _login(client, "admin", "admin-pass-123")
|
||||||
|
assert resp["code"] == 0, f"admin 登录失败: {resp}"
|
||||||
|
return _bearer(resp["data"]["token"])
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(
|
||||||
|
client: TestClient,
|
||||||
|
admin_headers: dict[str, str],
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
role: str = "user",
|
||||||
|
) -> None:
|
||||||
|
"""admin 创建用户并断言成功"""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/users",
|
||||||
|
json={"username": username, "password": password, "role": role},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 0, f"创建用户 {username} 失败: {resp}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleChange:
|
||||||
|
"""改角色生效全链路"""
|
||||||
|
|
||||||
|
def test_role_change_takes_effect(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
# admin 创建 user 角色账号 alice
|
||||||
|
_create_user(client, admin_headers, "alice", "alice-pass-123", role="user")
|
||||||
|
|
||||||
|
# alice 登录,初始角色 user
|
||||||
|
alice_login = _login(client, "alice", "alice-pass-123")
|
||||||
|
assert alice_login["code"] == 0
|
||||||
|
assert alice_login["data"]["role"] == "user"
|
||||||
|
alice_headers = _bearer(alice_login["data"]["token"])
|
||||||
|
|
||||||
|
# 改角色前 GET /auth/me 反映 user 角色
|
||||||
|
me_before = client.get("/api/v1/auth/me", headers=alice_headers).json()
|
||||||
|
assert me_before["code"] == 0
|
||||||
|
assert me_before["data"]["role"] == "user"
|
||||||
|
|
||||||
|
# admin PATCH 改 alice 角色为 admin
|
||||||
|
patched = client.patch(
|
||||||
|
"/api/v1/auth/users/alice",
|
||||||
|
json={"role": "admin"},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert patched["code"] == 0
|
||||||
|
assert patched["data"]["role"] == "admin"
|
||||||
|
|
||||||
|
# 同一 token 立即反映新角色(角色变更不清 session,用户记录实时读取)
|
||||||
|
me_after = client.get("/api/v1/auth/me", headers=alice_headers).json()
|
||||||
|
assert me_after["code"] == 0
|
||||||
|
assert me_after["data"]["role"] == "admin"
|
||||||
|
|
||||||
|
# 重新登录也反映新角色
|
||||||
|
relogin = _login(client, "alice", "alice-pass-123")
|
||||||
|
assert relogin["code"] == 0
|
||||||
|
assert relogin["data"]["role"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisableUser:
|
||||||
|
"""禁用用户:旧 session 清除 + 登录拒绝"""
|
||||||
|
|
||||||
|
def test_disable_user_clears_session_and_blocks_login(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
_create_user(client, admin_headers, "bob", "bob-pass-123")
|
||||||
|
|
||||||
|
# bob 登录拿 token
|
||||||
|
bob_login = _login(client, "bob", "bob-pass-123")
|
||||||
|
assert bob_login["code"] == 0
|
||||||
|
bob_headers = _bearer(bob_login["data"]["token"])
|
||||||
|
|
||||||
|
# admin 禁用 bob(清空其全部 session)
|
||||||
|
disabled = client.patch(
|
||||||
|
"/api/v1/auth/users/bob",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert disabled["code"] == 0
|
||||||
|
assert disabled["data"]["enabled"] is False
|
||||||
|
|
||||||
|
# bob 旧 token 调鉴权端点 → 1005(session 已清,凭证无效)
|
||||||
|
me = client.get("/api/v1/auth/me", headers=bob_headers).json()
|
||||||
|
assert me["code"] == 1005
|
||||||
|
|
||||||
|
# bob 重新登录 → 1005(账号已禁用)
|
||||||
|
relogin = _login(client, "bob", "bob-pass-123")
|
||||||
|
assert relogin["code"] == 1005
|
||||||
|
assert "禁用" in relogin["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReenableUser:
|
||||||
|
"""重新启用:login 恢复成功"""
|
||||||
|
|
||||||
|
def test_reenable_user_allows_login(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
_create_user(client, admin_headers, "carol", "carol-pass-123")
|
||||||
|
|
||||||
|
# 先禁用 carol,确认登录被拒
|
||||||
|
disabled = client.patch(
|
||||||
|
"/api/v1/auth/users/carol",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert disabled["code"] == 0
|
||||||
|
assert _login(client, "carol", "carol-pass-123")["code"] == 1005
|
||||||
|
|
||||||
|
# 重新启用
|
||||||
|
enabled = client.patch(
|
||||||
|
"/api/v1/auth/users/carol",
|
||||||
|
json={"enabled": True},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert enabled["code"] == 0
|
||||||
|
assert enabled["data"]["enabled"] is True
|
||||||
|
|
||||||
|
# login 恢复成功
|
||||||
|
relogin = _login(client, "carol", "carol-pass-123")
|
||||||
|
assert relogin["code"] == 0
|
||||||
|
assert relogin["data"]["username"] == "carol"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLastAdminProtection:
|
||||||
|
"""最后 admin 保护:唯一 admin 不可降级/禁用"""
|
||||||
|
|
||||||
|
def test_cannot_demote_last_admin(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
# 唯一 admin(admin)尝试降级自己 role=user → 1001
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/admin",
|
||||||
|
json={"role": "user"},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1001
|
||||||
|
# admin 未被降级,仍可访问 admin 专属端点
|
||||||
|
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
|
||||||
|
|
||||||
|
def test_cannot_disable_last_admin(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
# 唯一 admin 尝试禁用自己 enabled=False → 1001
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/admin",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1001
|
||||||
|
# admin 未被禁用,旧 token 仍可用
|
||||||
|
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatchValidation:
|
||||||
|
"""PATCH 端点校验:空 body / 非法 role / 不存在用户 / 非 admin"""
|
||||||
|
|
||||||
|
def test_empty_body_rejected(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
_create_user(client, admin_headers, "dave", "dave-pass-123")
|
||||||
|
|
||||||
|
# 空 body(role 与 enabled 均缺)→ 模型校验 1001
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/dave",
|
||||||
|
json={},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1001
|
||||||
|
|
||||||
|
def test_invalid_role_rejected(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
_create_user(client, admin_headers, "dave", "dave-pass-123")
|
||||||
|
|
||||||
|
# role 非法(非 admin/user)→ 模型校验 1001
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/dave",
|
||||||
|
json={"role": "superuser"},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1001
|
||||||
|
|
||||||
|
def test_nonexistent_user_rejected(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
|
||||||
|
# 不存在用户 → 1004
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/ghost",
|
||||||
|
json={"role": "admin"},
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1004
|
||||||
|
|
||||||
|
def test_non_admin_forbidden(self, auth_stores) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_headers = _admin_login(client)
|
||||||
|
_create_user(client, admin_headers, "eve", "eve-pass-123", role="user")
|
||||||
|
|
||||||
|
# eve(user 角色)登录后调 PATCH → 1006
|
||||||
|
eve_login = _login(client, "eve", "eve-pass-123")
|
||||||
|
assert eve_login["code"] == 0
|
||||||
|
eve_headers = _bearer(eve_login["data"]["token"])
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/auth/users/eve",
|
||||||
|
json={"role": "admin"},
|
||||||
|
headers=eve_headers,
|
||||||
|
).json()
|
||||||
|
assert resp["code"] == 1006
|
||||||
@@ -17,6 +17,7 @@ import pytest
|
|||||||
|
|
||||||
from app.core.users import (
|
from app.core.users import (
|
||||||
UserExistsError,
|
UserExistsError,
|
||||||
|
UserNotFoundError,
|
||||||
UserStore,
|
UserStore,
|
||||||
UserStoreError,
|
UserStoreError,
|
||||||
bootstrap_admin,
|
bootstrap_admin,
|
||||||
@@ -225,6 +226,101 @@ class TestCountAdmins:
|
|||||||
assert await store.count_admins() == 1
|
assert await store.count_admins() == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnabledField:
|
||||||
|
"""enabled 字段:默认 True、create 传参、get/list 回读、存量兼容"""
|
||||||
|
|
||||||
|
async def test_default_enabled_is_true(self):
|
||||||
|
record = await UserStore(FakeRedis()).create("alice", "password123")
|
||||||
|
assert record.enabled is True
|
||||||
|
|
||||||
|
async def test_create_with_enabled_false(self):
|
||||||
|
redis = FakeRedis()
|
||||||
|
store = UserStore(redis)
|
||||||
|
record = await store.create("alice", "password123", enabled=False)
|
||||||
|
assert record.enabled is False
|
||||||
|
# 持久化后回读仍为 False
|
||||||
|
assert (await store.get("alice")).enabled is False
|
||||||
|
# 落库 JSON 含 enabled 字段
|
||||||
|
assert json.loads(redis.store["user:alice"])["enabled"] is False
|
||||||
|
|
||||||
|
async def test_get_list_roundtrip_enabled(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123", enabled=False)
|
||||||
|
await store.create("bob", "password123", role="admin")
|
||||||
|
assert (await store.get("alice")).enabled is False
|
||||||
|
assert (await store.get("bob")).enabled is True
|
||||||
|
records = {r.username: r for r in await store.list()}
|
||||||
|
assert records["alice"].enabled is False
|
||||||
|
assert records["bob"].enabled is True
|
||||||
|
|
||||||
|
async def test_legacy_record_without_enabled_defaults_true(self):
|
||||||
|
"""存量记录无 enabled 字段时按 True 兼容(get/list)"""
|
||||||
|
redis = FakeRedis()
|
||||||
|
store = UserStore(redis)
|
||||||
|
# 直接写入无 enabled 字段的存量记录
|
||||||
|
redis.store["user:legacy"] = json.dumps(
|
||||||
|
{
|
||||||
|
"username": "legacy",
|
||||||
|
"role": "user",
|
||||||
|
"password_hash": "hash",
|
||||||
|
"salt": "00" * 16,
|
||||||
|
"must_change_password": False,
|
||||||
|
"created_at": "2024-01-01T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
record = await store.get("legacy")
|
||||||
|
assert record is not None
|
||||||
|
assert record.enabled is True
|
||||||
|
records = await store.list()
|
||||||
|
assert records[0].enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateUser:
|
||||||
|
"""update_user:角色/启用状态更新与校验"""
|
||||||
|
|
||||||
|
async def test_update_role(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123")
|
||||||
|
updated = await store.update_user("alice", role="admin")
|
||||||
|
assert updated.role == "admin"
|
||||||
|
assert updated.enabled is True # 未改动
|
||||||
|
# 持久化
|
||||||
|
record = await store.get("alice")
|
||||||
|
assert record is not None
|
||||||
|
assert record.role == "admin"
|
||||||
|
|
||||||
|
async def test_update_enabled(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123", role="admin")
|
||||||
|
updated = await store.update_user("alice", enabled=False)
|
||||||
|
assert updated.enabled is False
|
||||||
|
assert updated.role == "admin" # 未改动
|
||||||
|
|
||||||
|
async def test_update_both(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123")
|
||||||
|
updated = await store.update_user("alice", role="admin", enabled=False)
|
||||||
|
assert updated.role == "admin"
|
||||||
|
assert updated.enabled is False
|
||||||
|
|
||||||
|
async def test_user_not_found_raises(self):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await UserStore(FakeRedis()).update_user("nobody", role="admin")
|
||||||
|
|
||||||
|
async def test_invalid_role_raises_value_error(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await store.update_user("alice", role="superuser")
|
||||||
|
|
||||||
|
async def test_no_fields_noop(self):
|
||||||
|
store = UserStore(FakeRedis())
|
||||||
|
await store.create("alice", "password123", role="admin")
|
||||||
|
updated = await store.update_user("alice")
|
||||||
|
assert updated.role == "admin"
|
||||||
|
assert updated.enabled is True
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrapAdmin:
|
class TestBootstrapAdmin:
|
||||||
"""空库引导创建默认管理员"""
|
"""空库引导创建默认管理员"""
|
||||||
|
|
||||||
|
|||||||
@@ -159,14 +159,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "flatbuffers"
|
|
||||||
version = "25.12.19"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio"
|
name = "grpcio"
|
||||||
version = "1.83.0"
|
version = "1.83.0"
|
||||||
@@ -403,86 +395,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lxml"
|
|
||||||
version = "6.1.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.5.1"
|
version = "2.5.1"
|
||||||
@@ -534,38 +446,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "onnxruntime"
|
|
||||||
version = "1.28.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "flatbuffers" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "packaging" },
|
|
||||||
{ name = "protobuf" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openai"
|
name = "openai"
|
||||||
version = "2.49.0"
|
version = "2.49.0"
|
||||||
@@ -585,25 +465,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opencv-python"
|
|
||||||
version = "5.0.0.93"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "numpy" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -613,77 +474,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pillow"
|
|
||||||
version = "12.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -720,36 +510,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyclipper"
|
|
||||||
version = "1.4.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -872,44 +632,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pypdf"
|
|
||||||
version = "6.14.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pypdfium2"
|
|
||||||
version = "5.12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/db/42/0b51bdf50ccf13f3deb3209ca996179a49761dc191748469cf0de55b0055/pypdfium2-5.12.1.tar.gz", hash = "sha256:d0e0648fb2e28f50efcd1ec0a5a18ced9f4d66b2c227fae9b603f0a883b2d13f", size = 274428, upload-time = "2026-07-17T10:01:22.713Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/7e/bd8df53b1131c582f6646372047b49162032bd01628d49b9a60cd94d2181/pypdfium2-5.12.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4", size = 3392276, upload-time = "2026-07-17T10:00:47.376Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/13/a2b71e17b0439d2af78c817a381e3557371c6c56098581da8485aef65ea6/pypdfium2-5.12.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d4ee061e566a6422b660cdddaaa799a2d1cbf2f016921bcaf24d61426d01d942", size = 2848776, upload-time = "2026-07-17T10:00:49.09Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/a8/d7a61700db3022792b28bce5264be90a7d2e7104998362af6b93766f50b2/pypdfium2-5.12.1-py3-none-macosx_13_0_arm64.whl", hash = "sha256:66a9ed40d70a5d728cd42148fecb9d7a0917c6161d6bb67c844093a4ed1df089", size = 3480243, upload-time = "2026-07-17T10:00:50.674Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/2c/d7a38fad74b6da0947cf8763aee0f8e6c9d3c12fc8e137aa615f7f8ae76c/pypdfium2-5.12.1-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:847378a5ab41332998b2621b21bab2e96dc8c3eff36a08bce26695b964163983", size = 3643490, upload-time = "2026-07-17T10:00:52.236Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/29/aca739676323558595fcf8cdc8d7939d2b25aaaa6f538e829f1fee938cdd/pypdfium2-5.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eabf028ad8e7bc7811c9acf3a72718c180569b624b844d2c6cc974609784275", size = 3649734, upload-time = "2026-07-17T10:00:53.776Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/e0/e4ecc05f4f1a11d11c8d684a24b2fc8be8207f341be985dac301caa4f6aa/pypdfium2-5.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7857cfa6642ec5a09db12ff8f5cf6b6494585b5e3a605399fddc4fb862837b63", size = 3380828, upload-time = "2026-07-17T10:00:55.377Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/1b/c94c9d486791276e736350917a11fe2cf3acba2c6c7f03f9ac0d51f8952a/pypdfium2-5.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf", size = 3777202, upload-time = "2026-07-17T10:00:57.212Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/aa/7f81f0c035fc32850dfab9daf78530814f215be226dad7491e3caa0a3e8c/pypdfium2-5.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f059f7bdbdf4352eb83691071096940d769d6ae5930b8734237fdb1bd78fbc2", size = 4186083, upload-time = "2026-07-17T10:00:59.022Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/16/21420a6f2bc5f981299c336817dd5d72709dad5fda30ac38cbd5f0f7b372/pypdfium2-5.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10cbf41b21233ec5e20adfc170cf60edd77abead86a97dc708fff55a8a886c7", size = 3701734, upload-time = "2026-07-17T10:01:00.952Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/a1/bb89f49e2b3ea3e945b67859d2ec6e73e722a83c006099c675692641e51d/pypdfium2-5.12.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07eeebb2784f4cd38d386b924235df43217a397442796673296bb6efbdaad1d0", size = 4030403, upload-time = "2026-07-17T10:01:02.568Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/a7/cea5eb0c39e9c6fdf9853a3008bf021d08f962228073af90354b60c5ccdc/pypdfium2-5.12.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c3e6cbe43581af79526184643920ab03a9401a0c79f2226bea9d4d1e3d34008", size = 3994411, upload-time = "2026-07-17T10:01:04.25Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/43/5e470213b27c13b0d94d03d97fc3740507edadea1d1e2ce2049bfbec4aa0/pypdfium2-5.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4648f0905441bcb141687ca2263bbf38a1aa056b943eef06019f91cff3e1da4a", size = 4993687, upload-time = "2026-07-17T10:01:05.811Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/da/af7972ca72f24ed720db6501333fd67bc66aa6aa5a5ec698551bd30ae62e/pypdfium2-5.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bdff622181fab64f32328591c9c8287cdc745c9a1f2afc26ca3feba39e3e6645", size = 4534560, upload-time = "2026-07-17T10:01:07.291Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/63/06a7f2cd691f7e336cbc53fd65453fee516042c8ddb016bc50d1cd2bed45/pypdfium2-5.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:236dbdc88aa54f14b27937ccb2ebe3dcf08c10dbb8652f432ea982dc9af39732", size = 5237681, upload-time = "2026-07-17T10:01:08.997Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/f5/937b080671758ab0b3d3d69b2657006682e4c6a0134b36774be4ed1afcfb/pypdfium2-5.12.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:9c8856ce7dd77a7827476c7d75afe1197d6cd505f5cb4167b6aacf661f3f8ea5", size = 5143027, upload-time = "2026-07-17T10:01:10.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/02/094632700c24728fa443dc89d9d1c6e4fc05bb00778b22e8482fdb133da0/pypdfium2-5.12.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:5f257bb40fa44ce9ba18d2c919777dbd3f16bf22548b1d68fd56c7c92f1de530", size = 4647048, upload-time = "2026-07-17T10:01:12.559Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/d0/12d84bf55a4fcf2c0ed242afc94168933194f672067e1d162aa60a8e4426/pypdfium2-5.12.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:974082344172da76a5c3c0782eaedfe6069dbe88db77d8c671ef36b61e9b14e2", size = 5088747, upload-time = "2026-07-17T10:01:14.42Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/9c/92a460bac1f6cfd6f96251802b3098a804fb251dfe0b5eb004ede958ae0e/pypdfium2-5.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715ae16b34ea1d64884d58800155179ba700e9ea65a2f583b020666acd2bfb12", size = 5049695, upload-time = "2026-07-17T10:01:15.961Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/67/53c61d366222550220b42a9212131407f49d3dbcf050178c620bf80fa899/pypdfium2-5.12.1-py3-none-win32.whl", hash = "sha256:e5358d2ce4ebc5c899aab1df9ca5d215357244e9168aa443225d3c1e649c7eac", size = 3725466, upload-time = "2026-07-17T10:01:17.773Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/c3/08b62718faf2f6b6aa49207626e3113ff3ec1b3cd076c0ef8fd852f0e57c/pypdfium2-5.12.1-py3-none-win_amd64.whl", hash = "sha256:9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca", size = 3859845, upload-time = "2026-07-17T10:01:19.417Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/d5/ad551e55790134c8bc87ea16e0866a3721def0620fbfa19112c8ad4a25a6/pypdfium2-5.12.1-py3-none-win_arm64.whl", hash = "sha256:afc0b7e0c975a429abc75875209ce17b66d749f6ac5cbe8ba72470e83901e304", size = 3674605, upload-time = "2026-07-17T10:01:21.008Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.1"
|
version = "9.1.1"
|
||||||
@@ -939,19 +661,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-docx"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "lxml" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -961,15 +670,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-multipart"
|
|
||||||
version = "0.0.32"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywin32"
|
name = "pywin32"
|
||||||
version = "312"
|
version = "312"
|
||||||
@@ -1065,12 +765,7 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pypdf" },
|
|
||||||
{ name = "pypdfium2" },
|
|
||||||
{ name = "python-docx" },
|
|
||||||
{ name = "python-multipart" },
|
|
||||||
{ name = "qdrant-client" },
|
{ name = "qdrant-client" },
|
||||||
{ name = "rapidocr-onnxruntime" },
|
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -1092,14 +787,9 @@ requires-dist = [
|
|||||||
{ name = "pydantic", specifier = ">=2.10.0" },
|
{ name = "pydantic", specifier = ">=2.10.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.7.0" },
|
{ name = "pydantic-settings", specifier = ">=2.7.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.13.0" },
|
{ name = "pyjwt", specifier = ">=2.13.0" },
|
||||||
{ name = "pypdf", specifier = ">=5.1.0" },
|
|
||||||
{ name = "pypdfium2", specifier = ">=4.0.0" },
|
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
|
||||||
{ name = "python-docx", specifier = ">=1.1.2" },
|
|
||||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
|
||||||
{ name = "qdrant-client", specifier = ">=1.12.0" },
|
{ name = "qdrant-client", specifier = ">=1.12.0" },
|
||||||
{ name = "rapidocr-onnxruntime", specifier = ">=1.3.8" },
|
|
||||||
{ name = "redis", specifier = ">=5.2.0" },
|
{ name = "redis", specifier = ">=5.2.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
|
||||||
{ name = "structlog", specifier = ">=24.4.0" },
|
{ name = "structlog", specifier = ">=24.4.0" },
|
||||||
@@ -1107,25 +797,6 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rapidocr-onnxruntime"
|
|
||||||
version = "1.4.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "onnxruntime" },
|
|
||||||
{ name = "opencv-python" },
|
|
||||||
{ name = "pillow" },
|
|
||||||
{ name = "pyclipper" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "shapely" },
|
|
||||||
{ name = "six" },
|
|
||||||
{ name = "tqdm" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", hash = "sha256:971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", size = 14915192, upload-time = "2025-01-17T01:48:25.104Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "8.0.1"
|
version = "8.0.1"
|
||||||
@@ -1160,66 +831,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "shapely"
|
|
||||||
version = "2.1.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "numpy" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.17.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user