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

@@ -213,7 +213,7 @@ function startSpringBoot() {
}
}
// startSpringBoot();
startSpringBoot();
function stopSpringBoot() {
if (!springProcess) return;
@@ -253,12 +253,23 @@ function createWindow() {
Menu.setApplicationMenu(null);
mainWindow.setMenuBarVisibility(false);
// 阻止默认的文件拖拽导航行为,让渲染进程的 JavaScript 处理拖拽上传
mainWindow.webContents.on('will-navigate', (event, url) => {
// 允许开发模式下的热重载导航
if (isDev && url.startsWith('http://localhost')) return;
// 阻止所有其他导航(包括拖拽文件)
event.preventDefault();
});
// 同样阻止新窗口的文件拖拽
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
// 拦截关闭事件
mainWindow.on('close', (event) => {
if (isQuitting) return;
const config = loadConfig();
const closeAction = config.closeAction || 'tray';
const closeAction = config.closeAction || 'quit';
if (closeAction === 'quit') {
isQuitting = true;
@@ -280,6 +291,21 @@ function createWindow() {
}
// 单实例锁定
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
}
app.whenReady().then(() => {
// 应用开机自启动配置
const config = loadConfig();
@@ -324,9 +350,9 @@ app.whenReady().then(() => {
}
}
setTimeout(() => {
openAppIfNotOpened();
}, 2000);
// setTimeout(() => {
// openAppIfNotOpened();
// }, 2000);
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -727,7 +753,7 @@ ipcMain.handle('read-log-file', async (event, logDate: string) => {
// 获取关闭行为配置
ipcMain.handle('get-close-action', () => {
const config = loadConfig();
return config.closeAction || 'tray';
return config.closeAction;
});
// 保存关闭行为配置

View File

@@ -415,6 +415,14 @@ async function openDeviceManager() {
await fetchDeviceData()
}
async function handleDeviceConflict(username: string) {
showAuthDialog.value = false
currentUsername.value = username
showDeviceDialog.value = true
ElMessage.warning('该设备已在其他位置登录,请先移除冲突的设备')
await fetchDeviceData()
}
function openSettings() {
showSettingsDialog.value = true
}
@@ -470,6 +478,18 @@ onMounted(async () => {
// 检查是否有待安装的更新
await checkPendingUpdate()
// 全局阻止文件拖拽到窗口(避免意外打开文件)
// 只在指定的 dropzone 区域处理拖拽上传
document.addEventListener('dragover', (e) => {
e.preventDefault()
e.stopPropagation()
}, false)
document.addEventListener('drop', (e) => {
e.preventDefault()
e.stopPropagation()
}, false)
})
async function checkPendingUpdate() {
@@ -580,7 +600,8 @@ onUnmounted(() => {
<LoginDialog
v-model="showAuthDialog"
@login-success="handleLoginSuccess"
@show-register="showRegisterDialog"/>
@show-register="showRegisterDialog"
@device-conflict="handleDeviceConflict"/>
<RegisterDialog
v-model="showRegDialog"

View File

@@ -40,7 +40,7 @@ export const deviceApi = {
return http.post('/monitor/device/register', { ...payload, ip })
},
remove(payload: { deviceId: string }) {
remove(payload: { deviceId: string; username: string }) {
return http.post('/monitor/device/remove', payload)
},

View File

@@ -39,7 +39,7 @@ async function request<T>(path: string, options: RequestInit & { signal?: AbortS
cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/json;charset=UTF-8',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
}
@@ -54,7 +54,9 @@ async function request<T>(path: string, options: RequestInit & { signal?: AbortS
if (contentType.includes('application/json')) {
const json: any = await res.json();
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}
@@ -98,7 +100,9 @@ export const http = {
if (contentType.includes('application/json')) {
const json: any = await res.json();
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}

View File

@@ -5,6 +5,10 @@ export const zebraApi = {
return http.get('/tool/banma/accounts', name ? { name } : undefined)
},
getAccountLimit(name?: string) {
return http.get('/tool/banma/account-limit', name ? { name } : undefined)
},
saveAccount(body: any, name?: string) {
const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
return http.post(url, body)

View File

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { amazonApi } from '../../api/amazon'
import { systemApi } from '../../api/system'
import { handlePlatformFileExport } from '../../utils/settings'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
@@ -28,7 +29,6 @@ let abortController: AbortController | null = null // 请求取消控制器
const currentPage = ref(1)
const pageSize = ref(15)
const amazonUpload = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
@@ -88,17 +88,12 @@ async function handleExcelUpload(e: Event) {
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
const ok = /\.xlsx?$/i.test(file.name)
if (!ok) return showMessage('仅支持 .xls/.xlsx 文件', 'warning')
await processExcelFile(file)
}
// 拖拽上传
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processExcelFile,
onError: (msg) => showMessage(msg, 'warning')
})
// 批量获取产品信息 - 核心数据处理逻辑
async function batchGetProductInfo(asinList: string[]) {
@@ -347,7 +342,7 @@ onMounted(async () => {
<span class="sep">|</span>
<a class="link" @click.prevent="downloadAmazonTemplate">点击下载模板</a>
</div>
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
<div class="dropzone" :class="{ active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
<div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>

View File

@@ -13,6 +13,7 @@ interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'showRegister'): void
(e: 'deviceConflict', username: string): void
}
const props = defineProps<Props>()
@@ -49,8 +50,14 @@ async function handleAuth() {
})
ElMessage.success('登录成功')
resetForm()
} catch (err) {
ElMessage.error((err as Error).message)
} catch (err: any) {
// 设备冲突/数量达上限:触发设备管理
if (err.code === 501 ) {
emit('deviceConflict', authForm.value.username)
resetForm()
} else {
ElMessage.error(err.message || '登录失败')
}
} finally {
authLoading.value = false
}

View File

@@ -18,11 +18,17 @@ const PLATFORM_LABEL: Record<PlatformKey, string> = {
}
const accounts = ref<BanmaAccount[]>([])
const accountLimit = ref({ limit: 1, count: 0 })
async function load() {
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const [res, limitRes] = await Promise.all([
zebraApi.getAccounts(username),
zebraApi.getAccountLimit(username)
])
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const limitData = (limitRes as any)?.data ?? limitRes
accountLimit.value = { limit: limitData?.limit ?? 1, count: limitData?.count ?? 0 }
}
// 暴露方法供父组件调用
@@ -74,10 +80,10 @@ export default defineComponent({ name: 'AccountManager' })
<div class="top">
<img src="/icon/image.png" class="hero" alt="logo" />
<div class="head-main">
<div class="main-title">在线账号管理3/3</div>
<div class="main-title">在线账号管理{{ accountLimit.count }}/{{ accountLimit.limit }}</div>
<div class="main-sub">
您当前订阅可同时托管3家 Shopee 店铺<br>
如需扩增同时托管店铺数 <span class="upgrade">升级订阅</span>
您当前订阅可同时托管{{ accountLimit.limit }}个斑马账号<br>
<span v-if="accountLimit.limit < 3">如需扩增账号数量,请 <span class="upgrade">升级订阅</span></span>
</div>
</div>
</div>

View File

@@ -281,7 +281,7 @@ async function loadLogDates() {
async function loadCloseAction() {
try {
const action = await (window as any).electronAPI.getCloseAction()
closeAction.value = action || 'tray'
if (action) closeAction.value = action
} catch (error) {
console.warn('获取关闭行为配置失败:', error)
}

View File

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
@@ -26,7 +27,6 @@ let abortController: AbortController | null = null
const singleShopName = ref('')
const currentBatchId = ref('')
const uploadInputRef = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 数据与分页
const allProducts = ref<any[]>([])
@@ -223,15 +223,12 @@ async function handleExcelUpload(e: Event) {
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
await processFile(file)
}
// 拖拽上传
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processFile,
onError: (msg) => ElMessage({ message: msg, type: 'warning' })
})
// 点击"获取数据
@@ -358,7 +355,6 @@ function delay(ms: number) {
}
function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve()
}
@@ -424,12 +420,10 @@ async function exportToExcel() {
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
@@ -440,9 +434,7 @@ async function exportToExcel() {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('rakuten', blob, fileName)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
@@ -458,7 +450,6 @@ onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<div class="body-layout">
<!-- 左侧步骤栏 -->
@@ -480,7 +471,7 @@ onMounted(loadLatest)
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
</div>
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload" :class="{ disabled: loading }">
<div class="dropzone" :class="{ disabled: loading, active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload">
<div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>
@@ -532,12 +523,8 @@ onMounted(loadLatest)
</div>
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div>
</div>
</div>
</aside>

View File

@@ -289,7 +289,19 @@ const rememberPwd = ref(true)
const managerVisible = ref(false)
const accountManagerRef = ref()
function openAddAccount() {
async function openAddAccount() {
try {
const username = getUsernameFromToken()
const limitRes = await zebraApi.getAccountLimit(username)
const limitData = (limitRes as any)?.data ?? limitRes
const { limit = 1, count = 0 } = limitData
if (count >= limit) {
ElMessage({ message: `账号数量已达上限(${limit}个)${limit < 3 ? '请升级订阅或' : ''}请先删除其他账号`, type: 'warning' })
return
}
} catch (e) {
console.error('检查账号限制失败:', e)
}
isEditMode.value = false
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
formUsername.value = ''

View File

@@ -0,0 +1,64 @@
import { ref } from 'vue'
export interface UseFileDropOptions {
accept?: RegExp
onFile: (file: File) => void | Promise<void>
onError?: (message: string) => void
}
export function useFileDrop(options: UseFileDropOptions) {
const { accept, onFile, onError } = options
const dragActive = ref(false)
let dragCounter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter++
dragActive.value = true
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter--
if (dragCounter === 0) {
dragActive.value = false
}
}
const onDrop = async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter = 0
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
if (accept && !accept.test(file.name)) {
const fileTypes = accept.source.includes('xlsx?') ? '.xls 或 .xlsx' : accept.source
onError?.(`仅支持 ${fileTypes} 文件`)
return
}
await onFile(file)
}
return {
dragActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop
}
}

View File

@@ -10,7 +10,7 @@
</parent>
<groupId>com.tashow.erp</groupId>
<artifactId>erp_client_sb</artifactId>
<version>2.4.7</version>
<version>2.4.8</version>
<name>erp_client_sb</name>
<description>erp客户端</description>
<properties>

View File

@@ -92,12 +92,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("data", jsonData);
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);
Iterator<JsonNode> offerIterator = null;
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
offerIterator = root.path("data").path("offerList").path("offers").elements();
}
Iterator<JsonNode> offerIterator = getOfferIterator(url, requestEntity);
//运费 - 收集所有运费数据
Set<Double> freight = new HashSet<>();
while (offerIterator.hasNext()) {
@@ -111,12 +106,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
.filter(fee -> fee > 0)
.ifPresent(freight::add);
}
offerIterator = null;
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
offerIterator = root.path("data").path("offerList").path("offers").elements();
}
offerIterator = getOfferIterator(url, requestEntity);
for (int i = 0; i < 10 && offerIterator.hasNext(); i++) {
JsonNode offer = offerIterator.next();
String offerId = offer.path("id").asText();
@@ -162,6 +152,15 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
}
}
private Iterator<JsonNode> getOfferIterator(String url, HttpEntity<MultiValueMap<String, String>> requestEntity) throws IOException {
for (int retry = 0; retry < 3; retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
Iterator<JsonNode> iterator = root.path("data").path("offerList").path("offers").elements();
if (iterator.hasNext()) return iterator;
}
return Collections.emptyIterator();
}
/**
* 获取sku价格
@@ -252,7 +251,16 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
public String uploadImageBase64(String imageUrl) {
String token = Alibaba1688CookieUtil.getToken();
long timestamp = System.currentTimeMillis();
byte[] imageBytes = noSslRestTemplate.getForObject(imageUrl, byte[].class);
byte[] imageBytes = null;
for (int i = 0; i < 3; i++) {
try {
imageBytes = noSslRestTemplate.getForObject(imageUrl, byte[].class);
break;
} catch (Exception e) {
if (i == 2) throw e;
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
}
}
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String jsonData = "{\"appId\":32517,\"params\":\"{\\\"searchScene\\\":\\\"imageEx\\\",\\\"interfaceName\\\":\\\"imageBase64ToImageId\\\",\\\"serviceParam.extendParam[imageBase64]\\\":\\\"" + base64Image + "\\\",\\\"subChannel\\\":\\\"pc_image_search_image_id\\\"}\"}";
String sign = Alibaba1688CookieUtil.generateSign(token, String.valueOf(timestamp), jsonData);

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

View File

@@ -62,8 +62,15 @@ spring:
messages:
# 国际化资源文件路径
basename: i18n/messages
encoding: UTF-8
profiles:
active: druid
# HTTP编码
http:
encoding:
charset: UTF-8
enabled: true
force: true
# 文件上传
servlet:
multipart:

View File

@@ -0,0 +1,61 @@
package com.ruoyi.system.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/**
* 客户端账号-设备关联对象 client_account_device
*/
public class ClientAccountDevice extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private Long accountId;
private String deviceId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date bindTime;
private String status; // active/removed
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getAccountId() {
return accountId;
}
public void setAccountId(Long accountId) {
this.accountId = accountId;
}
public String getDeviceId() {
return deviceId;
}
public void setDeviceId(String deviceId) {
this.deviceId = deviceId;
}
public Date getBindTime() {
return bindTime;
}
public void setBindTime(Date bindTime) {
this.bindTime = bindTime;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -13,8 +13,6 @@ public class ClientDevice extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
@Excel(name = "用户名")
private String username;
@Excel(name = "设备ID")
private String deviceId;
@Excel(name = "设备名")
@@ -36,8 +34,6 @@ public class ClientDevice extends BaseEntity {
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getName() { return name; }

View File

@@ -0,0 +1,48 @@
package com.ruoyi.system.mapper;
import com.ruoyi.system.domain.ClientAccountDevice;
import com.ruoyi.system.domain.ClientDevice;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 客户端账号-设备关联Mapper接口
*/
public interface ClientAccountDeviceMapper {
/**
* 根据账号ID查询已绑定的设备列表
*/
List<ClientDevice> selectDevicesByAccountId(@Param("accountId") Long accountId);
/**
* 根据设备ID查询绑定的账号ID列表
*/
List<Long> selectAccountIdsByDeviceId(@Param("deviceId") String deviceId);
/**
* 查询账号绑定的设备数量(不包括已移除的)
*/
int countActiveDevicesByAccountId(@Param("accountId") Long accountId);
/**
* 检查账号和设备是否已绑定
*/
ClientAccountDevice selectByAccountIdAndDeviceId(@Param("accountId") Long accountId, @Param("deviceId") String deviceId);
/**
* 插入账号-设备绑定
*/
int insert(ClientAccountDevice binding);
/**
* 更新绑定状态
*/
int updateStatus(@Param("accountId") Long accountId, @Param("deviceId") String deviceId, @Param("status") String status);
/**
* 删除绑定
*/
int delete(@Param("accountId") Long accountId, @Param("deviceId") String deviceId);
}

View File

@@ -7,14 +7,10 @@ import java.util.List;
public interface ClientDeviceMapper {
ClientDevice selectByDeviceId(@Param("deviceId") String deviceId);
ClientDevice selectByDeviceIdAndUsername(@Param("deviceId") String deviceId, @Param("username") String username);
List<ClientDevice> selectByUsername(@Param("username") String username);
List<ClientDevice> selectOnlineDevices();
int insert(ClientDevice device);
int updateByDeviceId(ClientDevice device);
int updateByDeviceIdAndUsername(ClientDevice device);
int deleteByDeviceId(@Param("deviceId") String deviceId);
int countByUsername(@Param("username") String username);
}

View File

@@ -10,7 +10,9 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;
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;
/**
@@ -21,9 +23,21 @@ public class BanmaAccountServiceImpl implements IBanmaAccountService {
@Autowired
private BanmaAccountMapper mapper;
@Autowired
private ClientAccountMapper clientAccountMapper;
private final RestTemplate restTemplate = new RestTemplate();
private static final String LOGIN_URL = "https://banma365.cn/api/login";
private int getAccountLimit(String clientUsername) {
if (clientUsername == null) return 3;
ClientAccount client = clientAccountMapper.selectClientAccountByUsername(clientUsername);
if (client == null) return 1;
if ("paid".equals(client.getAccountType()) && client.getExpireTime() != null && new Date().before(client.getExpireTime())) {
return 3;
}
return 1;
}
@Override
public List<BanmaAccount> listSimple() {
return listSimple(null);
@@ -51,6 +65,17 @@ public class BanmaAccountServiceImpl implements IBanmaAccountService {
entity.setClientUsername(clientUsername);
}
// 新增时检查数量限制
if (entity.getId() == null && entity.getClientUsername() != null) {
int limit = getAccountLimit(entity.getClientUsername());
BanmaAccount query = new BanmaAccount();
query.setClientUsername(entity.getClientUsername());
int count = mapper.selectList(query).size();
if (count >= limit) {
throw new RuntimeException("账号数量已达上限(" + limit + "个),请升级订阅或删除其他账号");
}
}
if (entity.getId() == null) {
mapper.insert(entity);
} else {

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.ClientAccountDeviceMapper">
<resultMap id="ClientAccountDeviceMap" type="com.ruoyi.system.domain.ClientAccountDevice">
<id property="id" column="id"/>
<result property="accountId" column="account_id"/>
<result property="deviceId" column="device_id"/>
<result property="bindTime" column="bind_time"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<resultMap id="ClientDeviceResultMap" type="com.ruoyi.system.domain.ClientDevice">
<id property="id" column="id"/>
<result property="deviceId" column="device_id"/>
<result property="name" column="name"/>
<result property="os" column="os"/>
<result property="status" column="status"/>
<result property="ip" column="ip"/>
<result property="location" column="location"/>
<result property="lastActiveAt" column="last_active_at"/>
<result property="trialExpireTime" column="trial_expire_time"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<!-- 根据账号ID查询已绑定的设备列表 -->
<select id="selectDevicesByAccountId" resultMap="ClientDeviceResultMap">
SELECT
d.id, d.device_id, d.name, d.os, d.status, d.ip, d.location,
d.last_active_at, d.trial_expire_time, d.create_time, d.update_time
FROM client_device d
INNER JOIN client_account_device ad ON d.device_id = ad.device_id
WHERE ad.account_id = #{accountId}
AND ad.status = 'active'
ORDER BY d.last_active_at DESC
</select>
<!-- 根据设备ID查询绑定的账号ID列表 -->
<select id="selectAccountIdsByDeviceId" resultType="java.lang.Long">
SELECT account_id
FROM client_account_device
WHERE device_id = #{deviceId}
AND status = 'active'
</select>
<!-- 查询账号绑定的设备数量(不包括已移除的) -->
<select id="countActiveDevicesByAccountId" resultType="int">
SELECT COUNT(1)
FROM client_account_device
WHERE account_id = #{accountId}
AND status = 'active'
</select>
<!-- 检查账号和设备是否已绑定 -->
<select id="selectByAccountIdAndDeviceId" resultMap="ClientAccountDeviceMap">
SELECT *
FROM client_account_device
WHERE account_id = #{accountId}
AND device_id = #{deviceId}
</select>
<!-- 插入账号-设备绑定 -->
<insert id="insert" parameterType="com.ruoyi.system.domain.ClientAccountDevice" useGeneratedKeys="true" keyProperty="id">
INSERT INTO client_account_device(account_id, device_id, bind_time, status, create_time, update_time)
VALUES(#{accountId}, #{deviceId}, #{bindTime}, #{status}, now(), now())
</insert>
<!-- 更新绑定状态 -->
<update id="updateStatus">
UPDATE client_account_device
SET status = #{status}, update_time = now()
WHERE account_id = #{accountId}
AND device_id = #{deviceId}
</update>
<!-- 删除绑定 -->
<delete id="delete">
DELETE FROM client_account_device
WHERE account_id = #{accountId}
AND device_id = #{deviceId}
</delete>
</mapper>

View File

@@ -3,7 +3,6 @@
<mapper namespace="com.ruoyi.system.mapper.ClientDeviceMapper">
<resultMap id="ClientDeviceMap" type="com.ruoyi.system.domain.ClientDevice">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="deviceId" column="device_id"/>
<result property="name" column="name"/>
<result property="os" column="os"/>
@@ -17,38 +16,16 @@
</resultMap>
<select id="selectByDeviceId" resultMap="ClientDeviceMap">
select * from client_device where device_id = #{deviceId}
</select>
<select id="selectByDeviceIdAndUsername" resultMap="ClientDeviceMap">
select * from client_device where device_id = #{deviceId} and username = #{username}
</select>
<select id="selectByUsername" resultMap="ClientDeviceMap">
select * from client_device where username = #{username} and status != 'removed' order by update_time desc
SELECT * FROM client_device WHERE device_id = #{deviceId}
</select>
<insert id="insert" parameterType="com.ruoyi.system.domain.ClientDevice" useGeneratedKeys="true" keyProperty="id">
insert into client_device(username, device_id, name, os, status, ip, location, last_active_at, trial_expire_time, create_time, update_time)
values(#{username}, #{deviceId}, #{name}, #{os}, #{status}, #{ip}, #{location}, #{lastActiveAt}, #{trialExpireTime}, now(), now())
INSERT INTO client_device(device_id, name, os, status, ip, location, last_active_at, trial_expire_time, create_time, update_time)
VALUES(#{deviceId}, #{name}, #{os}, #{status}, #{ip}, #{location}, #{lastActiveAt}, #{trialExpireTime}, now(), now())
</insert>
<update id="updateByDeviceId" parameterType="com.ruoyi.system.domain.ClientDevice">
update client_device
set username = #{username},
name = #{name},
os = #{os},
status = #{status},
ip = #{ip},
location = #{location},
last_active_at = #{lastActiveAt},
trial_expire_time = #{trialExpireTime},
update_time = now()
where device_id = #{deviceId}
</update>
<update id="updateByDeviceIdAndUsername" parameterType="com.ruoyi.system.domain.ClientDevice">
update client_device
UPDATE client_device
<set>
<if test="name != null">name = #{name},</if>
<if test="os != null">os = #{os},</if>
@@ -59,19 +36,15 @@
<if test="trialExpireTime != null">trial_expire_time = #{trialExpireTime},</if>
update_time = now()
</set>
where device_id = #{deviceId} and username = #{username}
WHERE device_id = #{deviceId}
</update>
<delete id="deleteByDeviceId">
delete from client_device where device_id = #{deviceId}
DELETE FROM client_device WHERE device_id = #{deviceId}
</delete>
<select id="countByUsername" resultType="int">
select count(1) from client_device where username = #{username}
</select>
<select id="selectOnlineDevices" resultMap="ClientDeviceMap">
select * from client_device where status = 'online' order by last_active_at desc
SELECT * FROM client_device WHERE status = 'online' ORDER BY last_active_at DESC
</select>
</mapper>

View File

@@ -169,21 +169,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectClientInfoList" parameterType="ClientInfo" resultMap="ClientInfoResult">
select
d.device_id as client_id,
d.username,
a.username,
d.os as os_name,
d.ip as ip_address,
d.last_active_at as last_active_time,
d.create_time as auth_time,
ad.bind_time as auth_time,
CASE WHEN d.status = 'online' THEN '1' ELSE '0' END as online,
a.account_name as hostname,
'' as app_version,
'' as os_version,
'' as java_version
from client_device d
left join client_account a on d.username COLLATE utf8mb4_unicode_ci = a.username
left join client_account_device ad on d.device_id = ad.device_id and ad.status = 'active'
left join client_account a on ad.account_id = a.id
<where>
<if test="clientId != null and clientId != ''">AND d.device_id like concat('%', #{clientId}, '%')</if>
<if test="username != null and username != ''">AND d.username like concat('%', #{username}, '%')</if>
<if test="username != null and username != ''">AND a.username like concat('%', #{username}, '%')</if>
<if test="osName != null and osName != ''">AND d.os like concat('%', #{osName}, '%')</if>
<if test="online != null and online != ''">
AND d.status =

View File

@@ -1,20 +0,0 @@
-- 斑马账号表(与 BanmaAccount 实体、BanmaAccountMapper.xml 一致)
DROP TABLE IF EXISTS `banma_account`;
CREATE TABLE `banma_account` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(64) DEFAULT NULL COMMENT '显示名',
`username` VARCHAR(128) DEFAULT NULL COMMENT '登录用户名',
`token` VARCHAR(512) DEFAULT NULL COMMENT '访问Token客户端刷新后回写',
`token_expire_at` DATETIME DEFAULT NULL COMMENT 'Token过期时间',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认 1是 0否',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态 1启用 0停用',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`create_by` VARCHAR(64) DEFAULT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_by` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_banma_is_default` (`is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='斑马账号表';