feat: AI用户 - 下放/回收积分,去掉设置余额功能。恢复AiDeptController

This commit is contained in:
yys 2026-04-20 11:55:32 +08:00
parent c92f531f1b
commit db2052b9fd
16 changed files with 648 additions and 58 deletions

View File

@ -56,18 +56,18 @@ export function changeUserStatus(id, status) {
}) })
} }
export function changeBalance(id, balance) { // 修改余额接口已关闭(与后端 AiUserController.changeBalance 一致停用)
const data = { // export function changeBalance(id, balance) {
id, // const data = {
balance // id,
} // balance
return request({ // }
url: '/ai/user/changeBalance', // return request({
method: 'put', // url: '/ai/user/changeBalance',
data: data // method: 'put',
}) // data: data
} // })
// }
// 用户状态修改 // 用户状态修改
export function updatePassword(id, newPassword) { export function updatePassword(id, newPassword) {
@ -90,3 +90,21 @@ export function assignAiUserDept(data) {
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
})
}

View File

@ -106,8 +106,10 @@
<el-table-column label="主键ID" align="center" prop="id" width="90" /> <el-table-column label="主键ID" align="center" prop="id" width="90" />
<el-table-column label="用户ID" align="center" prop="userId" /> <el-table-column label="用户ID" align="center" prop="userId" />
<el-table-column label="用户账号" align="center" prop="username" /> <el-table-column label="用户账号" align="center" prop="username" />
<!--
<el-table-column label="上级ID" align="center" prop="superiorUuid" /> <el-table-column label="上级ID" align="center" prop="superiorUuid" />
<el-table-column label="上级账号" align="center" prop="superiorName" /> <el-table-column label="上级账号" align="center" prop="superiorName" />
-->
<el-table-column label="用户昵称" align="center" prop="nickname" /> <el-table-column label="用户昵称" align="center" prop="nickname" />
<el-table-column label="归属部门" align="center" prop="deptName" width="120" show-overflow-tooltip /> <el-table-column label="归属部门" align="center" prop="deptName" width="120" show-overflow-tooltip />
<el-table-column label="邮箱" align="center" prop="email" /> <el-table-column label="邮箱" align="center" prop="email" />
@ -135,7 +137,7 @@
</el-table-column> </el-table-column>
<el-table-column label="余额" align="center" prop="balance" /> <el-table-column label="余额" align="center" prop="balance" />
<el-table-column label="source" align="center" prop="source" /> <el-table-column label="source" align="center" prop="source" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="310"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="460">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button <el-button
size="mini" size="mini"
@ -151,6 +153,21 @@
@click="updatePassword(scope.row)" @click="updatePassword(scope.row)"
v-hasPermi="['ai:user:edit']" v-hasPermi="['ai:user:edit']"
>修改密码</el-button> >修改密码</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-download"
@click="openDeptScoreDialog(scope.row, 'issue')"
v-hasPermi="['ai:user:deptScoreIssue']"
>下放积分</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-upload2"
@click="openDeptScoreDialog(scope.row, 'reclaim')"
v-hasPermi="['ai:user:deptScoreReclaim']"
>回收积分</el-button>
<!-- 修改余额功能已关闭
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@ -158,6 +175,7 @@
@click="updateBalance(scope.row)" @click="updateBalance(scope.row)"
v-hasPermi="['ai:user:remove']" v-hasPermi="['ai:user:remove']"
>修改余额</el-button> >修改余额</el-button>
-->
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@ -263,7 +281,47 @@
</div> </div>
</el-dialog> </el-dialog>
<!-- 修改余额对话框 --> <!-- 部门积分下放 / 回收 -->
<el-dialog
:title="deptScoreMode === 'issue' ? '下放积分' : '回收积分'"
:visible.sync="deptScoreOpen"
width="480px"
append-to-body
@close="cancelDeptScore"
>
<el-form ref="deptScoreFormRef" :model="deptScoreForm" :rules="deptScoreRules" label-width="88px">
<el-form-item label="用户账号">
<span>{{ deptScoreForm.username }}</span>
</el-form-item>
<el-form-item label="积分数量" prop="amount">
<el-input-number
v-model="deptScoreForm.amount"
:min="1"
:max="100000000"
:precision="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="deptScoreForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitDeptScore"> </el-button>
<el-button @click="cancelDeptScore"> </el-button>
</div>
</el-dialog>
<!-- 修改余额功能已关闭
<el-dialog :title="title" :visible.sync="openUpdateBalance" width="500px" append-to-body> <el-dialog :title="title" :visible.sync="openUpdateBalance" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="余额" prop="balance"> <el-form-item label="余额" prop="balance">
@ -276,6 +334,7 @@
<el-button @click="cancelBalance"> </el-button> <el-button @click="cancelBalance"> </el-button>
</div> </div>
</el-dialog> </el-dialog>
-->
</div> </div>
</template> </template>
@ -286,10 +345,11 @@ import {
delUser, delUser,
addUser, addUser,
updateUser, updateUser,
changeBalance,
changeUserStatus, changeUserStatus,
updatePassword, updatePassword,
assignAiUserDept assignAiUserDept,
issueDeptScore,
reclaimDeptScore
} from "@/api/ai/user"; } from "@/api/ai/user";
import { listDept } from "@/api/ai/dept"; import { listDept } from "@/api/ai/dept";
import Treeselect from "@riophae/vue-treeselect"; import Treeselect from "@riophae/vue-treeselect";
@ -320,7 +380,27 @@ export default {
// //
open: false, open: false,
openUpdatePassword: 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: "请输入 1100000000 的整数",
trigger: "blur"
}
],
remark: [{ max: 50, message: "备注最多50个字", trigger: "blur" }]
},
assignDeptOpen: false, assignDeptOpen: false,
deptOptions: [], deptOptions: [],
assignForm: { assignForm: {
@ -337,7 +417,7 @@ export default {
phone: null, phone: null,
password: null, password: null,
openid: null, openid: null,
status: null, status: "0",
email: null, email: null,
birthday: null, birthday: null,
invitationCode: null, invitationCode: null,
@ -417,9 +497,9 @@ export default {
case "updatePassword": case "updatePassword":
this.updatePassword(row); this.updatePassword(row);
break; break;
case "updateBalance": // case "updateBalance":
this.updateBalance(row); // this.updateBalance(row);
break; // break;
default: default:
break; break;
} }
@ -437,15 +517,62 @@ export default {
}); });
return; return;
}, },
updateBalance(row) { openDeptScoreDialog(row, mode) {
this.reset(); if (!row.deptId) {
const id = row.id || this.ids; this.$modal.msgWarning("请先分配归属部门后再操作积分");
getUser(id).then(response => { return;
this.form = response.data; }
this.openUpdateBalance = true; this.deptScoreMode = mode;
this.title = "修改余额"; 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("请输入 1100000000 的整数积分");
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-用户信息列表 */ /** 查询ai-用户信息列表 */
getList() { getList() {
this.loading = true; this.loading = true;
@ -475,10 +602,10 @@ export default {
this.open = false; this.open = false;
this.reset(); this.reset();
}, },
cancelBalance() { // cancelBalance() {
this.openUpdateBalance = false; // this.openUpdateBalance = false;
this.reset(); // this.reset();
}, // },
cancelPassword() { cancelPassword() {
this.openUpdatePassword = false; this.openUpdatePassword = false;
this.reset(); this.reset();
@ -567,16 +694,16 @@ export default {
} }
}); });
}, },
handleChangeBalance() { // handleChangeBalance() {
changeBalance; // this.$refs["form"].validate(valid => {
this.$refs["form"].validate(valid => { // if (!valid) return;
changeBalance(this.form.id, this.form.balance).then(response => { // changeBalance(this.form.id, this.form.balance).then(() => {
this.$modal.msgSuccess("修改成功"); // this.$modal.msgSuccess("");
this.openUpdateBalance = false; // this.openUpdateBalance = false;
this.getList(); // this.getList();
}); // });
}); // });
}, // },
/** 删除按钮操作 */ /** 删除按钮操作 */
handleDelete(row) { handleDelete(row) {
const ids = row && row.id != null ? row.id : this.ids; const ids = row && row.id != null ? row.id : this.ids;

View File

@ -26,8 +26,8 @@ import com.ruoyi.system.service.ISysDeptService;
/** /**
* AI 业务侧部门管理数据源与系统部门 sys_dept 一致权限独立 * AI 业务侧部门管理数据源与系统部门 sys_dept 一致权限独立
*/ */
//@RestController @RestController
//@RequestMapping("/ai/dept") @RequestMapping("/ai/dept")
public class AiDeptController extends BaseController public class AiDeptController extends BaseController
{ {
@Autowired @Autowired

View File

@ -1,9 +1,8 @@
package com.ruoyi.web.controller.ai; package com.ruoyi.web.controller.ai;
import cn.hutool.core.util.NumberUtil;
import com.ruoyi.ai.service.IAiUserService; import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.ai.service.IDeptUserScoreTransferService;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.constant.BalanceChangerConstants;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AiUser; 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 org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.math.BigDecimal; import javax.validation.Valid;
import java.text.SimpleDateFormat;
import java.util.Date; import com.ruoyi.common.core.request.ai.AiUserDeptScoreRequest;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* ai-用户信息Controller * ai-用户信息Controller
@ -36,6 +34,9 @@ public class AiUserController extends BaseController {
@Autowired @Autowired
private IAiUserService aiUserService; private IAiUserService aiUserService;
@Autowired
private IDeptUserScoreTransferService deptUserScoreTransferService;
/** /**
* 查询ai-用户信息列表 * 查询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')") @PreAuthorize("@ss.hasPermi('ai:user:edit')")
@Log(title = "ai-用户信息", businessType = BusinessType.UPDATE) @Log(title = "ai-用户信息", businessType = BusinessType.UPDATE)
@PutMapping("/changeBalance") @PutMapping("/changeBalance")
@ -130,14 +132,15 @@ public class AiUserController extends BaseController {
aiUser.setUpdateBy(getUsername()); aiUser.setUpdateBy(getUsername());
// 获取原余额 // 获取原余额
AiUser u = aiUserService.selectAiUserById(aiUser.getId()); AiUser u = aiUserService.selectAiUserById(aiUser.getId());
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8); String uuid = java.util.UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date()); String dateTime = new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date());
String orderNo = dateTime + uuid; String orderNo = dateTime + uuid;
// 计算变更余额 // 计算变更余额
BigDecimal amount = NumberUtil.sub(aiUser.getBalance(), u.getBalance()); java.math.BigDecimal amount = cn.hutool.core.util.NumberUtil.sub(aiUser.getBalance(), u.getBalance());
aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION); aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, com.ruoyi.common.constant.BalanceChangerConstants.SYSTEM_OPERATION);
return toAjax(1); return toAjax(1);
} }
*/
/** /**
* 状态密码 * 状态密码
@ -149,6 +152,30 @@ public class AiUserController extends BaseController {
return toAjax(aiUserService.updatePassword(aiUser)); 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-用户信息 * 删除ai-用户信息
*/ */

View File

@ -73,4 +73,14 @@ public class BalanceChangerConstants {
*/ */
public static final int SYSTEM_OPERATION = 11; 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;
} }

View File

@ -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 用户余额互转下放 / 回收
* <p>积分为整数落库仍用 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;
}

View File

@ -11,7 +11,7 @@ public enum GroupBalanceChangeType {
RECHARGE(0, "充值"), RECHARGE(0, "充值"),
REFUND(1, "退款"), REFUND(1, "退款"),
ISSUE(2, "下发"), ISSUE(2, "下发"),
CONSUME(3, "消费"), RECLAIM(3, "回收"),
MANUAL_ADJUST(4, "手动修改"); MANUAL_ADJUST(4, "手动修改");
private final int code; private final int code;

View File

@ -29,6 +29,13 @@
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<!-- 与 ruoyi-framework 对齐,供部门-用户积分下放/回收 Redisson 锁使用 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.7</version>
</dependency>
<!-- <dependency>--> <!-- <dependency>-->
<!-- <groupId>com.byteplus</groupId>--> <!-- <groupId>com.byteplus</groupId>-->

View File

@ -51,7 +51,7 @@ public class AiBalanceChangeRecord extends BaseEntity {
private String uuid; 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 = "操作类型") @Excel(name = "操作类型")
private Integer type; private Integer type;

View File

@ -30,4 +30,9 @@ public interface AiOrderMapper extends BaseMapper<AiOrder> {
@Select("SELECT * FROM ai_order WHERE third_party_order_num = #{id} LIMIT 1") @Select("SELECT * FROM ai_order WHERE third_party_order_num = #{id} LIMIT 1")
AiOrder selectOneByThirdPartyOrderNum(@Param("id") String id); AiOrder selectOneByThirdPartyOrderNum(@Param("id") String id);
/**
* 未完结订单数量del_flag=0 status 非已完成(1)非失败(2)即进行中(0)
*/
int countOpenOrdersByUserId(@Param("userId") Long userId);
} }

View File

@ -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);
}

View File

@ -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();
}
}
}
}

View File

@ -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;
}
}

View File

@ -173,4 +173,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id} #{id}
</foreach> </foreach>
</delete> </delete>
<select id="countOpenOrdersByUserId" resultType="int">
select count(1) from ai_order
where user_id = #{userId}
and del_flag = '0'
and status=0
</select>
</mapper> </mapper>

View File

@ -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
);

View File

@ -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 (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 (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, ''); 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 (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 (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', ''); 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', '');