浏览代码

1. 优惠券
2. 加密货币送5%

laowu 1 天之前
父节点
当前提交
c3009e8e22

+ 267 - 0
Coupon_API_Doc.md

@@ -0,0 +1,267 @@
+# 优惠券 (Coupon) API 文档
+
+## 概述
+
+优惠券功能允许用户在充值时获得额外的金币奖励。系统会根据配置的策略自动向符合条件的用户发放优惠券。
+
+**架构**: 采用事件驱动模式 —— 订单支付成功后触发 `OrderPaid` 事件,由 `ProcessCouponOnOrderPaid` Listener 异步处理优惠券逻辑(验证、发放金币、标记已用),与订单核心流程完全解耦。
+
+**路由位置**: `routes/game.php`(`checkGameLogin` + `mustGameLogin` 中间件组)
+
+### 优惠券类型
+
+| 类型 | type_id | 说明 |
+|------|---------|------|
+| 固定金额 | 1 | 充值后赠送固定金额金币(与充值额无关) |
+| 充值百分比 | 2 | 充值后按充值金额的百分比赠送金币,有上限 |
+
+### 优惠券状态
+
+| 状态 | status | 说明 |
+|------|--------|------|
+| 未使用 | 0 | 可用 |
+| 已使用 | 1 | 已绑定订单并发放金币 |
+| 已过期 | 2 | 超过有效期 |
+
+---
+
+## 接口列表
+
+### 1. 获取优惠券列表
+
+获取用户当前可用的优惠券列表,**同时触发自动发放逻辑**。系统会根据 `config/coupon.php` 中的规则检查用户是否符合发放条件,符合则自动发放新券。
+
+```
+GET /game/coupon/list
+```
+
+**鉴权**: `checkGameLogin` + `mustGameLogin` 中间件(从 `$request->globalUser->UserID` 获取用户ID)
+
+**请求示例**:
+```http
+GET /game/coupon/list
+```
+
+**成功响应**:
+```json
+{
+    "code": 200,
+    "msg": "success",
+    "data": {
+        "list": [
+            {
+                "id": 1,
+                "name": "new_user_bonus",
+                "type": "percent",
+                "type_id": 2,
+                "value": 50,
+                "min_recharge": 10,
+                "max_bonus": 50,
+                "desc": "+50% bonus (max 50)",
+                "status": 0,
+                "expire_at": "2026-06-03 12:00:00",
+                "issued_at": "2026-05-27 12:00:00"
+            }
+        ],
+        "new_issued": [
+            {
+                "id": 1,
+                "name": "new_user_bonus",
+                "type": "percent",
+                "type_id": 2,
+                "value": 50,
+                "min_recharge": 10,
+                "max_bonus": 50,
+                "desc": "+50% bonus (max 50)",
+                "status": 0,
+                "expire_at": "2026-06-03 12:00:00",
+                "issued_at": "2026-05-27 12:00:00"
+            }
+        ]
+    }
+}
+```
+
+**响应字段说明**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| list | array | 当前所有可用优惠券 |
+| new_issued | array | 本次请求新发放的优惠券(首次调用时可能非空) |
+| id | int | 优惠券ID,充值下单时传入 |
+| name | string | 优惠券名称(标识) |
+| type | string | `fixed`=固定金额, `percent`=百分比 |
+| type_id | int | 1=固定金额, 2=百分比 |
+| value | float | 优惠值(元 or %) |
+| min_recharge | float | 最低充值门槛(元) |
+| max_bonus | float | 最大赠送上限(元),百分比券有效 |
+| desc | string | 前端展示文案 |
+| status | int | 0=可用, 1=已用, 2=过期 |
+| expire_at | string | 过期时间 |
+| issued_at | string | 发放时间 |
+
+---
+
+### 2. 预估优惠券赠送金额
+
+在用户选择优惠券后、发起支付前调用,用于向用户展示预计获得的额外金币。
+
+```
+POST /game/coupon/preview
+```
+
+**鉴权**: `checkGameLogin` + `mustGameLogin` 中间件
+
+**请求参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| userID | int | 是 | 用户ID |
+| coupon_id | int | 是 | 优惠券ID |
+| payAmt | float | 是 | 充值金额(元) |
+
+**请求示例**:
+```http
+POST /game/coupon/preview
+Content-Type: application/json
+
+{
+    "userID": 123456,
+    "coupon_id": 1,
+    "payAmt": 50
+}
+```
+
+**成功响应**:
+```json
+{
+    "code": 200,
+    "msg": "success",
+    "data": {
+        "coupon_id": 1,
+        "bonus_amount": 25.00,
+        "bonus_coins": 2500,
+        "total_amount": 75.00
+    }
+}
+```
+
+**响应字段说明**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| coupon_id | int | 优惠券ID |
+| bonus_amount | float | 预计赠送金额(元) |
+| bonus_coins | int | 预计赠送金币(分,1元=100分) |
+| total_amount | float | 预计到账总额(元)= 充值额 + 赠送额 |
+
+**失败响应**:
+```json
+{
+    "code": 301,
+    "msg": "Minimum recharge 10 required for this coupon",
+    "data": []
+}
+```
+
+---
+
+### 3. 支付时使用优惠券
+
+在支付接口中新增 `coupon_id` 参数,传入要使用的优惠券ID。
+
+**影响接口**: `POST /payment_entry/pay`(`routes/game.php`)
+
+**新增参数**:
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| coupon_id | int | 否 | 0 | 优惠券ID,0=不使用优惠券 |
+
+**请求示例**:
+```json
+{
+    "payAmt": 50,
+    "pay_type": 1,
+    "pay_method": "2",
+    "GiftsID": 0,
+    "coupon_id": 1
+}
+```
+
+**处理流程(事件驱动)**:
+1. `ProcessCouponOnOrderPaid` Listener 异步处理:
+   - 验证优惠券有效性(未使用、未过期、充值金额满足门槛)
+   - 计算赠送金币
+   - 标记优惠券为已使用,绑定订单号
+   - 增加用户金币(Reason=55, 优惠券赠送)
+2. 如果优惠券使用失败(已过期/已使用/金额不满足),不影响正常充值流程,仅记录日志
+
+---
+
+## 自动发放策略
+
+系统在用户调用 `GET /game/coupon/list` 时自动检查以下条件并发放优惠券。
+
+配置文件: `config/coupon.php`
+
+### 默认策略
+
+| 策略名称 | 条件 | 券类型 | 优惠 | 最低充值 | 有效期 |
+|----------|------|--------|------|----------|--------|
+| new_user_bonus | 注册≤3天 + 从未充值 | 百分比 | 50% (上限50元) | 10元 | 7天 |
+| comeback_bonus | 最近充值≥14天前 | 百分比 | 30% (上限30元) | 20元 | 3天 |
+| vip_recharge_bonus | 累计充值≥500元 | 固定 | 20元 | 50元 | 14天 |
+
+### 发放条件类型
+
+| 条件类型 | 参数 | 说明 |
+|----------|------|------|
+| new_user | days | 注册天数 ≤ days 且从未充值 |
+| inactive_days | days | 最近一次充值距今 ≥ days(需有充值历史) |
+| recharge_total | amount | 累计充值金额 ≥ amount(元) |
+| vip_level | level | VIP等级 ≥ level |
+
+### 防重复机制
+
+同一用户不会重复获得同名的有效优惠券。只有当前券过期或使用后,下次请求才会重新发放。
+
+---
+
+## 数据库表结构
+
+### agent.dbo.user_coupons
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT IDENTITY PK | 主键 |
+| user_id | INT NOT NULL | 用户ID |
+| coupon_name | VARCHAR(50) | 优惠券名称 |
+| coupon_type | TINYINT | 1=固定金额, 2=百分比 |
+| coupon_value | DECIMAL(10,2) | 优惠值 |
+| min_recharge | DECIMAL(10,2) | 最低充值门槛 |
+| max_bonus | DECIMAL(10,2) | 最大赠送上限 |
+| bonus_coins | INT | 实际赠送金币(分) |
+| order_sn | VARCHAR(64) | 关联订单号 |
+| status | TINYINT | 0=未使用, 1=已使用, 2=已过期 |
+| issued_at | DATETIME | 发放时间 |
+| used_at | DATETIME | 使用时间 |
+| expire_at | DATETIME | 过期时间 |
+
+### 优惠券-订单关联
+
+优惠券通过 `order_sn` 字段关联订单,**不在 order 表中新增字段**,保持订单表结构不变。
+
+`coupon_id` 在支付请求→回调之间通过 Redis 临时存储(key=`coupon_{order_sn}`,TTL=24h)。
+
+---
+
+## 金币变更记录
+
+使用优惠券后,金币增加记录写入 `QPRecordDB.dbo.RecordUserScoreChange`:
+
+| 字段 | 值 | 说明 |
+|------|-----|------|
+| Reason | 55 | 优惠券赠送(与现有21/33/44/45/49/51/52/72/73不冲突) |
+
+可通过 `config/coupon.php` 中的 `score_reason` 配置修改。

