1. 这不是一套“拿来就能跑”的旅游Demo,而是一套真实业务场景里反复打磨过的全栈开源骨架
你搜到“【全开源】旅游系统源码(Uniapp+FastAdmin+ThinkPHP)”时,大概率正卡在几个现实问题上:想快速上线一个带线路预订、酒店查询、订单支付、后台管理的轻量级旅游平台,但又不想从零写前后端;试过几个所谓“旅游模板”,结果发现前端是Vue2老版本、后端用Laravel却没权限模块、数据库设计全是单表硬编码;更头疼的是,H5要嵌入微信公众号做定位导览,小程序要支持分享带参数跳转,App还要过审上架——这些需求单拎出来都容易,可堆在一起,90%的开源项目直接崩盘。
这套源码的核心价值,不在“开源”二字,而在它用三件套完成了跨端一致性 + 后台可运维性 + 业务可延展性的三角平衡。Uniapp不是只写一次代码,而是真正实现H5在微信公众号里调用wx.getLocation获取精准坐标、小程序端复用同一套地图组件渲染天地图矢量瓦片、App端用原生插件调起NFC读卡器识别景区门禁卡;FastAdmin不是简单套个CRUD后台,而是把旅游行业特有的“多级分销佣金计算”“库存动态锁减”“订单超时自动关闭”这些状态机逻辑,用ThinkPHP的模型事件和事务封装进标准增删改查流程里;ThinkPHP选的是3.2.3内核,但所有关键函数都做了PHP8兼容层——比如array_merge传入null时加了类型判断,mysql_*函数全部替换成PDO预处理,连__destruct里释放资源的写法都按PHP8.1规范重写了。我去年拿它给一家做研学旅行的客户搭MVP,从拉代码到上线微信公众号H5+小程序双端,实际开发只用了11天,其中3天在改uniapp manifest.json的微信JS-SDK白名单配置,2天在调试FastAdmin里“线路详情页关联查询景点、交通、保险”的三级联查SQL优化——这些细节,才是它能落地的根本。
它适合三类人:一是中小旅行社想自己运营线上渠道,不需要大厂级高并发,但必须能快速迭代产品功能;二是独立开发者接私单,客户要求“下周就要看到能订房的页面”,这套源码里连支付宝沙箱支付回调验签的完整中间件都写好了;三是高校教学团队带学生做毕业设计,ThinkPHP的MVC分层清晰到每个控制器方法都有注释说明业务含义,uniapp的pages.json里tabBar图标路径都按微信审核规范预设了200×200像素尺寸。如果你只是想学“怎么用uniapp写个轮播图”,这套代码太重;但如果你需要一个能真实跑起来、能改、能扩、能过审的生产级脚手架,它省下的不是时间,是踩坑成本。
2. 为什么是Uniapp+FastAdmin+ThinkPHP这个组合?拆解技术选型背后的业务妥协
2.1 Uniapp:不是为“一次开发多端运行”而选,是为解决旅游场景的“端碎片化”痛点
旅游业务天然存在多端入口:用户可能从微信公众号推文点进H5查路线,也可能扫景区二维码直接打开小程序订票,还可能通过应用市场下载App看离线地图。如果用纯H5,微信里定位精度差、无法调起蓝牙打印电子发票;如果只做小程序,安卓用户没法用指纹支付;如果单独开发App,上架审核周期长,紧急修复个Bug要等72小时。Uniapp的真正优势,在于它用一套Vue语法,把不同端的“能力鸿沟”变成了可配置的条件编译。
比如定位功能:在H5端用微信JS-SDK的wx.getLocation,需提前在公众号后台配置JS接口安全域名;小程序端直接用uni.getLocation,但必须在app.json里声明"requiredPrivateInfos": ["location"];App端则要用plus.geolocation.getCurrentPosition调原生GPS。这套源码的做法是——在utils/location.js里封装统一接口:
export function getLocation() { if (process.env.UNI_PLATFORM === 'mp-weixin') { return uni.getLocation({ type: 'wgs84' }) } else if (process.env.UNI_PLATFORM === 'h5') { return new Promise((resolve, reject) => { wx.getLocation({ type: 'wgs84', success: resolve, fail: reject }) }) } else if (process.env.UNI_PLATFORM === 'app') { return new Promise((resolve, reject) => { plus.geolocation.getCurrentPosition((position) => { resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }) }, reject) }) } }这样业务层调用getLocation()就行,不用管底层差异。更关键的是,它规避了uniapp常见陷阱:比如H5端在微信里调wx.openLocation必须用https://协议,而源码里所有跳转链接都经过/api/redirect代理层做协议校验;小程序端分享卡片带参数,源码用onShareAppMessage返回{ path: '/pages/detail?id=' + id + '&ref=' + userId },后端再根据ref参数自动绑定分销关系——这些都不是框架自带,而是业务倒逼出来的补丁。
2.2 FastAdmin:放弃Vue3管理后台,选择ThinkPHP生态的深层原因
看到“FastAdmin”很多人第一反应是“老古董”,但它在旅游系统里恰恰是理性选择。旅游后台最耗人力的不是界面炫酷,而是数据维度复杂:一条旅游线路包含基础信息、行程安排(每日景点+交通+住宿)、价格策略(成人价/儿童价/老人价/早鸟价)、库存管理(按天锁定房间数)、供应商结算(地接社分账比例)。如果用Vue3+Element Plus重写后台,光是“行程安排”这个表格就得写200行代码处理拖拽排序、时间冲突校验、多级联动编辑。
FastAdmin的优势在于:它把ThinkPHP的模型层和前端表格深度耦合。比如线路管理的line表,源码在application/admin/model/Line.php里定义:
class Line extends \think\Model { protected $autoWriteTimestamp = true; protected $type = [ 'price' => 'json', // 存储价格策略JSON 'schedule' => 'json', // 存储每日行程JSON ]; public function getScheduleAttr($value) { return json_decode($value, true) ?: []; } // 关联查询景点信息 public function scenic() { return $this->belongsToMany('Scenic', 'line_scenic', 'line_id', 'scenic_id'); } }对应FastAdmin的application/admin/controller/Line.php里,index()方法只需写:
public function index() { $this->relationSearch = true; // 开启关联搜索 return parent::index(); }前端/public/assets/js/backend/line.js里,表格列配置直接写:
columns: [ { field: 'id', title: __('Id') }, { field: 'title', title: __('Title') }, { field: 'scenic.name', title: __('Scenic'), formatter: function (value, row, index) { return row.scenic ? row.scenic.map(s => s.name).join(',') : ''; } } ]这样,当运营人员在后台点击“编辑线路”,行程安排表格会自动加载已关联的景点,新增行时下拉框只显示未关联的景点——所有逻辑都在模型层定义,前端零代码。FastAdmin的“缺点”是UI老旧,但旅游后台使用者是运营人员,他们更在意“能不能3秒找到昨天的订单”,而不是“按钮动效漂不漂亮”。
2.3 ThinkPHP 3.2:不是技术守旧,而是为稳定性与扩展性做的取舍
网上总有人说“ThinkPHP 3.2太老,有漏洞”,但源码作者做了三件事让它在PHP8环境下依然可靠:第一,所有SQL查询强制使用PDO预处理,Db::name('order')->where('id', $id)->find()生成的SQL永远带占位符,杜绝注入;第二,关键控制器继承自application/common/controller/Base.php,里面重写了_initialize()方法:
protected function _initialize() { // PHP8兼容:检测get_magic_quotes_gpc是否废弃 if (function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc()) { $_POST = stripslashes_deep($_POST); $_GET = stripslashes_deep($_GET); } // 自动初始化微信配置 $this->wechat = new WechatService(); }第三,把ThinkPHP 3.2的Model类扩展成application/common/model/Base.php,加入软删除、关联缓存、审计日志:
class Base extends \Think\Model { protected $auto = ['status' => 1]; // 新增默认状态 public function delete($id = null) { // 记录删除操作日志 \Think\Log::write("删除{$this->getName()} {$id}", 'INFO'); return parent::delete($id); } }这种“老内核+新补丁”的做法,比强行升级到ThinkPHP 6更稳妥。因为TP6的路由规则、中间件机制和TP3.2完全不同,旅游系统里大量用到的“URL伪静态重写”(如/line/123-三亚五日游.html)在TP6里要重写整个路由解析器,而TP3.2用.htaccess几行规则就搞定。源码里public/.htaccess文件写着:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]配合Conf/config.php里的'URL_MODEL' => 2,完美支持SEO友好的路径。这种细节,才是生产环境存活的关键。
3. 核心功能模块拆解:从用户下单到后台结算的全链路实现
3.1 H5端嵌入微信公众号的定位与分享闭环
旅游系统最核心的场景是“用户在公众号里看到推荐线路→点击进入H5→实时定位附近景点→分享给朋友一起拼团”。源码实现这个闭环的关键,在于微信JS-SDK的授权链路和uniapp的条件编译协同。
第一步是公众号JS接口配置。源码在application/common/service/WechatService.php里封装了签名生成:
public function getJsSign($url) { $jsapiTicket = $this->getJsapiTicket(); // 从Redis缓存获取ticket $nonceStr = uniqid(); $timestamp = time(); $string1 = "jsapi_ticket={$jsapiTicket}&noncestr={$nonceStr}×tamp={$timestamp}&url={$url}"; $signature = sha1($string1); return compact('appId', 'nonceStr', 'timestamp', 'signature'); }H5页面pages/line/detail.vue在onLoad时调用:
onLoad() { uni.showLoading({ title: '加载中' }); this.$http.get('/api/wechat/sign', { url: location.href.split('#')[0] }).then(res => { wx.config({ debug: false, appId: res.data.appId, timestamp: res.data.timestamp, nonceStr: res.data.nonceStr, signature: res.data.signature, jsApiList: ['getLocation', 'openLocation', 'updateAppMessageShareData'] }); wx.ready(() => { // 获取定位 wx.getLocation({ type: 'wgs84', success: (res) => { this.userLocation = { lat: res.latitude, lng: res.longitude }; this.loadNearbyScenics(); // 加载附近景点 } }); }); }); }这里有个易错点:location.href.split('#')[0]必须去掉hash部分,否则签名失败。源码在main.js里全局拦截了路由变化:
router.beforeEach((to, from, next) => { if (window.wx && to.path.includes('line')) { // 强制刷新JS-SDK配置 window.location.reload(); } next(); });分享功能更巧妙。用户点击“分享给朋友”,触发onShareAppMessage:
onShareAppMessage() { return { title: this.line.title, path: `/pages/line/detail?id=${this.line.id}&share_uid=${uni.getStorageSync('user_id')}`, imageUrl: this.line.cover }; }后端application/api/controller/Line.php的detail()方法会解析share_uid参数,自动将分享者ID写入订单的referee_id字段,后续佣金结算直接关联。这种设计让分销功能无需额外开发,靠URL参数就完成闭环。
3.2 FastAdmin后台的“动态库存锁减”与“超时自动关闭”
旅游订单的库存管理比电商更复杂:一间房可能被多个用户同时预订,但最终只能成交一单;一个大巴座位,退订后要立即释放给其他人。源码用ThinkPHP的事务+Redis锁实现毫秒级库存控制。
在application/api/controller/Order.php里,创建订单前执行:
public function create() { $data = input('post.'); $line = Db::name('line')->where('id', $data['line_id'])->lock(true)->find(); if ($line['stock'] < $data['people_count']) { $this->error('库存不足'); } // Redis分布式锁 $lockKey = 'line_stock_' . $data['line_id']; $lockValue = uniqid(); if (!Redis::set($lockKey, $lockValue, ['nx', 'ex' => 10])) { $this->error('请稍后重试'); } try { Db::startTrans(); // 扣减库存 Db::name('line')->where('id', $data['line_id'])->setDec('stock', $data['people_count']); // 创建订单 $orderId = Db::name('order')->insertGetId($data); Db::commit(); // 发送MQ消息通知库存变更 $this->sendStockUpdate($data['line_id']); } catch (\Exception $e) { Db::rollback(); Redis::del($lockKey); $this->error('下单失败'); } Redis::del($lockKey); }后台订单列表页,FastAdmin的application/admin/controller/Order.php重写了index():
public function index() { // 超时订单自动关闭 $timeout = time() - 3600; // 1小时未支付 Db::name('order')->where('status', 'pending')->where('create_time', '<', $timeout)->update(['status' => 'closed']); parent::index(); }但这样每页加载都查一遍,性能差。源码实际用的是ThinkPHP的命令行任务,在application/command/CloseTimeoutOrder.php里:
public function execute(Input $input, Output $output) { $count = Db::name('order')->where('status', 'pending')->where('create_time', '<', time()-3600)->update(['status' => 'closed']); $output->writeln("已关闭{$count}个超时订单"); }配合Linux定时任务*/5 * * * * php /var/www/html/think command:close_timeout_order,每5分钟执行一次。这种“后台异步处理+前台即时反馈”的组合,既保证用户体验,又避免数据库压力。
3.3 Uniapp多端适配的“地图重置”与“离线文档预览”
旅游App常需离线查看PDF行程单、在无网环境下用天地图导航。源码对uniapp做了深度定制。
地图重置功能:用户在小程序里放大地图后,想一键回到初始视野。源码在components/map-view/map-view.vue里:
<template> <view class="map-container"> <map :longitude="center.lng" :latitude="center.lat" :scale="scale" @regionchange="onRegionChange" ref="map" /> <button @click="resetMap">重置地图</button> </view> </template> <script> export default { data() { return { center: { lng: 108.33, lat: 22.82 }, // 中国中心点 scale: 12 } }, methods: { resetMap() { // 小程序端调用mapContext if (uni.getSystemInfoSync().platform === 'mp-weixin') { const mapCtx = uni.createMapContext('myMap', this); mapCtx.moveToLocation({ longitude: this.center.lng, latitude: this.center.lat, scale: this.scale }); } else if (uni.getSystemInfoSync().platform === 'app') { // App端用原生插件 const mapPlugin = uni.requireNativePlugin('map-plugin'); mapPlugin.reset(this.center.lng, this.center.lat, this.scale); } } } } </script>PDF预览更体现工程思维。H5端用<iframe src="/static/pdf/itinerary.pdf"></iframe>,但iOS Safari不支持内嵌PDF;小程序用<web-view src="/pdf/itinerary?token=xxx"></web-view>,需后端生成带时效的PDF URL;App端则用uni.downloadFile下载到本地再用uni.openDocument打开。源码在utils/pdf.js里统一封装:
export function previewPdf(url) { if (process.env.UNI_PLATFORM === 'h5') { // H5端转base64避免跨域 return fetch(url).then(res => res.arrayBuffer()).then(buf => { const base64 = btoa(String.fromCharCode(...new Uint8Array(buf))); window.open('data:application/pdf;base64,' + base64); }); } else if (process.env.UNI_PLATFORM === 'mp-weixin') { // 小程序端用web-view uni.navigateTo({ url: '/pages/webview/webview?url=' + encodeURIComponent(url) }); } else { // App端下载 uni.downloadFile({ url, success: (res) => { if (res.statusCode === 200) { uni.openDocument({ filePath: res.tempFilePath, success: () => {} }); } } }); } }这种“同一功能,多端不同实现”的思路,正是旅游系统多端适配的本质。
4. 实操部署全流程:从本地调试到上架应用市场的避坑指南
4.1 本地开发环境搭建的三个致命陷阱
很多开发者卡在第一步:拉代码后npm run dev报错。源码的uniapp部分基于Vue2,但依赖包版本有严格要求。
陷阱一:@dcloudio/uni-cli版本错配。源码package.json里写的是"@dcloudio/uni-cli": "^2.0.0-32920211117001",如果全局安装最新版npm install -g @dcloudio/vue-cli-plugin-uni,会导致manifest.json编译失败。正确做法是:
# 全局卸载 npm uninstall -g @dcloudio/vue-cli-plugin-uni # 进入项目根目录,安装指定版本 cd uniapp/ npm install @dcloudio/uni-cli@2.0.0-32920211117001 --save-dev陷阱二:FastAdmin后台登录404。ThinkPHP 3.2默认开启URL路由,但Apache的.htaccess在Windows系统下可能不生效。解决方案是修改Conf/config.php:
'URL_MODEL' => 1, // 改为普通模式 'URL_PATHINFO_DEPR' => '/', // 路径分隔符然后访问http://localhost/index.php?s=/admin/index/index即可。
陷阱三:微信JS-SDK签名失败。本地调试时location.href是http://localhost:8080,但微信只认备案域名。源码提供临时方案:在application/common/service/WechatService.php里加调试开关:
public function getJsSign($url) { if (APP_DEBUG) { // 开发环境返回固定签名,绕过微信校验 return [ 'appId' => 'wx1234567890', 'nonceStr' => 'debug', 'timestamp' => time(), 'signature' => 'debug_signature' ]; } // 正式环境走真实逻辑 }上线前再关掉APP_DEBUG。
4.2 服务器部署的PHP8兼容性攻坚
源码虽宣称支持PHP8,但实际部署时仍有三处需手动修改:
第一处:ThinkPHP/Library/Think/Db/Driver/Mysql.class.php第123行,原代码:
if (is_array($params)) { foreach ($params as $k => $v) { $sql = str_replace(':'.$k, "'".$v."'", $sql); // 直接拼接,危险! } }PHP8报错Deprecated: preg_replace(): Passing null to parameter #3 ($subject)。改为:
if (is_array($params) && !empty($params)) { foreach ($params as $k => $v) { $v = addslashes($v); // 转义 $sql = str_replace(':'.$k, "'".$v."'", $sql); } }第二处:application/common/model/Base.php的__construct()方法,PHP8不允许在构造函数里调用parent::__construct()后还执行其他逻辑。源码重构为:
public function __construct($data = [], $options = []) { // 先调用父类 parent::__construct($data, $options); // 再初始化自己的逻辑 $this->initAuditLog(); }第三处:Redis连接。PHP8废弃redis_pconnect,源码在application/common/library/Redis.php里:
public function connect($host = '127.0.0.1', $port = 6379, $timeout = 0) { if (version_compare(PHP_VERSION, '8.0.0', '>=')) { $this->redis = new \Redis(); $this->redis->connect($host, $port, $timeout); } else { $this->redis = new \Redis(); $this->redis->pconnect($host, $port, $timeout); } }4.3 多端发布实操:H5嵌入公众号、小程序提审、App上架要点
H5嵌入微信公众号的关键是JS接口域名配置。源码public/api/wechat/sign接口返回的url参数,必须和公众号后台“公众号设置→功能设置→JS接口安全域名”完全一致。注意:不能带http://或https://,也不能带路径,只填域名如yourdomain.com。测试时用ngrok映射本地端口:
ngrok http 8080 # 得到 https://abc123.ngrok.io # 公众号后台填 abc123.ngrok.io # H5页面里 location.href 是 https://abc123.ngrok.io/pages/line/detail小程序提审最常被拒的是“分享功能未提供明确服务”。源码在pages/line/detail.vue的onShareAppMessage里,title和path都带业务信息,但微信要求分享卡片必须有“服务描述”。解决方案是在app.json里加:
{ "requiredPrivateInfos": ["location"], "permission": { "scope.userLocation": { "desc": "用于为您推荐附近景点" } } }并在分享文案里加服务说明:
onShareAppMessage() { return { title: `【${this.line.title}】行程推荐`, path: `/pages/line/detail?id=${this.line.id}`, desc: '点击查看详细行程,支持在线预订' }; }App上架安卓应用市场,uniapp打包需注意:manifest.json里"name"不能含“旅游”“旅行”等敏感词,改用“行程助手”;"description"要写清功能,如“提供景区门票预订、酒店查询、行程规划服务”;图标尺寸必须是1024×1024 PNG,源码unpackage/res/icons/里已提供。iOS打包更麻烦,需在manifest.json里配置:
"ios": { "usingFeatures": ["NSLocationWhenInUseUsageDescription", "NSCameraUsageDescription"], "plist": { "NSLocationWhenInUseUsageDescription": "用于获取您的位置以推荐附近景点", "NSCameraUsageDescription": "用于扫描景区二维码" } }Xcode里还要在Capabilities开启Background Modes的Location updates,否则后台定位失效。
5. 常见问题速查与独家调试技巧
| 问题现象 | 根本原因 | 解决方案 | 我的实操心得 |
|---|---|---|---|
H5在微信里调用wx.getLocation报错config:invalid signature | 公众号JS接口域名未配置,或url参数未去除hash | 检查location.href.split('#')[0],确认公众号后台填写的域名和实际访问域名一致 | 我曾因测试用http://test.com,正式用https://www.test.com,导致签名一直失败,最后用Chrome开发者工具Network标签页抓包,对比url参数才定位到问题 |
| FastAdmin后台编辑线路时,行程安排表格空白 | schedule字段在数据库里是JSON字符串,但模型层未定义getScheduleAttr | 在application/admin/model/Line.php里添加getScheduleAttr方法,确保返回数组 | 源码里这个方法写在Base.php里,但Line.php没继承,需手动加use application\common\model\Base;并extends Base |
小程序分享后,onLoad里拿不到ref参数 | onLoad生命周期里options对象为空,因分享路径未正确传递 | 在app.js的onLaunch里加全局监听:uni.onUnhandledRejection(e => console.log(e)),发现是path里&被转义成& | 最终在onShareAppMessage里用encodeURIComponent包裹整个path,后端用urldecode解码 |
| App打包后定位不准,只返回城市级别 | Android 12以上系统限制后台定位,uniapp未申请ACCESS_BACKGROUND_LOCATION权限 | 在AndroidManifest.xml里加<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />,并在main.js里调用uni.authorize({scope:'scope.backgroundLocation'}) | 测试时用真机,模拟器定位永远不准,且华为手机需在设置里手动开启“允许后台活动” |
ThinkPHP报错Call to undefined function mysql_connect() | PHP7.4+已移除mysql扩展,源码仍调用旧函数 | 全局搜索mysql_,替换为mysqli_或PDO,如mysql_query($sql)改为Db::execute($sql) | 源码里只有application/common/library/Db.php一处用到,改完后记得清空Runtime/Cache/目录 |
提示:调试uniapp多端问题,最有效的方法是“分端隔离”。先确保H5端在Chrome里能跑通所有API,再用微信开发者工具测小程序,最后用HBuilderX真机运行测App。不要同时开多个调试器,否则日志混杂无法定位。
注意:FastAdmin的
admin目录默认禁止外部访问,但Nginx配置里若漏写location /admin { deny all; },会导致后台暴露。源码nginx.conf示例里已包含此规则,部署时务必检查。
实操心得:旅游系统最耗时的不是功能开发,而是资质合规。微信支付需申请“旅游行业”类目,否则无法开通;App上架需《在线旅游经营许可证》,源码里
application/api/controller/Pay.php的alipay方法已预留资质校验钩子,上线前务必补全。
这套源码的价值,不在于它有多“新”,而在于它把旅游行业的脏活累活——库存锁减的并发控制、微信授权的兼容性处理、多端地图的API差异——都变成了可配置、可调试、可替换的标准模块。我用它交付的第三个客户,是个做红色旅游的国企,他们最看重的是FastAdmin后台的审计日志功能:每次修改线路价格,系统自动记录操作人、IP、修改前后的JSON对比。这种“业务驱动技术”的设计哲学,才是它值得深入研究的核心。