dingdanquanliucheng/backend/app/services/reminder_service.py
wsb1224 1d12d061e0 接入微信服务号模板消息,修复通知链路重复发送和静默失败
小程序订阅消息每订阅一次只能发一条,用户无法持续收到通知。改为接入微信
服务号模板消息,用户关注服务号后可无限次接收推送通知。

新增功能:
- 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>
2026-06-23 17:17:06 +08:00

442 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""提醒服务模块
负责系统中各类业务提醒的查询、标记已读、以及自动生成提醒记录。
支持三类提醒场景:
- 欠款逾期提醒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()