Files
shaguabijia-app-server/app/services/notification_events.py
T
2026-07-31 14:34:07 +08:00

394 lines
16 KiB
Python

"""消息通知中心:业务事件 → 站内消息 + 厂商 push 的统一下发口。
PRD《消息通知中心》真实业务触发在此收口(替代 /push/test 的样例数据),已接入:
#3 withdraw_success 提现到账(repositories/wallet 各「pending→success」转换点)
#4 withdraw_failed 提现失败/退回(repositories/wallet._refund_withdraw,含审核拒绝)
#9 feedback_reply 官方回复(admin 反馈审核「拒绝」,带用户可见原因/留言)
#10 feedback_reward 反馈奖励(admin 反馈审核「采纳」发金币,必带官方留言)
#11 report_approved 爆料审核通过(admin 上报更低价「通过」发金币)
#12 invite_order_reward 好友下单到账(repositories/invite.try_reward_on_compare 发奖后)
行为约定(调用方唯一需要知道的两条):
1. **绝不抛异常**——通知只是业务的副产物,站内消息落库失败/推送失败只 log,
绝不让提现退款、审核发奖等主流程回滚或报错。
2. **必须在业务事务 commit 之后调用**——内部会再 commit(写 notification 表);
若在业务半途调用,会把调用方未提交的脏状态一并提交。
去重:各事件用业务主键做 dedup_key(提现单号/反馈 id/爆料 id/被邀请人 id),配合
notification 表的部分唯一索引,同一事件并发重复触发时未读期间只落一条、只推一次。
推送:向该用户所有已上报厂商 token 的设备直推(integrations/vendor_push);
厂商凭据未配置(本地/测试环境)时自动跳过推送、只落站内消息。extras 按
PRD §4 约定带 {type, notificationId, ...跳转参数},客户端点击 push 深链落地
并调 POST /notifications/read 同步置读。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import notification_catalog as catalog
from app.core.rewards import CN_TZ
from app.integrations import vendor_push
from app.models.user import User
from app.repositories import device as device_repo
from app.repositories import notification as notif_repo
if TYPE_CHECKING:
from app.models.feedback import Feedback
from app.models.notification import Notification
from app.models.price_report import PriceReport
from app.models.wallet import WithdrawOrder
logger = logging.getLogger("shagua.notification_events")
def _fmt_time(dt: datetime) -> str:
"""信息行「到账时间」的展示格式(与 repositories/notification 样例卡一致)。"""
return dt.strftime("%Y-%m-%d %H:%M")
def _yuan_trim(cents: int) -> str:
"""分 → 元,去掉多余的 0(200→"2"、1280→"12.80")。push 正文用(PRD §5 示例口径:
{2}元现金已到账」);卡片数值仍走 cash_cents 由前端按两位小数渲染。"""
yuan = cents / 100
return f"{yuan:.2f}".rstrip("0").rstrip(".")
def _display_name(user: User | None) -> str:
"""好友昵称展示:昵称 → 微信昵称 → 手机尾号,全无则「好友」。"""
name = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
if not name and user and user.phone:
name = f"用户{user.phone[-4:]}"
return name or "好友"
# ---------------------------------------------------------------------------
# 内核:落站内消息 + 厂商推送(全程吞异常)
# ---------------------------------------------------------------------------
def _dispatch(
db: Session,
*,
user_id: int,
type_key: str,
coins: int | None = None,
cash_cents: int | None = None,
info_rows: list[dict[str, str]] | None = None,
extra: dict[str, str] | None = None,
dedup_key: str | None = None,
push_vars: dict[str, str] | None = None,
push_token_contains: str | None = None,
) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
logger.info(
"notification dispatch started user_id=%s type=%s dedup_key=%s",
user_id, type_key, dedup_key,
)
try:
row = notif_repo.create_notification(
db,
user_id=user_id,
type_key=type_key,
coins=coins,
cash_cents=cash_cents,
info_rows=info_rows,
extra=extra,
dedup_key=dedup_key,
)
except IntegrityError:
# 同 (user, type, dedup_key) 已有未读消息 = 同一事件并发/重复触发 → 不重复落、不重复推
db.rollback()
logger.info(
"notification dedup hit user_id=%s type=%s dedup_key=%s", user_id, type_key, dedup_key
)
return None
except Exception: # noqa: BLE001 — 通知失败绝不影响业务主流程
logger.exception("create notification failed user_id=%s type=%s", user_id, type_key)
try:
db.rollback()
except Exception: # noqa: BLE001 — 回滚失败也不外抛,session 由请求生命周期兜底
logger.exception("rollback after notification failure also failed")
return None
logger.info(
"notification created user_id=%s type=%s notification_id=%s dedup_key=%s",
user_id, type_key, row.id, dedup_key,
)
_push_to_user_devices(db, row, push_vars, push_token_contains)
return row
def _push_to_user_devices(
db: Session,
row: Notification,
push_vars: dict[str, str] | None,
push_token_contains: str | None = None,
) -> None:
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
try:
title, body = catalog.render_push(row.type, push_vars)
# PRD §4 push 联动:extras 至少带 type + notificationId,外加该类型的跳转参数(extra 列)
extras: dict[str, str] = {"type": row.type}
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
extras["notificationId"] = str(row.id)
targets = device_repo.list_push_targets(db, user_id=row.user_id)
if push_token_contains:
normalized_filter = push_token_contains.casefold()
targets = [
target
for target in targets
if normalized_filter in (target.push_token or "").casefold()
]
logger.info(
"push targets resolved user_id=%s type=%s notification_id=%s "
"token_filter=%s target_count=%s",
row.user_id, row.type, row.id, bool(push_token_contains), len(targets),
)
if not targets:
logger.warning(
"push skipped no targets user_id=%s type=%s notification_id=%s",
row.user_id, row.type, row.id,
)
return
sent = failed = skipped = data_sent = data_failed = 0
for dev in targets:
vendor = vendor_push.normalize_vendor(dev.push_vendor)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
skipped += 1
logger.warning(
"push target skipped unsupported vendor user_id=%s type=%s "
"notification_id=%s device_id=%s raw_vendor=%s normalized_vendor=%s",
row.user_id, row.type, row.id, dev.device_id, dev.push_vendor, vendor,
)
continue
missing = vendor_push.missing_settings(vendor)
if missing:
skipped += 1
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.warning(
"push target skipped vendor not configured user_id=%s type=%s "
"notification_id=%s device_id=%s vendor=%s missing_settings=%s",
row.user_id, row.type, row.id, dev.device_id, vendor, missing,
)
continue
logger.info(
"push send started user_id=%s type=%s notification_id=%s "
"device_id=%s vendor=%s",
row.user_id, row.type, row.id, dev.device_id, vendor,
)
try:
response = vendor_push.send_notification(
vendor, dev.push_token, title=title, body=body, extras=extras
)
if vendor == "huawei" and row.type in {"feedback_reply", "feedback_reward"}:
# 华为反馈推送联调日志:保留厂商返回码/requestId 等排障信息,
# 请求本身的 token、Authorization 和应用密钥不会进入 response。
logger.info(
"huawei feedback push response user_id=%s type=%s "
"notification_id=%s response=%s",
row.user_id, row.type, row.id, response,
)
logger.info(
"push sent user_id=%s type=%s vendor=%s notification_id=%s device_id=%s",
row.user_id, row.type, vendor, row.id, dev.device_id,
)
sent += 1
except vendor_push.VendorPushError as e:
failed += 1
logger.warning(
"push failed user_id=%s type=%s vendor=%s notification_id=%s "
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
)
try:
data_response = vendor_push.send_data_event(
vendor,
dev.push_token,
event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED,
notification_id=str(row.id),
)
data_sent += 1
logger.info(
"push data event completed user_id=%s type=%s vendor=%s "
"notification_id=%s device_id=%s response=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, data_response,
)
except vendor_push.VendorPushError as e:
data_failed += 1
# 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。
logger.warning(
"push data event failed user_id=%s type=%s vendor=%s notification_id=%s "
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
)
logger.info(
"push dispatch completed user_id=%s type=%s notification_id=%s targets=%s "
"sent=%s failed=%s skipped=%s data_sent=%s data_failed=%s",
row.user_id, row.type, row.id, len(targets),
sent, failed, skipped, data_sent, data_failed,
)
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
# ---------------------------------------------------------------------------
# 六个业务事件(PRD §1/§3/§5 编号见文件头)
# ---------------------------------------------------------------------------
def notify_withdraw_success(
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_success",
cash_cents=order.amount_cents,
info_rows=[
{"label": "到账账户", "value": "微信钱包"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
push_token_contains=push_token_contains,
)
def notify_withdraw_failed(
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。
"""
reason = (order.fail_reason or "").strip() or "提现未成功"
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_failed",
cash_cents=order.amount_cents,
info_rows=[
{"label": "失败原因", "value": reason},
{"label": "退回说明", "value": "款项已原路退回现金余额"},
],
extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
push_token_contains=push_token_contains,
)
def notify_feedback_reply(
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)。"""
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reply",
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_token_contains=push_token_contains,
)
def notify_feedback_reward(
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
运营漏填时省略该信息行,不硬造文案。"""
coins = int(feedback.reward_coins or 0)
info_rows = [{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}]
reply = (feedback.admin_reply or "").strip()
if reply:
info_rows.append({"label": "官方留言", "value": reply})
info_rows.append({"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))})
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reward",
coins=coins,
info_rows=info_rows,
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_vars={"coins": str(coins)},
push_token_contains=push_token_contains,
)
def notify_report_approved(
db: Session,
report: PriceReport,
*,
push_token_contains: str | None = None,
) -> None:
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺"
_dispatch(
db,
user_id=report.user_id,
type_key="report_approved",
coins=coins,
info_rows=[
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"reportId": str(report.id)},
dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)},
push_token_contains=push_token_contains,
)
def notify_invite_order_reward(
db: Session,
*,
inviter_user_id: int,
invitee_user_id: int,
cash_cents: int,
push_token_contains: str | None = None,
) -> None:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。"""
invitee = db.get(User, invitee_user_id)
nickname = _display_name(invitee)
_dispatch(
db,
user_id=inviter_user_id,
type_key="invite_order_reward",
cash_cents=cash_cents,
info_rows=[
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
push_token_contains=push_token_contains,
)