ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

三端悬赏任务源码落地:状态机、并发抢名额与资金流水

三端悬赏任务源码落地:状态机、并发抢名额与资金流水

拿到一套号称 Web、APP、小程序三端齐全的悬赏任务源码,很多人的第一反应是把数据库导进去、后端跑起来、前端npm run dev看到首页就觉得成了。我见过太多这样的翻车现场:任务大厅能刷出来,点发布直接 500;小程序端点授权登录一直转圈;APP 端付完款回调永远收不到,订单卡在“待支付”里出不来。悬赏任务系统看着是个很普通的业务系统,无非是发任务、接任务、交作业、验收、给钱,但它把三个最容易出问题的东西拧在了一起——多端登录态、并发抢名额、资金流水。任何一个环节设计得粗糙,后面就是无穷无尽的补丁。

这篇东西写给两类人:一类是手里已经拿到或者准备拿一套悬赏发布系统的源码,想搞清楚这套代码到底该怎么读、怎么改、哪里必须重写;另一类是打算自己从零搭一套 Web + APP + 小程序三端通的众包任务平台,想知道哪些坑可以提前绕开。我不打算贴一堆源码片段糊弄篇幅,而是按“这套系统真正的骨架长什么样—哪些地方必须自己动手—上线前要盯住什么”这个顺序,把我在类似项目里踩过的、见过的、返工过的部分摊开讲。源码只是起点,能不能跑成能运营的东西,取决于你有没有把状态机、账务和三端差异这三件事想明白。

1. 三端同构的悬赏平台,先把“共用”和“必须分开”的界线划清楚

1.1 一套后端 + 三个壳,哪些逻辑必须死守服务端

悬赏类源码最常见的一种结构是:后端一套 API,Web 端一套前端工程,APP 和小程序共用一套跨端框架工程(打包两次)。这个结构本身没问题,问题在于很多人接手后第一件事是改 UI,而没去看业务规则散落在哪一层。

我把这套系统里的逻辑分成三类,界线必须划死:

第一类是绝对不能下放到端上的规则。任务金额、佣金比例、平台服务费、接单资格判定(信用分、实名状态、历史违约次数)、名额剩余数量、结算金额计算——这些只要有一处写在端上,就意味着改个参数要发三个版本的包,而且随时可能被人改包绕过。我见过一个项目把“接单需要信用分≥80”写在前端按钮的 disabled 判断里,接口层压根没校验,结果有人直接调接口抢单,一分钱成本没有。

第二类是可以在端上做、但服务端必须再校验一遍的。比如表单校验、字数限制、附件格式、图片大小。端上做是为了体验,服务端做是为了安全,两边都要有。

第三类是纯端上逻辑,比如页面路由、骨架屏、下拉刷新时机、本地草稿缓存。这类东西服务端管不着,也不该管。

判断标准其实很简单:这条规则如果被绕过,会不会造成钱上的损失或者数据上的脏乱?会,就放服务端;不会,随你。

1.2 三端的差异不在界面,在登录、支付、触达这三条线

刚接手的人容易把“三端适配”理解成做三套 UI,其实 UI 是最不费脑子的部分。真正让工期翻倍的是下面这张表里的东西。

能力Web 端APP 端小程序端
登录方式账号密码 / 手机验证码 / 扫码手机验证码 / 一键登录 / 第三方授权授权静默登录 + 手机号授权
登录态载体Cookie 或 localStorage本地安全存储本地存储,随包清理
支付通道聚合扫码支付各家应用内支付平台内支付
消息触达站内信、邮件系统推送订阅消息(需用户逐次授权)
分享裂变复制链接分享卡片 / 邀请码会话转发、海报
包体限制无相对宽松严格,主包体积卡得死
域名要求备案 + HTTPSHTTPS必须配置合法域名白名单

这张表里最容易出事的是两行:登录态和消息触达。

