
简介这是一套完整的新款任务悬赏与本地任务联盟平台源码面向具备一定PHP和前端开发经验的站长或开发者可用于快速搭建地推拉新、游戏试玩、威客兼职等场景的多端任务系统。后端基于PHPTinkCMF前端采用UniappVUE原生混合框架能打包网页端、双端APP、小程序并支持公众号登录功能上覆盖地区定位、联盟配置、商学院、任务设置等模块可对接多款游戏试玩与任务联盟设置会员发布手续费、置顶刷新费用、每人每日领取次数、任务返佣比例等。包体共6个文件含3个zip源码包、2个txt说明文档、1个sql数据库文件总大小223.87MB另赠开源客服系统可参照教程完成部署与数据导入。已有263人学习下载适合需要获取无加密开源源码、快速二次开发或上线运营任务悬赏平台的读者代码按功能模块划分便于扩展与二次维护。1. 任务墙不是信息差生意是劳动力调度系统“新款任务悬赏拉新地推本地任务同城地区定位游戏试玩任务联盟众人帮威客兼职任务墙”——这一长串词拆开看其实就是一套把“推广需求”和“有零散时间的人”对接起来的派单系统。商家或平台发任务地推人员、兼职用户按定位和条件接单完成截图或行为记录后拿赏金。别把它想成复杂的社交产品核心是四个字发布、审核、结算。标题里出现 PHPThinkCMF 后端和 Uniapp 前端说明这是一套需要自己部署、自己掌控审核流程的私有化任务平台适合本地生活地推团队、游戏试玩渠道、威客任务站这类玩家。下面按我实际搭这类系统的顺序从后端数据模型讲到前端跨端、再到避坑和压测。2. 后端用 PHP ThinkCMF任务墙的后台骨架与发布接口怎么搭为什么选 PHP ThinkCMF而不是 Java、Node任务平台第一版本质是 CRUD 审核 结算业务不算复杂但管理后台的角色权限很碎运营要能发任务审核员只能审核财务只能看提现。ThinkCMF 在 ThinkPHP 之上封装好了后台登录、菜单、权限、上传、插件机制正好省掉这部分重复劳动。我一般把 ThinkCMF 当后台基座业务代码按 ThinkPHP 的 MVC 写不直接改它底层。2.1 ThinkCMF 在任务平台里负责什么和裸 ThinkPHP 差在哪ThinkCMF 给的是一个“后台治理”外壳自带管理员表、角色权限、菜单管理、配置项还有文件上传接口。任务平台最麻烦的不是写用户端接口而是后台要分角色权限。用裸 ThinkPHP这套 RBAC 从建表到中间件差不多一周用 ThinkCMF后台左侧菜单和授权页直接就能用。我习惯把用户端接口放在api模块后台管理放在admin模块二者分开否则测试时会翻车。对比项裸 ThinkPHPThinkCMF后台登录/权限自己写 session 中间件自带管理员与访问授权菜单管理写死在视图或路由后台可视化配置文件上传自己封装有公共上传接口业务接口自由同样自由不强制改底层这个表只说明选型差异。实际项目里我会把 ThinkCMF 自带的内容管理功能关掉只留框架和后台底座让升级时少点冲突。任务相关表都放在app/admin/model和app/api/controller下核心钩子就两个任务发布后写待审列表任务通过后写钱包流水。思路很简单CMF 管人我管任务和钱。2.2 任务中心与用户钱包四张核心表的字段设计任务平台的表可以拆很多张但第一版必须做扎实的是四张任务表、用户表、用户任务表、钱包流水表。任务表负责“有什么活”用户表负责“谁在干”用户任务表负责“干到哪一步”钱包流水表负责“钱怎么走”。下面这四张表我会在设计评审时反复过。任务表task字段类型说明titlevarchar任务标题展示在任务墙上task_typetinyint1 拉新 2 地推 3 游戏试玩 4 问卷 5 威客rewarddecimal单次赏金单位元total_quotaint总名额控制预算上限remain_quotaint剩余名额领取前必须判断city_codevarchar同城任务的城市编码lat / lng / radiusdecimal / int任务地点与允许范围单位米audit_typetinyint1 截图审核 2 行为审核 3 位置打卡start_time / end_timeint任务墙展示时间区间statustinyint0 下架 1 上架 2 结束用户表user里额外关心三个字段invite_code是用户专属邀请码pid是邀请人用户 idlocked_balance是提现冻结金额。这三个字段不能省拉新任务和提现并发都要靠它们。用户任务表user_task字段类型说明user_idint领取人task_idint哪个任务statustinyint0 领取中 1 待审核 2 通过 3 驳回submit_texttext用户提交的文案/外链submit_imagestext截图 URLJSON 存location_jsontext打卡地点的经纬度和时间audit_timeint管理员审核时间钱包流水表wallet_log字段类型说明user_idint用户amountdecimal金额正数入账负数支出biz_typevarchartask_reward / withdraw / refundrelation_idint关联任务或提现记录balance_afterdecimal操作后余额对账用这些字段够第一版跑。别一上来就设计几十张分销表后面接支付时会发现只需要一个locked_balance。2.3 用 PHP 写任务发布接口从请求到上墙的最小实现先给后台管理员一个最小发布接口控制器放在app/admin/controller/TaskController.php。ThinkCMF 基类里request()-param()统一接参数。代码?php namespace app\admin\controller; use think\facade\Db; use cmf\controller\AdminBaseController; class TaskController extends AdminBaseController { public function add() { if (!$this-request-isPost()) { return json([code 0, msg 仅支持 POST 提交]); } $data $this-request-param(); // 基本校验标题、赏金、总名额不能为空 if (empty($data[title]) || $data[reward] 0 || $data[total_quota] 0) { return json([code 0, msg 标题、赏金、名额参数不合法]); } // 时间字段统一转时间戳避免前端字符串格式不一致 $data[start_time] strtotime($data[start_time] ?? date(Y-m-d 00:00:00)); $data[end_time] strtotime($data[end_time] ?? date(Y-m-d 23:59:59, strtotime(7 days))); $data[remain_quota] $data[total_quota]; // 刚发布时剩余名额等于总名额 $data[create_time] time(); $data[update_time] time(); Db::startTrans(); try { $taskId Db::name(task)-insertGetId($data); // 记录操作日志方便后续追责 Db::name(admin_log)-insert([ admin_id cmf_get_current_admin_id(), action task_add, task_id $taskId, create_time time(), ]); Db::commit(); return json([code 1, msg 发布成功, data [task_id $taskId]]); } catch (\Exception $e) { Db::rollback(); return json([code 0, msg 发布失败 . $e-getMessage()]); } } }逻辑说明insertGetId是为了拿回任务 id后续审核、统计都要用到它。事务不是必须的但加了admin_log就要保证任务和日志同时成功否则后台显示发布成功、日志却丢了排查起来很难受。参数说明total_quota和remain_quota分开存是为了列表页显示“剩余名额”也方便领取时用原子扣减而不是先查再改。用户端看到的列表接口放在app/api/controller/TaskController.php返回前把reward转成字符串、start_time转成Y-m-d H:i。发布接口写完先手动造两条带位置的任务再看用户端是否出来别一上来就考虑并发。3. Uniapp 前端一套代码出小程序和 App任务列表怎么接Uniapp 负责用户端。任务平台的用户场景是微信里点开小程序或者下载一个安卓轻应用打开定位、浏览任务墙、提交截图。用 Uniapp 意味着 Vue 语法写一次编译到微信小程序和 AppUI 层面省一半工作量。下面按真实接入顺序讲。3.1 为什么任务平台选 Uniapp 而不是分开写两个端大多数任务平台预算有限只够养一两个前端。分开写微信小程序原生和安卓原生意味着两套定位权限、两套登录、两套版本迭代。Uniapp 的 Vue 单文件组件页面、路由、请求封装都能共用。我一般约定所有接口在api/目录下统一封装页面只调用方法不直接写uni.request。这样以后某个端要替换改动只在封装层。需要特别注意Uniapp 的 App 端和微信小程序端在定位权限、地图选点、登录授权上仍然有差异必须用条件编译#ifdef MP-WEIXIN处理差异。微信小程序里获取定位要配置manifest.json的permission.scope.userLocationApp 端要在打包配置里写定位用途。别指望一点不改两端行为完全一致这是第一个玄学坑。// manifest.json 配置片段 mp-weixin: { permission: { scope.userLocation: { desc: 用于领取附近任务时校验位置 } } }这段配置是微信小程序授权弹窗的文案必须写清楚为什么用定位否则审核和用户授权环节都会被卡。3.2 同城定位Uniapp 取经纬度之后服务端还要做什么先看最小定位代码放在任务列表页onLoad里onLoad() { this.getLocationAndLoadTasks(); }, methods: { getLocationAndLoadTasks() { uni.showLoading({ title: 定位中 }); uni.getLocation({ type: gcj02, success: (res) { this.lat res.latitude; this.lng res.longitude; this.loadTasks(); // 带坐标请求任务墙 }, fail: (err) { uni.showToast({ title: 定位失败请开系统定位, icon: none }); this.loadTasks(); // 降级不带坐标只看全城任务 } }); } }这段代码的坑在于uni.getLocation是异步的页面渲染不会等它。所以先用 loading再调接口。参数说明gcj02是国测局坐标任务表里也用同一坐标系避免展示范围时做坐标偏移。服务端不能只信这个坐标因为前端定位可以被改。我一般拿到坐标后再用城市编码做约束如果任务要求同城先比对city_code再计算与任务点的距离。距离用 haversine 公式在 SQL 里写会很吃力第一版用 PHP 数组过滤就行因为一个城市同时上架的任务量不会超过几千条。3.3 任务列表页从 uni.request 到下拉刷新的完整片段接口路径统一维护在config.js页面只写业务逻辑// api/task.js const request (url, data) { return new Promise((resolve, reject) { uni.request({ url: https://api.example.com url, data: data, method: GET, header: { Authorization: uni.getStorageSync(token) }, success: (res) { if (res.data.code 1) resolve(res.data.data); else uni.showToast({ title: res.data.msg || 请求失败, icon: none }); }, fail: reject }); }); }; export const getTaskList (data) request(/api/task/lists, data);页面里data() { return { list: [], page: 1, city: , lat: 0, lng: 0, loading: false }; }, async loadTasks() { if (this.loading) return; this.loading true; try { const data await getTaskList({ page: this.page, city: this.city, lat: this.lat, lng: this.lng }); this.list this.page 1 ? data.list : this.list.concat(data.list); this.page; } finally { this.loading false; uni.stopPullDownRefresh(); } }, onPullDownRefresh() { this.page 1; this.loadTasks(); }, onReachBottom() { this.loadTasks(); }逻辑说明列表页最关键的是分页防重。loading标志位防止onReachBottom和下拉刷新同时触发避免同一页数据重复。page在刷新时重置为 1否则下拉刷新后concat会把旧数据接上去。请求头带 token任务平台很多接口需要识别邀请关系匿名访问拿不到任务进度和提现页。有一个容易被忽略的边界列表页的city参数来自用户选择或定位逆解析而uni.getLocation只给经纬度不给城市名。常用做法是把经纬度提交给后端由后端用逆地理数据转城市编码前端不要内置城市库。如果前端内置 citycode 映射行政区划调整后就会失配一个月没人维护就是旧数据。4. 拉新与地推闭环邀请关系、审核状态机与结算这一章解决从“用户来了”到“钱出去了”的闭环。拉新任务的核心不是接单而是邀请绑定地推任务的核心是审核。两者都做对平台才不会有坏账。4.1 邀请关系只存 pid不搞多级分销拉新奖励才安全用户表里的pid字段就是整个邀请关系的核心。用户注册时带上邀请码invite_code服务端通过邀请码反查邀请人 id写进user.pid。后续奖励和统计都通过 pid 一条 SQL 查到。不建议为拉新做多级分销树风险太高平台只需要给一级奖励就够了。注册接口代码// app/api/controller/UserController.php public function register() { $data $this-request-param(); if (empty($data[mobile]) || empty($data[code])) { return json([code 0, msg 缺少注册参数]); } Db::startTrans(); try { $inviterId 0; if (!empty($data[invite_code])) { $inviterId Db::name(user) -where(invite_code, $data[invite_code]) -value(id) ?: 0; } $userId Db::name(user)-insertGetId([ mobile $data[mobile], invite_code $this-genInviteCode(), pid $inviterId, // 邀请人 id0 表示没有邀请人 create_time time(), ]); // 给邀请人发放拉新奖励真实场景要校验任务配置这里简化 if ($inviterId 0) { Db::name(wallet_log)-insert([ user_id $inviterId, amount 1.00, biz_type invite_reward, relation_id $userId, create_time time(), ]); } Db::commit(); return json([code 1, msg 注册成功, data [user_id $userId]]); } catch (\Exception $e) { Db::rollback(); return json([code 0, msg 注册失败]); } }逻辑说明注册和邀请奖励放进同一个事务防止出现注册成功但邀请奖励丢失也防止奖励重复发放。genInviteCode()是自定义函数生成唯一邀请码。如果先用随机字符串占位插入后再回填需要多一次 update更稳妥的做法是插入前用随机串加校验冲突再重试。参数说明拉新奖励不要写死金额应该放到任务表里配置上面的 1.00 只是演示。真实场景还要增加防刷校验两个用户如果同一 IP 段、同一设备指纹不能算有效拉新。4.2 任务审核状态机从提交截图到放款状态怎么流转用户领取任务后状态是“进行中”提交截图后变成“待审核”审核通过变成“已完成”并加钱审核驳回则恢复为“进行中”并退回名额。状态流转表状态码名称触发条件后置动作0进行中用户点击领取扣减 remain_quota1待审核用户提交截图/位置无2已完成审核通过奖励入账累计完成数3已驳回审核驳回恢复 remain_quota记录原因这个状态机用user_task.status一个字段就能表达。不要用两个字段表示“已提交”和“审核中”查询条件会翻倍。审核通过接口代码// app/admin/controller/AuditController.php public function pass() { $id $this-request-param(id/d, 0); $userId $this-request-param(user_id/d, 0); $taskId $this-request-param(task_id/d, 0); Db::startTrans(); try { $ut Db::name(user_task) -where(id, $id) -where(status, 1) // 必须是待审核状态防止重复审核 -find(); if (!$ut) { throw new \Exception(记录不存在或已被审核); } Db::name(user_task)-where(id, $id)-update([ status 2, audit_time time(), audit_remark 审核通过, ]); // 入账前再读一次用户余额避免覆盖并发修改 $balance Db::name(user)-where(id, $userId)-value(balance); Db::name(user)-where(id, $userId)-update([ balance $balance $ut[reward] ]); Db::name(wallet_log)-insert([ user_id $userId, amount $ut[reward], biz_type task_reward, relation_id $ut[task_id], balance_after $balance $ut[reward], create_time time(), ]); Db::commit(); return json([code 1, msg 审核通过]); } catch (\Exception $e) { Db::rollback(); return json([code 0, msg $e-getMessage()]); } }逻辑说明where(status, 1)是关键是状态机的“锁”。多人同时审核同一条记录时只有一个人能把它从 1 改成 2。余额更新先读再加虽然做不到行锁但在任务平台低频并发下够用提现环节靠“提现记录唯一约束”兜底。注意审核通过时不恢复remain_quota驳回时才恢复。维持这两条规则后台对账才平。4.3 提现结算余额、冻结与打款之间的顺序提现流程三步用户申请提现 - 冻结余额 - 管理员确认打款。若平台对接微信商家转账可以在后台发起打款并回调更新状态。第一版不建议用户发起后自动打款应该先走人工复核防止刷单者利用免校验漏洞把钱提走。提现申请接口// app/api/controller/WithdrawController.php public function apply() { $userId $this-user[id]; $amount floatval($this-request-param(amount)); if ($amount 10) { return json([code 0, msg 单次提现至少 10 元]); } Db::startTrans(); try { // 先查余额再冻结两个动作必须在一个事务里 $user Db::name(user)-where(id, $userId)-find(); if ($user[balance] - $user[locked_balance] $amount) { throw new \Exception(可提现余额不足); } Db::name(user)-where(id, $userId)-update([ locked_balance $user[locked_balance] $amount ]); $withdrawId Db::name(withdraw)-insertGetId([ user_id $userId, amount $amount, status 0, // 0 待审核 1 已打款 2 拒绝 create_time time(), ]); Db::commit(); return json([code 1, msg 申请成功, data [withdraw_id $withdrawId]]); } catch (\Exception $e) { Db::rollback(); return json([code 0, msg $e-getMessage()]); } }逻辑说明可提现余额 balance - locked_balance这是防止用户重复提现造成超扣。申请提现时只冻结不扣余额管理员打款成功后把balance扣掉、locked_balance解冻并走一笔负数流水拒绝则直接解冻。用locked_balance字段而不是另建一张提现流水表对账简单。参数说明最低提现额、单笔限额、每日次数最好做成后台配置不要写死。5. 任务墙避坑定位失效、重复提交、审核积压的排查记录这套平台上线前的回归测试我一般按下面五类问题过一遍。它们不要求一次全修完但每次发版前都要确认没有回归。5.1 室内定位漂移同城任务总是领不了现象用户在商场或写字楼打开任务墙显示距离任务点 200 米点领取却提示“不在任务范围”。后台看用户坐标漂到了隔壁街区距离计算超过 500 米。原因手机 GPS 在室内被削弱会借用 Wi-Fi 和基站定位精度往往只有几百米前端直接把第一次拿到的坐标传上去没有等待定位稳定。解决前端定位成功后再加一次精度判断。uni.getLocation返回的accuracy是精度值我一般要求小于 200 米才允许提交坐标否则弹窗提示到开阔处。后台同时放宽范围任务范围半径默认 500 米给室内场景留出余量。不要教条地设 30 米那在室外都经常失败。还有一个补充任务打卡点要提前采集真实坐标不要直接取地图上的商户坐标否则存在偏差。5.2 用户拿旧截图重复提交后台审核疲于奔命现象同一张游戏试玩截图被十几个账号反复提交后台待审记录堆成山运营要点十几分钟。原因没做提交记录去重也没有跟用户历史提交比对。解决在user_task表加一个submit_md5字段保存截图 MD5提交接口先查同任务、同 MD5 是否存在。真实截图每次全屏都不一样但用户裁剪后 MD5 会变所以要再比对图片的感知哈希或直接人工抽检。地推任务还要求拍现场照片带门店招牌和手持设备后台按任务维度把相同图片排在一起审核效率能高一倍。这个坑最花钱因为审核人力是按单量算的。5.3 审核积压导致用户以为平台跑了现象任务发出去两天用户提交截图后没有任何反馈开始四处说平台骗人任务墙口碑崩了。原因审核人员有限任务量上来后积压用户只看自己的任务进度看不到排队状态。解决一是给每个任务设audit_deadline超过 24 小时未审核自动提醒后台二是用户端任务详情显示“预计 X 小时内审核”降低焦虑三是审核列表按提交时间倒序先到先审别让运营挑简单的先审。审核时效就是现金流信誉积压是最隐蔽的翻车点。我还会给审核后台加一个“超时未审”筛选视图只看红色告警记录。5.4 提现频繁触发风控打不了款现象新人集中提现 10 元、20 元小额每天几百笔接的支付商户号被风控冻结用户账号也跟着受限。原因任务平台本身是高频场景大批小额转账和正常消费完全不同容易触发“交易频率异常”。解决先把提现策略改成 T1 人工处理不接自动打款后台对同一用户当日提现次数限制为最多 2 次。同时准备一个纠纷退款入口如果任务奖励被判定为刷量而撤销余额负数要能处理。最务实的一条上一套 mock 打款接口先跑顺业务流程再接正式打款 API避免一上线就撞风控。血泪经验是宁可让用户等一天也不要让商户号被封。5.5 任务墙被脚本刷量防了定位没防设备指纹现象后台看某个拉新任务半天完成量暴涨但邀请来的账号一周内全不活跃提现申请却不少。原因刷量脚本用改机工具模拟定位和截图只防坐标防不住批量注册。解决前端采集设备指纹。App 端可用 Android ID 拼 token小程序端用手机型号和设备信息生成指纹传到后端一个设备指纹一天最多领 3 个任务同一任务只能领一次。任务平台要清醒审核不能完全靠人机器码只能挡批量脚本真人众包偶尔也会绕过所以风控规则要保守。发现异常任务后第一时间把任务下架而不是只驳回记录避免损失扩大。6. 上线前的压测与防作弊进阶让任务墙真正跑得稳6.1 压测盯数据库不要只看接口响应时间任务墙的接口大多简单瓶颈不在 PHP而在数据库。先用 ab 做一轮冒烟压测ab -n 500 -c 20 -H Authorization: test-token \ https://api.example.com/api/task/lists?city330100lat30.1lng120.1page1如果Requests per second掉到个位数先看 MySQL 慢查询日志十有八九是city_code和status没建联合索引。上线前把task(city_code, status, end_time)建好user_task(user_id, task_id)建唯一索引这是最大收益。并发高峰期用 Redis 缓存任务列表 30 秒发布任务时清缓存比堆机器省事。6.2 行为轨迹比截图更可信用埋点给审核当裁判我的进阶做法是任务详情页、试玩跳转、提交截图这三步都上报行为日志字段只有user_id、task_id、event、url、duration、create_time。审核后台把轨迹和截图并排展示正常试玩用户往往在页面停留几分钟提交时有一段操作间隔脚本则瞬间打开、瞬间提交。不用做复杂风控算法规则判断就够了。这条经验让审核通过率更稳定也让刷量成本变高。6.3 我自己的上线习惯每次发版前我把前一天统计报表跑一遍看“领取数 / 提交数 / 通过数”三级漏斗。如果通过率突然高于 80%第一反应不是高兴而是检查审核模板是不是放水了。任务平台最怕的是奖励被刷走所以我的底线是所有资金类操作全部走事务和流水表所有审核记录保留一年不能删。定位精度、截图去重、审核时效、提现限额、设备指纹这五件事都验证过系统基本就稳了。希望帮到你。本文还有配套的精品资源点击获取