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

533 lines
16 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 主密钥
*/
private static function getVrSecret(): string
{
$secret = env('VR_TICKET_SECRET', 'vrt-default-secret-change-me');
if ($secret === 'vrt-default-secret-change-me') {
self::log('WARNING: using default VR_TICKET_SECRET, set in .env for production', [], 'warning');
}
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
*
* @param int $goods_id
* @param int $ticket_id ticket_id 必须 ≤ 131071 (17bit)
* @return string base36小写短码
* @throws \Exception 如果 ticket_id 超出17bit范围
*/
public static function shortCodeEncode(int $goods_id, int $ticket_id): string
{
// 验证 ticket_id 不超过 17bit
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);
// 候选 goods_id 列表
$candidates = [];
if ($goods_id_hint !== null) {
$candidates[] = $goods_id_hint;
}
// 暴力搜索ShopXO 商品 ID 通常 < 100000
$max_goods = 100000;
for ($gid = 1; $gid <= $max_goods; $gid++) {
if ($goods_id_hint !== null && $gid !== $goods_id_hint) {
continue;
}
$candidates[] = $gid;
}
foreach ($candidates as $gid) {
$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 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']),
];
}
}