在这里插入图片描述
在这里插入图片描述

实例:卡券管理(Coupon)|技术:10 张券、三态分布、有效期梯度

一、种子数据的设计目标

实例 18 的种子数据要撑起「券包列表 + 状态筛选 + 过期检测」,设计目标:

  1. 10 张卡券:券包内容充实——列表多屏;
  2. 三态分布:未使用 5 / 已使用 2 / 已过期 3——筛选 tab 数字有区分;
  3. 有效期梯度:过期时间从现在 +3 天到 +60 天——「剩 N 天」有变化(3/7/15/20/30/60);
  4. 类型覆盖:优惠券 6 / 代金券 3 / 礼品卡 1——类型多样;
  5. 过期历史:3 张已过期(-10 天 / -5 天 / -30 天前到期)——markExpired 对这些是「已标记」(种子直接 status=2);
  6. 面值/门槛梯度:¥5 无门槛 ~ ¥100 礼品卡、满 50 可用 ~ 满 500 可用——券面内容有层次。

二、10 张卡券种子数据

# 标题 类型 面值 门槛 状态 生效 过期 颜色 备注
1 全场满 100 减 20 优惠券 ¥20 满100 未使用 -10天 +20天 红 #EF4444 全场通用
2 生鲜满 50 减 10 优惠券 ¥10 满50 未使用 -8天 +15天 绿 #10B981 生鲜专区
3 数码满 500 减 80 优惠券 ¥80 满500 未使用 -5天 +30天 蓝 #3B82F6 数码专区
4 新人 5 元无门槛 代金券 ¥5 无门槛 未使用 -3天 +7天 橙 #F59E0B 新人专享
5 年终回馈 100 元 礼品卡 ¥100 无门槛 未使用 -1天 +60天 靛 #6366F1 积分兑换
6 会员生日礼 30 元 代金券 ¥30 满200 已使用 -30天 +5天 粉 #EC4899 已使用
7 周五咖啡券 优惠券 ¥15 满30 已使用 -20天 +3天 紫 #8B5CF6 已使用
8 图书满 99 减 30 优惠券 ¥30 满99 已过期 -60天 -10天 青 #06B6D4 已过期
9 超市满 200 减 50 代金券 ¥50 满200 已过期 -45天 -5天 黄绿 #84CC16 已过期
10 洗车 5 折券 优惠券 ¥25 满50 已过期 -90天 -30天 橙红 #F97316 已过期

设计要点

  • 三态分布 5/2/3:未使用 5 张(可用券主力)——筛选条「未使用 5 / 已使用 2 / 已过期 3 / 全部 10」;
  • 到期梯度:未使用券的剩余天数 +7(新人券)/ +15 / +20 / +30 / +60——「剩 N 天」标签 5 种值,急迫感梯度(新人券最急 7 天);
  • 已过期 3 张:直接 status=2(种子就标好)——不依赖 markExpired 演示(markExpired 演示留给「现场把券放过期」的进阶场景,或等时间推移);
  • 类型分布:优惠券 6 / 代金券 3 / 礼品卡 1——主流类型多、高端礼品卡少;
  • 无门槛 2 张:新人 5 元、年终回馈 100——「无门槛」券面文案与「满 X 可用」形成对比。

三、代码实现

const now = Date.now();
const day = 86400000;
const seed: CouponSeed[] = [ /* 10 张,见第二节表格 */ ];
for (const s of seed) {
  const values: relationalStore.ValuesBucket = {
    title: s.title, type: s.type, face_value: s.faceValue, min_spend: s.minSpend,
    status: s.status, start_time: s.startTime, expire_time: s.expireTime,
    color: s.color, remark: s.remark,
  };
  await store.insert(CouponDao.TABLE, values);
}
hilog.info(DOMAIN, TAG, '已填充 10 张卡券种子数据');

const day = 86400000:一天的毫秒常量——种子时间 now - 10 * day(10 天前)、now + 20 * day(20 天后)——相对时间表达(可读性强于裸毫秒)。幂等保护:查 coupon COUNT,非空即返回。

四、注入后的卡券包效果预测

区块 预期效果
筛选条 全部 10 / 未使用 5 / 已使用 2 / 已过期 3
列表顶部 未使用 5 张(红/绿/蓝/橙/靛券面):全场减 20(剩 20 天)→ 生鲜减 10(15 天)→ 数码减 80(30 天)→ 新人 5 元(7 天)→ 年终回馈 100(60 天)
中间 已使用 2 张(粉/紫券面 + 灰色「已使用」标签)
底部 已过期 3 张(青/黄绿/橙红券面 + 灰「已过期」标签)
点券 详情弹窗 7 行(类型/面值/门槛/生效/过期/状态/备注)

