基于地理位置的城市化 DIY 装修模板自动加载方案
文档版本:v1.0.0-draft
创建日期:2026-06-10
作者:sileya-ai
状态:草稿 - 待评审
末次更新:2026-06-28(部分已实现,详见末尾"更新历史")
一、背景与目标
1.1 项目背景
随着业务扩展到多个城市,不同城市的用户对首页内容有不同的展示需求:
- 厦门用户希望看到本地热门票务活动和场馆推荐
- 上海用户希望看到上海场次的演出信息
- 以此类推,每个城市可能有其专属的营销活动和商品推荐
现有系统仅支持单一默认首页模板,无法满足城市化的精细运营需求。
1.2 项目目标
| # |
目标 |
说明 |
| 1 |
城市化首页 |
根据用户地理位置自动加载对应城市的 DIY 装修模板 |
| 2 |
零数据库改动 |
利用现有 DIY 模板表的 name 字段实现命名约定 |
| 3 |
自动兜底 |
当特定城市模板不存在时,自动 fallback 到默认模板 |
| 4 |
前端零改动 |
复用现有位置选择和坐标传递机制,无需修改 UniApp 前端代码 |
| 5 |
最小后端改动 |
仅修改 index/index 控制器逻辑,不新增数据表 |
1.3 预期效果
用户定位厦门(lng:118.11, lat:24.58)
↓
自动加载 home_350200 模板(厦门专属首页)
↓
展示厦门热门票务、场馆推荐等本地化内容
用户定位上海
↓
自动加载 home_310000 模板(上海专属首页)
↓
展示上海场次演出信息
用户定位未知/坐标缺失
↓
自动加载 home_default 模板(默认兜底)
↓
展示通用内容
二、需求分析
2.1 功能需求
| 需求ID |
需求描述 |
优先级 |
| F01 |
后端根据用户 GPS 坐标自动匹配对应城市的 DIY 模板 |
必须 |
| F02 |
DIY 模板通过命名约定(name 字段)区分不同城市 |
必须 |
| F03 |
当特定城市模板不存在时,自动加载默认模板作为兜底 |
必须 |
| F04 |
前端自动传递用户坐标到后端 API |
必须 |
| F05 |
前端位置选择器切换城市后,触发首页模板刷新 |
必须 |
2.2 非功能需求
| 需求ID |
需求描述 |
优先级 |
| N01 |
模板匹配逻辑应在 100ms 内完成 |
必须 |
| N02 |
默认模板必须存在,确保兜底可用 |
必须 |
| N03 |
城市坐标数据必须完整(Region 表 lng/lat 字段) |
必须 |
| N04 |
模板切换应有良好的用户体验(骨架屏过渡) |
建议 |
三、技术约束
3.1 现有系统架构
┌─────────────────────────────────────────────────────────────┐
│ UniApp 前端 │
├─────────────────────────────────────────────────────────────┤
│ 1. App.vue - 全局数据配置 │
│ - choice_user_location_init() 获取缓存的位置 │
│ - request_params_handle() 自动附加 user_lng/user_lat │
│ │
│ 2. choice-location 组件 - 位置选择器 │
│ - @onBack 事件返回 { name, lat, lng, status } │
│ - user_back_choice_location() 处理回调,触发 init() │
│ │
│ 3. index/index.vue - 首页 │
│ - init() 调用 /api.php?s=index/index │
│ - data_mode == 3 时渲染 DIY 组件 │
│ - component-diy 接收 { config, id } 渲染模板 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 后端 API │
├─────────────────────────────────────────────────────────────┤
│ 1. index/index 控制器 │
│ - 返回首页数据,含 data_list(含 DIY 模板) │
│ - 接收参数:user_lng, user_lat, city_id │
│ │
│ 2. GeoCityService::FindNearestCityId(lng, lat) │
│ - Haversine 公式计算最近城市 │
│ - 依赖 Region 表的 lng/lat 坐标数据 │
│ - 已被 VR Ticket 插件使用(成熟方案) │
│ │
│ 3. DIY 模板表 (shopxo_diy) │
│ - id: 主键 │
│ - name: 模板名称(用于命名约定) │
│ - describe: 描述 │
│ - config: DIY 配置 JSON │
└─────────────────────────────────────────────────────────────┘
3.2 约束条件
| # |
约束 |
说明 |
| C01 |
不能新增数据表 |
必须使用现有 DIY 模板表 |
| C02 |
不能修改数据库结构 |
现有字段不变,仅改变使用方式 |
| C03 |
前端零改动 |
UniApp 代码保持不变 |
| C04 |
复用现有服务 |
使用已有的 GeoCityService::FindNearestCityId() |
| C05 |
必须保留默认模板 |
home_default 必须存在作为兜底 |
3.3 依赖关系
| 依赖 |
说明 |
状态 |
| Region 表坐标数据 |
用于 Haversine 计算最近城市 |
需要确认数据完整性 |
| GeoCityService |
坐标转城市 ID 服务 |
已存在(VR Ticket 使用) |
| DIY 模板 name 字段 |
用于模板匹配 |
已存在 |
四、设计方案
4.1 命名规则设计
4.1.1 命名格式
| 类型 |
格式 |
示例 |
说明 |
| 默认模板 |
home_default |
home_default |
全国通用兜底模板,必须存在 |
| 城市模板 |
home_{城市ID} |
home_350200 |
城市 ID(来自 Region 表的 id 字段) |
| 拼音别名(可选) |
home_{拼音} |
home_xiamen |
便于运营记忆,可选扩展 |
4.1.2 城市 ID 参考
| 城市 |
城市ID |
模板名称 |
| 全国默认 |
- |
home_default |
| 厦门 |
350200 |
home_350200 |
| 上海 |
310000 |
home_310000 |
| 北京 |
110000 |
home_110000 |
| 广州 |
440100 |
home_440100 |
| 深圳 |
440300 |
home_440300 |
4.2 后端逻辑设计
4.2.1 修改范围
| 文件 |
修改内容 |
风险等级 |
app/index/controller/Index.php |
修改 index() 方法,增加模板匹配逻辑 |
低 |
app/service/DiyService.php |
新增 GetTemplateByName() 方法(如不存在) |
低 |
4.2.2 流程图
┌─────────────────────────────────────────────────────────────┐
│ index/index 控制器 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 接收请求参数 │
│ - user_lng (经度) │
│ - user_lat (纬度) │
│ - city_id (可选,显式城市ID) │
│ │
│ 2. 确定模板名称 │
│ │ │
│ ├─── 优先:city_id 显式传入 ──→ template_name = 'home_{city_id}'
│ │ │
│ └─── 其次:user_lng + user_lat ──→ │
│ │ │
│ ↓ │
│ GeoCityService::FindNearestCityId(lng, lat) │
│ │ │
│ ↓ │
│ city_id = 350200 │
│ │ │
│ ↓ │
│ template_name = 'home_350200' │
│ │
│ 3. 查询模板 │
│ │ │
│ ├─── 找到模板 ──→ 返回模板数据 │
│ │ │
│ └─── 未找到 ──→ template_name = 'home_default' │
│ │ │
│ ↓ │
│ 查询默认模板 │
│ │ │
│ ↓ │
│ 返回默认模板数据 │
│ │
│ 4. 返回响应 │
│ - data_list: { id, name, describe, config } │
│ - data_mode: 3 (DIY 模式) │
└─────────────────────────────────────────────────────────────┘
4.2.3 伪代码实现
<?php
// app/index/controller/Index.php
namespace app\index\controller;
use app\common\controller\BaseIndex;
use app\service\GeoCityService;
use app\service\DiyService;
class Index extends BaseIndex
{
/**
* 首页数据
*/
public function index()
{
// 1. 确定模板名称
$template_name = 'home_default'; // 默认值
// 2. 尝试使用显式 city_id
$city_id = $this->data['city_id'] ?? 0;
// 3. 如果没有 city_id,尝试坐标反推
if (empty($city_id) && !empty($this->data['user_lng']) && !empty($this->data['user_lat'])) {
$city_id = GeoCityService::FindNearestCityId(
$this->data['user_lng'],
$this->data['user_lat']
);
}
// 4. 拼接模板名称
if (!empty($city_id)) {
$template_name = 'home_' . $city_id;
}
// 5. 查询模板
$template = DiyService::GetTemplateByName($template_name);
// 6. 未找到则使用默认模板兜底
if (empty($template)) {
$template = DiyService::GetTemplateByName('home_default');
}
// 7. 组装返回数据
$result = [
'data_mode' => 3, // DIY 模式
'data_list' => $template,
// ... 其他首页数据
];
return $this->success($result);
}
}
<?php
// app/service/DiyService.php
namespace app\service;
use think\facade\Db;
class DiyService
{
/**
* 根据模板名称获取模板详情
*
* @param string $name 模板名称
* @return array|null
*/
public static function GetTemplateByName($name)
{
return Db::name('diy')
->where('name', $name)
->where('is_enable', 1)
->find();
}
/**
* 根据模板ID获取模板详情(已有方法)
*
* @param int $id 模板ID
* @return array|null
*/
public static function GetDetail($id)
{
return Db::name('diy')
->where('id', $id)
->where('is_enable', 1)
->find();
}
}
4.3 前端逻辑(现有代码,无需修改)
4.3.1 坐标自动传递
// App.vue - request_params_handle() 中的现有逻辑
let user_location = this.choice_user_location_init();
if(user_location != null && user_location.status == 1) {
query_str += '&user_lng=' + (user_location.lng || '');
query_str += '&user_lat=' + (user_location.lat || '');
}
4.3.2 位置选择回调
// index.vue - 现有回调处理
user_back_choice_location(e) {
// 重新加载数据,自动带上新坐标
this.init();
}
4.3.3 DIY 渲染
<!-- index.vue - 现有渲染逻辑 -->
<component-diy
:propValue="data_list.config"
:propDataId="data_list.id"
:propKey="random_value"
@onLocationBack="user_back_choice_location">
</component-diy>
五、数据要求
5.1 DIY 模板数据
| 字段 |
类型 |
约束 |
说明 |
| id |
INT |
主键 |
模板ID |
| name |
VARCHAR(100) |
唯一索引 |
模板名称(命名约定:home_{城市ID} 或 home_default) |
| describe |
VARCHAR(255) |
- |
模板描述 |
| config |
JSON |
- |
DIY 配置 JSON |
| is_enable |
TINYINT |
默认1 |
是否启用 |
| is_home |
TINYINT |
- |
是否首页模板 |
5.2 地区坐标数据(Region 表)
| 字段 |
类型 |
约束 |
说明 |
| id |
INT |
主键 |
地区ID(用于模板命名) |
| name |
VARCHAR(50) |
- |
地区名称 |
| level |
TINYINT |
- |
级别(2=城市) |
| pid |
INT |
- |
上级ID |
| lng |
DECIMAL(10,6) |
> 0 |
经度 |
| lat |
DECIMAL(10,6) |
> 0 |
纬度 |
5.3 模板配置示例
// home_default(默认模板)
{
"id": 1,
"name": "home_default",
"describe": "全国默认首页模板",
"config": {
"header": { ... },
"diy_data": [ ... ],
"footer": { ... }
}
}
// home_350200(厦门模板)
{
"id": 6,
"name": "home_350200",
"describe": "厦门城市首页模板",
"config": {
"header": { ... },
"diy_data": [ ... ],
"footer": { ... }
}
}
六、运营指南
6.1 创建城市模板步骤
- 登录管理后台
- 进入 DIY 装修 → 模板管理
- 点击「新增模板」
- 设置模板名称(如
home_350200)
- 进行页面装修
- 保存模板
6.2 命名规范
| 模板用途 |
命名格式 |
必须存在 |
| 默认兜底 |
home_default |
✅ 必须 |
| 厦门 |
home_350200 |
可选 |
| 上海 |
home_310000 |
可选 |
| 北京 |
home_110000 |
可选 |
警告:home_default 模板必须存在,否则当城市模板不存在时将无法显示首页内容。
6.3 模板预览
管理后台应提供「按模板名称预览」功能,方便运营确认不同城市的模板效果。目前采用后台管理界面的“DIY 装修模板”作为解决方案,用户手动创建、设置多个城市的模板并且进行预览。需要修改的时候手动修改管理对应城市的首页活动界面。未来如果需要方便管理,再考虑做一个集成界面用于快速切换。
七、错误处理与兜底机制
7.1 兜底场景
| 场景 |
处理方式 |
| 用户未授权位置权限 |
使用默认模板 home_default |
| 坐标解析失败 |
使用默认模板 home_default |
| 城市模板不存在 |
使用默认模板 home_default |
| 默认模板也不存在 |
返回错误提示「请配置默认首页模板」 |
| Region 表缺少坐标数据 |
使用默认模板 home_default |
7.2 错误日志
后端应记录以下日志便于排查:
- 模板匹配失败原因
- 坐标转城市失败原因
- 默认模板 fallback 记录
八、测试计划
8.1 单元测试
| 测试项 |
测试内容 |
预期结果 |
| T01 |
传入厦门坐标 (118.11, 24.58) |
返回 home_350200 模板 |
| T02 |
传入上海坐标 (121.47, 31.23) |
返回 home_310000 模板 |
| T03 |
传入坐标但模板不存在 |
返回 home_default 模板 |
| T04 |
不传坐标 |
返回 home_default 模板 |
| T05 |
显式传入 city_id=350200 |
返回 home_350200 模板 |
| T06 |
默认模板不存在 |
返回错误 |
8.2 集成测试
| 测试项 |
测试内容 |
预期结果 |
| I01 |
UniApp 授权位置后打开首页 |
显示对应城市模板 |
| I02 |
切换位置选择器到上海 |
首页刷新为上海模板 |
| I03 |
未授权位置打开首页 |
显示默认模板 |
| I04 |
清除位置缓存后打开首页 |
显示默认模板 |
8.3 边界测试
| 测试项 |
测试内容 |
预期结果 |
| B01 |
传入无效坐标 (0, 0) |
使用默认模板 |
| B02 |
传入超出中国范围坐标 |
使用默认模板 |
| B03 |
并发请求不同城市 |
各自返回正确模板 |
| B04 |
模板 config 为空 |
显示空页面或错误提示 |
九、部署说明
9.1 后端部署
- 备份
app/index/controller/Index.php
- 修改
index() 方法,增加模板匹配逻辑
- 检查
DiyService 是否有 GetTemplateByName() 方法,如无则新增
- 测试 本地环境验证功能
- 部署 到生产环境
9.2 配置检查清单
| # |
检查项 |
状态 |
| 1 |
Region 表坐标数据完整 |
⬜ |
| 2 |
存在 home_default 模板 |
⬜ |
| 3 |
GeoCityService 正常工作 |
⬜ |
| 4 |
前端坐标传递正常 |
⬜ |
9.3 回滚方案
如出现问题,可通过以下方式回滚:
- 恢复
Index.php 备份文件
- 临时删除城市模板,仅保留
home_default
十、相关文档
十一、版本历史
| 版本 |
日期 |
作者 |
说明 |
| v1.0.0-draft |
2026-06-10 |
sileya-ai |
初始版本 |
十二、待确认事项
十三、联系方式
- 作者:sileya-ai
- 技术支持:vr-shopxo 开发组
〇、更新历史
2026-06-28 部分修正
关于本方案"前端零改动"假设(§1.2.4、§3.2 C03、§4.3):
⚠️ 本假设已被打破。前端已做大量改动以支持位置自动获取 + 城市反查。详细实现见 diy-home-location-work-record-20260628.md。
已实现的前端改动(2026-06-28):
App.vue:get_user_location 重写(5 条件守护 + fail 占位 + onLocationReady 事件)
pages/index/index.vue 监听事件 + 重新请求首页(让后端按新坐标推荐 DIY 模板)
components/choice-location/choice-location.vue 监听事件 + 城市反查
- 完整验证:DIY 首页左上角从"当前位置"自动切换为"厦门市"
后端能力依赖(已由另一名同事提供):
vr_ticket/geo/city 距离匹配接口(>150km 报 code:-1)
- 业务后端按 URL 里的
user_lng / user_lat 自动推荐对应城市的 DIY 模板
未变的内容:
- 后端控制器逻辑(不在本前端任务范围)
- 城市中心点数据(Region 表)
- DIY 模板命名约定(
home_<city_id> / home_default)
为什么打破"前端零改动"假设:
- 原方案假设"用户进入首页前已经选过城市"——但实际场景是"用户首次进首页就要按位置自动推荐"
- 现有的
get_user_location 有一个长期隐藏 bug:已授权用户后续进首页不会再重新获取位置(守护条件 result == null 把第二次进入挡在外面)
- 需要前端在拿到新坐标后主动重新请求首页,让后端按新坐标推荐对应 DIY 模板