$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', ''); if (empty($secret)) { throw new \Exception('[vr_ticket] VR_TICKET_QR_SECRET 环境变量未配置,QR加密密钥不能为空。请在.env中设置VR_TICKET_QR_SECRET=<随机64字符字符串>'); } return $secret; } /** * 判断商品是否为票务商品 * * @param int $goods_id * @return bool */ public static function isTicketGoods($goods_id) { $goods = \think\facade\Db::name('Goods')->find($goods_id); 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) { $goods = \think\facade\Db::name('Goods')->find($goods_id); if (empty($goods) || empty($goods['category_id'])) { return null; } return \think\facade\Db::name(self::table('seat_templates')) ->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); } } /** * 初始化票务商品规格 * * 修复商品 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. 检查商品是否存在 $goods = \think\facade\Db::name('Goods')->where('id', $goodsId)->find(); if (empty($goods)) { return ['code' => -2, 'msg' => "商品 {$goodsId} 不存在"]; } $now = time(); // 2. 启用多规格模式 \think\facade\Db::name('Goods')->where('id', $goodsId)->update([ 'is_exist_many_spec' => 1, 'upd_time' => $now, ]); // 3. 定义 $vr- 规格类型(5维:场次、场馆、演播室、分区、座位号) $specTypes = [ '$vr-场次' => '[{"name":"待选场次","images":""}]', '$vr-场馆' => '[{"name":"' . ($goods['venue_data'] ?? '国家体育馆') . '","images":""}]', '$vr-演播室' => '[{"name":"主厅","images":""}]', '$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', '$vr-座位号' => '[{"name":"待选座位","images":""}]', ]; $insertedCount = 0; foreach ($specTypes as $name => $value) { // 幂等:检查是否已存在 $exists = \think\facade\Db::name('GoodsSpecType') ->where('goods_id', $goodsId) ->where('name', $name) ->find(); if (empty($exists)) { \think\facade\Db::name('GoodsSpecType')->insert([ '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,便于验证 $specTypes = \think\facade\Db::name('GoodsSpecType') ->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, ], ]; } /** * 插件后台权限菜单 * * 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'], ], ], ]; } /** * Phase 4: Feistel-8 混淆 + QR签名 + 短码编解码 * ================================================================ */ /** * 获取 VR Ticket 主密钥 * @throws \Exception 未配置密钥时抛出异常 */ private static function getVrSecret(): string { $secret = env('VR_TICKET_SECRET', ''); if (empty($secret)) { throw new \Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全'); } 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(4位base36) + ticket_id(5位base36) = 9位 → Feistel8 → 短码 * - goods_id: 4位 base36 (范围 0-1,679,615,ShopXO 商品上限充足) * - ticket_id: 5位 base36 (范围 0-60,466,175,每商品可发约6000万张票) * - 解码 O(1): 直接取前4位=goods_id,后5位=ticket_id,无需暴力搜索 * * @param int $goods_id 0-1679615 * @param int $ticket_id 0-60466175 * @return string base36小写短码 * @throws \Exception 参数超范围时抛出 */ public static function shortCodeEncode(int $goods_id, int $ticket_id): string { // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615) if ($goods_id > 0xFFFFFF) { throw new \Exception("goods_id 超出范围 (max=1679615), given={$goods_id}"); } // 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823) if ($ticket_id > 0x3FFFFFFF) { throw new \Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}"); } // goods_id 固定4位 base36 $goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT); // ticket_id 固定5位 base36 $ticket_part = str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT); // 拼接为9位 base36 字符串,转为大整数 // 使用 intval(string, 36) 转换 $packed_str = $goods_part . $ticket_part; $packed = intval($packed_str, 36); // Feistel8 混淆 $key = self::getGoodsKey($goods_id); return strtolower(self::feistelEncode($packed, $key)); } /** * 解析短码(解码回 goods_id + ticket_id) * * 明文方案:短码经 Feistel8 解密后,前4位=goods_id,后5位=ticket_id * 解码 O(1),无需暴力搜索 * * @param string $code 短码(小写或大写均可) * @param int|null $goods_id_hint 可选提示(已不再需要,用于兼容) * @return array ['goods_id' => int, 'ticket_id' => int] * @throws \Exception 解码失败时抛出 */ public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array { $code = strtolower($code); // 如果有 hint,直接用 hint 的 key 解密 // 如果没有 hint,暴力搜索(最多尝试 1-100000) $decoded_goods_id = null; $decoded_ticket_id = null; if ($goods_id_hint !== null) { $key = self::getGoodsKey($goods_id_hint); $packed = self::feistelDecode($code, $key); $packed_str = base_convert($packed, 10, 36); // 前4位 goods_id,后5位 ticket_id $decoded_goods_id = intval(substr($packed_str, 0, 4), 36); $decoded_ticket_id = intval(substr($packed_str, 4, 5), 36); // 验证解码出的 goods_id 是否与 hint 匹配 if ($decoded_goods_id !== $goods_id_hint) { throw new \Exception("短码解码失败:hint 不匹配 (code={$code}, hint={$goods_id_hint}, decoded={$decoded_goods_id})"); } } else { // 暴力搜索 goods_id(优化:只搜索实际存在的范围) $max_goods = 100000; for ($gid = 1; $gid <= $max_goods; $gid++) { $key = self::getGoodsKey($gid); $packed = self::feistelDecode($code, $key); $packed_str = base_convert($packed, 10, 36); $candidate_goods_id = intval(substr($packed_str, 0, 4), 36); if ($candidate_goods_id === $gid) { $decoded_goods_id = $gid; $decoded_ticket_id = intval(substr($packed_str, 4, 5), 36); break; } } } if ($decoded_goods_id === null) { throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})"); } return [ 'goods_id' => $decoded_goods_id, 'ticket_id' => $decoded_ticket_id, ]; } /** * 签名 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']), ]; } }