演示脚本:指着券包说「我有 10 张券,5 张能用」→ 指新人券「这个最急,剩 7 天就过期了」→ 指年终回馈「这个最值钱,100 块无门槛,还有 60 天」→ 点「未使用」筛选 → 5 张 → 点新人券「使用」→ 确认 → Toast「✅ 已使用」→ 新人券变「已使用」灰色、筛选条数字更新(未使用 5→4、已使用 2→3)→ 点「已过期」→ 3 张灰券。

五、验证方法

  1. hilog 日志:搜 CouponDao,看到「已填充 10 张卡券种子数据」;
  2. 页面显示:筛选条 10/5/2/3、未使用券面 5 张(带「剩 N 天」)、已使用/已过期标签正确;
  3. 数据库直查
    SELECT COUNT(*) FROM coupon;  -- 10
    SELECT status, COUNT(*) FROM coupon GROUP BY status;  -- 0:5 1:2 2:3
    SELECT title, expire_time FROM coupon WHERE status = 0 ORDER BY expire_time ASC;
    -- 新人5元无门槛(+7天)最先
    SELECT COUNT(*) FROM coupon WHERE status = 0 AND expire_time > 1720869600000;  -- 5
    
  4. 交互验证:状态筛选、使用卡券(状态流转)、长按删除、点券详情。

六、FAQ

Q1:为什么未使用 5 张、已使用 2 张、已过期 3 张?
A:三态有区分度——未使用最多(可用券主力,筛选「未使用 5」)、已使用 2(演示过使用)、已过期 3(历史券)。「主力 + 演示 + 历史」的分布让每个筛选 tab 都有内容。

Q2:已过期的券为什么不靠 markExpired 自动标?
A:种子直接 status=2 是「静态过期」——保证首启就有 3 张过期券(markExpired 对它们无影响,因为已标记)。markExpired 的动态演示可这样触发:改种子过期时间(now - 1 天)→ 首启时 status=0 但 expire<now → refresh 的 markExpired 自动标为过期。「静态种子 + 动态检查」两条路径都覆盖

Q3:新人券剩 7 天、年终回馈剩 60 天——梯度怎么设计的?
A:让「剩 N 天」标签有 5 种值(7/15/20/30/60)——到期急迫感梯度(7 天最急提醒使用、60 天从容)。真实券包的到期提醒优先级就是「越急越靠前」(orderByAsc expire_time 已实现)。

Q4:为什么有 2 张无门槛券?
A:新人 5 元(拉新)、年终回馈 100(积分兑换)——无门槛是「高价值券」的标志(直接抵现)。券面「无门槛」文案与「满 100 可用」对比,展示 min_spend=0 的哨兵语义。

Q5:面值为什么从 ¥5 到 ¥100?
A:梯度覆盖——5 元新人券、10/15/20/25/30/50 元满减、80 元数码、100 元礼品卡——**「小面值高频 + 大面值稀缺」**的券包结构(大面值通常门槛高或获取难)。

Q6:10 张券的 markExpired 会误伤吗?
A:不会——已使用的(status=1)不在条件内(status=0 限定)、已过期的(status=2)也不在——只有「未使用且到期」的券会被标过期。种子里没有这种「边界券」(未使用但过期),所以 markExpired 首启影响 0 行——种子数据保持状态自洽

七、文章小结

本篇文章展示了实例 18 的种子数据:10 张卡券三态分布(未使用 5 / 已使用 2 / 已过期 3 + 到期梯度 7~60 天 + 类型 4 种 + 面值 ¥5~¥100 梯度)。核心是「券包感的种子设计」——到期急迫感梯度(剩 7 天最急)、无门槛高价值券对比、静态过期与动态 markExpired 双路径。卡券包一开屏就三色券面、状态分明。

下一篇(18-5)是实例 18 的收官文章,展示 CouponPage 全量代码与运行效果。

八、10 张卡券种子数据核对表(补全版)

把种子里每张券的 9 个字段 全部列齐(基准时间 now = 2024-07-13,日期为当天 00:00 换算),作为核对与测试断言的参照:

