开头
农业数字化喊了很多年,真正落到田埂上的工具却不多。大部分农户手机里装得最多的还是微信,其次是短视频,真正能帮他们记录农事、查到靠谱农技知识的应用少之又少。我做过一个“小程序 + ThinkPHP”的农事管理及农业技术知识科普系统,覆盖农事记录、农时提醒、知识库、问答互动这几个核心场景,前后端加起来开发周期大约一个半月。这篇文章把整个项目的设计思路、数据库结构、接口实现、小程序端交互和部署上线完整梳理一遍,项目不算大,但五脏俱全,从表结构到接口安全、从页面交互到数据统计都走了一遍。如果你是做农业类应用的开发者,或者正准备用 ThinkPHP 给小程序做后端,这篇应该能帮你省掉不少弯路。
1. 项目整体设计思路
1.1 为什么选小程序 + ThinkPHP 这套组合
技术选型这事,有时候不是越新越好,而是越合适越好。农业类应用的使用场景很特殊:用户群体是农户、基层技术员、农业院校学生,大家用的手机配置参差不齐,网络环境也未必稳定,很多人在田间地头打开应用,信号可能只有一格。
小程序最大的优势是免安装,微信扫一扫就能打开,用完即走,不占用手机内存。对农户来说,让他们专门去应用商店下载一个 App 的门槛远比想象中高。小程序里分享也方便,看到一个病虫害防治的文章,随手转发到微信群,传播链条很短,这就是农业科普最需要的裂变方式。
后端选 ThinkPHP,核心考虑有几点。第一,团队或者个人开发者对 PHP 的熟悉程度普遍高于其他语言,ThinkPHP 的文档在国内开发者社区里算很友好的,遇到问题搜索解决方案的成本低。第二,ThinkPHP 的部署成本低,一个普通的云服务器加 Nginx 就能跑,不需要折腾复杂的容器编排,对于中小型项目来说非常实用。第三,TP6/TP8 的架构已经相当规范,中间件、验证器、模型关联这些机制都很成熟,完全能支撑一个小程序的完整业务闭环。
我没有选择前后端分离的微服务架构,也没有上消息队列、Redis 缓存这一套重型组件,因为项目体量决定了复杂度。农业类系统的访问量特点是大批次、低并发,农忙时节大家集中用,平时访问量并不高。与其为了架构焦虑而引入一堆中间件,不如把核心业务逻辑写扎实,把数据库设计好,这才是这个项目真正需要投入精力的地方。
1.2 功能模块怎么拆,先做什么后做什么
功能拆解是项目启动前最重要的一步。我参照使用频率和价值优先级,把整个系统拆成了四个模块:
- 基础用户体系:小程序端微信登录、用户信息维护
- 农事管理模块:农事记录的增删改查、农事日历展示、农时提醒
- 知识科普模块:农业技术文章的分类浏览、搜索、收藏、分享
- 互动问答模块:农户提问、后台专家回答(MVP 阶段可以简化)
模块拆完之后,开发顺序也很关键。我建议先把农事管理模块做扎实,因为这是整个系统里数据价值最高的部分。农户每天在地里做了什么,用了什么农药、施了什么肥、天气如何、产量如何,这些数据一旦沉淀下来,就是最真实的农业生产档案,后续的统计分析、农时建议、产量预测全都依赖这批数据。
知识科普模块放在第二个开发。原因是内容制作需要时间,可以先搭好内容管理后台,然后慢慢填充文章。内容侧可以先接一些公开的农业百科数据做冷启动,再逐步转成原创和专家投稿。
问答模块我放在了 MVP 的末尾,原因是它需要内容运营的支撑。没有专人维护问答,农户提问没人回答,反而伤害活跃度。MVP 阶段可以只做简单的留资功能,等技术体系稳定了再开放完整问答。
2. 数据库设计与核心模型
2.1 农事记录表怎么建才能撑起后续统计
数据库设计是整个项目的地基。农事记录表我改了三版,最终结构长这样:
CREATE TABLE `farm_record` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) unsigned NOT NULL COMMENT '用户ID', `crop_type` varchar(50) NOT NULL COMMENT '作物类型', `work_type` tinyint(4) NOT NULL COMMENT '农事类型 1播种 2施肥 3灌溉 4喷药 5收获 6其他', `work_date` date NOT NULL COMMENT '农事日期', `area` decimal(10,2) DEFAULT NULL COMMENT '作业面积(亩)', `crop_variety` varchar(100) DEFAULT NULL COMMENT '品种/品系', `fertilizer_type` varchar(100) DEFAULT NULL COMMENT '肥料/农药名称', `dosage` varchar(50) DEFAULT NULL COMMENT '用量', `weather` varchar(20) DEFAULT NULL COMMENT '天气情况', `remark` text COMMENT '备注', `photos` json DEFAULT NULL COMMENT '现场照片JSON数组', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`, `work_date`), KEY `idx_crop_type` (`crop_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;有几个设计细节值得重点说。
第一,work_type用tinyint而不是直接用字符串。农事类型是高度稳定的枚举值,播种、施肥、灌溉、喷药、收获,用数字存储配合字典表解释,查询、统计、索引效率都更高。这里加了一个字段work_type对应“其他”,原因是我发现农户的记录习惯比预想中多样,比如“松土”“除草”“整枝”这种操作,如果强行归类到五个枚举里反而失真,干脆留一个兜底选项。
第二,肥料和农药我没有单独建表存,而是直接在记录表里用fertilizer_type和dosage字段,用文本记录。原因是农药化肥的产品名极其碎片化,同一个“草铵膦”在不同地区、不同品牌下叫法不一,强行建标准化表反而增加录入成本。对这种动态的文本信息,直接存字符串更务实。以后要做农资分析的时候,再用关键词匹配的方式去做模糊归类。
第三,photos字段用 JSON 类型。农事记录需要支持现场拍照,比如病虫害的照片、施肥后的长势照片。用 JSON 存数组可以避免单独建子表,读取的时候一次性取出来给前端用。MySQL 5.7 以上对 JSON 的支持已经非常成熟,不用担心性能问题。
第四,索引策略上,user_id + work_date的联合索引是核心。农事日历的展示逻辑就是“查某用户在某个时间范围内有哪些记录”,这个联合索引可以精准覆盖。crop_type单独加索引是因为后续统计“某作物全年投入产出”时需要按作物类型查询聚合,这个查询频率也不低。
2.2 科普内容体系:分类、文章、收藏与问答
科普内容模块的数据结构相对常规,但有几个点在实际开发中踩过坑,值得展开说。
文章表的核心字段:
CREATE TABLE `article` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL COMMENT '分类ID', `title` varchar(200) NOT NULL COMMENT '标题', `cover` varchar(500) DEFAULT NULL COMMENT '封面图', `summary` varchar(500) DEFAULT NULL COMMENT '摘要', `content` longtext COMMENT '正文(Markdown)', `source_type` tinyint(4) DEFAULT 1 COMMENT '来源 1原创 2转载 3AI辅助整理', `view_count` int(11) DEFAULT 0 COMMENT '浏览量', `like_count` int(11) DEFAULT 0 COMMENT '点赞量', `status` tinyint(4) DEFAULT 1 COMMENT '1发布 0草稿', `posted_at` datetime DEFAULT NULL COMMENT '发布时间', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`, `posted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里特别想提醒的是content字段的选择。科普文章动辄几千字,包含大量图文说明,用longtext存储非常合适。但要注意,如果文章内容包含较多图片,建议在后台编辑器里把图片单独传到对象存储(如 COS 或 OSS),正文里只存图片的 URL,不要把图片 base64 直接塞进数据库,否则数据库体积会急剧膨胀,接口响应也会变慢。
文章分类表我用了简单的父子级结构:
CREATE TABLE `article_category` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `pid` int(11) NOT NULL DEFAULT 0 COMMENT '父分类ID,0为顶级', `name` varchar(50) NOT NULL COMMENT '分类名称', `sort_order` int(11) NOT NULL DEFAULT 0 COMMENT '排序权重', PRIMARY KEY (`id`) );农业知识分类我建议分两级,一级是作物大类(粮食作物、经济作物、蔬菜、果树、中药材),二级是技术方向(育种育苗、水肥管理、病虫害防治、采收储运)。用户浏览时,先按作物筛选,再按技术方向筛选,这个路径最符合农户的找知识习惯。
收藏表和问答表结构比较简单,收藏表是user_id + article_id的唯一索引,问答表是user_id + question_content + answer_content + status。问答MVP阶段我没做留言式互动,而是做成了一问一答的工单式,农户提交问题,后台运营人员延后回复,这样在内容团队还没建起来的时候也能维持基本运转。
3. 服务端接口开发实操(ThinkPHP)
3.1 登录与权限:小程序登录态怎么搞
小程序登录在很多初学者看来是最容易绕晕的部分。其实逻辑理顺就三步:小程序端调用微信的wx.login()获取临时code,发送到后端;后端拿到code,请求微信的接口换取openid和session_key;后端用openid去数据库查找或创建用户,并返回一个自定义的登录凭证。
下面说下我在 ThinkPHP 中的具体实现。
首先配置.env文件,存放小程序的 AppID 和 AppSecret:
WX_APPID=你的小程序appid WX_APPSECRET=你的小程序appsecret然后写一个登录接口:
<?php namespace app\api\controller; use think\facade\Db; use think\facade\Cache; use think\facade\Validate; class User { public function login() { $code = input('post.code'); if (!$code) { return json(['code' => 1, 'msg' => '缺少code参数']); } // 请求微信接口换取 openid 和 session_key $appid = env('wx.appid'); $secret = env('wx.appsecret'); $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $res = file_get_contents($url); $data = json_decode($res, true); if (isset($data['errcode']) && $data['errcode'] != 0) { return json(['code' => 1, 'msg' => '微信登录失败']); } $openid = $data['openid']; $user = Db::name('user')->where('openid', $openid)->find(); if (!$user) { $userId = Db::name('user')->insertGetId([ 'openid' => $openid, 'nickname' => '农户_' . random_int(10000, 99999), 'avatar' => '', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } else { $userId = $user['id']; Db::name('user')->where('id', $userId)->update(['updated_at' => date('Y-m-d H:i:s')]); } // 生成自定义 token,有效期 7 天 $token = md5($openid . time() . random_int(1000, 9999)); Cache::set('token_' . $token, $userId, 604800); return json(['code' => 0, 'data' => ['token' => $token, 'user_id' => $userId]]); } }这里说几个实际开发中容易踩的坑。
第一个是请求微信接口的方式。很多教程推荐用 Guzzle 这类 HTTP 客户端库,但如果你只是一个轻量级接口调用,PHP 原生的file_get_contents完全够了,没必要为了一个请求引入一个库。当然,如果你在框架里已经安装过 Guzzle,用它也可以,逻辑上没有本质区别。前提是服务器必须能正常访问微信的接口域名,这需要你的服务器有外网访问权限。
第二个是 token 的生成策略。不要直接拿微信返回的session_key当作后端登录凭证返回给小程序,session_key在微信侧的换发机制不受你控制,有效期不稳定,所以服务端自己要生成一份独立的登录凭证。我用md5($openid . time() . random_int())是为了保证每次登录生成的 token 不同,后续如果要做多点登录踢出,也可以在这个 token 逻辑上扩展。
第三个是用户表里额外维护nickname和avatar。现在微信已经调整了用户信息获取规则,小程序不能直接拿到用户的昵称和头像,需要用户主动填写或使用微信头像昵称填写能力。MVP 阶段我直接用默认昵称,等用户进入个人中心后引导他们自行修改,这样不阻塞核心流程。
3.2 农事记录的核心接口:创建、列表、统计
农事记录的创建接口是整个系统里逻辑最重的部分。表单字段多,还要处理图片上传,在写接口之前需要把表单校验做扎实。
ThinkPHP 的验证器很好用,推荐在控制器里先验证、后入库。以下是核心代码:
<?php namespace app\api\controller; use think\facade\Db; use think\facade\Validate; class FarmRecord { public function create() { $userId = get_user_id_from_token(); // 登录中间件注入的user_id $data = input('post.'); $data['user_id'] = $userId; $validate = Validate::rule([ 'crop_type' => 'require|max:50', 'work_type' => 'require|in:1,2,3,4,5,6', 'work_date' => 'require|date', 'area' => 'float|egt:0', 'dosage' => 'max:50', 'remark' => 'max:500', 'photos' => 'array', ])->message([ 'crop_type.require' => '请选择作物类型', 'work_type.require' => '请选择农事类型', 'work_type.in' => '农事类型不合法', 'work_date.require' => '请选择农事日期', 'work_date.date' => '日期格式不正确', ]); if (!$validate->check($data)) { return json(['code' => 1, 'msg' => $validate->getError()]); } $insertData = [ 'user_id' => $userId, 'crop_type' => $data['crop_type'], 'work_type' => $data['work_type'], 'work_date' => $data['work_date'], 'area' => $data['area'] ?? null, 'crop_variety' => $data['crop_variety'] ?? '', 'fertilizer_type' => $data['fertilizer_type'] ?? '', 'dosage' => $data['dosage'] ?? '', 'weather' => $data['weather'] ?? '', 'remark' => $data['remark'] ?? '', 'photos' => json_encode($data['photos'] ?? []), 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; $id = Db::name('farm_record')->insertGetId($insertData); return json(['code' => 0, 'data' => ['id' => $id]]); } }这里特别强调几个字段的校验细节。
work_type用in:1,2,3,4,5,6做枚举校验,可以防止恶意提交无效数据。所有文本字段都加了最大长度限制,remark最长 500 字。work_date用date规则校验,避免用户传进来乱七八糟的格式。
photos字段我在接收端强制转成了数组格式。小程序端上传图片时,可以选择先上传到服务端拿 URL,再提交表单;也可以直接把临时文件路径传给后端,由后端统一处理。我推荐前者,因为图片上传本身耗时,先上传拿 URL 后提交表单,可以做出更好的交互反馈。
列表接口的设计要符合小程序端的分页习惯:
public function list() { $userId = get_user_id_from_token(); $page = input('get.page', 1); $limit = input('get.limit', 20); $month = input('get.month', ''); // YYYY-MM $query = Db::name('farm_record')->where('user_id', $userId); if ($month) { $start = $month . '-01'; $end = date('Y-m-t', strtotime($start)) . ' 23:59:59'; $query->whereBetweenTime('work_date', $start, $end); } $list = $query->order('work_date desc') ->page($page, $limit) ->select()->toArray(); $total = $query->count(); return json([ 'code' => 0, 'data' => [ 'list' => $list, 'total' => $total, 'page' => $page, 'has_more' => $page * $limit < $total, ] ]); }统计接口是农事管理模块的一个亮点功能。用户给每种作物建一个档案,年底可以看他这一年在这块地上到底投入了多少工时、用了多少肥、打了多少药。统计逻辑可以用一条带条件聚合的 SQL 完成:
public function statistics() { $userId = get_user_id_from_token(); $year = input('get.year', date('Y')); $stats = Db::name('farm_record') ->where('user_id', $userId) ->whereYear('work_date', $year) ->field("crop_type, work_type, COUNT(*) as count, SUM(area) as total_area") ->group('crop_type, work_type') ->select()->toArray(); return json(['code' => 0, 'data' => $stats]); }注意whereYear这个方法在 ThinkPHP 中默认按created_at处理,如果我要按work_date筛选,需要写作->whereYear('work_date', $year),框架会自动识别。这里实际处理时我常写成where('work_date', 'between', [$year . '-01-01', $year . '-12-31']),兼容性更好,不容易踩版本坑。
3.3 科普内容的接口设计:列表、详情、搜索
科普内容的接口设计原则是:读多写少,尽量把查询条件一次给全。文章列表接口需要支持按分类筛选、按关键词搜索、按热度排序:
public function articleList() { $categoryId = input('get.category_id', 0); $keyword = input('get.keyword', ''); $page = input('get.page', 1); $limit = input('get.limit', 10); $order = input('get.order', 'new'); // new 最新 / hot 热门 $query = Db::name('article') ->where('status', 1) ->where('posted_at', '<=', date('Y-m-d H:i:s')); if ($categoryId > 0) { $query->where('category_id', $categoryId); } if ($keyword) { $query->whereLike('title', '%' . $keyword . '%'); } if ($order === 'hot') { $query->order('view_count', 'desc'); } else { $query->order('posted_at', 'desc'); } $list = $query->field('id, title, cover, summary, view_count, like_count, posted_at') ->page($page, $limit) ->select()->toArray(); $total = $query->count(); return json([ 'code' => 0, 'data' => [ 'list' => $list, 'total' => $total, 'has_more' => $page * $limit < $total, ] ]); }列表接口不要返回文章正文,只返回标题、封面、摘要、浏览量这些字段。原因有两个:列表页只需要展示标题和摘要,返回完整正文会大幅增加传输数据量;文章正文往往是几千字的 Markdown,列表接口每次都拉过来,移动端流量浪费很严重。详情接口里再返回完整内容:
public function articleDetail() { $id = input('get.id', 0); if (!$id) { return json(['code' => 1, 'msg' => '参数错误']); } Db::name('article')->where('id', $id)->inc('view_count')->update(); $article = Db::name('article') ->where('id', $id) ->where('status', 1) ->find(); if (!$article) { return json(['code' => 1, 'msg' => '文章不存在']); } // 获取相关文章(同分类下) $related = Db::name('article') ->where('category_id', $article['category_id']) ->where('id', '<>', $id) ->where('status', 1) ->field('id,title') ->limit(5) ->select()->toArray(); return json([ 'code' => 0, 'data' => [ 'article' => $article, 'related' => $related, ] ]); }关于“浏览量 +1”这个问题,直接用inc('view_count')->update()在单机低并发场景下完全够用。如果以后访问量大了,可以把浏览量改成 Redis 计数器异步更新,但现在真的没必要,用数据库原生自增简单可靠。
4. 小程序端页面与交互实现
4.1 首页与农事日历组件
小程序端我用了原生开发,没有引入 uni-app 或者 Taro 等跨端框架。原因很简单:项目只需要跑微信小程序,原生框架的性能其实最好,而且不用担心框架适配带来的额外问题。
首页的布局参考了很多农业 App 的常见设计,从上到下分为四块:
- 顶部导航:显示当前节气、天气(可接入天气 API)
- 核心功能区:快捷录入农事、查看农事日历、进入知识库
- 农事提醒卡片:根据后端计算的农时提醒,展示最近需要做的农事操作
- 科普文章推荐流:按浏览量推荐 5 篇热门文章
农事日历组件是整个小程序端开发中比较费心思的部分。需求是:用户按月查看日历,每个日期下方显示当天的农事类型小图标。比如 6 月 1 日有 1 条“喷药”记录,就在 1 号下面显示一个绿色小圆点。
实现方式是小程序端用picker的mode="date"做日期选择,日历网格自己用 CSS 画:
<view class="calendar-grid"> <block wx:for="{{days}}" wx:key="index"> <view class="day-item {{item.date === today ? 'active' : ''}}"> <text class="day-num">{{item.day}}</text> <view class="work-dots"> <text wx:if="{{item.workCount > 0}}" class="dot dot-green"></text> </view> </view> </block> </view>把每个月的天数计算出来,循环渲染成网格,然后请求后端接口,把当月有农事记录的日期列表返回,前端做一次映射即可。这里要注意性能问题,一个月的天数最多 31 天,性能完全不用焦虑。
为了让日历更实用,我加了一个“标记今天”的逻辑。首次进入时获取当天日期,高亮显示。用户左右滑动切换月份时,重新请求该月的农事记录。这里有个小细节:记录work_date字段存的是完整日期,但请求接口时只需传month参数,后端负责计算该月的开始和结束时间。前端组件只需要管理好当前展示的是哪个月份。
4.2 农事录入表单:别让农户填表格
农事录入是最核心的操作,在交互设计上要特别注意“别让农户填表格”。
农户的手普遍粗糙,很多人不习惯手机键盘输入。如果录入一个农事记录需要填七八个输入框,很容易劝退用户。我的方案是能选的不填、能拍照的不写字:
- 作物类型:下拉选择,预置常见作物(水稻、小麦、玉米、大豆、蔬菜、果树)
- 农事类型:用卡片式单选按钮,四个大按钮排序(播种、施肥、喷药、灌溉),一眼就能点
- 日期:默认今天,用户不需要改
- 面积、用量:用数字输入,限制小数位数
- 肥料/农药名称:提供模糊搜索联想,可以免输入
- 备注:选填,主要留给用户记录特殊情况
- 现场照片:提供拍照和从相册选择,最多传 3 张
表单提交前的校验小程序端也要做一遍,虽然后端已经做了,但前端校验可以提升用户的即时反馈体验。比如日期为空的提示,应该在用户点击“保存”按钮后立即获得反馈,而不是等网络请求绕一圈回来才提示。
这里贡献一个实测经验:图片上传交互上,不要“传一张等一张”,而是先让用户选完照片,点击“保存”时统一上传。实现方式是先调wx.uploadFile逐张上传获取图片 URL,全部成功后再提交表单。如果中间有一张失败,就重试一次;重试还失败,就终止提交并提示用户。
4.3 知识科普页面:阅读体验与分享
知识科普页面的核心是阅读体验。计划是通过分类 Tab + 文章卡片列表 + 详情页三层结构实现。
首页顶部的分类 Tab 直接调用后端分类接口生成:
<scroll-view scroll-x="true" class="category-tabs"> <view wx:for="{{categories}}" wx:key="id" class="tab-item {{currentCategory === item.id ? 'active' : ''}}" bindtap="switchCategory">$html = str_replace('<img', '<img style="max-width:100%;height:auto"', $html);分享功能是科普传播的重头。小程序可以通过button配合open-type="share"实现转发,也可以在后端生成海报图片引导用户保存到相册再分享到朋友圈。MVP 阶段我只做了最简单的微信好友转发,后续可以再升级成海报模式。
5. 农时提醒与数据统计的业务逻辑
5.1 农时提醒怎么算:作物周期 + 固定节点
农时提醒是农事管理系统的灵魂功能。如果只是记录历史,农户的粘性不会高。提醒模块的设计思路是:根据作物的生长周期、季节规律和农户自定义设置,在合适的时间给用户推送“该打药了”“该施肥了”这样的信息。
实现方案是维护一张作物生长关键节点表:
CREATE TABLE `crop_schedule` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `crop_type` varchar(50) NOT NULL COMMENT '作物类型', `stage_name` varchar(100) NOT NULL COMMENT '生长阶段/关键节点', `remind_day` int(11) NOT NULL COMMENT '播种后天数(0表示播种当天)', `work_type` tinyint(4) NOT NULL COMMENT '对应农事类型', `content` varchar(200) NOT NULL COMMENT '提醒内容', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;比如水稻的节点可以配置为:
- 播种后 7 天:秧苗期管理,注意水分管理
- 播种后 15 天:施分蘖肥
- 播种后 25 天:晒田,控制无效分蘖
- 播种后 40 天:破口期,重点防治稻飞虱
有了这张表,提醒的计算逻辑就很清晰了。用户每新增一条“播种”类型的农事记录,系统根据crop_schedule表里配置的remind_day天数,推算出后续的提醒日期,存入提醒任务表。到时间后,通过小程序订阅消息推送给用户。
这里也提一下,小程序订阅消息的使用有讲究。微信规定订阅消息是一次性的,用户每次授权只能接收一条。所以前端要设计好提醒的授权引导,不能在一开始就弹窗让用户同意所有提醒。我采用的是进入“农时提醒设置”页面时,提示用户开启“未来两周的农事提醒”,用户点击授权后,后端记录这条授权凭证,到时间就推送。如果用户没有授权,提醒功能就退化为站内红点提示,不强制打扰。
5.2 农事统计报表:给管理者的视角
用户记录数据之后,需要一个出口让数据产生价值。统计报表我用简单图表展示,分为三个维度:按农事类型统计、按作物统计、按月度对比。
按农事类型统计,可以回答“我这一年到底主要在地里忙什么”。全年记录按类型聚合条形图展示,一眼就能看出喷药和施肥的占比。
按作物统计,可以回答“每种作物投入了多少面积和工时”。这个对种多种作物的农户非常实用,相当于一本精细的农业生产台账。
按月度对比,可以回答“农忙在哪些月份”。柱状图展示十二个月的记录数量分布,可以引导用户为下一年做好规划。
统计接口返回的数据结构我已经在接口部分写过了。前端可以用wx-charts或者echarts的小程序版本渲染图表。为了性能和包体积考虑,我用了轻量的wx-charts,渲染基本柱状图和折线图足够。
6. 开发中的坑与排查记录
6.1 常见问题速查表
整个开发过程中遇到的坑不少,我整理成了表格,方便后来者查阅。
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 小程序请求后端接口报域名不合法 | request 合法域名未配置 | 在小程序管理后台配置 HTTPS 域名,并在开发者工具里勾选“不校验合法域名” |
| 用户登录后每次重启小程序都要重新登录 | 前端没有持久化 storage | 登录成功后将 token 存到wx.setStorageSync,启动时先从 storage 读取 token 调用后端校验接口 |
| 农事记录列表接口在 1 月数据显示为空 | whereYear方法的字段识别错误 | 明确写成where('work_date', 'between', [$year.'-01-01', $year.'-12-31']) |
| 上传图片偶尔失败 | 用户选了超过 3 张图 | 前端限制count: 3,后端接口也加上数量校验 |
| 富文本渲染时图片溢出屏幕 | HTML 的 img 标签没有限制宽度 | 后端转换时给 img 标签添加style="max-width:100%" |
| 科普文章阅读量不涨 | 前端访问详情接口时后端未自增 | 详情接口里调用inc('view_count')->update() |
| 订阅消息推送失败 | 用户未授权或授权次数用完 | 订阅消息是一次性的,推送前确认用户还有剩余授权次数 |
| 数据库乱码 | 表字符集不是 utf8mb4 | 建表时统一CHARSET=utf8mb4,排序规则选utf8mb4_general_ci |
| 接口返回 JSON 中有 HTML 标签 | 富文本内容没转义 | 返回前用htmlspecialchars处理文本内容 |
6.2 几个值得说的调试经验
调试经验里最想说的一点是:接口联调一定要盯着网络面板看。小程序开发工具里有一个“Network”面板,可以看到每个请求的完整参数、返回结果、耗时和状态码。很多时候前端报错并不是后端逻辑有问题,而是参数没传对。我遇到过好几次前端传的是字符串"true",后端接收后当成布尔值true处理,结果逻辑判断完全错乱。最后都是在网络面板里发现的问题。
第二点经验是关于数据库索引的。农事记录表数据量一旦超过十万条,没有索引的查询会明显变慢。我在测试阶段插入了五万条模拟数据,未加索引时按user_id + work_date的查询耗时在 1.2 秒左右,加上联合索引后直接降到 20 毫秒左右。千万别觉得自己的项目数据量小就不用优化,农事记录这种表,一年数据量轻松破万。
第三点经验是关于错误日志的。ThinkPHP 的日志默认写在runtime/log/目录下,上线后一定要定期查看。我遇到过线上用户反馈“某些操作偶尔失败”,但在本地完全复现不了。后来翻日志发现是第三方图片存储服务的临时签名过期导致的 403 报错,排除了半天才定位到。日志是排查生产环境问题的第一道线索,不要过度依赖前端报错。
7. 部署上线与后续扩展方向
7.1 部署流程要点
部署流程说起来几步就完了,但每一步都有注意细节。
服务器环境推荐 Linux + Nginx + PHP 7.4/8.0 + MySQL 5.7/8.0。宝塔面板能省去很多手工配置的麻烦,适合中小型项目快速部署。
部署步骤如下:
- 服务器安装宝塔面板,一键安装 Nginx、MySQL、PHP
- 创建站点,把 ThinkPHP 项目代码上传到站点目录
- 配置伪静态,Nginx 里设置
location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?s=$1 last; } } - 导入数据库 SQL,修改
.env数据库连接信息 - 配置 HTTPS 证书,小程序要求接口必须是 HTTPS
- 小程序后台配置服务器域名,包括
request合法域名和uploadFile合法域名 - 使用微信开发者工具上传代码,提交审核
部署中最容易出错的是第 5 步和第 6 步。HTTPS 证书需要提前准备好,同时要在服务器上把 443 端口开放。小程序后台的域名配置要提前做,审核期间如果发现域名配错了,重新配置后还要等生效时间。
7.2 这个项目还能怎么长
系统上线运行一段时间后,有几个方向可以继续扩展。
第一个是接入天气数据。农业和天气强关联,如果能根据天气推荐农事操作,比如“明后天有雨,建议暂缓喷药”,这个功能对农户价值极高。实现上只需要对接一个天气 API,在农事录入页面自动带出当天天气,再在后端写一个简单的规则引擎,根据天气类型和农事类型做匹配。
第二个方向是作物模型完善。目前农时提醒用的是固定天数方案,如果能把作物模型做精确一些,结合当地积温、土壤墒情来做动态提醒,精准度会大幅提升。这个方向需要农业专家参与,适合和农科院、农业高校合作。
第三个方向是社区化运营。科普内容如果只是单向推送,用户活跃度有限。如果把问答模块升级为社区,支持用户发帖、评论、点赞,就会形成内容自生产的生态。农户之间的经验交流往往比冷冰冰的知识文章更受欢迎。
第四个方向是农产品溯源。农事记录沉淀下来的数据本身就是最好的溯源素材。后期可以给每块地生成一个溯源二维码,消费者扫码就能看到这块地从播种到收获的所有农事记录。这是农业数字化一个非常有想象力的落地方向。
最后再分享一点我自己做这个项目的体会。农业类应用的开发,难点往往不在技术,而在对业务的理解。我第一次接触农事管理时,对“分蘖肥”“破口期”“晒田”这些词完全没概念,后来跟着某位农户在地里泡了几天才算入门。做农业系统,一定要尽量贴近真实的使用场景,多去田间地头看农户怎么干活、怎么记录、怎么查资料。你以为很方便的操作,可能农户觉得复杂;你觉得不需要的功能,可能恰恰是他们的刚需。技术只是工具,真正理解用户才能做出好产品。