+ 32 - 0
app/Events/OrderCreated.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Queue\SerializesModels;
+
+/**
+ * 订单创建事件
+ *
+ * 在 OrderLogic::orderCreate() 中触发。
+ * Listener 可从 $requestData 中提取所需字段(如 coupon_id)。
+ */
+class OrderCreated
+{
+    use SerializesModels;
+
+    /** @var string 订单号 */
+    public $orderSn;
+
+    /** @var array 请求数据(Request::all()) */
+    public $requestData;
+
+    /**
+     * @param string $orderSn     订单号
+     * @param array  $requestData 请求数据
+     */
+    public function __construct($orderSn, array $requestData = [])
+    {
+        $this->orderSn     = $orderSn;
+        $this->requestData = $requestData;
+    }
+}

+ 37 - 0
app/Events/OrderPaid.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Queue\SerializesModels;
+
+/**
+ * 订单支付成功事件
+ *
+ * 在 OrderServices::addRecord() 中触发,所有支付提供商统一入口。
+ * Listener 可监听此事件执行后续逻辑(如优惠券处理、统计等)。
+ */
+class OrderPaid
+{
+    use SerializesModels;
+
+    /** @var int 用户ID */
+    public $userId;
+
+    /** @var float 充值金额(元) */
+    public $payAmt;
+
+    /** @var string 订单号 */
+    public $orderSn;
+
+    /**
+     * @param int    $userId
+     * @param float  $payAmt   充值金额(元)
+     * @param string $orderSn  订单号
+     */
+    public function __construct($userId, $payAmt, $orderSn)
+    {
+        $this->userId   = $userId;
+        $this->payAmt   = $payAmt;
+        $this->orderSn  = $orderSn;
+    }
+}

+ 100 - 0
app/Http/Controllers/Game/CouponController.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers\Game;
+
+use App\Http\logic\api\CouponLogic;
+use App\Models\UserCoupon;
+use Illuminate\Http\Request;
+
+/**
+ * 优惠券 API 控制器
+ *
+ * 路由:
+ *   GET  /coupon/list    获取用户优惠券列表(自动发放)
+ *   POST /coupon/preview 预估优惠券赠送金额(充值前调用)
+ */
+class CouponController
+{
+    /**
+     * 获取用户优惠券列表
+     *
+     * 同时触发自动发放逻辑:根据配置策略检查用户是否符合条件并自动发放。
+     *
+     * GET /api/coupon/list
+     * 参数: userID (必填)
+     *
+     * 响应:
+     * {
+     *   "code": 200,
+     *   "msg": "success",
+     *   "data": {
+     *     "list": [...],       // 当前可用优惠券
+     *     "new_issued": [...]   // 本次新发放的优惠券
+     *   }
+     * }
+     */
+    public function list(Request $request)
+    {
+        // game.php 中间件设置 globalUser;api.php 使用 userID 参数
+        $userId = (int) ($request->globalUser->UserID ?? $request->input('userID', 0));
+
+        if ($userId <= 0) {
+            return apiReturnFail('Invalid userID');
+        }
+
+        $logic = new CouponLogic();
+        $data = $logic->getList($userId);
+
+        return apiReturnSuc($data);
+    }
+
+    /**
+     * 预估优惠券赠送金额(充值前调用)
+     *
+     * 用户在充值页面选择优惠券后,可调用此接口查看预计赠送金额。
+     *
+     * POST /api/coupon/preview
+     * 参数: userID (必填), coupon_id (必填), payAmt (必填, 充值金额元)
+     *
+     * 响应:
+     * {
+     *   "code": 200,
+     *   "msg": "success",
+     *   "data": {
+     *     "coupon_id": 1,
+     *     "bonus_amount": 10.00,    // 赠送金额(元)
+     *     "bonus_coins": 1000,      // 赠送金币(分)
+     *     "total_amount": 60.00     // 到账总额(元) = 充值 + 赠送
+     *   }
+     * }
+     */
+    public function preview(Request $request)
+    {
+        // game.php 中间件设置 globalUser;api.php 使用 userID 参数
+        $userId = (int) ($request->globalUser->UserID ?? $request->input('userID', 0));
+        $couponId = (int) $request->input('coupon_id');
+        $payAmt = (float) $request->input('payAmt');
+
+        if ($userId <= 0 || $couponId <= 0 || $payAmt <= 0) {
+            return apiReturnFail('Invalid parameters');
+        }
+
+        $logic = new CouponLogic();
+        $coupon = $logic->validateForPayment($couponId, $userId, $payAmt);
+
+        if ($coupon === false) {
+            return apiReturnFail($logic->getError());
+        }
+
+        $couponService = new \App\Services\CouponService();
+        $bonusCoins = $couponService->calcBonusCoins($coupon, $payAmt);
+        $bonusAmount = $bonusCoins / 100; // 分转元
+
+        return apiReturnSuc([
+            'coupon_id'    => $couponId,
+            'bonus_amount' => round($bonusAmount, 2),
+            'bonus_coins'  => $bonusCoins,
+            'total_amount' => round($payAmt + $bonusAmount, 2),
+        ]);
+    }
+}

