
简介一款基于微信云开发的情侣互动小程序把任务、积分、商城串成完整闭环一方发布任务并确认完成另一方获得积分再用积分购买对方上架的商品使用后标记不可逆。资源面向情侣用户也适合想学习小程序云开发的初级开发者场景明确、逻辑清晰。压缩包共2008个文件主要是1627个js脚本、186个md文档、171个json配置等总大小29.62MB涵盖页面逻辑、云函数、项目文档与数据配置结构完整可二次开发。当前已有163人学习下载。资源内含预设商品和任务支持拖拽悬浮按钮快速新建配套搜索框、图片滑动轮播、仓库系统、时间记录与左滑菜单从情侣互动场景到工程实现细节都有体现可直接部署使用或作为实战参考。1. 情侣微信小程序云开发架构下的双人任务与积分商城闭环这套源码的核心不是“秀恩爱”而是一套完整的多角色任务分发、积分流转与库存消耗系统。它利用微信云开发的免服务器架构把数据库读写、云函数逻辑、前端交互全部托管在微信生态内适合想学习云开发全栈、或需要一套可扩展的双人互动业务模型的开发者。我拆完这份代码后发现它的价值在于把“任务-积分-商品-核销”这条完整链路用云函数统一封装避免了前端直连数据库导致的安全漏洞。项目源码包含完整的云函数目录、小程序前端页面、预设数据与拖拽悬浮按钮等交互细节直接导入微信开发者工具即可运行。这份资料对想快速搭建带积分体系的互动应用、或者需要参考云开发权限控制写法的开发者非常有参考价值。2. 云开发架构解析为什么把业务逻辑全部收敛到云函数2.1 从“前端直连数据库”到“云函数中转”的演进微信云开发最早期的常见写法是前端直接调用wx.cloud.database()API对集合执行增删改查。这样开发速度快但问题很明显数据库权限一旦配置不当任何用户都能绕过前端逻辑直接写入数据。就拿这个项目来说任务完成需要“女友发布任务、男友确认完成、女友获得积分”如果前端能直接修改tasks集合和users集合里的积分字段那所谓的“互动”就变成了双方都能给自己刷分的漏洞。源码里所有非云函数的云逻辑都被封装成了一组云函数前端拿到openid后只负责传递业务参数真正对数据库的读写全部在云端wx-server-sdk环境里完成。这种模式的收益不只是权限安全还包括校验集中化。同一套业务规则比如“完成任务的人不能是获得积分的人”只写在服务端一份前端不管怎么改都无法绕过。我一般建议任何涉及积分、余额、库存这类状态变更的操作至少要过三层身份鉴权确认openid、业务校验确认角色和状态合法、原子更新用_.inc或事务保证并发安全。这个项目的云函数分层恰好覆盖了这三层后面展开讲。2.2 数据库集合设计与角色模型代码中主要涉及users、tasks、goods、orders、inventory等集合。每个集合的文档结构是围绕“双人互动”场景设计的users存储_openid微信自动写入、nickname、avatarUrl、points当前积分、roleboy/girl。积分字段只允许云函数增减前端只读。tasks发布的任务包含title、desc、reward完成任务后获得的积分、statuspending/confirmed/done、publisher、receiver、createdAt。goods发布上架的商品包含name、image可多张、price、stock、preset标记是否为预设数据。orders购买记录用于记录“女友用积分买洗碗券”的行为。inventory已购商品的库存商品被使用时标记为已使用不可逆这是这套系统最巧妙的闭环设计。角色模型上没有做复杂的关系表只通过publisher和receiver两个字段记录操作者。云函数从cloud.getWXContext()里取OPENID再通过users集合反查角色从而确定当前用户是发布方还是接收方这一点贯穿全局。所以建表时_openid千万不要在前端 setData 里手动写入云开发会自动带上手写会造成权限校验错乱。2.3 云函数的模块拆分与调试方式为了不把所有业务逻辑堆在index.js里源码把每个云函数目录独立成包比如addTask、confirmTask、buyGoods、useItem。每个目录内是标准的 Node.js 模块package.json依赖统一为wx-server-sdk入口index.js导出main方法入参event中带上业务数据// addTask/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() exports.main async (event, context) { const { OPENID } cloud.getWXContext() const userRes await db.collection(users).where({ _openid: OPENID }).limit(1).get() if (userRes.data.length 0) { return { code: -1, msg: user not found } } const publisher userRes.data[0] // 只允许女友发布任务 if (publisher.role ! girl) { return { code: -1, msg: only girl can publish task } } const result await db.collection(tasks).add({ data: { title: event.title, desc: event.desc || , reward: Number(event.reward) || 0, status: pending, publisher: OPENID, createdAt: Date.now() } }) return { code: 0, data: result._id } }逻辑说明这段代码先取出调用者的openid并反查用户角色如果不是女友则直接拒绝通过校验后才执行add操作。注意cloud.DYNAMIC_CURRENT_ENV会自动指向当前环境不需要硬编码环境 ID。调试时我一般先在微信开发者工具里右键云函数目录选择“云端安装依赖并上传”然后在云开发控制台的“云函数”模块里用测试参数跑一次。如果业务严重依赖登录态验证需要手动传一个假的event.userInfo.openId来模拟不同角色。3. 任务闭环与积分流转云函数里的核心业务逻辑实现3.1 发布任务、确认完成与积分入账整套任务流的时序是这样女友发布任务title reward→ 任务出现在男友的任务列表中 → 男友点击“确认完成” → 系统校验任务确实由男友点击 → 任务状态改为done→ 女友的积分加上reward。这里最关键的一点是“点击完成任务的人不能是获得积分的人”也就是说男友负责点击完成女友才是积分收益者。// confirmTask/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event, context) { const { OPENID } cloud.getWXContext() const taskId event.taskId const taskRes await db.collection(tasks).doc(taskId).get() const task taskRes.data if (task.status ! pending) { return { code: -1, msg: task already processed } } // 完成任务的人不能是发布者发布者是收积分的人 if (task.publisher OPENID) { return { code: -1, msg: cannot confirm your own task } } const userRes await db.collection(users).where({ _openid: task.publisher }).limit(1).get() if (userRes.data.length 0) { return { code: -1, msg: publisher not found } } const publisherId userRes.data[0]._id const transRes await db.runTransaction(async transaction { const taskDoc await transaction.collection(tasks).doc(taskId).get() if (taskDoc.data.status ! pending) { throw new Error(task already processed) } await transaction.collection(tasks).doc(taskId).update({ data: { status: done, confirmTime: Date.now(), confirmBy: OPENID } }) await transaction.collection(users).doc(publisherId).update({ data: { points: _.inc(task.reward) } }) }) return { code: 0, msg: success } }逻辑说明先读取任务判断状态然后检查当前点击者是否是任务的发布人如果是则直接报错。通过校验后用数据库事务把“更新任务状态”和“给发布人加积分”两个操作绑在一起避免中途失败造成积分发了但任务没关。事务在这里是必要的因为积分变更不可回滚如果只做两次普通update中途网络闪断就会出现任务已完成但积分未加的状态。云开发的事务 API 在并发请求时也能自动做冲突检测直接把失败的任务抛出异常前端再提示重试。参数方面_.inc(task.reward)是原子自增操作比先读再加更可靠并发时不会丢更新。3.2 商品上架与积分购买中的库存扣减商品侧的闭环是男友发布商品比如“洗碗券”设置积分价格和库存女友看到商品后用积分购买购买成功后商品进入女友的inventory之后女友调用“使用”操作把商品标记为已使用。整个过程要保证扣积分、减库存、生成订单三个动作在同一事务里完成。// buyGoods/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event, context) { const { OPENID } cloud.getWXContext() const goodsId event.goodsId const goodsRes await db.collection(goods).doc(goodsId).get() const goods goodsRes.data if (goods.stock 0) { return { code: -1, msg: out of stock } } const userRes await db.collection(users).where({ _openid: OPENID }).limit(1).get() const user userRes.data[0] if (user.points goods.price) { return { code: -1, msg: points not enough } } const userId user._id const transRes await db.runTransaction(async transaction { const currentGoods await transaction.collection(goods).doc(goodsId).get() if (currentGoods.data.stock 0) { throw new Error(out of stock) } await transaction.collection(goods).doc(goodsId).update({ data: { stock: _.inc(-1) } }) await transaction.collection(users).doc(userId).update({ data: { points: _.inc(-goods.price) } }) await transaction.collection(orders).add({ data: { goodsId, goodsName: goods.name, price: goods.price, buyer: OPENID, createdAt: Date.now() } }) await transaction.collection(inventory).add({ data: { goodsId, goodsName: goods.name, image: goods.image, owner: OPENID, status: unused, boughtAt: Date.now() } }) }) return { code: 0, msg: success } }逻辑说明用户信息里没有把积分冗余到前端而是在云函数里实时读取后再判断是否足够。扣库存用了_.inc(-1)并把库存不足的异常情况放在事务内部二次检查防止超卖。库存明细同时写入orders做流水、inventory做归属两个维度分开存方便后续查账。我额外强调一点这里的inventory.add放在事务里是合理的因为云开发事务支持了add操作。如果是普通模式我会先减库存再生成订单失败时做补偿回滚但代码量会多不少。事务方案虽然执行时间略长但对这种低频互动场景完全够用。3.3 仓库系统与商品使用的不可逆标记新增的“仓库系统”把已购商品暂存到inventory使用后再统一标记status: used。源码里这步特意做成不可逆操作目的就是为了防止双方“用完再还回去”的扯皮。实现上就是一个条件更新// useItem/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() exports.main async (event, context) { const { OPENID } cloud.getWXContext() const inventoryId event.inventoryId const res await db.collection(inventory).where({ _id: inventoryId, owner: OPENID, status: unused }).update({ data: { status: used, usedAt: Date.now() } }) if (res.stats.updated 0) { return { code: -1, msg: item not found or already used } } return { code: 0, msg: success } }逻辑说明这里的筛选条件里同时带上owner和status权限隔离不是靠数据库权限配置而是在查询条件层面强制执行。如果调用者不是物品所有者或者物品已经被使用过条件匹配不到数据更新数量为 0前端就能直接提示失败。这个模式我建议在类似业务里沿用不要单独先查再改改成“条件更新”一条命令搞定既减少一次网络往返又在语义上保证了状态流转的原子性。只有unused才能变成used天然屏蔽重复使用。4. 前端交互实战搜索、预设数据、拖拽悬浮按钮与滑动窗口4.1 任务与商品的搜索过滤逻辑页面上方的搜索框支持同时过滤任务和商品实现思路不是调云函数而是前端在已加载的数据里做本地过滤。云开发的where正则匹配每次都要走网络在数据量不大时反而增加延迟。源码里的过滤器写法类似// utils/filter.js function filterList(list, keyword, fields) { if (!keyword) return list const kw keyword.trim().toLowerCase() return list.filter(item { return fields.some(field { const value item[field] return value String(value).toLowerCase().includes(kw) }) }) } module.exports { filterList }逻辑说明fields可以是[title, desc]或[name]这样同一个过滤器能复用到任务列表和商品列表。搜索匹配的是子串而非全等includes比indexOf更直观性能上对百级数量的前端数组完全不是瓶颈。如果后续数据量涨到几千条我建议改成云函数里做db.RegExp查询并且给title建索引避免前端一次性拉全量数据。当前版本本地过滤的好处是滑动搜索无延迟体验上更顺滑。4.2 预设商品与任务数据的批量注入源码里内置了一批预设商品和任务添加时可以直接选择预设省去手动输入的麻烦。前端预设数组通常是这种结构// config/presets.js const presetTasks [ { title: 陪我看电影, desc: 选一部我想看的电影并陪我全程看完, reward: 60 }, { title: 给我买奶茶, desc: 去我喜欢的那家店买指定口味, reward: 30 } ] const presetGoods [ { name: 洗碗券, desc: 包一周洗碗不可转让, price: 80 }, { name: 按摩券, desc: 肩颈按摩 30 分钟, price: 50 } ] module.exports { presetTasks, presetGoods }逻辑说明预设数据只是一个静态配置用户点选后回填到表单真正提交时仍然走云函数。这里做个区分预设数据和“一键导入数据库”是两回事。如果要做成批量导入需要新增一个云函数遍历数组逐条add还要注意去重否则每次打开页面都会重复插入。我建议在goods和tasks集合里加一个presetKey字段同一预设只能导入一次后续导入时先查presetKey是否存在避免测试时重复点击造成数据爆炸。4.3 可拖拽悬浮按钮与图片滑动窗口的实现新增按钮被做成了页面悬浮按钮并且支持长按拖拽。这个功能在小程序里常用movable-area和movable-view组合实现movable-area stylewidth: 100%; height: 100vh; position: fixed; top: 0; left: 0; pointer-events: none; movable-view classfab directionall bindchangeonFabChange x{{fabX}} y{{fabY}} button classfab-btn bindtapopenAddModal/button /movable-view /movable-areaPage({ data: { fabX: 300, fabY: 600 }, onFabChange(e) { this.setData({ fabX: e.detail.x, fabY: e.detail.y }) } })逻辑说明movable-view的directionall表示允许水平和垂直方向移动x和y是初始坐标。bindchange事件会在拖动过程中持续触发源码里直接把最新坐标写回 data这样下次进入页面时能恢复到上次位置。注意movable-area的pointer-events: none是为了不遮挡底下的列表滚动不然悬浮区域会截获触摸事件导致列表划不动。滑动窗口播放多张图片用的是swiper组件设置autoplay和interval就能实现自动轮播。如果是商品详情页的多图预览我建议用current字段绑定当前索引并加一个指示器显示当前张/总张数。4.4 订单时间的统一格式化处理源码里把购买、上架、新建任务的时间统一记录为时间戳前端展示时再格式化成“今天 14:30”这类友好格式。这里有个容易踩的坑Date.now()得到的是 UTC 毫秒数在真机上格式化需要先补时区偏移function formatTime(ts) { const d new Date(ts) const year d.getFullYear() const month String(d.getMonth() 1).padStart(2, 0) const day String(d.getDate()).padStart(2, 0) const hour String(d.getHours()).padStart(2, 0) const min String(d.getMinutes()).padStart(2, 0) return ${year}-${month}-${day} ${hour}:${min} }逻辑说明getHours()返回的是本地时区小时所以用户看到的是自己所在时区的时间。如果云函数端存储的是 ISO 字符串而不是时间戳格式化的逻辑需要改用new Date(isoString)效果一样。真机上如果发现时间偏差 8 小时检查是前后端某一处把字符串当成 UTC 解析了统一用时间戳就能规避。5. 左滑菜单与状态流转 UI交互升级中的坑与优化方案5.1 为什么把“点击操作”改成“左滑菜单”老版本里点击订单可以直接完成或购买误触率太高。尤其是任务列表和商品列表混排时随手一点就可能触发购买或确认且确认完成动作不可逆。新版本把操作统一收敛为左滑菜单里的图标点击任务进入详情左滑才出现“确认完成”“删除”等操作误触成本直线下降。微信小程序没有内置的滑动单元格组件源码里常见做法是借助swipe-cell自定义组件或movable-view实现。如果是简单场景我用的是第三方扩展库miniprogram-slide-view特点是轻量、手势判定灵敏。如果你不想引组件也可以自己监听touchstart、touchend计算横向位移量超过 50px 就显示操作层onTouchEnd(e) { const { clientX, clientY } e.changedTouches[0] const deltaX clientX - this.startX const deltaY clientY - this.startY if (Math.abs(deltaX) 50 Math.abs(deltaX) Math.abs(deltaY) * 1.5) { this.setData({ activeId: e.currentTarget.dataset.id }) } else { this.setData({ activeId: }) } }逻辑说明deltaX 50是触发阈值deltaX绝对值要大于deltaY的 1.5 倍防止上下滚动页面时误触。这里的activeId控制当前操作层是否展开同一时间只允许一行展开避免多行操作层同时出现导致视觉混乱。5.2 订单状态机的颜色与文案映射订单流转有pending待确认、done已完成、used已使用等状态前端需要一个统一映射对象来渲染徽标和按钮文案const statusMap { pending: { text: 待确认, color: #ff9900 }, done: { text: 已完成, color: #07c160 }, used: { text: 已使用, color: #888888 } }逻辑说明集中管理状态映射可以保证列表、详情页、订单页三处显示一致。新增状态时只改这一处不需要全局搜替换。按钮的禁用逻辑也要依赖状态pending状态显示“确认完成”done后按钮变成已置灰的占位符used后完全不可操作。小程序里disabled属性配合状态值即可。5.3 实际开发中的触控冲突处理左滑菜单最容易出的问题有两个一个是和页面纵向滚动冲突用户下滑列表时如果手指有轻微横向位移会被误判为左滑另一个是操作层弹出后点击其他区域没有自动收起。第一个问题的解决方式是加入角度判定上面代码的deltaY判断第二个常见做法是给页面根节点加catchtouchstart事件点击空白处时清空activeIdview classpage catchtouchstartcloseSlide !-- 列表内容 -- /view逻辑说明catchtouchstart会拦截所有触摸事件的冒泡但内部子节点如果没有单独处理也都会被捕获。关闭操作只重置activeId不影响子节点的 click 事件所以这里是安全的。如果列表项内部还有按钮需要给按钮单独加上catchtap避免触发closeSlide导致按钮点击无效。6. 权限边界与幂等性设计这套源码里最值得复用的安全技巧6.1 二次校验“不可自增积分”的防守逻辑源码里“女友发布任务、男友确认完成、女友收积分”这套规则表面上看是一次简单的角色判断但实际中有个隐患如果男友把自己变成发布者去发任务再让自己确认完成就能无限刷积分。所以云函数里不能只判断“点击者不能是发布者”还要确保“发布者只能是女友”const roleRes await db.collection(users).where({ _openid: task.publisher }).limit(1).get() const publisher roleRes.data[0] if (publisher.role ! girl) { return { code: -1, msg: invalid task publisher } }逻辑说明这是一层额外的校验光靠task.publisher ! OPENID是不够的。如果用户在另一台设备上篡改了本地用户角色的缓存或者利用其他云函数的漏洞创建了role: girl的用户那这层校验同样会被绕过。所以更严格的做法是在用户注册时role只能由管理端写入前端永远不能传role字段。数据库的集合权限里也可以设置“用户集合不可由客户端写入”只允许云函数读写。6.2 防止重复提交的幂等控制任务确认完成、商品购买、物品使用这三个操作都是高频且不可逆的重复提交会造成积分翻倍或者库存为负。云函数里除了用事务保证原子性还应该在入口处做一次幂等检查。常用的办法是为每条任务和商品生成一个requestId前端提交时带上。const existing await db.collection(task_logs).where({ requestId: event.requestId }).limit(1).get() if (existing.data.length 0) { return { code: 0, msg: duplicate request ignored } }逻辑说明requestId由前端在onLoad时用Date.now() Math.random()生成一次业务操作只使用一个requestId。云函数收到请求后先查日志表如果存在就直接返回成功不再执行后续逻辑。这个方案实现成本低却能挡住绝大多数由于网络超时导致的重复点击。如果不想额外建集合也可以在users文档里维护一个lastOperateToken每次操作后更新。但这样并发时会互相覆盖不如独立日志集合可靠。事务内插入task_logs同样可行但副作用是事务体积变大执行时间变长在小程序场景下尽量只对核心状态变更用事务幂等检查放在事务外即可。6.3 上线前建议补充的数据库索引与权限配置源码本身没有把索引清单整理出来但有两个查询模式是必须建索引的否则数据量上来后会出现“操作超时”或“索引命中失败”的报错。第一个是tasks集合上的publisher status复合索引因为任务列表常用“某个人的待确认任务”作为筛选条件。第二个是inventory集合上的owner status复合索引用于查询“当前用户未使用的仓库物品”。控制台里在对应集合的“索引管理”页面添加即可。权限配置上所有集合建议设为“仅创建者可读”且关闭客户端的写权限。这样即使前端代码被反编译攻击者也拿不到users集合的写权限。云函数端统一用cloud.getWXContext().OPENID做身份标识不受前端登录态伪造影响。提示微信开发者工具里调试云函数时cloud.init建议使用cloud.DYNAMIC_CURRENT_ENV。如果你本地同时开了多个云环境硬编码环境 ID 很容易把数据写到测试环境去排查时非常浪费时间。另外这套源码的交互层有大量setData频繁更新的场景比如滑动窗口自动播放、悬浮按钮坐标变化。在低端安卓机上建议把非必要数据拆到独立的data字段里减少每次setData的数据量。对列表渲染加wx:key避免全量 diff 带来的掉帧卡顿这是小程序性能优化里性价比最高的一步。本文还有配套的精品资源点击获取