1.1 原生商品体系的类型设计
LikeShop 的 ls_goods 表通过 goods_type 字段区分商品类型,原生支持实物商品和虚拟商品两类;虚拟商品不参与物流发货,订单完成后直接进入“已完成”状态,无需填写收货地址和物流单号;但原生的虚拟商品仅支持“一次性交付”模式——用户购买后系统交付一个固定的虚拟内容(如课程链接、网盘地址),没有卡券化、兑换码化、有效期管理和核销记录的能力。
1.2 电子卡券场景的四个核心缺口
缺口一:无卡券实体与库存管理。原生的虚拟商品交付的是一个静态内容,不支持“一个商品对应多张卡券”的模式;商家无法为一个虚拟商品批量生成1000张兑换码,每码独立核销、独立记录有效期。
缺口二:无有效期管控。原生虚拟商品没有有效期概念;电子卡券场景下,每张卡券需要独立的生效时间和过期时间,过期后自动失效且不可核销;原生系统缺乏有效期字段和过期自动失效的定时任务。
缺口三:无核销流程与记录。电子卡券的核心是“核销”——用户持券到店或在线兑换,系统记录核销时间、核销人、核销方式;原生的虚拟商品订单只有“已支付/已完成”状态,没有“已核销/未核销”的区分,也没有核销日志。
1.3 二开策略定位
遵循 LikeShop 的“扩展优先”原则,虚拟商品与卡券模块采用以下策略:在 ls_goods 的 goods_type 枚举中新增“电子卡券”类型(值为3),卡券的实体数据、有效期、核销记录通过独立的数据表管理;订单支付成功后,通过事件监听器触发卡券的发放和绑定;核销逻辑封装在独立的 CardVerifyService 中,不修改原生的订单状态机。
2.1 电子卡券商品配置表
php
CREATE TABLE `ls_goods_card_config` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
`card_type` tinyint NOT NULL DEFAULT 1 COMMENT '卡券类型:1-兑换码,2-电子券,3-会员权益卡',
`card_name` varchar(100) NOT NULL COMMENT '卡券名称',
`card_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '卡券说明',
`valid_type` tinyint NOT NULL DEFAULT 1 COMMENT '有效期类型:1-固定日期,2-领取后N天,3-永久有效',
`valid_start_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '固定有效期开始时间',
`valid_end_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '固定有效期结束时间',
`valid_days` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '领取后有效天数',
`code_prefix` varchar(10) NOT NULL DEFAULT '' COMMENT '兑换码前缀',
`code_length` tinyint NOT NULL DEFAULT 12 COMMENT '兑换码长度',
`stock_type` tinyint NOT NULL DEFAULT 1 COMMENT '库存模式:1-预生成卡券池,2-购买时动态生成',
`card_stock` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '卡券库存总量',
`sold_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '已售出数量',
`verify_type` tinyint NOT NULL DEFAULT 1 COMMENT '核销方式:1-线上兑换,2-线下扫码核销,3-两者都支持',
`verify_limit` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '每张卡券可核销次数',
`exchange_goods_ids` varchar(255) NOT NULL DEFAULT '' COMMENT '兑换商品ID,逗号分隔(线上兑换时使用)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_goods_id` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电子卡券商品配置表';
valid_type 支持三种有效期模式:固定日期(如“2026年12月31日前有效”)、领取后N天(如“领取后30天内有效”)、永久有效;stock_type 区分预生成和动态生成两种库存模式。
2.2 卡券实体表
这是卡券的核心数据表,每张卡券对应一条记录:
sql
CREATE TABLE `ls_card_instance` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`card_config_id` int UNSIGNED NOT NULL COMMENT '卡券配置ID',
`goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
`card_code` varchar(32) NOT NULL COMMENT '卡券兑换码(唯一)',
`card_secret` varchar(32) NOT NULL DEFAULT '' COMMENT '卡券密钥(二次验证用)',
`batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '生成批次号',
`user_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持有用户ID,0为未售出',
`order_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联订单ID',
`order_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '关联订单号',
`valid_start_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '生效时间',
`valid_end_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '过期时间,0为永久有效',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-未使用,2-已使用,3-已过期,4-已作废,5-已冻结',
`verify_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '已核销次数',
`max_verify_count` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大核销次数',
`first_verify_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '首次核销时间',
`last_verify_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后核销时间',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_card_code` (`card_code`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_status_valid` (`status`, `valid_end_time`),
KEY `idx_batch_no` (`batch_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡券实体表';
card_code 是用户看到的兑换码,全局唯一;card_secret 是二次验证的密钥(可选),用于放伪造;status 的枚举覆盖卡券的完整生命周期:未使用、已使用、已过期、已作废、已冻结。
2.3 核销记录表
sql
CREATE TABLE `ls_card_verify_log` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`card_instance_id` int UNSIGNED NOT NULL COMMENT '卡券实例ID',
`card_code` varchar(32) NOT NULL COMMENT '卡券兑换码',
`user_id` int UNSIGNED NOT NULL COMMENT '核销用户ID',
`verify_type` tinyint NOT NULL DEFAULT 1 COMMENT '核销方式:1-线上兑换,2-线下扫码,3-后台手动核销',
`operator_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人ID(线下核销/后台核销时记录)',
`operator_type` tinyint NOT NULL DEFAULT 1 COMMENT '操作人类型:1-用户,2-核销员,3-平台管理员',
`exchange_goods_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '兑换的商品ID(线上兑换时)',
`exchange_order_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '兑换生成的订单ID',
`verify_result` tinyint NOT NULL DEFAULT 1 COMMENT '核销结果:1-成功,2-失败',
`fail_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '失败原因',
`verify_ip` varchar(45) NOT NULL DEFAULT '',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_card_instance` (`card_instance_id`),
KEY `idx_card_code` (`card_code`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡券核销记录表';
3.1 预生成模式
预生成模式下,商家在后台配置好卡券数量(如1000张),系统一次性批量生成1000条 ls_card_instance 记录,user_id 为 0(未售出);用户购买后,系统从卡券池中取出未售出的卡券绑定到用户。
批量生成的核心是兑换码的唯一性;采用“前缀 + 随机串”的生成策略,生成后通过数据库唯一索引做最终去重:
php
class CardGenerateService
{
/**
* 批量生成卡券
*/
public function batchGenerate($configId, $count, $batchNo = '')
{
$config = GoodsCardConfigModel::find($configId);
if (!$config) {
throw new \Exception('卡券配置不存在');
}
$batchNo = $batchNo ?: date('YmdHis') . mt_rand(100, 999);
$prefix = $config['code_prefix'];
$length = max(8, min(32, $config['code_length']));
$randomLength = $length - strlen($prefix);
// 计算有效期时间范围
[$validStart, $validEnd] = $this->calcValidTime($config);
$generatedCodes = [];
$insertData = [];
$batchSize = 500;
for ($i = 0; $i < $count; $i++) {
// 生成唯一兑换码
$code = $this->generateUniqueCode($prefix, $randomLength, $generatedCodes);
$generatedCodes[] = $code;
$insertData[] = [
'card_config_id' => $configId,
'goods_id' => $config['goods_id'],
'card_code' => $code,
'batch_no' => $batchNo,
'user_id' => 0,
'valid_start_time'=> $validStart,
'valid_end_time' => $validEnd,
'status' => 1,
'max_verify_count'=> $config['verify_limit'],
'create_time' => time(),
];
// 分批写入
if (count($insertData) >= $batchSize) {
Db::name('card_instance')->insertAll($insertData);
$insertData = [];
}
}
// 写入剩余数据
if (!empty($insertData)) {
Db::name('card_instance')->insertAll($insertData);
}
// 更新卡券库存
GoodsCardConfigModel::where('id', $configId)
->inc('card_stock', $count)->update();
return ['batch_no' => $batchNo, 'generated_count' => $count];
}
/**
* 生成唯一兑换码
*/
private function generateUniqueCode($prefix, $length, $excludeCodes = [])
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆的0/O/1/I
$maxAttempts = 100;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$random = '';
for ($j = 0; $j < $length; $j++) {
$random .= $chars[mt_rand(0, strlen($chars) - 1)];
}
$code = $prefix . $random;
// 检查批次内是否重复
if (in_array($code, $excludeCodes)) continue;
// 检查数据库是否已存在
$exists = CardInstanceModel::where('card_code', $code)->count();
if ($exists == 0) {
return $code;
}
}
throw new \Exception('兑换码生成失败,请重试');
}
/**
* 计算卡券有效期时间范围
*/
private function calcValidTime($config)
{
switch ($config['valid_type']) {
case 1: // 固定日期
return [$config['valid_start_time'], $config['valid_end_time']];
case 2: // 领取后N天
return [0, 0]; // 在发放时动态计算
case 3: // 永久有效
return [0, 0];
default:
return [0, 0];
}
}
}
3.2 动态生成模式
动态生成模式下,卡券不预先创建,而是在用户购买时实时生成;这种模式适合“按需生产”的场景,避免大量卡券闲置:
php
/**
* 购买时动态生成卡券
*/
public function generateOnPurchase($configId, $userId, $orderId, $orderSn)
{
$config = GoodsCardConfigModel::find($configId);
// 校验库存
if ($config['sold_count'] >= $config['card_stock']) {
throw new \Exception('卡券库存不足');
}
Db::startTrans();
try {
$code = $this->generateUniqueCode($config['code_prefix'],
$config['code_length'] - strlen($config['code_prefix']));
// 计算有效期
$validStart = time();
$validEnd = 0;
if ($config['valid_type'] == 1) {
$validStart = $config['valid_start_time'];
$validEnd = $config['valid_end_time'];
} elseif ($config['valid_type'] == 2) {
$validEnd = time() + $config['valid_days'] * 86400;
}
CardInstanceModel::create([
'card_config_id' => $configId,
'goods_id' => $config['goods_id'],
'card_code' => $code,
'user_id' => $userId,
'order_id' => $orderId,
'order_sn' => $orderSn,
'valid_start_time'=> $validStart,
'valid_end_time' => $validEnd,
'status' => 1,
'max_verify_count'=> $config['verify_limit'],
'create_time' => time(),
]);
GoodsCardConfigModel::where('id', $configId)
->inc('sold_count', 1)->update();
Db::commit();
return $code;
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
3.3 预生成卡券的发放
预生成模式下,订单支付成功后从卡券池中绑定卡券到用户:
php
class CardDispatchService
{
/**
* 订单支付成功后发放卡券
*/
public function dispatch($orderId, $goodsId, $num)
{
$config = GoodsCardConfigModel::where('goods_id', $goodsId)
->where('status', 1)->find();
if (!$config) return;
$order = OrderModel::find($orderId);
for ($i = 0; $i < $num; $i++) {
// 取出一张未售出的卡券(行锁防止并发)
$card = CardInstanceModel::where('card_config_id', $config['id'])
->where('user_id', 0)
->where('status', 1)
->lock(true)
->find();
if (!$card) {
throw new \Exception('卡券库存不足');
}
// 计算有效期
$validStart = $card['valid_start_time'];
$validEnd = $card['valid_end_time'];
if ($config['valid_type'] == 2) {
$validStart = time();
$validEnd = time() + $config['valid_days'] * 86400;
}
// 绑定到用户
CardInstanceModel::where('id', $card['id'])->update([
'user_id' => $order['user_id'],
'order_id' => $orderId,
'order_sn' => $order['order_sn'],
'valid_start_time'=> $validStart,
'valid_end_time' => $validEnd,
'update_time' => time(),
]);
}
GoodsCardConfigModel::where('id', $config['id'])
->inc('sold_count', $num)->update();
}
}
4.1 有效期校验
卡券在核销前需要校验有效期:
php
public function checkCardValid($cardCode)
{
$card = CardInstanceModel::where('card_code', $cardCode)->find();
if (!$card) {
throw new \Exception('卡券不存在');
}
if ($card['status'] == 3) {
throw new \Exception('卡券已过期');
}
if ($card['status'] == 4) {
throw new \Exception('卡券已作废');
}
if ($card['status'] == 2) {
throw new \Exception('卡券已使用');
}
if ($card['status'] == 5) {
throw new \Exception('卡券已冻结');
}
$now = time();
if ($card['valid_start_time'] > 0 && $now < $card['valid_start_time']) {
throw new \Exception('卡券尚未生效');
}
if ($card['valid_end_time'] > 0 && $now > $card['valid_end_time']) {
throw new \Exception('卡券已过期');
}
return $card;
}
4.2 过期自动失效定时任务
通过定时任务每日扫描已过期的卡券,将其状态更新为“已过期”:
php
class CardExpireCrontab
{
/**
* 卡券过期自动失效(每日凌晨执行)
*/
public function expireCards()
{
$now = time();
$batchSize = 1000;
$totalExpired = 0;
while (true) {
// 查询已过期但状态仍为未使用的卡券
$expiredCards = CardInstanceModel::where('status', 1)
->where('valid_end_time', '>', 0)
->where('valid_end_time', '<=', $now)
->limit($batchSize)
->column('id');
if (empty($expiredCards)) break;
// 批量更新状态
CardInstanceModel::whereIn('id', $expiredCards)
->update(['status' => 3, 'update_time' => $now]);
$totalExpired += count($expiredCards);
// 记录日志
CardExpireLogModel::create([
'expired_count' => count($expiredCards),
'expire_date' => date('Y-m-d'),
'create_time' => $now,
]);
// 发送过期提醒(可选)
$this->sendExpireNotice($expiredCards);
}
return $totalExpired;
}
/**
* 过期前提醒(提前7天和1天)
*/
public function remindBeforeExpire()
{
$now = time();
// 7天后过期的卡券
$cards7 = CardInstanceModel::where('status', 1)
->where('valid_end_time', '>', $now + 6 * 86400)
->where('valid_end_time', '<=', $now + 7 * 86400)
->select();
foreach ($cards7 as $card) {
MessageService::send($card['user_id'], '卡券即将过期',
"您的卡券「{$card['card_code']}」将于7天后过期,请尽快使用。");
}
// 1天后过期的卡券
$cards1 = CardInstanceModel::where('status', 1)
->where('valid_end_time', '>', $now)
->where('valid_end_time', '<=', $now + 86400)
->select();
foreach ($cards1 as $card) {
MessageService::send($card['user_id'], '卡券即将过期',
"您的卡券「{$card['card_code']}」将于明天过期,请尽快使用。");
}
}
}
5.1 线上兑换核销
用户持卡券在线兑换指定商品;核销时校验卡券有效性、兑换商品权限,生成兑换订单:
php
class CardVerifyService
{
/**
* 线上兑换核销
*/
public function onlineExchange($userId, $cardCode, $goodsId, $itemId = 0)
{
// 1. 校验卡券有效性
$card = $this->checkCardValid($cardCode);
if ($card['user_id'] != $userId) {
throw new \Exception('该卡券不属于您');
}
// 2. 校验兑换商品权限
$config = GoodsCardConfigModel::find($card['card_config_id']);
if (!empty($config['exchange_goods_ids'])) {
$allowGoods = explode(',', $config['exchange_goods_ids']);
if (!in_array($goodsId, $allowGoods)) {
throw new \Exception('该卡券不支持兑换此商品');
}
}
// 3. 校验核销次数
if ($card['verify_count'] >= $card['max_verify_count']) {
throw new \Exception('该卡券核销次数已用完');
}
Db::startTrans();
try {
// 4. 锁定卡券
$card = CardInstanceModel::lock(true)->find($card['id']);
// 5. 更新卡券状态
$newVerifyCount = $card['verify_count'] + 1;
$newStatus = ($newVerifyCount >= $card['max_verify_count']) ? 2 : 1;
CardInstanceModel::where('id', $card['id'])->update([
'status' => $newStatus,
'verify_count' => $newVerifyCount,
'first_verify_time'=> $card['first_verify_time'] ?: time(),
'last_verify_time' => time(),
'update_time' => time(),
]);
// 6. 生成兑换订单(0元订单)
$orderSn = generateSn('CE');
$orderId = OrderModel::create([
'order_sn' => $orderSn,
'user_id' => $userId,
'order_type' => 8, // 8-卡券兑换订单
'order_amount' => 0,
'goods_amount' => 0,
'order_status' => 0,
'pay_status' => 1,
'create_time' => time(),
])->id;
// 7. 写入核销记录
CardVerifyLogModel::create([
'card_instance_id' => $card['id'],
'card_code' => $cardCode,
'user_id' => $userId,
'verify_type' => 1,
'operator_type' => 1,
'exchange_goods_id'=> $goodsId,
'exchange_order_id'=> $orderId,
'verify_result' => 1,
'verify_ip' => request()->ip(),
'create_time' => time(),
]);
Db::commit();
return ['order_id' => $orderId, 'order_sn' => $orderSn];
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
}
5.2 线下扫码核销
线下核销员通过扫码或输入兑换码完成核销;核销员需要先登录核销端,核销时记录操作人:
php
/**
* 线下扫码核销
*/
public function offlineVerify($operatorId, $cardCode)
{
// 1. 校验操作人是否为核销员
$isVerifier = CardVerifierModel::where('user_id', $operatorId)
->where('status', 1)->count();
if (!$isVerifier) {
throw new \Exception('您不是核销员,无权核销');
}
// 2. 校验卡券有效性
$card = $this->checkCardValid($cardCode);
// 3. 校验核销次数
if ($card['verify_count'] >= $card['max_verify_count']) {
throw new \Exception('该卡券核销次数已用完');
}
Db::startTrans();
try {
$card = CardInstanceModel::lock(true)->find($card['id']);
$newVerifyCount = $card['verify_count'] + 1;
$newStatus = ($newVerifyCount >= $card['max_verify_count']) ? 2 : 1;
CardInstanceModel::where('id', $card['id'])->update([
'status' => $newStatus,
'verify_count' => $newVerifyCount,
'first_verify_time'=> $card['first_verify_time'] ?: time(),
'last_verify_time' => time(),
'update_time' => time(),
]);
CardVerifyLogModel::create([
'card_instance_id' => $card['id'],
'card_code' => $cardCode,
'user_id' => $card['user_id'],
'verify_type' => 2,
'operator_id' => $operatorId,
'operator_type' => 2,
'verify_result' => 1,
'verify_ip' => request()->ip(),
'create_time' => time(),
]);
Db::commit();
return ['card_code' => $cardCode, 'verify_time' => date('Y-m-d H:i:s')];
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
5.3 核销失败的记录
核销失败时同样需要记录日志,便于排查问题;失败原因包括:卡券不存在、已过期、已使用、核销员无权、兑换商品不匹配等;失败记录写入 ls_card_verify_log,verify_result 设为 2,fall_reason 记录具体原因。
6.1 后台配置清单
在“商品管理 → 电子卡券”中增加配置页面:
| 配置项 | 类型 | 说明 |
|---|---|---|
| 关联商品 | 商品选择器 | 选择商品类型为“电子卡券”的商品 |
| 卡券类型 | 单选 | 兑换码 / 电子券 / 会员权益卡 |
| 有效期类型 | 单选 | 固定日期 / 领取后N天 / 永久有效 |
| 兑换码前缀 | 文本输入 | 如“VIP” |
| 兑换码长度 | 数字输入 | 8-32位 |
| 库存模式 | 单选 | 预生成卡券池 / 购买时动态生成 |
| 卡券数量 | 数字输入 | 预生成模式下的批量生成数量 |
| 核销方式 | 多选 | 线上兑换 / 线下扫码 / 后台核销 |
| 核销次数 | 数字输入 | 每张卡券可核销次数 |
| 兑换商品 | 商品多选 | 线上兑换时可选的商品范围 |
6.2 开发注意事项
兑换码的唯一性与防猜测。兑换码使用去除了易混淆字符的字符集(去掉0/O/1/l),生成后通过数据库唯一索引做最终去重;兑换码长度建议不低于12位,防止被暴力枚举;对于高价值卡券,建议增加 card_secret 二次验证密钥。
并发核销的防重。同一张卡券可能在多端同时被核销(如用户线上兑换的同时,线下核销员也在扫码);核销时必须使用lock(true) 行锁,并在锁内重新校验核销次数,防止并发核销导致超次。
过期卡券的退款处理。卡券过期后状态变为“已过期”,但如果用户申请退款,需要校验卡券是否已被核销;已核销的卡券不支持退款,未核销但已过期的卡券是否可退由商家配置决定。
兼容性。新增字段全部设置默认值,ls_goods 表的 goods_type 枚举新增值3(电子卡券),不影响已有的实物商品和虚拟商品;卡券模块作为独立的后台模块开发,不修改原生的商品发布和订单逻辑;卡券订单的 order_type 使用8(卡券兑换订单),与原生订单类型区分。
