feat(client): 实现账号设备试用期管理功能

- 新增设备试用期过期时间字段及管理接口
- 实现试用期状态检查与过期提醒逻辑
- 支持账号类型区分试用与付费用户
- 添加设备注册时自动设置3天试用期- 实现VIP状态刷新与过期类型判断
-优化账号列表查询支持按客户端用户名过滤
- 更新客户端设备管理支持试用期控制- 完善登录流程支持试用期状态提示
-修复设备离线通知缺少用户名参数问题
- 调整账号默认设置清除逻辑关联客户端用户名
This commit is contained in:
2025-10-17 14:17:02 +08:00
parent 132299c4b7
commit 6e1b4d00de
18 changed files with 348 additions and 129 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import {onMounted, ref, computed, defineAsyncComponent, type Component, onUnmounted} from 'vue'
import {onMounted, ref, computed, defineAsyncComponent, type Component, onUnmounted, provide} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
@@ -8,6 +8,7 @@ import {deviceApi, type DeviceItem, type DeviceQuota} from './api/device'
import {getOrCreateDeviceId} from './utils/deviceId'
import {getToken, setToken, removeToken, getUsernameFromToken, getClientIdFromToken} from './utils/token'
import {CONFIG} from './api/http'
import {getSettings} from './utils/settings'
const LoginDialog = defineAsyncComponent(() => import('./components/auth/LoginDialog.vue'))
const RegisterDialog = defineAsyncComponent(() => import('./components/auth/RegisterDialog.vue'))
const NavigationBar = defineAsyncComponent(() => import('./components/layout/NavigationBar.vue'))
@@ -16,6 +17,7 @@ const AmazonDashboard = defineAsyncComponent(() => import('./components/amazon/A
const ZebraDashboard = defineAsyncComponent(() => import('./components/zebra/ZebraDashboard.vue'))
const UpdateDialog = defineAsyncComponent(() => import('./components/common/UpdateDialog.vue'))
const SettingsDialog = defineAsyncComponent(() => import('./components/common/SettingsDialog.vue'))
const TrialExpiredDialog = defineAsyncComponent(() => import('./components/common/TrialExpiredDialog.vue'))
const dashboardsMap: Record<string, Component> = {
rakuten: RakutenDashboard,
@@ -51,23 +53,39 @@ const userPermissions = ref<string>('')
// VIP状态
const vipExpireTime = ref<Date | null>(null)
const deviceTrialExpired = ref(false)
const accountType = ref<string>('trial')
const vipStatus = computed(() => {
if (!vipExpireTime.value) return { isVip: false, daysLeft: 0, status: 'expired' }
const now = new Date()
const expire = new Date(vipExpireTime.value)
const daysLeft = Math.ceil((expire.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysLeft <= 0) return { isVip: false, daysLeft: 0, status: 'expired' }
if (daysLeft <= 7) return { isVip: true, daysLeft, status: 'warning' }
if (daysLeft <= 30) return { isVip: true, daysLeft, status: 'normal' }
return { isVip: true, daysLeft, status: 'active' }
})
// 功能可用性账号VIP + 设备试用期)
const canUseFunctions = computed(() => {
// 付费账号不受设备限制
if (accountType.value === 'paid') return vipStatus.value.isVip
// 试用账号需要账号VIP有效 且 设备未过期
return vipStatus.value.isVip && !deviceTrialExpired.value
})
// 更新对话框状态
const showUpdateDialog = ref(false)
const updateDialogRef = ref()
// 设置对话框状态
const showSettingsDialog = ref(false)
// 试用期过期对话框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both'>('device')
// 菜单配置 - 复刻ERP客户端格式
const menuConfig = [
{key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R'},
@@ -147,7 +165,7 @@ function handleMenuSelect(key: string) {
addToHistory(key)
}
async function handleLoginSuccess(data: { token: string; permissions?: string; expireTime?: string }) {
async function handleLoginSuccess(data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }) {
try {
setToken(data.token)
isAuthenticated.value = true
@@ -157,6 +175,8 @@ async function handleLoginSuccess(data: { token: string; permissions?: string; e
currentUsername.value = getUsernameFromToken(data.token)
userPermissions.value = data.permissions || ''
vipExpireTime.value = data.expireTime ? new Date(data.expireTime) : null
accountType.value = data.accountType || 'trial'
deviceTrialExpired.value = data.deviceTrialExpired || false
const deviceId = await getOrCreateDeviceId()
await deviceApi.register({
@@ -165,6 +185,31 @@ async function handleLoginSuccess(data: { token: string; permissions?: string; e
os: navigator.platform
})
SSEManager.connect()
// 根据不同场景显示提示
const accountExpired = vipExpireTime.value && new Date() > vipExpireTime.value
const deviceExpired = deviceTrialExpired.value
const isPaid = accountType.value === 'paid'
if (isPaid) {
// 场景5: 付费用户
ElMessage.success('登录成功')
} else if (deviceExpired && accountExpired) {
// 场景4: 试用已到期,请订阅
trialExpiredType.value = 'both'
showTrialExpiredDialog.value = true
} else if (accountExpired) {
// 场景3: 账号试用已到期,请订阅
trialExpiredType.value = 'account'
showTrialExpiredDialog.value = true
} else if (deviceExpired) {
// 场景2: 设备试用已到期,请更换设备或订阅
trialExpiredType.value = 'device'
showTrialExpiredDialog.value = true
} else {
// 场景1: 允许使用
ElMessage.success('登录成功')
}
} catch (e: any) {
isAuthenticated.value = false
showAuthDialog.value = true
@@ -179,6 +224,8 @@ function clearLocalAuth() {
currentUsername.value = ''
userPermissions.value = ''
vipExpireTime.value = null
deviceTrialExpired.value = false
accountType.value = 'trial'
showAuthDialog.value = true
showDeviceDialog.value = false
SSEManager.disconnect()
@@ -187,7 +234,7 @@ function clearLocalAuth() {
async function logout() {
try {
const deviceId = getClientIdFromToken()
if (deviceId) await deviceApi.offline({ deviceId })
if (deviceId) await deviceApi.offline({ deviceId, username: currentUsername.value })
} catch (error) {
console.warn('离线通知失败:', error)
}
@@ -235,6 +282,8 @@ async function checkAuth() {
isAuthenticated.value = true
currentUsername.value = getUsernameFromToken(token)
userPermissions.value = res.data.permissions || ''
deviceTrialExpired.value = res.data.deviceTrialExpired || false
accountType.value = res.data.accountType || 'trial'
if (res.data.expireTime) {
vipExpireTime.value = new Date(res.data.expireTime)
@@ -249,6 +298,38 @@ async function checkAuth() {
}
}
// 刷新VIP状态采集前调用
async function refreshVipStatus() {
try {
const token = getToken()
if (!token) return false
const res = await authApi.verifyToken(token)
deviceTrialExpired.value = res.data.deviceTrialExpired || false
accountType.value = res.data.accountType || 'trial'
if (res.data.expireTime) {
vipExpireTime.value = new Date(res.data.expireTime)
}
return true
} catch {
return false
}
}
// 判断过期类型
function checkExpiredType(): 'device' | 'account' | 'both' {
const accountExpired = vipExpireTime.value && new Date() > vipExpireTime.value
const deviceExpired = deviceTrialExpired.value
if (deviceExpired && accountExpired) return 'both'
if (accountExpired) return 'account'
if (deviceExpired) return 'device'
return 'account' // 默认
}
// 提供给子组件使用
provide('refreshVipStatus', refreshVipStatus)
provide('checkExpiredType', checkExpiredType)
const SSEManager = {
connection: null as EventSource | null,
@@ -364,7 +445,7 @@ async function confirmRemoveDevice(row: DeviceItem) {
type: 'warning'
})
await deviceApi.remove({deviceId: row.deviceId})
await deviceApi.remove({deviceId: row.deviceId, username: currentUsername.value})
devices.value = devices.value.filter(d => d.deviceId !== row.deviceId)
deviceQuota.value.used = Math.max(0, deviceQuota.value.used - 1)
@@ -382,8 +463,30 @@ async function confirmRemoveDevice(row: DeviceItem) {
onMounted(async () => {
showContent()
await checkAuth()
// 检查是否有待安装的更新
await checkPendingUpdate()
})
async function checkPendingUpdate() {
try {
const result = await (window as any).electronAPI.checkPendingUpdate()
if (result && result.hasPendingUpdate) {
// 有待安装的更新,直接弹出安装对话框
showUpdateDialog.value = true
}
} catch (error) {
console.error('检查待安装更新失败:', error)
}
}
// 处理自动更新配置变化
function handleAutoUpdateChanged(enabled: boolean) {
if (enabled && updateDialogRef.value) {
updateDialogRef.value.checkForUpdatesNow()
}
}
onUnmounted(() => {
SSEManager.disconnect()
})
@@ -461,7 +564,7 @@ onUnmounted(() => {
</div>
</div>
<keep-alive v-if="activeDashboard">
<component :is="activeDashboard" :key="activeMenu" :is-vip="vipStatus.isVip"/>
<component :is="activeDashboard" :key="activeMenu" :is-vip="canUseFunctions"/>
</keep-alive>
<div v-if="showPlaceholder" class="placeholder">
<div class="placeholder-card">
@@ -483,10 +586,13 @@ onUnmounted(() => {
@back-to-login="backToLogin"/>
<!-- 更新对话框 -->
<UpdateDialog v-model="showUpdateDialog" />
<UpdateDialog ref="updateDialogRef" v-model="showUpdateDialog" />
<!-- 设置对话框 -->
<SettingsDialog v-model="showSettingsDialog" />
<SettingsDialog v-model="showSettingsDialog" @auto-update-changed="handleAutoUpdateChanged" />
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 设备管理弹框 -->
<el-dialog

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, defineAsyncComponent, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { amazonApi } from '../../api/amazon'
import { handlePlatformFileExport } from '../../utils/settings'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态
const props = defineProps<{
isVip: boolean
@@ -24,6 +28,12 @@ const pageSize = ref(15)
const amazonUpload = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
// 计算属性 - 当前页数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
@@ -90,19 +100,13 @@ async function onDrop(e: DragEvent) {
// 批量获取产品信息 - 核心数据处理逻辑
async function batchGetProductInfo(asinList: string[]) {
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查
if (!props.isVip) {
try {
await ElMessageBox.confirm(
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
showTrialExpiredDialog.value = true
return
}
@@ -390,6 +394,9 @@ onMounted(async () => {
<el-button type="primary" class="btn-blue" @click="amazonExampleVisible = false">我知道了</el-button>
</template>
</el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 表格上方进度条 -->
<div v-if="progressVisible" class="progress-head">

View File

@@ -2,6 +2,7 @@
import { ref, onMounted, computed } from 'vue'
import { zebraApi, type BanmaAccount } from '../../api/zebra'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getUsernameFromToken } from '../../utils/token'
type PlatformKey = 'zebra' | 'shopee' | 'rakuten' | 'amazon'
@@ -18,7 +19,8 @@ const PLATFORM_LABEL: Record<PlatformKey, string> = {
const accounts = ref<BanmaAccount[]>([])
async function load() {
const res = await zebraApi.getAccounts()
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
}

View File

@@ -54,36 +54,18 @@ public class BanmaOrderServiceImpl implements IBanmaOrderService {
builder.readTimeout(Duration.ofSeconds(10));
restTemplate = builder.build();
}
@SuppressWarnings("unchecked")
private void fetchTokenFromServer(Long accountId) {
ResponseEntity<Map> resp = restTemplate.getForEntity(RUOYI_ADMIN_BASE + "/tool/banma/accounts", Map.class);
Object body = resp.getBody();
if (body == null) return;
Object data = ((Map<String, Object>) body).get("data");
List<Map<String, Object>> list;
if (data instanceof List) {
list = (List<Map<String, Object>>) data;
} else if (body instanceof Map && ((Map) body).get("list") instanceof List) {
list = (List<Map<String, Object>>) ((Map) body).get("list");
} else {
return;
}
if (list.isEmpty()) return;
Map<String, Object> picked;
if (accountId != null) {
picked = list.stream().filter(m -> Objects.equals(((Number) m.get("id")).longValue(), accountId)).findFirst().orElse(null);
if (picked == null) return;
} else {
picked = list.stream()
.filter(m -> Objects.equals(((Number) m.getOrDefault("status", 1)).intValue(), 1))
.sorted((a,b) -> Integer.compare(((Number) b.getOrDefault("isDefault", 0)).intValue(), ((Number) a.getOrDefault("isDefault", 0)).intValue()))
.findFirst().orElse(list.get(0));
}
Object token = picked.get("token");
if (token instanceof String && !((String) token).isEmpty()) {
String t = (String) token;
currentAuthToken = t.startsWith("Bearer ") ? t : ("Bearer " + t);
Map<String, Object> resp = restTemplate.getForObject(RUOYI_ADMIN_BASE + "/tool/banma/accounts", Map.class);
List<Map<String, Object>> list = (List<Map<String, Object>>) resp.get("data");
if (list == null || list.isEmpty()) return;
Map<String, Object> account = accountId != null
? list.stream().filter(m -> accountId.equals(((Number) m.get("id")).longValue())).findFirst().orElse(null)
: list.stream().filter(m -> ((Number) m.getOrDefault("isDefault", 0)).intValue() == 1).findFirst().orElse(list.get(0));
if (account != null) {
String token = (String) account.get("token");
currentAuthToken = token != null && token.startsWith("Bearer ") ? token : "Bearer " + token;
currentAccountId = accountId;
}
}

View File

@@ -32,7 +32,7 @@ import com.ruoyi.system.domain.SysCache;
public class CacheController
{
@Autowired
private RedisTemplate<String, String> redisTemplate;
private RedisTemplate<Object, Object> redisTemplate;
private final static List<SysCache> caches = new ArrayList<SysCache>();
{
@@ -80,15 +80,16 @@ public class CacheController
@GetMapping("/getKeys/{cacheName}")
public AjaxResult getCacheKeys(@PathVariable String cacheName)
{
Set<String> cacheKeys = redisTemplate.keys(cacheName + "*");
return AjaxResult.success(new TreeSet<>(cacheKeys));
Set<Object> cacheKeys = redisTemplate.keys(cacheName + "*");
return AjaxResult.success(cacheKeys != null ? new TreeSet<>(cacheKeys.stream().map(String::valueOf).collect(java.util.stream.Collectors.toSet())) : new TreeSet<>());
}
@PreAuthorize("@ss.hasPermi('monitor:cache:list')")
@GetMapping("/getValue/{cacheName}/{cacheKey}")
public AjaxResult getCacheValue(@PathVariable String cacheName, @PathVariable String cacheKey)
{
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
Object cacheValueObj = redisTemplate.opsForValue().get(cacheKey);
String cacheValue = cacheValueObj != null ? cacheValueObj.toString() : null;
SysCache sysCache = new SysCache(cacheName, cacheKey, cacheValue);
return AjaxResult.success(sysCache);
}
@@ -97,8 +98,10 @@ public class CacheController
@DeleteMapping("/clearCacheName/{cacheName}")
public AjaxResult clearCacheName(@PathVariable String cacheName)
{
Collection<String> cacheKeys = redisTemplate.keys(cacheName + "*");
redisTemplate.delete(cacheKeys);
Set<Object> cacheKeys = redisTemplate.keys(cacheName + "*");
if (cacheKeys != null && !cacheKeys.isEmpty()) {
redisTemplate.delete(cacheKeys);
}
return AjaxResult.success();
}
@@ -114,8 +117,10 @@ public class CacheController
@DeleteMapping("/clearCacheAll")
public AjaxResult clearCacheAll()
{
Collection<String> cacheKeys = redisTemplate.keys("*");
redisTemplate.delete(cacheKeys);
Set<Object> cacheKeys = redisTemplate.keys("*");
if (cacheKeys != null && !cacheKeys.isEmpty()) {
redisTemplate.delete(cacheKeys);
}
return AjaxResult.success();
}
}

View File

@@ -149,7 +149,6 @@ public class ClientAccountController extends BaseController {
if (userDevice >= deviceLimit) {
return AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
}
String token = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
.setSubject(username)
@@ -161,15 +160,24 @@ public class ClientAccountController extends BaseController {
.signWith(SignatureAlgorithm.RS256, jwtRsaKeyService.getPrivateKey())
.compact();
// 检查设备试用期仅对trial账号生效
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());
}
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("permissions", account.getPermissions());
data.put("accountName", account.getAccountName());
data.put("expireTime", account.getExpireTime());
data.put("accountType", account.getAccountType());
data.put("deviceTrialExpired", deviceTrialExpired);
return AjaxResult.success(data);
}
/**
* 验证token
*/
@@ -181,17 +189,35 @@ public class ClientAccountController extends BaseController {
.parseClaimsJws(token)
.getBody();
String username = (String) claims.get("sub");
String clientId = (String) claims.get("clientId");
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account == null || !"0".equals(account.getStatus())) {
return AjaxResult.error("token无效");
}
// 付费账号到期自动降级为试用账号
if ("paid".equals(account.getAccountType()) && account.getExpireTime() != null && new Date().after(account.getExpireTime())) {
account.setAccountType("trial");
clientAccountService.updateClientAccount(account);
}
// 检查设备试用期仅对trial账号生效
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());
}
Map<String, Object> result = new HashMap<>();
result.put("username", username);
result.put("permissions", account.getPermissions());
result.put("accountName", account.getAccountName());
result.put("expireTime", account.getExpireTime());
result.put("accountType", account.getAccountType());
result.put("deviceTrialExpired", deviceTrialExpired);
return AjaxResult.success(result);
}
@@ -217,7 +243,7 @@ public class ClientAccountController extends BaseController {
/**
* 客户端账号注册
* 新设备注册送3天VIP同一设备ID重复注册不赠送
* 新账号注册送3天VIP试用期
*/
@PostMapping("/register")
public AjaxResult register(@RequestBody Map<String, String> registerData) {
@@ -232,16 +258,8 @@ public class ClientAccountController extends BaseController {
clientAccount.setStatus("0");
clientAccount.setPermissions("{\"amazon\":true,\"rakuten\":true,\"zebra\":true}");
clientAccount.setPassword(passwordEncoder.encode(password));
// 新设备赠送3天VIP
ClientDevice existingDevice = clientDeviceMapper.selectByDeviceId(deviceId);
int vipDays = (existingDevice == null) ? 3 : 0;
if (vipDays > 0) {
clientAccount.setExpireTime(new Date(System.currentTimeMillis() + vipDays * 24L * 60 * 60 * 1000));
} else {
clientAccount.setExpireTime(new Date());
}
clientAccount.setAccountType("trial");
clientAccount.setExpireTime(new Date(System.currentTimeMillis() + 3 * 24L * 60 * 60 * 1000));
int result = clientAccountService.insertClientAccount(clientAccount);
if (result <= 0) {
@@ -263,6 +281,8 @@ public class ClientAccountController extends BaseController {
data.put("permissions", clientAccount.getPermissions());
data.put("accountName", clientAccount.getAccountName());
data.put("expireTime", clientAccount.getExpireTime());
data.put("accountType", clientAccount.getAccountType());
data.put("deviceTrialExpired", false);
return AjaxResult.success(data);
}
@@ -300,6 +320,7 @@ public class ClientAccountController extends BaseController {
Date newExpireTime = cal.getTime();
account.setExpireTime(newExpireTime);
account.setAccountType("paid");
account.setUpdateBy(getUsername());
clientAccountService.updateClientAccount(account);

View File

@@ -97,32 +97,33 @@ public class ClientDeviceController {
*/
@PostMapping("/register")
public AjaxResult register(@RequestBody ClientDevice device, HttpServletRequest request) {
ClientDevice exists = clientDeviceMapper.selectByDeviceId(device.getDeviceId());
String ip = IpUtils.getIpAddr(request);
String username = device.getUsername();
String deviceId = device.getDeviceId();
String os = device.getOs();
String deviceName = username + "@" + ip + " (" + os + ")";
ClientDevice exists = clientDeviceMapper.selectByDeviceIdAndUsername(deviceId, username);
if (exists == null) {
// 检查设备数量限制
try {
checkDeviceLimit(device.getUsername(), device.getDeviceId());
checkDeviceLimit(username, deviceId);
} catch (RuntimeException e) {
return AjaxResult.error(e.getMessage());
}
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.setUsername(device.getUsername());
exists.setName(deviceName);
exists.setOs(device.getOs());
exists.setOs(os);
exists.setStatus("online");
exists.setIp(ip);
exists.setLocation(device.getLocation());
exists.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceId(exists);
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
}
return AjaxResult.success();
}
@@ -137,6 +138,15 @@ public class ClientDeviceController {
clientDeviceMapper.updateByDeviceId(device);
return AjaxResult.success();
}
/**
* 修改设备试用期过期时间
*/
@PostMapping("/updateExpire")
public AjaxResult updateExpire(@RequestBody ClientDevice device) {
clientDeviceMapper.updateByDeviceIdAndUsername(device);
return AjaxResult.success();
}
/**
* 移除设备
* 根据 deviceId 删除设备绑定记录。
@@ -144,19 +154,20 @@ public class ClientDeviceController {
@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.selectByDeviceId(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.updateByDeviceId(exists);
sseHubService.sendEvent(exists.getUsername(), deviceId, "DEVICE_REMOVED", "{}");
sseHubService.disconnectDevice(exists.getUsername(), deviceId);
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
sseHubService.sendEvent(username, deviceId, "DEVICE_REMOVED", "{}");
sseHubService.disconnectDevice(username, deviceId);
}
return AjaxResult.success();
}
@@ -167,11 +178,13 @@ public class ClientDeviceController {
@PostMapping("/offline")
public AjaxResult offline(@RequestBody Map<String, String> body) {
String deviceId = body.get("deviceId");
ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId);
String username = body.get("username");
ClientDevice device = clientDeviceMapper.selectByDeviceIdAndUsername(deviceId, username);
if (device != null) {
device.setStatus("offline");
device.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceId(device);
clientDeviceMapper.updateByDeviceIdAndUsername(device);
}
return AjaxResult.success();
}
@@ -182,38 +195,37 @@ public class ClientDeviceController {
*/
@PostMapping("/heartbeat")
public AjaxResult heartbeat(@RequestBody ClientDevice device, HttpServletRequest request) {
ClientDevice exists = clientDeviceMapper.selectByDeviceId(device.getDeviceId());
String ip = IpUtils.getIpAddr(request);
String username = device.getUsername();
String deviceId = device.getDeviceId();
String os = device.getOs();
String deviceName = username + "@" + ip + " (" + os + ")";
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 if ("removed".equals(exists.getStatus())) {
// 被移除设备重新激活
exists.setUsername(device.getUsername());
exists.setName(deviceName);
exists.setOs(device.getOs());
exists.setOs(os);
exists.setStatus("online");
exists.setIp(ip);
exists.setLocation(device.getLocation());
exists.setLastActiveAt(new java.util.Date());
clientDeviceMapper.updateByDeviceId(exists);
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
} else {
// 已存在设备更新
exists.setUsername(device.getUsername());
exists.setStatus("online");
exists.setIp(ip);
exists.setLastActiveAt(new java.util.Date());
exists.setName(deviceName);
clientDeviceMapper.updateByDeviceId(exists);
clientDeviceMapper.updateByDeviceIdAndUsername(exists);
}
return AjaxResult.success();
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.web.controller.tool;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Anonymous;
@@ -10,9 +11,7 @@ import com.ruoyi.system.domain.BanmaAccount;
import com.ruoyi.system.service.IBanmaAccountService;
/**
* 斑马账号管理(数据库版,极简接口):
* - 仅负责账号与 Token 的存取
* - 不参与登录/刷新与数据采集,客户端自行处理
* 斑马账号管理
*/
@RestController
@RequestMapping("/tool/banma")
@@ -22,47 +21,36 @@ public class BanmaOrderController extends BaseController {
@Autowired
private IBanmaAccountService accountService;
/**
* 查询账号列表(
*/
@GetMapping("/accounts")
public R<?> listAccounts() {
List<BanmaAccount> list = accountService.listSimple();
return R.ok(list);
public R<?> listAccounts(String name) {
return R.ok(accountService.listSimple(name));
}
/**
* 新增或编辑账号(含设为默认)
*/
@PostMapping("/accounts")
public R<?> saveAccount(@RequestBody BanmaAccount body) {
Long id = accountService.saveOrUpdate(body);
boolean ok = false;
try { ok = accountService.refreshToken(id); } catch (Exception ignore) {}
return ok ? R.ok(Map.of("id", id)) : R.fail("账号或密码错误无法获取Token");
public R<?> saveAccount(@RequestBody BanmaAccount body, String name) {
if (body.getId() == null && accountService.validateAndGetToken(body.getUsername(), body.getPassword()) == null) {
throw new RuntimeException("账号或密码错误");
}
Long id = accountService.saveOrUpdate(body, name);
accountService.refreshToken(id);
return R.ok(Map.of("id", id));
}
/**
* 删除账号
*/
@DeleteMapping("/accounts/{id}")
public R<?> remove(@PathVariable Long id) {
accountService.remove(id);
return R.ok();
}
/** 手动刷新单个账号 Token */
@PostMapping("/accounts/{id}/refresh-token")
public R<?> refreshOne(@PathVariable Long id) {
accountService.refreshToken(id);
return R.ok();
}
/** 手动刷新全部启用账号 Token */
@PostMapping("/refresh-all")
public R<?> refreshAll() {
accountService.refreshAllTokens();
accountService.listSimple().forEach(account -> accountService.refreshToken(account.getId()));
return R.ok();
}
}
}

View File

@@ -17,6 +17,8 @@ public class BanmaAccount extends BaseEntity {
private String username;
/** 登录密码服务端刷新Token用不对外返回 */
private String password;
/** 客户端账号用户名关联client_account.username */
private String clientUsername;
/** 访问 Token客户端刷新后回写 */
private String token;
/** Token 过期时间(可选) */
@@ -37,6 +39,8 @@ public class BanmaAccount extends BaseEntity {
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getClientUsername() { return clientUsername; }
public void setClientUsername(String clientUsername) { this.clientUsername = clientUsername; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Date getTokenExpireAt() { return tokenExpireAt; }

View File

@@ -51,6 +51,10 @@ public class ClientAccount extends BaseEntity
@Excel(name = "设备数量限制")
private Integer deviceLimit;
/** 账号类型 */
@Excel(name = "账号类型")
private String accountType; // trial试用, paid付费
public void setId(Long id)
{
this.id = id;
@@ -147,4 +151,14 @@ public class ClientAccount extends BaseEntity
{
return deviceLimit;
}
public void setAccountType(String accountType)
{
this.accountType = accountType;
}
public String getAccountType()
{
return accountType;
}
}

View File

@@ -30,6 +30,9 @@ public class ClientDevice extends BaseEntity {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "最近在线", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date lastActiveAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "试用期过期时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date trialExpireTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -49,6 +52,8 @@ public class ClientDevice extends BaseEntity {
public void setLocation(String location) { this.location = location; }
public Date getLastActiveAt() { return lastActiveAt; }
public void setLastActiveAt(Date lastActiveAt) { this.lastActiveAt = lastActiveAt; }
public Date getTrialExpireTime() { return trialExpireTime; }
public void setTrialExpireTime(Date trialExpireTime) { this.trialExpireTime = trialExpireTime; }
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.system.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.BanmaAccount;
/**
@@ -12,7 +13,7 @@ public interface BanmaAccountMapper {
int insert(BanmaAccount entity);
int update(BanmaAccount entity);
int deleteById(Long id);
int clearDefault();
int clearDefault(@Param("clientUsername") String clientUsername);
}

View File

@@ -1,18 +1,20 @@
package com.ruoyi.system.mapper;
import com.ruoyi.system.domain.ClientDevice;
import io.lettuce.core.dynamic.annotation.Param;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ClientDeviceMapper {
ClientDevice selectByDeviceId(String deviceId);
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 deleteByDeviceId(String deviceId);
int countByUsername(String username);
int updateByDeviceIdAndUsername(ClientDevice device);
int deleteByDeviceId(@Param("deviceId") String deviceId);
int countByUsername(@Param("username") String username);
}

View File

@@ -26,7 +26,14 @@ public class BanmaAccountServiceImpl implements IBanmaAccountService {
@Override
public List<BanmaAccount> listSimple() {
List<BanmaAccount> list = mapper.selectList(new BanmaAccount());
return listSimple(null);
}
@Override
public List<BanmaAccount> listSimple(String clientUsername) {
BanmaAccount query = new BanmaAccount();
query.setClientUsername(clientUsername);
List<BanmaAccount> list = mapper.selectList(query);
// 隐藏密码
for (BanmaAccount a : list) { a.setPassword(null); }
return list;
@@ -34,13 +41,25 @@ public class BanmaAccountServiceImpl implements IBanmaAccountService {
@Override
public Long saveOrUpdate(BanmaAccount entity) {
return saveOrUpdate(entity, null);
}
@Override
public Long saveOrUpdate(BanmaAccount entity, String clientUsername) {
// 设置客户端用户名
if (clientUsername != null) {
entity.setClientUsername(clientUsername);
}
if (entity.getId() == null) {
mapper.insert(entity);
} else {
mapper.update(entity);
}
if (Objects.equals(entity.getIsDefault(), 1)) {
mapper.clearDefault();
// 如果设为默认,则清除该客户端的其他默认账号
if (Objects.equals(entity.getIsDefault(), 1) && entity.getClientUsername() != null) {
mapper.clearDefault(entity.getClientUsername());
BanmaAccount only = new BanmaAccount();
only.setId(entity.getId());
only.setIsDefault(1);

View File

@@ -7,6 +7,7 @@
<result property="name" column="name"/>
<result property="username" column="username"/>
<result property="password" column="password"/>
<result property="clientUsername" column="client_username"/>
<result property="token" column="token"/>
<result property="tokenExpireAt" column="token_expire_at"/>
<result property="isDefault" column="is_default"/>
@@ -19,7 +20,7 @@
</resultMap>
<sql id="Base_Column_List">
id, name, username, password, token, token_expire_at, is_default, status, remark, create_by, create_time, update_by, update_time
id, name, username, password, client_username, token, token_expire_at, is_default, status, remark, create_by, create_time, update_by, update_time
</sql>
<select id="selectById" parameterType="long" resultMap="BanmaAccountResult">
@@ -29,6 +30,7 @@
<select id="selectList" parameterType="com.ruoyi.system.domain.BanmaAccount" resultMap="BanmaAccountResult">
select <include refid="Base_Column_List"/> from banma_account
<where>
<if test="clientUsername != null and clientUsername != ''"> and client_username = #{clientUsername}</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="username != null and username != ''"> and username like concat('%', #{username}, '%')</if>
<if test="status != null"> and status = #{status}</if>
@@ -42,6 +44,7 @@
<if test="name != null">name,</if>
<if test="username != null">username,</if>
<if test="password != null">password,</if>
<if test="clientUsername != null">client_username,</if>
<if test="token != null">token,</if>
<if test="tokenExpireAt != null">token_expire_at,</if>
<if test="isDefault != null">is_default,</if>
@@ -54,6 +57,7 @@
<if test="name != null">#{name},</if>
<if test="username != null">#{username},</if>
<if test="password != null">#{password},</if>
<if test="clientUsername != null">#{clientUsername},</if>
<if test="token != null">#{token},</if>
<if test="tokenExpireAt != null">#{tokenExpireAt},</if>
<if test="isDefault != null">#{isDefault},</if>
@@ -70,6 +74,7 @@
<if test="name != null">name=#{name},</if>
<if test="username != null">username=#{username},</if>
<if test="password != null">password=#{password},</if>
<if test="clientUsername != null">client_username=#{clientUsername},</if>
<if test="token != null">token=#{token},</if>
<if test="tokenExpireAt != null">token_expire_at=#{tokenExpireAt},</if>
<if test="isDefault != null">is_default=#{isDefault},</if>
@@ -82,7 +87,10 @@
</update>
<update id="clearDefault">
update banma_account set is_default = 0 where is_default = 1
update banma_account set is_default = 0 where is_default = 1
<if test="clientUsername != null and clientUsername != ''">
and client_username = #{clientUsername}
</if>
</update>
<delete id="deleteById" parameterType="long">

View File

@@ -15,6 +15,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark" />
<result property="permissions" column="permissions" />
<result property="deviceLimit" column="device_limit" />
<result property="accountType" column="account_type" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -23,7 +24,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectClientAccountVo">
select id, account_name, username, password, status, expire_time,
allowed_ip_range, remark, permissions, device_limit, create_by, create_time, update_by, update_time
allowed_ip_range, remark, permissions, device_limit, account_type, create_by, create_time, update_by, update_time
from client_account
</sql>
@@ -59,6 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">remark,</if>
<if test="permissions != null">permissions,</if>
<if test="deviceLimit != null">device_limit,</if>
<if test="accountType != null">account_type,</if>
<if test="createBy != null">create_by,</if>
create_time
</trim>
@@ -72,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">#{remark},</if>
<if test="permissions != null">#{permissions},</if>
<if test="deviceLimit != null">#{deviceLimit},</if>
<if test="accountType != null">#{accountType},</if>
<if test="createBy != null">#{createBy},</if>
sysdate()
</trim>
@@ -89,6 +92,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">remark = #{remark},</if>
<if test="permissions != null">permissions = #{permissions},</if>
<if test="deviceLimit != null">device_limit = #{deviceLimit},</if>
<if test="accountType != null">account_type = #{accountType},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate()
</trim>

View File

@@ -11,6 +11,7 @@
<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>
@@ -19,13 +20,17 @@
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>
<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, create_time, update_time)
values(#{username}, #{deviceId}, #{name}, #{os}, #{status}, #{ip}, #{location}, #{lastActiveAt}, now(), now())
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>
<update id="updateByDeviceId" parameterType="com.ruoyi.system.domain.ClientDevice">
@@ -37,10 +42,26 @@
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
<set>
<if test="name != null">name = #{name},</if>
<if test="os != null">os = #{os},</if>
<if test="status != null">status = #{status},</if>
<if test="ip != null">ip = #{ip},</if>
<if test="location != null">location = #{location},</if>
<if test="lastActiveAt != null">last_active_at = #{lastActiveAt},</if>
<if test="trialExpireTime != null">trial_expire_time = #{trialExpireTime},</if>
update_time = now()
</set>
where device_id = #{deviceId} and username = #{username}
</update>
<delete id="deleteByDeviceId">
delete from client_device where device_id = #{deviceId}
</delete>

View File

@@ -68,4 +68,22 @@ export function renewAccount(data) {
method: 'post',
data: data
})
}
}
// 获取账号设备列表
export function getDeviceList(username) {
return request({
url: '/monitor/device/list',
method: 'get',
params: { username }
})
}
// 修改设备试用期过期时间
export function updateDeviceExpire(data) {
return request({
url: '/monitor/device/updateExpire',
method: 'post',
data: data
})
}