feat: P1-P3 全栈集成 — 数据库基础 + DWS 扩展 + 小程序鉴权 + 工程化体系
## P1 数据库基础 - zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu - etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表 - 清理 assistant_abolish 残留数据 ## P2 ETL/DWS 扩展 - 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution) - 新增 assistant_order_contribution_task 任务及 RLS 视图 - member_consumption 增加充值字段、assistant_daily 增加处罚字段 - 更新 ODS/DWD/DWS 任务文档及业务规则文档 - 更新 consistency_checker、flow_runner、task_registry 等核心模块 ## P3 小程序鉴权系统 - 新增 xcx_auth 路由/schema(微信登录 + JWT) - 新增 wechat/role/matching/application 服务层 - zqyy_app 鉴权表迁移 + 角色权限种子数据 - auth/dependencies.py 支持小程序 JWT 鉴权 ## 文档与审计 - 新增 DOCUMENTATION-MAP 文档导航 - 新增 7 份 BD_Manual 数据库变更文档 - 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth) - 新增全栈集成审计记录、部署检查清单更新 - 新增 BACKLOG 路线图、FDW→Core 迁移计划 ## Kiro 工程化 - 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务) - 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan) - 新增 6 个 Hook(合规检查/会话日志/提交审计等) - 新增 doc-map steering 文件 ## 运维与测试 - 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告 - 新增属性测试:test_dws_contribution / test_auth_system - 清理过期 export 报告文件 - 更新 .gitignore 排除规则
This commit is contained in:
@@ -5,9 +5,15 @@ FastAPI 依赖注入:从 JWT 提取当前用户信息。
|
||||
@router.get("/protected")
|
||||
async def protected_endpoint(user: CurrentUser = Depends(get_current_user)):
|
||||
print(user.user_id, user.site_id)
|
||||
|
||||
# 允许 pending 用户(受限令牌)访问
|
||||
@router.get("/apply")
|
||||
async def apply_endpoint(user: CurrentUser = Depends(get_current_user_or_limited)):
|
||||
if user.limited:
|
||||
... # 受限逻辑
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -24,7 +30,10 @@ class CurrentUser:
|
||||
"""从 JWT 解析出的当前用户上下文。"""
|
||||
|
||||
user_id: int
|
||||
site_id: int
|
||||
site_id: int = 0
|
||||
roles: list[str] = field(default_factory=list)
|
||||
status: str = "pending"
|
||||
limited: bool = False
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -33,7 +42,7 @@ async def get_current_user(
|
||||
"""
|
||||
FastAPI 依赖:从 Authorization header 提取 JWT,验证后返回用户信息。
|
||||
|
||||
失败时抛出 401。
|
||||
要求完整令牌(非 limited),失败时抛出 401。
|
||||
"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
@@ -45,6 +54,14 @@ async def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 受限令牌不允许通过此依赖
|
||||
if payload.get("limited"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="受限令牌无法访问此端点",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id_raw = payload.get("sub")
|
||||
site_id = payload.get("site_id")
|
||||
|
||||
@@ -64,4 +81,78 @@ async def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return CurrentUser(user_id=user_id, site_id=site_id)
|
||||
roles = payload.get("roles", [])
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
site_id=site_id,
|
||||
roles=roles,
|
||||
status="approved",
|
||||
limited=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user_or_limited(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
FastAPI 依赖:允许 pending 用户(受限令牌)访问。
|
||||
|
||||
- 受限令牌(limited=True):返回 CurrentUser(limited=True, roles=[], status="pending")
|
||||
- 完整令牌:正常返回 CurrentUser
|
||||
"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 user_id 格式无效",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 受限令牌:pending 用户
|
||||
if payload.get("limited"):
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
site_id=0,
|
||||
roles=[],
|
||||
status="pending",
|
||||
limited=True,
|
||||
)
|
||||
|
||||
# 完整令牌:要求 site_id
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
roles = payload.get("roles", [])
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
site_id=site_id,
|
||||
roles=roles,
|
||||
status="approved",
|
||||
limited=False,
|
||||
)
|
||||
|
||||
@@ -27,11 +27,14 @@ def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(user_id: int, site_id: int) -> str:
|
||||
def create_access_token(
|
||||
user_id: int, site_id: int, roles: list[str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
生成 access_token。
|
||||
|
||||
payload: sub=user_id, site_id, type=access, exp
|
||||
payload: sub=user_id, site_id, roles, type=access, exp
|
||||
roles 参数默认 None,保持向后兼容。
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
@@ -42,6 +45,8 @@ def create_access_token(user_id: int, site_id: int) -> str:
|
||||
"type": "access",
|
||||
"exp": expire,
|
||||
}
|
||||
if roles is not None:
|
||||
payload["roles"] = roles
|
||||
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
@@ -63,15 +68,46 @@ def create_refresh_token(user_id: int, site_id: int) -> str:
|
||||
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_token_pair(user_id: int, site_id: int) -> dict[str, str]:
|
||||
def create_token_pair(user_id: int, site_id: int, roles: list[str] | None = None) -> dict[str, str]:
|
||||
"""生成 access_token + refresh_token 令牌对。"""
|
||||
return {
|
||||
"access_token": create_access_token(user_id, site_id),
|
||||
"access_token": create_access_token(user_id, site_id, roles=roles),
|
||||
"refresh_token": create_refresh_token(user_id, site_id),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
def create_limited_token_pair(user_id: int) -> dict[str, str]:
|
||||
"""
|
||||
为 pending 用户签发受限令牌。
|
||||
|
||||
payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True。
|
||||
受限令牌仅允许访问申请提交和状态查询端点。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
access_payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "access",
|
||||
"limited": True,
|
||||
"exp": now + timedelta(minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
}
|
||||
refresh_payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "refresh",
|
||||
"limited": True,
|
||||
"exp": now + timedelta(days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
}
|
||||
return {
|
||||
"access_token": jwt.encode(
|
||||
access_payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM
|
||||
),
|
||||
"refresh_token": jwt.encode(
|
||||
refresh_payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM
|
||||
),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""
|
||||
解码并验证 JWT 令牌。
|
||||
|
||||
@@ -14,7 +14,8 @@ from app import config
|
||||
# CHANGE 2026-02-19 | 新增 xcx_test 路由(MVP 验证)+ wx_callback 路由(微信消息推送)
|
||||
# CHANGE 2026-02-22 | 新增 member_birthday 路由(助教手动补录会员生日)
|
||||
# CHANGE 2026-02-23 | 新增 ops_panel 路由(运维控制面板)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_birthday, ops_panel
|
||||
# CHANGE 2026-02-25 | 新增 xcx_auth 路由(小程序微信登录 + 申请 + 状态查询 + 店铺切换)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_birthday, ops_panel, xcx_auth
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -64,6 +65,7 @@ app.include_router(xcx_test.router)
|
||||
app.include_router(wx_callback.router)
|
||||
app.include_router(member_birthday.router)
|
||||
app.include_router(ops_panel.router)
|
||||
app.include_router(xcx_auth.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
|
||||
417
apps/backend/app/routers/xcx_auth.py
Normal file
417
apps/backend/app/routers/xcx_auth.py
Normal file
@@ -0,0 +1,417 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序认证路由 —— 微信登录、申请提交、状态查询、店铺切换、令牌刷新。
|
||||
|
||||
端点清单:
|
||||
- POST /api/xcx/login — 微信登录(查找/创建用户 + 签发 JWT)
|
||||
- POST /api/xcx/apply — 提交入驻申请
|
||||
- GET /api/xcx/me — 查询自身状态 + 申请列表
|
||||
- GET /api/xcx/me/sites — 查询关联店铺
|
||||
- POST /api/xcx/switch-site — 切换当前店铺
|
||||
- POST /api/xcx/refresh — 刷新令牌
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from jose import JWTError
|
||||
from psycopg2 import errors as pg_errors
|
||||
|
||||
from app.auth.dependencies import (
|
||||
CurrentUser,
|
||||
get_current_user,
|
||||
get_current_user_or_limited,
|
||||
)
|
||||
from app.auth.jwt import (
|
||||
create_limited_token_pair,
|
||||
create_token_pair,
|
||||
decode_refresh_token,
|
||||
)
|
||||
from app.database import get_connection
|
||||
from app.services.application import (
|
||||
create_application,
|
||||
get_user_applications,
|
||||
)
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationRequest,
|
||||
ApplicationResponse,
|
||||
RefreshTokenRequest,
|
||||
SiteInfo,
|
||||
SwitchSiteRequest,
|
||||
UserStatusResponse,
|
||||
WxLoginRequest,
|
||||
WxLoginResponse,
|
||||
)
|
||||
from app.services.wechat import WeChatAuthError, code2session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/xcx", tags=["小程序认证"])
|
||||
|
||||
|
||||
# ── 辅助:查询用户在指定 site_id 下的角色 code 列表 ──────────
|
||||
|
||||
def _get_user_roles_at_site(conn, user_id: int, site_id: int) -> list[str]:
|
||||
"""查询用户在指定 site_id 下的角色 code 列表。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.code
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
WHERE usr.user_id = %s AND usr.site_id = %s
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _get_user_default_site(conn, user_id: int) -> int | None:
|
||||
"""获取用户第一个关联的 site_id(按创建时间排序)。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id
|
||||
FROM auth.user_site_roles
|
||||
WHERE user_id = %s
|
||||
ORDER BY site_id
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# ── POST /api/xcx/login ──────────────────────────────────
|
||||
|
||||
@router.post("/login", response_model=WxLoginResponse)
|
||||
async def wx_login(body: WxLoginRequest):
|
||||
"""
|
||||
微信登录。
|
||||
|
||||
流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。
|
||||
- disabled 用户返回 403
|
||||
- 新用户自动创建(status=pending)
|
||||
- approved 用户签发包含 site_id + roles 的完整令牌
|
||||
- pending/rejected 用户签发受限令牌
|
||||
"""
|
||||
# 1. 调用微信 code2Session
|
||||
try:
|
||||
wx_result = await code2session(body.code)
|
||||
except WeChatAuthError as exc:
|
||||
raise HTTPException(status_code=exc.http_status, detail=exc.detail)
|
||||
except RuntimeError as exc:
|
||||
# 微信配置缺失
|
||||
logger.error("微信配置错误: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="服务器配置错误",
|
||||
)
|
||||
|
||||
openid = wx_result["openid"]
|
||||
unionid = wx_result.get("unionid")
|
||||
|
||||
# 2. 查找/创建用户
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||||
(openid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
# 新用户:创建 pending 记录
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.users (wx_openid, wx_union_id, status)
|
||||
VALUES (%s, %s, 'pending')
|
||||
RETURNING id, status
|
||||
""",
|
||||
(openid, unionid),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
except pg_errors.UniqueViolation:
|
||||
# 并发创建:回滚后查询已有记录
|
||||
conn.rollback()
|
||||
cur.execute(
|
||||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||||
(openid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
user_id, user_status = row
|
||||
|
||||
# 3. disabled 用户拒绝登录
|
||||
if user_status == "disabled":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 4. 签发令牌
|
||||
if user_status == "approved":
|
||||
# 查找默认 site_id 和角色
|
||||
default_site_id = _get_user_default_site(conn, user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||||
else:
|
||||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# pending / rejected → 受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/xcx/apply ──────────────────────────────────
|
||||
|
||||
@router.post("/apply", response_model=ApplicationResponse)
|
||||
async def submit_application(
|
||||
body: ApplicationRequest,
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
提交入驻申请。
|
||||
|
||||
委托 application service 处理:
|
||||
检查重复 pending → site_code 映射 → 创建记录 → 更新 nickname。
|
||||
"""
|
||||
result = await create_application(
|
||||
user_id=user.user_id,
|
||||
site_code=body.site_code,
|
||||
applied_role_text=body.applied_role_text,
|
||||
phone=body.phone,
|
||||
employee_number=body.employee_number,
|
||||
nickname=body.nickname,
|
||||
)
|
||||
return ApplicationResponse(**result)
|
||||
|
||||
|
||||
# ── GET /api/xcx/me ───────────────────────────────────────
|
||||
|
||||
@router.get("/me", response_model=UserStatusResponse)
|
||||
async def get_my_status(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
查询自身状态 + 所有申请记录。
|
||||
|
||||
pending / approved / rejected 用户均可访问。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询用户基本信息
|
||||
cur.execute(
|
||||
"SELECT id, status, nickname FROM auth.users WHERE id = %s",
|
||||
(user.user_id,),
|
||||
)
|
||||
user_row = cur.fetchone()
|
||||
if user_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
user_id, user_status, nickname = user_row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 委托 service 查询申请列表
|
||||
app_list = await get_user_applications(user_id)
|
||||
applications = [ApplicationResponse(**a) for a in app_list]
|
||||
|
||||
return UserStatusResponse(
|
||||
user_id=user_id,
|
||||
status=user_status,
|
||||
nickname=nickname,
|
||||
applications=applications,
|
||||
)
|
||||
|
||||
|
||||
# ── GET /api/xcx/me/sites ────────────────────────────────
|
||||
|
||||
@router.get("/me/sites", response_model=list[SiteInfo])
|
||||
async def get_my_sites(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
查询当前用户关联的所有店铺及对应角色。
|
||||
|
||||
仅 approved 用户可访问(通过 get_current_user 依赖保证)。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT usr.site_id,
|
||||
COALESCE(scm.site_name, '未知店铺') AS site_name,
|
||||
r.code AS role_code,
|
||||
r.name AS role_name
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
LEFT JOIN auth.site_code_mapping scm ON usr.site_id = scm.site_id
|
||||
WHERE usr.user_id = %s
|
||||
ORDER BY usr.site_id, r.code
|
||||
""",
|
||||
(user.user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 按 site_id 分组
|
||||
sites_map: dict[int, SiteInfo] = {}
|
||||
for site_id, site_name, role_code, role_name in rows:
|
||||
if site_id not in sites_map:
|
||||
sites_map[site_id] = SiteInfo(
|
||||
site_id=site_id, site_name=site_name, roles=[]
|
||||
)
|
||||
sites_map[site_id].roles.append({"code": role_code, "name": role_name})
|
||||
|
||||
return list(sites_map.values())
|
||||
|
||||
|
||||
# ── POST /api/xcx/switch-site ────────────────────────────
|
||||
|
||||
@router.post("/switch-site", response_model=WxLoginResponse)
|
||||
async def switch_site(
|
||||
body: SwitchSiteRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
切换当前店铺。
|
||||
|
||||
验证用户在目标 site_id 下有角色绑定,然后签发包含新 site_id 的 JWT。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 验证用户在目标 site_id 下有角色
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM auth.user_site_roles
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(user.user_id, body.site_id),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="您在该店铺下没有角色绑定",
|
||||
)
|
||||
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, body.site_id)
|
||||
|
||||
# 查询用户状态
|
||||
cur.execute(
|
||||
"SELECT status FROM auth.users WHERE id = %s",
|
||||
(user.user_id,),
|
||||
)
|
||||
user_row = cur.fetchone()
|
||||
user_status = user_row[0] if user_row else "pending"
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
tokens = create_token_pair(user.user_id, body.site_id, roles=roles)
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user.user_id,
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/xcx/refresh ────────────────────────────────
|
||||
|
||||
@router.post("/refresh", response_model=WxLoginResponse)
|
||||
async def refresh_token(body: RefreshTokenRequest):
|
||||
"""
|
||||
刷新令牌。
|
||||
|
||||
解码 refresh_token → 根据用户当前状态签发新的令牌对。
|
||||
- 受限 refresh_token(limited=True)→ 签发新的受限令牌对
|
||||
- 完整 refresh_token → 签发新的完整令牌对(保持原 site_id)
|
||||
"""
|
||||
try:
|
||||
payload = decode_refresh_token(body.refresh_token)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的刷新令牌",
|
||||
)
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
is_limited = payload.get("limited", False)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询用户当前状态
|
||||
cur.execute(
|
||||
"SELECT id, status FROM auth.users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
user_row = cur.fetchone()
|
||||
if user_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
_, user_status = user_row
|
||||
|
||||
if user_status == "disabled":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
if is_limited or user_status != "approved":
|
||||
# 受限令牌刷新 → 仍签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# 完整令牌刷新 → 使用原 site_id 签发
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
# 回退到默认 site
|
||||
site_id = _get_user_default_site(conn, user_id)
|
||||
if site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||||
tokens = create_token_pair(user_id, site_id, roles=roles)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
)
|
||||
104
apps/backend/app/schemas/xcx_auth.py
Normal file
104
apps/backend/app/schemas/xcx_auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
小程序认证相关 Pydantic 模型。
|
||||
|
||||
覆盖:微信登录、用户申请、审核、人员匹配、店铺切换等场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── 微信登录 ──────────────────────────────────────────────
|
||||
|
||||
class WxLoginRequest(BaseModel):
|
||||
"""微信登录请求。"""
|
||||
code: str = Field(..., min_length=1, description="微信临时登录凭证")
|
||||
|
||||
|
||||
class WxLoginResponse(BaseModel):
|
||||
"""微信登录响应。"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user_status: str # pending / approved / rejected / disabled
|
||||
user_id: int
|
||||
|
||||
|
||||
# ── 用户申请 ──────────────────────────────────────────────
|
||||
|
||||
class ApplicationRequest(BaseModel):
|
||||
"""用户申请提交请求。"""
|
||||
site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID")
|
||||
applied_role_text: str = Field(..., min_length=1, max_length=100, description="申请身份")
|
||||
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号")
|
||||
employee_number: str | None = Field(None, max_length=50, description="员工编号")
|
||||
nickname: str | None = Field(None, max_length=50, description="昵称")
|
||||
|
||||
|
||||
class ApplicationResponse(BaseModel):
|
||||
"""申请记录响应。"""
|
||||
id: int
|
||||
site_code: str
|
||||
applied_role_text: str
|
||||
status: str
|
||||
review_note: str | None = None
|
||||
created_at: str
|
||||
reviewed_at: str | None = None
|
||||
|
||||
|
||||
# ── 用户状态 ──────────────────────────────────────────────
|
||||
|
||||
class UserStatusResponse(BaseModel):
|
||||
"""用户状态查询响应。"""
|
||||
user_id: int
|
||||
status: str
|
||||
nickname: str | None = None
|
||||
applications: list[ApplicationResponse] = []
|
||||
|
||||
|
||||
# ── 店铺 ──────────────────────────────────────────────────
|
||||
|
||||
class SiteInfo(BaseModel):
|
||||
"""店铺信息。"""
|
||||
site_id: int
|
||||
site_name: str
|
||||
roles: list[dict] = []
|
||||
|
||||
|
||||
class SwitchSiteRequest(BaseModel):
|
||||
"""切换店铺请求。"""
|
||||
site_id: int
|
||||
|
||||
|
||||
# ── 刷新令牌 ──────────────────────────────────────────────
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""刷新令牌请求。"""
|
||||
refresh_token: str = Field(..., min_length=1, description="刷新令牌")
|
||||
|
||||
|
||||
# ── 人员匹配 ──────────────────────────────────────────────
|
||||
|
||||
class MatchCandidate(BaseModel):
|
||||
"""匹配候选人。"""
|
||||
source_type: str # assistant / staff
|
||||
id: int
|
||||
name: str
|
||||
mobile: str | None = None
|
||||
job_num: str | None = None
|
||||
|
||||
|
||||
# ── 管理端审核 ────────────────────────────────────────────
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
"""批准申请请求。"""
|
||||
role_id: int
|
||||
binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
|
||||
review_note: str | None = None
|
||||
|
||||
|
||||
class RejectRequest(BaseModel):
|
||||
"""拒绝申请请求。"""
|
||||
review_note: str = Field(..., min_length=1, description="拒绝原因")
|
||||
|
||||
347
apps/backend/app/services/application.py
Normal file
347
apps/backend/app/services/application.py
Normal file
@@ -0,0 +1,347 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
申请服务 —— 处理用户申请的创建、查询、审核。
|
||||
|
||||
职责:
|
||||
- create_application():创建申请 + site_code 映射查找
|
||||
- approve_application():批准 + 创建绑定/角色
|
||||
- reject_application():拒绝 + 记录原因
|
||||
- get_user_applications():查询用户申请列表
|
||||
|
||||
所有数据库操作使用 psycopg2 原生 SQL,不引入 ORM。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_application(
|
||||
user_id: int,
|
||||
site_code: str,
|
||||
applied_role_text: str,
|
||||
phone: str,
|
||||
employee_number: str | None = None,
|
||||
nickname: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
创建用户申请。
|
||||
|
||||
1. 检查是否有 pending 申请(有则 409)
|
||||
2. 查找 site_code → site_id 映射
|
||||
3. 插入 user_applications 记录
|
||||
4. 更新 users.nickname(如提供)
|
||||
|
||||
返回:
|
||||
申请记录 dict,包含 id / site_code / applied_role_text / status /
|
||||
review_note / created_at / reviewed_at
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 检查重复 pending 申请
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM auth.user_applications
|
||||
WHERE user_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有待审核的申请,请等待审核完成",
|
||||
)
|
||||
|
||||
# 2. 查找 site_code → site_id 映射
|
||||
site_id = None
|
||||
cur.execute(
|
||||
"SELECT site_id FROM auth.site_code_mapping WHERE site_code = %s",
|
||||
(site_code,),
|
||||
)
|
||||
mapping_row = cur.fetchone()
|
||||
if mapping_row is not None:
|
||||
site_id = mapping_row[0]
|
||||
|
||||
# 3. 创建申请记录
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_applications
|
||||
(user_id, site_code, site_id, applied_role_text,
|
||||
phone, employee_number, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
site_code,
|
||||
site_id,
|
||||
applied_role_text,
|
||||
phone,
|
||||
employee_number,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
# 4. 更新 nickname(如提供)
|
||||
if nickname:
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET nickname = %s, updated_at = NOW() WHERE id = %s",
|
||||
(nickname, user_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"id": row[0],
|
||||
"site_code": row[1],
|
||||
"applied_role_text": row[2],
|
||||
"status": row[3],
|
||||
"review_note": row[4],
|
||||
"created_at": row[5],
|
||||
"reviewed_at": row[6],
|
||||
}
|
||||
|
||||
|
||||
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
reviewer_id: int,
|
||||
role_id: int,
|
||||
binding: dict | None = None,
|
||||
review_note: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
批准申请。
|
||||
|
||||
1. 查询申请记录(不存在则 404)
|
||||
2. 检查申请状态为 pending(否则 409)
|
||||
3. 更新 user_applications.status = 'approved'
|
||||
4. 创建 user_site_roles 记录
|
||||
5. 创建 user_assistant_binding 记录(如有 binding)
|
||||
6. 更新 users.status = 'approved'
|
||||
7. 记录 reviewer_id 和 reviewed_at
|
||||
|
||||
参数:
|
||||
application_id: 申请 ID
|
||||
reviewer_id: 审核人 user_id
|
||||
role_id: 分配的角色 ID
|
||||
binding: 绑定信息,格式 {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
|
||||
review_note: 审核备注(可选)
|
||||
|
||||
返回:
|
||||
更新后的申请记录 dict
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 查询申请记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, site_id, status
|
||||
FROM auth.user_applications
|
||||
WHERE id = %s
|
||||
""",
|
||||
(application_id,),
|
||||
)
|
||||
app_row = cur.fetchone()
|
||||
if app_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="申请不存在",
|
||||
)
|
||||
|
||||
_, app_user_id, app_site_id, app_status = app_row
|
||||
|
||||
# 2. 检查状态为 pending
|
||||
if app_status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"申请当前状态为 {app_status},无法审核",
|
||||
)
|
||||
|
||||
# 3. 更新申请状态为 approved
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.user_applications
|
||||
SET status = 'approved',
|
||||
reviewer_id = %s,
|
||||
review_note = %s,
|
||||
reviewed_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
""",
|
||||
(reviewer_id, review_note, application_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
|
||||
# 4. 创建 user_site_roles 记录(如果有 site_id)
|
||||
if app_site_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_site_roles (user_id, site_id, role_id)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id, site_id, role_id) DO NOTHING
|
||||
""",
|
||||
(app_user_id, app_site_id, role_id),
|
||||
)
|
||||
|
||||
# 5. 创建 user_assistant_binding 记录(如有 binding 且有 site_id)
|
||||
if binding and app_site_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_assistant_binding
|
||||
(user_id, site_id, assistant_id, staff_id, binding_type)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
app_user_id,
|
||||
app_site_id,
|
||||
binding.get("assistant_id"),
|
||||
binding.get("staff_id"),
|
||||
binding.get("binding_type", "assistant"),
|
||||
),
|
||||
)
|
||||
|
||||
# 6. 更新用户状态为 approved
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.users
|
||||
SET status = 'approved', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(app_user_id,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"id": updated_row[0],
|
||||
"site_code": updated_row[1],
|
||||
"applied_role_text": updated_row[2],
|
||||
"status": updated_row[3],
|
||||
"review_note": updated_row[4],
|
||||
"created_at": updated_row[5],
|
||||
"reviewed_at": updated_row[6],
|
||||
}
|
||||
|
||||
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
reviewer_id: int,
|
||||
review_note: str,
|
||||
) -> dict:
|
||||
"""
|
||||
拒绝申请。
|
||||
|
||||
1. 查询申请记录(不存在则 404)
|
||||
2. 检查申请状态为 pending(否则 409)
|
||||
3. 更新 user_applications.status = 'rejected'
|
||||
4. 记录 reviewer_id、review_note、reviewed_at
|
||||
|
||||
返回:
|
||||
更新后的申请记录 dict
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 查询申请记录
|
||||
cur.execute(
|
||||
"SELECT id, status FROM auth.user_applications WHERE id = %s",
|
||||
(application_id,),
|
||||
)
|
||||
app_row = cur.fetchone()
|
||||
if app_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="申请不存在",
|
||||
)
|
||||
|
||||
# 2. 检查状态为 pending
|
||||
if app_row[1] != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"申请当前状态为 {app_row[1]},无法审核",
|
||||
)
|
||||
|
||||
# 3. 更新申请状态为 rejected
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.user_applications
|
||||
SET status = 'rejected',
|
||||
reviewer_id = %s,
|
||||
review_note = %s,
|
||||
reviewed_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
""",
|
||||
(reviewer_id, review_note, application_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"id": updated_row[0],
|
||||
"site_code": updated_row[1],
|
||||
"applied_role_text": updated_row[2],
|
||||
"status": updated_row[3],
|
||||
"review_note": updated_row[4],
|
||||
"created_at": updated_row[5],
|
||||
"reviewed_at": updated_row[6],
|
||||
}
|
||||
|
||||
|
||||
async def get_user_applications(user_id: int) -> list[dict]:
|
||||
"""
|
||||
查询用户的所有申请记录。
|
||||
|
||||
按创建时间倒序排列。
|
||||
|
||||
返回:
|
||||
申请记录 dict 列表
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
WHERE user_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"site_code": r[1],
|
||||
"applied_role_text": r[2],
|
||||
"status": r[3],
|
||||
"review_note": r[4],
|
||||
"created_at": r[5],
|
||||
"reviewed_at": r[6],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
170
apps/backend/app/services/matching.py
Normal file
170
apps/backend/app/services/matching.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
人员匹配服务 —— 根据申请信息在 FDW 外部表中查找候选匹配。
|
||||
|
||||
职责:
|
||||
- find_candidates():根据 site_id + phone(+ employee_number)在助教表和员工表中查找匹配
|
||||
|
||||
查询通过业务库的 fdw_etl Schema 访问 ETL 库的 RLS 视图。
|
||||
查询前需 SET LOCAL app.current_site_id 以启用门店隔离。
|
||||
FDW 外部表可能不存在(测试库等场景),需优雅降级返回空列表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_candidates(
|
||||
site_id: int | None,
|
||||
phone: str,
|
||||
employee_number: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
在助教表和员工表中查找匹配候选。
|
||||
|
||||
查询逻辑:
|
||||
1. 若 site_id 为 None,跳过匹配,返回空列表
|
||||
2. 设置 app.current_site_id 进行 RLS 隔离
|
||||
3. fdw_etl.v_dim_assistant: WHERE mobile = phone
|
||||
4. fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex: WHERE mobile = phone OR job_num = employee_number
|
||||
5. 合并结果返回统一候选列表
|
||||
|
||||
参数:
|
||||
site_id: 门店 ID(None 时跳过匹配)
|
||||
phone: 手机号
|
||||
employee_number: 员工编号(可选,用于 job_num 匹配)
|
||||
|
||||
返回:
|
||||
[{"source_type": "assistant"|"staff", "id": int, "name": str,
|
||||
"mobile": str|None, "job_num": str|None}]
|
||||
"""
|
||||
# site_id 为空时直接返回空列表(需求 5.6)
|
||||
if site_id is None:
|
||||
return []
|
||||
|
||||
candidates: list[dict] = []
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
# 设置 RLS 隔离:FDW 会透传 session 变量到远端 ETL 库
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
# 1. 查询助教匹配
|
||||
candidates.extend(_query_assistants(cur, phone))
|
||||
|
||||
# 2. 查询员工匹配
|
||||
candidates.extend(_query_staff(cur, phone, employee_number))
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"FDW 人员匹配查询失败 (site_id=%s, phone=%s),返回空列表",
|
||||
site_id,
|
||||
phone,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _query_assistants(cur, phone: str) -> list[dict]:
|
||||
"""查询 fdw_etl.v_dim_assistant 中按 mobile 匹配的助教记录。"""
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, real_name, mobile
|
||||
FROM fdw_etl.v_dim_assistant
|
||||
WHERE mobile = %s
|
||||
AND scd2_is_current = TRUE
|
||||
""",
|
||||
(phone,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"查询 fdw_etl.v_dim_assistant 失败,跳过助教匹配",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"source_type": "assistant",
|
||||
"id": row[0],
|
||||
"name": row[1] or "",
|
||||
"mobile": row[2],
|
||||
"job_num": None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _query_staff(
|
||||
cur, phone: str, employee_number: str | None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询 fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex
|
||||
按 mobile 或 job_num 匹配的员工记录。
|
||||
"""
|
||||
try:
|
||||
# 构建 WHERE 条件:mobile = phone,或 job_num = employee_number
|
||||
if employee_number:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
|
||||
FROM fdw_etl.v_dim_staff s
|
||||
LEFT JOIN fdw_etl.v_dim_staff_ex ex
|
||||
ON s.staff_id = ex.staff_id
|
||||
AND ex.scd2_is_current = TRUE
|
||||
WHERE s.scd2_is_current = TRUE
|
||||
AND (s.mobile = %s OR ex.job_num = %s)
|
||||
""",
|
||||
(phone, employee_number),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
|
||||
FROM fdw_etl.v_dim_staff s
|
||||
LEFT JOIN fdw_etl.v_dim_staff_ex ex
|
||||
ON s.staff_id = ex.staff_id
|
||||
AND ex.scd2_is_current = TRUE
|
||||
WHERE s.scd2_is_current = TRUE
|
||||
AND s.mobile = %s
|
||||
""",
|
||||
(phone,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"查询 fdw_etl.v_dim_staff 失败,跳过员工匹配",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"source_type": "staff",
|
||||
"id": row[0],
|
||||
"name": row[1] or "",
|
||||
"mobile": row[2],
|
||||
"job_num": row[3],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
129
apps/backend/app/services/role.py
Normal file
129
apps/backend/app/services/role.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
角色权限服务 —— 查询用户在指定店铺下的角色和权限。
|
||||
|
||||
职责:
|
||||
- get_user_permissions():获取用户在指定 site_id 下的权限 code 列表
|
||||
- get_user_sites():获取用户关联的所有店铺及对应角色
|
||||
- check_user_has_site_role():检查用户在指定 site_id 下是否有任何角色绑定
|
||||
|
||||
所有数据库操作使用 psycopg2 原生 SQL,不引入 ORM。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_user_permissions(user_id: int, site_id: int) -> list[str]:
|
||||
"""
|
||||
获取用户在指定 site_id 下的权限 code 列表。
|
||||
|
||||
通过 user_site_roles → role_permissions → permissions 三表联查,
|
||||
返回去重后的权限 code 列表。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID
|
||||
site_id: 门店 ID
|
||||
|
||||
返回:
|
||||
权限 code 字符串列表,如 ["view_tasks", "view_board"]
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT p.code
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.role_permissions rp ON usr.role_id = rp.role_id
|
||||
JOIN auth.permissions p ON rp.permission_id = p.id
|
||||
WHERE usr.user_id = %s AND usr.site_id = %s
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
async def get_user_sites(user_id: int) -> list[dict]:
|
||||
"""
|
||||
获取用户关联的所有店铺及对应角色。
|
||||
|
||||
查询 user_site_roles JOIN roles,LEFT JOIN site_code_mapping 获取店铺名称,
|
||||
按 site_id 分组聚合角色列表。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID
|
||||
|
||||
返回:
|
||||
[{"site_id": int, "site_name": str, "roles": [{"code": str, "name": str}]}]
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT usr.site_id,
|
||||
COALESCE(scm.site_name, '') AS site_name,
|
||||
r.code,
|
||||
r.name
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
LEFT JOIN auth.site_code_mapping scm ON usr.site_id = scm.site_id
|
||||
WHERE usr.user_id = %s
|
||||
ORDER BY usr.site_id, r.code
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 按 site_id 分组聚合
|
||||
sites_map: dict[int, dict] = {}
|
||||
for site_id, site_name, role_code, role_name in rows:
|
||||
if site_id not in sites_map:
|
||||
sites_map[site_id] = {
|
||||
"site_id": site_id,
|
||||
"site_name": site_name,
|
||||
"roles": [],
|
||||
}
|
||||
sites_map[site_id]["roles"].append({"code": role_code, "name": role_name})
|
||||
|
||||
return list(sites_map.values())
|
||||
|
||||
|
||||
async def check_user_has_site_role(user_id: int, site_id: int) -> bool:
|
||||
"""
|
||||
检查用户在指定 site_id 下是否有任何角色绑定。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID
|
||||
site_id: 门店 ID
|
||||
|
||||
返回:
|
||||
True 表示有角色绑定,False 表示无
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM auth.user_site_roles
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -44,7 +44,7 @@ class DwdTableDefinition:
|
||||
ODS_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("ODS_ASSISTANT_ACCOUNT", "助教账号", "抽取助教账号主数据", "助教", "ODS", is_ods=True),
|
||||
TaskDefinition("ODS_ASSISTANT_LEDGER", "助教服务记录", "抽取助教服务流水", "助教", "ODS", is_ods=True),
|
||||
TaskDefinition("ODS_ASSISTANT_ABOLISH", "助教取消记录", "抽取助教取消/作废记录", "助教", "ODS", is_ods=True),
|
||||
|
||||
TaskDefinition("ODS_SETTLEMENT_RECORDS", "结算记录", "抽取订单结算记录", "结算", "ODS", is_ods=True),
|
||||
# CHANGE [2026-07-20] intent: 同步 ETL 侧移除——ODS_SETTLEMENT_TICKET 已在 Task 7.3 中彻底移除
|
||||
TaskDefinition("ODS_TABLE_USE", "台费流水", "抽取台费使用流水", "台桌", "ODS", is_ods=True),
|
||||
@@ -65,6 +65,7 @@ ODS_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("ODS_STORE_GOODS", "门店商品", "抽取门店商品主数据", "商品", "ODS", is_ods=True, requires_window=False),
|
||||
TaskDefinition("ODS_STORE_GOODS_SALES", "商品销售", "抽取门店商品销售记录", "商品", "ODS", is_ods=True),
|
||||
TaskDefinition("ODS_TENANT_GOODS", "租户商品", "抽取租户级商品主数据", "商品", "ODS", is_ods=True, requires_window=False),
|
||||
TaskDefinition("ODS_STAFF_INFO", "员工档案", "抽取员工档案(含在职/离职)", "助教", "ODS", is_ods=True, requires_window=False),
|
||||
]
|
||||
|
||||
# ── DWD 任务定义 ──────────────────────────────────────────────
|
||||
@@ -105,18 +106,17 @@ INDEX_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("DWS_ML_MANUAL_IMPORT", "手动导入 (ML)", "手动导入机器学习数据", "指数", "INDEX", requires_window=False, is_common=False),
|
||||
# CHANGE [2026-02-19] intent: 补充说明 RelationIndexTask 产出 RS/OS/MS/ML 四个子指数
|
||||
TaskDefinition("DWS_RELATION_INDEX", "关系指数 (RS)", "产出 RS/OS/MS/ML 四个子指数", "指数", "INDEX"),
|
||||
TaskDefinition("DWS_SPENDING_POWER_INDEX", "消费力指数 (SPI)", "计算会员消费力指数", "指数", "INDEX"),
|
||||
]
|
||||
|
||||
# ── 工具类任务定义 ────────────────────────────────────────────
|
||||
|
||||
UTILITY_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("MANUAL_INGEST", "手动导入", "从本地 JSON 文件手动导入数据", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("INIT_ODS_SCHEMA", "初始化 ODS Schema", "创建 ODS 层表结构", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("INIT_DWD_SCHEMA", "初始化 DWD Schema", "创建 DWD 层表结构", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("INIT_DWS_SCHEMA", "初始化 DWS Schema", "创建 DWS 层表结构", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
# CHANGE [2026-02-24] intent: 移除 4 个一次性初始化任务(INIT_ODS/DWD/DWS_SCHEMA、SEED_DWS_CONFIG),
|
||||
# 环境已搭建完成,仅保留 ETL 侧实现供运维脚本直接 import 使用,UI 不再展示
|
||||
TaskDefinition("ODS_JSON_ARCHIVE", "ODS JSON 归档", "归档 ODS 原始 JSON 文件", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("CHECK_CUTOFF", "游标检查", "检查各任务数据游标截止点", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("SEED_DWS_CONFIG", "DWS 配置种子", "初始化 DWS 配置数据", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
TaskDefinition("DATA_INTEGRITY_CHECK", "数据完整性校验", "校验跨层数据完整性", "工具", "UTILITY", requires_window=False, is_common=False),
|
||||
]
|
||||
|
||||
@@ -202,8 +202,7 @@ DWD_TABLES: list[DwdTableDefinition] = [
|
||||
DwdTableDefinition("dwd.dwd_store_goods_sale_ex", "商品销售(扩展)", "商品", "ods.store_goods_sales_records"),
|
||||
DwdTableDefinition("dwd.dwd_assistant_service_log", "助教服务流水", "助教", "ods.assistant_service_records"),
|
||||
DwdTableDefinition("dwd.dwd_assistant_service_log_ex", "助教服务流水(扩展)", "助教", "ods.assistant_service_records"),
|
||||
DwdTableDefinition("dwd.dwd_assistant_trash_event", "助教取消事件", "助教", "ods.assistant_cancellation_records"),
|
||||
DwdTableDefinition("dwd.dwd_assistant_trash_event_ex", "助教取消事件(扩展)", "助教", "ods.assistant_cancellation_records"),
|
||||
# CHANGE [2026-02-24] intent: 移除已废弃的 assistant_trash_event 表定义(ODS_ASSISTANT_ABOLISH 全链路已清理)
|
||||
DwdTableDefinition("dwd.dwd_member_balance_change", "会员余额变动", "会员", "ods.member_balance_changes"),
|
||||
DwdTableDefinition("dwd.dwd_member_balance_change_ex", "会员余额变动(扩展)", "会员", "ods.member_balance_changes"),
|
||||
DwdTableDefinition("dwd.dwd_groupbuy_redemption", "团购核销", "团购", "ods.group_buy_redemption_records"),
|
||||
|
||||
90
apps/backend/app/services/wechat.py
Normal file
90
apps/backend/app/services/wechat.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
微信认证服务 —— 封装 code2Session API 调用。
|
||||
|
||||
通过 httpx.AsyncClient 异步调用微信 jscode2session 接口,
|
||||
将小程序端的临时登录凭证 (code) 换取 openid / session_key。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
# 微信 errcode → (HTTP 状态码, 用户可见提示)
|
||||
_WX_ERROR_MAP: dict[int, tuple[int, str]] = {
|
||||
40029: (401, "登录凭证无效,请重新登录"),
|
||||
45011: (429, "请求过于频繁"),
|
||||
40226: (403, "账号存在风险"),
|
||||
}
|
||||
|
||||
|
||||
class WeChatAuthError(Exception):
|
||||
"""微信认证错误,包含 errcode 和 errmsg。"""
|
||||
|
||||
def __init__(self, errcode: int, errmsg: str) -> None:
|
||||
self.errcode = errcode
|
||||
self.errmsg = errmsg
|
||||
super().__init__(f"WeChatAuthError({errcode}): {errmsg}")
|
||||
|
||||
@property
|
||||
def http_status(self) -> int:
|
||||
"""根据 errcode 映射到建议的 HTTP 状态码。"""
|
||||
return _WX_ERROR_MAP.get(self.errcode, (401, ""))[0]
|
||||
|
||||
@property
|
||||
def detail(self) -> str:
|
||||
"""根据 errcode 映射到用户可见的错误提示。"""
|
||||
return _WX_ERROR_MAP.get(self.errcode, (401, "微信登录失败"))[1]
|
||||
|
||||
|
||||
async def code2session(code: str) -> dict:
|
||||
"""
|
||||
调用微信 code2Session 接口。
|
||||
|
||||
参数:
|
||||
code: 小程序端 wx.login() 获取的临时登录凭证
|
||||
|
||||
返回:
|
||||
{"openid": str, "session_key": str, "unionid": str | None}
|
||||
|
||||
异常:
|
||||
WeChatAuthError: 微信接口返回非零 errcode 时抛出
|
||||
RuntimeError: WX_APPID / WX_SECRET 环境变量缺失时抛出
|
||||
"""
|
||||
appid = get("WX_APPID", "")
|
||||
secret = get("WX_SECRET", "")
|
||||
|
||||
if not appid or not secret:
|
||||
raise RuntimeError("微信配置缺失:WX_APPID 或 WX_SECRET 未设置")
|
||||
|
||||
params = {
|
||||
"appid": appid,
|
||||
"secret": secret,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(CODE2SESSION_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
errcode = data.get("errcode", 0)
|
||||
if errcode != 0:
|
||||
errmsg = data.get("errmsg", "unknown error")
|
||||
logger.warning("微信 code2Session 失败: errcode=%s, errmsg=%s", errcode, errmsg)
|
||||
raise WeChatAuthError(errcode, errmsg)
|
||||
|
||||
return {
|
||||
"openid": data["openid"],
|
||||
"session_key": data["session_key"],
|
||||
"unionid": data.get("unionid"),
|
||||
}
|
||||
6
apps/backend/auth_only.txt
Normal file
6
apps/backend/auth_only.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
..................F........... [100%]
|
||||
================================== FAILURES ===================================
|
||||
__________________ test_invalid_credentials_always_rejected ___________________
|
||||
+ Exception Group Traceback (most recent call last):\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 2246, in wrapped_test\n | raise the_error_hypothesis_found\n | hypothesis.errors.FlakyFailure: Hypothesis test_invalid_credentials_always_rejected(password='\u4d77\U0002325c\u0133', username='uv') produces unreliable results: Falsified on the first call but did not on a subsequent one (1 sub-exception)\n | Falsifying example: test_invalid_credentials_always_rejected(\n | username='uv',\n | password='\u4d77\U0002325c\u0133',\n | )\n | Unreliable test timings! On an initial run, this test took 285.80ms, which exceeded the deadline of 200.00ms, but on a subsequent run it took 5.67 ms, which did not. If you expect this sort of variability in your test timings, consider turning deadlines off for this test by setting deadline=None.\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1211, in _execute_once_for_engine\n | result = self.execute_once(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1150, in execute_once\n | result = self.test_runner(data, run)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 824, in default_executor\n | return function(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1107, in run\n | return test(*args, **kwargs)\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1041, in test\n | raise DeadlineExceeded(\n | datetime.timedelta(seconds=runtime), self.settings.deadline\n | )\n | hypothesis.errors.DeadlineExceeded: Test took 285.80ms, which exceeds the deadline of 200.00ms. If you expect test cases to take this long, you can use @settings(deadline=...) to either set a higher deadline, or to disable it with deadline=None.\n +------------------------------------\n=========================== short test summary info ===========================
|
||||
FAILED tests/test_auth_properties.py::test_invalid_credentials_always_rejected
|
||||
1 failed, 29 passed in 11.59s
|
||||
42
apps/backend/auth_only_results.txt
Normal file
42
apps/backend/auth_only_results.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
|
||||
cachedir: .pytest_cache
|
||||
hypothesis profile 'default'
|
||||
rootdir: C:\NeoZQYY\apps\backend
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
|
||||
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 30 items
|
||||
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_hash_and_verify PASSED [ 3%]
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_wrong_password_rejected PASSED [ 6%]
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_hash_is_not_plaintext PASSED [ 10%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_access_token_contains_expected_fields PASSED [ 13%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_refresh_token_contains_expected_fields PASSED [ 16%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_token_pair_returns_both_tokens PASSED [ 20%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_access_token_rejects_refresh PASSED [ 23%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_refresh_token_rejects_access PASSED [ 26%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_access_token_accepts_access PASSED [ 30%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_refresh_token_accepts_refresh PASSED [ 33%]
|
||||
tests/test_auth_jwt.py::TestTokenExpiry::test_expired_token_rejected PASSED [ 36%]
|
||||
tests/test_auth_jwt.py::TestInvalidToken::test_garbage_token_rejected PASSED [ 40%]
|
||||
tests/test_auth_jwt.py::TestInvalidToken::test_wrong_secret_rejected PASSED [ 43%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_valid_access_token PASSED [ 46%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_missing_auth_header_returns_401 PASSED [ 50%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_invalid_token_returns_401 PASSED [ 53%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_refresh_token_rejected PASSED [ 56%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_current_user_is_frozen_dataclass PASSED [ 60%]
|
||||
tests/test_auth_properties.py::test_invalid_credentials_always_rejected PASSED [ 63%]
|
||||
tests/test_auth_properties.py::test_valid_jwt_grants_access PASSED [ 66%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_success PASSED [ 70%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_user_not_found PASSED [ 73%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_wrong_password PASSED [ 76%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_disabled_account PASSED [ 80%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_missing_username PASSED [ 83%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_empty_password PASSED [ 86%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_success PASSED [ 90%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_with_invalid_token PASSED [ 93%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_with_access_token_rejected PASSED [ 96%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_missing_token PASSED [100%]
|
||||
|
||||
============================= 30 passed in 9.92s ==============================
|
||||
42
apps/backend/auth_test_results.txt
Normal file
42
apps/backend/auth_test_results.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
|
||||
cachedir: .pytest_cache
|
||||
hypothesis profile 'default'
|
||||
rootdir: C:\NeoZQYY\apps\backend
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
|
||||
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 30 items
|
||||
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_hash_and_verify PASSED [ 3%]
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_wrong_password_rejected PASSED [ 6%]
|
||||
tests/test_auth_jwt.py::TestPasswordHashing::test_hash_is_not_plaintext PASSED [ 10%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_access_token_contains_expected_fields PASSED [ 13%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_refresh_token_contains_expected_fields PASSED [ 16%]
|
||||
tests/test_auth_jwt.py::TestTokenCreation::test_token_pair_returns_both_tokens PASSED [ 20%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_access_token_rejects_refresh PASSED [ 23%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_refresh_token_rejects_access PASSED [ 26%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_access_token_accepts_access PASSED [ 30%]
|
||||
tests/test_auth_jwt.py::TestTokenTypeValidation::test_decode_refresh_token_accepts_refresh PASSED [ 33%]
|
||||
tests/test_auth_jwt.py::TestTokenExpiry::test_expired_token_rejected PASSED [ 36%]
|
||||
tests/test_auth_jwt.py::TestInvalidToken::test_garbage_token_rejected PASSED [ 40%]
|
||||
tests/test_auth_jwt.py::TestInvalidToken::test_wrong_secret_rejected PASSED [ 43%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_valid_access_token PASSED [ 46%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_missing_auth_header_returns_401 PASSED [ 50%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_invalid_token_returns_401 PASSED [ 53%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_refresh_token_rejected PASSED [ 56%]
|
||||
tests/test_auth_dependencies.py::TestGetCurrentUser::test_current_user_is_frozen_dataclass PASSED [ 60%]
|
||||
tests/test_auth_properties.py::test_invalid_credentials_always_rejected PASSED [ 63%]
|
||||
tests/test_auth_properties.py::test_valid_jwt_grants_access PASSED [ 66%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_success PASSED [ 70%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_user_not_found PASSED [ 73%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_wrong_password PASSED [ 76%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_disabled_account PASSED [ 80%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_missing_username PASSED [ 83%]
|
||||
tests/test_auth_router.py::TestLogin::test_login_empty_password PASSED [ 86%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_success PASSED [ 90%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_with_invalid_token PASSED [ 93%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_with_access_token_rejected PASSED [ 96%]
|
||||
tests/test_auth_router.py::TestRefresh::test_refresh_missing_token PASSED [100%]
|
||||
|
||||
============================= 30 passed in 10.76s =============================
|
||||
349
apps/backend/docs/API-REFERENCE.md
Normal file
349
apps/backend/docs/API-REFERENCE.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# API 参考手册
|
||||
|
||||
后端 API 基于 FastAPI 构建,所有端点均以 `/api/` 为前缀。
|
||||
在线文档:启动后访问 `http://localhost:8000/docs`(Swagger UI)或 `/redoc`(ReDoc)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 管理后台认证 `/api/auth`
|
||||
|
||||
### POST `/api/auth/login`
|
||||
管理后台用户名密码登录。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "username": "admin", "password": "..." }
|
||||
```
|
||||
响应:
|
||||
```json
|
||||
{ "access_token": "...", "refresh_token": "...", "token_type": "bearer" }
|
||||
```
|
||||
|
||||
### POST `/api/auth/refresh`
|
||||
刷新访问令牌。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "refresh_token": "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 小程序认证 `/api/xcx-auth`
|
||||
|
||||
小程序用户的完整生命周期:微信登录 → 提交申请 → 管理员审批 → 正式使用。
|
||||
|
||||
### POST `/api/xcx-auth/login`
|
||||
微信登录。用 `wx.login()` 获取的 code 换取 JWT。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "code": "微信临时登录凭证" }
|
||||
```
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "bearer",
|
||||
"user_status": "pending | approved | rejected | disabled",
|
||||
"user_id": 1
|
||||
}
|
||||
```
|
||||
说明:
|
||||
- 首次登录自动创建 `auth.users` 记录(status=pending)
|
||||
- pending 用户获得受限令牌(`limited=True`),仅可访问申请相关端点
|
||||
- approved 用户获得完整令牌,包含 `site_id` 和 `roles`
|
||||
|
||||
### POST `/api/xcx-auth/apply`
|
||||
提交入驻申请。需受限令牌或完整令牌。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"site_code": "AB123",
|
||||
"applied_role_text": "助教",
|
||||
"phone": "13800138000",
|
||||
"employee_number": "E001",
|
||||
"nickname": "张三"
|
||||
}
|
||||
```
|
||||
说明:
|
||||
- `site_code` 格式:2 字母 + 3 数字(如 `AB123`),映射到 `auth.site_code_mapping`
|
||||
- 后端自动进行人员匹配(`matching.py`),在 ETL 库中查找助教/员工记录
|
||||
|
||||
### GET `/api/xcx-auth/status`
|
||||
查询当前用户状态和申请记录。需受限令牌或完整令牌。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user_id": 1,
|
||||
"status": "approved",
|
||||
"nickname": "张三",
|
||||
"applications": [
|
||||
{
|
||||
"id": 1,
|
||||
"site_code": "AB123",
|
||||
"applied_role_text": "助教",
|
||||
"status": "approved",
|
||||
"review_note": null,
|
||||
"created_at": "2026-02-25T10:00:00",
|
||||
"reviewed_at": "2026-02-25T11:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/xcx-auth/sites`
|
||||
获取当前用户关联的门店列表。需完整令牌。
|
||||
|
||||
### POST `/api/xcx-auth/switch-site`
|
||||
切换当前门店,返回新的令牌对。需完整令牌。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "site_id": 2 }
|
||||
```
|
||||
|
||||
### POST `/api/xcx-auth/refresh`
|
||||
刷新令牌。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "refresh_token": "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 任务配置 `/api/tasks`
|
||||
|
||||
所有端点需 JWT 认证。
|
||||
|
||||
### GET `/api/tasks/registry`
|
||||
按业务域分组的 ETL 任务列表。
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"groups": {
|
||||
"会员": [
|
||||
{
|
||||
"code": "DWD_LOAD_FROM_ODS",
|
||||
"name": "ODS → DWD 加载",
|
||||
"domain": "会员",
|
||||
"layer": "DWD",
|
||||
"requires_window": true,
|
||||
"is_ods": false,
|
||||
"is_dimension": false,
|
||||
"default_enabled": true,
|
||||
"is_common": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/tasks/dwd-tables`
|
||||
按业务域分组的 DWD 表定义。
|
||||
|
||||
### GET `/api/tasks/flows`
|
||||
返回 7 种 Flow 定义和 4 种处理模式。
|
||||
|
||||
Flow 列表:
|
||||
| ID | 名称 | 层级 |
|
||||
|----|------|------|
|
||||
| `api_ods` | API → ODS | ODS |
|
||||
| `api_ods_dwd` | API → ODS → DWD | ODS, DWD |
|
||||
| `api_full` | API → ODS → DWD → DWS → INDEX | ODS, DWD, DWS, INDEX |
|
||||
| `ods_dwd` | ODS → DWD | DWD |
|
||||
| `dwd_dws` | DWD → DWS汇总 | DWS |
|
||||
| `dwd_dws_index` | DWD → DWS → INDEX | DWS, INDEX |
|
||||
| `dwd_index` | DWD → DWS指数 | INDEX |
|
||||
|
||||
处理模式:
|
||||
| ID | 名称 | 说明 |
|
||||
|----|------|------|
|
||||
| `increment_only` | 仅增量处理 | 只处理新增和变更的数据 |
|
||||
| `verify_only` | 仅校验修复 | 校验现有数据并修复不一致 |
|
||||
| `increment_verify` | 增量 + 校验修复 | 先增量处理,再校验并修复 |
|
||||
| `full_window` | 全窗口处理 | 用 API 返回数据的实际时间范围处理全部层 |
|
||||
|
||||
### POST `/api/tasks/validate`
|
||||
验证任务配置并返回 CLI 命令预览。`store_id` 从 JWT 自动注入。
|
||||
|
||||
### GET `/api/tasks/sync-check`
|
||||
对比后端硬编码任务列表与 ETL 真实注册表,返回差异。
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务执行 `/api/execution`
|
||||
|
||||
所有端点需 JWT 认证,`site_id` 从 JWT 提取。
|
||||
|
||||
### POST `/api/execution/run`
|
||||
直接执行任务(不经过队列)。异步启动 ETL CLI 子进程。
|
||||
|
||||
请求体:`TaskConfigSchema`(flow、tasks、window 等)
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "execution_id": "uuid", "message": "任务已提交执行" }
|
||||
```
|
||||
|
||||
### GET `/api/execution/queue`
|
||||
获取当前门店的待执行队列。
|
||||
|
||||
### POST `/api/execution/queue`
|
||||
将任务配置添加到执行队列。
|
||||
|
||||
### PUT `/api/execution/queue/reorder`
|
||||
调整队列中任务的执行顺序。
|
||||
|
||||
### DELETE `/api/execution/queue/{task_id}`
|
||||
从队列中删除待执行任务(仅 pending 状态)。
|
||||
|
||||
### POST `/api/execution/{execution_id}/cancel`
|
||||
取消正在执行的任务。
|
||||
|
||||
### GET `/api/execution/history`
|
||||
执行历史记录(按 `started_at` 降序,默认 50 条,最多 200 条)。
|
||||
|
||||
### GET `/api/execution/{execution_id}/logs`
|
||||
获取指定执行的完整日志。优先从内存缓冲区读取(执行中),否则从数据库读取(已完成)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 调度管理 `/api/schedules`
|
||||
|
||||
所有端点需 JWT 认证。
|
||||
|
||||
### GET `/api/schedules`
|
||||
列出当前门店的所有调度任务。
|
||||
|
||||
### POST `/api/schedules`
|
||||
创建调度任务,自动计算 `next_run_at`。
|
||||
|
||||
### PUT `/api/schedules/{schedule_id}`
|
||||
更新调度任务(部分更新,仅更新请求中提供的字段)。
|
||||
|
||||
### DELETE `/api/schedules/{schedule_id}`
|
||||
删除调度任务。
|
||||
|
||||
### PATCH `/api/schedules/{schedule_id}/toggle`
|
||||
切换启用/禁用状态。禁用时 `next_run_at` 置 NULL;启用时重新计算。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据库查看器 `/api/db`
|
||||
|
||||
所有端点需 JWT 认证。使用 ETL 只读连接 + RLS 门店隔离。
|
||||
|
||||
### GET `/api/db/schemas`
|
||||
返回 ETL 数据库中的 Schema 列表。
|
||||
|
||||
### GET `/api/db/schemas/{name}/tables`
|
||||
返回指定 Schema 下所有表的名称和行数统计。
|
||||
|
||||
### GET `/api/db/tables/{schema}/{table}/columns`
|
||||
返回指定表的列定义(列名、数据类型、是否可空、默认值)。
|
||||
|
||||
### POST `/api/db/query`
|
||||
只读 SQL 执行。
|
||||
|
||||
安全措施:
|
||||
- 拦截写操作关键词(INSERT / UPDATE / DELETE / DROP / TRUNCATE)
|
||||
- 返回行数上限 1000 行
|
||||
- 查询超时 30 秒
|
||||
- 连接级 `read_only` 保护
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "sql": "SELECT * FROM dwd.member_info LIMIT 10" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. ETL 状态 `/api/etl-status`
|
||||
|
||||
### GET `/api/etl-status/cursors`
|
||||
返回各 ODS 表的最新数据游标(查询 `meta.etl_cursor`)。
|
||||
|
||||
### GET `/api/etl-status/recent-runs`
|
||||
返回最近 50 条任务执行记录。
|
||||
|
||||
---
|
||||
|
||||
## 8. 环境配置 `/api/env-config`
|
||||
|
||||
### GET `/api/env-config`
|
||||
读取根 `.env` 文件内容(敏感值脱敏显示)。
|
||||
|
||||
### PUT `/api/env-config`
|
||||
更新 `.env` 文件中的配置项。
|
||||
|
||||
---
|
||||
|
||||
## 9. 运维面板 `/api/ops`
|
||||
|
||||
### GET `/api/ops/system`
|
||||
服务器系统资源概况(CPU、内存、磁盘、启动时间)。
|
||||
|
||||
### GET `/api/ops/services`
|
||||
所有环境(test/prod)的服务运行状态(PID、端口、内存、CPU、运行时长)。
|
||||
|
||||
### POST `/api/ops/services/{env}/start`
|
||||
启动指定环境的后端服务。
|
||||
|
||||
### POST `/api/ops/services/{env}/stop`
|
||||
停止指定环境的后端服务。
|
||||
|
||||
### POST `/api/ops/services/{env}/restart`
|
||||
重启指定环境的后端服务。
|
||||
|
||||
### GET `/api/ops/git`
|
||||
所有环境的 Git 状态(分支、最新提交、是否有本地修改)。
|
||||
|
||||
### POST `/api/ops/git/{env}/pull`
|
||||
对指定环境执行 `git pull --ff-only`。
|
||||
|
||||
### POST `/api/ops/git/{env}/sync-deps`
|
||||
对指定环境执行 `uv sync --all-packages`。
|
||||
|
||||
### GET `/api/ops/env-file/{env}`
|
||||
读取指定环境的 `.env` 文件(敏感值脱敏)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 其他端点
|
||||
|
||||
### GET `/health`
|
||||
健康检查。返回 `{"status": "ok"}`。
|
||||
|
||||
### GET `/api/xcx-test`
|
||||
MVP 全链路验证端点,从 `test."xcx-test"` 表读取数据。
|
||||
|
||||
### GET/POST `/api/wx-callback`
|
||||
微信消息推送回调。GET 用于签名验证,POST 用于接收消息。
|
||||
|
||||
### POST `/api/member-birthday`
|
||||
助教手动补录会员生日。
|
||||
|
||||
### WebSocket `/ws/logs/{execution_id}`
|
||||
实时日志推送。连接后自动接收指定执行的日志流。
|
||||
|
||||
---
|
||||
|
||||
## 错误码约定
|
||||
|
||||
| HTTP 状态码 | 含义 |
|
||||
|-------------|------|
|
||||
| 200 | 成功 |
|
||||
| 201 | 创建成功 |
|
||||
| 400 | 请求参数错误 / SQL 执行错误 |
|
||||
| 401 | 未认证 / 令牌无效 / 受限令牌 |
|
||||
| 404 | 资源不存在 |
|
||||
| 408 | 查询超时 |
|
||||
| 409 | 状态冲突(如删除非 pending 任务) |
|
||||
| 422 | 请求体验证失败 |
|
||||
| 500 | 服务器内部错误 |
|
||||
94
apps/backend/test_results.txt
Normal file
94
apps/backend/test_results.txt
Normal file
@@ -0,0 +1,94 @@
|
||||
..................................................................F..... [ 23%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 71%]
|
||||
....F..FFFFFFFF.F...........................................F..F........ [ 95%]
|
||||
............... [100%]
|
||||
================================== FAILURES ===================================
|
||||
__________ TestGetEtlReadonlyConnection.test_uses_etl_config_params ___________
|
||||
tests\test_database.py:94: in test_uses_etl_config_params
|
||||
assert connect_kwargs["dbname"] == "etl_feiqiu"
|
||||
E AssertionError: assert 'test_etl_feiqiu' == 'etl_feiqiu'
|
||||
E
|
||||
E - etl_feiqiu
|
||||
E + test_etl_feiqiu
|
||||
E ? +++++
|
||||
__________ TestRunningState.test_is_running_true_when_process_active __________
|
||||
tests\test_task_executor.py:118: in test_is_running_true_when_process_active
|
||||
assert executor.is_running("exec-1") is True
|
||||
E AssertionError: assert False is True
|
||||
E + where False = is_running('exec-1')
|
||||
E + where is_running = <app.services.task_executor.TaskExecutor object at 0x000001C9442CBD40>.is_running
|
||||
____________________ TestReadStream.test_read_stdout_lines ____________________
|
||||
tests\test_task_executor.py:146: in test_read_stdout_lines
|
||||
await executor._read_stream("exec-1", stream, "stdout", collector)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
E AttributeError: 'TaskExecutor' object has no attribute '_read_stream'
|
||||
____________________ TestReadStream.test_read_stderr_lines ____________________
|
||||
tests\test_task_executor.py:158: in test_read_stderr_lines
|
||||
await executor._read_stream("exec-1", stream, "stderr", collector)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
E AttributeError: 'TaskExecutor' object has no attribute '_read_stream'
|
||||
________________ TestReadStream.test_read_stream_none_is_safe _________________
|
||||
tests\test_task_executor.py:166: in test_read_stream_none_is_safe
|
||||
await executor._read_stream("exec-1", None, "stdout", collector)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
E AttributeError: 'TaskExecutor' object has no attribute '_read_stream'
|
||||
__________________ TestReadStream.test_broadcast_during_read __________________
|
||||
tests\test_task_executor.py:175: in test_broadcast_during_read
|
||||
await executor._read_stream("exec-1", stream, "stdout", collector)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
E AttributeError: 'TaskExecutor' object has no attribute '_read_stream'
|
||||
____________________ TestExecute.test_successful_execution ____________________
|
||||
tests\test_task_executor.py:218: in test_successful_execution
|
||||
assert "processing..." in update_kwargs["output_log"]
|
||||
E AssertionError: assert 'processing...' in ''
|
||||
______________________ TestExecute.test_failed_execution ______________________
|
||||
tests\test_task_executor.py:245: in test_failed_execution
|
||||
assert update_kwargs["status"] == "failed"
|
||||
E AssertionError: assert 'success' == 'failed'
|
||||
E
|
||||
E - failed
|
||||
E + success
|
||||
_________________ TestExecute.test_exception_during_execution _________________
|
||||
tests\test_task_executor.py:263: in test_exception_during_execution
|
||||
assert update_kwargs["status"] == "failed"
|
||||
E AssertionError: assert 'success' == 'failed'
|
||||
E
|
||||
E - failed
|
||||
E + success
|
||||
_____________ TestExecute.test_subscribers_notified_on_completion _____________
|
||||
tests\test_task_executor.py:290: in test_subscribers_notified_on_completion
|
||||
assert "[stdout] line" in messages
|
||||
E AssertionError: assert '[stdout] line' in ['[stderr] [2026-02-25 02:42:10] INFO | etl_billiards | 配置加载完成', '[stderr] [2026-02-25 02:42:10] INFO | etl_billiard... 02:42:11] INFO | etl_billiards | 使用回溯时间窗口: 2026-02-24 02:42:11.615145+08:00 ~ 2026-02-25 02:42:11.615145+08:00', ...]
|
||||
___________________ TestCancel.test_cancel_running_process ____________________
|
||||
tests\test_task_executor.py:331: in test_cancel_running_process
|
||||
assert result is True
|
||||
E assert False is True
|
||||
_____________ TestFlows.test_flows_returns_three_processing_modes _____________
|
||||
tests\test_tasks_router.py:124: in test_flows_returns_three_processing_modes
|
||||
assert len(data["processing_modes"]) == 3
|
||||
E AssertionError: assert 4 == 3
|
||||
E + where 4 = len([{'description': '只处理新增和变更的数据', 'id': 'increment_only', 'name': '仅增量处理'}, {'description': '校验现有数据并修复不一致', 'id': 'verif...nt_verify', 'name': '增量 + 校验修复'}, {'description': '用 API 返回数据的实际时间范围处理全部层,无需校验', 'id': 'full_window', 'name': '全窗口处理'}])
|
||||
_____________________ TestFlows.test_processing_mode_ids ______________________
|
||||
tests\test_tasks_router.py:143: in test_processing_mode_ids
|
||||
assert mode_ids == {"increment_only", "verify_only", "increment_verify"}
|
||||
E AssertionError: assert {'full_window...'verify_only'} == {'increment_o...'verify_only'}
|
||||
E
|
||||
E Extra items in the left set:
|
||||
E 'full_window'
|
||||
E Use -v to get more diff
|
||||
=========================== short test summary info ===========================
|
||||
FAILED tests/test_database.py::TestGetEtlReadonlyConnection::test_uses_etl_config_params
|
||||
FAILED tests/test_task_executor.py::TestRunningState::test_is_running_true_when_process_active
|
||||
FAILED tests/test_task_executor.py::TestReadStream::test_read_stdout_lines - ...
|
||||
FAILED tests/test_task_executor.py::TestReadStream::test_read_stderr_lines - ...
|
||||
FAILED tests/test_task_executor.py::TestReadStream::test_read_stream_none_is_safe
|
||||
FAILED tests/test_task_executor.py::TestReadStream::test_broadcast_during_read
|
||||
FAILED tests/test_task_executor.py::TestExecute::test_successful_execution - ...
|
||||
FAILED tests/test_task_executor.py::TestExecute::test_failed_execution - Asse...
|
||||
FAILED tests/test_task_executor.py::TestExecute::test_exception_during_execution
|
||||
FAILED tests/test_task_executor.py::TestExecute::test_subscribers_notified_on_completion
|
||||
FAILED tests/test_task_executor.py::TestCancel::test_cancel_running_process
|
||||
FAILED tests/test_tasks_router.py::TestFlows::test_flows_returns_three_processing_modes
|
||||
FAILED tests/test_tasks_router.py::TestFlows::test_processing_mode_ids - Asse...
|
||||
13 failed, 290 passed in 146.30s (0:02:26)
|
||||
Reference in New Issue
Block a user