景区票务小程序:分时段库存、实名票与核销闸机的一致性设计

简介: 本文以青海湖等高原景区为背景,详解景区票务小程序核心难点:分时段库存动态分配、实名票一人一证加密校验、高并发下幂等核销(Redis+MySQL双校验),并提供Node.js+MySQL 8可落地数据模型与代码实现,兼顾合规性与高性能。

景区票务小程序和普通电商最大的区别是:它卖的不是商品,是一段时间内某个空间的使用权。这个特性决定了三件事必须做对——分时段库存怎么算、实名票怎么绑、核销怎么做才能不重不漏。

本文以青海湖、茶卡这类高原景区为背景,给出可落地的数据模型与关键实现。示例用 Node.js + MySQL 8,核销部分给出 Redis 方案。

一、分时段库存:为什么不能按"总库存"卖

一个景区一天接 8000 人,如果只设一个总数 8000,会出现什么问题?

上午九点到十一点,三千人同时到门口,检票队伍排出五百米,观光车排队一小时;下午三点,园区空了一半。总收入没变,体验崩了。

分时段的意义是把"总量控制"变成"流量曲线控制"。但一分时段,库存模型就复杂了:总库存 8000 要拆成若干时段,每个时段有自己的容量,还要处理"某时段卖完了能不能调剂到相邻时段"。

先看模型:

-- 场次(一个景区一天可以有多个时段)
CREATE TABLE ticket_slot (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  scenic_id     BIGINT NOT NULL,
  biz_date      DATE NOT NULL,
  start_time    TIME NOT NULL,
  end_time      TIME NOT NULL,
  capacity      INT NOT NULL COMMENT '该时段容量',
  sold          INT NOT NULL DEFAULT 0,
  locked        INT NOT NULL DEFAULT 0,
  status        TINYINT NOT NULL DEFAULT 1 COMMENT '1可售 0关闭',
  UNIQUE KEY uk_scenic_date_start (scenic_id, biz_date, start_time),
  KEY idx_date (biz_date)
);
-- 票种(成人票、学生票、老人票),价格挂在这里
CREATE TABLE ticket_type (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  scenic_id   BIGINT NOT NULL,
  name        VARCHAR(60) NOT NULL,
  price_cents INT NOT NULL,
  need_realname TINYINT NOT NULL DEFAULT 1,
  stock_mode  TINYINT NOT NULL DEFAULT 1 COMMENT '1按场次 2全场通用',
  KEY idx_scenic (scenic_id)
);

stock_mode 这个字段很重要。有的票种必须绑场次(比如限流的博物馆),有的票种全场通用(比如年卡)。两种模式的扣减逻辑不同,混在一起写会在后面出大问题。

二、下单扣减:条件更新 + 事务边界

抢票的并发比普通电商高一个量级,尤其是在节假日开售那一分钟。核心只有一条原则:把判断写进 SQL 的 WHERE 里。

async function lockSlot(conn, slotId, qty) {
  const [r] = await conn.query(
    `UPDATE ticket_slot
        SET locked = locked + ?
      WHERE id = ?
        AND status = 1
        AND capacity - sold - locked >= ?`,
    [qty, slotId, qty]
  );
  // 影响行数为 0,说明库存不足或场次已关闭
  if (r.affectedRows === 0) {
    throw new Error('SLOT_SOLD_OUT');
  }
}

下单流程要在同一个事务里完成四步:

1. 锁场次库存(上面这条 SQL)2. 创建订单3. 创建票明细(每张票一条记录,实名票要带游客信息)4. 写库存流水(用于事后对账)

任何一步失败,整单回滚。不要在下单时调外部接口(比如给游客发短信),否则事务会变长,锁持有时间上升,高并发下直接拖垮数据库。短信放到支付成功之后异步发。

三、实名票:一人一票与证件校验

景区实名制有两个层次:实名购买(下单时填身份证)和实名入园(入园时校验本人)。前者是管理要求,后者是防黄牛手段。

数据设计上,票明细表要独立于订单表:

CREATE TABLE ticket_item (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_id      BIGINT NOT NULL,
  slot_id       BIGINT NOT NULL,
  ticket_type_id BIGINT NOT NULL,
  visitor_name  VARCHAR(40) NOT NULL,
  id_type       TINYINT NOT NULL DEFAULT 1 COMMENT '1身份证 2护照 3港澳台通行证',
  id_no_enc     VARBINARY(255) NOT NULL COMMENT '证件号加密存储',
  id_no_hash    CHAR(64) NOT NULL COMMENT '证件号哈希,用于唯一性校验',
  status        TINYINT NOT NULL DEFAULT 1 COMMENT '1待使用 2已核销 3已退款',
  qr_token      CHAR(32) NOT NULL COMMENT '核销二维码令牌',
  used_at       DATETIME NULL,
  KEY idx_order (order_id),
  KEY idx_qr (qr_token),
  KEY idx_idhash_slot (id_no_hash, slot_id)
);

三个设计要点:

证件号必须加密存储,同时保留哈希。 明文存证件号是合规红线,一旦泄露后果严重。但业务上又需要"同一个证件在同一场次只能买一张",这个校验用哈希做:

const crypto = require('crypto');
// 加密用 AES-256-GCM,密钥放 KMS
function encIdNo(plain, key, iv) {
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), enc]);
}
// 哈希用于唯一校验,加盐防止彩虹表
function hashIdNo(plain, salt) {
  return crypto.createHmac('sha256', salt).update(plain).digest('hex');
}
同一场次一人一票的约束,落在应用层查 (id_no_hash, slot_id) 是否存在,再加一个唯一索引兜底:
ALTER TABLE ticket_item
  ADD UNIQUE KEY uk_idhash_slot (id_no_hash, slot_id);
