11 月是景区票务系统的淡季切换期。去年这个时候我们接到一个需求,原话是"把票价改成淡季价",听起来是十分钟的活。
真正做完之后,改的是票种的整张数据模型。
这篇复盘记一下三个坑,都是真实场景推出来的。

难的不是降价,而是"同时成立"。
坑一:两个日期字段表达不了"区间内挖洞"
先看今年 11 月的真实公告时间维度(只取区间):
| 规则 | 起 | 止 |
|---|---|---|
| 故宫淡季 | 11-01 | 次年 03-31 |
| 云冈/华严寺/善化寺 | 11-01 | 次年 03-31 |
| 茶卡盐湖免门票 | 11-01 | 次年 02-28 |
| 三星堆未成年免票 | 11-04 | 长期执行 |
| 黄山青少年免票 | 09-21 | 12-31,中秋国庆除外 |
| 小鸟天堂秋假优惠 | 11-14 | 11-22 |
| 大冶龙凤山全员免 | 11-01 | 11-30 |
结束日六种:03-31 / 02-28 / 无 / 12-31 / 11-22 / 11-30。
单是"多个结束日并存"还好办,各票种自己的 valid_from / valid_to 就够。出问题的是黄山那条------在一个连续区间里,要把中秋、国庆两段挖掉。
我们原来的模型只有两个字段。第一次实现的做法是加一张排除表,逻辑写成"命中主区间后,再查排除区间":
python
def sku_active(sku_id, visit_date):
hits = q("period_start<=? AND (period_end IS NULL OR period_end>=?) AND include=1",
visit_date, visit_date)
ex = q("period_start<=? AND period_end>=? AND include=0", visit_date, visit_date)
if not hits:
return False
for e in ex:
for h in hits:
if h.period_start >= e.period_start and \
(e.period_end is None or h.period_end <= e.period_end):
return False
return True
三个细节后来都改过:
1. period_end IS NULL 不能写成 9999-12-31。 一开始图省事用了一个极大值占位。结果报表里"长期有效"的票种全被算成 29 年有效期,缓存 TTL 计算溢出了一次。语义上"没有结束"和"结束得很晚"是两件事,必须用 NULL。
2. 排除区间与命中区间的嵌套方向要想清楚。 上面那段代码判断的是"命中区间被排除区间完全覆盖才失效"。但如果政策是"11-01 至 03-31 有效,其中 10-01 至 10-07 无效"(部分景区国庆无效),排除段落在主区间外部,这种写法会漏掉。后来补了一条:只要日期点落在任一排除区间内即失效,不做区间包含判断。
python
# 简化且更稳的判定:按点判定
def active_on(sku_id, d):
if any(d in ex_range for ex_range in excludes(sku_id)):
return False
return any(in_range(d, r) for r in includes(sku_id))
3. 优先级字段必须留。 "既是深圳市民又满 18 岁"这种重叠场景,两档规则都给优惠,取哪个?没有 priority 就只能靠表顺序,运行结果不稳定。