# 标题 类型 面值 门槛 状态 生效 过期 颜色 备注
1 全场满 100 减 20 优惠券 ¥20 满100 未使用 07-03 08-02 红 #EF4444 全场通用
2 生鲜满 50 减 10 优惠券 ¥10 满50 未使用 07-05 07-28 绿 #10B981 生鲜专区
3 数码满 500 减 80 优惠券 ¥80 满500 未使用 07-08 08-12 蓝 #3B82F6 数码专区
4 新人 5 元无门槛 代金券 ¥5 无门槛 未使用 07-10 07-20 橙 #F59E0B 新人专享
5 年终回馈 100 元 礼品卡 ¥100 无门槛 未使用 07-12 09-11 靛 #6366F1 积分兑换
6 会员生日礼 30 元 代金券 ¥30 满200 已使用 06-13 07-18 粉 #EC4899 已使用
7 周五咖啡券 优惠券 ¥15 满30 已使用 06-23 07-16 紫 #8B5CF6 已使用
8 图书满 99 减 30 优惠券 ¥30 满99 已过期 05-14 07-03 青 #06B6D4 已过期
9 超市满 200 减 50 代金券 ¥50 满200 已过期 05-29 07-08 黄绿 #84CC16 已过期
10 洗车 5 折券 优惠券 ¥25 满50 已过期 04-14 06-13 橙红 #F97316 已过期

核对要点

  • 未使用 5 张expire_time 全部 > now 且 status=0——筛选「未使用 5」成立;
  • 已使用 2 张status=1——即使过期时间在未来(生日礼 07-18)也不影响状态展示,状态优先于时间
  • 已过期 3 张expire_time < nowstatus=2——时间与状态自洽,没有「未使用但已过期」的边界券,markExpired 首启影响 0 行。

九、种子设计复盘:三态分布与到期梯度

三态分布 5/2/3 复盘

方案 未使用 已使用 已过期 问题
5/2/3(采用) 5 2 3 主力多、演示够、历史足——筛选数字 10/5/2/3 有层次
7/1/2 7 1 2 已使用只有 1 张,「使用」演示后该 tab 就空了
3/3/4 3 3 4 可用券太少,「我能用 5 张」的观感立不住

为什么未使用要过半:券包的第一观感是「有多少能用的」——5 张可用(占一半)让演示话术成立;已使用 2 张恰好支撑「用完 1 张还剩 1 张」的流转演示;已过期 3 张让「已过期」tab 不是单张孤例。任何一态少于 2 张,对应的筛选 tab 就失去演示价值

到期梯度 7/15/20/30/60 复盘

剩余天数 面值 作用
7 新人 5 元 ¥5 最急——演示「剩 7 天」急迫感
15 生鲜满 50 减 10 ¥10 次急
20 全场满 100 减 20 ¥20 中等
30 数码满 500 减 80 ¥80 从容
60 年终回馈 100 元 ¥100 最值钱最不急——「还有 60 天」

梯度设计要点:5 个值覆盖「急 → 从容」全谱,且与面值大体成反比(越值钱越不急)——「新人券最急但最便宜、年终回馈最值钱但最不急」的反差让演示有故事性;同时保证 queryAll 组内按 expire_time 升序后,列表顶部第一张就是最急的新人券,一屏就能看到梯度。

十、initSeedData 完整代码(day 常量版)

interface CouponSeed {
  title: string; type: string; faceValue: number; minSpend: number;
  status: number; startTime: number; expireTime: number;
  color: string; remark: string;
}

static async initSeedData(context: common.Context): Promise<void> {
  const store = await CouponDao.getStore(context);
  // 幂等保护:已有数据直接返回
  const count = await CouponDao.count(context);
  if (count > 0) {
    hilog.info(DOMAIN, TAG, '种子数据已存在,跳过(当前 %{public}d 张)', count);
    return;
  }
  const now = Date.now();
  const day = 86400000; // 一天的毫秒数
  const seed: CouponSeed[] = [
    { title: '全场满 100 减 20', type: '优惠券', faceValue: 20, minSpend: 100, status: 0,
      startTime: now - 10 * day, expireTime: now + 20 * day, color: '#EF4444', remark: '全场通用' },
    { title: '生鲜满 50 减 10', type: '优惠券', faceValue: 10, minSpend: 50, status: 0,
      startTime: now - 8 * day, expireTime: now + 15 * day, color: '#10B981', remark: '生鲜专区' },
    { title: '数码满 500 减 80', type: '优惠券', faceValue: 80, minSpend: 500, status: 0,
      startTime: now - 5 * day, expireTime: now + 30 * day, color: '#3B82F6', remark: '数码专区' },
    { title: '新人 5 元无门槛', type: '代金券', faceValue: 5, minSpend: 0, status: 0,
      startTime: now - 3 * day, expireTime: now + 7 * day, color: '#F59E0B', remark: '新人专享' },
    { title: '年终回馈 100 元', type: '礼品卡', faceValue: 100, minSpend: 0, status: 0,
      startTime: now - 1 * day, expireTime: now + 60 * day, color: '#6366F1', remark: '积分兑换' },
    { title: '会员生日礼 30 元', type: '代金券', faceValue: 30, minSpend: 200, status: 1,
      startTime: now - 30 * day, expireTime: now + 5 * day, color: '#EC4899', remark: '已使用' },
    { title: '周五咖啡券', type: '优惠券', faceValue: 15, minSpend: 30, status: 1,
      startTime: now - 20 * day, expireTime: now + 3 * day, color: '#8B5CF6', remark: '已使用' },
    { title: '图书满 99 减 30', type: '优惠券', faceValue: 30, minSpend: 99, status: 2,
      startTime: now - 60 * day, expireTime: now - 10 * day, color: '#06B6D4', remark: '已过期' },
    { title: '超市满 200 减 50', type: '代金券', faceValue: 50, minSpend: 200, status: 2,
      startTime: now - 45 * day, expireTime: now - 5 * day, color: '#84CC16', remark: '已过期' },
    { title: '洗车 5 折券', type: '优惠券', faceValue: 25, minSpend: 50, status: 2,
      startTime: now - 90 * day, expireTime: now - 30 * day, color: '#F97316', remark: '已过期' },
  ];
  for (const s of seed) {
    const values: relationalStore.ValuesBucket = {
      title: s.title, type: s.type, face_value: s.faceValue, min_spend: s.minSpend,
      status: s.status, start_time: s.startTime, expire_time: s.expireTime,
      color: s.color, remark: s.remark,
    };
    await store.insert(CouponDao.TABLE, values);
  }
  hilog.info(DOMAIN, TAG, '已填充 10 张卡券种子数据(三态 5/2/3)');
}

