主题
站内通知系统(统一消息中心)· 产品与技术方案
版本:v2.0 | 最后更新:2026-07-09 适用系统:wallet-server(UTGL 渠道;银盛卡通知由银盛自发,本系统不发) 配套原型:
站内通知原型.html(web 端静态交互效果,含消息中心 + 客服入口) 一句话目标:为平台建一个"用户信箱"——把卡(3DS/锁卡/风控)、POBO、股票交易等各业务里"用户漏看会有损失"的事件,统一收进一个消息中心,让用户在平台内就能第一时间知道、能处理、能查历史;并把客服(Chatwoot)入口提到显眼位置,形成"平台→用户(消息中心)+ 用户↔平台(客服)"的完整触达闭环。
0. 核心结论(先看这里)
- 消息中心是"信箱",不是"仓库",与现有业务状态页并存、不替代。
- 现有的卡详情页 / 股票持仓页 / POBO 列表是仓库——用户主动去查"现在什么状态",前提是用户知道该去看、去哪看。
- 消息中心是信箱——平台主动告诉用户"刚发生了什么",解决"用户根本不知道要去查"的问题。
- 一句话:状态页解决"查得到",消息中心解决"知道要去查"。3DS(5-15 分钟时效)、风控冻结、股票止损/爆仓这类"漏看即损失/掉单"的事件,必须有一条消息主动把用户拉回来,光有状态页不够。
- 统一一张表存所有业务消息,但业务详情表各自分开(不合并)。 各业务(卡/POBO/股票)的状态、生命周期、权限仍在自己的业务表里;消息中心只做一张统一的
t_front_message(信封索引),存 title/content/link 等轻量字段 +business_type区分来源。这样"数据上分开、入口上统一"两个诉求同时满足。t_notice(代理店广播公告)保持现状不动,与本消息中心并列。 - 只收"漏看有损失"的事件,不做全量通知垃圾场。 3DS、风控、锁卡、股票止损/爆仓/成交、POBO 关键状态——这些进消息中心。纯高频数据(股票实时行情跳动、实时盈亏数字)不是消息,走 WebSocket 实时刷新,绝不进消息表,否则表会被行情冲爆。
- 系统里 80% 的零件已经存在,主要工作是"补全空实现 + 串联 + 补一个统一消息 API",不是从零造。
- 3DS 已在发 Email/SMS/Push(
UTGLWebhookCardAccountEventImpl.kt:463),唯独漏了写站内信。 - 消息中心
MessageSendCenterListener的 EMAIL/SMS 是空方法,锁卡/风控信号目前只发内部 Telegram 群,没送用户。 - 站内信表
t_front_message、FCM push、卡级通知偏好(receivePush/receiveEmail/receiveSms)都已就绪。 - 唯一完全缺失:用户侧"我的消息"列表/详情/已读接口。
- 3DS 已在发 Email/SMS/Push(
- 交易类通知与营销类通知必须彻底分离。交易类(3DS/锁卡/风控/股票/POBO)不可退订、不受频控、优先级最高、多通道必达;营销类(EDM 运营触达)受频控、可退订。混在一起迟早出"用户退订营销邮件结果连 3DS 都收不到"的合规事故。
- 用户端通知偏好设置暂不做(P1 砍掉)。站内消息无条件写入;外部通道(push/email/sms)沿用
UserCard已有的receiveXxx字段的默认值,不给用户开设置页。等有需要再于后期补回。 - 按发卡渠道开关:UTGL 卡触发平台通知,银盛卡由银盛自发、本系统跳过。在 webhook 入口按 channel 判断。
- 客服(Chatwoot)不替换、只做入口优化:把入口从"侧栏深处"提到顶栏铃铛旁 + 移动端常驻悬浮按钮,并打通未读红点,让用户随时能一键触达客服。详见 §8。
1. 产品价值与必要性(为什么必须做这个消息中心)
1.0 产品意义:从"弱连接"到"每天有理由回来"
平台和用户之间目前是弱连接——用户办完卡、下完单就离开,只有出问题时才被动地翻邮件、找客服。平台没有一个主动、可信、承载所有业务事件的官方触点。这带来三个结构性问题:
- 交易在流失,平台却不知道用户没看到:3DS 只发邮件,用户没及时看→交易被拒→GMV 白白掉了,而这本可以用一条站内+push 挽回。
- 用户的安全感不足:卡被锁、异地可疑交易,用户最后一个知道(甚至只能靠客服告知),对平台的信任被侵蚀。
- 业务割裂、没有"回来的理由":卡、股票、POBO 各自为政,用户要挨个页面巡逻。像支付宝/京东/美团那样——消息中心是用户每天愿意点开的入口,因为"我关心的所有事都在这"。
消息中心的产品价值就是把这条弱连接变强:让平台有能力在正确的时刻、用正确的方式,把"你该知道的事"送到用户面前,并沉淀成可查的历史。它既是交易的挽回工具(3DS/风控),也是信任的建立工具(知情权),更是用户留存的入口(跨业务聚合,提升打开频次与黏性)。
1.1 "信箱"与"仓库":为什么有状态页还要消息中心
这是最容易被质疑的一点,必须讲透。用户的业务状态,现有页面(卡详情、股票持仓、POBO 列表)都查得到,那为什么还要消息中心?因为两者解决的是完全不同的问题:
| 现有业务状态页(仓库) | 消息中心(信箱) | |
|---|---|---|
| 定位 | 用户主动去查"这张卡/这只股票现在什么状态" | 平台主动告知"刚刚发生了什么,你得知道" |
| 前提 | 用户知道要去看、且知道去哪看 | 用户不知道发生了事,靠推送/红点拉回来 |
| 覆盖 | 单业务、当前状态(快照) | 跨业务、事件流(历史 + 时间线) |
| 触发 | 被动,等用户想起来 | 主动,事件发生即触达 |
一句话:状态页解决"查得到",消息中心解决"知道要去查"。 对"漏看即损失"的事件(3DS 掉单、风控盗刷、股票爆仓),仓库是不够的——用户不会每 5 分钟去刷卡详情页,必须有一封"信"主动把他拉回来。
1.2 3DS 的业务特性决定了 email 是最差通道
3DS(3-D Secure)是 Visa/Mastercard 的持卡人身份验证协议。当一笔线上消费触发挑战时,发卡侧下发一个验证入口,持卡人必须在当前这笔交易的授权窗口内完成验证,交易才能继续。
| 特性 | 对通知送达的硬要求 | email 的问题 |
|---|---|---|
| 强时效:链接通常 5-15 分钟过期 | 秒级触达 + 主动提醒 | 邮件有投递延迟、可能进垃圾箱、用户不会实时盯邮箱 |
| 一次性:点击即失效 | 用户必须在正确的时刻看到正确的那一条 | 邮件列表里堆积多条,用户点到旧的失效链接 |
| 中断成本高:验证不及时=交易被拒 | 失败直接掉单 | 掉的是真实消费 GMV(北极星指标) |
行业标准做法:成熟发卡机构(Revolut、Wise、各大银行 App)对 3DS 验证一律 App push + 站内为主,email/SMS 兜底。只有 email 单通道,等于把一个秒级场景交给分钟级甚至更慢的通道。
1.3 锁卡 / 风控 / 股票 / POBO:知情权、自助与止损
- 锁卡:卡被置为 LOCKED/SUSPENDED 后,用户在收银台刷不出来却不知道原因,只能打客服。站内 + push 第一时间告知"卡因 X 被锁,点此处理",降客诉、提升自助率。
- 风控:可疑交易(
TRANSACTION_SUSPICIOUS_ACTIVITY_UTGL)目前只进内部 Telegram,用户完全不知情。合规上持卡人对账户风险事件有知情权;产品上及时通知能让用户尽快确认"是否本人操作",既防盗刷也减少误伤。 - 股票交易:成交、触发止损、爆仓预警——都是"错过就有真金白银损失"的事件,必须进消息中心。注意:实时行情跳动、实时盈亏数字不是消息(不会让用户去"已读"每一次报价),走 WebSocket 实时刷新,不进消息表。
- POBO:关键状态流转(如代付/代收成功、失败、需补充材料)进消息中心。
1.4 本系统的三个现实驱动
- UTGL 卡由平台自己通知用户 → 送达质量 100% 是平台的责任,单通道(email)就是风险敞口。
- 银盛卡后续由银盛自发 → 模块必须按渠道可开关,否则银盛卡会被平台重复通知,权责不清。
- 交易强触达 ≠ 营销触达 → EDM 运营体系是营销(可频控、可退订);本消息中心是交易/业务类(不可退订、最高优先级)。两套逻辑必须在系统层面分开。
结论:必要性高,且属"必须做"。它直接影响交易成功率、风控合规、用户信任与留存,不是可后置的优化项。
2. 产品设计
2.1 通知分类(顶层模型)
所有站内通知先分两大类(category),这是频控与退订逻辑的分水岭:
| 类别 | 含义 | 频控 | 可否退订 | 优先级 | 通道策略 |
|---|---|---|---|---|---|
| TRANSACTIONAL 交易/业务类 | 3DS、锁卡、风控、股票成交/止损、POBO 状态 | 不受频控 | 不可退订 | 最高 | 站内 + push + email + SMS 并发必达 |
| MARKETING 营销类 | EDM 催激活/首刷/唤醒(见运营体系文档) | 受 canSend 频控 | 可退订 | 普通 | 主邮件,站内可选 |
本方案只负责 TRANSACTIONAL;MARKETING 见
运营数据指标与归因体系.md。两者共用站内信表和展示组件,但走不同的发送校验。t_notice(代理店广播公告)是独立的第三类,保持现状、与本消息中心并列,不并入t_front_message。
2.2 业务频道(business_type,消息中心按此分 segment)
消息中心是跨业务的统一信箱,前端用一排 segment(分段控件)在业务频道间切换。business_type 既是入库字段,也是前端 segment 的 key:
| segment(business_type) | 覆盖事件 | 触发源 | 落地/CTA |
|---|---|---|---|
| 全部 | 聚合所有频道,按时间倒序 | — | 点击进各自详情 |
| 交易验证(TRANSACTION_3DS) | 3DS 交易验证(强时效倒计时) | webhook challenge.initiated | "立即验证"→ 打开验证链接(web 新窗/app deep link) |
| 卡状态(CARD_STATUS) | 锁卡/解锁/激活/冻结 | 卡状态变为 LOCKED/SUSPENDED 等 | "查看卡片"/"联系客服" |
| 风控(RISK_ALERT) | 可疑交易、异地交易 | suspicious-activity-detected、RISK_CONTROL | "这是我本人"/"不是我,冻结" |
| 股票(STOCK) | 成交、触发止损、爆仓预警 | 股票业务事件(非实时行情) | "查看持仓/订单" |
| POBO(POBO) | 代付/代收关键状态流转 | POBO 业务事件 | "查看详情" |
| 系统(SYSTEM) | 平台维护、账户级系统通知 | 系统事件 | 纯展示 |
频道可随业务扩展:新增业务只需扩
MessageType枚举 + 往统一表投递,前端 segment 自动多一项,消息中心主体不改。 实时行情 / 实时盈亏不在此表:走 WebSocket 实时刷新交易页面,不产生"一条条待已读的消息"。
2.3 消息生命周期与"一次性链接"状态(关键体验点)
3DS 链接点击即失效、且会过期。站内消息列表里若放一个已失效的链接,用户点了会困惑。因此动作类消息要带 action_status(纯展示类消息该字段为 null):
PENDING(待处理,链接有效,显示倒计时)
├─→ COMPLETED(验证已完成:收到 challenge.succeeded/declined/failed 事件后回写)
├─→ EXPIRED (超过 expiresAt 未处理,前端置灰)
└─→ USED (链接已被点击使用,可选,若渠道能感知)read_time(已读)与action_status(处理状态)是两个独立维度:用户读了 3DS 通知(read_time有值)不等于验证完成(action_status仍 PENDING)。红点按未读消,但"待验证"标签要留到真正验证完。- 前端根据状态渲染:PENDING 显示醒目按钮 + 倒计时;COMPLETED/EXPIRED/USED 置灰,按钮不可点,并给出说明文案。
- 收到
challenge.succeeded/declined/failed的 webhook 事件时,用biz_ref反查对应 3DS 消息并更新action_status(这几个事件当前代码已接收但只更新了 webhook 事件表,未回写业务,见UTGLWebhookCardAccountEventImpl.kt:212-241)。
2.4 已读 / 未读
- 每条一个
read_time(null=未读),取代现有会漏验证的"已读指针"模型(指针模型下,点开最新一条会把前面未验证的 3DS 也标成已读,红点消失→漏验证)。 - 未读数 =
COUNT(read_time IS NULL),可按business_type分组 → 铃铛总红点 + 各 segment 角标。 - 支持:单条已读(打开详情即写)、整个频道已读、全部已读。
2.5 通知偏好(P1 暂不做)
- 用户端不给设置页。站内消息无条件写入(这是信箱的本质,永远有记录);外部通道 push/email/sms 沿用
UserCard已有receiveEmail/receiveSms/receivePush字段的默认值。 - 后期若要开放用户自助设置,再补页面(复用卡级字段),且站内通道恒开、不可关。
2.6 web 端交互(详见原型)
- 入口① 顶栏铃铛 + 未读红点:点击展开最近通知下拉(最近 5 条),"查看全部"进消息中心。位于顶栏公告 carousel 与语言切换之间。
- 入口② 侧栏"消息中心":与现有代理店"公告"入口分开列出。
- 消息中心页:顶部一排 segment 切换业务频道(全部/交易验证/卡状态/风控/股票/POBO/系统,带未读角标);下方 master-detail(左列表 + 右详情),风格对齐现有
notify.tsx。 - 各业务详情模板:3DS=大号倒计时+"立即验证";卡状态=状态+原因+"查看卡片";风控="本人/冻结";股票=成交/盈亏+"查看持仓";系统=纯文本。
- 一键已读 / 单条已读;segment 角标实时更新。
- 客服入口:铃铛旁再放一个"客服"入口,一键唤起 Chatwoot(详见 §8)。
3. 现状盘点(已读代码,标注可复用度)
| 能力 | 位置 | 现状 | 可复用 |
|---|---|---|---|
| 3DS webhook 接收 | UTGLWebhookCardAccountEventImpl.kt:196 | 已接 challenge.initiated,DTO 含 url/expiresAt/amount/currency | ✅ |
| 3DS 多通道发送 | 同上 :463-523 | Email + SMS + Push 已发,唯独没写站内信 | 🟡 补站内 |
| 3DS 结果事件 | 同上 :212-241 | challenge.succeeded/declined/failed 已接收,但只更新事件表、未回写业务状态 | 🟡 补回写 |
| 消息中心分发 | MessageSendCenterListener.kt:110 | RocketMQ 消费框架完整;EMAIL/SMS 是空实现,只有 Telegram 通 | 🟡 补 EMAIL/站内 |
| 锁卡/风控信号 | 同上 :254-278 | TRANSACTION_SUSPICIOUS_ACTIVITY_UTGL/RISK_CONTROL/USER_CARD_BALANCE 只发内部 TG 群 | 🟡 加发用户 |
| 站内信表 | Message.kt → t_front_message | 表 + DAL 有,字段:title/content/link/type/user_id/delete | 🟡 需扩字段 |
| 消息类型枚举 | MessageType.kt | 只有 SYSTEM 一种 | 🟡 需扩 |
| 未读计数 | MessageMapper.xml:33-50 | "已读指针"模型:客户端传每类最后已读 id,unread = id > 指针;无 per-message 已读、无 list/detail 接口 | 🟡 |
| App Push | IPushMessageService.kt / FirebasePushSender | FCM 完整,支持 i18n、deep link、ttl、批量 | ✅ |
| 卡级通知偏好 | UserCard.kt:284-296 | receiveEmail/receiveSms/receivePush 已有 | ✅ |
| 邮件发送 | TemplateMailSender + EmailServiceImpl | SMTP + Freemarker 就绪;已有 mail/utgl_3ds.ftl 等模板 | ✅ |
| 用户"我的消息"接口 | — | 完全缺失 | ❌ 新建 |
| 用户端实时推送(WebSocket) | WebSocketServer.kt | 存在,但用于 eladmin 监控,非用户通知 | 🟡 可选新建 |
4. 技术方案
4.1 数据流
UTGL webhook (challenge.initiated / suspicious-activity / 卡状态变更)
│ ★渠道判断:仅 UTGL 触发,银盛跳过
▼
UTGLWebhookCardAccountEventImpl
│
├─(1) 写站内信 t_front_message(★新增,交易类恒定写)
├─(2) push IPushMessageService.sendToUserAsync(已有,受 receivePush)
├─(3) email TemplateMailSender(已有,受 receiveEmail)
└─(4) sms IGlobalSmsSender(已有,受 receiveSms)
▼
challenge.succeeded/declined/failed ──► 反查 3DS 消息,回写 action_status(★新增)
▼
用户端(web/app):GET /message/list、/message/detail、/message/unread-count、POST /message/read
▲
(可选)WebSocket 用户通道,未读数实时刷新;否则登录/轮询拉取4.2 表设计(统一一张表,扩展现有 t_front_message,不新建、不合并 t_notice)
决策:所有业务的消息统一进 t_front_message 一张表(信封索引),各业务详情表保持分开、各管各的。t_notice 代理店公告保持现状不动。复用现有站内信表,补齐所需字段。只加列,不改已有列(现有 SYSTEM 消息不受影响)。
现有字段(已就绪):id, title, content, link, type, user_id, create_time, update_time, delete
需要补的字段:
sql
ALTER TABLE t_front_message
-- 必须
ADD COLUMN read_time DATETIME NULL COMMENT '已读时间,NULL=未读' AFTER update_time,
ADD COLUMN agency_id BIGINT NULL COMMENT '代理id,多代理隔离与统计' AFTER user_id,
-- 强烈建议(跳转与状态回写依赖)
ADD COLUMN biz_ref VARCHAR(64) NULL COMMENT '业务主键(3DS txnId/股票orderId/POBO单号),用于回写状态与定位业务记录' AFTER `type`,
ADD COLUMN action_status VARCHAR(16) NULL COMMENT 'PENDING/COMPLETED/EXPIRED/USED,纯展示类为NULL' AFTER biz_ref,
-- 推荐(多业务差异字段兜底,避免反复 ALTER)
ADD COLUMN extra VARCHAR(1024) NULL COMMENT 'JSON:金额/币种/last4/商户/过期时间等各业务展示参数',
ADD KEY idx_user_read (user_id, `delete`, read_time),
ADD KEY idx_user_type (user_id, `type`),
ADD KEY idx_biz_ref (biz_ref);字段说明与取舍:
| 字段 | 档位 | 作用 |
|---|---|---|
read_time | 必须 | 每条独立已读时间,取代会漏验证的"已读指针"模型 |
agency_id | 必须 | 多代理架构下按代理隔离/统计(现有表缺,补上) |
type(复用现有列) | 复用 | 即 business_type,作为业务频道 key;只需扩 MessageType 枚举,不新增列 |
biz_ref | 强烈建议 | 指回业务表的主键。link 是"跳到哪个页面",biz_ref 是"对应业务表哪条记录"——3DS 结果回写、股票订单定位都靠它 |
action_status | 强烈建议 | 一次性动作状态;与 read_time 是两个独立维度 |
extra(JSON) | 推荐 | 业务多(卡/股票/POBO)差异字段各异,用一个 JSON 兜底比不断加列干净 |
link | 现有 | 内部统一的页面跳转 link(点消息落地到哪个页面) |
可选不加:
expires_at可单列,也可塞进extraJSON(推荐后者,减少列);card_id同理可并入extra或用biz_ref。本方案按"最小列 + extra 兜底"取向。
已读模型升级:现有"已读指针"模型(id > 指针,见 MessageMapper.xml:33-50)无法表达动作状态、也不能单条已读。加 read_time 后:单条已读=写 read_time;一键已读=批量写;未读数=COUNT(read_time IS NULL AND delete=0) 可按 type 分组。保留旧指针查询以兼容 App 老版本,新功能走 read_time。
表膨胀提示:统一表会随所有业务消息量增长(卡+股票+POBO)。短期加好索引即可;长期若股票等高频业务量大,按 create_time 归档/分表,并从源头控制——只发"漏看有损失"的关键事件,不发每笔成交等高频流水(呼应 §0.3)。
4.3 消息类型枚举扩展(type = business_type)
kotlin
// MessageType.kt —— 既是入库 type,也是前端 segment 的 key
enum class MessageType {
SYSTEM, // 既有:系统消息
TRANSACTION_3DS, // 3DS 交易验证
CARD_STATUS, // 卡状态变更(锁卡/解锁/激活/冻结)
RISK_ALERT, // 风控/可疑交易
STOCK, // 股票:成交/止损/爆仓(非实时行情)
POBO, // POBO 关键状态流转
// 以后新增业务:加一枚举 + 往统一表投递,前端 segment 自动多一项
}4.4 站内信写入服务(新增薄封装)
在 wallet-front-dal 的 IMessageDalService 增加单用户写入 + 状态回写;发送侧统一走一个 SiteMessageSender(logic-service),避免在 webhook 里散落 SQL:
kotlin
// 新增:站内信发送封装(logic-service)
interface ISiteMessageService {
fun sendTransactional(
userId: Long, type: MessageType,
title: String, content: String,
link: String? = null, // 内部统一页面跳转 link(落地到哪个页面)
bizRef: String? = null, // 业务主键,用于结果回写/定位业务记录
actionStatus: String? = null, // PENDING/…;纯展示类传 null
extra: Map<String, Any>? = null // JSON 兜底:url/expiresAt/last4/amount/currency…
): Long // 返回 messageId
fun updateActionStatus(bizRef: String, status: String) // 3DS 结果回写
fun markExpired() // 定时/查询时惰性置 EXPIRED
}与 §4.2 的"最小列 + extra 兜底"一致:不为
actionUrl/expiresAt/cardId单开列——一次性链接url、过期时间expiresAt、卡后四位last4等都塞进extraJSON;agency_id在实现内部按userId反查填充,调用方不用传。
4.5 触发点改造(最小侵入)
(A) 3DS —— sendTransaction3DSUrl() 末尾追加站内写入(UTGLWebhookCardAccountEventImpl.kt:463):
kotlin
// email/sms/push 之后,追加:
siteMessageService.sendTransactional(
userId = userCard.userId!!, type = MessageType.TRANSACTION_3DS,
title = "交易验证 Transaction Verification",
content = "您的卡片 ****${userCard.last4} 有一笔 ${dto.amount} ${dto.currency} 的交易需要验证,请在有效期内完成。",
link = buildInternalLink(MessageType.TRANSACTION_3DS, dto.transactionId), // 内部统一跳转 link
bizRef = dto.transactionId /*或 challengeId,实现前用真实 payload 核对*/,
actionStatus = "PENDING",
extra = mapOf(
"url" to dto.url, "expiresAt" to dto.expiresAt, // 一次性链接 + 过期时间进 extra
"last4" to userCard.last4, "amount" to dto.amount, "currency" to dto.currency
)
)(B) 3DS 结果回写 —— challenge.succeeded/declined/failed 分支(:212-241):
kotlin
siteMessageService.updateActionStatus(bizRef = resultDto.transactionId, status = "COMPLETED")(C) 锁卡 —— 卡状态变为 LOCKED/SUSPENDED 的地方:在卡状态更新的 service 里,状态跃迁为锁定态时调用 sendTransactional(type=CARD_STATUS, ...)。(触发点:搜索 UserCardStatusEnum.LOCKED 的赋值处;若锁卡也来自 webhook,则在对应事件分支加。)
(D) 风控 —— suspicious-activity-detected 分支 + RISK_CONTROL(:176):现在只调 innerMessageLogSender(发内部 TG),追加一条 sendTransactional(type=RISK_ALERT, ...) 发给用户。
(E) 渠道开关:以上都包在 if (card.channel == BusinessType.UTGL) 内;银盛卡(YINSHENG)不触发平台通知。
4.6 补全 MessageSendCenterListener 的 EMAIL(顺带)
sendEmailMsg 目前是空方法(:173)。若要让消息中心也能统一发交易邮件,用 TemplateMailSender 按 messageSendSubject 选模板填充实现(当前 3DS 邮件是在 webhook 里直接发的,短期可不动;此项为体系统一性的补全,可后置)。
4.7 用户侧接口(新建,wallet-front-api,仿 NoticeApi 风格)
@RequestMapping("message"),全部 @LoginRequired,userId 从 RequestContextParam.USER_ID 取:
| 接口 | 方法 | 说明 |
|---|---|---|
/message/list | POST | 分页;参数 category(可选,过滤 tab)、pageNo/pageSize;返回列表 + 每条 action_status/expires_at |
/message/detail | POST | id;返回详情(含渲染参数 extra);打开即写 read_time |
/message/unread-count | GET | 返回总未读 + 分 category 未读(铃铛红点 + tab 角标) |
/message/read | POST | id 单条 / category 整类 / 全部;写 read_time |
查询时对
action_status='PENDING' AND expires_at < now()的记录惰性标记为EXPIRED(或加一个轻量定时任务批量置换,见 4.9)。
4.8 实时性(分级,按需)
- P1(够用):铃铛未读数在登录后拉取 + 前端轮询(如 30-60s)
/message/unread-count。3DS 已有 push 做即时提醒,站内作为"可查记录",轮询延迟可接受。 - P2(可选增强):为用户端开一条 WebSocket 通道(复用
WebSocketServer.kt基础设施,新增 user-scope channel),站内信写入后主动推未读数,做到"push 一到,网页铃铛立刻跳数字"。体量不大时后置。
4.9 一次性链接过期处理
- 惰性方案(推荐先做):查询 list/detail 时,对
action_status='PENDING' AND expires_at < now()现算为 EXPIRED 返回给前端(不必落库)。 - 若要落库统一状态:加一个每分钟的轻量定时任务把过期的 PENDING 批量 UPDATE 成 EXPIRED(
wallet-front-api/svc/schedule/,仿 CardStatisticsTask 骨架)。数据量小惰性即可,不必上任务。
5. 落地路线
| 阶段 | 交付 | 改动点 | 说明 |
|---|---|---|---|
| P1(止血,最高优先级) | 3DS 落站内 + 用户消息接口 + web 消息中心 + 客服入口提前 | ALTER t_front_message;扩 MessageType;ISiteMessageService;3DS 触发点(A)(B);4 个查询接口;前端消息中心 + 铃铛;客服入口提到顶栏 + 移动端悬浮(§8 第 1 档) | 解决"email 单通道漏交易",用户平台内可查可处理 3DS;同时让用户一键触达客服 |
| P2 | 锁卡 + 风控落站内 + 客服未读红点打通 | 触发点(C)(D);风控消息发用户;客服未读数接入铃铛/悬浮按钮角标(§8 第 2 档) | 用户对卡状态/风险有知情权与自助入口;客服有新回复即时可见 |
| P3 | 股票 / POBO 落站内 + 实时 WebSocket | 触发点扩展 STOCK/POBO;用户端 WS 通道 | 覆盖更多业务;铃铛未读实时刷新 |
| 后期(可选) | 通知偏好页 + 消息中心统一收口 | 偏好读写复用卡级字段(站内通道恒开不可关);补全 MessageSendCenterListener EMAIL/SMS | 用户端设置页 P1 已砍,等有需要再补;体系统一,非阻塞 |
6. 风险与注意事项
- 交易类站内信恒发:不受
receiveXxx偏好和营销频控影响,否则用户关了通道就收不到 3DS,等于漏交易 + 合规风险。 - 一次性链接状态:站内展示必须能反映 有效/已完成/已过期,不能让用户点一个失效链接。依赖
challenge.succeeded/declined/failed事件回写。 - 渠道隔离:银盛卡不发平台通知(银盛自发),全部触发点按
channel == UTGL判断,避免重复打扰。 biz_ref关联:3DS 消息与结果事件的关联键要选对(transactionId 或 challengeId,以 UTGL DTO 实际能对上的为准,实现前用一条真实 webhook payload 核对)。- 敏感信息:站内/push 内容只放卡后四位、金额,不放完整卡号、不放 CVV,链接本身由渠道保证一次性。
- push token 可能为空:
UserInfo.fcmToken未必存在(没装 App/没授权)。多通道兜底,push 失败不影响站内和 email。 - firebase.json 配置:push 走
@ConditionalOnResource,确认生产环境该配置存在,否则 push 静默不发。 - 已读模型兼容:新增
read_time后,保留旧"已读指针"查询以兼容 App 老版本,新端走read_time。 - i18n:标题/正文用现有 i18n(
IPushMessageService.sendI18nMessageAsync已支持按 locale),站内文案同样按user.locale出中英双版。 - 客服入口不改后端:Chatwoot 只做前端入口优化(§8),不替换、不重写会话逻辑;移动端此前
hideMessageBubble把官方气泡藏了,务必用自定义悬浮按钮补回可达性,否则移动端用户仍只能从侧栏深处进。
7. 交付物汇总
- 文档:本文件(产品 + 技术)。
- 原型:
站内通知原型.html(web 端:铃铛下拉、消息中心分类列表、3DS/锁卡/风控详情、偏好设置,含交互)。 - DDL:
ALTER TABLE t_front_message(见 4.2),入docs/sql/。 - 代码改动清单:扩
MessageType;新增ISiteMessageService/impl;t_front_message实体加字段;3DS/锁卡/风控 4 处触发点追加站内写入 + 结果回写;wallet-front-api新增MessageApi(list/detail/unread-count/read);前端消息中心;客服入口优化(顶栏入口 + 移动端悬浮按钮 + 未读红点,见 §8,不改 Chatwoot 后端)。
本方案贴合现有代码约定(Kotlin 前端模块、MyBatis-Plus DAL、RocketMQ 消息中心、FCM push、Chatwoot 客服),可直接拆分为 P1-P3 工程任务。实现前用一条真实 UTGL webhook payload 核对 3DS 关联键与字段。
8. 客服(Chatwoot)入口优化
8.1 现状与问题
平台已接入 Chatwoot 作为客服/IM 系统(前端 utils/chatwoot.ts 封装了 openChatwoot/closeChatwoot/initChatwoot/setChatwootUser 等;SDK 由 pages/_document.tsx 注入)。当前有两个可达性问题:
- 入口太深:PC 端客服入口在左侧栏底部
menu_bottom_actions的"客服支持"里(workbench/[path].tsx:936调openChatwoot),用户要先注意到侧栏底部那一排小图标才能找到。 - 移动端气泡被隐藏:
_document.tsx里hideMessageBubble在移动 UA / 小屏(max-width:768px)/ webview 下为true,官方悬浮气泡被关掉——移动端用户只能从侧栏深处进,等于没有随手可点的入口。
目标:让用户在任何页面都能"一眼看到、一键触达"客服,形成"平台→用户(消息中心)+ 用户↔平台(客服)"的完整闭环。只做入口优化,不替换 Chatwoot、不重写会话逻辑。
8.2 改造方案(三档,建议做第 1、2 档)
| 档位 | 改动 | 效果 | 优先级 |
|---|---|---|---|
| 第 1 档:浅入口(必做) | ①PC:在顶栏铃铛旁加"客服"图标入口,点击 openChatwoot();②移动端:加一个常驻悬浮按钮(FAB),右下角固定,点击 openChatwoot(),替代被隐藏的官方气泡 | 客服入口从"侧栏深处"提到与消息铃铛并列的显眼位置;移动端恢复随手可点 | P1 |
| 第 2 档:未读红点打通(建议) | 监听 Chatwoot 未读消息数(window.$chatwoot 事件/轮询),在顶栏客服图标与移动端 FAB 上显示红点/角标 | 客服有新回复时用户立刻知道,不用主动开面板查 | P2 |
| 第 3 档:深度自定义 IM(暂不做) | 用 Chatwoot Client API + WebSocket 自绘会话 UI | 完全自定义 IM 观感 | 后期,非必要 |
建议:只做第 1、2 档——用官方 SDK 的
toggle('open')唤起原生会话面板,成本低、稳定;不建议第 3 档自绘(维护成本高,收益低)。
8.3 与消息中心的关系(互补,不合并)
- 消息中心 = 平台→用户的单向通知信箱(3DS/卡/风控/股票/POBO),承载"平台告诉你的事"。
- 客服(Chatwoot)= 用户↔平台的双向对话,承载"你要问平台的事"。
- 两者入口并列(顶栏铃铛 + 客服图标;移动端消息入口 + 客服 FAB),各有独立未读红点,不合并成一个列表——一个是通知流、一个是对话流,语义不同。
- 从消息详情可放一个"联系客服"CTA(如锁卡/风控详情里),一键从"看到通知"跳到"找客服处理",把两条链路串起来。
8.4 前端落点(wallet-pc)
| 改动 | 位置 | 说明 |
|---|---|---|
| PC 顶栏客服入口 | pages/workbench/[path].tsx 的 center_header_box_right(铃铛旁) | 复用已有 openChatwoot();图标 + 可选未读角标 |
| 移动端悬浮按钮 | 全局布局(如 templates/H5-Main 或 _app) | 固定右下角 FAB,点击 openChatwoot();解决 hideMessageBubble 后移动端无入口 |
| 未读角标 | 复用 utils/chatwoot.ts,监听/轮询未读数 | 与消息铃铛红点风格一致 |
| 消息详情"联系客服"CTA | 消息中心详情模板(锁卡/风控等) | 点击 openChatwoot(),串起通知→对话 |
不改
_document.tsx的 SDK 注入与hideMessageBubble逻辑本身(移动端仍隐藏官方气泡),改为用我们自己的 FAB 唤起,观感统一、可控。