PromotionReferrals.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service\promotion;
  4. use app\miniapi\model\MiniGrant;
  5. use app\miniapi\model\MiniCheckout;
  6. use plugin\saiadmin\app\model\water\WaterEnterprise;
  7. use plugin\saiadmin\app\model\water\WaterDelivery;
  8. use plugin\saiadmin\app\model\water\WaterSku;
  9. use plugin\saiadmin\app\model\water\WaterModel;
  10. use plugin\saiadmin\app\model\water\WaterOrder;
  11. use plugin\saiadmin\app\model\water\WaterPromotionCode;
  12. use plugin\saiadmin\app\model\water\WaterPromotionReferral;
  13. use plugin\saiadmin\app\model\water\WaterPromotionSetting;
  14. use plugin\saiadmin\exception\ApiException;
  15. final class PromotionReferrals
  16. {
  17. public function code(int $enterprise): array
  18. {
  19. if (!PromotionPolicy::current()['enabled']) {
  20. throw new ApiException('推广活动尚未开启');
  21. }
  22. return WaterModel::transaction(function () use ($enterprise) {
  23. $row = WaterEnterprise::query()->where('status', 1)->lockForUpdate()->find($enterprise);
  24. if (!$row) {
  25. throw new ApiException('企业已停用或不存在');
  26. }
  27. $code = WaterPromotionCode::query()->firstOrCreate(['enterprise_id' => $enterprise], [
  28. 'code' => strtoupper(bin2hex(random_bytes(10))), 'created_at' => date('Y-m-d H:i:s'),
  29. ]);
  30. return ['invite_code' => $code->code];
  31. });
  32. }
  33. public function bind(int $enterprise, string $code, int $user): array
  34. {
  35. if (!preg_match('/^[A-F0-9]{20}$/D', $code)) {
  36. throw new ApiException('邀请码格式不正确');
  37. }
  38. if (!PromotionPolicy::current()['initialized']) {
  39. throw new ApiException('请先初始化推广活动');
  40. }
  41. return WaterModel::transaction(function () use ($enterprise, $code, $user) {
  42. // Serialize new attribution edges, preventing reciprocal/cyclic invitations.
  43. $policy = WaterPromotionSetting::query()->lockForUpdate()->find(1);
  44. if (!$policy || !$policy->enabled) {
  45. throw new ApiException('推广活动尚未开启');
  46. }
  47. $invitation = WaterPromotionCode::query()->where('code', $code)->first();
  48. if (!$invitation || (int) $invitation->enterprise_id === $enterprise) {
  49. throw new ApiException('邀请码无效或不能邀请自身企业');
  50. }
  51. $inviter = (int) $invitation->enterprise_id;
  52. if (!WaterSku::query()->where('status', 1)->lockForUpdate()->find($policy->sku_id)) {
  53. throw new ApiException('活动赠水规格已停用,请联系平台');
  54. }
  55. $ids = [$enterprise, $inviter];
  56. sort($ids, SORT_NUMERIC);
  57. $companies = WaterEnterprise::query()->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
  58. if ($companies->count() !== 2 || $companies->contains(fn ($row) => (int) $row->status !== 1)) {
  59. throw new ApiException('邀请企业或当前企业已停用');
  60. }
  61. $old = WaterPromotionReferral::query()->where('invited_id', $enterprise)->lockForUpdate()->first();
  62. if ($old) {
  63. if ((int) $old->inviter_id !== $inviter) {
  64. throw new ApiException('该企业已经绑定其他邀请人,不能修改');
  65. }
  66. return ['id' => (int) $old->id, 'replayed' => true];
  67. }
  68. if (WaterOrder::query()->where('enterprise_id', $enterprise)->lockForUpdate()->first(['id'])) {
  69. throw new ApiException('已有购水记录,不能参加新企业首购邀请');
  70. }
  71. $inviterAdmins = MiniGrant::query()->where('role', 'enterprise_admin')->where('status', 1)
  72. ->where('resource_id', $inviter)->pluck('user_id');
  73. if ($inviterAdmins->contains($user) || MiniGrant::query()->where('role', 'enterprise_admin')
  74. ->where('status', 1)->where('resource_id', $enterprise)->whereIn('user_id', $inviterAdmins)->exists()) {
  75. throw new ApiException('同一管理员管理的企业不能互相邀请');
  76. }
  77. $cursor = $inviter;
  78. $seen = [$enterprise];
  79. while ($cursor) {
  80. if (in_array($cursor, $seen, true) || count($seen) > 100) {
  81. throw new ApiException('邀请关系存在循环或层级过深');
  82. }
  83. $seen[] = $cursor;
  84. $cursor = (int) (WaterPromotionReferral::query()->where('invited_id', $cursor)->lockForUpdate()->first()?->inviter_id ?? 0);
  85. }
  86. $referral = WaterPromotionReferral::query()->create([
  87. 'inviter_id' => $inviter, 'invited_id' => $enterprise, 'bound_by' => $user,
  88. 'policy_revision' => (int) $policy->revision, 'sku_id' => (int) $policy->sku_id,
  89. 'quantity' => (int) $policy->quantity, 'min_amount_cents' => (int) $policy->min_amount_cents,
  90. 'status' => 'bound', 'revision' => 1, 'created_at' => date('Y-m-d H:i:s'),
  91. ]);
  92. return ['id' => (int) $referral->id];
  93. });
  94. }
  95. /** Called inside the paid receipt transaction; old orders are never backfilled. */
  96. public function purchased(WaterOrder $order, ?int $checkoutId = null): void
  97. {
  98. if (!PromotionPolicy::current()['initialized'] || (int) $order->amount_cents <= 0
  99. || str_starts_with($order->reference, 'TEST-') || str_contains($order->package_name, '测试')) {
  100. return;
  101. }
  102. $referral = WaterPromotionReferral::query()->where('invited_id', $order->enterprise_id)->lockForUpdate()->first();
  103. if (!$referral || $referral->status !== 'bound' || $referral->created_at > $order->created_at) {
  104. return;
  105. }
  106. if (WaterOrder::query()->where('enterprise_id', $order->enterprise_id)->where('id', '<', $order->id)
  107. ->where('amount_cents', '>', 0)->where('reference', 'not like', 'TEST-%')
  108. ->where('package_name', 'not like', '%测试%')->lockForUpdate()->first(['id'])) {
  109. return;
  110. }
  111. // A combined checkout creates multiple receipts; the threshold uses the entire paid checkout.
  112. $checkout = $checkoutId ? MiniCheckout::query()->find($checkoutId) : null;
  113. if ($checkoutId && (!$checkout || (int) $checkout->enterprise_id !== (int) $order->enterprise_id
  114. || !str_starts_with($order->reference, $checkout->code . '-'))) {
  115. throw new ApiException('推广首购与结算订单不匹配');
  116. }
  117. $amount = $checkout ? (int) $checkout->amount_cents : (int) $order->amount_cents;
  118. $deliveryFloor = WaterDelivery::query()->where('enterprise_id', $order->enterprise_id)
  119. ->where('sku_id', $order->sku_id)->orderByDesc('id')->lockForUpdate()->first();
  120. // Even a below-threshold first purchase consumes eligibility; later purchases cannot replace it.
  121. $referral->fill([
  122. 'order_id' => (int) $order->id,
  123. 'checkout_id' => $checkoutId, 'purchase_amount_cents' => $amount,
  124. 'delivery_floor_id' => (int) ($deliveryFloor?->id ?? 0),
  125. 'status' => $amount >= (int) $referral->min_amount_cents ? 'pending' : 'rejected',
  126. 'qualified_at' => date('Y-m-d H:i:s'), 'revision' => (int) $referral->revision + 1,
  127. 'review_note' => $amount >= (int) $referral->min_amount_cents ? '' : '首笔购水未达到绑定时约定金额',
  128. ])->save();
  129. }
  130. }