☰
装修预约小程序源码实战:留资闭环、前后端联调与上线避坑
2026/11/5 5:40:12 网站建设 项目流程

简介:面向装修行业从业者与微信小程序开发者的装修预约类小程序源码,核心解决线上展示、咨询与预约获客需求,也适合作为前后端完整项目案例学习。压缩包共231个文件,约1.95MB,以gif界面动效、js业务逻辑、wxml页面结构、wxss样式、json配置及png图片资源为主,覆盖小程序端与后端交互所需的基础文件。已有1195人浏览学习。内容围绕预约表单、时间选择、地址定位、后台审核与预约管理展开,并涉及微信支付接入、数据校验与常见安全防护思路,可帮助读者理解从页面搭建、接口联调到部署上线的完整流程,适合需要快速搭建装修预约小程序或研究其业务闭环的开发者参考。

1. 拿到装修小程序源码先别急着跑:这套预约系统到底管哪些业务

拆过十几份行业小程序源码之后,我对装修公司这个品类有个很直接的判断:能不能用,不取决于首页画得多好看,而取决于「用户看案例 → 动心咨询 → 留下手机号 → 后台有人跟进」这条链路有没有闭环。这份装修公司微信小程序源码(带装修预约系统)走的就是这条主线,前端是小程序原生页面,后端是常规的 PHP + MySQL 组合,核心模块有案例图册、设计师展示、报价试算和预约留资。

它适合两类人:接私活需要一个能改能上线的底座的,以及想练手完整业务闭环、但不想从零设计表结构的开发者。要提醒一句,这类包给的是骨架,案例图、设计师头像、公司简介这些内容得自己填。

2. 目录勘一遍:小程序端、服务端、数据库三层怎么对上号

压缩包解开后的第一件事不是双击运行,而是把三层结构在脑子里过一遍:小程序端负责渲染和交互,服务端负责接口和鉴权,数据库负责把预约单和内容条目存下来。三层如果没有对齐,后面调任何一个接口都会变成猜谜。这一章按我拆包的顺序展开,你可以对着自己手里的目录同步核对。

2.1 小程序端只该管三件事:渲染、校验、发起请求

小程序端不要塞业务规则。我见过一些包把预算计算公式、折扣逻辑全写在前端 JS 里,结果是后端接口一改、前端得跟着重新提审发版,而且懂点技术的用户抓个包就能改价。合格的写法是:pages按页面分目录,每个目录里.js管逻辑、.wxml管结构、.wxss管样式、.json管页面级配置;utils放请求封装和格式化函数;components放可复用的卡片、空态、加载中。

判断一个小程序端写得好不好,看两个地方就够了:请求有没有统一出口,页面里有没有直接硬编码后端地址。统一出口意味着换域名只改一行;硬编码意味着上线前要全项目搜索替换,漏一个就是线上 404。

// utils/request.js —— 全项目唯一的请求出口,换域名只动 BASE_URL const BASE_URL = 'https://api.yourdomain.com'; // 必须 https,且在小程序后台 request 合法域名里登记过 function request(options) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', // 登录态统一从 storage 取,未登录时是空串,服务端据此判断匿名访问 'Authorization': wx.getStorageSync('token') || '' }, success(res) { // 约定服务端统一返回 { code, msg, data },code 为 0 才算成功 if (res.data && res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: (res.data && res.data.msg) || '请求失败', icon: 'none' }); reject(res.data); } }, fail(err) { // 网络层失败(超时、断网、域名未配置)走这里,和业务失败要区分开 wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' }); reject(err); } }); }); } module.exports = { request, BASE_URL };

这段代码的逻辑很直白:把wx.request的回调式 API 包成 Promise,让页面里可以写await;把「成功」的判定标准收敛成code === 0一个条件,避免每个页面自己写一遍if (res.data.status === 1 || res.data.code === 200)这种兼容老代码的补丁。参数上要注意两点:BASE_URL结尾不要带斜杠,options.url开头要带斜杠,两者拼起来才是完整地址;header里的Authorization不是微信官方字段,是这套包自己的约定,服务端要按同名 key 取。

2.2 从接口路径反推数据表:三张核心表先摸清

