多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

二手交易小程序核心功能设计:聊天、锁定与数据脱敏实践

二手交易小程序核心功能设计:聊天、锁定与数据脱敏实践 做二手交易类小程序这段时间最深的体会是商品列表、详情页、发布流程这些看起来最重的功能反而是最容易做的。真正让项目从能跑变成能用的是聊天、锁定好友、脱敏这三个看起来不起眼的能力——它们决定了一个交易平台能不能建立基本的信任链路。这篇把整个设计与实现过程完整拆开讲包括消息通道选型、锁定状态机、数据脱敏的层级设计和上线前后踩过的坑给准备做闲置交易、同城回收、兴趣换物这类小程序的朋友一份可直接抄作业的方案。1. 二手交易平台的信任悖论聊天、锁定、脱敏为什么是命门二手交易和电商最大的区别在于平台上没有统一的供应链把关交易对象是一个个真实的、素未谋面的个人。因此买方和卖方之间天然存在信任鸿沟——我需要确认你是真实卖家你需要确认我是真心要买而不是随便问问。这个确认过程几乎全部发生在聊天对话里。1.1 二手交易场景的信任链路是怎么断的我在做需求梳理时画过一条用户路径刷到商品 - 点进详情 - 发起咨询 - 沟通价格 - 确定成交。五个环节里后三个全部依赖聊天。但聊天一旦放开问题就跟着来了买卖双方在聊天里互相交换手机号、微信号平台无法监控跳单率直线上升有些用户把联系方式直接写进商品描述被爬虫采集之后骚扰电话不断谈得好好的两个人被第三方看到商品后抢先下单截胡原买家体验极差被拉黑的用户换个账号继续骚扰完全没有约束力。这些问题单独看都是小问题合在一起就是平台能不能做起来的大问题。所以我在设计阶段就把聊天沟通、交易锁定、信息脱敏三条链路同时纳入架构而不是等上线之后修修补补。1.2 技术路线选型云开发快但业务逻辑复杂时锁不住技术选型这件事我一开始在微信云开发CloudBase和自建后端之间纠结了很久。云开发的实时数据库推送watch对聊天场景非常友好不用自己维护WebSocket数据库权限也自带安全规则。但深入做下去发现三个硬伤第一云开发的数据库安全规则能控制谁能读却很难控制读到的内容长什么样。脱敏这个需求意味着同一个字段对卖家返回明文、对买家返回脱敏结果这在安全规则层面几乎没法写。第二锁定交易涉及判断当前状态 - 修改状态 - 插入消息这个组合操作需要事务保证。云函数里做事务不是不行但跨集合的ACID操作写起来非常别扭。第三云开发的并发连接数计费在小程序用户量上来之后成本增长比自建服务器快得多。索引、慢查询、日志排查这些能力也远不如传统数据库顺手。所以我最终选了uniappVue3 Node.jsExpress MySQL 原生WebSocket的组合。uniapp负责跨端复用后端自己全权控制接口的出入参这样脱敏、锁定、黑名单这些规则可以在服务端一体化实现小程序端只负责展示安全性可控得多。1.3 项目整体结构规划整个项目我拆成了两层小程序端商品模块发布/编辑/列表/详情、聊天模块会话列表/聊天页/黑名单、个人中心。UI层不存任何敏感明文所有数据均以服务端返回为准。服务端用户鉴权、商品接口、WebSocket消息服务、锁定交易状态机、脱敏中间件。其中脱敏不是写在某个接口里的而是做成一个响应处理中间件所有涉及敏感字段的接口统一走脱敏规则避免漏掉某个角落。这个结构在后期帮了大忙——新增接口时只要在脱敏配置表里登记字段就不会出现某个接口忘了脱敏导致明文泄露的低级事故。2. 聊天模块不是套一个文本框而是一套会话系统聊天模块看起来简单无非是你一句我一句。但二手交易的聊天有一个特殊点每条消息都可能成为后续纠纷的证据。所以消息的可靠性、时序性、离线补偿都比普通IM要求高。2.1 WebSocket连接管理与心跳机制微信小程序原生支持wx.connectSocket不需要再引socket.io的客户端库小程序环境也没有。我在实现时做了一层简单的封装// 简易版WebSocket封装 function connectSocket(token) { const socketTask wx.connectSocket({ url: wss://api.example.com/ws, header: { token } }); socketTask.onOpen(() { console.log(连接建立); startHeartbeat(socketTask); // 启动心跳 }); socketTask.onMessage((res) { handleMessage(JSON.parse(res.data)); // 分发消息 }); socketTask.onClose(() { clearHeartbeat(); // 断线重连带指数退避 setTimeout(() connectSocket(token), 3000); }); return socketTask; }心跳机制一定不能省。小程序进入后台后WebSocket会被系统挂起回到前台时连接已经断了。我用的方案是每30秒发一个ping服务端返回pong如果连续两次心跳没有回包客户端主动重连。同时监听小程序的onShow生命周期每次回到前台都主动检查连接状态断开了就立刻重连。数据包格式我定义得比较轻{ type: chat_msg, content: { msgId: xxx, from: 1001, to: 2002, content: 800, time: 1710000000 } }msgId是前端时间戳加随机数生成服务端用唯一索引去重这样断网重发不会产生重复消息。2.2 会话列表的查询优化会话列表如果每次实时联表查商品信息、买家信息、最后一条消息全查一遍索引再合理也扛不住高频刷新。我采用了对账本模式conversations表冗余了last_message、last_time、unread_count字段。每收发一条消息事务里同时更新这个会话的冗余字段。查询会话列表时就变成一次简单的单表查询加一次用户信息联查消息体完全不用扫。-- 会话表核心字段 CREATE TABLE conversations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL COMMENT 商品ID, buyer_id BIGINT NOT NULL COMMENT 买家ID, seller_id BIGINT NOT NULL COMMENT 卖家ID, last_message TEXT COMMENT 最后一条消息摘要, last_time DATETIME NOT NULL COMMENT 最后消息时间, unread_count INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 会话状态0正常 1已锁定 2已被黑名单拦截, created_at DATETIME, updated_at DATETIME, UNIQUE KEY uk_item_buyer_seller (item_id, buyer_id, seller_id) );这里有个实际经验会话列表的数据一定要按last_time倒序分页游标分页比OFFSET分页稳定得多。用户消息量大之后OFFSET越翻越慢游标用last_time id的组合定位是常规解法。2.3 收发消息与离线补拉消息走WebSocket实时推给在线用户但离线用户怎么办我的做法是服务端保存所有消息用户进入聊天页时先请求一次recent_messages接口拉最近50条。WebSocket只负责增量推送不做全量同步。这样设计的好处是不依赖WebSocket的可靠性。哪怕用户全程没有连接成功进入聊天页依然能拿到历史消息。聊天页里再维护一个是否已拉过配合下拉刷新和触底加载更多。2.4 聊天链路里埋的安全暗桩聊天相关的服务端接口我统一加了三个前置校验校验双方是否都未被拉黑。blacklist表如果存在反向记录直接拒绝发送错误码返回对方已把你拉黑其实是双向校验被拉黑的一方无法再发起会话。校验当前商品是否处于锁定状态。如果商品已被锁定给某个买家非锁定买家发消息会被拒绝提示该商品交易已锁定。校验消息内容是否包含敏感联系方式。这个在下面脱敏部分细讲。这些校验全部放在服务端小程序前端不做拦截——前端做了也只是提高体验防不了直接调接口的攻击者。记住一个原则所有安全逻辑能放服务端就不放前端。3. 锁定好友黑名单与交易锁定一套机制解决两个问题锁定好友在产品需求里其实有两种完全不同的含义一是把讨厌的人拉进黑名单不再接收他的消息二是在交易过程中把某个买家锁定为唯一议价对象避免被截胡。这两个需求在实现层面刚好可以用同一套关系状态机制来管理。3.1 先想清楚锁定到底锁的是什么我见过一些团队把锁定好友做成简单的标记一下结果商品被人拍下后依然一团糟。锁定的本质是锁的是当前商品可交互的用户集合。商品处于普通状态时任何登录用户都可以发起咨询一旦锁定给某个买家其他用户的咨询、下单全部被拦截只有被锁定的买家可以继续沟通和支付。锁的是两个用户之间的消息通道。拉黑之后不仅不能发消息连对方的主页资料、历史会话入口都应该隐藏。这两类锁定都涉及状态变更而状态变更最怕的就是并发。所以我在设计时不是零散地写几个if判断而是把它们统一成一个状态机。3.2 黑名单机制拉黑之后的完整链路闭环黑名单的实现比较简单核心表就一张CREATE TABLE blacklist ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 拉黑方, blocked_user_id BIGINT NOT NULL COMMENT 被拉黑方, source TINYINT DEFAULT 1 COMMENT 来源1聊天页 2个人主页 3商品页, created_at DATETIME, UNIQUE KEY uk_user_blocked (user_id, blocked_user_id) );拉黑的校验逻辑要注意双向性。用户A拉黑了B系统要保证B无法给A发送新消息B无法查看A发布的商品详情中的联系方式B无法下单购买A的商品A的聊天列表里不再展示与B的会话或者显示为已拉黑状态。这些校验不需要每个接口都单独写一遍我封装成一个通用中间件凡是标记了needBlacklistCheck的路由都会在业务逻辑执行前自动校验一次。数据库层面聊天发送接口在写入消息前执行SELECT 1 FROM blacklist WHERE (user_id A AND blocked_user_id B) OR (user_id B AND blocked_user_id A) LIMIT 1存在即为拦截。一个容易忽略的细节被拉黑方在聊天页已经发送过的历史消息要不要在双方的会话记录里继续显示我的处理是保留显示但双方都无法继续追加新消息。原因很简单这些消息可能涉及交易约定删掉反而说不清。3.3 交易锁定的状态机设计交易锁定是整个项目里最需要严谨设计的模块。先看状态定义状态含义可操作用户可以聊天可以下单pending可议价状态所有用户是是locked已锁定买家仅锁定买家仅锁定买家仅锁定买家success交易完成仅买卖双方是但锁定关系解除前仅双方否cancel交易取消可回到pending恢复恢复状态流转的出发点是买家发起锁定请求。买家在聊天页点击确定要了服务端执行一个事务-- 伪代码事务内执行 BEGIN; -- 查询当前商品状态如果是pending才继续 SELECT * FROM items WHERE id ? FOR UPDATE; -- 更新商品状态为locked绑定锁定买家 UPDATE items SET trade_status locked, locked_buyer_id ?, updated_at NOW() WHERE id ? AND trade_status pending; -- 记录锁定日志 INSERT INTO trade_lock_logs (item_id, buyer_id, seller_id, action, created_at) VALUES (?, ?, ?, lock, NOW()); COMMIT;这个sleep(3)中间故意不做就是为了逼自己用事务而不是先查再改。SELECT FOR UPDATE是行级锁能防止两个用户同时确认锁定同一件商品时产生脏数据。实际压测中100个并发请求同时锁定同一商品事务机制下最终只有1个成功其余99个全部拿到商品已被锁定的错误返回。锁定之后有个体验细节买家锁定后如果一直不支付、不回复卖家不能永远被绑着。我加了一个超时机制——默认3小时锁定超时后自动回到pending状态。实现就是一个定时任务扫描trade_lock_logs里超过时间没有流转的记录。这个能力对C2C场景很重要不然一个占坑用户就能让卖家的商品永远卖不出去。3.4 锁定之后的消息通道控制锁定不只是一个状态字段它还直接影响聊天模块。这里我遇到过一个设计失误一开始只判断了商品状态但没判断当前用户是不是锁定买家。结果就是锁定之后其他用户仍然能进聊天页发消息只不过发出去不成功——用户看到的是消息发送失败体验非常差。正确的做法是进入聊天页时前端就调用一次conversation_detail接口服务端返回can_chat布尔值。如果商品处于locked状态且当前用户不是锁定买家接口直接返回can_chat: false前端把输入框置灰而不是让用户发出消息再收到失败提示。体验和校验的一致性在这个环节体现得很明显。4. 数据脱敏从数据库到接口再到日志一层都不能漏脱敏这个词听起来简单但真正落地的时候牵扯到数据库存储、接口返回、日志输出三个层面。一处漏了数据保护就是空谈。二手交易场景里最典型的敏感数据是这几类数据类型示例脱敏策略手机号13800138000保留前三后二中间打星微信号/QQweixin_123中间字符遮挡保留首尾收货地址北京市朝阳区xx小区x号楼仅保留市级区级详细部分打星聊天内容中出现的联系方式打我电话138xxxx正则识别后整个替换为[联系方式已隐藏]4.1 手机号脱敏保留前三后二的业务逻辑手机号脱敏规则通常写为保留前3位后2位中间用****代替。这里有个业务细节保留前三位是因为运营商号段以13x、15x、18x、19x开头用户看一眼能大致判断运营商和地域保留后两位是让用户能想起哦这是我自己的号码或这是我发给他的尾号。太短没有辨识度太长就泄露了隐私。服务端的脱敏函数很简单但位置很关键// Node.js 脱敏函数 function maskPhone(phone) { if (!phone || phone.length ! 11) return phone; return phone.slice(0, 3) **** phone.slice(-2); }这个函数不是为了在前端展示的时候遮一下而是服务端在把数据返回给小程序端之前就已经处理过了。也就是说数据库里存的是完整手机号接口返回的是脱敏数据前端拿到手之后根本没有明文可看。4.2 不同身份看到不同内容卖家看明文买家看脱敏这里有个很容易踩坑的需求点商品发布者自己需要看到完整手机号因为那是他的联系方式但其他用户只能看到脱敏结果。在普通电商项目里这个需求可以靠前端判断实现。但在这个项目里绝对不行因为小程序逆向调接口非常容易别人直接请求/api/item/detail绕过前端判断就能拿到明文。我的做法是在服务端接口层做身份判断// 商品详情接口的脱敏逻辑 const item await getItemById(req.params.id); const isSelf item.seller_id req.user.id; const data { ...item, contact_phone: isSelf ? item.contact_phone : maskPhone(item.contact_phone), contact_wechat: isSelf ? item.contact_wechat : maskWechat(item.contact_wechat) };同样的逻辑也用在聊天页的用户资料接口上。只有买卖双方在正式交易时比如生成订单后可以解锁查看完整联系方式其他任何人一律只给脱敏数据。4.3 聊天内容里的联系方式识别与自动替换商品描述和聊天内容是脱敏最容易漏掉的角落。用户为了效率天然会把加我微信xxxx或者电话号码是138****打进聊天框里。如果平台不拦截这些数据就会存储在消息表里而拿下这些数据只需要扫一次数据库。我在消息写入之前加了一道正则过滤// 消息发送前的敏感信息过滤 function maskSensitiveInText(text) { // 手机号判断 text text.replace(/(1[3-9]\d{9})/g, [联系方式已隐藏]); // 微信号判断字母开头5-20位字母数字下划线 text text.replace(/([a-zA-Z][a-zA-Z0-9_-]{5,19})/g, (match) { // 排除正常的英文单词这里用词库规避 if (COMMON_WORDS.includes(match.toLowerCase())) return match; return [联系方式已隐藏]; }); return text; }为什么这样做因为用户一旦在聊天里互换了真实联系方式平台就彻底退出交易链路了。对他而言后续纠纷平台管不了对平台而言交易佣金收不到用户数据还被抽走。所以不让联系方式出现在聊天里不是限制用户是保护平台自身的交易闭环。4.4 日志脱敏与数据库加密存储除了接口返回日志是另一个数据泄露的高危口。我见过很多项目接口层脱敏做得严严实实但服务端日志里打了完整的手机号、微信号运维排查问题的时候扫一眼日志信息全出来了。我处理日志的原则是结构化日志里只记录userId不记录手机号。确需记录的少量敏感字段统一用脱敏函数再输出。数据库存储层面手机号、微信号这两列在落库时用AES加密存储查询时解密后再走脱敏逻辑。加密会损失一点查询效率所以我的方案是增加一个phone_masked字段专门存脱敏后的值用于需要模糊搜索的场景完整值加密存储每次只在有权限时解密。5. 权限链路界面按钮隐藏得再好不如后端拦住锁定和脱敏都依赖一个前提接口能正确识别来访者的身份。如果身份校验稀烂前面做的所有状态机、脱敏函数都等于白做。这一节说身份校验和请求拦截的实际实现。5.1 小程序端请求封装token注入与全局错误处理小程序通过wx.login获取code客户端把code传给后端后端调用微信接口换取openid并生成自定义token。之后所有请求的header里带Authorization: Bearer token。我在uniapp里封装了一个统一的请求对象// uniapp 请求封装示例 function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Authorization: Bearer uni.getStorageSync(token) }, success: (res) { // 全局处理登录失效 if (res.statusCode 401) { uni.removeStorageSync(token); uni.reLaunch({ url: /pages/login/index }); return; } // 解构统一响应业务码处理 if (res.data.code 0) { resolve(res.data.data); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: (err) { reject(err); } }); }); }这个封装看起来简单但实际价值在于401的全局处理。如果token过期所有请求都该统一踢回登录页而不是每个页面各自弹窗。5.2 服务端的二次校验不能只信前端传的参数服务端校验的核心原则所有涉及权限的字段都必须通过token解析出的用户身份到数据库里查一遍不能信任请求体里传来的任何ID。举个例子。锁定交易的接口接收{ item_id: 123, buyer_id: 456 }但服务端不能直接用这个buyer_id而要取req.user.id作为操作者。一个典型的越权攻击就是把自己伪装成别的买家ID去锁定一个自己压根没资格买的商品。我在实现里对所有关键接口统一做了一个校验链解析token拿userId根据userId查用户状态是否被封禁如果是商品相关操作查商品的seller_id和trade_status如果是会话相关操作查blacklist表和conversations表的绑定关系全部通过才进入业务逻辑。5.3 关于逆向抓包的一点提醒说个实在的小程序前端的JS代码被打包成wxapkg后很多工具是可以反编译的。接口地址、加密密钥、业务逻辑都能被翻出来。有人习惯用抓包工具直接看小程序调了哪些接口然后手动改参数模拟请求。我碰到的真实案例是有人直接调用商品详情接口把item_id循环遍历抓走了整个平台的商品数据。所以做这类项目一定要做到接口返回的数据是服务端过滤后的最小集不要返回结构中的冗余字段关键业务锁定、下单、删除必须服务端校验状态而不是靠前端隐藏按钮敏感数据脱敏必须在服务端完成前端拿到的就已经是脱敏值。简单说把小程序当成一个什么都能打开的前端看所有安全性设计都按攻击者可以拿到前端所有代码的前提来做。6. 上线前后踩过的坑和后续优化方向最后聊几个实际运营中踩到的坑都不是技术文档里会写的问题但真能卡住项目上线。6.1 微信审核对二手交易类目的门槛我第一版小程序提交审核时用的类目是工具-信息查询结果被打回。原因是只要涉及用户之间的交易行为就必须选电商平台类目而电商平台类目通常要求提供相关资质个人主体基本过不了至少需要企业主体。所以如果你准备做这类项目先把这个资质问题弄清楚不然开发完才发现主体不符合整个项目都得推倒重来。顺序建议先确认账号类型和类目资质再动工开发。最少是拿一个已经完成微信认证的企业主体小程序去申请类目然后再开始写代码。6.2 iOS端键盘弹出遮挡聊天输入框聊天页在iOS上有个经典问题键盘弹起时输入框被挡住。小程序有adjust-position属性和bindkeyboardheightchange事件但实测下来iOS的键盘高度每次弹起不一定一致尤其在切换输入法时会出现明显跳动。我的解决思路是用uni.pageScrollTo在键盘弹起时把聊天内容滚动到最底部同时给输入框设置adjust-position{{true}}。更稳妥的方案是输入框不固定在最底部而是放在内容流之后用padding-bottom动态撑开。这个方案兼容性最好但实现上要多一点代码量。实测下来官方属性和自研padding方案双管齐下iOS端的体验才基本稳定。6.3 断网重发消息的幂等性微信小程序的网络环境普遍差于App。用户在弱网下发一条消息可能前端发了三次请求每次都没有收到成功回调但服务端实际已经写入了三条一模一样的消息。解决幂等的思路是用客户端生成的msgId做唯一索引。消息表里加UNIQUE KEY uk_msg_id (msg_id)重复插入直接报错业务层捕获后返回消息已发送成功。这样扩展性也好将来做多端同步不会出现消息重复。6.4 图片里手写手机号的处理文字脱敏做了之后有用户开始走图片渠道——拍一张写着手机号的纸或者直接把微信二维码截图发在聊天里。这个问题文字正则解决不了。我目前的处理是两个方向配合图片上传时统一叠一层平台水印水印覆盖图片下半部分让写有联系方式的区域变得不清晰聊天图片在展示时做一层高斯模糊预览点击并长按后才显示高清大图。这些方案都挡不住铁了心要交换联系方式的人但能把门槛抬高。6.5 后续方向虚拟小号与AI违规检测如果项目跑起来想进一步做安全升级我的下一步计划是接入虚拟小号服务——用户之间通过平台生成的临时号码进行通话通话结束后号码失效。这样既解决了平台之外联系的安全问题也避免了滥用脱敏的麻烦。另一个方向是基于大模型的违规内容检测把图片、文本里的联系方式识别做得更智能。不过这些都是优化项前提是把前面几层的逻辑跑稳——先做到审核能过、数据不泄露、核心链路不出错。
返回列表