文档版本:v1.0.0 by 来团科技
创建日期:2026-03-22
项目:lt-store-pro 多商户商城系统
框架:ThinkPHP 8.0 + Vue.js + 微信小程序
商城系统中,商品规格(SKU)的排序功能直接影响用户购物体验:
| 挑战点 | 描述 |
|---|---|
| 多层级排序 | 规格组 → 规格值,两层排序关系 |
| 商品级自定义 | 每个商品的规格排序可以不同 |
| 前后端一致性 | 后台、API、小程序三端排序必须统一 |
| 高并发场景 | 秒杀/活动期间规格查询性能要求高 |
| 可扩展性 | 未来可能支持第三级分类排序 |
┌─────────────────────────────────────────────────────────────────┐
│ 架构分层设计 │
├─────────────────────────────────────────────────────────────────┤
│ Controller层 │ 接收排序请求,参数校验,调用Service │
├─────────────────────────────────────────────────────────────────┤
│ Service层 │ 排序业务逻辑,事务管理,排序算法实现 │
├─────────────────────────────────────────────────────────────────┤
│ Model层 │ 数据访问,缓存管理,查询优化 │
├─────────────────────────────────────────────────────────────────┤
│ Repository层 │ 数据仓储模式,抽象数据操作(可选) │
└─────────────────────────────────────────────────────────────────┘
| 方案 | 描述 | 优点 | 缺点 | 推荐指数 |
|---|---|---|---|---|
| 方案A:紧凑排序 | 使用整数序号,相邻序号间隔10 | 查询高效,插入方便 | 移动多个时需批量更新 | ⭐⭐⭐⭐⭐ |
| 方案B:稀疏排序 | 使用浮点数/字符串 | 插入时无需移动 | 查询需额外排序,跨数据库兼容差 | ⭐⭐⭐ |
| 方案C:链表形式 | prev/next指针 | 移动无需更新其他记录 | 查询需递归,跨表关联复杂 | ⭐⭐ |
选型依据:
-- 商品规格排序主表(存储规格组的排序)
CREATE TABLE `ltshop_goods_spec_sort` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`goods_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '商品ID',
`spec_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '规格组ID',
`sort` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '规格组排序值',
`wxapp_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '小程序ID',
`create_time` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
`update_time` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_goods_spec` (`goods_id`, `spec_id`, `wxapp_id`),
KEY `idx_goods_sort` (`goods_id`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品规格组排序表';
-- 商品规格值排序表(存储规格值在商品内的排序)
CREATE TABLE `ltshop_goods_spec_value_sort` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`goods_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '商品ID',
`spec_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '规格组ID',
`spec_value_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '规格值ID',
`sort` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '规格值排序值',
`wxapp_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '小程序ID',
`create_time` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
`update_time` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_goods_spec_value` (`goods_id`, `spec_id`, `spec_value_id`, `wxapp_id`),
KEY `idx_goods_spec_sort` (`goods_id`, `spec_id`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品规格值排序表';
| 特性 | 说明 |
|---|---|
| 分离存储 | 规格组排序和规格值排序分离,互不影响 |
| 唯一索引 | (goods_id, spec_id, wxapp_id) 确保同一商品同规格只有一条记录 |
| 查询优化 | 联合索引支持 WHERE goods_id = ? ORDER BY sort 的有序查询 |
| 无冗余 | 排序表独立,不影响原有业务表结构 |
| 可扩展 | 如需支持分类排序,可新建 ltshop_category_spec_sort 表 |
-- 规格组排序查询(按商品ID查所有规格组)
EXPLAIN SELECT * FROM ltshop_goods_spec_sort
WHERE goods_id = 10001
ORDER BY sort ASC;
-- 规格值排序查询(按商品ID+规格组查所有规格值)
EXPLAIN SELECT * FROM ltshop_goods_spec_value_sort
WHERE goods_id = 10001 AND spec_id = 10023
ORDER BY sort ASC;
初始状态:规格A(sort=10) → 规格B(sort=20) → 规格C(sort=30)
↓
插入新规格D到B和C之间
↓
重新计算:D=25,B和C不动(如果25还在间隔内)
↓
如果超出间隔(如插入10个新规格),则触发重排:
↓
重排后:A(10) → B(20) → D(30) → C(40)
/**
* 商品规格排序服务
* Class GoodsSpecSortService
*/
class GoodsSpecSortService
{
private const SORT_STEP = 10; // 排序间隔
private const SORT_INIT = 10; // 初始排序值
private const REBALANCE_THRESHOLD = 3; // 重排阈值(剩余间隔不足3时触发重排)
/**
* 批量保存规格组排序
* @param int $goodsId 商品ID
* @param array $specIds 规格组ID数组(按排序顺序)
* @param int $wxappId 小程序ID
* @return bool
*/
public function saveSpecGroupSort(int $goodsId, array $specIds, int $wxappId): bool
{
$model = new GoodsSpecSort();
$time = time();
// 开启事务
$model->startTrans();
try {
// 1. 检查是否需要重排
$existing = $model->where('goods_id', $goodsId)
->where('wxapp_id', $wxappId)
->order('sort', 'asc')
->select()
->toArray();
// 2. 计算新的排序值
$sortData = [];
$sort = self::SORT_INIT;
foreach ($specIds as $specId) {
$sortData[$specId] = $sort;
$sort += self::SORT_STEP;
}
// 3. 检查是否需要重排(间隔不足)
$needsRebalance = $this->checkNeedsRebalance($existing, $sortData);
if ($needsRebalance) {
// 触发重排,重新分配所有排序值
$sortData = $this->rebalance($specIds);
}
// 4. 批量插入/更新
$insertData = [];
foreach ($sortData as $specId => $sort) {
$insertData[] = [
'goods_id' => $goodsId,
'spec_id' => $specId,
'sort' => $sort,
'wxapp_id' => $wxappId,
'create_time' => $time,
'update_time' => $time,
];
}
// 使用 REPLACE INTO 实现插入或更新
$model->replaceAll($insertData);
$model->commit();
return true;
} catch (Exception $e) {
$model->rollback();
throw $e;
}
}
/**
* 检查是否需要重排
*/
private function checkNeedsRebalance(array $existing, array $newData): bool
{
if (empty($existing)) {
return false;
}
// 检查新排序是否与现有排序冲突
$existingSorts = array_column($existing, 'sort', 'spec_id');
foreach ($newData as $specId => $newSort) {
if (isset($existingSorts[$specId])) {
// spec_id 已存在,检查排序值差距
if (abs($existingSorts[$specId] - $newSort) >= self::REBALANCE_THRESHOLD) {
return true;
}
}
}
// 检查是否有重复排序值
$sortCounts = array_count_values(array_column($existing, 'sort'));
foreach ($sortCounts as $count) {
if ($count > 1) {
return true;
}
}
return false;
}
/**
* 重排所有规格组排序
*/
private function rebalance(array $specIds): array
{
$sortData = [];
$sort = self::SORT_INIT;
foreach ($specIds as $specId) {
$sortData[$specId] = $sort;
$sort += self::SORT_STEP;
}
return $sortData;
}
}
1. 查询规格组排序(ltshop_goods_spec_sort)
↓
2. 查询规格值排序(ltshop_goods_spec_value_sort)
↓
3. 与规格关系表(ltshop_goods_spec_rel)联合查询
↓
4. 按 sort 字段排序返回
/**
* 获取商品规格数据(带排序)
* @param int $goodsId 商品ID
* @param int $wxappId 小程序ID
* @return array
*/
public function getGoodsSpecData(int $goodsId, int $wxappId): array
{
// 1. 查询规格组排序
$specSortList = (new GoodsSpecSort())
->where('goods_id', $goodsId)
->where('wxapp_id', $wxappId)
->column('sort', 'spec_id');
// 2. 查询规格值排序
$valueSortList = (new GoodsSpecValueSort())
->where('goods_id', $goodsId)
->where('wxapp_id', $wxappId)
->select()
->toArray();
// 构建 spec_id => [value_id => sort] 的映射
$valueSortMap = [];
foreach ($valueSortList as $item) {
$valueSortMap[$item['spec_id']][$item['spec_value_id']] = $item['sort'];
}
// 3. 查询规格关系数据
$specRelList = (new GoodsSpecRel())
->with(['spec', 'specValue'])
->where('goods_id', $goodsId)
->where('wxapp_id', $wxappId)
->select()
->toArray();
// 4. 按排序组装数据
$specAttrData = [];
foreach ($specRelList as $item) {
$specId = $item['spec_id'];
$specValueId = $item['spec_value_id'];
// 获取排序值(默认为0,按spec_id和value_id排序)
$groupSort = $specSortList[$specId] ?? 0;
$valueSort = $valueSortMap[$specId][$specValueId] ?? 0;
// 构建排序键(用于多级排序)
$sortKey = sprintf('%05d_%05d_%05d', $groupSort, $specId, $valueSort);
$specAttrData[$sortKey] = [
'group_id' => $specId,
'group_name' => $item['spec']['spec_name'],
'spec_items' => [],
'group_sort' => $groupSort,
];
}
// 5. 按sortKey排序并构建spec_items
ksort($specAttrData);
$result = [];
$groupMap = [];
foreach ($specAttrData as $sortKey => $item) {
if (!isset($groupMap[$item['group_id']])) {
$groupMap[$item['group_id']] = count($result);
$result[] = [
'group_id' => $item['group_id'],
'group_name' => $item['group_name'],
'spec_items' => [],
'group_sort' => $item['group_sort'],
];
}
// 查找对应的spec_value并添加
foreach ($specRelList as $relItem) {
if ($relItem['spec_id'] == $item['group_id']) {
$valueSort = $valueSortMap[$item['group_id']][$relItem['spec_value_id']] ?? 0;
$result[$groupMap[$item['group_id']]]['spec_items'][] = [
'item_id' => $relItem['spec_value_id'],
'spec_value' => $relItem['spec_value']['spec_value'],
'value_sort' => $valueSort,
];
}
}
}
// 6. 对spec_items按value_sort排序
foreach ($result as &$group) {
usort($group['spec_items'], function($a, $b) {
return $a['value_sort'] - $b['value_sort'];
});
}
return $result;
}
POST /api/goods/sortSpec
请求参数:
{
"goods_id": 10001,
"spec_data": [
{
"spec_id": 10023,
"value_data": [
{"spec_value_id": 10025, "sort": 10},
{"spec_value_id": 10026, "sort": 20}
]
},
{
"spec_id": 10024,
"value_data": [
{"spec_value_id": 10027, "sort": 10}
]
}
]
}
响应示例:
{
"code": 1,
"msg": "排序已保存",
"data": null
}
GET /api/goods/getSpecSort?goods_id=10001
响应示例:
{
"code": 1,
"msg": "success",
"data": {
"spec_sort": {
"10023": 10,
"10024": 20
},
"value_sort": {
"10023": {
"10025": 10,
"10026": 20
},
"10024": {
"10027": 10
}
}
}
}
// components/goods/spec/GroupSortable.vue
<template>
<div class="spec-group-list">
<div
v-for="(group, index) in specGroups"
:key="group.spec_id"
class="spec-group-item"
:data-index="index"
>
<div class="drag-handle">
<i class="iconfont icon-drag"></i>
</div>
<div class="group-content">
<span class="group-name">{{ group.spec_name }}</span>
<spec-value-sortable
:spec-id="group.spec_id"
:values="group.values"
@sort-change="onValueSortChange"
/>
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import SpecValueSortable from './ValueSortable.vue';
export default {
name: 'GroupSortable',
components: { SpecValueSortable },
props: {
goodsId: { type: Number, required: true },
specGroups: { type: Array, default: () => [] }
},
emits: ['sort-change'],
data() {
return {
sortable: null
};
},
mounted() {
this.initSortable();
},
beforeUnmount() {
this.sortable && this.sortable.destroy();
},
methods: {
initSortable() {
const el = this.$el;
this.sortable = new Sortable(el, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
onEnd: this.handleDragEnd
});
},
handleDragEnd(evt) {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
// 移动数组元素
const movedGroup = this.specGroups.splice(oldIndex, 1)[0];
this.specGroups.splice(newIndex, 0, movedGroup);
// 触发保存
this.saveSort();
},
saveSort() {
const specData = this.specGroups.map((group, index) => ({
spec_id: group.spec_id,
sort: (index + 1) * 10,
value_data: group.values.map((v, vIndex) => ({
spec_value_id: v.spec_value_id,
sort: (vIndex + 1) * 10
}))
}));
this.$api.post('/goods/sortSpec', {
goods_id: this.goodsId,
spec_data: specData
}).then(res => {
if (res.code !== 1) {
this.$message.error(res.msg);
}
});
},
onValueSortChange(specId, newValues) {
const group = this.specGroups.find(g => g.spec_id === specId);
if (group) {
group.values = newValues;
}
this.saveSort();
}
}
};
</script>
// components/goods/spec/ValueSortable.vue
<template>
<div class="spec-value-list">
<div
v-for="value in values"
:key="value.spec_value_id"
class="spec-value-item"
:data-id="value.spec_value_id"
>
<span class="drag-handle">
<i class="iconfont icon-drag"></i>
</span>
<span class="value-name">{{ value.spec_value }}</span>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
export default {
name: 'ValueSortable',
props: {
specId: { type: Number, required: true },
values: { type: Array, default: () => [] }
},
emits: ['sort-change'],
data() {
return {
sortable: null
};
},
mounted() {
this.initSortable();
},
beforeUnmount() {
this.sortable && this.sortable.destroy();
},
methods: {
initSortable() {
const el = this.$el;
this.sortable = new Sortable(el, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
onEnd: this.handleDragEnd
});
},
handleDragEnd(evt) {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
// 移动数组元素
const movedValue = this.values.splice(oldIndex, 1)[0];
this.values.splice(newIndex, 0, movedValue);
// 通知父组件
this.$emit('sort-change', this.specId, [...this.values]);
}
}
};
</script>
/**
* 规格排序缓存服务
* Class SpecSortCacheService
*/
class SpecSortCacheService
{
private const CACHE_KEY_SPEC = 'goods_spec_sort:%d:%d'; // goods_id:wxapp_id
private const CACHE_KEY_VALUE = 'goods_value_sort:%d:%d:%d'; // goods_id:spec_id:wxapp_id
private const CACHE_TTL = 86400; // 24小时
/**
* 获取规格组排序缓存
*/
public function getSpecSortCache(int $goodsId, int $wxappId): ?array
{
$key = sprintf(self::CACHE_KEY_SPEC, $goodsId, $wxappId);
$cache = Cache::get($key);
return $cache ?: null;
}
/**
* 设置规格组排序缓存
*/
public function setSpecSortCache(int $goodsId, int $wxappId, array $data): void
{
$key = sprintf(self::CACHE_KEY_SPEC, $goodsId, $wxappId);
Cache::set($key, $data, self::CACHE_TTL);
}
/**
* 获取规格值排序缓存
*/
public function getValueSortCache(int $goodsId, int $specId, int $wxappId): ?array
{
$key = sprintf(self::CACHE_KEY_VALUE, $goodsId, $specId, $wxappId);
$cache = Cache::get($key);
return $cache ?: null;
}
/**
* 设置规格值排序缓存
*/
public function setValueSortCache(int $goodsId, int $specId, int $wxappId, array $data): void
{
$key = sprintf(self::CACHE_KEY_VALUE, $goodsId, $specId, $wxappId);
Cache::set($key, $data, self::CACHE_TTL);
}
/**
* 清除商品排序缓存
*/
public function clearGoodsSortCache(int $goodsId, int $wxappId): void
{
$specKey = sprintf(self::CACHE_KEY_SPEC, $goodsId, $wxappId);
Cache::delete($specKey);
// 清除所有规格值的缓存(模糊匹配)
$pattern = sprintf('goods_value_sort:%d:*:%d', $goodsId, $wxappId);
$keys = Cache::tag('goods_sort')->clear($pattern);
}
}
┌─────────────────────────────────────────────────────────────────┐
│ 缓存更新策略 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 写入时: │
│ 1. 更新数据库 │
│ 2. 删除缓存(Cache-Aside模式) │
│ 3. 下次查询时自动回填 │
│ │
│ 读取时: │
│ 1. 查询缓存 → 命中则返回 │
│ 2. 未命中 → 查询数据库 → 写入缓存 → 返回 │
│ │
└─────────────────────────────────────────────────────────────────┘
/**
* 规格排序服务单元测试
*/
class GoodsSpecSortServiceTest extends TestCase
{
/**
* 测试:保存规格组排序
*/
public function testSaveSpecGroupSort()
{
$service = new GoodsSpecSortService();
$result = $service->saveSpecGroupSort(10001, [10023, 10024, 10025], 10001);
$this->assertTrue($result);
// 验证数据库
$specSort = (new GoodsSpecSort())
->where('goods_id', 10001)
->where('wxapp_id', 10001)
->column('sort', 'spec_id');
$this->assertEquals(10, $specSort[10023]);
$this->assertEquals(20, $specSort[10024]);
$this->assertEquals(30, $specSort[10025]);
}
/**
* 测试:中间插入规格后重排
*/
public function testInsertBetweenTriggersRebalance()
{
$service = new GoodsSpecSortService();
// 初始:A(10), B(20), C(30)
$service->saveSpecGroupSort(10001, [10023, 10024, 10025], 10001);
// 插入D到B和C之间
$service->saveSpecGroupSort(10001, [10023, 10026, 10024, 10025], 10001);
// 验证:D应该在B和C之间
$specSort = (new GoodsSpecSort())
->where('goods_id', 10001)
->order('sort')
->column('spec_id', 'sort');
$this->assertEquals([10 => 10023, 20 => 10026, 30 => 10024, 40 => 10025], $specSort);
}
/**
* 测试:查询带排序的规格数据
*/
public function testGetGoodsSpecDataWithSort()
{
$service = new GoodsSpecSortService();
$data = $service->getGoodsSpecData(10001, 10001);
// 验证排序正确
$this->assertEquals(10023, $data[0]['group_id']);
$this->assertEquals(10024, $data[1]['group_id']);
}
}
| 指标 | 标准 | 测试方法 |
|---|---|---|
| 单商品规格查询 | < 10ms | ab -n 1000 |
| 批量排序更新(10组) | < 50ms | 接口压测 |
| 100商品并发查询 | QPS > 500 | wrk压测 |
| 缓存命中率 | > 95% | Redis MONITOR |
| 风险 | 概率 | 影响 | 应对措施 |
|---|---|---|---|
| 排序数据丢失 | 低 | 高 | 事务保护 + 备份表 |
| 缓存雪崩 | 中 | 中 | 缓存过期时间随机化 |
| 主从延迟 | 低 | 低 | 读写分离 + 强制从主库写 |
| 跨版本兼容 | 低 | 高 | 接口版本控制 |
-- 回滚脚本:删除排序表
BEGIN;
-- 备份数据
CREATE TABLE ltshop_goods_spec_sort_backup AS SELECT * FROM ltshop_goods_spec_sort;
CREATE TABLE ltshop_goods_spec_value_sort_backup AS SELECT * FROM ltshop_goods_spec_value_sort;
-- 删除排序表
DROP TABLE IF EXISTS ltshop_goods_spec_sort;
DROP TABLE IF EXISTS ltshop_goods_spec_value_sort;
-- 验证回滚(检查商品详情是否正常)
SELECT goods_id, goods_name FROM ltshop_goods WHERE goods_id = 10001;
COMMIT;
| 阶段 | 内容 | 交付物 |
|---|---|---|
| Phase 1 | 数据库设计与创建 | 建表SQL |
| Phase 2 | 后端Service层开发 | 排序服务类 |
| Phase 3 | API接口开发 | RESTful接口 |
| Phase 4 | 前端组件开发 | Vue拖拽组件 |
| Phase 5 | 缓存集成 | Redis缓存方案 |
| Phase 6 | 单元测试 | 测试报告 |
| Phase 7 | 集成测试 | 测试报告 |
| Phase 8 | 上线部署 | 部署文档 |
本方案通过以下设计实现了一个高效、可扩展的商品规格排序系统:
方案具备以下优势:
来团科技GEO优化&AI搜索优化系统,是通过大模型内容投喂+训练,将企业品牌及产品信息在多平台AI生成的答案中获取优先展现,更精准触达潜在目标客户,让企业品牌出现在AI搜索里。让客户一搜就看到你,实现一问就有你,一查就信你,一看就找你的营销效果。
来团智慧商业小程序零代码开发平台,多行业适配。无需代码,拖拽式设计,轻松打造订货商城、会员制商城、分销商城及小程序官网。不仅能满足通用需求,还支持定制化,从页面布局到功能模块,随心定制,助您快速搭建专属商业小程序,抢占市场先机。
来团科技微名通不止是电子名片,更是你的商业连接器。比起传统名片,它更像你的 “迷你商业工具”:信息多、好携带、能互动,还不浪费纸张。不管是跑业务、拓人脉,还是展示企业,一张「微名通」电子名片,就能帮你把商机揣在手机里。
来团科技CRM客户管理系统,帮你把 “线索→成交→回款” 全流程管明白。这就是一套 “让销售省心、老板放心” 的客户管理工具,从获客到回款,帮你把生意攥在手里。