免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

中古玩具店库存与交易系统设计:从单品实例到防超卖实战

中古玩具店库存与交易系统设计:从单品实例到防超卖实战 带这个话题写一篇 CSDN 技术长文需要做一个很重要的转变中古模型玩具店这个现象本身不是技术主题但它背后有一套非常典型的信息系统设计问题。一家线下的中古玩具店如果想把库存、鉴定、交易、线上展示串起来会遇到大量普通电商系统没有覆盖的场景同一款模型可能有好多件但每一件的成色、盒况、配件都不一样价格不是全国统一价而是随热度、稀有度、品相波动交易也经常不是标准快递发货而是同城自提、展会面交。所以这篇文章真正要回答的问题是一家做非标孤品生意的线下门店到底需要一套什么样的系统普通电商 SaaS 能不能直接拿去用如果不能核心表结构该怎么设计库存锁单怎么做鉴定记录怎么留痕线上展示又该怎么和线下库存保持一致。文章会从需求分析一直讲到表结构、核心代码、防超卖方案、常见排错和生产建议。读者如果正在做类似的二手商品、收藏品、潮玩交易系统或者想给自己的线下门店做数字化改造这篇可以直接作为起步参考。1. 中古模型玩具店到底难在哪普通电商系统为什么不够用中古模型玩具和标准电商商品有本质区别。普通电商卖的是标品一个手机壳、一箱纸巾库存只有一个数字商品属性完全一样发哪一件都一样。但中古玩具店里货架上的每一件高达、每一盒变形金刚、每一个兵人都是独立的个体。同样一款 RG 1/144 独角兽高达市面上可能有五件在售一件全新未拆盒况完美价格最高一件素组过零件齐全但水口明显价格中等一件缺了一个配件价格很低一件带初版特典价格翻倍一件盒损严重但内容物全新适合自玩价格介于中间。对买家来说他买的不是独角兽高达这个商品而是这一件具体物品。对卖家来说库存管理也不是数量减一而是要精确知道卖掉的是哪一件、剩下的是哪一件、每件分别放在哪个货位、有没有被预订过、鉴定记录是什么。这就是普通电商系统的第一个瓶颈它们的商品模型基于模板 数量而中古玩具店的商品模型必须基于模板 单品实例。同一个模板下每一件实例都有自己的状态、成色、价格、图片、鉴定记录和生命周期。第二个瓶颈是价格体系。电商系统的价格通常挂在商品模板上顶多做一下 SKU 维度。但中古玩具的价格是一物一价的而且会随时间变化。某个系列再版了老版价格就会下跌某款绝版了价格又会上涨同一件商品因为成色从 A 级被重新评估降为 B 级定价也要立刻调整。如果价格只存在商品表里的一个字段一旦改价历史成交价格就没法追溯。第三个瓶颈是交易流程。线上标准订单是加购—支付—仓库发货—物流签收。中古店常见的是咨询—锁单—线下看货/自提—当面交易也可能有线上预订—到货后通知—再来支付这种信任链。订单状态不是简单的待发货、已发货、已完成还要加入锁定、到店看货、暂存、取消释放等状态。所以结论很清楚直接把淘宝、有赞那种标准电商 SaaS 搬过来短期能撑一旦商品量超过几百件或者交易方式复杂起来库存错乱、超卖、鉴定无记录、历史价格不可查这些问题会全部暴露。中古玩具店需要的是一套围绕单品唯一性、成色状态流转、价格变化轨迹、鉴定记录留痕来设计的库存与交易系统。2. 系统整体设计从业务域拆解到技术选型在设计系统之前最好先把业务域拆清楚。一家中古玩具店线上化通常涉及下面几个核心域。商品域负责维护商品模板和单品实例。模板解决的是这是什么模型比如品牌、系列、货号、官方售价、比例、年份实例解决的是店里具体哪一件在哪成色如何卖多少钱。库存域负责单品实例的入库、上架、锁定、出库、下架。这是整个系统最重要的域状态必须非常严谨因为每一件都是孤品卖错一件、锁错一件都会造成实际损失。订单域负责交易流转。这里不要套标准电商的五态模型要额外支持只锁不付到店自提预订到货这类场景。鉴定与成色域负责每次成色评估的记录。中古玩具很依赖信任买家凭什么相信你标注的 A 级是真的凭更新后的成色记录、实物照片、鉴定编号。这个域在普通电商系统里根本不存在但在这里是核心。社区与内容域不是必须但很多中古店靠玩家社群做口碑。收藏知识、再版信息、开箱报告、成色科普都可以作为后续运营功能。初期可以不做但表结构上不要堵死扩展路径。技术选型上比较稳妥的组合是 Spring Boot MySQL Redis MinIO Vue。如果你的团队更熟悉 Python用 FastAPI/Django PostgreSQL 也完全可以本文后面的核心思路是通用的。这里不推荐一上来就上微服务、消息队列、Elasticsearch。一家中古店的初期数据量MySQL 完全扛得住。真正需要从一开始就重视的是库存变更的原子性和并发控制成色/价格/状态变更的历史留痕图片文件的对象存储以及数据库的定期备份。等到单店商品上万、搜索压力变大再把搜索切到 Elasticsearch 也不迟。技术架构不追求复杂追求的是每一件商品的状态在任何时刻都可解释、可回溯。3. 数据库模型设计围绕单品实例建模数据库设计是这套系统的核心。我见过很多半路改造的店铺系统最容易犯的错误是把商品和单品混在一张表里成色、价格、状态全部写在商品记录上。一旦同一款模型来了第二件就只能新建一条商品记录最后前台搜索全是重复商品库存逻辑也乱掉。正确的做法是拆成两层商品模板表和单品实例表。商品模板表负责描述这一类商品是什么。品牌、系列、货号这些属性放在这里。单品实例表负责描述具体某一件商品现在的状态。成色、盒况、配件、价格、库位、状态这些字段放在这里。下面给出一个可以直接落地的建表示例。数据库建议使用 MySQL 8.0字符集统一使用 utf8mb4。-- 文件路径sql/schema.sql CREATE TABLE product_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, brand VARCHAR(64) NOT NULL COMMENT 品牌如 BANDAI、TAKARA TOMY, series_name VARCHAR(128) NOT NULL COMMENT 系列如 RG 1/144 独角兽, model_no VARCHAR(64) DEFAULT NULL COMMENT 官方货号, category VARCHAR(32) DEFAULT NULL COMMENT 分类高达/变形金刚/乐高/兵人/手办, standard_price DECIMAL(10,2) DEFAULT NULL COMMENT 官方发售价或参考价, release_year INT DEFAULT NULL COMMENT 发售年份, image_url VARCHAR(512) DEFAULT NULL COMMENT 模板主图, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_brand_series_no (brand, series_name, model_no), KEY idx_category (category) ) ENGINE InnoDB COMMENT 商品模板表; CREATE TABLE item_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT 所属商品模板ID, instance_code VARCHAR(64) NOT NULL COMMENT 单品唯一编码系统生成或人工编写, condition_level VARCHAR(8) NOT NULL COMMENT 成色等级N/S/A/B/C, condition_desc VARCHAR(500) DEFAULT NULL COMMENT 成色说明允许写瑕疵细节, box_status VARCHAR(16) DEFAULT NULL COMMENT 盒况全新/有盒/无盒/盒损, accessories_status VARCHAR(128) DEFAULT NULL COMMENT 配件情况齐全/缺XX/含特典, cert_no VARCHAR(64) DEFAULT NULL COMMENT 鉴定编号或防伪编码, price DECIMAL(10,2) NOT NULL COMMENT 当前售价一物一价, status VARCHAR(16) NOT NULL DEFAULT ON_SHELF COMMENT 状态ON_SHELF在售/BOOKED已锁/SOLD已售/OFF_SHELF下架, warehouse_location VARCHAR(32) DEFAULT NULL COMMENT 库位如 A-3-2, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_instance_code (instance_code), KEY idx_product (product_id), KEY idx_status (status) ) ENGINE InnoDB COMMENT 单品实例表;这里 id 使用的是数据库自增主键但在实际展示、盘点、客服沟通时建议让每个单品有一个业务编码 instance_code。比如一家临沂店可以用LY-01-BANDAI-0001这种风格其中包含门店、分类、品牌、序号信息方便实物标签和线上系统对应。商品属性不要全部堆在主表里。同一个模板下不同批次的盒况、板件、特典可能完全不同这些差异应该体现在单品实例表的 condition_desc、accessories_status 字段中而不是人为拆出几十个 SKU 字段。价格为什么要单独考虑虽然上面 item_instance 表里有一个 price 字段但它只代表当前售价。历史价格变化需要单独记录否则你无法判断这个东西是涨价了还是跌了这类数据对中古玩具定价非常有价值。CREATE TABLE price_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, old_price DECIMAL(10,2) NOT NULL, new_price DECIMAL(10,2) NOT NULL, operator_id BIGINT NOT NULL COMMENT 操作人, reason VARCHAR(255) DEFAULT NULL COMMENT 调价原因如再版/绝版/成色变更, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_item (item_id) ) ENGINE InnoDB COMMENT 单品价格历史表;调价时先写 price_history再更新 item_instance.price。这两个操作放在同一个事务里。这样即使过了很久也依然能回答这件商品从入店到现在价格经历了什么变化、为什么变化。商品模板和单品实例分离之后前台展示就很好处理了商品列表页展示模板信息点进去之后展示当前在售的所有单品实例每一件都有独立的价格、成色、图片、状态。买家看到的不是库存 N 件而是这个链接下面有 5 件在售分别是什么成色、什么价格。4. 核心功能一孤品入库、库存锁定与防超卖中古玩具最怕的事情是超卖。普通电商超卖一件最多补发一件库存中古玩具超卖一件可能意味着两单只能成一单你要跟另一单买家道歉、退款、谈补偿。更复杂的是线下顾客可能同时在店里看货店员已经在打单了线上又有人拍下同一件两边同时成交就是事故。所以要明确一个原则单品实例的每一次状态变更必须是有条件的、原子化的更新不能被先查再改这种两条 SQL 的方式带偏。先看一下错误的做法// 错误示例先查询再修改存在并发问题 ItemInstance item itemMapper.selectById(itemId); if (ON_SHELF.equals(item.getStatus())) { item.setStatus(BOOKED); itemMapper.updateById(item); // 可能被另一个请求覆盖 return true; } return false;这种查询后判断再更新的写法在并发量稍微上来一点就会出现问题。两个请求同时查到了 ON_SHELF又同时执行 update后一个覆盖前一个但两个客户都以为自己下单成功了。正确的做法是使用条件更新直接在 UPDATE 语句中带上状态条件UPDATE item_instance SET status BOOKED WHERE id #{itemId} AND status ON_SHELF;只要这个 UPDATE 影响的行数是 1就说明你成功把这一件从在售改成了已锁。如果影响行数是 0说明这件商品已经被别人锁走或者状态已经不是 ON_SHELF此时直接返回锁定失败不需要任何补偿逻辑。在 Spring Boot 项目中使用 MyBatis-Plus 可以这样写// 文件路径src/main/java/com/example/legacyshop/mapper/ItemInstanceMapper.java public interface ItemInstanceMapper extends BaseMapperItemInstance { Update(UPDATE item_instance SET status #{targetStatus} WHERE id #{itemId} AND status #{expectStatus}) int compareAndSetStatus(Param(itemId) Long itemId, Param(expectStatus) String expectStatus, Param(targetStatus) String targetStatus); }注意这里的字段名要和数据库列名一致mybatis 默认会把驼峰映射成下划线itemId 对应 item_id。如果项目里没有开启驼峰映射可以在 application.yml 中配置mybatis-plus: configuration: map-underscore-to-camel-case: true在这个基础上再叠加一个分布式锁用来防止有两个订单同时尝试对一个单品做复杂操作。库存锁定本身用条件 UPDATE 就能保证安全但下单流程里可能还要写订单表、写日志、锁定客户额度涉及多个步骤时用 Redis 锁把整个操作串起来更稳妥。// 文件路径src/main/java/com/example/legacyshop/service/TradeService.java Service public class TradeService { private static final String LOCK_PREFIX shop:lock:item:; Autowired private StringRedisTemplate redisTemplate; Autowired private ItemInstanceMapper itemInstanceMapper; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public boolean lockAndCreateOrder(Long itemId, Long customerId) { String lockKey LOCK_PREFIX itemId; // 尝试加锁避免同一件商品短时间内被多个订单流程同时进入 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, customerId.toString(), Duration.ofMinutes(15)); if (!Boolean.TRUE.equals(locked)) { return false; } try { int updated itemInstanceMapper.compareAndSetStatus(itemId, ON_SHELF, BOOKED); if (updated 0) { return false; } Order order new Order(); order.setItemId(itemId); order.setCustomerId(customerId); order.setStatus(BOOKED); orderMapper.insert(order); return true; } finally { redisTemplate.delete(lockKey); } } }这个设计里有两个保险Redis 锁解决同一件商品被并发请求同时进入下单流程的问题数据库条件更新解决即使 Redis 锁失效或代码绕过了锁也不会出现超卖的问题。实际操作中Redis 锁的过期时间要足够长避免业务处理超过锁过期时间后另一个请求进入。上面示例写了 15 分钟你可以根据自己的流程时长调整但最好根据锁的 value这里是 customerId来判断只有持有锁的请求才能在 finally 里释放否则可能误删别的线程加的锁。订单取消时要做相反操作UPDATE item_instance SET status ON_SHELF WHERE id #{itemId} AND status BOOKED;只允许从 BOOKED 回到 ON_SHELF不能把已经 SOLD 的商品重新改成在售。状态机越严格后续出错的概率越低。5. 核心功能二成色鉴定记录与状态流转中古玩具行业的信任来自成色描述。相同一件商品有人标 A 级、有人标 B 级买家最终相信谁不是靠卖家嘴说而是靠照片、文字描述、鉴定记录。所以系统里一定要有一个独立的成色记录表每次评估或复检都追加一条记录而不是直接覆盖。成色等级可以参考日本中古市场的通用习惯N 为全新未拆S 为近乎全新A 为保存良好有轻微使用痕迹B 为正常使用痕迹或轻微瑕疵C 为明显瑕疵或缺件。当然每家电自己的标准可以调整关键是记录留痕。建表语句可以这样设计CREATE TABLE condition_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL COMMENT 单品实例ID, operator_id BIGINT NOT NULL COMMENT 鉴定/操作人ID, before_level VARCHAR(8) DEFAULT NULL COMMENT 鉴定前成色等级, after_level VARCHAR(8) NOT NULL COMMENT 鉴定后成色等级, evidence_images TEXT DEFAULT NULL COMMENT 证据图片存OSS路径JSON数组, description VARCHAR(500) DEFAULT NULL COMMENT 瑕疵说明如盒子有压痕、水口明显, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_item (item_id) ) ENGINE InnoDB COMMENT 成色鉴定记录表;这里的 evidence_images 建议存 JSON 数组字符串而不是把逗号拼接的图片路径塞进一个 VARCHAR。JSON 格式方便以后扩展也方便前端直接解析。在 Java 侧成色审核服务需要保证两件事追加一条记录同时更新单品实例的当前成色。这两个操作必须在同一个事务里完成。// 文件路径src/main/java/com/example/legacyshop/service/ConditionAuditService.java Service public class ConditionAuditService { Autowired private ConditionRecordMapper conditionRecordMapper; Autowired private ItemInstanceMapper itemInstanceMapper; Transactional(rollbackFor Exception.class) public void audit(Long itemId, Long operatorId, String beforeLevel, String afterLevel, ListString images, String description) { ItemInstance item itemInstanceMapper.selectById(itemId); if (item null) { throw new BusinessException(单品不存在); } ConditionRecord record new ConditionRecord(); record.setItemId(itemId); record.setOperatorId(operatorId); record.setBeforeLevel(beforeLevel); record.setAfterLevel(afterLevel); record.setEvidenceImages(JSON.toJSONString(images)); record.setDescription(description); conditionRecordMapper.insert(record); ItemInstance update new ItemInstance(); update.setId(itemId); update.setConditionLevel(afterLevel); itemInstanceMapper.updateById(update); } }这里有几个容易踩的坑。坑一before_level 不应该偷偷从数据库里查出来写入。成色鉴定应该基于操作员在鉴定页面看到的当前值如果两人同时操作后提交的人可能基于旧值覆盖新值。简单做法是前端打开鉴定页面时带上当前成色等级提交时把这个值带回来服务端再校验一遍不一致就让用户刷新重试。坑二证据图片必须真实可追溯。有些系统只允许上传图片不限制图片是否属于当前商品。业务上建议在图片上传时就指定 itemId把图片路径写入该商品的图片目录然后再与成色记录关联。这样即使以后有争议也能从存储结构上证明图片来自该单品的上传记录。坑三成色变更往往伴随价格变更。A 级变成 B 级价格一般要下调。在鉴定事务里应该一起写入 price_history并同步更新 item_instance.price。不要让操作员先改完成色再去单独改价格那样很容易漏。Transactional(rollbackFor Exception.class) public void auditWithPrice(Long itemId, Long operatorId, String beforeLevel, String afterLevel, BigDecimal oldPrice, BigDecimal newPrice, ListString images, String description) { // 1. 追加成色记录 // 2. 追加价格历史 // 3. 更新 item_instance 的 condition_level 和 price }这样一件商品从入店、初次鉴定、多次复检、调价、售出整个过程都有完整的时间线。6. 核心功能三商品展示、图片存储与搜索商品展示系统分为两层前台买家看到的是模板 单品列表后台店员看到的是库位 单品 状态。前端可以做两个页面但都依赖后端提供的接口。图片存储建议使用 MinIO 或阿里云 OSS 这类对象存储。MinIO 的优点是私有化部署、成本低、SDK 成熟。下面是一个基于 MinIO Java SDK 8.x 风格的上传示例具体版本以项目实际依赖为准。// 文件路径src/main/java/com/example/legacyshop/service/ImageStorageService.java Service public class ImageStorageService { Autowired private MinioClient minioClient; Value(${minio.bucket:model-shop}) private String bucket; public String upload(MultipartFile file) throws Exception { String originalName file.getOriginalFilename(); String suffix ; if (originalName ! null originalName.contains(.)) { suffix originalName.substring(originalName.lastIndexOf(.)); } String objectName products/ UUID.randomUUID() suffix; minioClient.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); return https://cdn.example.com/ objectName; } }注意几点对象名称不要直接用用户上传的原始文件名要重新生成防止重名、路径穿越、文件名乱码。图片路径最终要拼接出可访问的 URL。如果桶策略是私有读那么不应该返回永久 URL而是生成临时签名地址或者让请求经过后端代理读取。商品图建议区分模板主图和单品实拍图。模板主图可以来自官方素材单品实拍图必须真实对应到具体那一件。搜索场景初期用 MySQL 的 LIKE 查询就能解决。中古模型玩具店的商品量通常几百到几千件模板表可能上万行简单查询没有压力。// 文件路径src/main/java/com/example/legacyshop/service/ProductSearchService.java Service public class ProductSearchService { Autowired private ProductTemplateMapper productTemplateMapper; public PageProductTemplate search(String keyword, String brand, int page, int size) { LambdaQueryWrapperProductTemplate wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w .like(ProductTemplate::getSeriesName, keyword) .or().like(ProductTemplate::getModelNo, keyword) .or().like(ProductTemplate::getBrand, keyword)); } if (StringUtils.hasText(brand)) { wrapper.eq(ProductTemplate::getBrand, brand); } wrapper.orderByDesc(ProductTemplate::getUpdatedAt); return productTemplateMapper.selectPage(new Page(page, size), wrapper); } }这个接口返回的是模板分页前端展示出模板之后再调用单品列表接口// 文件路径src/main/java/com/example/legacyshop/service/ItemQueryService.java Service public class ItemQueryService { Autowired private ItemInstanceMapper itemInstanceMapper; public ListItemInstance listOnShelfItems(Long productId) { LambdaQueryWrapperItemInstance wrapper new LambdaQueryWrapper(); wrapper.eq(ItemInstance::getProductId, productId) .eq(ItemInstance::getStatus, ON_SHELF) .orderByDesc(ItemInstance::getPrice); return itemInstanceMapper.selectList(wrapper); } }当数据量增长到 MySQL 的 LIKE 查询明显变慢或者需要支持多标签、多条件筛选时再引入 Elasticsearch 或 OpenSearch。索引数据时注意单品实例是真正要检索的粒度但展示时需要关联模板信息所以索引结构一般设计成模板 在售单品列表的嵌套结构而不是把所有单品全部平铺到索引里。图片存储有个容易被忽略的问题图片上传不等于图片审核。中古玩具有时涉及版权盒子外观虽然绝大多数拍摄照片用于商品展示没有问题但建议后台保留图片记录运营上注意不要上传与商品无关的图片。系统层面要在上传接口里做好文件类型、大小、内容校验避免恶意文件上传。7. 常见问题与排查方法这套系统在开发、上线、运营过程中有几个问题出现频率很高。下面整理成排查表遇到问题可以先按这个思路走。问题现象可能原因排查方式解决方案同一件商品被两个订单同时锁定成功没有使用条件 UPDATE或 Redis 锁过期时间太短查看订单创建时间、单品状态变更日志统一使用 UPDATE ... WHERE statusON_SHELF并检查 Redis 锁过期时间订单取消后商品还是“已锁定”状态状态机不完整取消/超时回调没有释放库存查看订单状态流转日志和 item_instance.status在订单关闭、超时、取消时统一调用 compareAndSetStatus(BOOKED,ON_SHELF)成色记录有值但商品成色没变只写入了 condition_record没有同步更新 item_instance对照两个表的数据时间线把记录追加和实例更新放在同一个事务中图片上传成功但前端展示 403MinIO/OSS 桶为私有读返回的 URL 不可公网访问直接访问返回的 URL查看错误码使用临时签名 URL 或通过后端代理读取商品搜索关键词搜不到数据库里 series_name 与用户输入不完全一致查看 LIKE 条件和字符集在搜索入口做关键词归一化或升级为 Elasticsearch后台改价后前台还是旧价格改了缓存没有刷新或前端页面有 CDN 缓存检查 Redis 缓存 key 和 CDN 缓存策略改价后主动删除或更新缓存设置合理缓存过期时间单品编号重复导致无法入库instance_code 没有唯一约束或人工编号重复查看短时间内的入库记录数据库加唯一索引入库前做重复校验库存盘点时发现系统状态与实物不一致线下售出后未及时在系统操作回看盘点差异记录和操作日志建立线下收款即扫单品的强流程定期盘点最值得提前预防的其实是第一项并发锁单。建议在开发阶段就写并发测试脚本比如用 JMeter 或其他压测工具对同一个 itemId 发起 50 个并发请求断言最终只有 1 个成功。这个测试通过才能说明防超卖逻辑基本可靠。另一个容易出问题的地方是操作人员线下场景。店员平时很忙可能先收了钱、把货拿给顾客回来之后才补录系统。这时候系统状态就可能滞后。实际运营上建议让店员养成先扫单品码再收款的习惯把系统操作前置。技术侧则要在商品实物上贴唯一的二维码标签扫码可以直接进入该单品的下单或状态变更页面。8. 生产环境最佳实践与工程建议8.1 单品实例编码规范instance_code 一定要有业务含义方便线下沟通。推荐格式门店前缀 分类缩写 年份序号。比如临沂门店可以用LY-GK-2025-0001表示高达分类第一件。编码生成后不可复用即使商品已经售出编号也不能分配给下一件。这样才能保证历史订单可以精确对应到某一件商品。8.2 状态机要严格收敛单品状态从创建开始只能按设定好的方向流转。建议不要允许随意跳转入库后为 ON_SHELFON_SHELF 可以流转到 BOOKEDBOOKED 可以流转回 ON_SHELF或者流转到 SOLD只有 ON_SHELF 可以流转到 OFF_SHELFOFF_SHELF 重新上架也必须经过 ON_SHELF而不是直接跳回 BOOKED。把状态变更封装在服务层不要在每个 Controller 里直接 update status。这样以后要加校验、加日志、加消息通知只需要改一个地方。8.3 所有关键操作都要留操作日志中古玩具交易纠纷率比标品高因为每次交易都涉及成色、价格、履约方式。至少记录这些操作入库、改价、成色鉴定、锁单、取消、售出、盘点调整、下架。日志里要包含操作人、操作时间、操作前后的值。很多项目只记录谁在什么时间做了什么但没有记录操作前后的值。这远远不够。如果改价前是 800改价后是 750日志里必须同时有 800 和 750否则以后复盘价格纠纷就困难。8.4 数据库备份与恢复演练库存数据、订单数据、成色记录是这个系统的核心资产数据库备份绝不能省。建议每天全量备份每个小时做一次 binlog 增量备份。备份文件要留存足够长时间并且定期做恢复演练。不要等到硬盘坏了才发现备份不可用。8.5 图片与对象存储的管理图片目录按商品层级组织建议结构为products/{productId}/{itemId}/{timestamp}.jpg这样以后想清理、迁移、打水印都比较方便。图片上传后不要立刻把 URL 直接存入数据库最好存入相对路径由网关或 CDN 统一拼接域名。域名变更时只需要改一个配置。8.6 Redis 依赖与数据库兜底Redis 锁可以提高并发下的正确性但不能把它当作唯一防线。数据库条件更新才是最后一道防线。如果团队对 Redis 运维不熟初期可以不引入 Redis只靠数据库的条件 UPDATE 也能保证不超卖只是某些复杂流程可能稍微慢一点。等到有经验了再逐步引入缓存。8.7 权限与数据安全后台系统至少要有三种角色店员、店长、系统管理员。店员可以创建订单、改单品备注但不能直接改价、不能删除任何记录店长可以改价、做鉴定、上下架系统管理员负责账号和配置。不要在初期把所有权限都放开等出了问题再收紧只会更麻烦。8.8 订单号与交易凭证订单号不要用自增 ID最好用时间戳 门店码 随机数生成的业务订单号。交易凭证上要包含商品名称、实例编码、成色、价格、操作员工号。中古玩具的售后沟通非常依赖这些信息凭证越清楚后面的麻烦越少。8.9 线上与线下库存一致同一件商品可能线下已经卖掉了但线上还挂着。解决办法是让系统成为唯一数据源线下收款必须走系统生成订单线上付款直接扣库存。每一件商品只有一个状态售卖入口可以有多个但状态变更必须收敛在同一套逻辑里。可以引入盘点批次流程每周用 PDA 扫码核对实际库存把差异控制到最小。8.10 上线节奏与灰度不要一上来就把所有模块全部上线。建议分三步走第一步先跑通入库、展示、锁单、线下成交这一条主链路第二步加上成色鉴定、价格历史、订单取消释放第三步再做线上支付、快递发货、会员体系。每一步上线前都要造一批测试数据尤其是并发锁单、取消释放、改价回滚这几个高风险的场景。9. 总结与后续优化方向回到开始的判断中古模型玩具店需要的不是一套标准电商系统而是一套围绕单品唯一性设计的库存与交易系统。普通电商的模板 数量模型解决不了孤品商品的成色、价格、鉴定和线下履约问题这部分必须靠独立的单品实例模型、严格的状态机、条件更新防超卖、以及完整的成色与价格历史记录来实现。本文给出的方案从业务域拆分、数据库表设计、核心代码实现到常见排错和工程建议已经可以支撑一家中小型中古玩具店完成数字化起步。如果你正在做类似项目建议先把防超卖和状态机做对再考虑搜索、社区这些锦上添花的功能。后续可以深入的方向也有几条用收藏知识库和图片识别辅助鉴定让普通店员也能做初步成色判断积累历史成交和价格波动数据给每件商品生成建议定价区间做玩家社区让买家可以关注某件商品、到货通知、预约到店看货接入统一的物流和保价方案处理异地交易场景。这类非标商品交易系统的核心永远是每一件商品的状态可解释、可追溯。把这套底座打牢再往上加业务形态都会比较顺。建议先拿一个最小闭环跑起来再根据实际运营数据迭代。这篇内容建议收藏备用等真正动手设计的时候对照着表结构和代码示例走一遍会比看完就忘有效得多。
返回列表