+ 105 - 0
app/Http/logic/api/CouponLogic.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Http\logic\api;
+
+use App\Models\UserCoupon;
+use App\Services\CouponService;
+
+/**
+ * 优惠券业务逻辑层
+ *
+ * 处理优惠券相关的请求:列表获取、使用前验证
+ */
+class CouponLogic extends BaseApiLogic
+{
+    /** @var CouponService */
+    protected $couponService;
+
+    public function __construct()
+    {
+        $this->couponService = new CouponService();
+    }
+
+    /**
+     * 获取用户优惠券列表(含自动发放)
+     *
+     * @param int $userId
+     * @return array
+     */
+    public function getList($userId)
+    {
+        $result = $this->couponService->getCouponList($userId);
+
+        // 格式化返回给前端
+        $formattedList = [];
+        foreach ($result['list'] as $coupon) {
+            $formattedList[] = $this->formatCoupon($coupon);
+        }
+
+        $formattedNew = [];
+        foreach ($result['new_issued'] as $coupon) {
+            $formattedNew[] = $this->formatCoupon((object) $coupon);
+        }
+
+        return [
+            'list'       => $formattedList,
+            'new_issued' => $formattedNew,
+        ];
+    }
+
+    /**
+     * 验证优惠券是否可用于充值
+     *
+     * @param int   $couponId
+     * @param int   $userId
+     * @param float $payAmt 充值金额(元)
+     * @return array|false  成功返回优惠券信息,失败返回false
+     */
+    public function validateForPayment($couponId, $userId, $payAmt)
+    {
+        $validation = $this->couponService->validateCoupon($couponId, $userId, $payAmt);
+
+        if (!$validation['valid']) {
+            $this->error = $validation['error'];
+            return false;
+        }
+
+        return $validation['coupon'];
+    }
+
+    /**
+     * 格式化优惠券数据为前端友好格式
+     *
+     * @param object $coupon
+     * @return array
+     */
+    protected function formatCoupon($coupon)
+    {
+        $typeLabel = $coupon->coupon_type == UserCoupon::TYPE_FIXED ? 'fixed' : 'percent';
+
+        // 构建描述文案
+        if ($coupon->coupon_type == UserCoupon::TYPE_FIXED) {
+            $desc = "+{$coupon->coupon_value} bonus";
+        } else {
+            $desc = "+{$coupon->coupon_value}% bonus";
+            if ($coupon->max_bonus > 0) {
+                $desc .= " (max {$coupon->max_bonus})";
+            }
+        }
+
+        return [
+            'id'           => (int) $coupon->id,
+            'name'         => $coupon->coupon_name,
+            'type'         => $typeLabel,
+            'type_id'      => (int) $coupon->coupon_type,
+            'value'        => (float) $coupon->coupon_value,
+            'min_recharge' => (float) $coupon->min_recharge,
+            'max_bonus'    => (float) $coupon->max_bonus,
+            'desc'         => $desc,
+            'status'       => (int) $coupon->status,
+            'expire_at'    => $coupon->expire_at,
+            'issued_at'    => $coupon->issued_at ?? '',
+            'expire_in_seconds' => $coupon->expire_in_seconds ?? 0,
+        ];
+    }
+}

+ 5 - 0
app/Http/logic/api/OrderLogic.php

@@ -4,10 +4,12 @@
 namespace App\Http\logic\api;
 
 
+use App\Events\OrderCreated;
 use App\Facade\TableName;
 use App\Game\GlobalUserInfo;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Request;
 
 class OrderLogic extends BaseApiLogic
 {
@@ -113,6 +115,9 @@ class OrderLogic extends BaseApiLogic
 
         $RecordPlatformDataModel->PayRequestToday($Channel,$RegisterDate,!$first);
 
+        // 触发订单创建事件,传入完整请求数据(Listener 可从中提取 coupon_id 等)
+        event(new OrderCreated($order_sn, Request::all()));
+
         return true;
     }
 }