登录态的问题在于,三端拿到的身份凭证形态完全不一样。小程序端是一次性 code 换 openid 再换手机号,APP 端是短信验证码或者第三方授权,Web 端可能是密码登录。很多源码的做法是三套登录接口、三套 session 存储,最后用户在三端的数据对不上——同一个人在小程序里是 A 用户,在 APP 里是 B 用户,历史任务各看各的。这个问题必须在架构层面解决,不能靠后期打补丁(第 4 节会讲具体怎么收敛)。

消息触达的问题更隐蔽。小程序端的订阅消息是“一次授权一次推送”,用户不同意就推不了;APP 推送依赖厂商通道,各家到达率差别很大;短信有成本,不能什么都发短信。所以“任务被接单了要通知发布者”这句话,落到实现上是“小程序推订阅消息 → 失败落站内信;APP 走厂商推送 → 失败落站内信;Web 只走站内信 + 邮件”。这套降级链路不设计好,运营第一天就会被用户投诉“我根本不知道有人接了我的单”。

2. 悬赏任务的生命周期:状态机画不对,后面全是补丁

2.1 一条悬赏从草稿到完结,到底要经过多少个状态

我见过最粗糙的实现是用一个status字段打天下:0 待处理、1 进行中、2 已完成、3 已取消。跑一周就崩了——因为“待审核”和“招募中”分不清,“交付中”和“待验收”分不清,“申诉中”根本没地方放。

一条带资金托管的正规悬赏任务,状态至少是这些:

  • 草稿:用户编辑中,不对外可见,可随时改
  • 待审核:提交后进人工或机审,防止违规内容
  • 招募中:审核通过,展示在任务大厅,可接单
  • 已接单 / 进行中:名额已满或已指定接单人,进入交付期
  • 待验收:接单方提交了交付物,等发布方确认
  • 申诉中:任一方发起争议,任务冻结,等平台介入
  • 已完成:验收通过,触发结算
  • 已关闭:发布方主动取消或超时无人接单
  • 已退款:关闭后资金退回发布方

每个状态之间不是随便跳的,要有守卫条件。比如“招募中 → 已接单”必须满足:剩余名额 > 0、接单人信用达标、接单人不是发布者本人、该用户没有重复接同一单。“待验收 → 已完成”必须满足:操作人是发布方、任务处于待验收状态、没有进行中的申诉。

我的习惯是把这张流转表直接写成代码里的常量配置,而不是散落在各个if里:

// 状态流转白名单(节选,伪代码) $transitions = [ 'draft' => ['pending_review', 'closed'], 'pending_review' => ['recruiting', 'rejected'], 'recruiting' => ['in_progress', 'closed'], 'in_progress'=> ['delivering', 'appealing'], 'delivering' => ['pending_accept', 'appealing'], 'pending_accept' => ['completed', 'appealing', 'delivering'], 'appealing' => ['completed', 'refunded'], 'closed' => ['refunded'], ];

有了这张表,任何状态变更加一句白名单校验,就能挡住 90% 的脏数据。后期要对状态做统计、做漏斗分析,也直接按这张表来,不用满库翻。

2.2 抢名额这件事,本质就是库存扣减

“悬赏”这种业务有个天然特征:一条任务可能有多个名额(比如招 20 个人写文案),也可能只有一个名额(比如找一个设计师做张海报)。多人同时点“接单”的时候,如果不做并发控制,超卖是必然的。

这里可以直接借用电商秒杀那套思路,按成本从低到高排:

第一种,数据库乐观锁。给任务表加一个taken_count和version字段,更新时带上版本号条件:

UPDATE task SET taken_count = taken_count + 1, version = version + 1 WHERE id = ? AND version = ? AND taken_count < quota AND status = 'recruiting';

影响行数为 0 就说明被人抢先了,返回“手慢了”。这套方案零额外依赖,日活几千的量完全够用,缺点是高并发下失败率会上升。

