Files
Neo-ZQYY/apps/backend/app/services/application.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- 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>
2026-04-06 00:03:48 +08:00

492 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_codeUPPER
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. 更新用户状态为 pendingnew → 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],
}