day 常量的价值now + 20 * day 一眼看懂是「20 天后」;若写裸毫秒 now + 1728000000,维护时没人知道是多少天。所有相对时间都基于 now——种子永远「新鲜」(不管哪一天首启,剩余天数都成立)。幂等保护:先 COUNT 再插入——首启填充 10 张,重启不重复(否则每次启动都翻倍)。

十一、注入后的券包效果预测

按 queryAll 排序(ORDER BY status ASC, expire_time ASC),列表最终形态:

位置 状态 券面颜色 标签
1 新人 5 元无门槛 未使用 剩 7 天
2 生鲜满 50 减 10 未使用 绿 剩 15 天
3 全场满 100 减 20 未使用 剩 20 天
4 数码满 500 减 80 未使用 剩 30 天
5 年终回馈 100 元 未使用 剩 60 天
6 会员生日礼 30 元 已使用 已使用
7 周五咖啡券 已使用 已使用
8 图书满 99 减 30 已过期 已过期
9 超市满 200 减 50 已过期 黄绿 已过期
10 洗车 5 折券 已过期 橙红 已过期

注意:未使用组内按 expire_time 升序——新人券(剩 7 天)置顶、年终回馈(60 天)垫底——「最急的券排最前」正是 queryAll 双键排序在种子数据上的落地效果。筛选条数字:全部 10 / 未使用 5 / 已使用 2 / 已过期 3;三色券面(红/绿/蓝/橙/靛 + 粉/紫 + 青/黄绿/橙红)铺满列表。

十二、验证 SQL 扩展与 FAQ

-- 三态统计(预期 0:5 1:2 2:3)
SELECT status, COUNT(*) FROM coupon GROUP BY status;
-- 类型分布(预期 优惠券6 / 代金券3 / 礼品卡1)
SELECT type, COUNT(*) FROM coupon GROUP BY type;
-- 未使用券按到期升序(预期第 1 行是新人券,剩 7 天)
SELECT title, expire_time FROM coupon WHERE status = 0 ORDER BY expire_time ASC;
-- 幂等验证:连续两次 initSeedData 后 COUNT 仍为 10(不翻倍)
SELECT COUNT(*) FROM coupon;  -- 10

Q7:为什么 now 放在 initSeedData 方法内部而不是模块顶层?
A:顶层常量在模块加载时就固定,App 跑几天后种子时间就「旧」了;方法内每次调用都取最新 now——首启填充的那一瞬间,所有相对时间都是准的。day 则可以是模块级常量(它不随时间变),但为了「时间表达集中在一处」,放在一起更直观。

Q8:已使用的券过期时间在未来(生日礼 +5 天),会被 markExpired 误标吗?
A:不会——markExpired 的条件是 status = 0 AND expire_time < now,已使用券 status=1 直接被条件排除。状态优先于时间:只要 status 不是 0,时间再怎么过期都不处理——这就是状态机设计的护栏,也让种子里「已使用但未过期」「已过期且时间已过」两种组合都能安全共存。

Q9:如果想加第 11 张券,改哪里?
A:只改 initSeedData 的 seed 数组加一行,并同步更新三态统计口径(若分布变化)——页面、Dao 查询、表结构都不用动。种子数据是「数据层最薄的一层」:加券 = 加一条记录 + 调一个计数,这是把数据「配置化」带来的扩展性。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