第二种,缓存预扣 + 异步落库。把剩余名额放到缓存里,用原子自减操作扣减,扣成功再异步写数据库。性能好,但要处理缓存和数据库不一致的补偿逻辑,实现复杂度立刻上一个台阶。

第三种,数据库唯一索引兜底。不管前面用什么方案,最后一定要有一张“接单关系表”,并在这张表上建(task_id, user_id)的唯一索引。这是最后一道防线——就算前面的逻辑全错了,数据库也不会让同一个人接同一单两次。这条我强烈建议无论什么方案都加上,成本几乎为零,收益极大。

还有一个容易被忽略的点:取消接单要回补名额。用户接了单又主动退出,名额要加回去,状态要回退,如果这条任务只有他一个人接,还得看发布方愿不愿意重新开放。这条回补逻辑如果不做幂等,很容易出现“退一次加两次”的奇葩数据。

2.3 超时这件事,定时任务和延迟消息选哪个

悬赏业务里遍地是超时:发布后 7 天没人接自动关闭、接单后 3 天不交作业自动放弃、提交后 5 天不验收自动确认、申诉后 48 小时不处理自动流转。这些机制不做好,平台会堆满“僵尸任务”。

两种实现路线:

定时任务轮询。写一个每分钟跑一次的脚本,扫出所有deadline < now()且状态符合的记录,逐条处理。优点是简单直观,缺点是扫描压力随数据量线性增长,而且处理有延迟。优化手段是把deadline字段建索引,只扫时间窗口内的记录,并且按状态分批处理。

延迟消息。任务状态变更时投递一条延迟消息,到期后消费。优点是精准、无扫描压力,缺点是依赖消息中间件,而且消息可能丢、可能重复,必须做幂等(消费前先查当前状态是否还需要处理)。

我的实际选择是:量小用轮询,量大用延迟消息 + 轮询兜底。轮询那部分永远留着,因为它是最后一道保险——消息丢了、消费失败了,靠它还能兜回来。轮询脚本一定要做成可重入的,处理前先SELECT ... FOR UPDATE锁住记录,处理完打上标记,避免多实例部署时重复执行。

3. 钱的部分最不能拍脑袋:账务、结算与对账的最小实现

3.1 余额表和流水表,缺一个都是灾难

悬赏平台的钱有这么几种形态:发布方充值进来的钱、托管在任务里的冻结金额、接单方待结算的金额、可提现的余额、平台抽走的服务费。这几个概念混在一起,代码必然烂成一锅粥。

我的做法是永远只维护两个字段:available_balance(可用余额)和frozen_balance(冻结金额)。所有业务动作都拆成“减少可用、增加冻结”或者“减少冻结、增加可用/扣除”这样的原子操作,并且每一次变动都必须写一条流水。

流水表至少要有这些字段:流水号、用户 ID、业务类型(充值/冻结/解冻/结算/提现/退款/服务费)、变动金额、变动前余额、变动后余额、关联单据 ID、创建时间。变动前后余额这一对字段特别重要,它让每一笔流水都是可校验的——任意时刻把所有流水按顺序累加,结果必须等于当前余额,对不上就是有 bug。

-- 冻结算例(必须放在同一个事务里,并加行锁) BEGIN; SELECT available_balance, frozen_balance FROM wallet WHERE user_id = ? FOR UPDATE; -- 校验 available_balance >= amount UPDATE wallet SET available_balance = available_balance - ?, frozen_balance = frozen_balance + ? WHERE user_id = ?; INSERT INTO wallet_flow (flow_no, user_id, biz_type, amount, before_bal, after_bal, ref_id, created_at) VALUES (?, ?, 'freeze', ?, ?, ?, ?, NOW()); COMMIT;

这里有个我踩过的坑:金额一律用整数分存,绝对不要用浮点数。用decimal也行,但很多人为了省事用float,结果 0.1 + 0.2 算出来 0.30000000000000004,累积几万笔之后对账永远差几分钱,查起来要命。存整数分,展示的时候除以 100,这个习惯能省掉无数次深夜排查。

