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:
Neo
2026-02-26 08:03:53 +08:00
parent fafc95e64c
commit b25308c3f4
224 changed files with 17660 additions and 32198 deletions

View 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
]

View 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: 门店 IDNone 时跳过匹配)
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
]

View 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 rolesLEFT 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()

View File

@@ -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"),

View 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"),
}