Skip to content

站内通知系统(统一消息中心)· 产品与技术方案

版本:v2.0 | 最后更新:2026-07-09 适用系统:wallet-server(UTGL 渠道;银盛卡通知由银盛自发,本系统不发) 配套原型:站内通知原型.html(web 端静态交互效果,含消息中心 + 客服入口) 一句话目标:为平台建一个"用户信箱"——把卡(3DS/锁卡/风控)、POBO、股票交易等各业务里"用户漏看会有损失"的事件,统一收进一个消息中心,让用户在平台内就能第一时间知道、能处理、能查历史;并把客服(Chatwoot)入口提到显眼位置,形成"平台→用户(消息中心)+ 用户↔平台(客服)"的完整触达闭环。


0. 核心结论(先看这里)

  1. 消息中心是"信箱",不是"仓库",与现有业务状态页并存、不替代。
    • 现有的卡详情页 / 股票持仓页 / POBO 列表是仓库——用户主动去查"现在什么状态",前提是用户知道该去看、去哪看
    • 消息中心是信箱——平台主动告诉用户"刚发生了什么",解决"用户根本不知道要去查"的问题。
    • 一句话:状态页解决"查得到",消息中心解决"知道要去查"。3DS(5-15 分钟时效)、风控冻结、股票止损/爆仓这类"漏看即损失/掉单"的事件,必须有一条消息主动把用户拉回来,光有状态页不够。
  2. 统一一张表存所有业务消息,但业务详情表各自分开(不合并)。 各业务(卡/POBO/股票)的状态、生命周期、权限仍在自己的业务表里;消息中心只做一张统一的 t_front_message(信封索引),存 title/content/link 等轻量字段 + business_type 区分来源。这样"数据上分开、入口上统一"两个诉求同时满足。t_notice(代理店广播公告)保持现状不动,与本消息中心并列。
  3. 只收"漏看有损失"的事件,不做全量通知垃圾场。 3DS、风控、锁卡、股票止损/爆仓/成交、POBO 关键状态——这些进消息中心。纯高频数据(股票实时行情跳动、实时盈亏数字)不是消息,走 WebSocket 实时刷新,绝不进消息表,否则表会被行情冲爆。
  4. 系统里 80% 的零件已经存在,主要工作是"补全空实现 + 串联 + 补一个统一消息 API",不是从零造。
    • 3DS 已在发 Email/SMS/Push(UTGLWebhookCardAccountEventImpl.kt:463),唯独漏了写站内信
    • 消息中心 MessageSendCenterListener 的 EMAIL/SMS 是空方法,锁卡/风控信号目前只发内部 Telegram 群,没送用户
    • 站内信表 t_front_message、FCM push、卡级通知偏好(receivePush/receiveEmail/receiveSms)都已就绪。
    • 唯一完全缺失:用户侧"我的消息"列表/详情/已读接口。
  5. 交易类通知与营销类通知必须彻底分离。交易类(3DS/锁卡/风控/股票/POBO)不可退订、不受频控、优先级最高、多通道必达;营销类(EDM 运营触达)受频控、可退订。混在一起迟早出"用户退订营销邮件结果连 3DS 都收不到"的合规事故。
  6. 用户端通知偏好设置暂不做(P1 砍掉)。站内消息无条件写入;外部通道(push/email/sms)沿用 UserCard 已有的 receiveXxx 字段的默认值,不给用户开设置页。等有需要再于后期补回。
  7. 按发卡渠道开关:UTGL 卡触发平台通知,银盛卡由银盛自发、本系统跳过。在 webhook 入口按 channel 判断。
  8. 客服(Chatwoot)不替换、只做入口优化:把入口从"侧栏深处"提到顶栏铃铛旁 + 移动端常驻悬浮按钮,并打通未读红点,让用户随时能一键触达客服。详见 §8。

1. 产品价值与必要性(为什么必须做这个消息中心)

1.0 产品意义:从"弱连接"到"每天有理由回来"

平台和用户之间目前是弱连接——用户办完卡、下完单就离开,只有出问题时才被动地翻邮件、找客服。平台没有一个主动、可信、承载所有业务事件的官方触点。这带来三个结构性问题:

  1. 交易在流失,平台却不知道用户没看到:3DS 只发邮件,用户没及时看→交易被拒→GMV 白白掉了,而这本可以用一条站内+push 挽回。
  2. 用户的安全感不足:卡被锁、异地可疑交易,用户最后一个知道(甚至只能靠客服告知),对平台的信任被侵蚀。
  3. 业务割裂、没有"回来的理由":卡、股票、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 本系统的三个现实驱动

  1. UTGL 卡由平台自己通知用户 → 送达质量 100% 是平台的责任,单通道(email)就是风险敞口。
  2. 银盛卡后续由银盛自发 → 模块必须按渠道可开关,否则银盛卡会被平台重复通知,权责不清。
  3. 交易强触达 ≠ 营销触达 → 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-detectedRISK_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-523Email + SMS + Push 已发,唯独没写站内信🟡 补站内
