小程序订阅消息每订阅一次只能发一条,用户无法持续收到通知。改为接入微信 服务号模板消息,用户关注服务号后可无限次接收推送通知。 新增功能: - ServiceAccountNotificationService:服务号模板消息发送服务 - 服务号事件回调(subscribe/unsubscribe)+ 手机号验证绑定流程 - PendingServiceBinding 模型:存储关注者的 openid 待绑定记录 - 小程序 bind-service 页面:输入手机号完成绑定 - 三个详情页(审批/任务/发厂)增加"绑定微信通知"入口 - web-sales NotificationBell 增加服务号关注引导 - web-admin SystemPage/ConfigPage 增加服务号绑定状态和模板配置展示 修复问题: - EventBus 和 _notify_status_change 重复发送微信消息(业务员收到2条) - EventBus order_status_changed 事件缺少模板映射导致微信通知静默丢失 - 所有微信发送失败被 except Exception: pass 静默吞掉,增加日志 - 定时任务(欠款/沉默客户/物流超时)不触发 WebSocket 实时通知 - reminder_method 配置未生效 Co-Authored-By: Claude <noreply@anthropic.com>
442 lines
19 KiB
Python
442 lines
19 KiB
Python
"""提醒服务模块
|
||
|
||
负责系统中各类业务提醒的查询、标记已读、以及自动生成提醒记录。
|
||
支持三类提醒场景:
|
||
- 欠款逾期提醒(arrears)
|
||
- 沉默客户提醒(inactive_customer)
|
||
- 物流超时提醒(logistics_timeout)
|
||
|
||
被调用方:reminders 路由、salesman 路由(仪表盘提醒列表)、定时任务入口。
|
||
"""
|
||
|
||
from datetime import datetime, timedelta
|
||
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from backend.app.core.cache import cache_delete_pattern, cache_get, cache_set, make_cache_key
|
||
from backend.app.core.error_codes import ErrorCode
|
||
from backend.app.core.exceptions import AppException
|
||
from backend.app.repositories.config_repository import ConfigRepository
|
||
from backend.app.repositories.reminder_repository import ReminderRepository
|
||
from backend.app.services.audit_service import audit_service
|
||
|
||
|
||
class ReminderService:
|
||
"""提醒业务服务。
|
||
|
||
依赖:
|
||
- ReminderRepository:提醒数据的增删查改
|
||
- ConfigRepository:读取系统配置(沉默天数阈值、物流超时小时数等)
|
||
- AuditService:操作审计日志
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.repository = ReminderRepository()
|
||
self.config_repository = ConfigRepository()
|
||
|
||
def list_reminders(self, session: Session | None = None, filters: dict | None = None) -> dict:
|
||
"""分页查询提醒列表。
|
||
|
||
参数:
|
||
session: 数据库会话,不可为 None。
|
||
filters: 可选的筛选条件,支持 page_no、page_size、receiver_user_id 等。
|
||
|
||
返回:
|
||
包含 total、page_no、page_size、list 的分页结果字典。
|
||
|
||
被调用方:reminders 路由(列表接口)、salesman 路由(仪表盘提醒数)。
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
filters = filters or {}
|
||
cache_key = make_cache_key("reminder:list", **filters)
|
||
cached = cache_get(cache_key)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
reminders = self.repository.list_reminders(session, filters)
|
||
page_no = filters.get("page_no", 1)
|
||
page_size = filters.get("page_size", 20)
|
||
start = max(page_no - 1, 0) * page_size
|
||
page_list = reminders[start : start + page_size]
|
||
result = {
|
||
"total": len(reminders),
|
||
"page_no": page_no,
|
||
"page_size": page_size,
|
||
"list": [self._map_reminder(item) for item in page_list],
|
||
}
|
||
cache_set(cache_key, result, ttl=30)
|
||
return result
|
||
except SQLAlchemyError as exc:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def read_reminder(self, reminder_id: int, session: Session | None = None, current_user: dict | None = None) -> dict:
|
||
"""将指定提醒标记为已读。
|
||
|
||
参数:
|
||
reminder_id: 提醒记录 ID。
|
||
session: 数据库会话,不可为 None。
|
||
current_user: 当前登录用户信息;销售角色只能标记自己的提醒。
|
||
|
||
返回:
|
||
包含 reminder_id 和 status 的字典。
|
||
|
||
被调用方:reminders 路由(标记已读接口)。
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
try:
|
||
reminder = self.repository.get_reminder(session, reminder_id)
|
||
if reminder is None:
|
||
raise AppException(code=ErrorCode.NOT_FOUND, message="提醒不存在", status_code=404)
|
||
if current_user and current_user.get("role_code") == "salesman":
|
||
if reminder.receiver_user_id != current_user.get("user_id"):
|
||
raise AppException(code=ErrorCode.FORBIDDEN, message="无权操作他人提醒", status_code=403)
|
||
before_status = reminder.status
|
||
reminder.status = "read"
|
||
session.add(reminder)
|
||
audit_service.write_log(
|
||
session,
|
||
{
|
||
"operate_type": "reminder_read",
|
||
"biz_type": reminder.biz_type,
|
||
"biz_id": reminder.biz_id,
|
||
"before_value": {"status": before_status},
|
||
"after_value": {"status": reminder.status},
|
||
"remark": f"提醒 {reminder_id} 标记已读",
|
||
},
|
||
)
|
||
session.commit()
|
||
cache_delete_pattern("reminder:*")
|
||
cache_delete_pattern("dashboard:*")
|
||
return {"reminder_id": reminder.id, "status": reminder.status}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def check_arrears(self, session: Session | None = None) -> dict:
|
||
"""检查逾期欠款并自动生成提醒。
|
||
|
||
按客户汇总逾期欠款总额,为每笔逾期记录创建提醒,
|
||
提醒内容包含客户累计欠款金额,便于业务员催收。
|
||
|
||
参数:
|
||
session: 数据库会话,不可为 None。
|
||
|
||
返回:
|
||
包含 checked 和 created_count 的结果字典。
|
||
|
||
被调用方:reminders 路由(check-arrears 接口)、check_all 方法。
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
try:
|
||
customer_rows = self.repository.list_overdue_arrears_by_customer(session)
|
||
created_count = 0
|
||
for customer, receiver_user_id, total_amount, arrears_list in customer_rows:
|
||
if receiver_user_id <= 0 or total_amount <= 0:
|
||
continue
|
||
|
||
customer_name = customer.customer_name if customer else "未知客户"
|
||
for arrears, order in arrears_list:
|
||
if arrears.due_date is None or arrears.due_date >= datetime.now().date():
|
||
continue
|
||
|
||
existed = self.repository.find_active_reminder(
|
||
session,
|
||
"arrears",
|
||
"customer_arrears",
|
||
arrears.id,
|
||
receiver_user_id,
|
||
)
|
||
if existed is not None:
|
||
continue
|
||
|
||
title = f"客户欠款逾期提醒 - {customer_name}"
|
||
content = (
|
||
f"客户累计逾期欠款 {total_amount:.2f} 元,"
|
||
f"当前笔欠款 {float(arrears.arrears_amount or 0):.2f} 元,"
|
||
f"订单编号 {order.order_no if order else '-'},请及时跟进催收。"
|
||
)
|
||
self.repository.create_reminder(
|
||
session,
|
||
{
|
||
"reminder_type": "arrears",
|
||
"biz_type": "customer_arrears",
|
||
"biz_id": arrears.id,
|
||
"receiver_user_id": receiver_user_id,
|
||
"reminder_title": title,
|
||
"reminder_content": content,
|
||
"status": "pending",
|
||
"sent_at": datetime.now(),
|
||
},
|
||
)
|
||
arrears.reminded_at = datetime.now()
|
||
arrears.status = "overdue"
|
||
session.add(arrears)
|
||
created_count += 1
|
||
|
||
self._send_ws_notification(
|
||
receiver_user_id,
|
||
{"reminder_type": "arrears", "reminder_title": title, "reminder_content": content},
|
||
)
|
||
|
||
session.commit()
|
||
cache_delete_pattern("reminder:*")
|
||
cache_delete_pattern("dashboard:*")
|
||
return {"checked": True, "created_count": created_count}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def check_inactive_customers(self, session: Session | None = None) -> dict:
|
||
"""检查沉默客户并自动生成提醒。
|
||
|
||
根据系统配置的天数阈值和金额阈值,查找超期未下单的客户,
|
||
为对应销售人员创建沉默客户提醒。
|
||
|
||
参数:
|
||
session: 数据库会话,不可为 None。
|
||
|
||
返回:
|
||
包含 checked 和 created_count 的结果字典。
|
||
|
||
被调用方:reminders 路由(check-inactive-customers 接口)、check_all 方法。
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
try:
|
||
inactive_days = int(self._get_config_value(session, "inactive_customer_days", "90"))
|
||
amount_threshold = float(self._get_config_value(session, "inactive_customer_amount_threshold", "1000"))
|
||
inactive_before = datetime.now() - timedelta(days=inactive_days)
|
||
rows = self.repository.list_inactive_customers(session, inactive_before)
|
||
|
||
created_count = 0
|
||
for customer, latest_order, total_amount in rows:
|
||
if total_amount < amount_threshold:
|
||
continue
|
||
receiver_user_id = (customer.salesman_id or 0)
|
||
if receiver_user_id <= 0:
|
||
continue
|
||
|
||
existed = self.repository.find_active_reminder(
|
||
session,
|
||
"inactive_customer",
|
||
"customer",
|
||
customer.id,
|
||
receiver_user_id,
|
||
)
|
||
if existed is not None:
|
||
continue
|
||
|
||
last_order_time = (
|
||
latest_order.created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||
if latest_order and latest_order.created_at
|
||
else "暂无订单"
|
||
)
|
||
self.repository.create_reminder(
|
||
session,
|
||
{
|
||
"reminder_type": "inactive_customer",
|
||
"biz_type": "customer",
|
||
"biz_id": customer.id,
|
||
"receiver_user_id": receiver_user_id,
|
||
"reminder_title": f"沉默客户提醒 - {customer.customer_name}",
|
||
"reminder_content": f"客户超过 {inactive_days} 天未下单,最近订单时间:{last_order_time}。",
|
||
"status": "pending",
|
||
"sent_at": datetime.now(),
|
||
},
|
||
)
|
||
created_count += 1
|
||
|
||
self._send_ws_notification(
|
||
receiver_user_id,
|
||
{"reminder_type": "inactive_customer", "reminder_title": f"沉默客户提醒 - {customer.customer_name}"},
|
||
)
|
||
|
||
session.commit()
|
||
cache_delete_pattern("reminder:*")
|
||
cache_delete_pattern("dashboard:*")
|
||
return {"checked": True, "created_count": created_count}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def check_logistics_timeout(self, session: Session | None = None) -> dict:
|
||
"""检查物流超时并自动生成提醒。
|
||
|
||
根据系统配置的超时小时数,查找超时未完成物流流转的订单,
|
||
为对应销售人员创建物流超时提醒。覆盖两种场景:
|
||
- 任务创建后长时间未揽货
|
||
- 已揽货但长时间未送达
|
||
|
||
参数:
|
||
session: 数据库会话,不可为 None。
|
||
|
||
返回:
|
||
包含 checked 和 created_count 的结果字典。
|
||
|
||
被调用方:reminders 路由(check-logistics-timeout 接口)、check_all 方法。
|
||
"""
|
||
if session is None:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
try:
|
||
timeout_hours = int(self._get_config_value(session, "logistics_timeout_hours", "48"))
|
||
timeout_before = datetime.now() - timedelta(hours=timeout_hours)
|
||
candidates = self.repository.list_logistics_timeout_candidates(session, timeout_before)
|
||
created_count = 0
|
||
for order, timeout_type in candidates:
|
||
receiver_user_id = (order.salesman_id or 0)
|
||
if receiver_user_id <= 0:
|
||
continue
|
||
|
||
existed = self.repository.find_active_reminder(
|
||
session,
|
||
"logistics_timeout",
|
||
"sales_order",
|
||
order.id,
|
||
receiver_user_id,
|
||
)
|
||
if existed is not None:
|
||
continue
|
||
|
||
if timeout_type == "no_pickup":
|
||
title = f"物流超时提醒 - {order.order_no}"
|
||
content = f"订单 {order.order_no} 超过 {timeout_hours} 小时未完成物流流转,请及时跟进。"
|
||
else:
|
||
title = f"物流停滞提醒 - {order.order_no}"
|
||
content = f"订单 {order.order_no} 已揽货但超过 {timeout_hours} 小时未送达,请及时跟进。"
|
||
|
||
self.repository.create_reminder(
|
||
session,
|
||
{
|
||
"reminder_type": "logistics_timeout",
|
||
"biz_type": "sales_order",
|
||
"biz_id": order.id,
|
||
"receiver_user_id": receiver_user_id,
|
||
"reminder_title": title,
|
||
"reminder_content": content,
|
||
"status": "pending",
|
||
"sent_at": datetime.now(),
|
||
},
|
||
)
|
||
created_count += 1
|
||
|
||
self._send_ws_notification(
|
||
receiver_user_id,
|
||
{"reminder_type": "logistics_timeout", "reminder_title": title, "reminder_content": content},
|
||
)
|
||
|
||
session.commit()
|
||
cache_delete_pattern("reminder:*")
|
||
cache_delete_pattern("dashboard:*")
|
||
return {"checked": True, "created_count": created_count}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
def check_all(self, session: Session | None = None) -> dict:
|
||
"""统一触发入口,一次性执行全部三类提醒检查。
|
||
|
||
便于管理端定时任务或手动补跑,依次调用欠款、沉默客户、物流超时检查。
|
||
|
||
参数:
|
||
session: 数据库会话,为 None 时返回零计数结果。
|
||
|
||
返回:
|
||
包含各类提醒创建数量和 total_created_count 的汇总字典。
|
||
|
||
被调用方:reminders 路由(check-all 接口)。
|
||
"""
|
||
if session is None:
|
||
return {
|
||
"checked": True,
|
||
"arrears_created_count": 0,
|
||
"inactive_customer_created_count": 0,
|
||
"logistics_timeout_created_count": 0,
|
||
"total_created_count": 0,
|
||
}
|
||
|
||
# 统一触发入口直接串起三类提醒,便于管理端定时任务或手动补跑。
|
||
arrears_result = self.check_arrears(session)
|
||
inactive_result = self.check_inactive_customers(session)
|
||
logistics_result = self.check_logistics_timeout(session)
|
||
total_created_count = (
|
||
int(arrears_result.get("created_count", 0))
|
||
+ int(inactive_result.get("created_count", 0))
|
||
+ int(logistics_result.get("created_count", 0))
|
||
)
|
||
return {
|
||
"checked": True,
|
||
"arrears_created_count": arrears_result.get("created_count", 0),
|
||
"inactive_customer_created_count": inactive_result.get("created_count", 0),
|
||
"logistics_timeout_created_count": logistics_result.get("created_count", 0),
|
||
"total_created_count": total_created_count,
|
||
}
|
||
|
||
|
||
def _send_ws_notification(self, user_id, reminder):
|
||
"""Send WebSocket realtime notification for scheduled reminders."""
|
||
try:
|
||
from backend.app.services.event_bus import _run_async
|
||
async def _send():
|
||
from backend.app.core.pubsub import ReminderPubSub
|
||
pubsub = ReminderPubSub()
|
||
await pubsub.publish(user_id, reminder)
|
||
await pubsub.close()
|
||
_run_async(_send())
|
||
except Exception:
|
||
pass
|
||
|
||
def _get_config_value(self, session: Session, config_key: str, default: str) -> str:
|
||
"""从系统配置表读取配置值,不存在时返回默认值。
|
||
|
||
参数:
|
||
session: 数据库会话。
|
||
config_key: 配置键名。
|
||
default: 当配置不存在或为空时的默认值。
|
||
|
||
返回:
|
||
配置值字符串。
|
||
"""
|
||
config = self.config_repository.get_by_key(session, config_key)
|
||
return config.config_value if config is not None and config.config_value else default
|
||
|
||
def _map_reminder(self, reminder) -> dict:
|
||
"""将数据库提醒对象转换为 API 返回用的字典格式。
|
||
|
||
参数:
|
||
reminder: SQLAlchemy 的提醒模型对象。
|
||
|
||
返回:
|
||
包含提醒各字段的字典,日期格式化为 'YYYY-MM-DD HH:MM:SS'。
|
||
"""
|
||
return {
|
||
"reminder_id": reminder.id,
|
||
"title": reminder.reminder_title,
|
||
"content": reminder.reminder_content,
|
||
"type": reminder.reminder_type,
|
||
"biz_type": reminder.biz_type,
|
||
"biz_id": reminder.biz_id,
|
||
"receiver_user_id": reminder.receiver_user_id,
|
||
"status": reminder.status,
|
||
"sent_at": reminder.sent_at.strftime("%Y-%m-%d %H:%M:%S") if reminder.sent_at else "",
|
||
"created_at": reminder.created_at.strftime("%Y-%m-%d %H:%M:%S") if reminder.created_at else "",
|
||
}
|
||
|
||
|
||
reminder_service = ReminderService()
|