$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'], ], ], ]; } }