+ 104 - 0
app/Listeners/AddCryptoBonusOnOrderPaid.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Listeners;
+
+use App\Events\OrderPaid;
+use App\Http\helper\NumConfig;
+use App\Models\RecordScoreInfo;
+use App\Services\PaidRewardStatisticsService;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 加密货币支付额外赠送 5% 彩金
+ *
+ * 监听 OrderPaid 事件,当支付方式为加密货币(16=BTC, 32=ETH, 1024=USDT, 2048=USDC)时,
+ * 额外赠送充值金额 5% 的彩金到用户金币账户。
+ *
+ * 赠送失败不影响主流程(仅记录日志)。
+ */
+class AddCryptoBonusOnOrderPaid
+{
+    /** @var int 加密货币支付方式的 ID 集合 */
+    const CRYPTO_PAY_METHODS = [16, 32, 1024, 2048];
+
+    /** @var float 赠送比例 */
+    const BONUS_RATE = 0.05;
+
+    /**
+     * 处理事件
+     *
+     * @param OrderPaid $event
+     * @return void
+     */
+    public function handle(OrderPaid $event)
+    {
+        try {
+            // 查询订单的支付方式(order_title 存储 pay_method)
+            $order = DB::connection('write')
+                ->table('agent.dbo.order')
+                ->where('order_sn', $event->orderSn)
+                ->select('order_title')
+                ->first();
+
+            if (!$order) {
+                Log::warning('AddCryptoBonusOnOrderPaid: order not found', [
+                    'order_sn' => $event->orderSn,
+                ]);
+                return;
+            }
+
+            $payMethod = (int) $order->order_title;
+
+            // 仅在加密货币支付时赠送
+            if (!in_array($payMethod, self::CRYPTO_PAY_METHODS, true)) {
+                return;
+            }
+
+            // 计算赠送金币数(向下取整,避免小数)
+            $bonusCoins = (int) floor($event->payAmt * self::BONUS_RATE * NumConfig::NUM_VALUE);
+
+            if ($bonusCoins <= 0) {
+                return;
+            }
+
+            Log::info('AddCryptoBonusOnOrderPaid: start', [
+                'user_id'      => $event->userId,
+                'order_sn'     => $event->orderSn,
+                'pay_amt'      => $event->payAmt,
+                'pay_method'   => $payMethod,
+                'bonus_coins'  => $bonusCoins,
+            ]);
+
+            // 记录金币变化日志
+            RecordScoreInfo::addScore(
+                $event->userId,
+                $bonusCoins,
+                RecordScoreInfo::REASON_CRYPTO_BONUS
+            );
+
+            // 增加用户金币
+            DB::connection('write')
+                ->table('QPTreasureDB.dbo.GameScoreInfo')
+                ->where('UserID', $event->userId)
+                ->increment('Score', $bonusCoins);
+
+            // 统计
+            app(PaidRewardStatisticsService::class)
+                ->incrementRecordByDateIDAndType(date('Ymd'), 'crypto_bonus', $bonusCoins);
+
+            Log::info('AddCryptoBonusOnOrderPaid: success', [
+                'user_id'     => $event->userId,
+                'order_sn'    => $event->orderSn,
+                'bonus_coins' => $bonusCoins,
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('AddCryptoBonusOnOrderPaid: exception', [
+                'user_id'   => $event->userId,
+                'order_sn'  => $event->orderSn,
+                'error'     => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 40 - 0
app/Listeners/BindCouponToOrder.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Listeners;
+
+use App\Events\OrderCreated;
+use App\Models\UserCoupon;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 订单创建后绑定优惠券
+ *
+ * 监听 OrderCreated 事件,将优惠券的 order_sn 字段更新为实际订单号。
+ * 之后在支付回调中可直接通过 order_sn 查找关联的优惠券。
+ */
+class BindCouponToOrder
+{
+    /**
+     * 处理事件:从请求数据中提取 coupon_id 并将优惠券绑定到订单
+     *
+     * @param OrderCreated $event
+     * @return void
+     */
+    public function handle(OrderCreated $event)
+    {
+        $couponId = (int) ($event->requestData['coupon_id'] ?? 0);
+
+        if ($couponId <= 0 || empty($event->orderSn)) {
+            return;
+        }
+
+        // 将优惠券的 order_sn 更新为实际订单号(仅当 order_sn 为空且优惠券未使用时)
+        $updated = UserCoupon::bindOrderSn($couponId, $event->orderSn);
+
+        Log::info('BindCouponToOrder', [
+            'coupon_id' => $couponId,
+            'order_sn'  => $event->orderSn,
+            'updated'   => $updated,
+        ]);
+    }
+}

+ 81 - 0
app/Listeners/ProcessCouponOnOrderPaid.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Listeners;
+
+use App\Events\OrderPaid;
+use App\Models\UserCoupon;
+use App\Services\CouponService;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 订单支付成功后处理优惠券
+ *
+ * 监听 OrderPaid 事件(在 OrderServices::addRecord 中触发)。
+ * 通过 order_sn 查找关联的优惠券并处理:
+ *   1. 验证优惠券有效性
+ *   2. 计算并发放额外金币
+ *   3. 标记优惠券已使用
+ *
+ * 优惠券处理失败不影响主流程(仅记录日志)。
+ */
+class ProcessCouponOnOrderPaid
+{
+    /** @var CouponService */
+    protected $couponService;
+
+    public function __construct()
+    {
+        $this->couponService = new CouponService();
+    }
+
+    /**
+     * 处理事件
+     *
+     * @param OrderPaid $event
+     * @return void
+     */
+    public function handle(OrderPaid $event)
+    {
+        // 通过订单号查找关联的优惠券
+        $coupon = UserCoupon::findByOrderSn($event->orderSn);
+        if (!$coupon) {
+            return;
+        }
+
+        Log::info('ProcessCouponOnOrderPaid: start', [
+            'user_id'   => $event->userId,
+            'coupon_id' => $coupon->id,
+            'order_sn'  => $event->orderSn,
+            'pay_amt'   => $event->payAmt,
+        ]);
+
+        try {
+            $result = $this->couponService->useCoupon(
+                $coupon->id,
+                $event->userId,
+                $event->payAmt,
+                $event->orderSn
+            );
+
+            if ($result['success']) {
+                Log::info('ProcessCouponOnOrderPaid: success', [
+                    'user_id'     => $event->userId,
+                    'coupon_id'   => $coupon->id,
+                    'bonus_coins' => $result['bonus_coins'],
+                ]);
+            } else {
+                Log::warning('ProcessCouponOnOrderPaid: failed', [
+                    'user_id'   => $event->userId,
+                    'coupon_id' => $coupon->id,
+                    'error'     => $result['error'],
+                ]);
+            }
+        } catch (\Exception $e) {
+            Log::error('ProcessCouponOnOrderPaid: exception', [
+                'user_id'   => $event->userId,
+                'coupon_id' => $coupon->id,
+                'error'     => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 3 - 1
app/Models/RecordScoreInfo.php

@@ -17,9 +17,11 @@ class RecordScoreInfo extends Model
     protected $guarded = [];
 
     // 彩金----21:绑定手机赠送--33:注册赠送--44:签到--45:充值--49:月卡--42:邮件附件(彩金) 51:首充彩金 36:推广充值彩金
-    protected static $Reason = [21, 33, 36, 37, 42, 44, 45, 49, 51, 52, 72, 73];
+    protected static $Reason = [21, 33, 36, 37, 42, 44, 45, 49, 51, 52, 72, 73, 74];
     /** @var int vip商城充值额外赠送 */
     const REASON_VIP_SEND_CHIPS = 73;
+    /** @var int 加密货币支付额外赠送 */
+    const REASON_CRYPTO_BONUS = 74;
 
     public static function addScore($user_id, $ChangeScore, $Reason,$currentScore = 0)
     {

+ 184 - 0
app/Models/UserCoupon.php

@@ -0,0 +1,184 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 用户优惠券模型
+ *
+ * 表: agent.dbo.user_coupons
+ *
+ * 状态常量:
+ *   STATUS_UNUSED  = 0  未使用
+ *   STATUS_USED    = 1  已使用
+ *   STATUS_EXPIRED = 2  已过期
+ *
+ * 类型常量:
+ *   TYPE_FIXED     = 1  固定金额(单位:元)
+ *   TYPE_PERCENT   = 2  充值百分比(单位:%)
+ */
+class UserCoupon extends Model
+{
+    const TABLE = 'agent.dbo.user_coupons';
+
+    /** @var int 未使用 */
+    const STATUS_UNUSED = 0;
+    /** @var int 已使用 */
+    const STATUS_USED = 1;
+    /** @var int 已过期 */
+    const STATUS_EXPIRED = 2;
+
+    /** @var int 固定金额券 */
+    const TYPE_FIXED = 1;
+    /** @var int 百分比券 */
+    const TYPE_PERCENT = 2;
+
+    protected $table = self::TABLE;
+    public $timestamps = false;
+
+    protected $fillable = [
+        'user_id',
+        'coupon_name',
+        'coupon_type',
+        'coupon_value',
+        'min_recharge',
+        'max_bonus',
+        'bonus_coins',
+        'order_sn',
+        'status',
+        'issued_at',
+        'used_at',
+        'expire_at',
+    ];
+
+    /**
+     * 获取用户当前可用的优惠券列表
+     *
+     * @param int $userId
+     * @return \Illuminate\Support\Collection
+     */
+    public static function getAvailableList($userId)
+    {
+        $data = DB::connection('write')->table(self::TABLE)
+            ->where('user_id', $userId)
+            ->where('status', self::STATUS_UNUSED)
+            ->where('expire_at', '>', date('Y-m-d H:i:s'))
+            ->orderBy('expire_at', 'asc')
+            ->get();
+        // 过期时间改为距离当前秒数,前端展示更友好
+        $data->transform(function ($item) {
+            $item->expire_in_seconds = max(0, strtotime($item->expire_at) - time());
+            return $item;
+        });
+        return $data;
+    }
+
+    /**
+     * 根据 ID 获取单张优惠券,校验归属
+     *
+     * @param int $couponId
+     * @param int $userId
+     * @return object|null
+     */
+    public static function getUserCouponById($couponId, $userId)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('id', $couponId)
+            ->where('user_id', $userId)
+            ->first();
+    }
+
+    /**
+     * 使用优惠券:标记已使用、绑定订单
+     *
+     * @param int    $couponId
+     * @param string $orderSn
+     * @param int    $bonusCoins 实际赠送金币数(分)
+     * @return bool
+     */
+    public static function markUsed($couponId, $orderSn, $bonusCoins)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('id', $couponId)
+            ->where('status', self::STATUS_UNUSED)
+            ->update([
+                'status'      => self::STATUS_USED,
+                'order_sn'    => $orderSn,
+                'bonus_coins' => $bonusCoins,
+                'used_at'     => date('Y-m-d H:i:s'),
+            ]) > 0;
+    }
+
+    /**
+     * 发放优惠券给用户
+     *
+     * @param array $data 优惠券数据
+     * @return int 插入ID
+     */
+    public static function issueToUser(array $data)
+    {
+        return DB::connection('write')->table(self::TABLE)->insertGetId($data);
+    }
+
+    /**
+     * 检查用户是否已持有同类型有效优惠券(防重复发放)
+     *
+     * @param int    $userId
+     * @param string $couponName
+     * @return bool
+     */
+    public static function hasSameCoupon($userId, $couponName)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('user_id', $userId)
+            ->where('coupon_name', $couponName)
+            ->where('status', self::STATUS_UNUSED)
+            ->where('expire_at', '>', date('Y-m-d H:i:s'))
+            ->exists();
+    }
+
+    /**
+     * 批量过期更新:将已过期但状态仍为未使用的优惠券标记为过期
+     *
+     * @param int $userId
+     * @return int 影响行数
+     */
+    public static function expireByUser($userId)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('user_id', $userId)
+            ->where('status', self::STATUS_UNUSED)
+            ->where('expire_at', '<=', date('Y-m-d H:i:s'))
+            ->update(['status' => self::STATUS_EXPIRED]);
+    }
+
+    /**
+     * 绑定优惠券到订单(订单创建时调用)
+     *
+     * @param int    $couponId
+     * @param string $orderSn
+     * @return bool
+     */
+    public static function bindOrderSn($couponId, $orderSn)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('id', $couponId)
+            ->where('status', self::STATUS_UNUSED)
+            ->update(['order_sn' => $orderSn]) > 0;
+    }
+
+    /**
+     * 根据订单号查找关联的优惠券
+     *
+     * @param string $orderSn
+     * @return object|null
+     */
+    public static function findByOrderSn($orderSn)
+    {
+        return DB::connection('write')->table(self::TABLE)
+            ->where('order_sn', $orderSn)
+            ->first();
+    }
+}

+ 12 - 0
app/Providers/EventServiceProvider.php

@@ -2,6 +2,11 @@
 
 namespace App\Providers;
 
+use App\Events\OrderCreated;
+use App\Events\OrderPaid;
+use App\Listeners\AddCryptoBonusOnOrderPaid;
+use App\Listeners\BindCouponToOrder;
+use App\Listeners\ProcessCouponOnOrderPaid;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
@@ -18,6 +23,13 @@ class EventServiceProvider extends ServiceProvider
         Registered::class => [
             SendEmailVerificationNotification::class,
         ],
+        OrderCreated::class => [
+            BindCouponToOrder::class,
+        ],
+        OrderPaid::class => [
+            ProcessCouponOnOrderPaid::class,
+            AddCryptoBonusOnOrderPaid::class,
+        ],
     ];
 
     /**

+ 378 - 0
app/Services/CouponService.php

@@ -0,0 +1,378 @@
+<?php
+
+namespace App\Services;
+
+use App\Http\helper\NumConfig;
+use App\Models\RecordScoreInfo;
+use App\Models\UserCoupon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 优惠券服务
+ *
+ * 职责:
+ *   1. 自动发放优惠券(根据配置策略)
+ *   2. 获取用户可用优惠券列表
+ *   3. 使用优惠券(充值成功后:增加金币 + 标记已用)
+ */
+class CouponService
+{
+    /** @var int 优惠券赠送金币的原因码 */
+    const SCORE_REASON = 55;
+
+    /**
+     * 获取用户可用优惠券列表,同时触发自动发放逻辑
+     *
+     * @param int $userId
+     * @return array  ['list' => [...], 'new_issued' => [...]]
+     */
+    public function getCouponList($userId)
+    {
+        // 1. 先清理已过期的优惠券
+        UserCoupon::expireByUser($userId);
+
+        // 2. 自动发放符合条件的优惠券
+        $newIssued = $this->autoIssue($userId);
+
+        // 3. 返回当前可用列表
+        $list = UserCoupon::getAvailableList($userId);
+
+        return [
+            'list'       => $list,
+            'new_issued' => $newIssued,
+        ];
+    }
+
+    /**
+     * 自动发放优惠券(根据 config/coupon.php 中的 auto_issue_rules)
+     *
+     * 发放逻辑:
+     *   - 遍历所有规则
+     *   - 检查用户是否满足发放条件
+     *   - 检查用户是否已持有同名有效券(防重复)
+     *   - 满足条件则发放
+     *
+     * @param int $userId
+     * @return array 本次新发放的优惠券列表
+     */
+    public function autoIssue($userId)
+    {
+        $rules = config('coupon.auto_issue_rules', []);
+        $enabled = config('coupon.enabled_rules', []);
+        $allEnabled = in_array('*', $enabled, true);
+        $newIssued = [];
+
+        foreach ($rules as $rule) {
+            // 仅处理当前生效的规则('*' 表示全部生效)
+            if (!$allEnabled && !in_array($rule['coupon_name'], $enabled, true)) {
+                continue;
+            }
+
+            // 防重复:已持有同名有效券则跳过
+            if (UserCoupon::hasSameCoupon($userId, $rule['coupon_name'])) {
+                continue;
+            }
+
+            // 检查发放条件
+            if (!$this->checkCondition($userId, $rule['condition'])) {
+                continue;
+            }
+
+            // 发放优惠券
+            $now = date('Y-m-d H:i:s');
+            $couponData = [
+                'user_id'      => $userId,
+                'coupon_name'  => $rule['coupon_name'],
+                'coupon_type'  => $rule['coupon_type'],
+                'coupon_value' => $rule['coupon_value'],
+                'min_recharge' => $rule['min_recharge'] ?? 0,
+                'max_bonus'    => $rule['max_bonus'] ?? 0,
+                'bonus_coins'  => 0,
+                'order_sn'     => '',
+                'status'       => UserCoupon::STATUS_UNUSED,
+                'issued_at'    => $now,
+                'used_at'      => null,
+                'expire_at'    => date('Y-m-d H:i:s', strtotime("+{$rule['valid_days']} days")),
+            ];
+
+            try {
+                $id = UserCoupon::issueToUser($couponData);
+                $couponData['id'] = $id;
+                $newIssued[] = $couponData;
+
+                Log::info('Coupon auto-issued', [
+                    'user_id'     => $userId,
+                    'coupon_name' => $rule['coupon_name'],
+                    'coupon_id'   => $id,
+                ]);
+            } catch (\Exception $e) {
+                Log::error('Coupon auto-issue failed', [
+                    'user_id'     => $userId,
+                    'coupon_name' => $rule['coupon_name'],
+                    'error'       => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $newIssued;
+    }
+
+    /**
+     * 检查用户是否满足发放条件
+     *
+     * @param int   $userId
+     * @param array $condition  ['type' => '...', '...' => ...]
+     * @return bool
+     */
+    protected function checkCondition($userId, array $condition)
+    {
+        $type = $condition['type'] ?? '';
+
+        switch ($type) {
+
+            // 新人条件:注册天数 <= N 且无充值记录
+            case 'new_user':
+                $days = $condition['days'] ?? 3;
+                return $this->isNewUser($userId, $days);
+
+            // 累计充值 >= N 元
+            case 'recharge_total':
+                $amount = $condition['amount'] ?? 0;
+                return $this->hasRechargeTotal($userId, $amount);
+
+            // 最近充值距今 >= N 天(且有过充值记录)
+            case 'inactive_days':
+                $days = $condition['days'] ?? 7;
+                return $this->isInactive($userId, $days);
+
+            // VIP 等级 >= N
+            case 'vip_level':
+                $level = $condition['level'] ?? 1;
+                return $this->hasVipLevel($userId, $level);
+
+            default:
+                Log::warning('Unknown coupon condition type', ['type' => $type]);
+                return false;
+        }
+    }
+
+    /**
+     * 判断是否为新用户:注册天数 <= N 且无充值记录
+     *
+     * @param int $userId
+     * @param int $days 注册天数阈值
+     * @return bool
+     */
+    protected function isNewUser($userId, $days)
+    {
+        $user = DB::connection('write')->table('QPAccountsDB.dbo.AccountsInfo')
+            ->where('UserID', $userId)
+            ->select('RegisterDate')
+            ->first();
+
+        if (!$user || !$user->RegisterDate) {
+            return false;
+        }
+
+        // 注册天数 <= 阈值
+        $registerDate = strtotime($user->RegisterDate);
+        $diffDays = (time() - $registerDate) / 86400;
+        if ($diffDays > $days) {
+            return false;
+        }
+
+        // 无充值记录
+        $hasRecharge = DB::connection('write')->table('agent.dbo.order')
+            ->where('user_id', $userId)
+            ->where('pay_status', 1)
+            ->exists();
+
+        return !$hasRecharge;
+    }
+
+    /**
+     * 判断累计充值是否达到阈值
+     *
+     * @param int   $userId
+     * @param float $amount 阈值(元)
+     * @return bool
+     */
+    protected function hasRechargeTotal($userId, $amount)
+    {
+        $total = DB::connection('write')->table('QPAccountsDB.dbo.YN_VIPAccount')
+            ->where('UserID', $userId)
+            ->value('Recharge');
+
+        return ($total ?? 0) >= $amount;
+    }
+
+    /**
+     * 判断用户是否处于不活跃状态:最近一次充值距今 >= N 天
+     * 前提:用户至少有过一次充值记录
+     *
+     * @param int $userId
+     * @param int $days
+     * @return bool
+     */
+    protected function isInactive($userId, $days)
+    {
+        $lastRecharge = DB::connection('write')->table('agent.dbo.order')
+            ->where('user_id', $userId)
+            ->where('pay_status', 1)
+            ->max('pay_at');
+
+        if (!$lastRecharge) {
+            return false; // 从未充值,不适用"回归"逻辑
+        }
+
+        $lastTime = strtotime($lastRecharge);
+        $diffDays = (time() - $lastTime) / 86400;
+
+        return $diffDays >= $days;
+    }
+
+    /**
+     * 判断 VIP 等级是否达到阈值
+     *
+     * @param int $userId
+     * @param int $level
+     * @return bool
+     */
+    protected function hasVipLevel($userId, $level)
+    {
+        // 使用 VipService 获取用户当前VIP等级
+        $userRecharge = DB::connection('write')->table('QPAccountsDB.dbo.YN_VIPAccount')
+            ->where('UserID', $userId)
+            ->value('Recharge') ?? 0;
+
+        $vipLevel = VipService::calculateVipLevel($userId, $userRecharge);
+
+        return $vipLevel >= $level;
+    }
+
+    /**
+     * 验证优惠券是否可用于本次充值
+     *
+     * @param int   $couponId
+     * @param int   $userId
+     * @param float $payAmt 充值金额(元)
+     * @return array  ['valid' => bool, 'coupon' => object|null, 'error' => string]
+     */
+    public function validateCoupon($couponId, $userId, $payAmt)
+    {
+        $coupon = UserCoupon::getUserCouponById($couponId, $userId);
+
+        if (!$coupon) {
+            return ['valid' => false, 'coupon' => null, 'error' => 'Coupon not found'];
+        }
+
+        if ($coupon->status != UserCoupon::STATUS_UNUSED) {
+            return ['valid' => false, 'coupon' => $coupon, 'error' => 'Coupon already used or expired'];
+        }
+
+        if (strtotime($coupon->expire_at) < time()) {
+            return ['valid' => false, 'coupon' => $coupon, 'error' => 'Coupon expired'];
+        }
+
+        if ($payAmt < $coupon->min_recharge) {
+            return [
+                'valid'  => false,
+                'coupon' => $coupon,
+                'error'  => "Minimum recharge {$coupon->min_recharge} required for this coupon",
+            ];
+        }
+
+        return ['valid' => true, 'coupon' => $coupon, 'error' => ''];
+    }
+
+    /**
+     * 计算优惠券赠送的金币数(单位:分)
+     *
+     * @param object $coupon 优惠券记录
+     * @param float  $payAmt 充值金额(元)
+     * @return int 赠送金币数(分)
+     */
+    public function calcBonusCoins($coupon, $payAmt)
+    {
+        if ($coupon->coupon_type == UserCoupon::TYPE_FIXED) {
+            // 固定金额
+            $bonusYuan = $coupon->coupon_value;
+        } else {
+            // 百分比:赠送金额 = 充值金额 * 百分比
+            $bonusYuan = $payAmt * ($coupon->coupon_value / 100);
+
+            // 上限限制
+            if ($coupon->max_bonus > 0 && $bonusYuan > $coupon->max_bonus) {
+                $bonusYuan = $coupon->max_bonus;
+            }
+        }
+
+        // 转换为分(1元 = 100分)
+        return (int) round($bonusYuan * NumConfig::NUM_VALUE);
+    }
+
+    /**
+     * 使用优惠券:标记已用 + 发放金币
+     *
+     * 在充值回调中调用此方法。
+     *
+     * @param int    $couponId
+     * @param int    $userId
+     * @param float  $payAmt 充值金额(元)
+     * @param string $orderSn
+     * @return array  ['success' => bool, 'bonus_coins' => int, 'error' => string]
+     */
+    public function useCoupon($couponId, $userId, $payAmt, $orderSn)
+    {
+        // 验证
+        $validation = $this->validateCoupon($couponId, $userId, $payAmt);
+        if (!$validation['valid']) {
+            return ['success' => false, 'bonus_coins' => 0, 'error' => $validation['error']];
+        }
+
+        $coupon = $validation['coupon'];
+
+        // 计算赠送金币
+        $bonusCoins = $this->calcBonusCoins($coupon, $payAmt);
+
+        if ($bonusCoins <= 0) {
+            return ['success' => false, 'bonus_coins' => 0, 'error' => 'Bonus amount is zero'];
+        }
+
+        // 标记优惠券已使用(乐观锁:status=unused 才更新)
+        $marked = UserCoupon::markUsed($coupon->id, $orderSn, $bonusCoins);
+        if (!$marked) {
+            return ['success' => false, 'bonus_coins' => 0, 'error' => 'Coupon already used'];
+        }
+
+        // 发放金币到用户账户
+        try {
+            $reason = config('coupon.score_reason', self::SCORE_REASON);
+            RecordScoreInfo::addScore($userId, $bonusCoins, $reason);
+
+            // 实际增加 GameScoreInfo.Score
+            DB::connection('write')->table('QPTreasureDB.dbo.GameScoreInfo')
+                ->where('UserID', $userId)
+                ->increment('Score', $bonusCoins);
+
+            Log::info('Coupon bonus coins added', [
+                'user_id'     => $userId,
+                'coupon_id'   => $coupon->id,
+                'order_sn'    => $orderSn,
+                'bonus_coins' => $bonusCoins,
+                'pay_amt'     => $payAmt,
+            ]);
+
+            return ['success' => true, 'bonus_coins' => $bonusCoins, 'error' => ''];
+        } catch (\Exception $e) {
+            Log::error('Coupon add coins failed', [
+                'user_id'     => $userId,
+                'coupon_id'   => $coupon->id,
+                'error'       => $e->getMessage(),
+            ]);
+
+            return ['success' => false, 'bonus_coins' => 0, 'error' => 'Failed to add bonus coins'];
+        }
+    }
+}

+ 14 - 0
app/Services/OrderServices.php

@@ -17,6 +17,7 @@ use App\Models\AgentUser;
 use App\Models\RecordScoreInfo;
 use App\Models\RecordUserDataStatistics;
 use App\Services\HolidayWheelService;
+use App\Events\OrderPaid;
 use App\Util;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -539,6 +540,19 @@ class  OrderServices
         } catch (\Exception $exception) {
             Util::WriteLog("AgentService", $exception->getTraceAsString());
         }
+
+        // 触发订单支付完成事件(优惠券处理等由 Listener 负责)
+        try {
+            event(new OrderPaid($user_id, $payAmt, $order_sn));
+        } catch (\Exception $e) {
+            \Log::error('OrderPaid event dispatch failed', [
+                'user_id' => $user_id,
+                'payAmt' => $payAmt,
+                'order_sn' => $order_sn,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
         return [$Score];
     }
 

+ 98 - 0
config/coupon.php

@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * 优惠券配置
+ *
+ * 自动发放条件说明:
+ *   - new_user: 新注册用户(注册天数 <= condition.days 且 无充值记录)
+ *   - recharge_total: 累计充值 >= condition.amount (元)
+ *   - inactive_days: 最近一次充值距今 >= condition.days 天
+ *   - vip_level: VIP等级 >= condition.level
+ *
+ * 券类型:
+ *   - type=1 (fixed): 固定金额赠送, coupon_value 即为赠送金额(元)
+ *   - type=2 (percent): 按充值金额百分比赠送, coupon_value=百分比(%), max_bonus=上限(元)
+ */
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | 自动发放策略
+    |--------------------------------------------------------------------------
+    |
+    | 当用户请求 GET /coupon/list 时,按顺序检查以下策略。
+    | 每个策略只能发放一次(同名称有效券不重复发放)。
+    |
+    */
+    'auto_issue_rules' => [
+
+        // 新人优惠券:注册3天内 + 从未充值
+        [
+            'coupon_name'   => 'new_user_bonus',
+            'coupon_type'   => 2,       // 百分比
+            'coupon_value'  => 50,      // 50%
+            'min_recharge'  => 10,      // 最低充值10元
+            'max_bonus'     => 50,      // 最多送50元
+            'valid_days'    => 7,       // 7天有效
+            'condition'     => [
+                'type' => 'new_user',
+                'days' => 3,
+            ],
+        ],
+
+        // 回归优惠券:超过14天未充值
+        [
+            'coupon_name'   => 'comeback_bonus',
+            'coupon_type'   => 2,       // 百分比
+            'coupon_value'  => 30,      // 30%
+            'min_recharge'  => 20,      // 最低充值20元
+            'max_bonus'     => 30,      // 最多送30元
+            'valid_days'    => 3,       // 3天有效
+            'condition'     => [
+                'type' => 'inactive_days',
+                'days' => 14,
+            ],
+        ],
+
+        // 大额充值券:累计充值 >= 500
+        [
+            'coupon_name'   => 'vip_recharge_bonus',
+            'coupon_type'   => 2,       // 百分比
+            'coupon_value'  => 20,      // 20%
+            'min_recharge'  => 10,      // 最低充值10元可用
+            'max_bonus'     => 0,       // 固定金额不需要上限
+            'valid_days'    => 14,      // 14天有效
+            'condition'     => [
+                'type'   => 'recharge_total',
+                'amount' => 500,
+            ],
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 生效规则
+    |--------------------------------------------------------------------------
+    |
+    | 仅在此列表中的优惠券名称才会自动发放。
+    | 设为空数组 [] 则关闭所有自动发放。
+    | 设为 ['*'] 则全部规则生效。
+    |
+    */
+    'enabled_rules' => [
+        // 'new_user_bonus',
+        // 'comeback_bonus',
+        // 'vip_recharge_bonus',
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 金币增加原因码 (Reason)
+    |--------------------------------------------------------------------------
+    |
+    | 使用优惠券增加金币时写入 RecordUserScoreChange 的原因码。
+    | 需确保不与现有 Reason 冲突(现有: 21,33,36,37,42,44,45,49,51,52,72,73)
+    |
+    */
+    'score_reason' => 55,  // 优惠券赠送
+];

+ 33 - 0
database/coupon_table.sql

@@ -0,0 +1,33 @@
+-- ============================================
+-- 用户优惠券表 (agent.dbo.user_coupons)
+-- SQL Server DDL
+-- ============================================
+
+IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='user_coupons' AND xtype='U')
+BEGIN
+    CREATE TABLE agent.dbo.user_coupons (
+        id              INT IDENTITY(1,1) PRIMARY KEY,
+        user_id         INT NOT NULL,                           -- 用户ID
+        coupon_name     VARCHAR(50) NOT NULL,                   -- 优惠券名称 (e.g. new_user_bonus)
+        coupon_type     TINYINT NOT NULL DEFAULT 1,             -- 类型: 1=固定金额, 2=充值百分比
+        coupon_value    DECIMAL(10,2) NOT NULL DEFAULT 0,       -- 优惠值 (元 or %)
+        min_recharge    DECIMAL(10,2) NOT NULL DEFAULT 0,       -- 最低充值门槛(元)
+        max_bonus       DECIMAL(10,2) NOT NULL DEFAULT 0,       -- 最大赠送上限(元), 百分比券使用
+        bonus_coins     INT NOT NULL DEFAULT 0,                 -- 实际赠送金币(分)
+        order_sn        VARCHAR(64) NOT NULL DEFAULT '',        -- 关联订单号
+        status          TINYINT NOT NULL DEFAULT 0,             -- 0=未使用, 1=已使用, 2=已过期
+        issued_at       DATETIME NULL,                          -- 发放时间
+        used_at         DATETIME NULL,                          -- 使用时间
+        expire_at       DATETIME NULL,                          -- 过期时间
+    );
+
+    -- 索引: 用户+状态查询(获取可用优惠券列表)
+    CREATE INDEX idx_user_coupons_user_status ON agent.dbo.user_coupons (user_id, status, expire_at);
+
+    -- 索引: 优惠券名称防重
+    CREATE INDEX idx_user_coupons_user_name ON agent.dbo.user_coupons (user_id, coupon_name, status);
+
+    -- 索引: 按订单号查找关联优惠券
+    CREATE INDEX idx_user_coupons_order_sn ON agent.dbo.user_coupons (order_sn);
+END
+GO

+ 4 - 0
routes/game.php

@@ -356,6 +356,10 @@ Route::group([
     $route->any('/payment_entry/iospay', 'Game\PaymentEntryController@iospay');
     $route->any('/payment_entry/googlepay', 'Game\PaymentEntryController@googlepay');
 
+    // 优惠券接口
+    $route->any('/coupon/list', 'Game\CouponController@list');
+    $route->any('/coupon/preview', 'Game\CouponController@preview');
+
     $route->any('/recharge/userTotalRecharge', 'Game\RechargeController@userTotalRecharge');//用户总充值
     $route->any('/recharge/guidePayment', 'Game\RechargeController@guidePayment');//引导付费
     $route->any('/pay/orderList', 'Game\PayRechargeController@orderList'); // 充值记录

+ 259 - 0
tests/Unit/CouponServiceTest.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Models\UserCoupon;
+use App\Services\CouponService;
+use Tests\TestCase;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 优惠券服务单元测试
+ *
+ * 测试 CouponService 的核心业务逻辑:
+ *   - 优惠券发放条件判断
+ *   - 优惠券验证
+ *   - 金币计算
+ *   - 优惠券使用
+ */
+class CouponServiceTest extends TestCase
+{
+    /** @var CouponService */
+    protected $service;
+
+    /** @var int 测试用户ID */
+    protected $testUserId = 999999;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->service = new CouponService();
+
+        // 清理测试数据
+        DB::connection('write')->table(UserCoupon::TABLE)
+            ->where('user_id', $this->testUserId)
+            ->delete();
+    }
+
+    public function tearDown(): void
+    {
+        // 清理测试数据
+        DB::connection('write')->table(UserCoupon::TABLE)
+            ->where('user_id', $this->testUserId)
+            ->delete();
+
+        parent::tearDown();
+    }
+
+    // ========================
+    // 固定金额券 - 金币计算
+    // ========================
+
+    /**
+     * 测试固定金额券:赠送金额等于券面值
+     */
+    public function test_calc_bonus_coins_fixed_type()
+    {
+        $coupon = (object) [
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 10.00,  // 10元
+            'max_bonus'    => 0,
+        ];
+
+        $bonus = $this->service->calcBonusCoins($coupon, 50.00);
+        $this->assertEquals(1000, $bonus); // 10元 × 100 = 1000分
+    }
+
+    /**
+     * 测试固定金额券:不同充值金额,赠送不变
+     */
+    public function test_calc_bonus_coins_fixed_independent_of_pay_amt()
+    {
+        $coupon = (object) [
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 5.00,
+            'max_bonus'    => 0,
+        ];
+
+        $bonus1 = $this->service->calcBonusCoins($coupon, 10.00);
+        $bonus2 = $this->service->calcBonusCoins($coupon, 100.00);
+        $this->assertEquals($bonus1, $bonus2); // 固定金额与充值额无关
+        $this->assertEquals(500, $bonus1);
+    }
+
+    // ========================
+    // 百分比券 - 金币计算
+    // ========================
+
+    /**
+     * 测试百分比券:按充值金额百分比计算
+     */
+    public function test_calc_bonus_coins_percent_type()
+    {
+        $coupon = (object) [
+            'coupon_type'  => UserCoupon::TYPE_PERCENT,
+            'coupon_value' => 50,    // 50%
+            'max_bonus'    => 100,   // 上限100元
+        ];
+
+        $bonus = $this->service->calcBonusCoins($coupon, 40.00);
+        $this->assertEquals(2000, $bonus); // 40 × 50% × 100 = 2000分
+    }
+
+    /**
+     * 测试百分比券:超过上限时按上限计算
+     */
+    public function test_calc_bonus_coins_percent_capped_at_max()
+    {
+        $coupon = (object) [
+            'coupon_type'  => UserCoupon::TYPE_PERCENT,
+            'coupon_value' => 50,
+            'max_bonus'    => 30,    // 上限30元
+        ];
+
+        // 充值200元,50% = 100元,但上限30元 → 3000分
+        $bonus = $this->service->calcBonusCoins($coupon, 200.00);
+        $this->assertEquals(3000, $bonus); // 30 × 100 = 3000分
+    }
+
+    // ========================
+    // 优惠券验证
+    // ========================
+
+    /**
+     * 测试验证:优惠券不存在
+     */
+    public function test_validate_coupon_not_found()
+    {
+        $result = $this->service->validateCoupon(99999, $this->testUserId, 50.00);
+        $this->assertFalse($result['valid']);
+        $this->assertEquals('Coupon not found', $result['error']);
+    }
+
+    /**
+     * 测试验证:充值金额不满足最低门槛
+     */
+    public function test_validate_coupon_below_min_recharge()
+    {
+        // 手动插入一张测试券
+        $couponId = DB::connection('write')->table(UserCoupon::TABLE)->insertGetId([
+            'user_id'      => $this->testUserId,
+            'coupon_name'  => 'test_fixed',
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 10.00,
+            'min_recharge' => 50.00,  // 最低50元
+            'max_bonus'    => 0,
+            'bonus_coins'  => 0,
+            'order_sn'     => '',
+            'status'       => UserCoupon::STATUS_UNUSED,
+            'issued_at'    => date('Y-m-d H:i:s'),
+            'used_at'      => null,
+            'expire_at'    => date('Y-m-d H:i:s', strtotime('+7 days')),
+        ]);
+
+        $result = $this->service->validateCoupon($couponId, $this->testUserId, 20.00); // 20 < 50
+        $this->assertFalse($result['valid']);
+        $this->assertStringContainsString('Minimum recharge', $result['error']);
+    }
+
+    /**
+     * 测试验证:优惠券已过期
+     */
+    public function test_validate_coupon_expired()
+    {
+        $couponId = DB::connection('write')->table(UserCoupon::TABLE)->insertGetId([
+            'user_id'      => $this->testUserId,
+            'coupon_name'  => 'test_expired',
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 10.00,
+            'min_recharge' => 0,
+            'max_bonus'    => 0,
+            'bonus_coins'  => 0,
+            'order_sn'     => '',
+            'status'       => UserCoupon::STATUS_UNUSED,
+            'issued_at'    => date('Y-m-d H:i:s', strtotime('-10 days')),
+            'used_at'      => null,
+            'expire_at'    => date('Y-m-d H:i:s', strtotime('-1 day')), // 已过期
+        ]);
+
+        $result = $this->service->validateCoupon($couponId, $this->testUserId, 50.00);
+        $this->assertFalse($result['valid']);
+        $this->assertEquals('Coupon expired', $result['error']);
+    }
+
+    /**
+     * 测试验证:有效优惠券
+     */
+    public function test_validate_coupon_valid()
+    {
+        $couponId = DB::connection('write')->table(UserCoupon::TABLE)->insertGetId([
+            'user_id'      => $this->testUserId,
+            'coupon_name'  => 'test_valid',
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 10.00,
+            'min_recharge' => 30.00,
+            'max_bonus'    => 0,
+            'bonus_coins'  => 0,
+            'order_sn'     => '',
+            'status'       => UserCoupon::STATUS_UNUSED,
+            'issued_at'    => date('Y-m-d H:i:s'),
+            'used_at'      => null,
+            'expire_at'    => date('Y-m-d H:i:s', strtotime('+7 days')),
+        ]);
+
+        $result = $this->service->validateCoupon($couponId, $this->testUserId, 50.00);
+        $this->assertTrue($result['valid']);
+    }
+
+    // ========================
+    // 防重复发放
+    // ========================
+
+    /**
+     * 测试 hasSameCoupon:有效券存在时返回 true
+     */
+    public function test_has_same_coupon_returns_true_when_valid_exists()
+    {
+        $couponName = 'test_duplicate_check';
+        DB::connection('write')->table(UserCoupon::TABLE)->insert([
+            'user_id'      => $this->testUserId,
+            'coupon_name'  => $couponName,
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 5.00,
+            'min_recharge' => 0,
+            'max_bonus'    => 0,
+            'bonus_coins'  => 0,
+            'order_sn'     => '',
+            'status'       => UserCoupon::STATUS_UNUSED,
+            'issued_at'    => date('Y-m-d H:i:s'),
+            'used_at'      => null,
+            'expire_at'    => date('Y-m-d H:i:s', strtotime('+7 days')),
+        ]);
+
+        $this->assertTrue(UserCoupon::hasSameCoupon($this->testUserId, $couponName));
+    }
+
+    /**
+     * 测试 hasSameCoupon:同名券已过期时返回 false(允许重新发放)
+     */
+    public function test_has_same_coupon_returns_false_when_expired()
+    {
+        $couponName = 'test_expired_dup';
+        DB::connection('write')->table(UserCoupon::TABLE)->insert([
+            'user_id'      => $this->testUserId,
+            'coupon_name'  => $couponName,
+            'coupon_type'  => UserCoupon::TYPE_FIXED,
+            'coupon_value' => 5.00,
+            'min_recharge' => 0,
+            'max_bonus'    => 0,
+            'bonus_coins'  => 0,
+            'order_sn'     => '',
+            'status'       => UserCoupon::STATUS_UNUSED,
+            'issued_at'    => date('Y-m-d H:i:s', strtotime('-10 days')),
+            'used_at'      => null,
+            'expire_at'    => date('Y-m-d H:i:s', strtotime('-1 day')), // 已过期
+        ]);
+
+        $this->assertFalse(UserCoupon::hasSameCoupon($this->testUserId, $couponName));
+    }
+}