diff --git a/admin-ui/src/api/ai/user.js b/admin-ui/src/api/ai/user.js index 389afbc..4077d9f 100644 --- a/admin-ui/src/api/ai/user.js +++ b/admin-ui/src/api/ai/user.js @@ -56,18 +56,18 @@ export function changeUserStatus(id, status) { }) } -export function changeBalance(id, balance) { - const data = { - id, - balance - } - return request({ - url: '/ai/user/changeBalance', - method: 'put', - data: data - }) -} - +// 修改余额接口已关闭(与后端 AiUserController.changeBalance 一致停用) +// export function changeBalance(id, balance) { +// const data = { +// id, +// balance +// } +// return request({ +// url: '/ai/user/changeBalance', +// method: 'put', +// data: data +// }) +// } // 用户状态修改 export function updatePassword(id, newPassword) { @@ -90,3 +90,21 @@ export function assignAiUserDept(data) { data }) } + +/** 部门积分下放至用户 */ +export function issueDeptScore(data) { + return request({ + url: '/ai/user/dept-score/issue', + method: 'put', + data + }) +} + +/** 用户积分回收至部门 */ +export function reclaimDeptScore(data) { + return request({ + url: '/ai/user/dept-score/reclaim', + method: 'put', + data + }) +} diff --git a/admin-ui/src/views/ai/user/index.vue b/admin-ui/src/views/ai/user/index.vue index 1465e0d..3f35b1b 100644 --- a/admin-ui/src/views/ai/user/index.vue +++ b/admin-ui/src/views/ai/user/index.vue @@ -106,8 +106,10 @@ + @@ -135,7 +137,7 @@ - + @@ -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', '');