CouponService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. <?php
  2. namespace App\Services;
  3. use App\Http\helper\NumConfig;
  4. use App\Models\RecordScoreInfo;
  5. use App\Models\UserCoupon;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. /**
  9. * 优惠券服务
  10. *
  11. * 职责:
  12. * 1. 自动发放优惠券(根据配置策略)
  13. * 2. 获取用户可用优惠券列表
  14. * 3. 使用优惠券(充值成功后:增加金币 + 标记已用)
  15. */
  16. class CouponService
  17. {
  18. /** @var int 优惠券赠送金币的原因码 */
  19. const SCORE_REASON = 55;
  20. /**
  21. * 获取用户可用优惠券列表,同时触发自动发放逻辑
  22. *
  23. * @param int $userId
  24. * @return array ['list' => [...], 'new_issued' => [...]]
  25. */
  26. public function getCouponList($userId)
  27. {
  28. // 1. 先清理已过期的优惠券
  29. UserCoupon::expireByUser($userId);
  30. // 2. 自动发放符合条件的优惠券
  31. $newIssued = $this->autoIssue($userId);
  32. // 3. 返回当前可用列表
  33. $list = UserCoupon::getAvailableList($userId);
  34. return [
  35. 'list' => $list,
  36. 'new_issued' => $newIssued,
  37. ];
  38. }
  39. /**
  40. * 自动发放优惠券(根据 config/coupon.php 中的 auto_issue_rules)
  41. *
  42. * 发放逻辑:
  43. * - 遍历所有规则
  44. * - 检查用户是否满足发放条件
  45. * - 检查用户是否已持有同名有效券(防重复)
  46. * - 满足条件则发放
  47. *
  48. * @param int $userId
  49. * @return array 本次新发放的优惠券列表
  50. */
  51. public function autoIssue($userId)
  52. {
  53. $rules = config('coupon.auto_issue_rules', []);
  54. $enabled = config('coupon.enabled_rules', []);
  55. $allEnabled = in_array('*', $enabled, true);
  56. $newIssued = [];
  57. foreach ($rules as $rule) {
  58. // 仅处理当前生效的规则('*' 表示全部生效)
  59. if (!$allEnabled && !in_array($rule['coupon_name'], $enabled, true)) {
  60. continue;
  61. }
  62. // 防重复:已持有同名有效券则跳过
  63. if (UserCoupon::hasSameCoupon($userId, $rule['coupon_name'])) {
  64. continue;
  65. }
  66. // 检查发放条件
  67. if (!$this->checkCondition($userId, $rule['condition'])) {
  68. continue;
  69. }
  70. // 发放优惠券
  71. $now = date('Y-m-d H:i:s');
  72. $couponData = [
  73. 'user_id' => $userId,
  74. 'coupon_name' => $rule['coupon_name'],
  75. 'coupon_type' => $rule['coupon_type'],
  76. 'coupon_value' => $rule['coupon_value'],
  77. 'min_recharge' => $rule['min_recharge'] ?? 0,
  78. 'max_bonus' => $rule['max_bonus'] ?? 0,
  79. 'bonus_coins' => 0,
  80. 'order_sn' => '',
  81. 'status' => UserCoupon::STATUS_UNUSED,
  82. 'issued_at' => $now,
  83. 'used_at' => null,
  84. 'expire_at' => date('Y-m-d H:i:s', strtotime("+{$rule['valid_days']} days")),
  85. ];
  86. try {
  87. $id = UserCoupon::issueToUser($couponData);
  88. $couponData['id'] = $id;
  89. $newIssued[] = $couponData;
  90. Log::info('Coupon auto-issued', [
  91. 'user_id' => $userId,
  92. 'coupon_name' => $rule['coupon_name'],
  93. 'coupon_id' => $id,
  94. ]);
  95. } catch (\Exception $e) {
  96. Log::error('Coupon auto-issue failed', [
  97. 'user_id' => $userId,
  98. 'coupon_name' => $rule['coupon_name'],
  99. 'error' => $e->getMessage(),
  100. ]);
  101. }
  102. }
  103. return $newIssued;
  104. }
  105. /**
  106. * 检查用户是否满足发放条件
  107. *
  108. * @param int $userId
  109. * @param array $condition ['type' => '...', '...' => ...]
  110. * @return bool
  111. */
  112. protected function checkCondition($userId, array $condition)
  113. {
  114. $type = $condition['type'] ?? '';
  115. switch ($type) {
  116. // 新人条件:注册天数 <= N 且无充值记录
  117. case 'new_user':
  118. $days = $condition['days'] ?? 3;
  119. return $this->isNewUser($userId, $days);
  120. // 累计充值 >= N 元
  121. case 'recharge_total':
  122. $amount = $condition['amount'] ?? 0;
  123. return $this->hasRechargeTotal($userId, $amount);
  124. // 最近充值距今 >= N 天(且有过充值记录)
  125. case 'inactive_days':
  126. $days = $condition['days'] ?? 7;
  127. return $this->isInactive($userId, $days);
  128. // VIP 等级 >= N
  129. case 'vip_level':
  130. $level = $condition['level'] ?? 1;
  131. return $this->hasVipLevel($userId, $level);
  132. default:
  133. Log::warning('Unknown coupon condition type', ['type' => $type]);
  134. return false;
  135. }
  136. }
  137. /**
  138. * 判断是否为新用户:注册天数 <= N 且无充值记录
  139. *
  140. * @param int $userId
  141. * @param int $days 注册天数阈值
  142. * @return bool
  143. */
  144. protected function isNewUser($userId, $days)
  145. {
  146. $user = DB::connection('write')->table('QPAccountsDB.dbo.AccountsInfo')
  147. ->where('UserID', $userId)
  148. ->select('RegisterDate')
  149. ->first();
  150. if (!$user || !$user->RegisterDate) {
  151. return false;
  152. }
  153. // 注册天数 <= 阈值
  154. $registerDate = strtotime($user->RegisterDate);
  155. $diffDays = (time() - $registerDate) / 86400;
  156. if ($diffDays > $days) {
  157. return false;
  158. }
  159. // 无充值记录
  160. $hasRecharge = DB::connection('write')->table('agent.dbo.order')
  161. ->where('user_id', $userId)
  162. ->where('pay_status', 1)
  163. ->exists();
  164. return !$hasRecharge;
  165. }
  166. /**
  167. * 判断累计充值是否达到阈值
  168. *
  169. * @param int $userId
  170. * @param float $amount 阈值(元)
  171. * @return bool
  172. */
  173. protected function hasRechargeTotal($userId, $amount)
  174. {
  175. $total = DB::connection('write')->table('QPAccountsDB.dbo.YN_VIPAccount')
  176. ->where('UserID', $userId)
  177. ->value('Recharge');
  178. return ($total ?? 0) >= $amount;
  179. }
  180. /**
  181. * 判断用户是否处于不活跃状态:最近一次充值距今 >= N 天
  182. * 前提:用户至少有过一次充值记录
  183. *
  184. * @param int $userId
  185. * @param int $days
  186. * @return bool
  187. */
  188. protected function isInactive($userId, $days)
  189. {
  190. $lastRecharge = DB::connection('write')->table('agent.dbo.order')
  191. ->where('user_id', $userId)
  192. ->where('pay_status', 1)
  193. ->max('pay_at');
  194. if (!$lastRecharge) {
  195. return false; // 从未充值,不适用"回归"逻辑
  196. }
  197. $lastTime = strtotime($lastRecharge);
  198. $diffDays = (time() - $lastTime) / 86400;
  199. return $diffDays >= $days;
  200. }
  201. /**
  202. * 判断 VIP 等级是否达到阈值
  203. *
  204. * @param int $userId
  205. * @param int $level
  206. * @return bool
  207. */
  208. protected function hasVipLevel($userId, $level)
  209. {
  210. // 使用 VipService 获取用户当前VIP等级
  211. $userRecharge = DB::connection('write')->table('QPAccountsDB.dbo.YN_VIPAccount')
  212. ->where('UserID', $userId)
  213. ->value('Recharge') ?? 0;
  214. $vipLevel = VipService::calculateVipLevel($userId, $userRecharge);
  215. return $vipLevel >= $level;
  216. }
  217. /**
  218. * 验证优惠券是否可用于本次充值
  219. *
  220. * @param int $couponId
  221. * @param int $userId
  222. * @param float $payAmt 充值金额(元)
  223. * @return array ['valid' => bool, 'coupon' => object|null, 'error' => string]
  224. */
  225. public function validateCoupon($couponId, $userId, $payAmt)
  226. {
  227. $coupon = UserCoupon::getUserCouponById($couponId, $userId);
  228. if (!$coupon) {
  229. return ['valid' => false, 'coupon' => null, 'error' => 'Coupon not found'];
  230. }
  231. if ($coupon->status != UserCoupon::STATUS_UNUSED) {
  232. return ['valid' => false, 'coupon' => $coupon, 'error' => 'Coupon already used or expired'];
  233. }
  234. if (strtotime($coupon->expire_at) < time()) {
  235. return ['valid' => false, 'coupon' => $coupon, 'error' => 'Coupon expired'];
  236. }
  237. if ($payAmt < $coupon->min_recharge) {
  238. return [
  239. 'valid' => false,
  240. 'coupon' => $coupon,
  241. 'error' => "Minimum recharge {$coupon->min_recharge} required for this coupon",
  242. ];
  243. }
  244. return ['valid' => true, 'coupon' => $coupon, 'error' => ''];
  245. }
  246. /**
  247. * 计算优惠券赠送的金币数(单位:分)
  248. *
  249. * @param object $coupon 优惠券记录
  250. * @param float $payAmt 充值金额(元)
  251. * @return int 赠送金币数(分)
  252. */
  253. public function calcBonusCoins($coupon, $payAmt)
  254. {
  255. if ($coupon->coupon_type == UserCoupon::TYPE_FIXED) {
  256. // 固定金额
  257. $bonusYuan = $coupon->coupon_value;
  258. } else {
  259. // 百分比:赠送金额 = 充值金额 * 百分比
  260. $bonusYuan = $payAmt * ($coupon->coupon_value / 100);
  261. // 上限限制
  262. if ($coupon->max_bonus > 0 && $bonusYuan > $coupon->max_bonus) {
  263. $bonusYuan = $coupon->max_bonus;
  264. }
  265. }
  266. // 转换为分(1元 = 100分)
  267. return (int) round($bonusYuan * NumConfig::NUM_VALUE);
  268. }
  269. /**
  270. * 使用优惠券:标记已用 + 发放金币
  271. *
  272. * 在充值回调中调用此方法。
  273. *
  274. * @param int $couponId
  275. * @param int $userId
  276. * @param float $payAmt 充值金额(元)
  277. * @param string $orderSn
  278. * @return array ['success' => bool, 'bonus_coins' => int, 'error' => string]
  279. */
  280. public function useCoupon($couponId, $userId, $payAmt, $orderSn)
  281. {
  282. // 验证
  283. $validation = $this->validateCoupon($couponId, $userId, $payAmt);
  284. if (!$validation['valid']) {
  285. return ['success' => false, 'bonus_coins' => 0, 'error' => $validation['error']];
  286. }
  287. $coupon = $validation['coupon'];
  288. // 计算赠送金币
  289. $bonusCoins = $this->calcBonusCoins($coupon, $payAmt);
  290. if ($bonusCoins <= 0) {
  291. return ['success' => false, 'bonus_coins' => 0, 'error' => 'Bonus amount is zero'];
  292. }
  293. // 标记优惠券已使用(乐观锁:status=unused 才更新)
  294. $marked = UserCoupon::markUsed($coupon->id, $orderSn, $bonusCoins);
  295. if (!$marked) {
  296. return ['success' => false, 'bonus_coins' => 0, 'error' => 'Coupon already used'];
  297. }
  298. // 发放金币到用户账户
  299. try {
  300. $reason = config('coupon.score_reason', self::SCORE_REASON);
  301. RecordScoreInfo::addScore($userId, $bonusCoins, $reason);
  302. // 实际增加 GameScoreInfo.Score
  303. DB::connection('write')->table('QPTreasureDB.dbo.GameScoreInfo')
  304. ->where('UserID', $userId)
  305. ->increment('Score', $bonusCoins);
  306. Log::info('Coupon bonus coins added', [
  307. 'user_id' => $userId,
  308. 'coupon_id' => $coupon->id,
  309. 'order_sn' => $orderSn,
  310. 'bonus_coins' => $bonusCoins,
  311. 'pay_amt' => $payAmt,
  312. ]);
  313. return ['success' => true, 'bonus_coins' => $bonusCoins, 'error' => ''];
  314. } catch (\Exception $e) {
  315. Log::error('Coupon add coins failed', [
  316. 'user_id' => $userId,
  317. 'coupon_id' => $coupon->id,
  318. 'error' => $e->getMessage(),
  319. ]);
  320. return ['success' => false, 'bonus_coins' => 0, 'error' => 'Failed to add bonus coins'];
  321. }
  322. }
  323. }