3DS 结果事件同上 :212-241challenge.succeeded/declined/failed 已接收,但只更新事件表、未回写业务状态🟡 补回写
消息中心分发MessageSendCenterListener.kt:110RocketMQ 消费框架完整;EMAIL/SMS 是空实现,只有 Telegram 通🟡 补 EMAIL/站内
锁卡/风控信号同上 :254-278TRANSACTION_SUSPICIOUS_ACTIVITY_UTGL/RISK_CONTROL/USER_CARD_BALANCE 只发内部 TG 群🟡 加发用户
站内信表Message.ktt_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 PushIPushMessageService.kt / FirebasePushSenderFCM 完整,支持 i18n、deep link、ttl、批量
卡级通知偏好UserCard.kt:284-296receiveEmail/receiveSms/receivePush 已有
邮件发送TemplateMailSender + EmailServiceImplSMTP + 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 可单列,也可塞进 extra JSON(推荐后者,减少列);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-dalIMessageDalService 增加单用户写入 + 状态回写;发送侧统一走一个 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 等都塞进 extra JSON;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)。若要让消息中心也能统一发交易邮件,用 TemplateMailSendermessageSendSubject 选模板填充实现(当前 3DS 邮件是在 webhook 里直接发的,短期可不动;此项为体系统一性的补全,可后置)。

4.7 用户侧接口(新建,wallet-front-api,仿 NoticeApi 风格)

@RequestMapping("message"),全部 @LoginRequired,userId 从 RequestContextParam.USER_ID 取:

接口方法说明
/message/listPOST分页;参数 category(可选,过滤 tab)、pageNo/pageSize;返回列表 + 每条 action_status/expires_at
/message/detailPOSTid;返回详情(含渲染参数 extra);打开即写 read_time
/message/unread-countGET返回总未读 + 分 category 未读(铃铛红点 + tab 角标)
/message/readPOSTid 单条 / 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;扩 MessageTypeISiteMessageService;3DS 触发点(A)(B);4 个查询接口;前端消息中心 + 铃铛;客服入口提到顶栏 + 移动端悬浮(§8 第 1 档)解决"email 单通道漏交易",用户平台内可查可处理 3DS;同时让用户一键触达客服
P2锁卡 + 风控落站内 + 客服未读红点打通触发点(C)(D);风控消息发用户;客服未读数接入铃铛/悬浮按钮角标(§8 第 2 档)用户对卡状态/风险有知情权与自助入口;客服有新回复即时可见
P3股票 / POBO 落站内 + 实时 WebSocket触发点扩展 STOCK/POBO;用户端 WS 通道覆盖更多业务;铃铛未读实时刷新
后期(可选)通知偏好页 + 消息中心统一收口偏好读写复用卡级字段(站内通道恒开不可关);补全 MessageSendCenterListener EMAIL/SMS用户端设置页 P1 已砍,等有需要再补;体系统一,非阻塞

6. 风险与注意事项

  1. 交易类站内信恒发:不受 receiveXxx 偏好和营销频控影响,否则用户关了通道就收不到 3DS,等于漏交易 + 合规风险。
  2. 一次性链接状态:站内展示必须能反映 有效/已完成/已过期,不能让用户点一个失效链接。依赖 challenge.succeeded/declined/failed 事件回写。
  3. 渠道隔离:银盛卡不发平台通知(银盛自发),全部触发点按 channel == UTGL 判断,避免重复打扰。
  4. biz_ref 关联:3DS 消息与结果事件的关联键要选对(transactionId 或 challengeId,以 UTGL DTO 实际能对上的为准,实现前用一条真实 webhook payload 核对)。
  5. 敏感信息:站内/push 内容只放卡后四位、金额,不放完整卡号、不放 CVV,链接本身由渠道保证一次性。
  6. push token 可能为空UserInfo.fcmToken 未必存在(没装 App/没授权)。多通道兜底,push 失败不影响站内和 email。
  7. firebase.json 配置:push 走 @ConditionalOnResource,确认生产环境该配置存在,否则 push 静默不发。
  8. 已读模型兼容:新增 read_time 后,保留旧"已读指针"查询以兼容 App 老版本,新端走 read_time
  9. i18n:标题/正文用现有 i18n(IPushMessageService.sendI18nMessageAsync 已支持按 locale),站内文案同样按 user.locale 出中英双版。
  10. 客服入口不改后端:Chatwoot 只做前端入口优化(§8),不替换、不重写会话逻辑;移动端此前 hideMessageBubble 把官方气泡藏了,务必用自定义悬浮按钮补回可达性,否则移动端用户仍只能从侧栏深处进。

7. 交付物汇总

  • 文档:本文件(产品 + 技术)。
  • 原型站内通知原型.html(web 端:铃铛下拉、消息中心分类列表、3DS/锁卡/风控详情、偏好设置,含交互)。
  • DDLALTER 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 注入)。当前有两个可达性问题:

  1. 入口太深:PC 端客服入口在左侧栏底部 menu_bottom_actions 的"客服支持"里(workbench/[path].tsx:936openChatwoot),用户要先注意到侧栏底部那一排小图标才能找到。
  2. 移动端气泡被隐藏_document.tsxhideMessageBubble 在移动 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].tsxcenter_header_box_right(铃铛旁)复用已有 openChatwoot();图标 + 可选未读角标
移动端悬浮按钮全局布局(如 templates/H5-Main_app固定右下角 FAB,点击 openChatwoot();解决 hideMessageBubble 后移动端无入口
未读角标复用 utils/chatwoot.ts,监听/轮询未读数与消息铃铛红点风格一致
消息详情"联系客服"CTA消息中心详情模板(锁卡/风控等)点击 openChatwoot(),串起通知→对话

不改 _document.tsx 的 SDK 注入与 hideMessageBubble 逻辑本身(移动端仍隐藏官方气泡),改为用我们自己的 FAB 唤起,观感统一、可控。