|
|
@@ -1,7 +1,9 @@
|
|
|
import math
|
|
|
-from datetime import datetime, timezone
|
|
|
+from datetime import datetime, timezone, timedelta
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
+CST = timezone(timedelta(hours=8)) # 东八区
|
|
|
+
|
|
|
from sqlalchemy import select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
from app.models.license import SuperAdminLicense
|
|
|
@@ -16,7 +18,7 @@ from app.schemas.license import (
|
|
|
SuperAdminOption,
|
|
|
LicenseListResponse,
|
|
|
)
|
|
|
-from app.services.sms import send_license_expired, send_license_restored
|
|
|
+from app.services.sms import send_license_expired, send_license_restored, send_license_warning
|
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
@@ -26,7 +28,7 @@ def _to_str(val) -> str:
|
|
|
if val is None:
|
|
|
return ""
|
|
|
if isinstance(val, datetime):
|
|
|
- return val.isoformat()
|
|
|
+ return val.astimezone(CST).isoformat()
|
|
|
return str(val)
|
|
|
|
|
|
|
|
|
@@ -58,24 +60,61 @@ async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) ->
|
|
|
return visitor.phone, company
|
|
|
|
|
|
|
|
|
-async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense):
|
|
|
- """License 过期时发送短信通知(静默失败,不阻断主流程)"""
|
|
|
+async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
|
|
|
+ """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
|
|
|
try:
|
|
|
phone, company = await _get_contact_for_license(db, lic)
|
|
|
- if phone:
|
|
|
- await send_license_expired(phone, company)
|
|
|
+ if not phone:
|
|
|
+ logger.warning("License #%d 无联系人手机号,跳过预警短信", lic.id)
|
|
|
+ return "skipped"
|
|
|
+ logger.info("发送 License 预警短信(过期): license_id=%d, phone=%s, company=%s", lic.id, phone, company)
|
|
|
+ ok, reason = await send_license_expired(phone, company)
|
|
|
+ if ok:
|
|
|
+ logger.info("License 预警短信发送成功: license_id=%d", lic.id)
|
|
|
+ return "sent"
|
|
|
+ logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
|
|
|
+ return "failed"
|
|
|
except Exception:
|
|
|
- logger.exception("发送过期短信失败,license_id=%d", lic.id)
|
|
|
+ logger.exception("发送预警短信异常,license_id=%d", lic.id)
|
|
|
+ return "failed"
|
|
|
|
|
|
|
|
|
-async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense):
|
|
|
- """License 恢复时发送短信通知(静默失败,不阻断主流程)"""
|
|
|
+async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
|
|
|
+ """License valid 从 false 变为 true 时发送恢复短信。返回: sent | skipped | failed"""
|
|
|
try:
|
|
|
phone, company = await _get_contact_for_license(db, lic)
|
|
|
- if phone:
|
|
|
- await send_license_restored(phone, company)
|
|
|
+ if not phone:
|
|
|
+ logger.warning("License #%d 无联系人手机号,跳过恢复短信", lic.id)
|
|
|
+ return "skipped"
|
|
|
+ logger.info("发送 License 恢复短信: license_id=%d, phone=%s, company=%s", lic.id, phone, company)
|
|
|
+ ok, reason = await send_license_restored(phone, company)
|
|
|
+ if ok:
|
|
|
+ logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
|
|
|
+ return "sent"
|
|
|
+ logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
|
|
|
+ return "failed"
|
|
|
except Exception:
|
|
|
- logger.exception("发送恢复短信失败,license_id=%d", lic.id)
|
|
|
+ logger.exception("发送恢复短信异常,license_id=%d", lic.id)
|
|
|
+ return "failed"
|
|
|
+
|
|
|
+
|
|
|
+async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
|
|
|
+ """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
|
|
|
+ try:
|
|
|
+ phone, company = await _get_contact_for_license(db, lic)
|
|
|
+ if not phone:
|
|
|
+ logger.warning("License #%d 无联系人手机号,跳过即将过期预警短信", lic.id)
|
|
|
+ return "skipped"
|
|
|
+ logger.info("发送 License 即将过期预警: license_id=%d, phone=%s, company=%s, days_left=%d", lic.id, phone, company, days_left)
|
|
|
+ ok, reason = await send_license_warning(phone, company, days_left)
|
|
|
+ if ok:
|
|
|
+ logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
|
|
|
+ return "sent"
|
|
|
+ logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
|
|
|
+ return "failed"
|
|
|
+ except Exception:
|
|
|
+ logger.exception("发送即将过期预警短信异常,license_id=%d", lic.id)
|
|
|
+ return "failed"
|
|
|
|
|
|
|
|
|
def _calc_days_left(expires_at: datetime) -> int:
|
|
|
@@ -104,6 +143,8 @@ async def create_license(
|
|
|
exist_lic = existing.scalar_one_or_none()
|
|
|
|
|
|
expires_at = datetime.fromisoformat(payload.expires_at)
|
|
|
+ if expires_at.tzinfo is None:
|
|
|
+ expires_at = expires_at.replace(tzinfo=CST)
|
|
|
|
|
|
if exist_lic:
|
|
|
exist_lic.license_key = payload.license_key
|
|
|
@@ -211,11 +252,11 @@ async def get_license_status(
|
|
|
raise ValueError("License 不存在")
|
|
|
|
|
|
days_left = _calc_days_left(lic.expires_at)
|
|
|
+ sms_status = None
|
|
|
if lic.status == "active" and days_left <= 0:
|
|
|
lic.status = "expired"
|
|
|
await db.commit()
|
|
|
- # 触发过期通知
|
|
|
- await _notify_on_expired(db, lic)
|
|
|
+ sms_status = await _notify_on_expired(db, lic)
|
|
|
|
|
|
sa_result = await db.execute(
|
|
|
select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
|
|
|
@@ -233,6 +274,7 @@ async def get_license_status(
|
|
|
max_tenants=lic.max_tenants,
|
|
|
max_users_per_tenant=lic.max_users_per_tenant,
|
|
|
remark=lic.remark,
|
|
|
+ sms_status=sms_status,
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -246,9 +288,12 @@ async def revoke_license(
|
|
|
lic = result.scalar_one_or_none()
|
|
|
if not lic:
|
|
|
raise ValueError("License 不存在")
|
|
|
+ old_status = lic.status
|
|
|
lic.status = "revoked"
|
|
|
await db.commit()
|
|
|
- return {"message": "License已吊销"}
|
|
|
+ # 吊销即 valid 变为 false,不管原状态如何都发预警短信
|
|
|
+ sms_status = await _notify_on_expired(db, lic)
|
|
|
+ return {"message": "License已吊销", "sms_status": sms_status}
|
|
|
|
|
|
|
|
|
async def update_license(
|
|
|
@@ -262,26 +307,36 @@ async def update_license(
|
|
|
if not lic:
|
|
|
raise ValueError("License 不存在")
|
|
|
changed = False
|
|
|
+ old_status = lic.status
|
|
|
+ sms_status = "none"
|
|
|
if payload.license_key is not None:
|
|
|
lic.license_key = payload.license_key
|
|
|
changed = True
|
|
|
if payload.expires_at is not None:
|
|
|
- old_status = lic.status
|
|
|
- lic.expires_at = datetime.fromisoformat(payload.expires_at)
|
|
|
+ expires_at = datetime.fromisoformat(payload.expires_at)
|
|
|
+ if expires_at.tzinfo is None:
|
|
|
+ expires_at = expires_at.replace(tzinfo=CST)
|
|
|
+ lic.expires_at = expires_at
|
|
|
if _calc_days_left(lic.expires_at) > 0:
|
|
|
lic.status = "active"
|
|
|
else:
|
|
|
lic.status = "expired"
|
|
|
changed = True
|
|
|
- # 状态变化时发送短信通知
|
|
|
- if old_status == "active" and lic.status == "expired":
|
|
|
- await _notify_on_expired(db, lic)
|
|
|
- elif old_status == "expired" and lic.status == "active":
|
|
|
- await _notify_on_restored(db, lic)
|
|
|
if not changed:
|
|
|
raise ValueError("没有需要更新的字段")
|
|
|
+ # 修改过期时间后已超出 7 天窗口,重置预警标记
|
|
|
+ if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
|
|
|
+ lic.warning_sent = False
|
|
|
+ elif old_status != "active" and lic.status == "active":
|
|
|
+ lic.warning_sent = False
|
|
|
await db.commit()
|
|
|
- return {"message": "License已更新", "license_id": lic.id}
|
|
|
+
|
|
|
+ # valid 变化时发送短信
|
|
|
+ if old_status == "active" and lic.status != "active":
|
|
|
+ sms_status = await _notify_on_expired(db, lic)
|
|
|
+ elif old_status != "active" and lic.status == "active":
|
|
|
+ sms_status = await _notify_on_restored(db, lic)
|
|
|
+ return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status}
|
|
|
|
|
|
|
|
|
async def restore_license(
|
|
|
@@ -297,10 +352,10 @@ async def restore_license(
|
|
|
if lic.status != "revoked":
|
|
|
raise ValueError("仅可恢复已吊销的 License")
|
|
|
lic.status = "active"
|
|
|
+ lic.warning_sent = False # 恢复后重置预警标记
|
|
|
await db.commit()
|
|
|
- # 触发恢复通知
|
|
|
- await _notify_on_restored(db, lic)
|
|
|
- return {"message": "License已恢复"}
|
|
|
+ sms_status = await _notify_on_restored(db, lic)
|
|
|
+ return {"message": "License已恢复", "sms_status": sms_status}
|
|
|
|
|
|
|
|
|
async def delete_license(
|