feat(device): 实现设备与账号绑定管理机制

- 引入 ClientAccountDevice 表管理设备与账号绑定关系
- 重构设备注册逻辑,支持多账号绑定同一设备
- 新增设备配额检查,基于账号维度限制设备数量
-优化设备移除逻辑,仅解除绑定而非物理删除- 改进设备列表查询,通过账号ID关联获取设备信息
- 更新心跳任务,支持向设备绑定的所有账号发送心跳
- 调整设备API参数,增加username字段用于权限校验
-修复HTTP请求编码问题,统一使用UTF-8字符集
- 增强错误处理,携带错误码信息便于前端识别
- 移除设备表中的username字段,解耦设备与用户名关联
This commit is contained in:
2025-10-22 09:51:55 +08:00
parent 901d67d2dc
commit 17b6a7b9f9
29 changed files with 589 additions and 277 deletions

View File

@@ -31,6 +31,8 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.ruoyi.web.sse.SseHubService;
import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.system.domain.ClientDevice;
import com.ruoyi.system.mapper.ClientAccountDeviceMapper;
import com.ruoyi.system.domain.ClientAccountDevice;
/**
@@ -56,14 +58,18 @@ public class ClientAccountController extends BaseController {
private SseHubService sseHubService;
@Autowired
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private ClientAccountDeviceMapper accountDeviceMapper;
private AjaxResult checkDeviceLimit(String username, String deviceId, int deviceLimit) {
List<ClientDevice> userDevices = clientDeviceMapper.selectByUsername(username);
int userDevice = userDevices.size();
boolean exists = userDevices.stream().anyMatch(d -> deviceId.equals(d.getDeviceId()));
if (exists) userDevice--;
if (userDevice >= deviceLimit) {
return AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
private AjaxResult checkDeviceLimit(Long accountId, String deviceId, int deviceLimit) {
int activeDeviceCount = accountDeviceMapper.countActiveDevicesByAccountId(accountId);
ClientAccountDevice binding = accountDeviceMapper.selectByAccountIdAndDeviceId(accountId, deviceId);
boolean exists = (binding != null && "active".equals(binding.getStatus()));
if (exists) activeDeviceCount--;
if (activeDeviceCount >= deviceLimit) {
AjaxResult result = AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
result.put("code", 501);
return result;
}
return null;
}
@@ -150,8 +156,10 @@ public class ClientAccountController extends BaseController {
return AjaxResult.error("账号已被停用");
}
AjaxResult limitCheck = checkDeviceLimit(username, clientId, account.getDeviceLimit());
// 检查设备限制
AjaxResult limitCheck = checkDeviceLimit(account.getId(), clientId, account.getDeviceLimit());
if (limitCheck != null) return limitCheck;
String token = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
.setSubject(username)
@@ -163,25 +171,16 @@ public class ClientAccountController extends BaseController {
.signWith(SignatureAlgorithm.RS256, jwtRsaKeyService.getPrivateKey())
.compact();
boolean deviceTrialExpired = false;
if ("trial".equals(account.getAccountType())) {
ClientDevice device = clientDeviceMapper.selectByDeviceIdAndUsername(clientId, username);
deviceTrialExpired = device != null
&& device.getTrialExpireTime() != null
&& new Date().after(device.getTrialExpireTime());
}
return AjaxResult.success(Map.of(
"token", token,
"permissions", account.getPermissions(),
"accountName", account.getAccountName(),
"expireTime", account.getExpireTime(),
"accountType", account.getAccountType(),
"deviceTrialExpired", deviceTrialExpired
"accountType", account.getAccountType()
));
}
/**
* 验证token
* 验证token
*/
@PostMapping("/verify")
public AjaxResult verifyToken(@RequestBody Map<String, String> data) {
@@ -204,9 +203,10 @@ public class ClientAccountController extends BaseController {
clientAccountService.updateClientAccount(account);
}
// 只有试用账号才检查设备试用期
boolean deviceTrialExpired = false;
if ("trial".equals(account.getAccountType())) {
ClientDevice device = clientDeviceMapper.selectByDeviceIdAndUsername(clientId, username);
ClientDevice device = clientDeviceMapper.selectByDeviceId(clientId);
deviceTrialExpired = device != null
&& device.getTrialExpireTime() != null
&& new Date().after(device.getTrialExpireTime());
@@ -233,7 +233,6 @@ public class ClientAccountController extends BaseController {
if (username == null || tokenClientId == null || !tokenClientId.equals(clientId)) {
throw new RuntimeException("会话不匹配");
}
SseEmitter emitter = sseHubService.register(username, clientId, 0L);
try {
emitter.send(SseEmitter.event().data("{\"type\":\"ready\"}"));
@@ -260,6 +259,7 @@ public class ClientAccountController extends BaseController {
clientAccount.setPermissions("{\"amazon\":true,\"rakuten\":true,\"zebra\":true}");
clientAccount.setPassword(passwordEncoder.encode(password));
clientAccount.setAccountType("trial");
clientAccount.setDeviceLimit(3);
clientAccount.setExpireTime(new Date(System.currentTimeMillis() + 3 * 24L * 60 * 60 * 1000));
int result = clientAccountService.insertClientAccount(clientAccount);
@@ -273,6 +273,7 @@ public class ClientAccountController extends BaseController {
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION))
.claim("accountId", clientAccount.getId())
.claim("username", username)
.claim("clientId", deviceId)
.signWith(SignatureAlgorithm.RS256, jwtRsaKeyService.getPrivateKey())
.compact();
@@ -282,8 +283,7 @@ public class ClientAccountController extends BaseController {
"permissions", clientAccount.getPermissions(),
"accountName", clientAccount.getAccountName(),
"expireTime", clientAccount.getExpireTime(),
"accountType", clientAccount.getAccountType(),
"deviceTrialExpired", false
"accountType", clientAccount.getAccountType()
));
}

View File

@@ -6,155 +6,124 @@ import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.system.mapper.ClientAccountMapper;
import com.ruoyi.system.domain.ClientAccount;
import com.ruoyi.web.sse.SseHubService;
import com.ruoyi.system.mapper.ClientAccountDeviceMapper;
import com.ruoyi.system.domain.ClientAccountDevice;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.Date;
@RestController
@RequestMapping("/monitor/device")
@Anonymous
public class ClientDeviceController {
@Autowired
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private ClientAccountMapper clientAccountMapper;
@Autowired
private SseHubService sseHubService;
private AjaxResult checkDeviceLimit(String username, String deviceId) {
ClientAccount account = clientAccountMapper.selectClientAccountByUsername(username);
int deviceLimit = (account != null && account.getDeviceLimit() != null) ? account.getDeviceLimit() : 3;
List<ClientDevice> userDevices = clientDeviceMapper.selectByUsername(username);
int userDevice = userDevices.size();
boolean deviceExists = userDevices.stream().anyMatch(d -> deviceId.equals(d.getDeviceId()));
if (deviceExists) userDevice--;
if (userDevice >= deviceLimit) {
return AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
}
return null;
@Autowired private ClientDeviceMapper deviceMapper;
@Autowired private ClientAccountMapper accountMapper;
@Autowired private ClientAccountDeviceMapper accountDeviceMapper;
@Autowired private SseHubService sseHubService;
private ClientAccount getAccount(String username) {
return accountMapper.selectClientAccountByUsername(username);
}
private boolean exceedDeviceLimit(Long accountId, String deviceId, int limit) {
int count = accountDeviceMapper.countActiveDevicesByAccountId(accountId);
ClientAccountDevice binding = accountDeviceMapper.selectByAccountIdAndDeviceId(accountId, deviceId);
if (binding != null && "active".equals(binding.getStatus())) count--;
return count >= limit;
}
@GetMapping("/quota")
public AjaxResult quota(@RequestParam(value = "username", required = false) String username) {
List<ClientDevice> all = clientDeviceMapper.selectByUsername(username);
int used = 0;
for (ClientDevice d : all) {
if (!"removed".equals(d.getStatus())) used++;
}
ClientAccount account = clientAccountMapper.selectClientAccountByUsername(username);
int limit = (account != null && account.getDeviceLimit() != null) ? account.getDeviceLimit() : 3;
public AjaxResult quota(@RequestParam String username) {
ClientAccount account = getAccount(username);
int used = accountDeviceMapper.countActiveDevicesByAccountId(account.getId());
int limit = account.getDeviceLimit() != null ? account.getDeviceLimit() : 3;
return AjaxResult.success(Map.of("limit", limit, "used", used));
}
/**
* 按用户名查询设备列表(最近活动优先)
* @param username 用户名,必需参数
* @return 设备列表
*/
@GetMapping("/list")
public AjaxResult list(@RequestParam("username") String username) {
List<ClientDevice> list = clientDeviceMapper.selectByUsername(username);
java.util.ArrayList<ClientDevice> active = new java.util.ArrayList<>();
for (ClientDevice d : list) {
if (!"removed".equals(d.getStatus())) active.add(d);
}
return AjaxResult.success(active);
public AjaxResult list(@RequestParam String username) {
return AjaxResult.success(accountDeviceMapper.selectDevicesByAccountId(getAccount(username).getId()));
}
/**
* 设备注册
*/
@PostMapping("/register")
public AjaxResult register(@RequestBody ClientDevice device, HttpServletRequest request) {
String ip = device.getIp();
String username = device.getUsername();
String deviceId = device.getDeviceId();
String os = device.getOs();
String deviceName = username + "@" + ip + " (" + os + ")";
AjaxResult limitCheck = checkDeviceLimit(username, deviceId);
if (limitCheck != null) return limitCheck;
ClientDevice exists = clientDeviceMapper.selectByDeviceIdAndUsername(deviceId, username);
if (exists == null) {
device.setIp(ip);
device.setStatus("online");
device.setLastActiveAt(new java.util.Date());
device.setTrialExpireTime(new java.util.Date(System.currentTimeMillis() + 3 * 24L * 60 * 60 * 1000));
device.setName(deviceName);
clientDeviceMapper.insert(device);
} else {
exists.setName(deviceName);
exists.setOs(os);
exists.setStatus("online");
exists.setIp(ip);
exists.setLocation(device.getLocation());
exists.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
public AjaxResult register(@RequestBody Map<String, String> data) {
String username = data.get("username");
String deviceId = data.get("deviceId");
ClientAccount account = getAccount(username);
int limit = account.getDeviceLimit() != null ? account.getDeviceLimit() : 3;
if (exceedDeviceLimit(account.getId(), deviceId, limit)) {
return AjaxResult.error("设备数量已达上限(" + limit + "个)");
}
ClientDevice device = deviceMapper.selectByDeviceId(deviceId);
Date now = new Date();
if (device == null) {
device = new ClientDevice();
device.setDeviceId(deviceId);
device.setName(username + "@" + data.get("ip") + " (" + data.get("os") + ")");
device.setOs(data.get("os"));
device.setIp(data.get("ip"));
device.setLocation(data.get("location"));
device.setTrialExpireTime(new Date(System.currentTimeMillis() + 3 * 24L * 60 * 60 * 1000));
deviceMapper.insert(device);
}
device.setStatus("online");
device.setLastActiveAt(now);
device.setIp(data.get("ip"));
device.setLocation(data.get("location"));
deviceMapper.updateByDeviceId(device);
ClientAccountDevice binding = accountDeviceMapper.selectByAccountIdAndDeviceId(account.getId(), deviceId);
if (binding == null) {
binding = new ClientAccountDevice();
binding.setAccountId(account.getId());
binding.setDeviceId(deviceId);
binding.setBindTime(now);
binding.setStatus("active");
accountDeviceMapper.insert(binding);
} else if ("removed".equals(binding.getStatus())) {
accountDeviceMapper.updateStatus(account.getId(), deviceId, "active");
}
return AjaxResult.success();
}
/**
* 重命名设备
*
* 根据 deviceId 更新 name。
*/
@PostMapping("/rename")
public AjaxResult rename(@RequestBody ClientDevice device) {
clientDeviceMapper.updateByDeviceId(device);
deviceMapper.updateByDeviceId(device);
return AjaxResult.success();
}
/**
* 修改设备试用期过期时间
*/
@PostMapping("/updateExpire")
public AjaxResult updateExpire(@RequestBody ClientDevice device) {
clientDeviceMapper.updateByDeviceIdAndUsername(device);
return AjaxResult.success();
}
/**
* 移除设备
* 根据 deviceId 删除设备绑定记录。
*/
@PostMapping("/remove")
public AjaxResult remove(@RequestBody Map<String, String> body) {
String deviceId = body.get("deviceId");
String username = body.get("username");
if (deviceId == null || deviceId.isEmpty()) {
return AjaxResult.error("deviceId不能为空");
}
ClientDevice exists = clientDeviceMapper.selectByDeviceIdAndUsername(deviceId, username);
if (exists == null) {
return AjaxResult.success();
}
if (!"removed".equals(exists.getStatus())) {
exists.setStatus("removed");
exists.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
sseHubService.sendEvent(username, deviceId, "DEVICE_REMOVED", "{}");
sseHubService.disconnectDevice(username, deviceId);
}
deviceMapper.updateByDeviceId(device);
return AjaxResult.success();
}
@PostMapping("/remove")
public AjaxResult remove(@RequestBody Map<String, String> data) {
String deviceId = data.get("deviceId");
String username = data.get("username");
Long accountId = getAccount(username).getId();
accountDeviceMapper.updateStatus(accountId, deviceId, "removed");
sseHubService.sendEvent(username, deviceId, "DEVICE_REMOVED", "{}");
sseHubService.disconnectDevice(username, deviceId);
return AjaxResult.success();
}
/**
* 设备离线
*/
@PostMapping("/offline")
public AjaxResult offline(@RequestBody Map<String, String> body) {
String deviceId = body.get("deviceId");
String username = body.get("username");
ClientDevice device = clientDeviceMapper.selectByDeviceIdAndUsername(deviceId, username);
public AjaxResult offline(@RequestBody Map<String, String> data) {
ClientDevice device = deviceMapper.selectByDeviceId(data.get("deviceId"));
if (device != null) {
device.setStatus("offline");
device.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceIdAndUsername(device);
device.setLastActiveAt(new Date());
deviceMapper.updateByDeviceId(device);
}
return AjaxResult.success();
}
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.web.controller.tool;
import java.util.Date;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
@@ -8,6 +9,9 @@ import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.system.domain.BanmaAccount;
import com.ruoyi.system.domain.ClientAccount;
import com.ruoyi.system.mapper.BanmaAccountMapper;
import com.ruoyi.system.mapper.ClientAccountMapper;
import com.ruoyi.system.service.IBanmaAccountService;
/**
@@ -20,11 +24,32 @@ public class BanmaOrderController extends BaseController {
@Autowired
private IBanmaAccountService accountService;
@Autowired
private ClientAccountMapper clientAccountMapper;
@Autowired
private BanmaAccountMapper banmaAccountMapper;
@GetMapping("/accounts")
public R<?> listAccounts(String name) {
return R.ok(accountService.listSimple(name));
}
@GetMapping("/account-limit")
public R<?> getAccountLimit(String name) {
int limit = 1;
int count = 0;
if (name != null) {
ClientAccount client = clientAccountMapper.selectClientAccountByUsername(name);
if (client != null && "paid".equals(client.getAccountType())
&& client.getExpireTime() != null && new Date().before(client.getExpireTime())) {
limit = 3;
}
BanmaAccount query = new BanmaAccount();
query.setClientUsername(name);
count = banmaAccountMapper.selectList(query).size();
}
return R.ok(Map.of("limit", limit, "count", count));
}
@PostMapping("/accounts")
public R<?> saveAccount(@RequestBody BanmaAccount body, String name) {
if (body.getId() == null && accountService.validateAndGetToken(body.getUsername(), body.getPassword()) == null) {

View File

@@ -124,13 +124,6 @@ public class SseHubService {
try {
ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId);
if (device != null) {
if ("removed".equals(device.getStatus()) && "offline".equals(status)) {
return;
}
if ("removed".equals(status)) {
disconnectDevice(device.getUsername(), deviceId);
}
device.setStatus(status);
device.setLastActiveAt(new Date());
clientDeviceMapper.updateByDeviceId(device);

View File

@@ -2,6 +2,9 @@ package com.ruoyi.web.task;
import com.ruoyi.system.domain.ClientDevice;
import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.system.mapper.ClientAccountDeviceMapper;
import com.ruoyi.system.mapper.ClientAccountMapper;
import com.ruoyi.system.domain.ClientAccount;
import com.ruoyi.web.sse.SseHubService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
@@ -20,6 +23,12 @@ public class DeviceHeartbeatTask {
@Autowired
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private ClientAccountDeviceMapper accountDeviceMapper;
@Autowired
private ClientAccountMapper accountMapper;
@Autowired
private SseHubService sseHubService;
@@ -30,7 +39,15 @@ public class DeviceHeartbeatTask {
public void sendHeartbeatPing() {
List<ClientDevice> onlineDevices = clientDeviceMapper.selectOnlineDevices();
for (ClientDevice device : onlineDevices) {
sseHubService.sendPing(device.getUsername(), device.getDeviceId());
String deviceId = device.getDeviceId();
// 查询该设备绑定的所有账号
List<Long> accountIds = accountDeviceMapper.selectAccountIdsByDeviceId(deviceId);
for (Long accountId : accountIds) {
ClientAccount account = accountMapper.selectClientAccountById(accountId);
if (account != null) {
sseHubService.sendPing(account.getUsername(), deviceId);
}
}
}
}
}