fix(P0): P0-1 idempotent ticket issuance, P0-3 XSS, P0-4 QR secret exception

P0-1: issueTicket() now checks for existing tickets by (order_id, spec_base_id)
      before inserting. Prevents duplicate tickets on HTTP retry/multi-instance.
P0-3: Removed |raw from simple_desc and content in ticket_detail.html.
      Prevents stored XSS via malicious admin content injection.
P0-4: getQrSecret() now throws exception if VR_TICKET_QR_SECRET is unset,
      instead of falling back to insecure default key.
refactor/vr-ticket-20260416
Council 2026-04-15 16:59:22 +08:00
parent 9171046435
commit 098bcfe780
3 changed files with 18 additions and 7 deletions

View File

@ -97,13 +97,11 @@ class BaseService
*/
private static function getQrSecret()
{
// 优先从环境变量读取
$secret = env('VR_TICKET_QR_SECRET', '');
if (!empty($secret)) {
return $secret;
if (empty($secret)) {
throw new \Exception('[vr_ticket] VR_TICKET_QR_SECRET 环境变量未配置QR加密密钥不能为空。请在.env中设置VR_TICKET_QR_SECRET=<随机64字符字符串>');
}
// 回退:使用 ShopXO 应用密钥
return config('shopxo.app_key', 'shopxo_default_secret_change_me');
return $secret;
}
/**

View File

@ -76,6 +76,19 @@ class TicketService extends BaseService
*/
public static function issueTicket($order, $order_goods)
{
// P0-1 幂等保护:同一订单+同一规格只发一张票
$existing = \Db::name(BaseService::table('tickets'))
->where('order_id', $order['id'])
->where('spec_base_id', $order_goods['spec_base_id'] ?? 0)
->find();
if (!empty($existing)) {
BaseService::log('issueTicket: idempotent_skip', [
'order_id' => $order['id'],
'spec_base_id'=> $order_goods['spec_base_id'] ?? 0,
], 'info');
return $existing['id'];
}
$ticket_code = BaseService::generateUuid();
// 构建 QR 数据

View File

@ -122,7 +122,7 @@
<!-- 商品头部 -->
<div class="vr-ticket-header">
<div class="vr-event-title">{$goods.title|default='VR演唱会'}</div>
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>
<div class="vr-event-subtitle">{$goods.simple_desc|default=''}</div>
</div>
<!-- 场次选择 -->
@ -161,7 +161,7 @@
{if !empty($goods.content)}
<div class="vr-seat-section">
<div class="vr-section-title">演出详情</div>
<div class="goods-detail-content">{$goods.content|raw}</div>
<div class="goods-detail-content">{$goods.content}</div>
</div>
{/if}