2026-06-21 03:08:54 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 扫码登录 API
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:扫码登录是 Phase 1.1 的核心功能,用于替代坐席端"用户名密码+企微
|
|
|
|
|
|
# OAuth"双因素登录,提供"用企微 App 扫一扫登录浏览器坐席端"的体验。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 完整流程:
|
|
|
|
|
|
# ┌─────────┐ create ┌─────────────┐ scan ┌──────────┐
|
|
|
|
|
|
# │ 浏览器 │ ───────→ │ ticket(120s)│ ←───── │ 企微 App │
|
|
|
|
|
|
# │ 前端 │ ←─────── │ +OAuth URL │ OAuth │ 扫码授权 │
|
|
|
|
|
|
# └─────────┘ qrcode_url └─────────────┘ code └──────────┘
|
|
|
|
|
|
# │ │ │
|
|
|
|
|
|
# │ poll │ scan │
|
|
|
|
|
|
# │ waiting/scanned │ 写 scan:{ticket} │
|
|
|
|
|
|
# │ ↓ │
|
|
|
|
|
|
# │ ┌────────────────┐ │
|
|
|
|
|
|
# │ │ 已登录坐席(企微)│ confirm │
|
|
|
|
|
|
# │ │ 点"确认登录"按钮 │ ────────→ │
|
|
|
|
|
|
# │ └────────────────┘ │
|
|
|
|
|
|
# │ │ │
|
|
|
|
|
|
# │ poll │ confirm │
|
|
|
|
|
|
# │ confirmed+token │ 写 confirm:{ticket} │
|
|
|
|
|
|
# ↓ ↓ │
|
|
|
|
|
|
# 拿到 token,跳坐席端主页 │
|
|
|
|
|
|
#
|
|
|
|
|
|
# 端点列表(4 个):
|
|
|
|
|
|
# POST /api/auth_qrcode/create — 浏览器前端生成 ticket
|
|
|
|
|
|
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
|
|
|
|
|
# POST /api/auth_qrcode/scan — 企微 OAuth2 回调(接收 code)
|
|
|
|
|
|
# POST /api/auth_qrcode/confirm — 当前登录坐席点确认
|
|
|
|
|
|
#
|
|
|
|
|
|
# 鉴权说明:
|
|
|
|
|
|
# - create / scan / poll: 无需登录(浏览器刚加载登录页,用户未登录)
|
|
|
|
|
|
# - confirm: 需要已登录坐席点确认(角色: agent / admin)
|
|
|
|
|
|
# - 票据状态全部存 Redis,TTL 到期自动失效,无 DB 表
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-07-11 23:13:10 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import secrets as secrets_mod
|
2026-06-21 03:08:54 +08:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
import redis.asyncio as aioredis
|
2026-07-11 23:13:10 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Path, Query, Request
|
2026-07-02 19:06:12 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-06-21 03:08:54 +08:00
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
2026-07-02 19:06:12 +08:00
|
|
|
|
from app.database import get_db
|
2026-06-21 03:08:54 +08:00
|
|
|
|
from app.dependencies import dep_redis, get_current_user, UserInfo
|
|
|
|
|
|
from app.schemas.qrcode import (
|
|
|
|
|
|
QrcodeConfirmRequest,
|
|
|
|
|
|
QrcodeConfirmResponse,
|
|
|
|
|
|
QrcodeCreateResponse,
|
|
|
|
|
|
QrcodePollResponse,
|
|
|
|
|
|
QrcodeScanRequest,
|
|
|
|
|
|
QrcodeScanResponse,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.qrcode_service import QrcodeService
|
|
|
|
|
|
from app.utils.response import AppException, success_response
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# 创建路由器
|
|
|
|
|
|
# prefix="/auth_qrcode" + tags=["扫码登录"] 用于 Swagger 分组
|
|
|
|
|
|
router = APIRouter(prefix="/auth_qrcode", tags=["扫码登录"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_qrcode_service(redis_client: aioredis.Redis) -> QrcodeService:
|
|
|
|
|
|
"""工厂函数: 构造扫码登录业务服务。
|
|
|
|
|
|
|
|
|
|
|
|
拆出来便于测试时 monkey-patch,以及后续接入 DI。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return QrcodeService(redis_client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# POST /api/auth_qrcode/create — 创建扫码登录票据
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/create", response_model=None)
|
|
|
|
|
|
async def create_qrcode(
|
|
|
|
|
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""创建扫码登录票据。
|
|
|
|
|
|
|
|
|
|
|
|
无需鉴权(用户尚未登录,正在登录页)。
|
|
|
|
|
|
返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,data 字段是 QrcodeCreateResponse
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
service = _get_qrcode_service(redis_client)
|
|
|
|
|
|
result = await service.create_ticket()
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"ticket": result["ticket"],
|
|
|
|
|
|
"qrcode_url": result["qrcode_url"],
|
2026-06-22 17:38:47 +08:00
|
|
|
|
"qrcode_png_base64": result["qrcode_png_base64"],
|
2026-06-21 03:08:54 +08:00
|
|
|
|
"expires_in": result["expires_in"],
|
|
|
|
|
|
"expires_at": result["expires_at"].isoformat(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"创建扫码票据异常: {e}", exc_info=True)
|
|
|
|
|
|
raise AppException(1005, f"创建扫码票据失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/poll/{ticket}", response_model=None)
|
|
|
|
|
|
async def poll_qrcode(
|
|
|
|
|
|
ticket: str = Path(..., description="扫码登录票据"),
|
|
|
|
|
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""轮询扫码状态。
|
|
|
|
|
|
|
|
|
|
|
|
无需鉴权(浏览器未登录态访问)。
|
|
|
|
|
|
|
|
|
|
|
|
状态机:
|
|
|
|
|
|
- waiting: ticket 有效,等待扫码
|
|
|
|
|
|
- scanned: 已扫码,等待 confirm
|
|
|
|
|
|
- confirmed: 已确认,返回 token
|
|
|
|
|
|
- expired: ticket 过期/不存在
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,data 字段是 QrcodePollResponse
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
service = _get_qrcode_service(redis_client)
|
|
|
|
|
|
result = await service.get_poll_state(ticket)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"status": result["status"],
|
|
|
|
|
|
"employee_id": result.get("employee_id"),
|
|
|
|
|
|
"name": result.get("name"),
|
|
|
|
|
|
"token": result.get("token"),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True)
|
|
|
|
|
|
raise AppException(1005, f"轮询扫码状态失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-02 19:06:12 +08:00
|
|
|
|
# GET|POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
2026-06-21 03:08:54 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-02 19:06:12 +08:00
|
|
|
|
@router.api_route("/scan", methods=["GET", "POST"], response_model=None)
|
2026-06-21 03:08:54 +08:00
|
|
|
|
async def scan_qrcode(
|
2026-07-11 23:13:10 +08:00
|
|
|
|
request: Request,
|
2026-07-02 19:06:12 +08:00
|
|
|
|
body: Optional[QrcodeScanRequest] = None,
|
|
|
|
|
|
ticket: Optional[str] = Query(None, description="扫码登录票据(兼容旧参数名)"),
|
|
|
|
|
|
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
|
|
|
|
|
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
2026-06-21 03:08:54 +08:00
|
|
|
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
2026-07-08 21:54:57 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-06-21 03:08:54 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""处理企微 OAuth2 扫码回调。
|
|
|
|
|
|
|
2026-07-02 19:06:12 +08:00
|
|
|
|
企微 OAuth2 标准回调走 **GET** 带 query 参数 `?code=xxx&state=<ticket>`,
|
|
|
|
|
|
本端点同时支持 GET 和 POST(POST 兼容内部调用 / 旧前端代码)。
|
|
|
|
|
|
|
|
|
|
|
|
GET 模式 (企微 OAuth2 标准回调):
|
|
|
|
|
|
- ticket ← query.state
|
|
|
|
|
|
- code ← query.code
|
|
|
|
|
|
- 自动 302 跳转到 /itdesk/ 或 /itadmin/ 或 /itagent/(按角色)
|
|
|
|
|
|
POST 模式 (内部调用):
|
|
|
|
|
|
- ticket ← body.ticket
|
|
|
|
|
|
- code ← body.code
|
|
|
|
|
|
|
2026-06-21 03:08:54 +08:00
|
|
|
|
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
|
|
|
|
|
|
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
|
|
|
|
|
|
|
|
|
|
|
|
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-07-02 19:06:12 +08:00
|
|
|
|
# 1. 解析参数:POST 用 body,GET 用 query
|
|
|
|
|
|
if body is not None:
|
|
|
|
|
|
final_ticket = body.ticket
|
|
|
|
|
|
final_code = body.code
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 优先用 state(企微 OAuth 标准),回退到 ticket(兼容旧调用)
|
|
|
|
|
|
final_ticket = state or ticket
|
|
|
|
|
|
final_code = code
|
2026-06-21 03:08:54 +08:00
|
|
|
|
|
2026-07-02 19:06:12 +08:00
|
|
|
|
if not final_ticket or not final_code:
|
|
|
|
|
|
logger.warning(f"扫码参数缺失: ticket={final_ticket!r}, code={final_code!r}")
|
|
|
|
|
|
raise AppException(1000, "缺少 ticket 或 code 参数")
|
|
|
|
|
|
|
|
|
|
|
|
service = _get_qrcode_service(redis_client)
|
|
|
|
|
|
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
|
|
|
|
|
|
2026-07-08 21:54:57 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 扫码后自动确认(auto-confirm)
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 原设计:requester 需已登录坐席调 /confirm 来授权新登录
|
|
|
|
|
|
# 问题:首次登录时电脑端无人登录,没有合法 current_user 可调 confirm
|
|
|
|
|
|
# 修正:扫码即确认,直接为扫码的企微用户签发 token
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
from app.services.token_service import TokenService
|
|
|
|
|
|
from app.services.role_mapping_service import RoleMappingService
|
|
|
|
|
|
import json
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
token_service = TokenService(redis_client)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取用户的真实角色(而非写死 agent)
|
|
|
|
|
|
role_service = RoleMappingService(db)
|
|
|
|
|
|
user_roles = await role_service.get_user_roles(result["employee_id"])
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"扫码登录角色: employee_id={result['employee_id']}, "
|
|
|
|
|
|
f"roles={user_roles}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
auto_token = await token_service.create_token(
|
|
|
|
|
|
employee_id=result["employee_id"],
|
|
|
|
|
|
name=result["name"],
|
|
|
|
|
|
roles=user_roles,
|
|
|
|
|
|
avatar=result.get("avatar", ""),
|
|
|
|
|
|
login_source="qrcode_scan",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
confirm_payload = {
|
|
|
|
|
|
"token": auto_token,
|
|
|
|
|
|
"confirmed_at": datetime.now().isoformat(),
|
|
|
|
|
|
"roles": user_roles,
|
|
|
|
|
|
"employee_id": result["employee_id"],
|
|
|
|
|
|
"name": result["name"],
|
|
|
|
|
|
}
|
|
|
|
|
|
CONFIRM_TTL = 60
|
|
|
|
|
|
await redis_client.setex(
|
|
|
|
|
|
f"qrcode:confirm:{final_ticket}",
|
|
|
|
|
|
CONFIRM_TTL,
|
|
|
|
|
|
json.dumps(confirm_payload, ensure_ascii=False),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"扫码自动确认: ticket={final_ticket[:8]}..., "
|
|
|
|
|
|
f"employee_id={result['employee_id']}, name={result['name']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-08 22:00:17 +08:00
|
|
|
|
# GET 请求(企微 OAuth 回调)→ 已自动确认,显示成功页 + 自动关闭
|
2026-07-02 19:06:12 +08:00
|
|
|
|
from fastapi.responses import HTMLResponse
|
2026-07-08 21:54:57 +08:00
|
|
|
|
if final_code is not None and final_ticket is not None and body is None:
|
2026-07-08 22:00:17 +08:00
|
|
|
|
user_name = result.get('name', '')
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 生成企微 JS-SDK 签名(用于 wx.closeWindow() 关闭当前页面)
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 签名算法: sha1(jsapi_ticket=X&noncestr=X×tamp=X&url=X)
|
|
|
|
|
|
# url 必须是企微 webview 中当前页面的完整 URL(含 query,不含 #hash)
|
|
|
|
|
|
jsapi_signature = ""
|
|
|
|
|
|
jsapi_timestamp = 0
|
|
|
|
|
|
jsapi_nonce = ""
|
|
|
|
|
|
jsapi_appid = getattr(settings, "wecom_corp_id", "")
|
|
|
|
|
|
current_url = "" # 用于调试显示和 JSAPI 签名 URL
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.wecom_service import WecomService
|
|
|
|
|
|
wecom_svc = WecomService(redis_client)
|
|
|
|
|
|
jsapi_ticket = await wecom_svc.get_jsapi_ticket()
|
|
|
|
|
|
# 当前页面 URL(不含 # 后面部分)
|
|
|
|
|
|
# ⚠️ 关键:request.url 默认用内部 HTTP 协议(Nginx→后端是 HTTP)
|
|
|
|
|
|
# 但企微 WebView 看到的是 HTTPS,协议不一致会导致签名校验失败
|
|
|
|
|
|
# 必须从 X-Forwarded-Proto 头重建正确的 HTTPS URL
|
|
|
|
|
|
scheme = request.headers.get("x-forwarded-proto", "https")
|
|
|
|
|
|
host = request.headers.get("host", "")
|
|
|
|
|
|
path = request.url.path
|
|
|
|
|
|
query = str(request.url.query) if request.url.query else ""
|
|
|
|
|
|
current_url = f"{scheme}://{host}{path}"
|
|
|
|
|
|
if query:
|
|
|
|
|
|
current_url += f"?{query}"
|
|
|
|
|
|
jsapi_timestamp = int(time.time())
|
|
|
|
|
|
jsapi_nonce = secrets_mod.token_hex(8)
|
|
|
|
|
|
jsapi_signature = wecom_svc.generate_jsapi_signature(
|
|
|
|
|
|
ticket=jsapi_ticket,
|
|
|
|
|
|
nonce_str=jsapi_nonce,
|
|
|
|
|
|
timestamp=jsapi_timestamp,
|
|
|
|
|
|
url=current_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"JS-SDK 签名生成成功: url={current_url}, "
|
|
|
|
|
|
f"timestamp={jsapi_timestamp}, appid={jsapi_appid}"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await wecom_svc.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"JS-SDK 签名生成失败(降级为 WeixinJSBridge): {e}")
|
|
|
|
|
|
|
2026-07-02 19:06:12 +08:00
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-07-08 22:00:17 +08:00
|
|
|
|
<title>登录成功 - IT智能服务台</title>
|
2026-07-02 19:06:12 +08:00
|
|
|
|
<style>
|
2026-07-07 21:52:11 +08:00
|
|
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
2026-07-08 22:00:17 +08:00
|
|
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
2026-07-11 23:13:10 +08:00
|
|
|
|
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 32px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
|
2026-07-08 22:00:17 +08:00
|
|
|
|
.check {{ width: 64px; height: 64px; margin: 0 auto 16px; }}
|
|
|
|
|
|
.title {{ color: #1f2937; font-size: 24px; font-weight: 600; margin-bottom: 8px; }}
|
|
|
|
|
|
.subtitle {{ color: #6b7280; font-size: 14px; margin-bottom: 24px; }}
|
|
|
|
|
|
.status {{ display: inline-flex; align-items: center; gap: 6px; background: #dcfce7; color: #166534; padding: 10px 20px; border-radius: 50px; font-size: 14px; font-weight: 500; }}
|
2026-07-11 23:13:10 +08:00
|
|
|
|
.footer {{ margin-top: 20px; color: #9ca3af; font-size: 12px; }}
|
|
|
|
|
|
.back-btn {{ display: none; margin-top: 20px; padding: 12px 32px; background: #07C160; color: white; border: none; border-radius: 50px; font-size: 16px; font-weight: 500; cursor: pointer; }}
|
|
|
|
|
|
.debug {{ margin-top: 16px; color: #6b7280; font-size: 11px; line-height: 1.6; word-break: break-all; text-align: left; background: #f3f4f6; padding: 10px 12px; border-radius: 8px; }}
|
|
|
|
|
|
.debug b {{ color: #07C160; }}
|
2026-07-02 19:06:12 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
2026-07-08 22:00:17 +08:00
|
|
|
|
<svg class="check" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
|
<circle cx="32" cy="32" r="30" fill="#07C160" stroke="#06AD56" stroke-width="4"/>
|
|
|
|
|
|
<path d="M20 32l8 8 16-16" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
2026-07-07 21:52:11 +08:00
|
|
|
|
</svg>
|
2026-07-08 22:00:17 +08:00
|
|
|
|
<h1 class="title">登录成功</h1>
|
|
|
|
|
|
<div class="status">已自动确认登录</div>
|
|
|
|
|
|
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
|
2026-07-11 23:13:10 +08:00
|
|
|
|
<button class="back-btn" id="backBtn" onclick="manualClose()">点击返回企微</button>
|
|
|
|
|
|
<div class="footer">页面即将自动关闭 · 税友集团</div>
|
|
|
|
|
|
<div class="debug" id="debugInfo">
|
|
|
|
|
|
<b>签名:</b> {'成功' if jsapi_signature else '未生成'}<br>
|
|
|
|
|
|
<b>URL:</b> {current_url}<br>
|
|
|
|
|
|
<b>状态:</b> <span id="jsStatus">JS加载中...</span>
|
|
|
|
|
|
</div>
|
2026-07-02 19:06:12 +08:00
|
|
|
|
</div>
|
2026-07-08 22:00:17 +08:00
|
|
|
|
<script>
|
2026-07-11 23:13:10 +08:00
|
|
|
|
(function() {{
|
|
|
|
|
|
var statusEl = document.getElementById('jsStatus');
|
|
|
|
|
|
var btnEl = document.getElementById('backBtn');
|
|
|
|
|
|
var startTime = Date.now();
|
|
|
|
|
|
var tried = {{}};
|
|
|
|
|
|
|
|
|
|
|
|
function setStatus(msg) {{
|
|
|
|
|
|
if (statusEl) statusEl.textContent = msg + ' (' + (Date.now() - startTime) + 'ms)';
|
2026-07-08 22:00:17 +08:00
|
|
|
|
}}
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
|
|
|
|
|
function showBtn() {{
|
|
|
|
|
|
if (btnEl) btnEl.style.display = 'inline-block';
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试关闭当前 WebView
|
|
|
|
|
|
function tryClose(forceShowBtn) {{
|
|
|
|
|
|
setStatus('尝试关闭');
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 企微/微信 JS-SDK wx.closeWindow
|
|
|
|
|
|
if (!tried.wxClose && typeof wx !== 'undefined' && wx.closeWindow) {{
|
|
|
|
|
|
tried.wxClose = true;
|
|
|
|
|
|
try {{
|
|
|
|
|
|
setStatus('wx.closeWindow');
|
|
|
|
|
|
wx.closeWindow();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}} catch(e) {{ setStatus('wx.closeWindow失败:' + (e.message || e)); }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. wx.invoke closeWindow
|
|
|
|
|
|
if (!tried.wxInvoke && typeof wx !== 'undefined' && wx.invoke) {{
|
|
|
|
|
|
tried.wxInvoke = true;
|
|
|
|
|
|
try {{
|
|
|
|
|
|
setStatus('wx.invoke closeWindow');
|
|
|
|
|
|
wx.invoke('closeWindow', {{}}, function(){{}});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}} catch(e) {{ setStatus('wx.invoke失败:' + (e.message || e)); }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 内置 WeixinJSBridge
|
|
|
|
|
|
if (!tried.jsBridge && typeof WeixinJSBridge !== 'undefined' && WeixinJSBridge.call) {{
|
|
|
|
|
|
tried.jsBridge = true;
|
|
|
|
|
|
try {{
|
|
|
|
|
|
setStatus('WeixinJSBridge.closeWindow');
|
|
|
|
|
|
WeixinJSBridge.call('closeWindow');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}} catch(e) {{ setStatus('JSBridge失败:' + (e.message || e)); }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. window.close
|
|
|
|
|
|
if (!tried.windowClose) {{
|
|
|
|
|
|
tried.windowClose = true;
|
|
|
|
|
|
try {{
|
|
|
|
|
|
setStatus('window.close');
|
|
|
|
|
|
window.close();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}} catch(e) {{}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. history.back
|
|
|
|
|
|
if (!tried.historyBack) {{
|
|
|
|
|
|
tried.historyBack = true;
|
|
|
|
|
|
try {{
|
|
|
|
|
|
setStatus('history.back');
|
|
|
|
|
|
history.back();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}} catch(e) {{}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
if (forceShowBtn) {{
|
|
|
|
|
|
setStatus('无法自动关闭,请手动返回');
|
|
|
|
|
|
showBtn();
|
|
|
|
|
|
}}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
function manualClose() {{
|
|
|
|
|
|
tryClose(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
// 轮询检测 WeixinJSBridge / wx,最多 5 秒
|
|
|
|
|
|
var checkCount = 0;
|
|
|
|
|
|
var maxChecks = 50;
|
|
|
|
|
|
var interval = setInterval(function() {{
|
|
|
|
|
|
checkCount++;
|
|
|
|
|
|
var hasWx = typeof wx !== 'undefined';
|
|
|
|
|
|
var hasBridge = typeof WeixinJSBridge !== 'undefined';
|
|
|
|
|
|
setStatus('检测中 wx=' + hasWx + ' bridge=' + hasBridge + ' count=' + checkCount);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasWx || hasBridge) {{
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
setStatus('已检测到关闭API,1秒后尝试关闭');
|
|
|
|
|
|
setTimeout(function() {{
|
|
|
|
|
|
tryClose(true);
|
|
|
|
|
|
}}, 1000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
if (checkCount >= maxChecks) {{
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
setStatus('未检测到API,直接尝试关闭');
|
|
|
|
|
|
tryClose(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
}}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
// 3 秒后无论结果都显示手动按钮兜底
|
|
|
|
|
|
setTimeout(function() {{
|
|
|
|
|
|
showBtn();
|
|
|
|
|
|
}}, 3000);
|
|
|
|
|
|
}})();
|
2026-07-08 22:00:17 +08:00
|
|
|
|
</script>
|
2026-07-02 19:06:12 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
return HTMLResponse(content=html, status_code=200)
|
|
|
|
|
|
|
|
|
|
|
|
# POST 模式:返回 JSON
|
2026-06-21 03:08:54 +08:00
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"success": result["success"],
|
|
|
|
|
|
"message": result["message"],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as ve:
|
|
|
|
|
|
# 票据过期/不存在 → 业务错误
|
|
|
|
|
|
logger.warning(f"扫码业务错误: {ve}")
|
|
|
|
|
|
raise AppException(1003, str(ve))
|
|
|
|
|
|
except Exception as e:
|
2026-07-02 19:06:12 +08:00
|
|
|
|
logger.error(f"扫码处理异常: error={e}", exc_info=True)
|
2026-06-21 03:08:54 +08:00
|
|
|
|
raise AppException(1005, f"扫码处理失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# POST /api/auth_qrcode/confirm — 当前已登录坐席确认授权
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/confirm", response_model=None)
|
|
|
|
|
|
async def confirm_qrcode(
|
|
|
|
|
|
body: QrcodeConfirmRequest,
|
|
|
|
|
|
current_user: UserInfo = Depends(get_current_user),
|
|
|
|
|
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
2026-07-02 19:06:12 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-06-21 03:08:54 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""处理当前已登录坐席的扫码确认授权。
|
|
|
|
|
|
|
|
|
|
|
|
需要鉴权: 只有已登录的坐席/管理员能确认授权。
|
|
|
|
|
|
把扫码用户身份变成可登录 Token(roles=['agent']),
|
|
|
|
|
|
写 Redis confirm:{ticket},前端 poll 拿到后跳坐席主页。
|
|
|
|
|
|
|
|
|
|
|
|
otp_code: admin 场景下可选,Phase 1.1 仅记录日志,
|
|
|
|
|
|
真实 OTP 校验留给 Phase 2.1(参考 agents.py:272-274 的 totp.verify)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 包含 ticket 和 otp_code(可选)
|
|
|
|
|
|
current_user: 当前已登录用户(由 get_current_user 注入)
|
|
|
|
|
|
redis_client: Redis 客户端
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,data 字段是 QrcodeConfirmResponse
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
service = _get_qrcode_service(redis_client)
|
|
|
|
|
|
result = await service.process_confirm(
|
|
|
|
|
|
ticket=body.ticket,
|
|
|
|
|
|
current_user_id=current_user.employee_id,
|
|
|
|
|
|
current_user_name=current_user.name,
|
|
|
|
|
|
current_roles=current_user.roles,
|
|
|
|
|
|
otp_code=body.otp_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# 同步头像:扫码时已从企微API拿到最新头像URL,这里落库 + 清缓存
|
|
|
|
|
|
# (要求 A:确保所有登录路径刷新头像;头像更新失败不阻塞登录)
|
|
|
|
|
|
confirm_avatar = result.get("avatar", "")
|
|
|
|
|
|
if confirm_avatar:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.avatar_service import sync_employee_avatar
|
|
|
|
|
|
await sync_employee_avatar(
|
|
|
|
|
|
db, redis_client, result["employee_id"], confirm_avatar
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"扫码确认同步头像失败(不阻塞): "
|
|
|
|
|
|
f"employee_id={result.get('employee_id')}, error={e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-02 19:06:12 +08:00
|
|
|
|
# 记录扫码登录日志(成功)
|
|
|
|
|
|
from app.services.audit_log_service import record_audit_log
|
|
|
|
|
|
await record_audit_log(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
employee_id=result["employee_id"],
|
|
|
|
|
|
action="qrcode_login",
|
|
|
|
|
|
resource="auth",
|
|
|
|
|
|
resource_id=result["employee_id"],
|
|
|
|
|
|
details={
|
|
|
|
|
|
"name": result["name"],
|
|
|
|
|
|
"roles": result["roles"],
|
|
|
|
|
|
"confirmed_by": current_user.employee_id,
|
|
|
|
|
|
"login_method": "qrcode_confirm",
|
|
|
|
|
|
},
|
|
|
|
|
|
result="success",
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
2026-06-21 03:08:54 +08:00
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"token": result["token"],
|
|
|
|
|
|
"employee_id": result["employee_id"],
|
|
|
|
|
|
"name": result["name"],
|
|
|
|
|
|
"roles": result["roles"],
|
|
|
|
|
|
"require_otp": result.get("require_otp"),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as ve:
|
|
|
|
|
|
# 票据过期/未扫码 → 业务错误
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"扫码确认业务错误: ticket={body.ticket[:8]}..., "
|
|
|
|
|
|
f"current_user={current_user.employee_id}, error={ve}"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise AppException(1003, str(ve))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
f"扫码确认异常: ticket={body.ticket[:8]}..., "
|
|
|
|
|
|
f"current_user={current_user.employee_id}, error={e}",
|
|
|
|
|
|
exc_info=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise AppException(1005, f"扫码确认失败: {str(e)}")
|