注意这个唯一索引会把"已退款的票"也算进去,导致退款后无法重购。解决办法是把已退款的记录迁移到历史表,或者用 (id_no_hash, slot_id, status) 做部分索引(MySQL 8 可以用生成列实现):
ALTER TABLE ticket_item
  ADD COLUMN uniq_key VARCHAR(80)
    GENERATED ALWAYS AS (IF(status = 1, CONCAT(id_no_hash, '-', slot_id), NULL)) STORED,
  ADD UNIQUE KEY uk_uniq (uniq_key);

status 变成 2 或 3 时 uniq_key 为 NULL,MySQL 的唯一索引允许多个 NULL,冲突就解开了。这个技巧在票务场景里很常用。

四、核销:闸机场景下的一致性

核销是整个票务系统最容易出事的地方。一个二维码,如果被两个闸机同时扫到(游客举着手机在两个通道之间晃),一张票可能被核销两次;如果闸机断网,核销记录可能丢失。

核心原则:核销必须是幂等的条件更新。

UPDATE ticket_item
SET status = 2, used_at = NOW(), used_gate = ?
WHERE qr_token = ?
 AND status = 1;

影响行数为 1 才是有效核销;为 0 说明票已经被用过、已退款或不存在。闸机根据这个结果决定放行还是拦截,判断逻辑只有一处,不会有二义性。

高并发下的性能问题:节假日入园高峰,每秒几百次核销,每次都打 MySQL 会有压力。常见做法是用 Redis 做前置状态:

// 核销前置校验:Redis 记录"已核销"标记
async function verify(redis, pool, qrToken, gateId) {
  // SETNX 成功表示之前没核销过
  const key = `ticket:used:${qrToken}`;
  const ok = await redis.set(key, gateId, 'NX', 'EX', 86400);
  if (!ok) {
    // Redis 说已核销,但可能是缓存不一致,回查数据库确认
    const [rows] = await pool.query(
      'SELECT status, used_at FROM ticket_item WHERE qr_token = ?', [qrToken]
    );
    if (!rows.length) return { pass: false, code: 'NOT_FOUND' };
    if (rows[0].status === 2) return { pass: false, code: 'ALREADY_USED', at: rows[0].used_at };
    // 数据库仍是待使用,说明是脏标记,放行并修复缓存
    await redis.del(key);
  }
  const [r] = await pool.query(
    'UPDATE ticket_item SET status = 2, used_at = NOW(), used_gate = ? WHERE qr_token = ? AND status = 1',
    [gateId, qrToken]
  );
  if (r.affectedRows === 0) {
    await redis.del(key);
    return { pass: false, code: 'ALREADY_USED' };
  }
  return { pass: true };
}

要点:Redis 只做加速,不作为权威。任何缓存判断不一致时,一律以数据库的条件更新结果为准。这样即使 Redis 挂了、数据丢了,也不会出现重复核销。

断网怎么办:闸机要支持离线核销——本地缓存当天的有效票令牌列表(只缓存不敏感的 token 和场次),断网时本地判断,联网后把离线核销记录补传。补传时同样走条件更新,冲突的以先到为准。这是一致性的必要妥协:宁可少数场景下允许重复入园,也不能让游客在门口干等。

五、退改签与库存回补

景区票的退改规则通常和日期强绑:当天可退、提前一天免费改、当天改期收手续费。规则本身是业务配置,实现上要注意两件事。

一是回补库存要防止超卖。 退票时把 sold 减回去,但如果这个场次已经过了,减回去没有意义(票已经不能卖了)。所以回补前要判断场次是否还在可售状态:

UPDATE ticket_slot
SET sold = sold - ?
WHERE id = ?
  AND status = 1
  AND CONCAT(biz_date, ' ', start_time) > NOW();

二是退款要和核销状态互斥。 已经核销的票不能退。这条约束要在退款接口里用条件更新保证:

UPDATE ticket_item SET status = 3
WHERE id = ? AND status = 1;

影响行数为 0 就拒绝退款,并返回具体原因(已核销 / 已退款 / 不存在),不要笼统地报"退款失败"。

六、运营侧需要的三个报表

系统上线后,景区运营最先要的是三张表:分时段售罄率(哪个时段卖完了、哪个时段空着)、核销率(卖了多少、实际来了多少)、渠道分布(小程序、OTA、窗口各占多少)。

前两张直接决定第二天的容量配置:某个时段连续售罄,可以考虑加开;核销率长期低于 70%,说明容量定高了或者存在大量退票。第三张决定渠道投入的优先级。

这三张表的数据都来自前面几张表,不需要额外埋点——前提是下单、核销、退款三条链路都写全了流水。青海青帝科技在本地文旅项目中,会把"流水是否完整"作为验收项之一,因为报表不准的时候,运营就失去了调整依据。

目录
相关文章
|
7天前
|
云安全 人工智能 运维
当攻击全面Agent化,你的安全防御能否跟上AI Speed?
多起客户侧AK泄露事件应急的真实观察
1309 6
|
5天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
|
10天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
1月前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
9963 33
|
5天前
|
人工智能 开发者
Qoder CN 大使计划正式开启|无限创造,等你一起
全年免费积分、优先内测、活动支持与更多共创机会,邀请用 Qoder 把想法变成现实的你。
523 6
|
16天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
7天前
|
Web App开发 人工智能 JavaScript
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
REA(Reverse Engineer Anything)是近期爆火的开源AI逆向工具,4月发布,1个月内斩获15万+ Star。它让AI Agent无需源码即可分析二进制、APK、JS/Electron、HAR包等17类目标,自动反编译、生成可运行代码,并强制返回带证据与局限的结论。全本地执行,MIT协议,支持Hopper/Ghidra/IDA等引擎,一行命令即可上手。
747 2
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
|
15天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
1069 1