服务端目录里通常有一个controller或api目录,文件名基本就是业务域的缩写。我会先把所有接口路径抄到一张纸上,再按前缀归堆,一般会收敛成三类:内容类(案例、设计师、公司介绍)、计算类(报价试算)、留资类(预约提交、状态查询)。内容类读多写少,留资类写多读少,这两者的索引策略完全不同。

装修预约系统里最核心的是预约表。很多人拿到包之后直接把表建好就开始跑,跑完发现同一用户提交了五条重复记录,或者后台列表时间全显示 1970 年,回头再改表结构就麻烦了。先看清楚字段类型和时间戳精度再动手。

-- 预约留资表:一行对应一次用户主动提交 CREATE TABLE `appointment` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信用户标识,用于去重和状态查询', `name` varchar(32) NOT NULL COMMENT '称呼', `phone` varchar(20) NOT NULL COMMENT '手机号,前端只做格式提示,服务端必须再校验', `house_type` varchar(32) NOT NULL DEFAULT '' COMMENT '户型,如 两室一厅', `area` decimal(6,2) DEFAULT NULL COMMENT '建筑面积,单位平米,允许为空', `budget` varchar(32) NOT NULL DEFAULT '' COMMENT '预算区间,存区间字符串而不是数字', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '用户补充说明', `designer_id` int unsigned NOT NULL DEFAULT 0 COMMENT '指定设计师,0 表示不指定', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待跟进 1已联系 2已到店 3已成交 4无效', `create_time` int unsigned NOT NULL COMMENT '秒级时间戳', `update_time` int unsigned NOT NULL COMMENT '秒级时间戳', PRIMARY KEY (`id`), KEY `idx_openid` (`openid`), KEY `idx_status_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='装修预约留资表';

字段设计上有几个取舍值得说清楚。area用decimal(6,2)而不是float,因为面积要参与报价计算,浮点误差累积到最后会出现 89.9999 这种数。budget存字符串不存数字,因为装修公司的预算区间是运营可配的(比如「10-15 万」),存成两个数字字段反而让后台配置变复杂。create_time用int unsigned存秒级时间戳而不是datetime,是因为跨时区部署时datetime容易出岔子,而时间戳在前后端之间传递不需要考虑时区。idx_status_time这个联合索引是为后台列表页准备的——后台最常见的查询就是「按状态筛选,按时间倒序」。

另外两张必需的表是design_case(案例)和designer(设计师)。案例表至少要有designer_id外键、cover封面图、images图集(一般存 JSON 或逗号分隔)、status上下架状态;设计师表至少要有name、title、avatar、years(从业年限)、sort排序权重。这两张表之间的关系是一对多,一个设计师挂多个案例,详情页取数时要注意别在循环里查库。

2.3 本地跑起来的最短路径:AppID、合法域名、数据库配置三处

把项目在本机跑通,实际只需要动三个地方,其余都不用碰。第一处是小程序的project.config.json,把appid换成你自己的测试号,urlCheck可以先关掉方便本地调试;第二处是服务端的数据库配置文件,把库名、账号、密码改成本机的;第三处是请求封装里的BASE_URL,本地调试时指向你自己的内网地址。

{ "description": "本地调试配置片段,只列需要改的字段", "appid": "wx0000000000000000", "projectname": "decoration-miniapp", "setting": { "urlCheck": false, "es6": true, "minified": false, "postcss": true }, "compileType": "miniprogram" }

urlCheck: false的意思是开发工具不校验请求域名是否在小程序后台登记过,这个开关只对开发者工具有效,真机和体验版一律走真实校验,这也是后面第 5 章第一个坑的来源。es6: true让开发工具把 ES6 语法转成 ES5,前提是代码里没有用到太新的 API。minified: false在本地调试时关掉压缩,报错堆栈才能对上源码行号。

服务端这边,如果是常规的 PHP 项目,数据库配置一般在一个单独的配置文件里。改完之后用命令行连一下库,能连上再启动服务,比在浏览器里点半天报错要快。

# 先验证数据库连通性,再启动服务,省得在两个地方来回猜 mysql -h 127.0.0.1 -u dbuser -p -D decoration -e "SELECT COUNT(*) FROM appointment;" # 启动 PHP 内置服务器做本地联调(生产环境请用 nginx + php-fpm) php -S 0.0.0.0:8080 -t ./public

第一条命令的重点不是看数量,是看能不能连上、库名对不对、账号有没有权限,这三件事任何一件不对都会在接口层表现为「请求成功但返回空」。第二条命令用 PHP 内置服务器起一个本地服务,-t ./public指定入口目录,0.0.0.0让同一局域网内的手机也能访问,方便真机调试。要注意内置服务器是单进程的,并发稍微一高就排队,只适合本地验证,别拿它压测。

3. 预约主链路拆解:从案例页点击到留资落库的四个环节

这条链路是整个小程序里唯一直接影响营收的部分,其他页面做得再漂亮,留资断了就等于零。链路本身是四段:用户在案例详情页点「免费报价」或「预约量房」,跳转到表单页;表单页收集称呼、手机号、户型、面积、预算、备注;提交时前端做一次格式校验并加提交锁;服务端做参数复验、幂等控制和限流,落库后返回单号。四段里任何一段偷懒,后面都得用运营人力去补。

3.1 表单字段设计:必填项越少,转化越高

我改这类包时第一件事就是砍字段。原包常见的做法是把户型、面积、预算、风格偏好、装修时间全设成必填,看着信息很全,实际转化率很难看。装修行业的用户决策周期长,第一次接触时愿意给的只有手机号。合理的必填项只有两个:称呼和手机号;面积和户型做成选填但给默认值,预算做成区间选择(不给输入框),备注完全放开。

手机号校验有个血泪教训:很多老代码里的正则是^1[3|4|5|7|8]\d{9}$,这个写法把 16x、19x 号段全拦在外面,而这两个号段现在的用户量已经不小。正确的写法是按号段范围匹配,不做枚举。

// pages/appointment/form.js —— 预约表单的提交逻辑 const { request } = require('../../utils/request'); Page({ data: { submitting: false, // 提交锁,防止用户连点 form: { name: '', phone: '', houseType: '', area: '', budget: '', remark: '' }, budgetOptions: ['10万以下', '10-15万', '15-25万', '25万以上', '还没想好'] }, // 号段用范围匹配,13~19 全段覆盖,不做枚举 isPhone(v) { return /^1[3-9]\d{9}$/.test(v); }, onFieldInput(e) { const key = e.currentTarget.dataset.key; this.setData({ ['form.' + key]: e.detail.value }); }, onBudgetChange(e) { // picker 的 value 是下标,要转成实际文案再存 this.setData({ 'form.budget': this.data.budgetOptions[e.detail.value] }); }, async onSubmit() { if (this.data.submitting) return; // 第一道拦截:正在提交就直接返回 const f = this.data.form; if (!f.name.trim()) return wx.showToast({ title: '请填写您的称呼', icon: 'none' }); if (!this.isPhone(f.phone)) return wx.showToast({ title: '手机号格式不正确', icon: 'none' }); this.setData({ submitting: true }); try { const res = await request({ url: '/api/appointment/create', method: 'POST', data: f }); wx.showToast({ title: '提交成功,稍后联系您', icon: 'success' }); // 清空表单,避免用户点返回再进来重复提交一遍 this.setData({ form: { name: '', phone: '', houseType: '', area: '', budget: '', remark: '' } }); // 记下单号,后续在「我的预约」里查状态用 wx.setStorageSync('lastAppointmentId', res.id); } catch (e) { // 错误提示在 request 封装里已经弹过,这里只负责恢复按钮状态 } finally { this.setData({ submitting: false }); } } });

submitting这个状态位看着简单,实际是防重复提交里性价比最高的一道拦截:用户点完之后按钮立即置灰,绝大多数误触在这一层就被挡住了。><?php // 接口:POST /api/appointment/create // 职责:接收小程序提交的留资信息,复验参数 → 幂等判断 → 落库 header('Content-Type: application/json; charset=utf-8'); // 1. 取参数:全部走 trim,防止空格绕过校验 $openid = trim($_POST['openid'] ?? ''); $name = trim($_POST['name'] ?? ''); $phone = trim($_POST['phone'] ?? ''); $houseType = trim($_POST['house_type'] ?? ''); $area = isset($_POST['area']) && $_POST['area'] !== '' ? (float)$_POST['area'] : 0; $budget = trim($_POST['budget'] ?? ''); $remark = mb_substr(trim($_POST['remark'] ?? ''), 0, 200); // 备注截断到 200 字,防超长写入 // 2. 参数复验:手机号用同一套正则,area 做合理区间约束 if ($openid === '' || $name === '' || !preg_match('/^1[3-9]\d{9}$/', $phone)) { echo json_encode(['code' => 1001, 'msg' => '参数不完整或手机号格式不正确']); exit; } if ($area < 0 || $area > 2000) { echo json_encode(['code' => 1002, 'msg' => '面积填写有误']); exit; } try { $pdo = new PDO('mysql:host=127.0.0.1;dbname=decoration;charset=utf8mb4', 'dbuser', 'dbpass', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常,禁止静默失败 ]); // 3. 幂等:同一 openid 在 60 秒内只允许提交一次,防连点和脚本刷 $check = $pdo->prepare('SELECT id FROM appointment WHERE openid = ? AND create_time > ? LIMIT 1'); $check->execute([$openid, time() - 60]); if ($check->fetch()) { echo json_encode(['code' => 0, 'msg' => '已提交,请勿重复操作']); exit; } // 4. 落库:status 固定从 0 开始,由后台推进 $now = time(); $stmt = $pdo->prepare( 'INSERT INTO appointment (openid,name,phone,house_type,area,budget,remark,designer_id,status,create_time,update_time) VALUES (?,?,?,?,?,?,?,0,0,?,?)' ); $stmt->execute([$openid, $name, $phone, $houseType, $area, $budget, $remark, $now, $now]); echo json_encode(['code' => 0, 'msg' => 'ok', 'data' => ['id' => (int)$pdo->lastInsertId()]]); } catch (PDOException $e) { // 生产环境不要直接把异常信息返回给前端,写日志即可 error_log('[appointment] ' . $e->getMessage()); echo json_encode(['code' => 500, 'msg' => '服务繁忙,请稍后再试']); }

逻辑上分四段,每段都有明确的失败出口。参数复验里area的上下界是拍脑袋定的,但必须有——没有上下界,一个恶意请求就能把报价页算崩。幂等判断用的是「查最近 60 秒有没有同 openid 记录」,比用 Redis 或文件锁简单,代价是高并发下可能漏判(两个请求同时查到没记录),但装修预约这种量级完全够用。mb_substr截断备注而不是靠数据库字段长度兜底,是因为超长写入在严格模式下会直接抛异常,用户看到的是「服务繁忙」,而截断之后至少能存进去。

参数说明:openid不是前端传什么就信什么,生产环境应该由服务端通过wx.login换取的session_key解密得到,这里简化成前端透传,属于这类源码包的常见妥协,上线前要改。designer_id固定写 0,是因为预约表单里没有让用户选设计师,如果后续从设计师详情页跳转过来,可以加一个designer_id参数带过来。

3.3 后台状态流转:五个状态的迁移条件

预约单落库之后,真正的运营动作从后台开始。状态设计成五个:待跟进、已联系、已到店、已成交、无效。这五个状态不是随便排的,它们对应装修公司的实际销售漏斗,每一档的转化率都是老板要看的数字。

迁移规则要卡死,不能允许后台随便跳。待跟进只能到已联系或无效;已联系只能到已到店或无效;已到店只能到已成交或无效;已成交和无效是终态。这个约束放在服务端做,不要放在前端下拉框里——前端下拉框只能限制展示,接口被直接调用时还是会绕过。

后台列表页的查询也有讲究。默认视图应该是「待跟进 + 按时间正序」,因为这是销售每天上班第一眼要看的东西;筛选视图才是按状态、按时间范围、按设计师查。很多包默认按时间倒序,结果最老的待跟进单被压到最后一页,销售根本看不到。

4. 三个高频模块的联调:案例图册、设计师名片、报价试算

这三个模块的技术含量不算高,但联调阶段最耗时间。原因不在代码难,在于它们都涉及「列表 + 详情 + 图片」的组合,而图片加载和关联查询这两个东西出问题时,报错信息往往指向不到真正的原因。下面按我踩过的顺序展开。

4.1 案例图册:图片体积是首屏加载的第一杀手

案例列表页是整个小程序里最吃流量的页面。运营上传的图基本是相机直出,单张 3MB 起步,一个列表页六张图就是 18MB,用户在 4G 下等七八秒,直接退出。这个问题在开发者工具的模拟网络环境下看不出来,因为开发工具默认不限速,只有真机才暴露。

解决分两层:上传层和展示层。上传层在服务端做压缩,运营传什么进来都转成宽 750 以内、质量 80 的 JPEG;展示层在小程序端用固定高度加aspectFill,避免图片加载前后布局抖动。

// pages/case/list.wxml 片段 —— 固定高度容器 + 裁切填充,防止布局抖动 // <view class="case-item" wx:for="{{list}}" wx:key="id"> // <image class="case-cover" src="{{item.cover}}" mode="aspectFill" lazy-load></image> // <view class="case-title">{{item.title}}</view> // </view> // pages/case/list.js —— 分页加载,触底追加 Page({ data: { list: [], page: 1, size: 10, loading: false, noMore: false }, onLoad() { this.loadList(); }, onReachBottom() { if (this.data.loading || this.data.noMore) return; this.loadList(); }, async loadList() { this.setData({ loading: true }); try { const res = await request({ url: '/api/case/list', data: { page: this.data.page, size: this.data.size } }); this.setData({ list: this.data.list.concat(res.items), page: this.data.page + 1, noMore: res.items.length < this.data.size // 返回条数不足一页,说明到底了 }); } finally { this.setData({ loading: false }); } } });

mode="aspectFill"配合 CSS 里写死的.case-cover { width: 100%; height: 400rpx; },是解决布局抖动的标准组合:容器尺寸在图片加载前就已经确定,图片是裁切填充进去的,不会出现「先塌下去再撑开」的跳动。lazy-load让视口外的图片延迟加载,首屏只加载能看见的两三张。分页里noMore的判断用的是「返回条数小于页大小」,而不是「总数小于已加载数」,因为后者需要服务端额外返回总数,多一次 count 查询。

服务端的压缩可以用 GD 或 Imagick,思路是读原图、按宽度等比缩放、以 80 的质量重新编码。这一步不要在前端做,wx.compressImage只能压缩用户当场选的图,对后台上传的图无效。

4.2 设计师名片:一对多关联怎么查不拖库

设计师列表页要显示头像、姓名、职称、从业年限,还要显示「旗下 N 个案例」。新手最容易写出的代码是先查设计师列表,然后foreach里循环查每个设计师的案例数——十个人就是十一条 SQL,二十个人就是二十一条。这个写法的学名叫 N+1 查询,用户量小的时候看不出来,案例一多就明显卡顿。

正确做法是一次查询用LEFT JOIN加GROUP BY带出来。

-- 设计师列表 + 各自已上架案例数,一条 SQL 出结果 SELECT d.id, d.name, d.title, d.avatar, d.years, COUNT(c.id) AS case_count FROM designer d LEFT JOIN design_case c ON c.designer_id = d.id AND c.status = 1 -- 只统计已上架案例 WHERE d.status = 1 -- 只查在职设计师 GROUP BY d.id ORDER BY d.sort DESC, d.id ASC LIMIT 0, 10;

这里的关键是把c.status = 1放在ON里而不是WHERE里。放在ON里,没有上架案例的设计师依然会出现,case_count为 0;放在WHERE里,这些设计师会被直接过滤掉,列表页就少人了。这是LEFT JOIN最经典的坑,很多人第一次踩的时候会以为数据丢了。GROUP BY d.id在 MySQL 的ONLY_FULL_GROUP_BY模式下,SELECT里的其他字段必须函数依赖于d.id,因为id是主键所以没问题,但如果换成按name分组就会报错。

详情页取数是另一套逻辑:先按id查设计师,再按designer_id查该设计师的案例列表,两次查询是合理的,因为详情页的案例列表要分页,合并到一条 SQL 里反而不好做分页。接口返回结构上,详情接口返回{ designer: {...}, cases: [...] }而不是把两者拍平成一个对象,前端取值时层级更清晰。

4.3 报价试算:公式放后端还是前端

报价试算是这类小程序里唯一涉及「算钱」的地方,也是争议最大的地方。放前端,用户输入面积就能实时出结果,体验流畅;放后端,每次调价都要走一次网络请求,但价格逻辑不怕被改。我的建议是:展示放前端,成交价以后端为准。

具体做法是前端放一份「参考单价表」,用户滑动面积时本地算出一个区间,这个区间只做展示用途,详情页下方明确写「实际报价以现场量房为准」;用户点击「获取详细报价」时,把面积和档次传给后端,后端按自己的单价表重新算,返回结果并附上报价单编号。这样用户感受到的是即时反馈,公司拿到的是可控价格。

// utils/quote.js —— 参考报价,只用于列表页和表单页的即时展示 // unit 单位:元/平米,数组两个值分别是低档和高档 const UNIT_PRICE = { 1: [600, 900], // 基础装修 2: [1000, 1500], // 中档 3: [1800, 2600] // 高档 }; function quoteRange(area, level) { const unit = UNIT_PRICE[level] || UNIT_PRICE[1]; // 取整到百位,避免出现 89643 这种看起来精确、实际拍脑袋的数字 const low = Math.round(area * unit[0] / 100) * 100; const high = Math.round(area * unit[1] / 100) * 100; return { low, high }; } module.exports = { quoteRange };

取整到百位这一步不是技术需要,是业务需要。算出89643 元会让用户觉得这个数很准,然后拿着这个数去跟线下门店砍价,而门店的实际报价受材料、楼层、拆改量影响,根本对不上,最后扯皮。算成89600 - 134500 元这种区间,用户心理预期就是「一个范围」,反而更好谈。UNIT_PRICE里的数字是占位值,实际要按当地市场行情改,改的时候只改这一个对象,别散落在页面里。

后端的报价接口要记录每一次试算(面积、档次、结果、时间、openid),这些数据攒起来就是后续调整单价的依据。很多人不做这一步,导致运营了半年都不知道用户主要集中在哪个面积段、哪个档次。

5. 避坑与排查:这套装修小程序最容易翻车的五处

下面这五条都是上线前后真实会遇到的问题,按「现象 → 原因 → 解决」写,遇到的时候可以直接对号入座。

5.1 配置与环境类:三个最容易被忽略的开关

第一条:开发工具里一切正常,真机体验版打开白屏。

现象:开发者工具里点「预览」扫码能打开,发到体验版之后白屏,控制台报request:fail url not in domain list。原因是开发工具里勾了「不校验合法域名」,而体验版和正式版走的是真实校验,请求域名不在白名单里就直接被拦。解决办法是去小程序后台的「开发管理 → 开发设置 → 服务器域名」里,把https://api.yourdomain.com加进 request 合法域名。三个硬性条件要同时满足:必须是 https、域名必须已备案、不能带端口号。改完之后不是立刻生效,一般要等几分钟,别急着反复提交。

第二条:后台把预约状态改成「已联系」,用户端看到的还是「待跟进」。

现象:销售在后台操作了状态,用户在小程序「我的预约」里刷新,状态没变。原因是用户端的状态查询接口要么没写,要么写成了只查一次不刷新。这类包常见做法是只做了提交接口,没做查询接口,用户端展示的是提交时缓存的本地状态。解决分两步:服务端补一个按openid查最新预约单的接口,前端在onShow里重新拉一次,不要只在onLoad里拉。onShow在页面每次显示时都会触发,包括从后台切回前台,比onLoad覆盖的场景多。

第三条:后台列表里所有预约时间都显示 1970-01-01。

现象:数据库里create_time存的是1730000000这种秒级时间戳,前端渲染出来是 1970 年。原因是前端拿字符串直接new Date('1730000000'),JS 会把它当毫秒解析,而 17 亿毫秒只有 20 天,再从 1970 起算就错得离谱。解决是统一约定:接口返回时间时要么返回格式化好的2024-10-27 15:33:20字符串,要么返回秒级数字并在前端明确new Date(ts * 1000)。我一般选前者,因为格式化放在服务端只做一次,前端各个页面不用各写一遍。

5.2 业务与数据类:两个上线后才爆发的问题

第四条:同一个人提交了五条预约,销售打电话过去被骂骚扰。

现象:后台出现同一手机号的重复记录,时间间隔几秒到几十秒。原因是前端提交锁只在单次页面生命周期内有效,用户退出再进、或者网络慢时连点,锁就失效了。后端如果也没做幂等,就会写进去多条。解决要前后端一起做:前端按钮点击后立即置灰(第 3.1 节的submitting),后端按openid加时间窗查重(第 3.2 节的 60 秒判断)。如果用户是匿名提交(没走登录),openid为空,就把查重条件换成phone + create_time。

第五条:用户上传户型图报 413,安卓机尤其明显。

现象:选完图片点上传,接口返回 413 Request Entity Too Large。原因是 nginx 的client_max_body_size默认是 1M,而现在手机拍的照片单张就 3-5M。解决是把这个值调到 10M,同时小程序端上传前先用wx.compressImage压一道。

# nginx 配置片段:放在 http 或 server 块里 client_max_body_size 10m; client_body_temp_path /var/cache/nginx/client_temp; # 改完必须 reload,直接 restart 会中断现有连接 nginx -t && nginx -s reload

nginx -t是先检查配置语法,语法不过直接 reload 会把服务搞挂,这个顺序不能反。client_max_body_size调大之后,client_body_temp_path对应的磁盘分区要有足够空间,因为大文件会先落到临时目录再交给 PHP 处理。小程序端的压缩用wx.compressImage({ src, quality: 70 }),压缩完再走wx.uploadFile,两步之间要await,不能并行。

6. 上线前跑一遍验证清单:我自己用的一套自检动作

这套源码能不能直接交付,我一般不看代码写得多漂亮,而是按下面这张表从头到尾走一遍。表里每一项都对应一个具体的失败后果,不是走过场。

检查项具体动作不通过时的后果
域名配置用真机体验版打开,点一次预约提交白屏或请求被拦,功能完全不可用
手机号号段用 16x、19x 号段各测一次部分用户无法提交,转化直接损失
重复提交快速连点提交按钮五次后台出现五条重复单,销售重复骚扰
图片体积后台上传一张 5MB 原图,看列表加载首屏加载超过 5 秒,用户流失
时间显示提交一单,看后台列表和用户端显示 1970 年,运营无法判断时效
状态流转后台把单子从待跟进推到已成交用户端状态不同步,信任度下降
空态处理清空数据库,看各列表页页面空白无提示,用户以为坏了

这张表里最容易被跳过的是「空态处理」,因为开发阶段数据库里总有测试数据,看不出来。清空数据之后,案例列表、设计师列表、我的预约这三个页面如果没有空态文案,用户看到的就是一片白,会以为小程序崩了。补空态不需要写复杂逻辑,在wx:if判断列表长度为 0 时渲染一段提示文案加一个「去看看案例」的按钮就行。

二次开发时,我的优先级排序是:先改品牌层(配色、logo、公司介绍),再改业务层(预约字段、报价单价表、状态流转规则),最后才碰架构层。架构层包括请求封装、鉴权、表结构,动这三样的风险最高,收益也最不明显。很多人拿到包第一件事就是想换掉后端框架,觉得 PHP 太老,换完之后发现前端所有接口都要跟着改一遍,工期翻三倍。

有一个我自己反复用的技巧:把appointment表按status做一次分组统计,看每个状态的占比。如果「待跟进」长期占 80% 以上,说明销售根本没在跟,问题不在小程序而在流程;如果「已联系」到「已到店」的转化极低,说明留资质量有问题,要么是表单引导词写得不对,要么是案例图跟目标客群不匹配。这个数字比任何页面的点击率都更能说明这套系统到底有没有在干活。

从那以后我每次拿到这类行业小程序源码,都强制自己先跑一遍预约链路再动任何一行代码——不是因为链路复杂,而是因为它一旦断了,后面所有优化都是在给一个漏水的桶刷漆。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询