AgentService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentFlowCommissionProfile;
  5. use App\Models\Agent\AgentProfitCommissionProfile;
  6. use App\Models\Agent\AgentToken;
  7. use App\Models\Agent\AgentApplication;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Hash;
  10. use Illuminate\Support\Str;
  11. class AgentService
  12. {
  13. public function __construct(
  14. private AgentFlowCommissionAssignmentService $flowAssignments,
  15. private AgentProfitCommissionAssignmentService $profitAssignments
  16. ) {
  17. }
  18. public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
  19. {
  20. return $this->createAccount($data, $createdBy, $scopeAgent);
  21. }
  22. public function createFromApplication(AgentApplication $application, array $options, int $createdBy): Agent
  23. {
  24. if ($application->status !== 'pending' || !$application->password_hash || !$application->withdraw_password_hash) {
  25. throw new \RuntimeException('申请已处理或凭证不完整');
  26. }
  27. return $this->createAccount(['username' => $application->username] + $options, $createdBy, null, [
  28. 'password' => $application->password_hash, 'withdraw_password' => $application->withdraw_password_hash,
  29. ]);
  30. }
  31. private function createAccount(array $data, ?int $createdBy, ?Agent $scopeAgent, ?array $hashes = null): Agent
  32. {
  33. return DB::transaction(function () use ($data, $createdBy, $scopeAgent, $hashes): Agent {
  34. $parentId = isset($data['parent_id']) ? (int) $data['parent_id'] : null;
  35. if ($scopeAgent) {
  36. if ((int) $scopeAgent->level !== Agent::LEVEL_ONE) {
  37. throw new \RuntimeException('二级代理不能继续新增下级代理');
  38. }
  39. if ($parentId && $parentId !== (int) $scopeAgent->id) {
  40. throw new \RuntimeException('一级代理只能新增自己的直属二级代理');
  41. }
  42. $parentId = (int) $scopeAgent->id;
  43. } elseif ($parentId) {
  44. throw new \RuntimeException('总后台只能新增一级代理');
  45. }
  46. $parent = $parentId ? Agent::query()->lockForUpdate()->findOrFail($parentId) : null;
  47. if ($scopeAgent && $parent && (int) $parent->status !== Agent::STATUS_ENABLED) {
  48. throw new \RuntimeException('不能在已禁用代理下新增账号');
  49. }
  50. $level = $parent ? ((int) $parent->level + 1) : Agent::LEVEL_ONE;
  51. if ($level > Agent::MAX_LEVEL) {
  52. throw new \RuntimeException('代理层级最多为二级');
  53. }
  54. $flowProfileId = (int)($data['flow_commission_profile_id']
  55. ?? $parent?->flow_commission_profile_id
  56. ?? AgentFlowCommissionProfile::officialDefaultId());
  57. if ($flowProfileId <= 0) throw new \RuntimeException('请先配置默认代理流水佣金方案');
  58. $flowProfile = AgentFlowCommissionProfile::query()->findOrFail($flowProfileId);
  59. $this->assertProfileAssignable($parent, $flowProfile, $scopeAgent);
  60. $profitProfileId = (int) ($data['profit_commission_profile_id']
  61. ?? $parent?->profit_commission_profile_id
  62. ?? AgentProfitCommissionProfile::officialDefaultId());
  63. if ($profitProfileId <= 0) throw new \RuntimeException('请先配置默认代理盈亏佣金比例');
  64. $profitProfile = AgentProfitCommissionProfile::query()->findOrFail($profitProfileId);
  65. $this->assertProfileAssignable($parent, $profitProfile, $scopeAgent);
  66. $agent = Agent::query()->create([
  67. 'parent_id' => $parent?->id,
  68. 'path' => '/',
  69. 'level' => $level,
  70. 'username' => trim((string) $data['username']),
  71. 'password' => $hashes['password'] ?? Hash::make((string) $data['password']),
  72. 'withdraw_password' => $hashes['withdraw_password'] ?? (empty($data['withdraw_password']) ? '' : Hash::make((string) $data['withdraw_password'])),
  73. 'real_name' => trim((string) ($data['real_name'] ?? '')),
  74. 'invitation_code' => $this->invitationCode(),
  75. 'deposit_fee_rate' => (string) ($data['deposit_fee_rate'] ?? 0),
  76. 'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
  77. 'profit_commission_rate' => (string) ($data['profit_commission_rate'] ?? 0),
  78. 'flow_commission_profile_id' => $flowProfileId,
  79. 'profit_commission_profile_id' => $profitProfileId,
  80. 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
  81. 'created_by' => $createdBy,
  82. ] + array_intersect_key($data, ['language' => true]));
  83. $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
  84. $agent->save();
  85. $this->flowAssignments->assign($agent, $flowProfile, now()->toDateString(), $createdBy);
  86. $this->profitAssignments->assign($agent, $profitProfile, now()->toDateString(), $createdBy);
  87. return $agent->fresh(['flowCommissionProfile', 'profitCommissionProfile']);
  88. });
  89. }
  90. public function update(Agent $agent, array $data, ?Agent $scopeAgent = null, ?int $createdBy = null): Agent
  91. {
  92. return DB::transaction(function () use ($agent, $data, $scopeAgent, $createdBy): Agent {
  93. $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
  94. $newFlowProfile = null;
  95. if (array_key_exists('flow_commission_profile_id', $data)
  96. && $data['flow_commission_profile_id'] !== null
  97. && (int)$data['flow_commission_profile_id'] !== (int)$agent->flow_commission_profile_id) {
  98. $newFlowProfile = AgentFlowCommissionProfile::query()
  99. ->findOrFail((int)$data['flow_commission_profile_id']);
  100. $this->assertFlowProfileCoversChildren($agent, $newFlowProfile);
  101. }
  102. $newProfitProfile = null;
  103. if (array_key_exists('profit_commission_profile_id', $data)
  104. && $data['profit_commission_profile_id'] !== null
  105. && (int) $data['profit_commission_profile_id'] !== (int) $agent->profit_commission_profile_id) {
  106. $newProfitProfile = AgentProfitCommissionProfile::query()
  107. ->findOrFail((int) $data['profit_commission_profile_id']);
  108. $this->assertProfitProfileCoversChildren($agent, $newProfitProfile);
  109. }
  110. if ($scopeAgent) {
  111. $this->assertVisible($scopeAgent, (int) $agent->id);
  112. } elseif (array_key_exists('parent_id', $data)) {
  113. $this->move(
  114. $agent,
  115. $data['parent_id'] === null ? null : (int)$data['parent_id'],
  116. $newFlowProfile,
  117. $newProfitProfile
  118. );
  119. }
  120. $parent = $agent->parent_id ? Agent::query()->findOrFail($agent->parent_id) : null;
  121. if ($newFlowProfile) $this->assertProfileAssignable($parent, $newFlowProfile, $scopeAgent);
  122. if ($newProfitProfile) $this->assertProfileAssignable($parent, $newProfitProfile, $scopeAgent);
  123. if (array_key_exists('real_name', $data)) {
  124. $agent->real_name = trim((string) ($data['real_name'] ?? ''));
  125. }
  126. foreach (['username', 'language', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_profile_id', 'profit_commission_profile_id'] as $field) {
  127. if (array_key_exists($field, $data) && $data[$field] !== null) {
  128. $agent->{$field} = $data[$field];
  129. }
  130. }
  131. if (!empty($data['password'])) {
  132. $agent->password = Hash::make((string) $data['password']);
  133. }
  134. if (!empty($data['withdraw_password'])) {
  135. $agent->withdraw_password = Hash::make((string) $data['withdraw_password']);
  136. }
  137. $agent->save();
  138. if (!empty($data['password']) || (int) $agent->status === Agent::STATUS_DISABLED) {
  139. AgentToken::query()->where('agent_id', $agent->id)->delete();
  140. }
  141. if ($newFlowProfile) {
  142. $this->flowAssignments->assign($agent, $newFlowProfile, now()->toDateString(), $createdBy);
  143. }
  144. if ($newProfitProfile) {
  145. $this->profitAssignments->assign($agent, $newProfitProfile, now()->toDateString(), $createdBy);
  146. }
  147. return $agent->fresh(['flowCommissionProfile', 'profitCommissionProfile']);
  148. });
  149. }
  150. public function assertVisible(Agent $root, int $targetId): void
  151. {
  152. if ((int) $root->id === $targetId) {
  153. return;
  154. }
  155. $visible = Agent::query()->whereKey($targetId)->where('path', 'like', rtrim((string) $root->path, '/') . '/%')->exists();
  156. if (!$visible) {
  157. throw new \RuntimeException('无权访问该代理');
  158. }
  159. }
  160. private function invitationCode(): string
  161. {
  162. do {
  163. $code = strtoupper(Str::random(10));
  164. } while (Agent::query()->where('invitation_code', $code)->exists());
  165. return $code;
  166. }
  167. private function assertProfileAssignable(
  168. ?Agent $parent,
  169. AgentFlowCommissionProfile|AgentProfitCommissionProfile $profile,
  170. ?Agent $scopeAgent = null
  171. ): void {
  172. $isFlow = $profile instanceof AgentFlowCommissionProfile;
  173. if ($scopeAgent) {
  174. if (!$parent) throw new \RuntimeException('一级代理只能给直属下级指定方案');
  175. if (!$profile->isOfficial() && !$profile->isOwnedBy((int) $parent->id)) {
  176. throw new \RuntimeException($isFlow ? '只能使用官方方案或自己创建的流水方案' : '只能使用官方方案或自己创建的盈亏方案');
  177. }
  178. } elseif (!$profile->isOfficial()) {
  179. throw new \RuntimeException($isFlow ? '总后台只能给代理指定官方流水方案' : '总后台只能给代理指定官方盈亏方案');
  180. }
  181. if ($parent) {
  182. $isFlow
  183. ? $this->assertFlowProfileWithinParent($parent, $profile)
  184. : $this->assertProfitProfileWithinParent($parent, $profile);
  185. }
  186. }
  187. private function assertFlowProfileWithinParent(Agent $parent, AgentFlowCommissionProfile $profile): void
  188. {
  189. $parent->loadMissing('flowCommissionProfile');
  190. foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
  191. if (!$parent->flowCommissionProfile) throw new \RuntimeException('上级代理未关联流水佣金方案');
  192. $parentRate = (string)$parent->flowCommissionProfile->{$field};
  193. if (bccomp((string)$profile->{$field}, $parentRate, 4) > 0) {
  194. throw new \RuntimeException('下级代理流水佣金方案不能高于上级方案');
  195. }
  196. }
  197. }
  198. private function assertProfitProfileWithinParent(Agent $parent, AgentProfitCommissionProfile $profile): void
  199. {
  200. $parent->loadMissing('profitCommissionProfile');
  201. foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
  202. if (!$parent->profitCommissionProfile) throw new \RuntimeException('上级代理未关联盈亏佣金方案');
  203. $parentRate = (string) $parent->profitCommissionProfile->{$field};
  204. if (bccomp((string) $profile->{$field}, $parentRate, 4) > 0) {
  205. throw new \RuntimeException('下级代理盈亏佣金方案不能高于上级方案');
  206. }
  207. }
  208. }
  209. private function assertFlowProfileCoversChildren(Agent $agent, AgentFlowCommissionProfile $profile): void
  210. {
  211. $children = Agent::query()->with('flowCommissionProfile')->where('parent_id', $agent->id)->get();
  212. foreach ($children as $child) {
  213. foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
  214. if (!$child->flowCommissionProfile) throw new \RuntimeException('下级代理未关联流水佣金方案');
  215. $childRate = (string)$child->flowCommissionProfile->{$field};
  216. if (bccomp((string)$profile->{$field}, $childRate, 4) < 0) {
  217. throw new \RuntimeException('代理流水佣金方案不能低于现有下级方案');
  218. }
  219. }
  220. }
  221. }
  222. private function assertProfitProfileCoversChildren(Agent $agent, AgentProfitCommissionProfile $profile): void
  223. {
  224. $children = Agent::query()->with('profitCommissionProfile')->where('parent_id', $agent->id)->get();
  225. foreach ($children as $child) {
  226. foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
  227. if (!$child->profitCommissionProfile) throw new \RuntimeException('下级代理未关联盈亏佣金方案');
  228. $childRate = (string) $child->profitCommissionProfile->{$field};
  229. if (bccomp((string) $profile->{$field}, $childRate, 4) < 0) {
  230. throw new \RuntimeException('代理盈亏佣金方案不能低于现有下级方案');
  231. }
  232. }
  233. }
  234. }
  235. private function move(
  236. Agent $agent,
  237. ?int $parentId,
  238. ?AgentFlowCommissionProfile $flowProfile = null,
  239. ?AgentProfitCommissionProfile $profitProfile = null
  240. ): void {
  241. if ($parentId === (int) $agent->id) {
  242. throw new \RuntimeException('上级代理不能是自己');
  243. }
  244. $currentParentId = $agent->parent_id === null ? null : (int) $agent->parent_id;
  245. if ($parentId === $currentParentId) {
  246. return;
  247. }
  248. $parent = $parentId ? Agent::query()->lockForUpdate()->findOrFail($parentId) : null;
  249. if ($parent && str_starts_with((string) $parent->path, (string) $agent->path)) {
  250. throw new \RuntimeException('不能把代理移动到自己的下级');
  251. }
  252. if ($parent && (int) $parent->level !== Agent::LEVEL_ONE) {
  253. throw new \RuntimeException('二级代理不能作为上级代理');
  254. }
  255. if ($parent) {
  256. $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
  257. $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
  258. if (!$effectiveFlowProfile) throw new \RuntimeException('代理未关联流水佣金方案');
  259. $this->assertProfileAssignable($parent, $effectiveFlowProfile);
  260. $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
  261. if (!$effectiveProfitProfile) throw new \RuntimeException('代理未关联盈亏佣金方案');
  262. $this->assertProfileAssignable($parent, $effectiveProfitProfile);
  263. } else {
  264. $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
  265. $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
  266. if ($effectiveFlowProfile && !$effectiveFlowProfile->isOfficial()) {
  267. throw new \RuntimeException('一级代理只能使用官方流水方案');
  268. }
  269. $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
  270. if ($effectiveProfitProfile && !$effectiveProfitProfile->isOfficial()) {
  271. throw new \RuntimeException('一级代理只能使用官方盈亏方案');
  272. }
  273. }
  274. $oldPath = (string) $agent->path;
  275. $oldLevel = (int) $agent->level;
  276. $newLevel = $parent ? ((int) $parent->level + 1) : 1;
  277. $newPath = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
  278. $descendants = Agent::query()->where('path', 'like', rtrim($oldPath, '/') . '/%')->lockForUpdate()->get();
  279. $newMaxLevel = $descendants->max(fn(Agent $descendant) =>
  280. (int) $descendant->level + $newLevel - $oldLevel
  281. ) ?? $newLevel;
  282. if ($newLevel > Agent::MAX_LEVEL || $newMaxLevel > Agent::MAX_LEVEL) {
  283. throw new \RuntimeException('移动后会产生三级代理,操作已拒绝');
  284. }
  285. $agent->parent_id = $parent?->id;
  286. $agent->path = $newPath;
  287. $agent->level = $newLevel;
  288. foreach ($descendants as $descendant) {
  289. $descendant->path = $newPath . substr((string) $descendant->path, strlen($oldPath));
  290. $descendant->level = max(1, (int) $descendant->level + $newLevel - $oldLevel);
  291. $descendant->save();
  292. }
  293. }
  294. }