3.2 资金通道:合规是底线,平台不要自己碰钱

这一点必须说清楚:悬赏平台涉及用户之间的资金往来,平台的正确姿势是接入持牌机构提供的合规支付与结算产品,由持牌方完成资金划转,平台自身只做账务记录和指令下发,绝不形成资金池。任何“用户充值到平台账户、平台再转给另一个用户”的自建模式都存在严重的合规风险,不要碰。

技术层面的落地方式通常是:发布方在发布任务时通过合规支付通道完成支付,资金进入持牌机构的托管账户;任务完成验收后,平台调用结算接口,指令资金划转给接单方;任务关闭时调用退款接口原路退回。平台侧要做的,是维护一套与外部通道一一对应的本地账本,每条记录都带通道返回的交易号,方便对账。

接入时几个实操要点:

  • 结算周期要写进协议。是验收后立即结算,还是 T+1 结算,直接决定你的资金流设计。立即结算实现简单但对账压力大,T+1 需要一张“待结算明细表”和每日跑批任务。
  • 手续费要提前算清楚。通道费率、平台服务费、可能的代扣代缴,三者叠加之后用户实际到手多少,必须在提交交付物之前就展示清楚,不能等结算完了才告诉人家扣了多少。
  • 回调必须验签 + 幂等。通道的回调可能重复投递、可能乱序到达,处理前先用交易号查一下本地是否已处理,验签不通过直接丢弃并告警。

3.3 对账不是可选项,是每天必须跑的作业

我见过太多项目上线三个月没做过一次对账,等到用户投诉“我明明提现了钱没到账”才慌。对账这件事,做起来不复杂,但必须提前设计。

基本流程是:每天固定时间拉取通道侧的对账文件或查询接口,和本地流水表按交易号做双向比对,输出三类差异——本地有通道无(可能是本地多记了)、通道有本地无(可能是回调丢了)、金额不一致(最严重,必须人工介入)。

差异结果要落到一张reconcile_diff表里,带状态(待处理/已处理/已忽略)和处理备注。别小看这张表,它是财务和技术的沟通桥梁,出了问题能立刻定位到是哪一笔、哪个用户、差多少。没有它,所有争议都只能靠翻日志。

4. 三端共用一套接口,靠什么把差异吃掉

4.1 用端标识和特性开关做接口差异化,而不是复制三套

很多源码的做法是/api/web/xxx、/api/app/xxx、/api/mini/xxx三套路径,实现代码 90% 重复。维护起来是噩梦——改一个 bug 要改三遍,忘了一处就出线上事故。

正确做法是一套接口,端差异通过请求头和配置驱动。前端在每个请求里带上:

X-Client: web | app | mini X-Client-Version: 1.4.2 X-Platform: ios | android | h5

后端拿到这些信息后做两件事:

一是字段裁剪。同一个任务详情接口,小程序端可能不需要返回完整的发布者资料,APP 端需要返回分享参数,Web 端需要返回 SEO 相关的描述字段。用一套返回结构 + 按端过滤,比写三个接口清爽得多。

二是行为开关。比如“接单后是否强制实名”,不同端因为审核策略不同可能有差异;再比如“分享按钮点击后的行为”,小程序是转发、APP 是唤起分享面板、Web 是复制链接。这类差异用配置表或者代码里的策略映射来控制:

const CLIENT_STRATEGY = { web: { login: 'password', share: 'copyLink', pay: 'qrcode' }, app: { login: 'sms', share: 'native', pay: 'inapp' }, mini: { login: 'silent', share: 'forward', pay: 'minipay'} };

这样做的好处是,新增一个端(比如以后要接平板端或者车机端)只需要加一条策略,不用动业务代码。

4.2 三套登录方式,收敛成一套 token

