修改密码
+ 下放积分
+ 回收积分
+
-
+
+
+
+
+ {{ deptScoreForm.username }}
+
+
+
+
+
+
+
+
+
+
+
+
@@ -286,10 +345,11 @@ import {
delUser,
addUser,
updateUser,
- changeBalance,
changeUserStatus,
updatePassword,
- assignAiUserDept
+ assignAiUserDept,
+ issueDeptScore,
+ reclaimDeptScore
} from "@/api/ai/user";
import { listDept } from "@/api/ai/dept";
import Treeselect from "@riophae/vue-treeselect";
@@ -320,7 +380,27 @@ export default {
// 是否显示弹出层
open: false,
openUpdatePassword: false,
- openUpdateBalance: false,
+ deptScoreOpen: false,
+ deptScoreMode: "issue",
+ deptScoreForm: {
+ userId: null,
+ username: "",
+ amount: undefined,
+ remark: ""
+ },
+ deptScoreRules: {
+ amount: [
+ { required: true, message: "请输入积分数量", trigger: "blur" },
+ {
+ type: "number",
+ min: 1,
+ max: 100000000,
+ message: "请输入 1~100000000 的整数",
+ trigger: "blur"
+ }
+ ],
+ remark: [{ max: 50, message: "备注最多50个字", trigger: "blur" }]
+ },
assignDeptOpen: false,
deptOptions: [],
assignForm: {
@@ -337,7 +417,7 @@ export default {
phone: null,
password: null,
openid: null,
- status: null,
+ status: "0",
email: null,
birthday: null,
invitationCode: null,
@@ -417,9 +497,9 @@ export default {
case "updatePassword":
this.updatePassword(row);
break;
- case "updateBalance":
- this.updateBalance(row);
- break;
+ // case "updateBalance":
+ // this.updateBalance(row);
+ // break;
default:
break;
}
@@ -437,15 +517,62 @@ export default {
});
return;
},
- updateBalance(row) {
- this.reset();
- const id = row.id || this.ids;
- getUser(id).then(response => {
- this.form = response.data;
- this.openUpdateBalance = true;
- this.title = "修改余额";
+ openDeptScoreDialog(row, mode) {
+ if (!row.deptId) {
+ this.$modal.msgWarning("请先分配归属部门后再操作积分");
+ return;
+ }
+ this.deptScoreMode = mode;
+ this.deptScoreForm = {
+ userId: row.id,
+ username: row.username || row.userId || "",
+ amount: undefined,
+ remark: ""
+ };
+ this.deptScoreOpen = true;
+ this.$nextTick(() => {
+ if (this.$refs.deptScoreFormRef) {
+ this.$refs.deptScoreFormRef.clearValidate();
+ }
});
},
+ submitDeptScore() {
+ this.$refs.deptScoreFormRef.validate(valid => {
+ if (!valid) {
+ return;
+ }
+ const raw = this.deptScoreForm.amount;
+ const amount = typeof raw === "number" ? Math.trunc(raw) : parseInt(String(raw), 10);
+ if (!Number.isFinite(amount) || amount < 1 || amount > 100000000) {
+ this.$modal.msgWarning("请输入 1~100000000 的整数积分");
+ return;
+ }
+ const payload = {
+ userId: this.deptScoreForm.userId,
+ amount,
+ remark: this.deptScoreForm.remark || undefined
+ };
+ const req = this.deptScoreMode === "issue" ? issueDeptScore : reclaimDeptScore;
+ req(payload).then(() => {
+ this.$modal.msgSuccess("操作成功");
+ this.deptScoreOpen = false;
+ this.getList();
+ });
+ });
+ },
+ cancelDeptScore() {
+ this.deptScoreOpen = false;
+ this.deptScoreForm = { userId: null, username: "", amount: undefined, remark: "" };
+ },
+ // updateBalance(row) {
+ // this.reset();
+ // const id = row.id || this.ids;
+ // getUser(id).then(response => {
+ // this.form = response.data;
+ // this.openUpdateBalance = true;
+ // this.title = "修改余额";
+ // });
+ // },
/** 查询ai-用户信息列表 */
getList() {
this.loading = true;
@@ -475,10 +602,10 @@ export default {
this.open = false;
this.reset();
},
- cancelBalance() {
- this.openUpdateBalance = false;
- this.reset();
- },
+ // cancelBalance() {
+ // this.openUpdateBalance = false;
+ // this.reset();
+ // },
cancelPassword() {
this.openUpdatePassword = false;
this.reset();
@@ -567,16 +694,16 @@ export default {
}
});
},
- handleChangeBalance() {
- changeBalance;
- this.$refs["form"].validate(valid => {
- changeBalance(this.form.id, this.form.balance).then(response => {
- this.$modal.msgSuccess("修改成功");
- this.openUpdateBalance = false;
- this.getList();
- });
- });
- },
+ // handleChangeBalance() {
+ // this.$refs["form"].validate(valid => {
+ // if (!valid) return;
+ // changeBalance(this.form.id, this.form.balance).then(() => {
+ // this.$modal.msgSuccess("修改成功");
+ // this.openUpdateBalance = false;
+ // this.getList();
+ // });
+ // });
+ // },
/** 删除按钮操作 */
handleDelete(row) {
const ids = row && row.id != null ? row.id : this.ids;
diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java
index 8c817b7..8593912 100644
--- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java
+++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java
@@ -26,8 +26,8 @@ import com.ruoyi.system.service.ISysDeptService;
/**
* AI 业务侧部门管理(数据源与系统部门 sys_dept 一致,权限独立)
*/
-//@RestController
-//@RequestMapping("/ai/dept")
+@RestController
+@RequestMapping("/ai/dept")
public class AiDeptController extends BaseController
{
@Autowired
diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java
index 8e1d350..48c5b3c 100644
--- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java
+++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java
@@ -1,9 +1,8 @@
package com.ruoyi.web.controller.ai;
-import cn.hutool.core.util.NumberUtil;
import com.ruoyi.ai.service.IAiUserService;
+import com.ruoyi.ai.service.IDeptUserScoreTransferService;
import com.ruoyi.common.annotation.Log;
-import com.ruoyi.common.constant.BalanceChangerConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AiUser;
@@ -17,11 +16,10 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
-import java.math.BigDecimal;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import javax.validation.Valid;
+
+import com.ruoyi.common.core.request.ai.AiUserDeptScoreRequest;
import java.util.List;
-import java.util.UUID;
/**
* ai-用户信息Controller
@@ -36,6 +34,9 @@ public class AiUserController extends BaseController {
@Autowired
private IAiUserService aiUserService;
+ @Autowired
+ private IDeptUserScoreTransferService deptUserScoreTransferService;
+
/**
* 查询ai-用户信息列表
@@ -120,9 +121,10 @@ public class AiUserController extends BaseController {
}
/**
- * 状态修改
+ * 管理端修改余额已关闭(如需恢复,同步放开 admin-ui 与 api/ai/user.js)
*/
- @ApiOperation("修改ai-用户状态")
+ /*
+ @ApiOperation("修改ai-用户余额")
@PreAuthorize("@ss.hasPermi('ai:user:edit')")
@Log(title = "ai-用户信息", businessType = BusinessType.UPDATE)
@PutMapping("/changeBalance")
@@ -130,14 +132,15 @@ public class AiUserController extends BaseController {
aiUser.setUpdateBy(getUsername());
// 获取原余额
AiUser u = aiUserService.selectAiUserById(aiUser.getId());
- String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
- String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
+ String uuid = java.util.UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
+ String dateTime = new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date());
String orderNo = dateTime + uuid;
// 计算变更余额
- BigDecimal amount = NumberUtil.sub(aiUser.getBalance(), u.getBalance());
- aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION);
+ java.math.BigDecimal amount = cn.hutool.core.util.NumberUtil.sub(aiUser.getBalance(), u.getBalance());
+ aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, com.ruoyi.common.constant.BalanceChangerConstants.SYSTEM_OPERATION);
return toAjax(1);
}
+ */
/**
* 状态密码
@@ -149,6 +152,30 @@ public class AiUserController extends BaseController {
return toAjax(aiUserService.updatePassword(aiUser));
}
+ /**
+ * 部门积分下放至当前用户余额
+ */
+ @ApiOperation("部门积分下放至用户")
+ @PreAuthorize("@ss.hasPermi('ai:user:deptScoreIssue')")
+ @Log(title = "AI用户部门积分下放", businessType = BusinessType.UPDATE)
+ @PutMapping("/dept-score/issue")
+ public AjaxResult issueDeptScore(@Valid @RequestBody AiUserDeptScoreRequest request) {
+ deptUserScoreTransferService.issueDeptScore(request);
+ return success();
+ }
+
+ /**
+ * 用户余额回收至部门积分池
+ */
+ @ApiOperation("用户积分回收至部门")
+ @PreAuthorize("@ss.hasPermi('ai:user:deptScoreReclaim')")
+ @Log(title = "AI用户部门积分回收", businessType = BusinessType.UPDATE)
+ @PutMapping("/dept-score/reclaim")
+ public AjaxResult reclaimDeptScore(@Valid @RequestBody AiUserDeptScoreRequest request) {
+ deptUserScoreTransferService.reclaimDeptScore(request);
+ return success();
+ }
+
/**
* 删除ai-用户信息
*/
diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/constant/BalanceChangerConstants.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/constant/BalanceChangerConstants.java
index d77345b..95b2639 100644
--- a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/constant/BalanceChangerConstants.java
+++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/constant/BalanceChangerConstants.java
@@ -73,4 +73,14 @@ public class BalanceChangerConstants {
*/
public static final int SYSTEM_OPERATION = 11;
+ /**
+ * 部门积分下放至用户(sys_dept.balance → ai_user.balance)
+ */
+ public static final int DEPT_SCORE_ISSUE = 12;
+
+ /**
+ * 用户积分回收至部门(ai_user.balance → sys_dept.balance)
+ */
+ public static final int DEPT_SCORE_RECLAIM = 13;
+
}
diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/ai/AiUserDeptScoreRequest.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/ai/AiUserDeptScoreRequest.java
new file mode 100644
index 0000000..8ea8a7f
--- /dev/null
+++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/ai/AiUserDeptScoreRequest.java
@@ -0,0 +1,28 @@
+package com.ruoyi.common.core.request.ai;
+
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 管理端:部门积分与 AI 用户余额互转(下放 / 回收)。
+ * 积分为整数;落库仍用 decimal 字段,由服务层转为 {@link java.math.BigDecimal}。
+ */
+@Data
+public class AiUserDeptScoreRequest {
+
+ /** 对应 ai_user.id(主键) */
+ @NotNull(message = "用户不能为空")
+ private Long userId;
+
+ /** 积分数量(正整数) */
+ @NotNull(message = "积分不能为空")
+ @Min(value = 1, message = "积分必须为正整数")
+ @Max(value = 100_000_000, message = "积分超出上限")
+ private Long amount;
+
+ @Size(max = 50, message = "备注最多50个字")
+ private String remark;
+}
diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/GroupBalanceChangeType.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/GroupBalanceChangeType.java
index 0aa9da2..0ea6f8e 100644
--- a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/GroupBalanceChangeType.java
+++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/GroupBalanceChangeType.java
@@ -11,7 +11,7 @@ public enum GroupBalanceChangeType {
RECHARGE(0, "充值"),
REFUND(1, "退款"),
ISSUE(2, "下发"),
- CONSUME(3, "消费"),
+ RECLAIM(3, "回收"),
MANUAL_ADJUST(4, "手动修改");
private final int code;
diff --git a/web-api/ruoyi-system/pom.xml b/web-api/ruoyi-system/pom.xml
index 5da2a4c..d031e05 100644
--- a/web-api/ruoyi-system/pom.xml
+++ b/web-api/ruoyi-system/pom.xml
@@ -29,6 +29,13 @@
compile
+
+
+ org.redisson
+ redisson-spring-boot-starter
+ 3.17.7
+
+
diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiBalanceChangeRecord.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiBalanceChangeRecord.java
index 989672f..d1602bf 100644
--- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiBalanceChangeRecord.java
+++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiBalanceChangeRecord.java
@@ -51,7 +51,7 @@ public class AiBalanceChangeRecord extends BaseEntity {
private String uuid;
/**
- * 操作类型:0-充值 1-返佣 2-充值赠送 3-体验金赠送 4-体验金回收 5-图生图 6-一键换脸 7-快捷生图 8-快捷生视频 9-退款 10-系统操作
+ * 操作类型:0-充值 1-返佣 2-充值赠送 3-体验金赠送 4-体验金回收 5-图生图 6-一键换脸 7-快捷生图 8-快捷生视频 9-退款 10-系统操作 12-部门下放积分 13-回收至部门
*/
@Excel(name = "操作类型")
private Integer type;
diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java
index bf1653e..e4be39a 100644
--- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java
+++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java
@@ -30,4 +30,9 @@ public interface AiOrderMapper extends BaseMapper {
@Select("SELECT * FROM ai_order WHERE third_party_order_num = #{id} LIMIT 1")
AiOrder selectOneByThirdPartyOrderNum(@Param("id") String id);
+
+ /**
+ * 未完结订单数量:del_flag=0 且 status 非已完成(1)、非失败(2),即进行中(0)等
+ */
+ int countOpenOrdersByUserId(@Param("userId") Long userId);
}
diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IDeptUserScoreTransferService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IDeptUserScoreTransferService.java
new file mode 100644
index 0000000..1b6c591
--- /dev/null
+++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IDeptUserScoreTransferService.java
@@ -0,0 +1,13 @@
+package com.ruoyi.ai.service;
+
+import com.ruoyi.common.core.request.ai.AiUserDeptScoreRequest;
+
+/**
+ * 部门积分与 AI 用户余额互转(下放 / 回收),含 Redisson 锁。
+ */
+public interface IDeptUserScoreTransferService {
+
+ void issueDeptScore(AiUserDeptScoreRequest request);
+
+ void reclaimDeptScore(AiUserDeptScoreRequest request);
+}
diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferServiceImpl.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferServiceImpl.java
new file mode 100644
index 0000000..bff6a3e
--- /dev/null
+++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferServiceImpl.java
@@ -0,0 +1,135 @@
+package com.ruoyi.ai.service.impl;
+
+import java.util.concurrent.TimeUnit;
+
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.ruoyi.ai.service.IAiUserService;
+import com.ruoyi.ai.service.IDeptUserScoreTransferService;
+import com.ruoyi.common.core.domain.entity.AiUser;
+import com.ruoyi.common.core.domain.entity.SysDept;
+import com.ruoyi.common.core.request.ai.AiUserDeptScoreRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.service.ISysDeptService;
+/**
+ * 部门与用户积分互转:先占 Redisson 锁,再进入事务内层。
+ */
+@Service
+public class DeptUserScoreTransferServiceImpl implements IDeptUserScoreTransferService {
+
+ private static final String USER_LOCK_PREFIX = "ai:user-score:";
+ private static final int LOCK_WAIT_SECONDS = 10;
+ private static final int LOCK_LEASE_SECONDS = 10;
+
+ @Autowired
+ private RedissonClient redissonClient;
+
+ @Autowired
+ private IAiUserService aiUserService;
+
+ @Autowired
+ private DeptUserScoreTransferTxService deptUserScoreTransferTxService;
+
+ @Autowired
+ private ISysDeptService deptService;
+
+ @Override
+ public void issueDeptScore(AiUserDeptScoreRequest request) {
+ AiUser user = loadUserForDeptOp(request.getUserId());
+ assertOperatorDeptRelatedToTarget(user.getDeptId());
+ runWithLock(user.getId(), () -> deptUserScoreTransferTxService.transferIssue(request));
+ }
+
+ @Override
+ public void reclaimDeptScore(AiUserDeptScoreRequest request) {
+ AiUser user = loadUserForDeptOp(request.getUserId());
+ assertOperatorDeptRelatedToTarget(user.getDeptId());
+ runWithLock(user.getId(), () -> deptUserScoreTransferTxService.transferReclaim(request));
+ }
+
+ private AiUser loadUserForDeptOp(Long id) {
+ AiUser user = aiUserService.selectAiUserById(id);
+ if (user == null) {
+ throw new ServiceException("用户不存在");
+ }
+ if (user.getDeptId() == null) {
+ throw new ServiceException("用户未分配部门,无法操作");
+ }
+ if (user.getDelFlag() != null && !"0".equals(user.getDelFlag())) {
+ throw new ServiceException("用户已删除或无效");
+ }
+ return user;
+ }
+
+ /**
+ * 当前登录用户部门与目标用户部门须相同,或一方在另一方祖级链上(上下级)。
+ * 超级管理员不校验部门。
+ */
+ private void assertOperatorDeptRelatedToTarget(Long targetUserDeptId) {
+ if (SecurityUtils.isAdmin(SecurityUtils.getUserId())) {
+ return;
+ }
+ Long operatorDeptId = SecurityUtils.getDeptId();
+ if (operatorDeptId == null) {
+ throw new ServiceException("当前账号无归属部门,无法操作");
+ }
+ if (targetUserDeptId == null) {
+ throw new ServiceException("用户未分配部门,无法操作");
+ }
+ if (operatorDeptId.equals(targetUserDeptId)) {
+ return;
+ }
+ SysDept targetDept = deptService.selectDeptById(targetUserDeptId);
+ SysDept operatorDept = deptService.selectDeptById(operatorDeptId);
+ if (targetDept == null || operatorDept == null) {
+ throw new ServiceException("部门数据异常");
+ }
+ // 目标部门在操作者部门之下,或操作者部门在目标部门之下
+ if (ancestorsChainContainsDeptId(targetDept.getAncestors(), operatorDeptId)
+ || ancestorsChainContainsDeptId(operatorDept.getAncestors(), targetUserDeptId)) {
+ return;
+ }
+ throw new ServiceException("仅可操作与本部门相同或存在上下级关系的部门用户");
+ }
+
+ /**
+ * 判断 {@code ancestors}(逗号分隔祖级 id)中是否包含 {@code deptId}(含与自身部门 id 一致的场景由调用方先处理)。
+ */
+ private static boolean ancestorsChainContainsDeptId(String ancestors, Long deptId) {
+ if (deptId == null || StringUtils.isEmpty(ancestors)) {
+ return false;
+ }
+ String needle = String.valueOf(deptId);
+ for (String segment : ancestors.split(",")) {
+ if (needle.equals(segment.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void runWithLock(Long userId, Runnable inLock) {
+ String key = USER_LOCK_PREFIX + userId;
+ RLock lock = redissonClient.getLock(key);
+ boolean locked = false;
+ try {
+ locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
+ if (!locked) {
+ throw new ServiceException("系统繁忙,请稍后再试");
+ }
+ inLock.run();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ServiceException("操作被中断");
+ } finally {
+ if (locked && lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+}
diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferTxService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferTxService.java
new file mode 100644
index 0000000..d728ca5
--- /dev/null
+++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/DeptUserScoreTransferTxService.java
@@ -0,0 +1,149 @@
+package com.ruoyi.ai.service.impl;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.ruoyi.ai.domain.AiGroupBalanceChangeRecord;
+import com.ruoyi.ai.mapper.AiOrderMapper;
+import com.ruoyi.ai.service.IAiGroupBalanceChangeRecordService;
+import com.ruoyi.ai.service.IAiUserService;
+import com.ruoyi.common.constant.BalanceChangerConstants;
+import com.ruoyi.common.core.domain.entity.AiUser;
+import com.ruoyi.common.core.domain.entity.SysDept;
+import com.ruoyi.common.core.request.ai.AiUserDeptScoreRequest;
+import com.ruoyi.common.enums.GroupBalanceChangeType;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.service.ISysDeptService;
+
+/**
+ * 部门与用户积分互转的事务内逻辑(由门面在 Redisson 锁内调用)。
+ */
+@Service
+public class DeptUserScoreTransferTxService {
+
+ @Autowired
+ private ISysDeptService deptService;
+
+ @Autowired
+ private IAiUserService aiUserService;
+
+ @Autowired
+ private IAiGroupBalanceChangeRecordService aiGroupBalanceChangeRecordService;
+
+ @Autowired
+ private AiOrderMapper aiOrderMapper;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void transferIssue(AiUserDeptScoreRequest request) {
+ AiUser user = requireUserWithDept(request.getUserId());
+ Long deptId = user.getDeptId();
+ BigDecimal amount = toAmountBigDecimal(request.getAmount());
+ String orderNum = buildOrderNum();
+ String remark = buildRemark(request.getRemark(), "部门下放积分至用户");
+
+ int rows = deptService.subtractDeptBalance(deptId, amount);
+ if (rows == 0) {
+ throw new ServiceException("部门积分不足或部门不存在");
+ }
+
+ aiUserService.addUserBalance(orderNum, user.getId(), amount, BalanceChangerConstants.DEPT_SCORE_ISSUE, remark);
+
+ BigDecimal deptBalAfter = getDeptBalance(deptId);
+ insertGroupRecord(orderNum, deptId, GroupBalanceChangeType.ISSUE.getCode(), amount.negate(), deptBalAfter, remark);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void transferReclaim(AiUserDeptScoreRequest request) {
+ AiUser user = requireUserWithDept(request.getUserId());
+ Long deptId = user.getDeptId();
+ BigDecimal amount = toAmountBigDecimal(request.getAmount());
+
+ int openCnt = aiOrderMapper.countOpenOrdersByUserId(user.getId());
+ if (openCnt > 0) {
+ throw new ServiceException("存在未完结订单,无法回收积分");
+ }
+
+ BigDecimal userBal = user.getBalance() == null ? BigDecimal.ZERO : user.getBalance();
+ if (userBal.compareTo(amount) < 0) {
+ throw new ServiceException("用户积分不足");
+ }
+
+ String orderNum = buildOrderNum();
+ String remark = buildRemark(request.getRemark(), "用户积分回收至部门");
+
+ aiUserService.addUserBalance(orderNum, user.getId(), amount.negate(), BalanceChangerConstants.DEPT_SCORE_RECLAIM, remark);
+
+ int rows = deptService.addDeptBalance(deptId, amount);
+ if (rows == 0) {
+ throw new ServiceException("部门不存在或已删除");
+ }
+
+ BigDecimal deptBalAfter = getDeptBalance(deptId);
+ insertGroupRecord(orderNum, deptId, GroupBalanceChangeType.RECLAIM.getCode(), amount, deptBalAfter, remark);
+ }
+
+ private AiUser requireUserWithDept(Long id) {
+ AiUser user = aiUserService.selectAiUserById(id);
+ if (user == null) {
+ throw new ServiceException("用户不存在");
+ }
+ if (user.getDeptId() == null) {
+ throw new ServiceException("用户未分配部门,无法操作");
+ }
+ if (user.getDelFlag() != null && !"0".equals(user.getDelFlag())) {
+ throw new ServiceException("用户已删除或无效");
+ }
+ return user;
+ }
+
+ /** 请求为整数积分,与库表 decimal 对接(无小数) */
+ private static BigDecimal toAmountBigDecimal(Long amount) {
+ if (amount == null || amount < 1) {
+ throw new ServiceException("积分必须为正整数");
+ }
+ return BigDecimal.valueOf(amount.longValue());
+ }
+
+ private String buildRemark(String input, String defaultPrefix) {
+ if (StringUtils.isNotEmpty(input)) {
+ return input;
+ }
+ return defaultPrefix;
+ }
+
+ private BigDecimal getDeptBalance(Long deptId) {
+ SysDept dept = deptService.selectDeptById(deptId);
+ if (dept == null) {
+ throw new ServiceException("部门不存在");
+ }
+ return dept.getBalance() == null ? BigDecimal.ZERO : dept.getBalance();
+ }
+
+ private void insertGroupRecord(String orderNum, Long deptId, int type, BigDecimal signedChange,
+ BigDecimal resultBalance, String remark) {
+ AiGroupBalanceChangeRecord record = new AiGroupBalanceChangeRecord();
+ record.setRelationOrderNo(orderNum);
+ record.setDeptId(deptId);
+ record.setType(type);
+ record.setChangeAmount(signedChange);
+ record.setResultAmount(resultBalance);
+ record.setRemark(remark);
+ record.setCreateBy(SecurityUtils.getUserId());
+ aiGroupBalanceChangeRecordService.insert(record);
+ }
+
+ private static String buildOrderNum() {
+ String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+ String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
+ return "DU" + dateTime + uuid;
+ }
+}
diff --git a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml
index 4272cde..b01f074 100644
--- a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml
+++ b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml
@@ -173,4 +173,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id}
+
+
\ No newline at end of file
diff --git a/web-api/sql/ai_user_dept_score_menu.sql b/web-api/sql/ai_user_dept_score_menu.sql
new file mode 100644
index 0000000..24c5125
--- /dev/null
+++ b/web-api/sql/ai_user_dept_score_menu.sql
@@ -0,0 +1,61 @@
+-- =============================================================================
+-- AI用户管理 下级权限:下放积分、回收积分(菜单类型 F,挂在「AI用户管理」下)
+-- 仅拥有对应 perms 的角色在页面可看到按钮(v-hasPermi)
+-- 可重复执行:已存在相同 perms 时跳过插入
+-- =============================================================================
+
+-- 父菜单:AI用户管理(C 菜单,component 一般为 ai/user/index)
+SET @ai_user_menu_id := (
+ SELECT menu_id FROM sys_menu
+ WHERE menu_type = 'C'
+ AND (perms = 'ai:user:list' OR perms LIKE 'ai:user:list')
+ AND (path = 'aiUser' OR component = 'ai/user/index' OR component LIKE '%ai/user/index%')
+ ORDER BY menu_id
+ LIMIT 1
+);
+
+-- 若未找到父菜单,后续 INSERT 会失败,请先核对 sys_menu 中 AI 用户页配置
+INSERT INTO sys_menu (
+ menu_name, parent_id, order_num, path, component, `query`, route_name,
+ is_frame, is_cache, menu_type, visible, status, perms, icon,
+ create_by, create_time, remark
+)
+SELECT
+ '下放积分', @ai_user_menu_id, 1, '', '', NULL, '',
+ 1, 0, 'F', '0', '0', 'ai:user:deptScoreIssue', '#',
+ 'admin', NOW(), '部门积分下放至用户(sys_dept.balance → ai_user.balance)'
+FROM (SELECT 1) AS _x
+WHERE @ai_user_menu_id IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'ai:user:deptScoreIssue');
+
+INSERT INTO sys_menu (
+ menu_name, parent_id, order_num, path, component, `query`, route_name,
+ is_frame, is_cache, menu_type, visible, status, perms, icon,
+ create_by, create_time, remark
+)
+SELECT
+ '回收积分', @ai_user_menu_id, 2, '', '', NULL, '',
+ 1, 0, 'F', '0', '0', 'ai:user:deptScoreReclaim', '#',
+ 'admin', NOW(), '用户积分回收至部门(需无未完结订单)'
+FROM (SELECT 1) AS _y
+WHERE @ai_user_menu_id IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'ai:user:deptScoreReclaim');
+
+-- 已勾选「AI用户管理」菜单的角色,自动勾选上述两个按钮(避免角色树里看不到或未授权)
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, m.menu_id
+FROM sys_role_menu rm
+JOIN sys_menu m ON m.perms = 'ai:user:deptScoreIssue'
+WHERE rm.menu_id = @ai_user_menu_id
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id
+ );
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, m.menu_id
+FROM sys_role_menu rm
+JOIN sys_menu m ON m.perms = 'ai:user:deptScoreReclaim'
+WHERE rm.menu_id = @ai_user_menu_id
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id
+ );
diff --git a/web-api/sql/aisql.sql b/web-api/sql/aisql.sql
index 8025d5f..9833dc0 100644
--- a/web-api/sql/aisql.sql
+++ b/web-api/sql/aisql.sql
@@ -4676,6 +4676,9 @@ INSERT INTO `sys_menu` VALUES (2001, 'AI用户管理', 2003, 1, 'aiUser', 'ai/us
INSERT INTO `sys_menu` VALUES (2002, 'AI管理', 2000, 4, 'aiManager', 'ai/manager/index', NULL, '', 1, 0, 'C', '0', '0', 'ai:manager:list', 'online', 'admin', '2025-11-13 19:18:54', 'admin', '2025-11-13 22:49:28', '');
INSERT INTO `sys_menu` VALUES (2003, '用户管理', 2000, 1, 'aiUserManger', NULL, NULL, '', 1, 0, 'M', '0', '0', NULL, 'peoples', 'admin', '2025-11-13 22:18:42', '', NULL, '');
INSERT INTO `sys_menu` VALUES (2004, '余额使用记录', 2003, 2, 'balanceChangeRecord', 'ai/balanceChangeRecord/index', NULL, '', 1, 0, 'C', '0', '0', 'ai:balanceChangeRecord:list', 'nested', 'admin', '2025-11-13 22:31:42', '', NULL, '');
+-- AI用户管理(2001)下级按钮:下放/回收积分(权限 ai:user:deptScoreIssue / ai:user:deptScoreReclaim);已存在时可跳过或执行 sql/ai_user_dept_score_menu.sql
+INSERT INTO `sys_menu` VALUES (2070, '下放积分', 2001, 3, '', '', NULL, '', 1, 0, 'F', '0', '0', 'ai:user:deptScoreIssue', '#', 'admin', '2025-11-13 22:31:42', '', NULL, '部门积分下放至用户');
+INSERT INTO `sys_menu` VALUES (2071, '回收积分', 2001, 4, '', '', NULL, '', 1, 0, 'F', '0', '0', 'ai:user:deptScoreReclaim', '#', 'admin', '2025-11-13 22:31:42', '', NULL, '用户积分回收至部门');
INSERT INTO `sys_menu` VALUES (2005, '返佣记录', 2003, 2, 'rebateRecord', 'ai/rebateRecord/index', NULL, '', 1, 0, 'C', '0', '0', 'ai:rebateRecord:list', 'monitor', 'admin', '2025-11-13 22:32:39', '', NULL, '');
INSERT INTO `sys_menu` VALUES (2006, '订单记录', 2000, 5, 'order', 'ai/order/index', NULL, '', 1, 0, 'C', '0', '0', 'ai:order:list', 'server', 'admin', '2025-11-13 22:36:36', 'admin', '2025-11-29 16:12:14', '');
INSERT INTO `sys_menu` VALUES (2007, '充值记录', 2000, 6, 'recharge', 'ai/recharge/index', NULL, '', 1, 0, 'C', '0', '0', 'ai:recharge:list', 'number', 'admin', '2025-11-13 22:38:06', 'admin', '2025-11-13 22:50:05', '');