包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
492 lines
16 KiB
Python
492 lines
16 KiB
Python
# -*- 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
|
||
from app.trace.decorators import trace_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@trace_service(description_zh="创建入驻申请", description_en="Create application")
|
||
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 映射(优先当前活跃编码,再查历史编码)
|
||
# CHANGE 2026-03-23 | 大小写不敏感匹配 site_code(UPPER)
|
||
site_id = None
|
||
site_code_upper = site_code.upper()
|
||
cur.execute(
|
||
"""
|
||
SELECT site_id FROM biz.sites
|
||
WHERE UPPER(site_code) = %s AND is_active = true
|
||
UNION ALL
|
||
SELECT s.site_id FROM biz.site_code_history h
|
||
JOIN biz.sites s ON s.site_id = h.site_id
|
||
WHERE UPPER(h.site_code) = %s AND h.is_current = false AND s.is_active = true
|
||
LIMIT 1
|
||
""",
|
||
(site_code_upper, site_code_upper),
|
||
)
|
||
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),
|
||
)
|
||
|
||
# 5. 更新用户状态为 pending(new → pending)
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users SET status = 'pending', updated_at = NOW()
|
||
WHERE id = %s AND status IN ('new', 'rejected')
|
||
""",
|
||
(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],
|
||
}
|
||
|
||
|
||
|
||
@trace_service(description_zh="审批通过申请", description_en="Approve application")
|
||
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],
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="驳回申请", description_en="Reject application")
|
||
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
|
||
5. 累加 users.rejection_count,达到 3 次自动禁用
|
||
|
||
返回:
|
||
更新后的申请记录 dict(含 user_disabled 标记)
|
||
"""
|
||
conn = get_connection()
|
||
user_disabled = False
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 1. 查询申请记录(含 user_id)
|
||
cur.execute(
|
||
"SELECT id, user_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_status = app_row
|
||
|
||
# 2. 检查状态为 pending
|
||
if app_status != "pending":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=f"申请当前状态为 {app_status},无法审核",
|
||
)
|
||
|
||
# 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()
|
||
|
||
# 4. 累加 rejection_count 并检查是否达到禁用阈值
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users
|
||
SET rejection_count = rejection_count + 1,
|
||
updated_at = NOW()
|
||
WHERE id = %s
|
||
RETURNING rejection_count
|
||
""",
|
||
(app_user_id,),
|
||
)
|
||
new_count = cur.fetchone()[0]
|
||
|
||
if new_count >= 3:
|
||
# 第三次拒绝:自动禁用账号
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users
|
||
SET status = 'disabled', updated_at = NOW()
|
||
WHERE id = %s
|
||
""",
|
||
(app_user_id,),
|
||
)
|
||
user_disabled = True
|
||
logger.warning(
|
||
"用户 %s 累计被拒绝 %d 次,已自动禁用",
|
||
app_user_id, new_count,
|
||
)
|
||
else:
|
||
# 未达阈值:回退用户状态为 rejected(允许重新申请)
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users
|
||
SET status = 'rejected', 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],
|
||
"user_disabled": user_disabled,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取用户申请列表", description_en="Get user applications")
|
||
async def get_user_applications(user_id: int) -> list[dict]:
|
||
"""
|
||
查询用户的所有申请记录。
|
||
|
||
按创建时间倒序排列。
|
||
|
||
返回:
|
||
申请记录 dict 列表(含 phone、employee_number)
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, site_code, applied_role_text, phone,
|
||
employee_number, 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],
|
||
"phone": r[3],
|
||
"employee_number": r[4],
|
||
"status": r[5],
|
||
"review_note": r[6],
|
||
"created_at": r[7],
|
||
"reviewed_at": r[8],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
@trace_service(description_zh="取消申请", description_en="Cancel application")
|
||
async def cancel_application(user_id: int) -> dict:
|
||
"""
|
||
用户主动取消当前 pending 申请。
|
||
|
||
1. 查找用户的 pending 申请(无则 404)
|
||
2. 更新申请 status = 'cancelled'
|
||
3. 回退用户 status 为 'new'
|
||
|
||
返回:
|
||
被取消的申请记录 dict
|
||
"""
|
||
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'
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(user_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="没有待审核的申请",
|
||
)
|
||
|
||
application_id = row[0]
|
||
|
||
# 2. 更新申请状态为 cancelled
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.user_applications
|
||
SET status = 'cancelled'
|
||
WHERE id = %s
|
||
RETURNING id, site_code, applied_role_text, phone,
|
||
employee_number, status, created_at::text
|
||
""",
|
||
(application_id,),
|
||
)
|
||
updated_row = cur.fetchone()
|
||
|
||
# 3. 回退用户状态为 new
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users
|
||
SET status = 'new', updated_at = NOW()
|
||
WHERE id = %s
|
||
""",
|
||
(user_id,),
|
||
)
|
||
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
return {
|
||
"id": updated_row[0],
|
||
"site_code": updated_row[1],
|
||
"applied_role_text": updated_row[2],
|
||
"phone": updated_row[3],
|
||
"employee_number": updated_row[4],
|
||
"status": updated_row[5],
|
||
"created_at": updated_row[6],
|
||
}
|