这是三端项目里最容易做歪的地方。核心原则:端上只负责拿到“能证明你是谁”的凭证,用户体系的唯一真相在服务端。

统一流程是这样的:每个端用自己的方式完成身份验证(小程序静默登录拿 openid、APP 短信验证码、Web 密码或扫码),验证通过后,服务端在用户主表里找到或创建唯一的用户记录,然后签发一个统一的 token。这个 token 里只放user_id和过期时间,权限和状态实时从缓存或数据库查。

关键设计点:

账号绑定关系单独建表。user_auth表存user_id、auth_type(手机号/小程序 openid/第三方)、auth_key(对应的值)、union_id。这样同一个人在小程序和 APP 里登录,只要能匹配到同一个手机号或者同一个 union 标识,就能绑定到同一个user_id上。不做这层,用户数据永远是分裂的。

token 存缓存,支持主动失效。用户改密码、被封禁、异地登录,都要能立刻踢掉旧 token。把 token 存进缓存并设置过期时间,服务端每次校验时比对,踢人就是删 key 的事。

刷新策略要克制。很多方案做双 token(access + refresh),实现复杂且容易出漏洞。中小型项目完全可以用单 token + 滑动续期:每次请求如果剩余有效期不足 20%,就返回一个新 token 让前端替换。简单、够用。

4.3 消息触达的降级链路,决定用户会不会骂你

前面提过,三端的消息能力差别巨大。把通知这件事设计好,用户体验的差距非常明显。

我一般的分层策略是:

优先级通道适用场景失败后
1小程序订阅消息 / APP 推送状态变更、交付提醒降级到站内信
2短信涉及资金、审核结果降级到站内信
3站内信全量兜底落库不丢
4邮件Web 端用户、周报汇总忽略

实现上,把所有通知抽象成一个notify服务,业务代码只调notify.send(userId, templateCode, params),内部根据用户当前的端类型、订阅状态、免打扰设置决定走哪条路。这样以后加通道或者调整策略,业务代码一行不用改。

这里有个很实际的坑:订阅消息的授权次数是消耗型的。用户同意了三次,你就只能推三次,推完要重新引导授权。所以不要把宝贵的推送次数浪费在“有人浏览了你的任务”这种弱提醒上,留给“任务被接单”“交付待验收”“结算到账”这些真正重要的节点。

5. 源码落地实操:从跑起来到能上线,中间还差多少刀

5.1 环境跑不起来的六个高频原因

拿到源码第一步不是读代码,是让它跑起来。我按踩坑频率排个序:

运行时版本不对。老项目用的框架版本和新装的运行时不兼容,报一堆莫名其妙的语法错误。先看项目里的依赖锁文件和版本说明,严格按版本装,别图省事装最新版。

时区不一致。数据库、应用、服务器三处时区不统一,导致任务截止时间永远差 8 小时,超时任务提前触发或者延迟触发。统一设成同一时区,应用启动时打印一次当前时区确认。

伪静态和路由配置缺失。Web 端刷新页面 404,基本都是 Web 服务器没配重写规则,把所有非静态资源请求转发到入口文件。

跨域配置没开。前后端分离部署时,浏览器控制台一片红。注意不要图省事用*通配,带上凭证的请求不允许通配,必须精确配置白名单。

小程序域名白名单没配。小程序端所有请求都失败,但 APP 端正常——九成是后台没配合法域名。而且必须是 HTTPS,自签证书不行。

文件存储路径写死。源码里把上传目录写成了开发机的绝对路径,换台机器图片全挂。这类硬编码在二手源码里极其常见,全局搜一遍绝对路径和 IP 地址能挖出一堆。

5.2 二次开发时最该动手改的五个位置

源码能跑起来只是开始,下面这五处如果不改,上线就是给自己埋雷。

