2026-07-02 23:13:23 +08:00
|
|
|
|
"""认证服务:JWT 签发、企微 OAuth、密码校验。"""
|
|
|
|
|
|
import jwt
|
|
|
|
|
|
import bcrypt
|
|
|
|
|
|
import time
|
2026-07-12 14:17:18 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import re
|
|
|
|
|
|
import secrets
|
2026-07-02 23:13:23 +08:00
|
|
|
|
import requests
|
2026-07-12 14:17:18 +08:00
|
|
|
|
from datetime import datetime, timedelta
|
2026-07-02 23:13:23 +08:00
|
|
|
|
from flask import current_app
|
|
|
|
|
|
from insurance.config import get_config as _get_wecom_config
|
|
|
|
|
|
from insurance.db.compat import db
|
2026-07-12 14:17:18 +08:00
|
|
|
|
from insurance.models.email_verification import EmailVerificationCode
|
2026-07-02 23:13:23 +08:00
|
|
|
|
from insurance.models.wecom_user import WeComUserMapping
|
2026-07-12 14:17:18 +08:00
|
|
|
|
from insurance.utils.email import EmailSendError, send_email
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuthService:
|
|
|
|
|
|
"""认证业务逻辑。"""
|
|
|
|
|
|
|
2026-07-12 14:17:18 +08:00
|
|
|
|
CODE_EXPIRE_MINUTES = 10
|
|
|
|
|
|
CODE_RESEND_SECONDS = 60
|
|
|
|
|
|
CODE_MAX_ATTEMPTS = 5
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_email(self, email: str) -> str:
|
|
|
|
|
|
return (email or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_email(self, email: str) -> bool:
|
|
|
|
|
|
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or ""))
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_password(self, password: str) -> str:
|
|
|
|
|
|
if not password or len(password) < 8 or len(password) > 128:
|
|
|
|
|
|
return "密码长度需在 8-128 之间"
|
|
|
|
|
|
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
|
|
|
|
|
|
return "密码需包含字母和数字"
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def _hash_code(self, email: str, purpose: str, code: str) -> str:
|
|
|
|
|
|
secret = current_app.config.get("JWT_SECRET", "change-this")
|
|
|
|
|
|
raw = f"{email}:{purpose}:{code}:{secret}"
|
|
|
|
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
def _user_payload(self, mapping: WeComUserMapping) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(mapping.id),
|
|
|
|
|
|
"username": mapping.username,
|
|
|
|
|
|
"email": mapping.email,
|
|
|
|
|
|
"real_name": mapping.real_name,
|
|
|
|
|
|
"wecom_userid": mapping.wecom_userid,
|
|
|
|
|
|
"role": mapping.role,
|
|
|
|
|
|
"department": mapping.department or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def send_email_code(self, email: str, purpose: str, ip: str = "") -> dict:
|
|
|
|
|
|
"""发送注册或找回密码验证码。"""
|
|
|
|
|
|
email = self._normalize_email(email)
|
|
|
|
|
|
if purpose not in ("register", "reset_password"):
|
|
|
|
|
|
return {"code": 1001, "message": "验证码用途无效", "data": None}
|
|
|
|
|
|
if not self._validate_email(email):
|
|
|
|
|
|
return {"code": 1001, "message": "邮箱格式不正确", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
if purpose == "register":
|
|
|
|
|
|
existing = db.session.query(WeComUserMapping).filter_by(email=email).first()
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return {"code": 1001, "message": "邮箱已被注册", "data": None}
|
|
|
|
|
|
elif not db.session.query(WeComUserMapping).filter_by(email=email).first():
|
|
|
|
|
|
return {"code": 0, "message": "验证码已发送", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
latest = db.session.query(EmailVerificationCode).filter_by(
|
|
|
|
|
|
email=email,
|
|
|
|
|
|
purpose=purpose,
|
|
|
|
|
|
).order_by(EmailVerificationCode.created_at.desc()).first()
|
|
|
|
|
|
if latest and latest.created_at and latest.created_at > now - timedelta(seconds=self.CODE_RESEND_SECONDS):
|
|
|
|
|
|
return {"code": 1001, "message": "验证码发送过于频繁,请稍后再试", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
code = f"{secrets.randbelow(1000000):06d}"
|
|
|
|
|
|
record = EmailVerificationCode(
|
|
|
|
|
|
email=email,
|
|
|
|
|
|
purpose=purpose,
|
|
|
|
|
|
code_hash=self._hash_code(email, purpose, code),
|
|
|
|
|
|
expires_at=now + timedelta(minutes=self.CODE_EXPIRE_MINUTES),
|
|
|
|
|
|
ip=ip,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
subject = "保险智能客服系统验证码"
|
|
|
|
|
|
action = "注册账号" if purpose == "register" else "重置密码"
|
|
|
|
|
|
content = (
|
|
|
|
|
|
f"您正在{action},验证码为:{code}\n\n"
|
|
|
|
|
|
f"验证码 {self.CODE_EXPIRE_MINUTES} 分钟内有效。"
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(record)
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.session.flush()
|
|
|
|
|
|
if not send_email(email, subject, content):
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
return {"code": 5001, "message": "邮件服务未配置,请联系管理员", "data": None}
|
|
|
|
|
|
except EmailSendError as e:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
current_app.logger.warning("发送邮箱验证码失败: %s", e)
|
|
|
|
|
|
return {"code": 5001, "message": e.user_message, "data": None}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
current_app.logger.exception("发送邮箱验证码失败")
|
|
|
|
|
|
return {"code": 5001, "message": "验证码发送失败,请稍后重试", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "message": "验证码已发送", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_email_code(self, email: str, purpose: str, code: str, commit_success: bool = True) -> tuple[bool, str]:
|
|
|
|
|
|
email = self._normalize_email(email)
|
|
|
|
|
|
code = (code or "").strip()
|
|
|
|
|
|
if not code:
|
|
|
|
|
|
return False, "请输入邮箱验证码"
|
|
|
|
|
|
|
|
|
|
|
|
record = db.session.query(EmailVerificationCode).filter_by(
|
|
|
|
|
|
email=email,
|
|
|
|
|
|
purpose=purpose,
|
|
|
|
|
|
consumed_at=None,
|
|
|
|
|
|
).order_by(EmailVerificationCode.created_at.desc()).first()
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
if not record or record.expires_at < now:
|
|
|
|
|
|
return False, "验证码已过期,请重新获取"
|
|
|
|
|
|
if (record.attempts or 0) >= self.CODE_MAX_ATTEMPTS:
|
|
|
|
|
|
return False, "验证码错误次数过多,请重新获取"
|
|
|
|
|
|
|
|
|
|
|
|
record.attempts = (record.attempts or 0) + 1
|
|
|
|
|
|
if record.code_hash != self._hash_code(email, purpose, code):
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return False, "验证码错误"
|
|
|
|
|
|
|
|
|
|
|
|
record.consumed_at = now
|
|
|
|
|
|
if commit_success:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return True, ""
|
|
|
|
|
|
|
2026-07-02 23:13:23 +08:00
|
|
|
|
def wework_login(self, code: str, state: str) -> dict:
|
|
|
|
|
|
"""企微 OAuth 登录:code 换 token -> 获取用户信息 -> 签发 JWT。"""
|
|
|
|
|
|
corp_id = _get_wecom_config("WECOM_CORP_ID")
|
|
|
|
|
|
secret = _get_wecom_config("WECOM_SECRET")
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 用 code 换取企微 access_token
|
|
|
|
|
|
token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
|
|
|
|
|
resp = requests.get(token_url, params={"corpid": corp_id, "corpsecret": secret})
|
|
|
|
|
|
token_data = resp.json()
|
|
|
|
|
|
if token_data.get("errcode") != 0:
|
|
|
|
|
|
return {"code": 3001, "message": "企微 API 调用失败", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
access_token = token_data["access_token"]
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 用 code 换取 userid(标准企微 OAuth 流程)
|
|
|
|
|
|
getuserinfo_url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo"
|
|
|
|
|
|
resp = requests.get(getuserinfo_url, params={"access_token": access_token, "code": code})
|
|
|
|
|
|
user_info = resp.json()
|
|
|
|
|
|
if user_info.get("errcode") != 0:
|
|
|
|
|
|
return {"code": 3001, "message": "获取企微用户信息失败", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
wecom_userid = user_info.get("userid", "")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 用 userid 获取详细用户信息
|
|
|
|
|
|
user_detail_url = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
|
|
|
|
|
|
resp = requests.get(user_detail_url, params={"access_token": access_token, "userid": wecom_userid})
|
|
|
|
|
|
user_data = resp.json()
|
|
|
|
|
|
if user_data.get("errcode") != 0:
|
|
|
|
|
|
return {"code": 3001, "message": "获取企微用户详情失败", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
username = user_data.get("name", "")
|
|
|
|
|
|
department = user_data.get("department", [""])[0] if user_data.get("department") else ""
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 查找或创建用户映射
|
|
|
|
|
|
mapping = db.session.query(WeComUserMapping).filter_by(wecom_userid=wecom_userid).first()
|
|
|
|
|
|
if not mapping:
|
|
|
|
|
|
mapping = WeComUserMapping(
|
|
|
|
|
|
wecom_userid=wecom_userid,
|
|
|
|
|
|
internal_user_id=f"wecom_{wecom_userid}",
|
|
|
|
|
|
username=username,
|
|
|
|
|
|
department=str(department),
|
|
|
|
|
|
role="sales",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(mapping)
|
2026-07-12 14:17:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 4. 签发 JWT
|
|
|
|
|
|
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"token": token,
|
|
|
|
|
|
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
|
2026-07-12 14:17:18 +08:00
|
|
|
|
"user": self._user_payload(mapping),
|
2026-07-02 23:13:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 14:17:18 +08:00
|
|
|
|
def register(self, username: str, password: str, email: str, real_name: str, verification_code: str) -> dict:
|
2026-07-02 23:13:23 +08:00
|
|
|
|
"""用户注册。"""
|
2026-07-12 14:17:18 +08:00
|
|
|
|
email = self._normalize_email(email)
|
|
|
|
|
|
real_name = (real_name or "").strip()
|
|
|
|
|
|
|
2026-07-02 23:13:23 +08:00
|
|
|
|
# 用户名校验
|
|
|
|
|
|
if not username or len(username) < 3 or len(username) > 64:
|
|
|
|
|
|
return {"code": 1001, "message": "用户名长度需在 3-64 之间", "data": None}
|
2026-07-12 14:17:18 +08:00
|
|
|
|
if not self._validate_email(email):
|
|
|
|
|
|
return {"code": 1001, "message": "邮箱格式不正确", "data": None}
|
|
|
|
|
|
if not real_name or len(real_name) > 128:
|
|
|
|
|
|
return {"code": 1001, "message": "请输入真实姓名", "data": None}
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 密码校验
|
2026-07-12 14:17:18 +08:00
|
|
|
|
password_error = self._validate_password(password)
|
|
|
|
|
|
if password_error:
|
|
|
|
|
|
return {"code": 1001, "message": password_error, "data": None}
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查用户名是否已存在
|
|
|
|
|
|
existing = db.session.query(WeComUserMapping).filter_by(username=username).first()
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return {"code": 1001, "message": "用户名已存在", "data": None}
|
2026-07-12 14:17:18 +08:00
|
|
|
|
existing_email = db.session.query(WeComUserMapping).filter_by(email=email).first()
|
|
|
|
|
|
if existing_email:
|
|
|
|
|
|
return {"code": 1001, "message": "邮箱已被注册", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
ok, message = self._verify_email_code(email, "register", verification_code, commit_success=False)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return {"code": 1001, "message": message, "data": None}
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 生成密码哈希
|
|
|
|
|
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
|
|
|
|
|
|
|
|
# 创建用户
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
mapping = WeComUserMapping(
|
|
|
|
|
|
wecom_userid=f"local_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
internal_user_id=f"user_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
username=username,
|
2026-07-12 14:17:18 +08:00
|
|
|
|
email=email,
|
|
|
|
|
|
real_name=real_name,
|
|
|
|
|
|
email_verified="true",
|
2026-07-02 23:13:23 +08:00
|
|
|
|
password_hash=password_hash,
|
|
|
|
|
|
role="client",
|
|
|
|
|
|
status="active",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(mapping)
|
2026-07-12 14:17:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 同步到 Dify accounts 表
|
2026-07-12 14:17:18 +08:00
|
|
|
|
self._sync_to_dify(username, password, mapping.id, email)
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 签发 JWT
|
|
|
|
|
|
token = self._generate_token(mapping.id, mapping.role)
|
|
|
|
|
|
|
|
|
|
|
|
from insurance.utils.audit import log_operation
|
|
|
|
|
|
log_operation(str(mapping.id), "register", "user", str(mapping.id))
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"token": token,
|
|
|
|
|
|
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
|
2026-07-12 14:17:18 +08:00
|
|
|
|
"user": self._user_payload(mapping),
|
2026-07-02 23:13:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 14:17:18 +08:00
|
|
|
|
def _sync_to_dify(self, username: str, password: str, local_user_id: int, email: str = "") -> bool:
|
2026-07-02 23:13:23 +08:00
|
|
|
|
"""同步用户到 Dify accounts 表。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.db.compat import db
|
|
|
|
|
|
import uuid as uuid_mod
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
# 检查 Dify 中是否已存在同名用户
|
|
|
|
|
|
result = db.session.execute(
|
|
|
|
|
|
db.text("SELECT id FROM accounts WHERE name = :name"),
|
|
|
|
|
|
{"name": username}
|
|
|
|
|
|
)
|
|
|
|
|
|
existing = result.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
# 已存在,记录映射关系
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# 创建 Dify 账号
|
|
|
|
|
|
account_id = str(uuid_mod.uuid4())
|
2026-07-12 14:17:18 +08:00
|
|
|
|
account_email = email or f"{username}@baodan.local"
|
2026-07-02 23:13:23 +08:00
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
# Dify 期望密码已经是 bcrypt 哈希后的值
|
|
|
|
|
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
|
|
|
|
|
|
|
|
# 插入 accounts 表
|
|
|
|
|
|
db.session.execute(
|
|
|
|
|
|
db.text("""
|
|
|
|
|
|
INSERT INTO accounts (id, name, email, password, avatar, interface_language,
|
|
|
|
|
|
interface_theme, timezone, last_login_at, last_active_at,
|
|
|
|
|
|
status, created_at, updated_at)
|
|
|
|
|
|
VALUES (:id, :name, :email, :password, '', 'zh-Hans', 'light', 'Asia/Shanghai',
|
|
|
|
|
|
:now, :now, 'active', :now, :now)
|
|
|
|
|
|
"""),
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": account_id,
|
|
|
|
|
|
"name": username,
|
2026-07-12 14:17:18 +08:00
|
|
|
|
"email": account_email,
|
2026-07-02 23:13:23 +08:00
|
|
|
|
"password": password_hash,
|
|
|
|
|
|
"now": now,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取默认租户
|
|
|
|
|
|
tenant_result = db.session.execute(
|
|
|
|
|
|
db.text("SELECT id FROM tenants LIMIT 1")
|
|
|
|
|
|
)
|
|
|
|
|
|
tenant = tenant_result.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if tenant:
|
|
|
|
|
|
# 关联到租户
|
|
|
|
|
|
db.session.execute(
|
|
|
|
|
|
db.text("""
|
|
|
|
|
|
INSERT INTO tenant_account_joins (id, tenant_id, account_id, role,
|
|
|
|
|
|
invited_by, created_at, updated_at)
|
|
|
|
|
|
VALUES (:id, :tenant_id, :account_id, 'normal', NULL, :now, :now)
|
|
|
|
|
|
"""),
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": str(uuid_mod.uuid4()),
|
|
|
|
|
|
"tenant_id": str(tenant[0]),
|
|
|
|
|
|
"account_id": account_id,
|
|
|
|
|
|
"now": now,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# 同步失败不影响注册
|
|
|
|
|
|
print(f"同步到 Dify 失败: {e}")
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def password_login(self, username: str, password: str) -> dict:
|
|
|
|
|
|
"""账密登录:bcrypt 校验,返回 JWT。"""
|
|
|
|
|
|
mapping = db.session.query(WeComUserMapping).filter_by(username=username).first()
|
|
|
|
|
|
if not mapping or not mapping.password_hash:
|
|
|
|
|
|
return {"code": 1002, "message": "用户名或密码错误", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
if not bcrypt.checkpw(password.encode(), mapping.password_hash.encode()):
|
|
|
|
|
|
return {"code": 1002, "message": "用户名或密码错误", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
if mapping.status != "active":
|
|
|
|
|
|
return {"code": 1002, "message": "账号已停用", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
|
|
|
|
|
|
|
|
|
|
|
|
from insurance.utils.audit import log_operation
|
|
|
|
|
|
log_operation(str(mapping.id), "login", "user", str(mapping.id))
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"token": token,
|
|
|
|
|
|
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
|
2026-07-12 14:17:18 +08:00
|
|
|
|
"user": self._user_payload(mapping),
|
2026-07-02 23:13:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_token(self, user_id: str) -> dict:
|
|
|
|
|
|
"""刷新 Token:签发新的 JWT。"""
|
|
|
|
|
|
mapping = db.session.query(WeComUserMapping).filter_by(id=int(user_id)).first()
|
|
|
|
|
|
if not mapping:
|
|
|
|
|
|
return {"code": 1002, "message": "用户不存在", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"token": token,
|
|
|
|
|
|
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def change_password(self, user_id: str, old_password: str, new_password: str) -> dict:
|
|
|
|
|
|
"""修改密码。"""
|
|
|
|
|
|
mapping = db.session.query(WeComUserMapping).filter_by(id=int(user_id)).first()
|
|
|
|
|
|
if not mapping:
|
|
|
|
|
|
return {"code": 1005, "message": "用户不存在", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
if not mapping.password_hash:
|
|
|
|
|
|
return {"code": 1002, "message": "该账号无密码,请使用企微登录", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
if not bcrypt.checkpw(old_password.encode(), mapping.password_hash.encode()):
|
|
|
|
|
|
return {"code": 1002, "message": "旧密码错误", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
mapping.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
|
2026-07-12 14:17:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
self._sync_password_to_dify(mapping.username, new_password)
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
from insurance.utils.audit import log_operation
|
|
|
|
|
|
log_operation(user_id, "change_password", "user", user_id)
|
|
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "message": "密码修改成功", "data": None}
|
|
|
|
|
|
|
2026-07-12 14:17:18 +08:00
|
|
|
|
def reset_password(self, email: str, verification_code: str, new_password: str) -> dict:
|
|
|
|
|
|
"""通过邮箱验证码重置密码。"""
|
|
|
|
|
|
email = self._normalize_email(email)
|
|
|
|
|
|
password_error = self._validate_password(new_password)
|
|
|
|
|
|
if password_error:
|
|
|
|
|
|
return {"code": 1001, "message": password_error, "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
ok, message = self._verify_email_code(email, "reset_password", verification_code, commit_success=False)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return {"code": 1001, "message": message, "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
mapping = db.session.query(WeComUserMapping).filter_by(email=email).first()
|
|
|
|
|
|
if not mapping:
|
|
|
|
|
|
return {"code": 1005, "message": "用户不存在", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
mapping.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
|
|
mapping.email_verified = "true"
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
self._sync_password_to_dify(mapping.username, new_password)
|
|
|
|
|
|
|
|
|
|
|
|
from insurance.utils.audit import log_operation
|
|
|
|
|
|
log_operation(str(mapping.id), "reset_password", "user", str(mapping.id))
|
|
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "message": "密码重置成功", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_password_to_dify(self, username: str, password: str) -> bool:
|
|
|
|
|
|
"""同步本地密码到 Dify accounts 表。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
|
|
db.session.execute(
|
|
|
|
|
|
db.text("UPDATE accounts SET password = :password, updated_at = :updated_at WHERE name = :name"),
|
|
|
|
|
|
{"password": password_hash, "updated_at": datetime.utcnow(), "name": username}
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"同步密码到 Dify 失败: {e}")
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-07-02 23:13:23 +08:00
|
|
|
|
def logout(self, token: str, user_id: str) -> dict:
|
|
|
|
|
|
"""吊销 Token:加入 Redis 黑名单。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.db.compat import redis_client
|
|
|
|
|
|
expire = current_app.config.get("JWT_EXPIRE_SECONDS", 7200)
|
|
|
|
|
|
redis_client.setex(f"token:blacklist:{token}", expire, "1")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # Redis 不可用时仍允许 logout
|
|
|
|
|
|
|
|
|
|
|
|
from insurance.utils.audit import log_operation
|
|
|
|
|
|
log_operation(user_id, "logout", "user", user_id)
|
|
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "message": "success", "data": None}
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_token(self, user_id: int, role: str, department: str = "") -> str:
|
|
|
|
|
|
"""生成 JWT Token。"""
|
|
|
|
|
|
secret = current_app.config.get("JWT_SECRET", "change-this")
|
|
|
|
|
|
expire = int(current_app.config.get("JWT_EXPIRE_SECONDS", 7200))
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"user_id": str(user_id),
|
|
|
|
|
|
"role": role,
|
|
|
|
|
|
"department": department,
|
|
|
|
|
|
"exp": int(time.time()) + expire,
|
|
|
|
|
|
"iat": int(time.time()),
|
|
|
|
|
|
}
|
|
|
|
|
|
return jwt.encode(payload, secret, algorithm="HS256")
|