2026-04-15 05:09:44 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* VR票务插件 - 基础服务
|
|
|
|
|
|
*
|
|
|
|
|
|
* @package vr_ticket\service
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\plugins\vr_ticket\service;
|
|
|
|
|
|
|
|
|
|
|
|
class BaseService
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取插件表前缀
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function table($name)
|
|
|
|
|
|
{
|
2026-04-16 09:23:40 +00:00
|
|
|
|
return 'vr_' . $name;
|
2026-04-15 05:09:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取当前时间戳
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function now()
|
|
|
|
|
|
{
|
|
|
|
|
|
return time();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成 UUID v4 票码
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function generateUuid()
|
|
|
|
|
|
{
|
|
|
|
|
|
$data = random_bytes(16);
|
|
|
|
|
|
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
|
|
|
|
|
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
|
|
|
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* AES-256-CBC 加密 QR 数据
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $data 待加密数据
|
|
|
|
|
|
* @param int|null $expire 过期时间戳(默认30天)
|
|
|
|
|
|
* @return string base64 编码密文
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function encryptQrData($data, $expire = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
$secret = self::getQrSecret();
|
|
|
|
|
|
$expire = $expire ?? (time() + 86400 * 30);
|
|
|
|
|
|
|
|
|
|
|
|
$payload = json_encode(array_merge($data, [
|
|
|
|
|
|
'exp' => $expire,
|
|
|
|
|
|
'iat' => time(),
|
|
|
|
|
|
]), JSON_UNESCAPED_UNICODE);
|
|
|
|
|
|
|
|
|
|
|
|
$iv = random_bytes(16);
|
|
|
|
|
|
$encrypted = openssl_encrypt($payload, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv);
|
|
|
|
|
|
|
|
|
|
|
|
return base64_encode($iv . $encrypted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解密 QR 数据
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $encoded base64 编码密文
|
|
|
|
|
|
* @return array|null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function decryptQrData($encoded)
|
|
|
|
|
|
{
|
|
|
|
|
|
$secret = self::getQrSecret();
|
|
|
|
|
|
$combined = base64_decode($encoded);
|
|
|
|
|
|
|
|
|
|
|
|
if (strlen($combined) < 16) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$iv = substr($combined, 0, 16);
|
|
|
|
|
|
$encrypted = substr($combined, 16);
|
|
|
|
|
|
|
|
|
|
|
|
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv);
|
|
|
|
|
|
|
|
|
|
|
|
if ($decrypted === false) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$data = json_decode($decrypted, true);
|
|
|
|
|
|
|
|
|
|
|
|
if (isset($data['exp']) && $data['exp'] < time()) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 QR 加密密钥
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function getQrSecret()
|
|
|
|
|
|
{
|
|
|
|
|
|
$secret = env('VR_TICKET_QR_SECRET', '');
|
2026-04-15 08:59:22 +00:00
|
|
|
|
if (empty($secret)) {
|
|
|
|
|
|
throw new \Exception('[vr_ticket] VR_TICKET_QR_SECRET 环境变量未配置,QR加密密钥不能为空。请在.env中设置VR_TICKET_QR_SECRET=<随机64字符字符串>');
|
2026-04-15 05:09:44 +00:00
|
|
|
|
}
|
2026-04-15 08:59:22 +00:00
|
|
|
|
return $secret;
|
2026-04-15 05:09:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 判断商品是否为票务商品
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $goods_id
|
|
|
|
|
|
* @return bool
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function isTicketGoods($goods_id)
|
|
|
|
|
|
{
|
2026-04-16 16:46:00 +00:00
|
|
|
|
$goods = \think\facade\Db::name('Goods')->find($goods_id);
|
2026-04-15 05:09:44 +00:00
|
|
|
|
if (empty($goods)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return !empty($goods['venue_data']) || ($goods['item_type'] ?? '') === 'ticket';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取商品座位模板
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $goods_id
|
|
|
|
|
|
* @return array|null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getSeatTemplateByGoods($goods_id)
|
|
|
|
|
|
{
|
2026-04-16 16:46:00 +00:00
|
|
|
|
$goods = \think\facade\Db::name('Goods')->find($goods_id);
|
2026-04-15 05:09:44 +00:00
|
|
|
|
if (empty($goods) || empty($goods['category_id'])) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 16:46:00 +00:00
|
|
|
|
return \think\facade\Db::name(self::table('seat_templates'))
|
2026-04-15 05:09:44 +00:00
|
|
|
|
->where('category_id', $goods['category_id'])
|
|
|
|
|
|
->where('status', 1)
|
|
|
|
|
|
->find();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 安全日志
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function log($message, $context = [], $level = 'info')
|
|
|
|
|
|
{
|
|
|
|
|
|
$tag = '[vr_ticket]';
|
|
|
|
|
|
$ctx = empty($context) ? '' : ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
|
|
|
|
|
|
$log_func = "log_{$level}";
|
|
|
|
|
|
if (function_exists($log_func)) {
|
|
|
|
|
|
$log_func($tag . $message . $ctx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:58:48 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化票务商品规格
|
|
|
|
|
|
*
|
|
|
|
|
|
* 修复商品 112 的 broken 状态:
|
|
|
|
|
|
* 1. 设置 is_exist_many_spec = 1(启用多规格模式)
|
|
|
|
|
|
* 2. 插入 $vr- 规格类型(幂等,多次执行不重复)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $goodsId 商品ID
|
|
|
|
|
|
* @return array ['code' => 0, 'msg' => '...', 'data' => [...]]
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function initGoodsSpecs(int $goodsId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$goodsId = intval($goodsId);
|
|
|
|
|
|
if ($goodsId <= 0) {
|
|
|
|
|
|
return ['code' => -1, 'msg' => '商品ID无效'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 检查商品是否存在
|
2026-04-16 16:46:00 +00:00
|
|
|
|
$goods = \think\facade\Db::name('Goods')->where('id', $goodsId)->find();
|
2026-04-15 11:58:48 +00:00
|
|
|
|
if (empty($goods)) {
|
|
|
|
|
|
return ['code' => -2, 'msg' => "商品 {$goodsId} 不存在"];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$now = time();
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 启用多规格模式
|
2026-04-16 16:46:00 +00:00
|
|
|
|
\think\facade\Db::name('Goods')->where('id', $goodsId)->update([
|
2026-04-15 11:58:48 +00:00
|
|
|
|
'is_exist_many_spec' => 1,
|
|
|
|
|
|
'upd_time' => $now,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-04-22 08:39:39 +00:00
|
|
|
|
// 3. 定义 $vr- 规格类型(5维:场次、场馆、演播室、分区、座位号)
|
2026-04-15 11:58:48 +00:00
|
|
|
|
$specTypes = [
|
2026-04-22 08:39:39 +00:00
|
|
|
|
'$vr-场次' => '[{"name":"待选场次","images":""}]',
|
2026-04-15 11:58:48 +00:00
|
|
|
|
'$vr-场馆' => '[{"name":"' . ($goods['venue_data'] ?? '国家体育馆') . '","images":""}]',
|
2026-04-22 08:39:39 +00:00
|
|
|
|
'$vr-演播室' => '[{"name":"主厅","images":""}]',
|
2026-04-15 11:58:48 +00:00
|
|
|
|
'$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]',
|
|
|
|
|
|
'$vr-座位号' => '[{"name":"待选座位","images":""}]',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
$insertedCount = 0;
|
|
|
|
|
|
foreach ($specTypes as $name => $value) {
|
|
|
|
|
|
// 幂等:检查是否已存在
|
2026-04-16 16:46:00 +00:00
|
|
|
|
$exists = \think\facade\Db::name('GoodsSpecType')
|
2026-04-15 11:58:48 +00:00
|
|
|
|
->where('goods_id', $goodsId)
|
|
|
|
|
|
->where('name', $name)
|
|
|
|
|
|
->find();
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($exists)) {
|
2026-04-16 16:46:00 +00:00
|
|
|
|
\think\facade\Db::name('GoodsSpecType')->insert([
|
2026-04-15 11:58:48 +00:00
|
|
|
|
'goods_id' => $goodsId,
|
|
|
|
|
|
'name' => $name,
|
|
|
|
|
|
'value' => $value,
|
|
|
|
|
|
'add_time' => $now,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$insertedCount++;
|
|
|
|
|
|
self::log('initGoodsSpecs: inserted spec_type', ['goods_id' => $goodsId, 'name' => $name]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self::log('initGoodsSpecs: done', ['goods_id' => $goodsId, 'inserted' => $insertedCount]);
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 返回当前所有 spec_type,便于验证
|
2026-04-16 16:46:00 +00:00
|
|
|
|
$specTypes = \think\facade\Db::name('GoodsSpecType')
|
2026-04-15 11:58:48 +00:00
|
|
|
|
->where('goods_id', $goodsId)
|
|
|
|
|
|
->order('id', 'asc')
|
|
|
|
|
|
->select()
|
|
|
|
|
|
->toArray();
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'code' => 0,
|
|
|
|
|
|
'msg' => "初始化完成,插入 {$insertedCount} 条规格类型",
|
|
|
|
|
|
'data' => [
|
|
|
|
|
|
'goods_id' => $goodsId,
|
|
|
|
|
|
'is_exist_many_spec' => 1,
|
|
|
|
|
|
'spec_types' => $specTypes,
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 05:09:44 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 插件后台权限菜单
|
|
|
|
|
|
*
|
|
|
|
|
|
* ShopXO 通过 PluginsService::PluginsAdminPowerMenu() 调用此方法
|
|
|
|
|
|
* 返回格式:二维数组,每项代表一个菜单分组
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function AdminPowerMenu()
|
|
|
|
|
|
{
|
|
|
|
|
|
return [
|
|
|
|
|
|
// 座位模板
|
|
|
|
|
|
[
|
|
|
|
|
|
'name' => '座位模板',
|
|
|
|
|
|
'control' => 'seat_template',
|
|
|
|
|
|
'action' => 'list',
|
|
|
|
|
|
'item' => [
|
|
|
|
|
|
['name' => '座位模板', 'action' => 'list'],
|
|
|
|
|
|
['name' => '添加模板', 'action' => 'save'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
// 电子票
|
|
|
|
|
|
[
|
|
|
|
|
|
'name' => '电子票',
|
|
|
|
|
|
'control' => 'ticket',
|
|
|
|
|
|
'action' => 'list',
|
|
|
|
|
|
'item' => [
|
|
|
|
|
|
['name' => '电子票列表', 'action' => 'list'],
|
|
|
|
|
|
['name' => '票详情', 'action' => 'detail'],
|
|
|
|
|
|
['name' => '手动核销', 'action' => 'verify'],
|
|
|
|
|
|
['name' => '导出票', 'action' => 'export'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
// 核销员
|
|
|
|
|
|
[
|
|
|
|
|
|
'name' => '核销员',
|
|
|
|
|
|
'control' => 'verifier',
|
|
|
|
|
|
'action' => 'list',
|
|
|
|
|
|
'item' => [
|
|
|
|
|
|
['name' => '核销员列表', 'action' => 'list'],
|
|
|
|
|
|
['name' => '添加核销员', 'action' => 'save'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
// 核销记录
|
|
|
|
|
|
[
|
|
|
|
|
|
'name' => '核销记录',
|
|
|
|
|
|
'control' => 'verification',
|
|
|
|
|
|
'action' => 'list',
|
|
|
|
|
|
'item' => [
|
|
|
|
|
|
['name' => '核销记录', 'action' => 'list'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
2026-04-22 10:51:22 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Phase 4: Feistel-8 混淆 + QR签名 + 短码编解码
|
|
|
|
|
|
* ================================================================
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 VR Ticket 主密钥
|
2026-04-22 15:26:31 +00:00
|
|
|
|
* @throws \Exception 未配置密钥时抛出异常
|
2026-04-22 10:51:22 +00:00
|
|
|
|
*/
|
|
|
|
|
|
private static function getVrSecret(): string
|
|
|
|
|
|
{
|
2026-04-22 15:26:31 +00:00
|
|
|
|
$secret = env('VR_TICKET_SECRET', '');
|
|
|
|
|
|
if (empty($secret)) {
|
|
|
|
|
|
throw new \Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全');
|
2026-04-22 10:51:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
return $secret;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 per-goods key
|
|
|
|
|
|
* 由 master_secret 派生,保证不同商品的编码互相独立
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $goods_id
|
|
|
|
|
|
* @return string 16字节hex
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getGoodsKey(int $goods_id): string
|
|
|
|
|
|
{
|
|
|
|
|
|
static $cache = [];
|
|
|
|
|
|
if (!isset($cache[$goods_id])) {
|
|
|
|
|
|
$secret = self::getVrSecret();
|
|
|
|
|
|
// HMAC-SHA256(master_secret, goods_id) 取前16字节
|
|
|
|
|
|
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
|
|
|
|
|
|
}
|
|
|
|
|
|
return $cache[$goods_id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Feistel Round 函数
|
|
|
|
|
|
* F(R, i, key) = HMAC-SHA256(R . i, key) 的低19bit
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $R 17bit 右半部分
|
|
|
|
|
|
* @param int $round 轮次 [0-7]
|
|
|
|
|
|
* @param string $key per-goods key
|
|
|
|
|
|
* @return int 19bit 输出
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function feistelRound(int $R, int $round, string $key): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$hmac = hash_hmac('sha256', $R . '.' . $round, $key, true);
|
|
|
|
|
|
// 取前3字节(24bit),保留低19bit
|
|
|
|
|
|
$val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]);
|
|
|
|
|
|
return $val & 0x7FFFF; // 19bit mask
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Feistel-8 混淆编码
|
|
|
|
|
|
*
|
|
|
|
|
|
* 位分配:L=19bit, R=17bit(凑满36bit)
|
|
|
|
|
|
* @param int $packed 33bit整数(goods_id<<17 | ticket_id)
|
|
|
|
|
|
* @param string $key per-goods key
|
|
|
|
|
|
* @return string base36编码
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function feistelEncode(int $packed, string $key): string
|
|
|
|
|
|
{
|
|
|
|
|
|
// 分离 L(高19bit) 和 R(低17bit)
|
|
|
|
|
|
$L = ($packed >> 17) & 0x7FFFF;
|
|
|
|
|
|
$R = $packed & 0x1FFFF;
|
|
|
|
|
|
|
|
|
|
|
|
// 8轮 Feistel 置换
|
|
|
|
|
|
for ($i = 0; $i < 8; $i++) {
|
|
|
|
|
|
$F = self::feistelRound($R, $i, $key);
|
|
|
|
|
|
$L_new = $R;
|
|
|
|
|
|
$R_new = $L ^ $F;
|
|
|
|
|
|
$L = $L_new;
|
|
|
|
|
|
$R = $R_new;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 合并为36bit整数
|
|
|
|
|
|
$result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
|
|
|
|
|
|
|
|
|
|
|
return base_convert($result, 10, 36);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Feistel-8 解码(逆向8轮)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $code base36编码
|
|
|
|
|
|
* @param string $key per-goods key
|
|
|
|
|
|
* @return int 整数
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function feistelDecode(string $code, string $key): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$packed = intval(base_convert(strtolower($code), 36, 10));
|
|
|
|
|
|
|
|
|
|
|
|
// 分离 L 和 R
|
|
|
|
|
|
$L = ($packed >> 17) & 0x7FFFF;
|
|
|
|
|
|
$R = $packed & 0x1FFFF;
|
|
|
|
|
|
|
|
|
|
|
|
// 8轮逆向 Feistel 置换
|
|
|
|
|
|
for ($i = 7; $i >= 0; $i--) {
|
|
|
|
|
|
$F = self::feistelRound($L, $i, $key);
|
|
|
|
|
|
$R_new = $L;
|
|
|
|
|
|
$L_new = $R ^ $F;
|
|
|
|
|
|
$R = $R_new;
|
|
|
|
|
|
$L = $L_new;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 合并
|
|
|
|
|
|
return ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成短码
|
|
|
|
|
|
*
|
|
|
|
|
|
* 位分配:goods_id(高16bit) + ticket_id(低17bit) = 33bit → Feistel8 → base36
|
|
|
|
|
|
*
|
2026-04-22 15:26:31 +00:00
|
|
|
|
* @param int $goods_id 必须 ≤ 65535 (16bit)
|
|
|
|
|
|
* @param int $ticket_id 必须 ≤ 131071 (17bit)
|
2026-04-22 10:51:22 +00:00
|
|
|
|
* @return string base36小写短码
|
2026-04-22 15:26:31 +00:00
|
|
|
|
* @throws \Exception goods_id 或 ticket_id 超范围时抛出
|
2026-04-22 10:51:22 +00:00
|
|
|
|
*/
|
|
|
|
|
|
public static function shortCodeEncode(int $goods_id, int $ticket_id): string
|
|
|
|
|
|
{
|
2026-04-22 15:26:31 +00:00
|
|
|
|
// 校验 goods_id 不超过 16bit
|
|
|
|
|
|
if ($goods_id > 0xFFFF) {
|
|
|
|
|
|
throw new \Exception("goods_id 超出16bit范围 (max=65535), given={$goods_id}");
|
|
|
|
|
|
}
|
|
|
|
|
|
// 校验 ticket_id 不超过 17bit
|
2026-04-22 10:51:22 +00:00
|
|
|
|
if ($ticket_id > 0x1FFFF) {
|
|
|
|
|
|
throw new \Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 位打包:goods_id(16bit) << 17 | ticket_id(17bit)
|
|
|
|
|
|
$packed = ($goods_id << 17) | $ticket_id;
|
|
|
|
|
|
|
|
|
|
|
|
// Feistel8 混淆
|
|
|
|
|
|
$key = self::getGoodsKey($goods_id);
|
|
|
|
|
|
return strtolower(self::feistelEncode($packed, $key));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析短码(解码回 goods_id + ticket_id)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $code 短码(小写或大写均可)
|
|
|
|
|
|
* @param int|null $goods_id_hint 可选的商品ID提示(用于优化搜索)
|
|
|
|
|
|
* @return array ['goods_id' => int, 'ticket_id' => int]
|
|
|
|
|
|
* @throws \Exception 如果找不到匹配的 goods_id
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$code = strtolower($code);
|
|
|
|
|
|
|
2026-04-22 15:26:31 +00:00
|
|
|
|
// 搜索范围:有 hint 则只搜索 hint,否则暴力搜索 1-100000
|
|
|
|
|
|
$start = $goods_id_hint ?? 1;
|
|
|
|
|
|
$end = $goods_id_hint ?? 100000;
|
2026-04-22 10:51:22 +00:00
|
|
|
|
|
2026-04-22 15:26:31 +00:00
|
|
|
|
for ($gid = $start; $gid <= $end; $gid++) {
|
2026-04-22 10:51:22 +00:00
|
|
|
|
$key = self::getGoodsKey($gid);
|
|
|
|
|
|
$packed = self::feistelDecode($code, $key);
|
|
|
|
|
|
|
|
|
|
|
|
// 提取 goods_id:高16bit
|
|
|
|
|
|
$decoded_goods_id = ($packed >> 17) & 0xFFFF;
|
|
|
|
|
|
|
|
|
|
|
|
// 提取 ticket_id:低17bit
|
|
|
|
|
|
$decoded_ticket_id = $packed & 0x1FFFF;
|
|
|
|
|
|
|
|
|
|
|
|
// 验证 goods_id 是否匹配
|
|
|
|
|
|
if ($decoded_goods_id === $gid) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'goods_id' => $gid,
|
|
|
|
|
|
'ticket_id' => $decoded_ticket_id,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 签名 QR payload(HMAC-SHA256 防篡改)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $payload ['id'=>int, 'g'=>int(goods_id), 'iat'=>int, 'exp'=>int]
|
|
|
|
|
|
* @return string base64编码的签名内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function signQrPayload(array $payload): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$secret = self::getVrSecret();
|
|
|
|
|
|
// 签名内容:id.g.iat.exp
|
|
|
|
|
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
|
|
|
|
|
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
|
|
|
|
|
$payload['sig'] = $sig;
|
|
|
|
|
|
|
|
|
|
|
|
return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 验证 QR payload
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $encoded base64编码
|
|
|
|
|
|
* @return array|null 验证失败返回null,成功返回 payload(含id/g/exp)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function verifyQrPayload(string $encoded)
|
|
|
|
|
|
{
|
|
|
|
|
|
$json = base64_decode($encoded);
|
|
|
|
|
|
if ($json === false) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$payload = json_decode($json, true);
|
|
|
|
|
|
if (!is_array($payload)) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 必填字段检查
|
|
|
|
|
|
if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 时间戳检查:是否过期
|
|
|
|
|
|
if ($payload['exp'] < time()) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 签名验证
|
|
|
|
|
|
$secret = self::getVrSecret();
|
|
|
|
|
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
|
|
|
|
|
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
|
|
|
|
|
|
|
|
|
|
|
if (!hash_equals($expected_sig, $payload['sig'])) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'id' => intval($payload['id']),
|
|
|
|
|
|
'g' => intval($payload['g']),
|
|
|
|
|
|
'exp' => intval($payload['exp']),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
2026-04-15 05:09:44 +00:00
|
|
|
|
}
|