第一,硬编码的密钥和域名。搜索appid、secret、key、http://、127.0.0.1,把所有敏感配置挪到环境变量或配置中心,并且确认这些密钥没有被打进前端包里。前端包里的任何东西都是公开的。

第二,写死的分类 ID 和模板 ID。这类源码经常把任务分类、通知模板的 ID 直接写在代码里,运营想加一个分类就得改代码发版。改成配置表驱动。

第三,支付回调地址。回调地址一旦写错,钱扣了但订单状态不变,是最难排查的问题之一。上线前务必用真实小额订单走一遍完整链路,确认回调能到达、能验签、能正确改状态。

第四,定时任务的单机假设。很多源码的定时脚本是按单实例写的,部署两台机器就会重复执行。要么加分布式锁,要么在脚本里加“抢占式”标记(先更新状态再处理,更新失败就跳过)。

第五,本地文件存储。源码默认把上传文件存本地磁盘,单机没问题,一旦上多台或者上对象存储,就得改。建议一开始就把存储层抽象成接口,本地和对象存储各实现一个,切换只改配置。

5.3 上线前的自检清单

我把这几年反复用的清单整理成表,逐项过一遍能挡掉大部分线上事故。

检查项具体要求风险等级
并发接单用压测工具模拟 50 并发抢 1 个名额,确认不超卖高
金额计算所有金额用整数分,全链路无浮点运算高
重复提交发布、接单、验收接口有幂等键或状态校验高
支付回调真实小额订单验证回调到达、验签、幂等高
权限校验接口层校验操作人身份,不能只靠前端隐藏按钮高
内容审核任务标题、描述、交付物有审核或过滤机制中
日志留存关键操作(发布、接单、结算、提现)有操作日志中
数据备份数据库每日备份,且验证过能恢复中
超时任务手动改系统时间或造数据,验证超时逻辑正确触发中
消息降级断开推送通道,确认能降级到站内信低

特别说一下“权限校验”这一项。悬赏系统里有个很典型的越权场景:接单方能不能看到发布方的联系方式?能不能看到其他接单人的交付内容?这些在源码里经常是“前端不展示”就当做了权限控制,接口一调全露。所有涉及他人数据的接口,都要在服务端做一次“这个 user_id 是否有权访问这条记录”的校验。

还有一点是业务模式上的红线:悬赏平台很容易被用来做虚假交易、刷量、代写代考之类的事情。上线前要想清楚业务边界,把违规类目挡在发布环节,审核规则和关键词过滤要做在前面,别等出了问题再补。

5.4 一个容易被忽略的细节:任务大厅的列表性能

任务大厅是流量最大的页面,也是最先扛不住的地方。源码里常见的是直接SELECT * FROM task WHERE status = 'recruiting' ORDER BY created_at DESC,数据量一上来就慢。

三个改动能立刻见效:一是只查需要的字段,列表页不需要任务详情的全部内容,把描述长文本干掉;二是建联合索引,(status, category_id, created_at)这种组合索引能覆盖大部分筛选场景;三是热点数据加缓存,首页前几页的结果缓存几十秒,量大的时候能挡掉大部分请求。

分页方式也要注意,用LIMIT offset, size在深翻页时性能很差,改成基于游标的分页(记住上一页最后一条的 ID 或时间,WHERE id < last_id),翻到第一百页也不会慢。这个改动不大,但效果立竿见影。


我个人在实际操作中的体会是,这类多端源码的价值不在于它写了多少功能,而在于它把三端的骨架搭出来了——登录、支付、消息、文件上传这些“基础设施”的接线方式,是真正省时间的部分。而任务状态机、并发控制、账务流水这些核心逻辑,几乎没有一套现成源码能直接拿来用,必须按自己的业务规则重写一遍。所以拿到源码之后,先别急着改 UI,花两天时间把状态流转图和数据表关系画出来,把每一处涉及金额和名额的代码标红,那些就是你真正要动手的地方。剩下的界面长什么样,反而是最不急的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进