表达力不够,现场就会用人工补位。
坑二:判定基准日默认写成了下单日
年龄免票的判定,第一版代码是这么写的:
python
age = date.today().year - birth.year # ← 错
看着没问题,直到测到跨年场景:12-31 下单、01-01 入园。下单当天满 18 岁,入园时已经超龄(或反之)。
政策口径是"入园日年龄",不是"下单日年龄":
python
age = visit_date.year - birth.year # ← 对
# 严格口径还应比较月日
if (visit_date.month, visit_date.day) < (birth.month, birth.day):
age -= 1
带月日比较这行是后来补的。只算年份差,把"12 月出生的人 1 月就大了一岁"这种边界漏掉了。
这个坑值得单独说一句:凡是"资格类"判定,都要先问清基准日是下单日、支付日、核销日还是入园日。 这四个日子在旅游场景经常分属不同日期,选错一个,线上就会稳定出错。
坑三:0 元票到底进不进库存
黄山要求免票人群在小程序预约"0 元秋假专属票"。这一约就说明它占名额。那它跟付费票是不是抢同一个池子?
我们第一版的做法很朴素:0 元票不进库存,现场核验放行。
结果三天后运营来找我们------后台显示"还有余量",现场已经排长队了。 因为承载量统计只数了付费票。
改法是把 0 元票当真实 SKU:
python
# 三种池模型
POOL_MERGED = 'merged' # 与付费票同池
POOL_SPLIT = 'split' # 独立池,总量 = paid + free <= capacity
POOL_BORROW = 'borrow' # 分池 + 单向借用
merged 的问题是秋假几天免费票把名额占满,付费票卖不出去。 split 的问题是两边余量不通,一边空一边满,吞吐损失。 实际用的是 borrow:
python
def release_free_leftover(date, cutoff='18:00'):
if now().time() < parse(cutoff):
return
left = free_pool(date).remaining
if left > 0:
paid_pool(date).add_capacity(left) # 免费池剩余额度释放给付费池
# 反向不允许:付费池从不借给免费池,保障政策履约
借用窗口和回收时点是这次新加的两个概念。它们不在最初的需求里,是被现场问题逼出来的。
还有一点必须留:0 元不等于 0 记录。 免费人群照样要实名预约,否则入园人数与预约数对不齐,出了事无从追溯。

0 元不等于 0 记录。
顺带说一下二消:四类规则塞不进一个模型
门票归零后收入转到二消。茶卡盐湖的公告里四项照收,但规则类型完全不同:
| 项目 | 规则类型 |
|---|---|
| 小火车 20 分钟/车次 | 班次库存 |
| 电瓶车 5 元单程 | 简单 SKU |
| 鞋套 10 元租金 + 50 元押金 | 租赁 + 押金状态机 |
| 停车不满 30 分钟免费 | 时长计费 |
三个高频错法:
1. 押金和租金记同一字段。 公告写"完好退还后全额返还"------退是主路径。混在一笔 60 元里记成收入,退款时要做收入红冲,报表和实际流水对不平。押金应挂负债科目。
2. 免费阈值实现成"价格 × 0"。 应该直接产出不计费判定,而不是留一条 0 元流水。后者在对账时会被当成异常数据。
3. 班次库存用"先查后改"。
sql
-- 错:并发下必超卖
SELECT sold FROM batch_stock WHERE id=?;
UPDATE batch_stock SET sold=sold+? WHERE id=?;
-- 对:条件更新,看 affected_rows
UPDATE batch_stock SET sold = sold + ?
WHERE id = ? AND sold + ? <= capacity;
景区团队票和散客同时下单很常见,这不是理论问题。

最后一个通用做法:停开是状态,下架是生命周期
公告里那句"游船、水上自行车、盐文化馆暂停开放,恢复时间另行通知"------注意"另行通知"意味着停开时长未知。
sql
ALTER TABLE sku ADD COLUMN suspend_until DATETIME NULL;
ALTER TABLE sku ADD COLUMN suspend_reason VARCHAR(32) NULL;
我们见过同行直接删 SKU。后果是历史核销流水外键悬空,日报和追溯同时断。
正确动作是置状态 + 把已购未核销订单批量推入退款队列,已核销记录完全不动。
python
def suspend_sku(sku_id, reason):
update(sku, suspend_until=None, suspend_reason=reason) # None = 恢复时间未知
for o in query("sku_id=? AND status='UNVERIFIED'", sku_id):
enqueue_refund(o.id, reason='PROJECT_SUSPENDED')
小结
这次重构真正的收获不是加了几个字段,而是把对票种表的理解换掉了:
- 票种表不是配置表,是规则的可执行版本。 表达力不够时,现场就会用人工补位,而人工补位不出现在任何监控里。
- 资格判定必须先确认基准日,下单日、支付日、核销日、入园日经常分属不同日期。
- 0 元票必须进库存模型,否则承载量数字失去意义。
- 计费方式与库存方式是两个正交维度,混在一起每加一种业务都要动表。
这四条都不是设计时想到的,都是窗口切换那天被叫去救火之后补的。

票种表不是配置表,而是经营规则的可执行版本。