票种表重构复盘:把填数字改成规则可执行版本

3 阅读7分钟

11 月是景区票务系统的淡季切换期。去年这个时候我们接到一个需求,原话是"把票价改成淡季价",听起来是十分钟的活。

真正做完之后,改的是票种的整张数据模型。

这篇复盘记一下三个坑,都是真实场景推出来的。

一个 11 月,五套时间窗

难的不是降价,而是"同时成立"。

坑一:两个日期字段表达不了"区间内挖洞"

先看今年 11 月的真实公告时间维度(只取区间):

规则起止
故宫淡季11-01次年 03-31
云冈/华严寺/善化寺11-01次年 03-31
茶卡盐湖免门票11-01次年 02-28
三星堆未成年免票11-04长期执行
黄山青少年免票09-2112-31,中秋国庆除外
小鸟天堂秋假优惠11-1411-22
大冶龙凤山全员免11-0111-30

结束日六种:03-31 / 02-28 / 无 / 12-31 / 11-22 / 11-30。

单是"多个结束日并存"还好办,各票种自己的 valid_from / valid_to 就够。出问题的是黄山那条——在一个连续区间里,要把中秋、国庆两段挖掉。

我们原来的模型只有两个字段。第一次实现的做法是加一张排除表,逻辑写成"命中主区间后,再查排除区间":

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 无效"(部分景区国庆无效),排除段落在主区间外部,这种写法会漏掉。后来补了一条:只要日期点落在任一排除区间内即失效,不做区间包含判断。

# 简化且更稳的判定:按点判定
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 就只能靠表顺序,运行结果不稳定。

四类免票人群的判定依据各不相同

表达力不够,现场就会用人工补位。

坑二:判定基准日默认写成了下单日

年龄免票的判定,第一版代码是这么写的:

age = date.today().year - birth.year     # ← 错

看着没问题,直到测到跨年场景:12-31 下单、01-01 入园。下单当天满 18 岁,入园时已经超龄(或反之)。

政策口径是"入园日年龄",不是"下单日年龄":

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:

# 三种池模型
POOL_MERGED  = 'merged'   # 与付费票同池
POOL_SPLIT   = 'split'    # 独立池,总量 = paid + free <= capacity
POOL_BORROW  = 'borrow'   # 分池 + 单向借用

merged 的问题是秋假几天免费票把名额占满,付费票卖不出去。 split 的问题是两边余量不通,一边空一边满,吞吐损失。 实际用的是 borrow:

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 元不等于 0 记录。

顺带说一下二消:四类规则塞不进一个模型

门票归零后收入转到二消。茶卡盐湖的公告里四项照收,但规则类型完全不同:

项目规则类型
小火车 20 分钟/车次班次库存
电瓶车 5 元单程简单 SKU
鞋套 10 元租金 + 50 元押金租赁 + 押金状态机
停车不满 30 分钟免费时长计费

三个高频错法:

1. 押金和租金记同一字段。 公告写"完好退还后全额返还"——退是主路径。混在一笔 60 元里记成收入,退款时要做收入红冲,报表和实际流水对不平。押金应挂负债科目。

2. 免费阈值实现成"价格 × 0"。 应该直接产出不计费判定,而不是留一条 0 元流水。后者在对账时会被当成异常数据。

3. 班次库存用"先查后改"。

-- 错:并发下必超卖
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;

景区团队票和散客同时下单很常见,这不是理论问题。

门票归零后四项二消照收,四类异质规则

最后一个通用做法:停开是状态,下架是生命周期

公告里那句"游船、水上自行车、盐文化馆暂停开放,恢复时间另行通知"——注意"另行通知"意味着停开时长未知。

ALTER TABLE sku ADD COLUMN suspend_until DATETIME NULL;
ALTER TABLE sku ADD COLUMN suspend_reason VARCHAR(32) NULL;

我们见过同行直接删 SKU。后果是历史核销流水外键悬空,日报和追溯同时断。

正确动作是置状态 + 把已购未核销订单批量推入退款队列,已核销记录完全不动。

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')

小结

这次重构真正的收获不是加了几个字段,而是把对票种表的理解换掉了:

  1. 票种表不是配置表,是规则的可执行版本。 表达力不够时,现场就会用人工补位,而人工补位不出现在任何监控里。
  2. 资格判定必须先确认基准日,下单日、支付日、核销日、入园日经常分属不同日期。
  3. 0 元票必须进库存模型,否则承载量数字失去意义。
  4. 计费方式与库存方式是两个正交维度,混在一起每加一种业务都要动表。

这四条都不是设计时想到的,都是窗口切换那天被叫去救火之后补的。

换季前 12 项自查清单

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