微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
214
apps/backend/app/routers/admin_applications.py
Normal file
214
apps/backend/app/routers/admin_applications.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端审核路由 —— 申请列表、详情、批准、拒绝。
|
||||
|
||||
端点清单:
|
||||
- GET /api/admin/applications — 查询申请列表(可按 status 过滤)
|
||||
- GET /api/admin/applications/{id} — 查询申请详情 + 候选匹配
|
||||
- POST /api/admin/applications/{id}/approve — 批准申请
|
||||
- POST /api/admin/applications/{id}/reject — 拒绝申请
|
||||
|
||||
所有端点要求 JWT + site_admin 或 tenant_admin 角色。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.database import get_connection
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationResponse,
|
||||
ApproveRequest,
|
||||
MatchCandidate,
|
||||
RejectRequest,
|
||||
)
|
||||
from app.services.application import approve_application, reject_application
|
||||
from app.services.matching import find_candidates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["管理端审核"])
|
||||
|
||||
|
||||
# ── 管理端需要 site_admin 或 tenant_admin 权限 ─────────────
|
||||
# require_permission() 不检查具体 permission code,
|
||||
# 但会验证 status=approved;管理端路由额外在依赖中检查角色。
|
||||
# 设计文档要求 site_admin / tenant_admin 角色,
|
||||
# 这里通过检查 CurrentUser.roles 实现。
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求用户 status=approved 且角色包含 site_admin 或 tenant_admin。
|
||||
|
||||
复用 require_permission()(无具体权限码 → 仅检查 approved),
|
||||
再额外校验角色列表。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(require_permission()),
|
||||
) -> CurrentUser:
|
||||
admin_roles = {"site_admin", "tenant_admin"}
|
||||
if not admin_roles.intersection(user.roles):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── GET /api/admin/applications ───────────────────────────
|
||||
|
||||
|
||||
@router.get("/applications", response_model=list[ApplicationResponse])
|
||||
async def list_applications(
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="按状态过滤:pending / approved / rejected"
|
||||
),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""查询申请列表,可按 status 过滤。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if status_filter:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
WHERE status = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
ApplicationResponse(
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
# ── GET /api/admin/applications/{id} ─────────────────────
|
||||
|
||||
|
||||
@router.get("/applications/{application_id}")
|
||||
async def get_application_detail(
|
||||
application_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""查询申请详情 + 候选匹配。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, site_code, site_id, applied_role_text,
|
||||
phone, employee_number, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
WHERE id = %s
|
||||
""",
|
||||
(application_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="申请不存在",
|
||||
)
|
||||
|
||||
app_data = {
|
||||
"id": row[0],
|
||||
"user_id": row[1],
|
||||
"site_code": row[2],
|
||||
"site_id": row[3],
|
||||
"applied_role_text": row[4],
|
||||
"phone": row[5],
|
||||
"employee_number": row[6],
|
||||
"status": row[7],
|
||||
"review_note": row[8],
|
||||
"created_at": row[9],
|
||||
"reviewed_at": row[10],
|
||||
}
|
||||
|
||||
# 查找候选匹配
|
||||
candidates_raw = await find_candidates(
|
||||
site_id=app_data["site_id"],
|
||||
phone=app_data["phone"],
|
||||
employee_number=app_data["employee_number"],
|
||||
)
|
||||
candidates = [MatchCandidate(**c) for c in candidates_raw]
|
||||
|
||||
return {
|
||||
"application": app_data,
|
||||
"candidates": [c.model_dump() for c in candidates],
|
||||
}
|
||||
|
||||
|
||||
# ── POST /api/admin/applications/{id}/approve ────────────
|
||||
|
||||
|
||||
@router.post("/applications/{application_id}/approve", response_model=ApplicationResponse)
|
||||
async def approve(
|
||||
application_id: int,
|
||||
body: ApproveRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""批准申请:分配角色 + 可选绑定。"""
|
||||
result = await approve_application(
|
||||
application_id=application_id,
|
||||
reviewer_id=user.user_id,
|
||||
role_id=body.role_id,
|
||||
binding=body.binding,
|
||||
review_note=body.review_note,
|
||||
)
|
||||
return ApplicationResponse(**result)
|
||||
|
||||
|
||||
# ── POST /api/admin/applications/{id}/reject ─────────────
|
||||
|
||||
|
||||
@router.post("/applications/{application_id}/reject", response_model=ApplicationResponse)
|
||||
async def reject(
|
||||
application_id: int,
|
||||
body: RejectRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""拒绝申请:记录拒绝原因。"""
|
||||
result = await reject_application(
|
||||
application_id=application_id,
|
||||
reviewer_id=user.user_id,
|
||||
review_note=body.review_note,
|
||||
)
|
||||
return ApplicationResponse(**result)
|
||||
20
apps/backend/app/routers/business_day.py
Normal file
20
apps/backend/app/routers/business_day.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""营业日配置 API
|
||||
|
||||
提供公开端点返回当前营业日分割点配置,供前端动态获取。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["业务配置"])
|
||||
|
||||
|
||||
@router.get("/business-day")
|
||||
async def get_business_day_config():
|
||||
"""返回当前营业日分割点配置。
|
||||
|
||||
无需认证(公开配置),前端启动时调用一次缓存。
|
||||
"""
|
||||
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
|
||||
@@ -202,7 +202,8 @@ async def get_execution_history(
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, task_codes, status, started_at,
|
||||
finished_at, exit_code, duration_ms, command, summary
|
||||
finished_at, exit_code, duration_ms, command, summary,
|
||||
schedule_id
|
||||
FROM task_execution_log
|
||||
WHERE site_id = %s
|
||||
ORDER BY started_at DESC
|
||||
@@ -227,6 +228,7 @@ async def get_execution_history(
|
||||
duration_ms=row[7],
|
||||
command=row[8],
|
||||
summary=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
102
apps/backend/app/routers/member_retention_clue.py
Normal file
102
apps/backend/app/routers/member_retention_clue.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
维客线索路由。
|
||||
|
||||
- POST /api/retention-clue — 提交维客线索(UPSERT)
|
||||
- GET /api/retention-clue/{member_id} — 查询某会员的全部维客线索
|
||||
- DELETE /api/retention-clue/{clue_id} — 删除单条线索
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import get_connection
|
||||
from app.schemas.member_retention_clue import RetentionClueSubmit, RetentionClueOut
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["维客线索"])
|
||||
|
||||
|
||||
@router.post("/retention-clue")
|
||||
async def submit_retention_clue(body: RetentionClueSubmit):
|
||||
"""
|
||||
提交维客线索(INSERT)。
|
||||
|
||||
同一会员可有多条不同大类的线索。
|
||||
"""
|
||||
sql = """
|
||||
INSERT INTO member_retention_clue
|
||||
(member_id, category, summary, detail,
|
||||
recorded_by_assistant_id, recorded_by_name, site_id, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (
|
||||
body.member_id,
|
||||
body.category.value,
|
||||
body.summary,
|
||||
body.detail,
|
||||
body.recorded_by_assistant_id,
|
||||
body.recorded_by_name,
|
||||
body.site_id,
|
||||
body.source.value,
|
||||
))
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": row[0] if row else None}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.exception("维客线索写入失败: member_id=%s", body.member_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="线索提交失败,请稍后重试",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/retention-clue/{member_id}", response_model=list[RetentionClueOut])
|
||||
async def get_retention_clues(member_id: int, site_id: int):
|
||||
"""查询某会员的全部维客线索,按录入时间倒序。"""
|
||||
sql = """
|
||||
SELECT id, member_id, category, summary, detail,
|
||||
recorded_by_assistant_id, recorded_by_name, recorded_at, site_id, source
|
||||
FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
ORDER BY recorded_at DESC
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (member_id, site_id))
|
||||
rows = cur.fetchall()
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/retention-clue/{clue_id}")
|
||||
async def delete_retention_clue(clue_id: int):
|
||||
"""删除单条维客线索。"""
|
||||
sql = "DELETE FROM member_retention_clue WHERE id = %s"
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (clue_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="线索不存在")
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.exception("维客线索删除失败: id=%s", clue_id)
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -17,12 +17,13 @@ import psutil
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import OPS_SERVER_BASE
|
||||
|
||||
router = APIRouter(prefix="/api/ops", tags=["运维面板"])
|
||||
|
||||
# ---- 环境定义 ----
|
||||
# 服务器上的两套环境;开发机上回退到本机路径(方便调试)
|
||||
|
||||
_SERVER_BASE = Path("D:/NeoZQYY")
|
||||
# CHANGE 2026-03-04 | 从 config 读取,消除硬编码 D 盘路径
|
||||
_SERVER_BASE = Path(OPS_SERVER_BASE)
|
||||
|
||||
ENVIRONMENTS: dict[str, dict[str, Any]] = {
|
||||
"test": {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""调度任务 CRUD API
|
||||
|
||||
提供 5 个端点:
|
||||
提供 8 个端点:
|
||||
- GET /api/schedules — 列表(按 site_id 过滤)
|
||||
- POST /api/schedules — 创建
|
||||
- POST /api/schedules — 创建(支持 run_immediately)
|
||||
- PUT /api/schedules/{id} — 更新
|
||||
- DELETE /api/schedules/{id} — 删除
|
||||
- PATCH /api/schedules/{id}/toggle — 启用/禁用
|
||||
- GET /api/schedules/{id}/history — 调度任务执行历史
|
||||
- POST /api/schedules/{id}/run — 手动执行一次(不更新调度间隔)
|
||||
|
||||
所有端点需要 JWT 认证,site_id 从 JWT 提取。
|
||||
"""
|
||||
@@ -17,7 +19,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
@@ -26,7 +28,10 @@ from app.schemas.schedules import (
|
||||
ScheduleResponse,
|
||||
UpdateScheduleRequest,
|
||||
)
|
||||
from app.schemas.execution import ExecutionHistoryItem
|
||||
from app.schemas.tasks import TaskConfigSchema
|
||||
from app.services.scheduler import calculate_next_run
|
||||
from app.services.task_queue import task_queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,7 +97,7 @@ async def create_schedule(
|
||||
body: CreateScheduleRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ScheduleResponse:
|
||||
"""创建调度任务,自动计算 next_run_at。"""
|
||||
"""创建调度任务,自动计算 next_run_at。支持 run_immediately 立即入队执行一次。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
next_run = calculate_next_run(body.schedule_config, now)
|
||||
|
||||
@@ -124,7 +129,18 @@ async def create_schedule(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _row_to_response(row)
|
||||
response = _row_to_response(row)
|
||||
|
||||
# 立即执行一次(入队,不影响调度间隔)
|
||||
if body.run_immediately:
|
||||
try:
|
||||
config = TaskConfigSchema(**body.task_config)
|
||||
config = config.model_copy(update={"store_id": user.site_id})
|
||||
task_queue.enqueue(config, user.site_id, schedule_id=response.id)
|
||||
except Exception:
|
||||
logger.warning("创建调度后立即执行入队失败 schedule_id=%s", response.id, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ── PUT /api/schedules/{id} — 更新 ──────────────────────────
|
||||
@@ -291,3 +307,88 @@ async def toggle_schedule(
|
||||
conn.close()
|
||||
|
||||
return _row_to_response(updated_row)
|
||||
|
||||
|
||||
# ── POST /api/schedules/{id}/run — 手动执行一次 ──────────────
|
||||
|
||||
@router.post("/{schedule_id}/run")
|
||||
async def run_schedule_now(
|
||||
schedule_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""手动触发调度任务执行一次,不更新 last_run_at / next_run_at / run_count。
|
||||
|
||||
读取调度任务的 task_config,构造 TaskConfigSchema 后入队执行。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT task_config, site_id FROM scheduled_tasks WHERE id = %s AND site_id = %s",
|
||||
(schedule_id, user.site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="调度任务不存在",
|
||||
)
|
||||
|
||||
task_config_raw = row[0] if isinstance(row[0], dict) else json.loads(row[0])
|
||||
config = TaskConfigSchema(**task_config_raw)
|
||||
config = config.model_copy(update={"store_id": user.site_id})
|
||||
task_id = task_queue.enqueue(config, user.site_id, schedule_id=schedule_id)
|
||||
|
||||
return {"message": "已提交到执行队列", "task_id": task_id}
|
||||
|
||||
|
||||
# ── GET /api/schedules/{id}/history — 执行历史 ────────────────
|
||||
|
||||
@router.get("/{schedule_id}/history", response_model=list[ExecutionHistoryItem])
|
||||
async def get_schedule_history(
|
||||
schedule_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ExecutionHistoryItem]:
|
||||
"""获取调度任务的执行历史记录,按开始时间倒序,支持分页。"""
|
||||
offset = (page - 1) * page_size
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, task_codes, status, started_at, finished_at,
|
||||
exit_code, duration_ms, command, summary, schedule_id
|
||||
FROM task_execution_log
|
||||
WHERE schedule_id = %s AND site_id = %s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(schedule_id, user.site_id, page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
ExecutionHistoryItem(
|
||||
id=str(row[0]),
|
||||
site_id=row[1],
|
||||
task_codes=row[2] or [],
|
||||
status=row[3],
|
||||
started_at=row[4],
|
||||
finished_at=row[5],
|
||||
exit_code=row[6],
|
||||
duration_ms=row[7],
|
||||
command=row[8],
|
||||
summary=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -236,6 +236,16 @@ async def sync_check(
|
||||
|
||||
backend_codes = {t.code for t in ALL_TASKS}
|
||||
|
||||
# ETL 侧存在但后端故意不注册的任务(一次性初始化 / 尚未上线)
|
||||
# 从 etl_only 差集中排除,避免同步检查误报
|
||||
ETL_ONLY_EXPECTED: set[str] = {
|
||||
"INIT_ODS_SCHEMA", # 一次性:ODS schema 初始化
|
||||
"INIT_DWD_SCHEMA", # 一次性:DWD schema 初始化
|
||||
"INIT_DWS_SCHEMA", # 一次性:DWS schema 初始化
|
||||
"SEED_DWS_CONFIG", # 一次性:DWS 配置种子数据
|
||||
"DWS_ASSISTANT_ORDER_CONTRIBUTION", # DWS 任务,后端暂未注册
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
@@ -257,7 +267,7 @@ async def sync_check(
|
||||
)
|
||||
|
||||
backend_only = sorted(backend_codes - etl_codes)
|
||||
etl_only = sorted(etl_codes - backend_codes)
|
||||
etl_only = sorted((etl_codes - backend_codes) - ETL_ONLY_EXPECTED)
|
||||
|
||||
return SyncCheckResponse(
|
||||
in_sync=len(backend_only) == 0 and len(etl_only) == 0,
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
- GET /api/xcx/me/sites — 查询关联店铺
|
||||
- POST /api/xcx/switch-site — 切换当前店铺
|
||||
- POST /api/xcx/refresh — 刷新令牌
|
||||
- POST /api/xcx/dev-login — 开发模式 mock 登录(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-role — 切换角色(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-status — 切换用户状态(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-binding — 切换人员绑定(仅 WX_DEV_MODE=true)
|
||||
- GET /api/xcx/dev-context — 查询调试上下文(仅 WX_DEV_MODE=true)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +34,7 @@ from app.auth.jwt import (
|
||||
create_token_pair,
|
||||
decode_refresh_token,
|
||||
)
|
||||
from app import config
|
||||
from app.database import get_connection
|
||||
from app.services.application import (
|
||||
create_application,
|
||||
@@ -37,6 +43,11 @@ from app.services.application import (
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationRequest,
|
||||
ApplicationResponse,
|
||||
DevLoginRequest,
|
||||
DevSwitchBindingRequest,
|
||||
DevSwitchRoleRequest,
|
||||
DevSwitchStatusRequest,
|
||||
DevContextResponse,
|
||||
RefreshTokenRequest,
|
||||
SiteInfo,
|
||||
SwitchSiteRequest,
|
||||
@@ -45,6 +56,7 @@ from app.schemas.xcx_auth import (
|
||||
WxLoginResponse,
|
||||
)
|
||||
from app.services.wechat import WeChatAuthError, code2session
|
||||
from app.services.role import get_user_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,9 +106,9 @@ async def wx_login(body: WxLoginRequest):
|
||||
|
||||
流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。
|
||||
- disabled 用户返回 403
|
||||
- 新用户自动创建(status=pending)
|
||||
- 新用户自动创建(status=new),前端引导至申请页
|
||||
- approved 用户签发包含 site_id + roles 的完整令牌
|
||||
- pending/rejected 用户签发受限令牌
|
||||
- new/pending/rejected 用户签发受限令牌
|
||||
"""
|
||||
# 1. 调用微信 code2Session
|
||||
try:
|
||||
@@ -125,12 +137,12 @@ async def wx_login(body: WxLoginRequest):
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
# 新用户:创建 pending 记录
|
||||
# 新用户:创建 new 记录(尚未提交申请)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.users (wx_openid, wx_union_id, status)
|
||||
VALUES (%s, %s, 'pending')
|
||||
VALUES (%s, %s, 'new')
|
||||
RETURNING id, status
|
||||
""",
|
||||
(openid, unionid),
|
||||
@@ -166,7 +178,7 @@ async def wx_login(body: WxLoginRequest):
|
||||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# pending / rejected → 受限令牌
|
||||
# new / pending / rejected → 受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
@@ -415,3 +427,333 @@ async def refresh_token(body: RefreshTokenRequest):
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-login(仅开发模式) ─────────────────
|
||||
|
||||
if config.WX_DEV_MODE:
|
||||
|
||||
@router.post("/dev-login", response_model=WxLoginResponse)
|
||||
async def dev_login(body: DevLoginRequest):
|
||||
"""
|
||||
开发模式 mock 登录。
|
||||
|
||||
直接根据 openid 查找/创建用户,跳过微信 code2Session。
|
||||
- 已有用户:status 参数为空时保留当前状态,非空时覆盖
|
||||
- 新用户:status 参数为空时默认 new,非空时使用指定值
|
||||
仅在 WX_DEV_MODE=true 时注册。
|
||||
"""
|
||||
openid = body.openid
|
||||
target_status = body.status # 可能为 None
|
||||
|
||||
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:
|
||||
# 新用户:使用指定状态或默认 new
|
||||
init_status = target_status or "new"
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.users (wx_openid, status)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id, status
|
||||
""",
|
||||
(openid, init_status),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
else:
|
||||
# 已有用户:仅在显式传入 status 时覆盖
|
||||
if target_status is not None:
|
||||
user_id_existing = row[0]
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(target_status, user_id_existing),
|
||||
)
|
||||
conn.commit()
|
||||
row = (user_id_existing, target_status)
|
||||
|
||||
user_id, user_status = row
|
||||
|
||||
# 签发令牌(逻辑与正常登录一致)
|
||||
if user_status == "approved":
|
||||
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:
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
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,
|
||||
)
|
||||
|
||||
# ── GET /api/xcx/dev-context(仅开发模式) ────────────────
|
||||
|
||||
@router.get("/dev-context", response_model=DevContextResponse)
|
||||
async def dev_context(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
返回当前用户的完整调试上下文。
|
||||
|
||||
包含:用户信息、当前门店、角色、权限、人员绑定、所有关联门店。
|
||||
允许受限令牌访问(返回基础信息,门店/角色/权限为空)。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 用户基本信息
|
||||
cur.execute(
|
||||
"SELECT wx_openid, status, nickname FROM auth.users WHERE id = %s",
|
||||
(user.user_id,),
|
||||
)
|
||||
u_row = cur.fetchone()
|
||||
if u_row is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
openid, u_status, nickname = u_row
|
||||
|
||||
# 当前门店名称
|
||||
site_name = None
|
||||
if user.site_id:
|
||||
cur.execute(
|
||||
"SELECT site_name FROM auth.site_code_mapping WHERE site_id = %s",
|
||||
(user.site_id,),
|
||||
)
|
||||
sn_row = cur.fetchone()
|
||||
site_name = sn_row[0] if sn_row else None
|
||||
|
||||
# 当前门店下的角色
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id) if user.site_id else []
|
||||
|
||||
# 当前门店下的权限
|
||||
permissions = await get_user_permissions(user.user_id, user.site_id) if user.site_id else []
|
||||
|
||||
# 人员绑定
|
||||
binding = None
|
||||
if user.site_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, staff_id, binding_type
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
b_row = cur.fetchone()
|
||||
if b_row:
|
||||
binding = {
|
||||
"assistant_id": b_row[0],
|
||||
"staff_id": b_row[1],
|
||||
"binding_type": b_row[2],
|
||||
}
|
||||
|
||||
# 所有关联门店
|
||||
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.user_id,),
|
||||
)
|
||||
site_rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
sites_map: dict[int, dict] = {}
|
||||
for sid, sname, rcode, rname in site_rows:
|
||||
if sid not in sites_map:
|
||||
sites_map[sid] = {"site_id": sid, "site_name": sname, "roles": []}
|
||||
sites_map[sid]["roles"].append({"code": rcode, "name": rname})
|
||||
|
||||
return DevContextResponse(
|
||||
user_id=user.user_id,
|
||||
openid=openid,
|
||||
status=u_status,
|
||||
nickname=nickname,
|
||||
site_id=user.site_id,
|
||||
site_name=site_name,
|
||||
roles=roles,
|
||||
permissions=permissions,
|
||||
binding=binding,
|
||||
all_sites=list(sites_map.values()),
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-role(仅开发模式) ───────────
|
||||
|
||||
@router.post("/dev-switch-role", response_model=WxLoginResponse)
|
||||
async def dev_switch_role(
|
||||
body: DevSwitchRoleRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
切换当前用户在当前门店下的角色。
|
||||
|
||||
删除旧角色绑定,插入新角色绑定,重签 token。
|
||||
"""
|
||||
valid_roles = ("coach", "staff", "site_admin", "tenant_admin")
|
||||
if body.role_code not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效角色,可选: {', '.join(valid_roles)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询目标角色 ID
|
||||
cur.execute(
|
||||
"SELECT id FROM auth.roles WHERE code = %s",
|
||||
(body.role_code,),
|
||||
)
|
||||
role_row = cur.fetchone()
|
||||
if role_row is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色 {body.role_code} 不存在")
|
||||
role_id = role_row[0]
|
||||
|
||||
# 删除当前门店下的所有角色
|
||||
cur.execute(
|
||||
"DELETE FROM auth.user_site_roles WHERE user_id = %s AND site_id = %s",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
|
||||
# 插入新角色
|
||||
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
|
||||
""",
|
||||
(user.user_id, user.site_id, role_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 重签 token
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id)
|
||||
cur.execute("SELECT status FROM auth.users WHERE id = %s", (user.user_id,))
|
||||
u_status = cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
tokens = create_token_pair(user.user_id, user.site_id, roles=roles)
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=u_status,
|
||||
user_id=user.user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-status(仅开发模式) ─────────
|
||||
|
||||
@router.post("/dev-switch-status", response_model=WxLoginResponse)
|
||||
async def dev_switch_status(
|
||||
body: DevSwitchStatusRequest,
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
切换当前用户状态,重签 token。
|
||||
|
||||
允许受限令牌访问(pending 用户也需要能切换状态)。
|
||||
"""
|
||||
valid_statuses = ("new", "pending", "approved", "rejected", "disabled")
|
||||
if body.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效状态,可选: {', '.join(valid_statuses)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(body.status, user.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 根据新状态签发对应令牌
|
||||
if body.status == "approved":
|
||||
default_site_id = _get_user_default_site(conn, user.user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
|
||||
tokens = create_token_pair(user.user_id, default_site_id, roles=roles)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user.user_id)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user.user_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=body.status,
|
||||
user_id=user.user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-binding(仅开发模式) ────────
|
||||
|
||||
@router.post("/dev-switch-binding")
|
||||
async def dev_switch_binding(
|
||||
body: DevSwitchBindingRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
切换当前用户在当前门店下的人员绑定。
|
||||
|
||||
删除旧绑定,插入新绑定。
|
||||
"""
|
||||
valid_types = ("assistant", "staff", "manager")
|
||||
if body.binding_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效绑定类型,可选: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 删除当前门店下的旧绑定
|
||||
cur.execute(
|
||||
"DELETE FROM auth.user_assistant_binding WHERE user_id = %s AND site_id = %s",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
|
||||
# 插入新绑定
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_assistant_binding
|
||||
(user_id, site_id, assistant_id, staff_id, binding_type)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(user.user_id, user.site_id, body.assistant_id, body.staff_id, body.binding_type),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"ok": True, "binding_type": body.binding_type}
|
||||
|
||||
67
apps/backend/app/routers/xcx_notes.py
Normal file
67
apps/backend/app/routers/xcx_notes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序备注路由 —— 备注 CRUD(含星星评分)。
|
||||
|
||||
端点清单:
|
||||
- POST /api/xcx/notes — 创建备注
|
||||
- GET /api/xcx/notes — 查询备注列表(query: target_type, target_id)
|
||||
- DELETE /api/xcx/notes/{id} — 删除备注
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_notes import NoteCreateRequest, NoteOut
|
||||
from app.services import note_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/notes", tags=["小程序备注"])
|
||||
|
||||
|
||||
@router.post("", response_model=NoteOut)
|
||||
async def create_note(
|
||||
body: NoteCreateRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""创建备注(含星星评分,可选关联任务)。"""
|
||||
return await note_service.create_note(
|
||||
site_id=user.site_id,
|
||||
user_id=user.user_id,
|
||||
target_type=body.target_type,
|
||||
target_id=body.target_id,
|
||||
content=body.content,
|
||||
task_id=body.task_id,
|
||||
rating_service_willingness=body.rating_service_willingness,
|
||||
rating_revisit_likelihood=body.rating_revisit_likelihood,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_notes(
|
||||
target_type: str = Query("member", description="目标类型"),
|
||||
target_id: int = Query(..., description="目标 ID"),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""查询某目标的备注列表(按创建时间倒序)。"""
|
||||
return await note_service.get_notes(
|
||||
site_id=user.site_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""删除备注(验证归属后硬删除)。"""
|
||||
return await note_service.delete_note(
|
||||
note_id=note_id,
|
||||
user_id=user.user_id,
|
||||
site_id=user.site_id,
|
||||
)
|
||||
71
apps/backend/app/routers/xcx_tasks.py
Normal file
71
apps/backend/app/routers/xcx_tasks.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序任务路由 —— 任务列表、置顶、放弃、取消放弃。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/tasks — 获取活跃任务列表
|
||||
- POST /api/xcx/tasks/{id}/pin — 置顶任务
|
||||
- POST /api/xcx/tasks/{id}/unpin — 取消置顶
|
||||
- POST /api/xcx/tasks/{id}/abandon — 放弃任务
|
||||
- POST /api/xcx/tasks/{id}/cancel-abandon — 取消放弃
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_tasks import AbandonRequest, TaskListItem
|
||||
from app.services import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/tasks", tags=["小程序任务"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskListItem])
|
||||
async def get_tasks(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取当前助教的活跃任务列表。"""
|
||||
return await task_manager.get_task_list(user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/pin")
|
||||
async def pin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""置顶任务。"""
|
||||
return await task_manager.pin_task(task_id, user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/unpin")
|
||||
async def unpin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""取消置顶。"""
|
||||
return await task_manager.unpin_task(task_id, user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/abandon")
|
||||
async def abandon_task(
|
||||
task_id: int,
|
||||
body: AbandonRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""放弃任务(需填写原因)。"""
|
||||
return await task_manager.abandon_task(
|
||||
task_id, user.user_id, user.site_id, body.reason
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel-abandon")
|
||||
async def cancel_abandon(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""取消放弃,恢复为活跃状态。"""
|
||||
return await task_manager.cancel_abandon(task_id, user.user_id, user.site_id)
|
||||
Reference in New Issue
Block a user