Explorar o código

1.新增回收积分的方法2.新增批量操作积分的功能

lh hai 1 mes
pai
achega
8a3b7d01a0

+ 8 - 6
app/Http/Controllers/Points/PointsController.php

@@ -105,12 +105,13 @@ class PointsController extends BaseController
     }
 
     /**
-     * 发放积分(平台给组织 / 组织给同公司组员
+     * 发放/回收积分(平台↔组织 / 组织↔同公司组员,支持批量
      *
      * 入参:
-     * - uid:目标用户ID(必填)
+     * - uid:目标用户ID(必填,支持多个ID用英文逗号隔开,如 142857,142858
      * - points:发放积分数(必填,大于0)
-     * - remark:备注文案(可选)
+     * - action:操作类型 grant-发放(默认)/ revoke-回收(可选)
+     * - remark:备注文案(可选,回收时校验目标积分充足)
      *
      * @param Request $request
      * @return mixed
@@ -120,16 +121,17 @@ class PointsController extends BaseController
         $data = $request->all();
 
         $validator = Validator::make($data, [
-            'uid' => 'required|integer|min:1',
+            'uid' => ['required', 'regex:/^[\d\s,]+$/'],
             'points' => 'required|integer|gt:0',
+            // 'action' => 'nullable|string|in:grant,revoke',
             'remark' => 'nullable|string|max:200',
         ], [
             'uid.required' => '请传入目标用户uid',
-            'uid.integer' => 'uid必须为整数',
-            'uid.min' => 'uid必须大于0',
+            'uid.regex' => 'uid格式不正确,多个用户ID请用英文逗号隔开,如 142857,142858',
             'points.required' => '请传入发放积分数',
             'points.integer' => '发放积分数必须为整数',
             'points.gt' => '发放积分数必须大于0',
+            // 'action.in' => 'action仅支持grant(发放)或revoke(回收)',
             'remark.max' => '备注不能超过200个字符',
         ]);
 

+ 279 - 91
app/Services/PointsService.php

@@ -525,17 +525,17 @@ class PointsService
 
         $user = DB::table('mp_manage_users')->where('id', $uid)->first();
         $summary = [
-            'points_balance' => (float)getProp($user, 'points', 0),
-            'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
+            'points_balance' => (int)getProp($user, 'points', 0),
+            'total_points_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
                 ->where('points_consumed', '>', 0)
                 ->where($excludeTestRecords)
                 ->sum('points_consumed'),
-            'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
+            'total_points_refunded' => (int)MpUserPointsDetail::where('uid', $uid)
                 ->where('points_consumed', '<', 0)
                 ->whereNotIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
                 ->where($excludeTestRecords)
                 ->sum('points_consumed'),
-            'total_points_granted' => (float)abs(MpUserPointsDetail::where('uid', $uid)
+            'total_points_granted' => (int)abs(MpUserPointsDetail::where('uid', $uid)
                 ->whereIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
                 ->where('points_consumed', '<', 0)
                 ->where($excludeTestRecords)
@@ -555,43 +555,132 @@ class PointsService
     }
 
     /**
-     * 发放积分(公共方法)
+     * 发放/回收积分(公共方法)
      *
      * 权限规则:
-     * - superadmin(平台):仅可给 role=admin(组织)发放积分,明细 type=system
-     * - admin(组织):仅可给同 cpid 的 role=user(组员)发放积分,明细 type=company
-     * - 其他角色不允许发放
+     * - superadmin(平台):仅可对 role=admin(组织)操作,明细 type=system
+     * - admin(组织):仅可对同 cpid 的 role=user(组员)操作,明细 type=company
+     * - 其他角色不允许操作
      *
-     * 发放成功后写入 mp_user_points_details(points_consumed 记负值,表示积分流入),
-     * 并原子累加目标用户余额。
+     * action=grant(发放,默认):目标用户积分增加,points_consumed 记负值(积分流入)
+     * action=revoke(回收):目标用户积分减少,需校验目标积分充足(只针对组织/组员角色),points_consumed 记正值(积分流出)
      *
-     * @param array $params uid(目标用户ID)/ points(发放积分数,正数)/ remark(可选文案)
+     * @param array $params uid(目标用户ID,支持多个ID用英文逗号隔开,如 "142857,142858")/ points(积分数,正)/ action(grant|revoke,默认 grant)/ remark(可选文案)
      * @return array
      */
     public function grantPoints(array $params): array
     {
-        $fromUid = (int)Site::getUid();
-        $fromRole = (string)Site::getRole();
-        if ($fromUid <= 0) {
-            Utils::throwError(ErrorConst::NOT_LOGIN);
-        }
-        $fromUser = DB::table('mp_manage_users')->where('id', $fromUid)->first();
-        if (!$fromUser) {
-            Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
-        }
-
-        $targetUid = (int)getProp($params, 'uid', 0);
+        $uidRaw = trim((string)getProp($params, 'uid', ''));
         $points = (float)getProp($params, 'points', 0);
+        $action = (string)getProp($params, 'action', 'grant');
         $remark = trim((string)getProp($params, 'remark', ''));
 
-        if ($targetUid <= 0) {
+        // 解析 uid:单个ID或多个ID(英文逗号隔开),自动去重、忽略空白
+        $uids = array_values(array_unique(array_filter(array_map(function ($v) {
+            return (int)trim((string)$v);
+        }, explode(',', $uidRaw)), function ($v) {
+            return $v > 0;
+        })));
+        if (empty($uids)) {
             Utils::throwError('1002:请传入目标用户uid');
         }
+
+        $this->validatePoints($points);
+        $this->validateAction($action);
+
+        // 单用户:保持原有返回结构
+        if (count($uids) === 1) {
+            try {
+                DB::beginTransaction();
+                $result = $this->applyPointsChange($uids[0], $points, $action, $remark);
+                DB::commit();
+                return $result;
+            } catch (\Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        }
+
+        // 多用户:先对全部目标校验(只读,不写库),任一失败整体拒绝;再单事务统一写库,保证原子性
+        foreach ($uids as $uid) {
+            $this->buildGrantContext($uid, $action, $points);
+        }
+        try {
+            DB::beginTransaction();
+            $results = [];
+            foreach ($uids as $uid) {
+                $results[] = $this->applyPointsChange($uid, $points, $action, $remark);
+            }
+            DB::commit();
+        } catch (\Exception $e) {
+            DB::rollBack();
+            throw $e;
+        }
+
+        dLog('points')->info('积分批量操作成功', [
+            'action' => $action,
+            'count' => count($uids),
+            'points' => $points,
+        ]);
+
+        return [
+            'action' => $action,
+            'points' => $points,
+            'success_count' => count($results),
+            'results' => $results,
+        ];
+    }
+
+    /**
+     * 校验积分数(正整数)
+     *
+     * @param float $points
+     * @return void
+     */
+    private function validatePoints(float $points): void
+    {
         if ($points <= 0) {
-            Utils::throwError('1002:发放积分数必须大于0');
+            Utils::throwError('1002:积分数必须大于0');
         }
         if (floor($points) != $points) {
-            Utils::throwError('1002:发放积分数必须为整数');
+            Utils::throwError('1002:积分数必须为整数');
+        }
+    }
+
+    /**
+     * 校验操作类型
+     *
+     * @param string $action
+     * @return void
+     */
+    private function validateAction(string $action): void
+    {
+        if (!in_array($action, ['grant', 'revoke'], true)) {
+            Utils::throwError('1002:action仅支持grant(发放)或revoke(回收)');
+        }
+    }
+
+    /**
+     * 校验并构建单用户积分操作上下文(只读)
+     *
+     * 校验当前操作者角色、目标用户存在/启用、目标角色与公司归属;
+     * 回收时额外校验目标用户积分充足;管理员发放时额外校验管理员自身可用积分充足。
+     *
+     * @param int    $targetUid
+     * @param string $action
+     * @param float  $points
+     * @return array
+     */
+    private function buildGrantContext(int $targetUid, string $action, float $points): array
+    {
+        $fromUid = (int)Site::getUid();
+        if ($fromUid <= 0) {
+            Utils::throwError(ErrorConst::NOT_LOGIN);
+        }
+        $fromRole = (string)Site::getRole();
+        $fromUser = DB::table('mp_manage_users')->where('id', $fromUid)->first();
+        if (!$fromUser) {
+            Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
         }
 
         $targetUser = DB::table('mp_manage_users')->where('id', $targetUid)->first();
@@ -604,102 +693,200 @@ class PointsService
 
         $targetRole = (string)getProp($targetUser, 'role', '');
         $targetCpid = (int)getProp($targetUser, 'cpid', 0);
+        $verb = $action === 'revoke' ? '回收' : '发放';
 
-        // 角色与发放对象校验
+        // 角色与操作对象校验
         if ($fromRole === 'superadmin') {
             if ($targetRole !== 'admin') {
-                Utils::throwError('1005:平台仅可给组织发放积分');
+                Utils::throwError('1005:平台仅可给组织' . $verb . '积分');
             }
             $type = MpUserPointsDetail::TYPE_SYSTEM;
-            $defaultRemark = '平台发放积分';
+            $defaultRemark = $action === 'revoke' ? '平台回收积分' : '平台发放积分';
         } elseif ($fromRole === 'admin') {
             if ($targetRole !== 'user') {
-                Utils::throwError('1005:组织仅可给同公司的组员发放积分');
+                Utils::throwError('1005:组织仅可给同公司的组员' . $verb . '积分');
             }
             if ($targetCpid !== (int)Site::getCpid()) {
-                Utils::throwError('1005:只能给同一公司的组员发放积分');
+                Utils::throwError('1005:只能给同一公司的组员' . $verb . '积分');
             }
             $type = MpUserPointsDetail::TYPE_COMPANY;
-            $defaultRemark = '组织发放积分';
+            $defaultRemark = $action === 'revoke' ? '组织回收积分' : '组织发放积分';
         } else {
-            Utils::throwError('1005:当前角色无权发放积分');
+            Utils::throwError('1005:当前角色无权操作积分');
         }
 
-        $finalRemark = $remark !== '' ? $defaultRemark . ':' . $remark : $defaultRemark;
-        $chargeInfo = [
+        // 回收时校验目标积分充足(目标只能是组织/组员角色,超管不在操作范围内,无需判断)
+        if ($action === 'revoke') {
+            $balance = (float)getProp($targetUser, 'points', 0);
+            if ($balance < $points) {
+                Utils::throwError('1002:目标用户积分不足,无法回收');
+            }
+        }
+
+        // 管理员发放:积分从管理员自身扣减转移给组员,需校验管理员可用积分充足(平台发放不校验)
+        if ($fromRole === 'admin' && $action === 'grant') {
+            $operatorBalance = (float)getProp($fromUser, 'points', 0);
+            if ($operatorBalance < $points) {
+                Utils::throwError('1002:可用积分不足,无法发放');
+            }
+        }
+
+        return [
             'from_uid' => $fromUid,
             'from_role' => $fromRole,
+            'from_cpid' => (int)getProp($fromUser, 'cpid', 0),
+            'from_user' => $fromUser,
+            'target_user' => $targetUser,
             'target_role' => $targetRole,
             'target_cpid' => $targetCpid,
-            'grant_type' => $type,
+            'target_uid' => $targetUid,
+            'type' => $type,
+            'default_remark' => $defaultRemark,
         ];
+    }
 
-        try {
-            DB::beginTransaction();
+    /**
+     * 对单个用户执行积分变动并写入明细(须在事务内调用)
+     *
+     * @param int    $targetUid
+     * @param float  $points
+     * @param string $action grant|revoke
+     * @param string $remark
+     * @return array
+     */
+    private function applyPointsChange(int $targetUid, float $points, string $action, string $remark): array
+    {
+        $ctx = $this->buildGrantContext($targetUid, $action, $points);
 
-            $pointsBefore = (float)getProp($targetUser, 'points', 0);
-            $pointsAfter = $pointsBefore + $points;
+        $finalRemark = $remark !== '' ? $ctx['default_remark'] . ':' . $remark : $ctx['default_remark'];
+        $chargeInfo = [
+            'from_uid' => $ctx['from_uid'],
+            'from_role' => $ctx['from_role'],
+            'target_uid' => $targetUid,
+            'target_role' => $ctx['target_role'],
+            'target_cpid' => $ctx['target_cpid'],
+            'grant_type' => $ctx['type'],
+            'action' => $action,
+        ];
 
-            // 原子累加积分余额,避免并发覆盖
-            DB::table('mp_manage_users')->where('id', $targetUid)->update([
-                'points' => DB::raw('points + ' . (float)$points),
-                'updated_at' => date('Y-m-d H:i:s'),
-            ]);
+        $pointsBefore = (float)getProp($ctx['target_user'], 'points', 0);
+        $delta = $action === 'revoke' ? -$points : $points;
+        $pointsAfter = $pointsBefore + $delta;
+
+        $now = date('Y-m-d H:i:s');
+        $operatorAfter = null;
+
+        // 原子更新余额,避免并发覆盖;COALESCE 处理余额为 NULL 的用户(NULL ± X 在 MySQL 中仍为 NULL)
+        if ($ctx['from_role'] === 'admin') {
+            // 转移模式:积分在管理员与组员之间流转,整体数额不变
+            $operatorBefore = (float)getProp($ctx['from_user'], 'points', 0);
+            if ($action === 'revoke') {
+                // 组员扣减(带余额条件),管理员增加
+                $affected = DB::table('mp_manage_users')
+                    ->where('id', $targetUid)
+                    ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
+                    ->update([
+                        'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
+                        'updated_at' => $now,
+                    ]);
+                if ($affected === 0) {
+                    Utils::throwError('1002:目标用户积分不足,无法回收');
+                }
+                DB::table('mp_manage_users')->where('id', $ctx['from_uid'])->update([
+                    'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
+                    'updated_at' => $now,
+                ]);
+                $operatorDelta = $points;
+            } else {
+                // 管理员扣减(带余额条件,防止并发超发),组员增加
+                $affected = DB::table('mp_manage_users')
+                    ->where('id', $ctx['from_uid'])
+                    ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
+                    ->update([
+                        'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
+                        'updated_at' => $now,
+                    ]);
+                if ($affected === 0) {
+                    Utils::throwError('1002:可用积分不足,无法发放');
+                }
+                DB::table('mp_manage_users')->where('id', $targetUid)->update([
+                    'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
+                    'updated_at' => $now,
+                ]);
+                $operatorDelta = -$points;
+            }
+            $operatorAfter = $operatorBefore + $operatorDelta;
 
-            // 发放明细:points_consumed 记负值,表示积分流入
+            // 管理员侧明细(方向与组员相反):发放记支出(正),回收记收入(负)
             DB::table('mp_user_points_details')->insert([
-                'uid' => $targetUid,
-                'cpid' => $targetCpid,
+                'uid' => $ctx['from_uid'],
+                'cpid' => $ctx['from_cpid'],
                 'task_id' => null,
-                'type' => $type,
-                'api_type' => 'grant',
+                'type' => $ctx['type'],
+                'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
                 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
-                'points_before' => $pointsBefore,
-                'points_consumed' => -$points,
-                'points_after' => $pointsAfter,
+                'points_before' => $operatorBefore,
+                'points_consumed' => -$operatorDelta,
+                'points_after' => $operatorAfter,
                 'tokens_consumed' => 0,
                 'remark' => $finalRemark,
-                'created_at' => date('Y-m-d H:i:s'),
-                'updated_at' => date('Y-m-d H:i:s'),
+                'created_at' => $now,
+                'updated_at' => $now,
             ]);
-
-            DB::commit();
-
-            dLog('points')->info('积分发放成功', [
-                'from_uid' => $fromUid,
-                'from_role' => $fromRole,
-                'target_uid' => $targetUid,
-                'points' => $points,
-                'type' => $type,
-            ]);
-
-            return [
-                'granted' => true,
-                'type' => $type,
-                'uid' => $targetUid,
-                'points' => $points,
-                'points_before' => $pointsBefore,
-                'points_after' => $pointsAfter,
-                'remark' => $finalRemark,
-            ];
-
-        } catch (\Exception $e) {
-            DB::rollBack();
-
-            dLog('points')->error('积分发放失败: ' . $e->getMessage(), [
-                'from_uid' => $fromUid,
-                'target_uid' => $targetUid,
-                'points' => $points,
-            ]);
-            logDB('points', 'error', '积分发放失败', [
-                'from_uid' => $fromUid,
-                'target_uid' => $targetUid,
-                'points' => $points,
-                'error' => $e->getMessage(),
+        } elseif ($action === 'revoke') {
+            // 平台回收:目标(组织)扣减,带余额条件
+            $affected = DB::table('mp_manage_users')
+                ->where('id', $targetUid)
+                ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
+                ->update([
+                    'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
+                    'updated_at' => $now,
+                ]);
+            if ($affected === 0) {
+                Utils::throwError('1002:目标用户积分不足,无法回收');
+            }
+        } else {
+            // 平台发放:目标(组织)增加(平台为积分源头,不校验平台余额)
+            DB::table('mp_manage_users')->where('id', $targetUid)->update([
+                'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
+                'updated_at' => $now,
             ]);
+        }
 
-            Utils::throwError('1006:积分发放失败,请稍后重试');
+        // 目标侧明细:发放 points_consumed 记负值(流入),回收记正值(流出)
+        DB::table('mp_user_points_details')->insert([
+            'uid' => $targetUid,
+            'cpid' => $ctx['target_cpid'],
+            'task_id' => null,
+            'type' => $ctx['type'],
+            'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
+            'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
+            'points_before' => $pointsBefore,
+            'points_consumed' => -$delta,
+            'points_after' => $pointsAfter,
+            'tokens_consumed' => 0,
+            'remark' => $finalRemark,
+            'created_at' => $now,
+            'updated_at' => $now,
+        ]);
+
+        $result = [
+            'uid' => $targetUid,
+            'success' => true,
+            'action' => $action,
+            'type' => $ctx['type'],
+            'points' => $points,
+            'points_before' => $pointsBefore,
+            'points_after' => $pointsAfter,
+            'remark' => $finalRemark,
+        ];
+        if ($operatorAfter !== null) {
+            $result['operator_uid'] = $ctx['from_uid'];
+            $result['operator_points_before'] = $operatorBefore;
+            $result['operator_points_after'] = $operatorAfter;
         }
+
+        return $result;
     }
 
     /**
@@ -1217,9 +1404,10 @@ class PointsService
 
             if (!$isTestUser) {
                 // 原子扣减积分:基于数据库当前值执行(points = points - X),
-                // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣)
+                // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣);
+                // COALESCE 处理余额为 NULL 的用户(NULL - X 在 MySQL 中仍为 NULL)
                 DB::table('mp_manage_users')->where('id', $uid)->update([
-                    'points' => DB::raw('points - ' . (float)$points),
+                    'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
                     'updated_at' => date('Y-m-d H:i:s')
                 ]);
             }

+ 1 - 1
routes/api.php

@@ -89,7 +89,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
 
         Route::get('rules', [PointsController::class, 'rules']);            // 获取积分规则
         Route::post('editRule', [PointsController::class, 'editRules']);        // 编辑积分规则
-        Route::post('grant', [PointsController::class, 'grantPoints']);      // 发放积分(超管→管理员 / 管理员→同公司组员
+        Route::post('grant', [PointsController::class, 'grantPoints']);      // 发放/回收积分(平台→组织 / 组织→同公司组员,uid支持逗号分隔批量
         Route::get('grantUsers', [PointsController::class, 'grantUsers']);   // 可发放积分用户列表
     });