vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/BaseService.php

542 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
/**
* VR票务插件 - 基础服务
*
* @package vr_ticket\service
*/
namespace app\plugins\vr_ticket\service;
class BaseService
{
/**
* 获取插件表前缀
*/
public static function table($name)
{
return 'vr_' . $name;
}
/**
* 获取当前时间戳
*/
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', '');
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', '');
// 测试密钥
$secret = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c';
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);
}
/**
* 生成短码
*
* 编码结构【明文4位 goods_id】【混淆5位 ticket_id】→ 短码
* - 前4位goods_id 明文 base36 (范围 0-1,679,615)
* - 后5位ticket_id 经 Feistel8 混淆 (范围 0-60,466,175)
* - 解码 O(1)直接读前4位=goods_id用key解密后5位=ticket_id
*
* @param int $goods_id 0-1679615
* @param int $ticket_id 0-60466175
* @return string base36小写短码9位
* @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 混淆
// ticket_id 填满5位 base36用 Feistel8 混淆
$ticket_int = intval(str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT), 36);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
// 确保混淆结果也是5位
$ticket_part = str_pad($obfuscated, 5, '0', STR_PAD_LEFT);
// 拼接前4位明文 goods_id + 后5位混淆 ticket_id
return strtolower($goods_part . $ticket_part);
}
/**
* 解析短码(解码回 goods_id + ticket_id
*
* 解码结构【明文4位 goods_id】【混淆5位 ticket_id】
* - 前4位直接 base36_decode = goods_id
* - 后5位用 goods_id 派生 key → Feistel 解密 = ticket_id
* - 解码 O(1),无暴力搜索
*
* @param string $code 短码小写或大写均可9位
* @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);
// 前4位明文 goods_id
$goods_part = substr($code, 0, 4);
$goods_id = intval($goods_part, 36);
// 校验 hint如果提供
if ($goods_id_hint !== null && $goods_id !== $goods_id_hint) {
throw new \Exception("短码解码失败hint 不匹配 (code={$code}, hint={$goods_id_hint}, decoded={$goods_id})");
}
// 用 goods_id 派生 key
$key = self::getGoodsKey($goods_id);
// 后5位混淆的 ticket_id → Feistel 解密
$ticket_part = substr($code, 4, 5);
$ticket_int = self::feistelDecode($ticket_part, $key);
// 转回字符串确保5位然后 decode
$ticket_id = intval(str_pad(base_convert($ticket_int, 10, 36), 5, '0', STR_PAD_LEFT), 36);
return [
'goods_id' => $goods_id,
'ticket_id' => $ticket_id,
];
}
/**
* 签名 QR payloadHMAC-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']),
];
}
}