diff --git a/.idea/compiler.xml b/.idea/compiler.xml index e6b8496..9793850 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -2,6 +2,7 @@ + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 81e762b..9101fad 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,12 +5,31 @@ - + + + + + + + + + + + + + + + + + + + + - + @@ -125,7 +149,87 @@ diff --git a/data/erp-cache.db b/data/erp-cache.db index 3cb31f8..340fbd4 100644 Binary files a/data/erp-cache.db and b/data/erp-cache.db differ diff --git a/data/erp-cache.db-wal b/data/erp-cache.db-wal index 75aaf18..2256a67 100644 Binary files a/data/erp-cache.db-wal and b/data/erp-cache.db-wal differ diff --git a/electron-vue-template/public/icon/img.png b/electron-vue-template/public/icon/img.png deleted file mode 100644 index 54b070b..0000000 Binary files a/electron-vue-template/public/icon/img.png and /dev/null differ diff --git a/electron-vue-template/public/icons/icon.ico b/electron-vue-template/public/icons/icon.ico deleted file mode 100644 index 6fcfdc3..0000000 Binary files a/electron-vue-template/public/icons/icon.ico and /dev/null differ diff --git a/electron-vue-template/public/icons/icon.png b/electron-vue-template/public/icons/icon.png deleted file mode 100644 index d826be6..0000000 Binary files a/electron-vue-template/public/icons/icon.png and /dev/null differ diff --git a/electron-vue-template/src/main/main.ts b/electron-vue-template/src/main/main.ts index 7b75f33..b2b3643 100644 --- a/electron-vue-template/src/main/main.ts +++ b/electron-vue-template/src/main/main.ts @@ -90,11 +90,10 @@ function stopSpringBoot() { } } -// 创建主窗口(预创建但隐藏) function createWindow () { mainWindow = new BrowserWindow({ - width: 800, - height: 600, + width: 1280, + height: 800, show: false, autoHideMenuBar: true, webPreferences: { diff --git a/electron-vue-template/src/renderer/App.vue b/electron-vue-template/src/renderer/App.vue index 1ae073a..a533193 100644 --- a/electron-vue-template/src/renderer/App.vue +++ b/electron-vue-template/src/renderer/App.vue @@ -1,17 +1,34 @@ @@ -573,6 +524,7 @@ onMounted(async () => { z-index: 9999; transition: opacity 0.1s ease; } + .loading-spinner { width: 50px; height: 50px; @@ -581,9 +533,14 @@ onMounted(async () => { border-radius: 50%; animation: spin 1s linear infinite; } + @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } .erp-container { @@ -592,16 +549,28 @@ onMounted(async () => { } .sidebar { - width: 220px; - min-width: 220px; + width: 180px; + min-width: 180px; flex-shrink: 0; background: #ffffff; border-right: 1px solid #e8eaec; padding: 16px 12px; box-sizing: border-box; } -.platform-icons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } -.picon { width: 28px; height: 28px; object-fit: contain; } + +.platform-icons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +.picon { + width: 28px; + height: 28px; + object-fit: contain; +} + .user-avatar { display: flex; align-items: center; @@ -610,6 +579,7 @@ onMounted(async () => { border-bottom: 1px solid #e8eaec; margin: 0 0 12px 0; } + .user-avatar img { width: 50px; height: 50px; @@ -617,17 +587,20 @@ onMounted(async () => { object-fit: contain; background: #ffffff; } + .menu-group-title { font-size: 12px; color: #909399; margin: 8px 6px 10px; text-align: left; /* “电商平台”四个字靠左 */ } + .menu { list-style: none; padding: 0; margin: 0; } + .menu-item { display: flex; align-items: center; @@ -637,22 +610,53 @@ onMounted(async () => { color: #333333; margin-bottom: 4px; } + .menu-item:hover { background: #f5f7fa; } + .menu-item.active { background: #ecf5ff !important; color: #409EFF !important; } + .menu-text { font-size: 14px; } -.menu-text { display: inline-flex; align-items: center; gap: 6px; } -.menu-icon { display: inline-flex; width: 18px; height: 18px; border-radius: 4px; align-items: center; justify-content: center; font-size: 12px; color: #fff; } -.menu-icon[data-k="rakuten"] { background: #BF0000; } -.menu-icon[data-k="amazon"] { background: #FF9900; color: #1A1A1A; } -.menu-icon[data-k="zebra"] { background: #34495e; } -.menu-icon[data-k="shopee"] { background: #EE4D2D; } + +.menu-text { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.menu-icon { + display: inline-flex; + width: 18px; + height: 18px; + border-radius: 4px; + align-items: center; + justify-content: center; + font-size: 12px; + color: #fff; +} + +.menu-icon[data-k="rakuten"] { + background: #BF0000; +} + +.menu-icon[data-k="amazon"] { + background: #FF9900; + color: #1A1A1A; +} + +.menu-icon[data-k="zebra"] { + background: #34495e; +} + +.menu-icon[data-k="shopee"] { + background: #EE4D2D; +} .main-content { flex: 1; @@ -671,6 +675,7 @@ onMounted(async () => { min-height: 0; overflow: hidden; } + .dashboard-home { position: absolute; inset: 0; @@ -680,7 +685,12 @@ onMounted(async () => { background: #ffffff; z-index: 100; } -.icon-container { display: flex; justify-content: center; } + +.icon-container { + display: flex; + justify-content: center; +} + .main-icon { width: 400px; height: 400px; @@ -696,6 +706,7 @@ onMounted(async () => { justify-content: center; background: #fff; } + .placeholder-card { background: #ffffff; border: 1px solid #e8eaec; @@ -704,6 +715,40 @@ onMounted(async () => { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); color: #2c3e50; } -.placeholder-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; } -.placeholder-desc { font-size: 13px; color: #606266; } + +.placeholder-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.placeholder-desc { + font-size: 13px; + color: #606266; +} +.device-dialog-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0 4px 0; + margin-left: 40px; +} +.device-dialog :deep(.el-dialog__header) { + text-align: center; +} +.device-dialog :deep(.el-dialog__body) { padding-top: 0; } +.device-illustration { + width: 180px; + height: auto; + object-fit: contain; + margin-bottom: 8px; +} +.device-title { + font-size: 18px; + font-weight: 600; + color: #303133; + margin-bottom: 6px; +} +.device-count { color: #909399; font-weight: 500; } +.device-subtitle { font-size: 12px; color: #909399; } diff --git a/electron-vue-template/src/renderer/api/auth.ts b/electron-vue-template/src/renderer/api/auth.ts index 807c9bc..cc8e125 100644 --- a/electron-vue-template/src/renderer/api/auth.ts +++ b/electron-vue-template/src/renderer/api/auth.ts @@ -44,10 +44,6 @@ interface RegisterResponse { message?: string; } -interface CheckUsernameResponse { - available: boolean; -} - export const authApi = { // 用户登录 login(params: LoginRequest) { @@ -68,7 +64,6 @@ export const authApi = { return http .get('/api/check-username', { username }) .then(res => { - // checkUsername 使用标准格式 {code: 200, data: boolean} if (res && res.code === 200) { return { available: res.data }; } @@ -87,4 +82,32 @@ export const authApi = { logout(token: string) { return http.postVoid('/api/logout', { token }); }, + + // 删除token缓存 + deleteTokenCache() { + return http.postVoid('/api/cache/delete?key=token'); + }, + // 保存token到本地数据库 + saveToken(token: string) { + return http.postVoid('/api/cache/save', { key: 'token', value: token }); + }, + + // 从本地数据库获取token + getToken(): Promise { + return http.get('/api/cache/get?key=token').then((res: any) => { + if (typeof res === 'string') return res; + if (res && typeof res === 'object') { + if (typeof res.code === 'number') { + return res.code === 0 ? (res.data as string | undefined) : undefined; + } + if (typeof (res as any).data === 'string') return (res as any).data as string; + } + return undefined; + }); + }, + + // 会话引导:检查并恢复会话(返回体各异,这里保持 any) + sessionBootstrap() { + return http.get('/api/session/bootstrap'); + }, }; \ No newline at end of file diff --git a/electron-vue-template/src/renderer/api/http.ts b/electron-vue-template/src/renderer/api/http.ts index fe6387f..63f40c4 100644 --- a/electron-vue-template/src/renderer/api/http.ts +++ b/electron-vue-template/src/renderer/api/http.ts @@ -1,7 +1,13 @@ -// 极简 HTTP 工具:仅封装 GET/POST,默认指向本地 8081 +// 极简 HTTP 工具:封装 GET/POST,按路径选择后端服务 export type HttpMethod = 'GET' | 'POST'; -const BASE_URL = 'http://localhost:8081'; +const BASE_CLIENT = 'http://localhost:8081'; // erp_client_sb +const BASE_RUOYI = 'http://localhost:8080'; // ruoyi-admin + +function resolveBase(path: string): string { + if (path.startsWith('/tool/banma')) return BASE_RUOYI; + return BASE_CLIENT; +} // 将对象转为查询字符串 function buildQuery(params?: Record): string { @@ -17,7 +23,7 @@ function buildQuery(params?: Record): string { // 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理 async function request(path: string, options: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await fetch(`${resolveBase(path)}${path}`, { credentials: 'omit', cache: 'no-store', ...options, @@ -44,9 +50,12 @@ export const http = { post(path: string, body?: unknown) { return request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }); }, + delete(path: string) { + return request(path, { method: 'DELETE' }); + }, // 用于无需读取响应体的 POST(如删除/心跳等),从根源避免读取中断 postVoid(path: string, body?: unknown) { - return fetch(`${BASE_URL}${path}`, { + return fetch(`${resolveBase(path)}${path}`, { method: 'POST', body: body ? JSON.stringify(body) : undefined, credentials: 'omit', @@ -59,7 +68,7 @@ export const http = { }, // 文件上传:透传 FormData,不设置 Content-Type 让浏览器自动处理 upload(path: string, form: FormData) { - const res = fetch(`${BASE_URL}${path}`, { + const res = fetch(`${resolveBase(path)}${path}`, { method: 'POST', body: form, credentials: 'omit', diff --git a/electron-vue-template/src/renderer/api/zebra.ts b/electron-vue-template/src/renderer/api/zebra.ts index bbec8cf..e071385 100644 --- a/electron-vue-template/src/renderer/api/zebra.ts +++ b/electron-vue-template/src/renderer/api/zebra.ts @@ -27,23 +27,48 @@ export interface ZebraOrdersResp { import { http } from './http'; +export interface BanmaAccount { + id?: number; + name?: string; + username?: string; + token?: string; + tokenExpireAt?: string | number; + isDefault?: number; + status?: number; + remark?: string; +} + // 斑马 API:与原 zebra-api.js 对齐的接口封装 export const zebraApi = { - getOrders(params: Record) { - return http.get('/api/banma/orders', params); + // 账号管理(ruoyi-admin) + getAccounts() { + return http.get<{ code?: number; msg?: string; data: BanmaAccount[] }>('/tool/banma/accounts'); }, + saveAccount(body: BanmaAccount) { + return http.post<{ id: number }>('/tool/banma/accounts', body); + }, + removeAccount(id: number) { + // 用 postVoid 也可,但这里前端未用到,保留以备将来 + return http.delete(`/tool/banma/accounts/${id}`); + }, + + // 业务采集(仍走客户端微服务 8081) + getShops() { + return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>( + '/api/banma/shops' + ); + }, + getOrders(params: { startDate?: string; endDate?: string; page?: number; pageSize?: number; shopIds?: string }) { + return http.get('/api/banma/orders', params as unknown as Record); + }, + + // 其他功能(客户端微服务) getOrdersByBatch(batchId: string) { return http.get(`/api/banma/orders/batch/${batchId}`); }, getLatestOrders() { return http.get('/api/banma/orders/latest'); }, - getShops() { - return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>('/api/banma/shops'); - }, - refreshToken() { - return http.post('/api/banma/refresh-token'); - }, exportAndSaveOrders(exportData: unknown) { return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData); }, diff --git a/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue b/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue index e072b6c..d887097 100644 --- a/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue +++ b/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue @@ -1,5 +1,6 @@ +// 账号对话框 +const accountDialogVisible = ref(false) +const accountForm = ref({ isDefault: 0, status: 1 }) +const isEditMode = ref(false) +const formUsername = ref('') +const formPassword = ref('') +const rememberPwd = ref(true) +const managerVisible = ref(false) + +function openAddAccount() { + isEditMode.value = false + accountForm.value = { name: '', username: '', isDefault: 0, status: 1 } + formUsername.value = '' + formPassword.value = '' + accountDialogVisible.value = true +} + +function openManageAccount() { + const cur = accounts.value.find(a => a.id === accountId.value) + if (!cur) return + isEditMode.value = true + accountForm.value = { ...cur } + formUsername.value = cur.username || '' + formPassword.value = localStorage.getItem(`banma:pwd:${cur.username || ''}`) || '' + accountDialogVisible.value = true +} + +async function submitAccount() { + if (!formUsername.value) { alert('请输入账号'); return } + const payload: BanmaAccount = { + id: accountForm.value.id, + name: accountForm.value.name || formUsername.value, + username: formUsername.value, + isDefault: accountForm.value.isDefault || 0, + status: accountForm.value.status || 1, + } + const { id } = await zebraApi.saveAccount(payload) + if (rememberPwd.value && formPassword.value) { + localStorage.setItem(`banma:pwd:${formUsername.value}`, formPassword.value) + } else { + localStorage.removeItem(`banma:pwd:${formUsername.value}`) + } + accountDialogVisible.value = false + await loadAccounts() + if (id) accountId.value = id +} + +async function removeCurrentAccount() { + if (!isEditMode.value || !accountForm.value.id) return + if (!confirm('确认删除该账号?')) return + await zebraApi.removeAccount(accountForm.value.id) + accountDialogVisible.value = false + await loadAccounts() +} + @@ -280,26 +425,68 @@ export default { diff --git a/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java b/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java index 0cdba52..0c75a41 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java @@ -120,9 +120,7 @@ public class AuthController { public JsonData saveCache(@RequestBody Map data) { String key = (String) data.get("key"); String value = (String) data.get("value"); - if (key == null || value == null) return JsonData.buildError("key和value不能为空"); - CacheDataEntity entity = cacheDataRepository.findByCacheKey(key).orElse(new CacheDataEntity()); entity.setCacheKey(key); entity.setCacheValue(value); @@ -157,6 +155,7 @@ public class AuthController { if (key == null || key.trim().isEmpty()) { return JsonData.buildError("key不能为空"); } + System.out.println("key: " + key); cacheDataRepository.deleteByCacheKey(key); return JsonData.buildSuccess("缓存数据删除成功"); } @@ -165,18 +164,16 @@ public class AuthController { * 会话引导:检查SQLite中是否存在token */ @GetMapping("/session/bootstrap") - public ResponseEntity sessionBootstrap() { + public JsonData sessionBootstrap() { Optional tokenEntity = cacheDataRepository.findByCacheKey("token"); if (tokenEntity.isEmpty()) { - return ResponseEntity.status(401).body(Map.of("code", 401, "message", "无可用会话,请重新登录")); + return JsonData.buildError("无可用会话,请重新登录"); } String token = tokenEntity.get().getCacheValue(); if (token == null || token.isEmpty()) { - return ResponseEntity.status(401).body(Map.of("code", 401, "message", "无可用会话,请重新登录")); + return JsonData.buildError("无可用会话,请重新登录"); } - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60)); - return ResponseEntity.ok().headers(headers).body(Map.of("code", 200, "message", "会话已恢复")); + return JsonData.buildSuccess("会话已恢复"); } private String buildHttpOnlyCookie(String name, String value, int maxAgeSeconds) { diff --git a/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java b/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java index 1f1c49f..1affdcb 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java @@ -42,6 +42,11 @@ public class DeviceProxyController { return apiForwarder.post("/monitor/device/remove", body, auth); } + @PostMapping("/api/device/offline") + public ResponseEntity deviceOffline(@RequestBody Map body, @RequestHeader(value = "Authorization", required = false) String auth) { + return apiForwarder.post("/monitor/device/offline", body, auth); + } + /** * 设备心跳 */ diff --git a/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java index 6033099..7ccfc61 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java @@ -83,6 +83,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service { */ @Override public SearchResult get1688Detail(String uploadedUrl) { + uploadedUrl = uploadedUrl.split("\\?")[0]; String fileName = "temp_" + System.currentTimeMillis() + ".png"; List detailUrls = new ArrayList<>(); SearchResult result = new SearchResult(); @@ -101,21 +102,36 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service { MultiValueMap formData = new LinkedMultiValueMap<>(); formData.add("data", jsonData); HttpEntity> requestEntity = new HttpEntity<>(formData, headers); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class); - JsonNode root = objectMapper.readTree(response.getBody()); - Iterator offerIterator = root.path("data").path("offerList").path("offers").elements(); - //运费 + Iterator offerIterator = null; + for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class); + JsonNode root = objectMapper.readTree(response.getBody()); + offerIterator = root.path("data").path("offerList").path("offers").elements(); + } + //运费 - 收集所有运费数据 Set freight = new HashSet<>(); - for (int i = 0; i < 10 && offerIterator.hasNext(); i++) { + while (offerIterator.hasNext()) { JsonNode offer = offerIterator.next(); - String offerId = offer.path("id").asText(); String freightProvFirstFee = offer.path("freightProvFirstFee").asText(); Optional.ofNullable(freightProvFirstFee) .map(s -> s.split(";", 2)[0]) .map(s -> s.split(":", 2)) .filter(parts -> parts.length == 2 && !parts[1].isBlank()) .map(parts -> Double.parseDouble(parts[1]) / 100.0) + .filter(fee -> fee > 0) .ifPresent(freight::add); + } + + offerIterator = null; + for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class); + JsonNode root = objectMapper.readTree(response.getBody()); + offerIterator = root.path("data").path("offerList").path("offers").elements(); + } + + for (int i = 0; i < 10 && offerIterator.hasNext(); i++) { + JsonNode offer = offerIterator.next(); + String offerId = offer.path("id").asText(); prices.add(offer.path("normalPrice").asDouble()); detailUrls.add(offerId); } @@ -136,12 +152,12 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service { break; } } - + System.out.println("url"+uploadedUrl); + System.out.println("skuPrices:"+skuPrices); result.setSkuPrice(skuPrices); result.setMedian( median); result.setMapRecognitionLink( uploadImageBase64(imageUrl)); - System.out.println("运费"+freightFee); - result.setFreight(freightFee.isEmpty() ? 0.0 :freightFee.get(freightFee.size()/2-1)); + result.setFreight(freightFee.isEmpty() ? 0.0 : freightFee.get(Math.max(0, freightFee.size()/2-1))); // String weight = getWeight(detailUrls); // result.setWeight(weight); return result; diff --git a/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java index fba6b96..c71e4e8 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java @@ -81,6 +81,7 @@ public class AuthServiceImpl implements IAuthService { if (accessToken != null) { saveTokenToCache(accessToken); + registerDeviceOnLogin(username); } result.put("success", true); @@ -201,6 +202,7 @@ public class AuthServiceImpl implements IAuthService { accessToken = newAccessToken; refreshToken = newRefreshToken; saveTokenToCache(newAccessToken); + registerDeviceOnLogin(username); } result.put("success", true); @@ -238,6 +240,9 @@ public class AuthServiceImpl implements IAuthService { */ public void logout() { try { + // 通知服务器设备离线 + setDeviceOffline(); + // 清除内存中的token accessToken = null; refreshToken = null; @@ -246,4 +251,28 @@ public class AuthServiceImpl implements IAuthService { cacheDataRepository.deleteByCacheKey("token"); } catch (Exception ignored) {} } + + /** + * 登录时注册设备 + */ + private void registerDeviceOnLogin(String username) { + try { + Map deviceData = new HashMap<>(); + deviceData.put("username", username); + deviceData.put("deviceId", clientId); + deviceData.put("os", System.getProperty("os.name")); + apiForwarder.post("/monitor/device/register", deviceData, buildAuthHeader()); + } catch (Exception ignored) {} + } + + /** + * 设备离线 + */ + private void setDeviceOffline() { + try { + Map offlineData = new HashMap<>(); + offlineData.put("deviceId", clientId); + apiForwarder.post("/monitor/device/offline", offlineData, buildAuthHeader()); + } catch (Exception ignored) {} + } } \ No newline at end of file diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java index f758f85..cbc6b21 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java @@ -198,11 +198,13 @@ public class ClientAccountController extends BaseController { Map claims = Jwts.parser().setSigningKey(jwtRsaKeyService.getPublicKey()).parseClaimsJws(token).getBody(); String username = (String) claims.getOrDefault("sub", claims.get("subject")); String tokenClientId = (String) claims.get("clientId"); + if (username == null || tokenClientId == null || !tokenClientId.equals(clientId)) { throw new RuntimeException("会话不匹配"); } + SseEmitter emitter = sseHubService.register(username, clientId, 0L); - try { emitter.send(SseEmitter.event().name("ready").data("ok")); } catch (Exception ignored) {} + try { emitter.send(SseEmitter.event().data("{\"type\":\"ready\"}")); } catch (Exception ignored) {} return emitter; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java index 77cae40..103deff 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java @@ -114,11 +114,31 @@ public class ClientDeviceController { return AjaxResult.success(); } if (!"removed".equals(exists.getStatus())) { + // 先推送下线事件,再断开连接 + sseHubService.sendEvent(exists.getUsername(), deviceId, "DEVICE_REMOVED", "{}"); + // 立即断开SSE连接,防止重新上线 + sseHubService.disconnectDevice(exists.getUsername(), deviceId); + // 更新设备状态 exists.setStatus("removed"); exists.setLastActiveAt(new java.util.Date()); clientDeviceMapper.updateByDeviceId(exists); - // 推送SSE下线事件 - try { sseHubService.sendEvent(exists.getUsername(), deviceId, "DEVICE_REMOVED", "{}"); } catch (Exception ignored) {} + } + return AjaxResult.success(); + } + + /** + * 设备离线 + */ + @PostMapping("/offline") + public AjaxResult offline(@RequestBody Map body) { + String deviceId = body.get("deviceId"); + if (deviceId == null) return AjaxResult.error("deviceId不能为空"); + + ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId); + if (device != null) { + device.setStatus("offline"); + device.setLastActiveAt(new java.util.Date()); + clientDeviceMapper.updateByDeviceId(device); } return AjaxResult.success(); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java index 5502fde..4fb10e8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java @@ -1,430 +1,52 @@ package com.ruoyi.web.controller.tool; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.stream.Collectors; +import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.bind.annotation.*; import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.R; -import com.ruoyi.common.utils.StringUtils; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiParam; -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; +import com.ruoyi.system.domain.BanmaAccount; +import com.ruoyi.system.service.IBanmaAccountService; + /** - * 斑马订单控制器 - * - * @author ruoyi + * 斑马账号管理(数据库版,极简接口): + * - 仅负责账号与 Token 的存取 + * - 不参与登录/刷新与数据采集,客户端自行处理 */ -@Api("斑马订单接口") @RestController @RequestMapping("/tool/banma") @Anonymous public class BanmaOrderController extends BaseController { - private static String AUTH_TOKEN = "Bearer e5V8Vlaf9xh5i31xaI300wbdXEE3iLtgip+JXfzZsb7GShP2XCGhoVzTEVxyc8LH"; - private static final String LOGIN_URL = "https://banma365.cn/api/login"; - private static final String LOGIN_USERNAME = "大赢家网络科技(主账号)"; - private static final String LOGIN_PASSWORD = "banma123456"; - private static final String API_URL = "https://banma365.cn/api/order/list?recipientName=&page=%d&size=%d&markFlag=0&state=4&_t=%d"; - private static final String API_URL_WITH_TIME = "https://banma365.cn/api/order/list?recipientName=&page=%d&size=%d&markFlag=0&state=4&orderedAtStart=%s&orderedAtEnd=%s&_t=%d"; - private static final String TRACKING_URL = "https://banma365.cn/zebraExpressHub/web/tracking/getByExpressNumber/%s"; - private static final int CONNECTION_TIMEOUT = 999999999; - private static final int READ_TIMEOUT = 999999999; - private static final int DEFAULT_PAGE_SIZE = 20; @Autowired - private RestTemplate restTemplate; + private IBanmaAccountService accountService; - @Autowired - private SagawaExpressController sagawaExpressController; - - private final ExecutorService executorService = Executors.newFixedThreadPool(10); - - public BanmaOrderController() { - HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); - factory.setConnectTimeout(CONNECTION_TIMEOUT); - factory.setReadTimeout(READ_TIMEOUT); - restTemplate = new RestTemplate(factory); - } /** - * 初始化方法,启动时刷新token + * 查询账号列表(仅返回必要字段) */ - @PostConstruct - public void init() { - refreshToken(); - } - - /** - * 关闭线程池 - */ - @PreDestroy - public void destroy() { - executorService.shutdownNow(); - } - - @Scheduled(fixedRate = 86400000 * 3) - public void refreshToken() { - try { - // 1. 输入准备:构建请求参数 - Map loginParams = new HashMap<>(); - loginParams.put("username", LOGIN_USERNAME); - loginParams.put("password", LOGIN_PASSWORD); - - HttpHeaders headers = new HttpHeaders(); - headers.set("Content-Type", "application/json"); - ResponseEntity response = restTemplate.postForEntity( - LOGIN_URL, - new HttpEntity<>(loginParams, headers), - Map.class - ); - Optional.ofNullable(response.getBody()) - .filter(body -> Integer.valueOf(0).equals(body.get("code"))) - .map(body -> (Map) body.get("data")) - .map(data -> (String) data.get("token")) - .filter(StringUtils::isNotEmpty) - .ifPresent(token -> { - AUTH_TOKEN = "Bearer " + token; - logger.info("斑马token刷新成功: {}", token); - }); - } catch (Exception e) { - logger.error("斑马token刷新异常: {}", e.getMessage()); - } + @GetMapping("/accounts") + public R listAccounts() { + List list = accountService.listSimple(); + return R.ok(list); } /** - * 创建HTTP请求实体 + * 新增或编辑账号(含设为默认) */ - private HttpEntity createHttpEntity() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", AUTH_TOKEN); - return new HttpEntity<>(headers); + @PostMapping("/accounts") + public R saveAccount(@RequestBody BanmaAccount body) { + Long id = accountService.saveOrUpdate(body); + return R.ok(Map.of("id", id)); } /** - * 处理订单数据 + * 删除账号 */ - @SuppressWarnings("unchecked") - private CompletableFuture> processOrderDataAsync(Map order) { - return CompletableFuture.supplyAsync(() -> { - if (order == null) return null; - - Map simplifiedOrder = new HashMap<>(); - - // 提取国际运单号和运费 - String trackingNumber = (String) order.get("internationalTrackingNumber"); - simplifiedOrder.put("internationalTrackingNumber", trackingNumber); - simplifiedOrder.put("internationalShippingFee", order.get("internationalShippingFee")); - - // 获取物流轨迹信息 - if (StringUtils.isNotEmpty(trackingNumber)) { - simplifiedOrder.put("trackInfo", getTrackingInfo(trackingNumber)); - } - - // 处理子订单信息 - Optional.ofNullable(order.get("subOrders")) - .map(subOrders -> (List>) subOrders) - .filter(list -> !list.isEmpty()) - .map(list -> list.get(0)) - .ifPresent(subOrder -> extractSubOrderFields(simplifiedOrder, subOrder)); - return simplifiedOrder; - }, executorService); + @DeleteMapping("/accounts/{id}") + public R remove(@PathVariable Long id) { + accountService.remove(id); + return R.ok(); } - /** - * 提取子订单字段 - */ - private void extractSubOrderFields(Map simplifiedOrder, Map subOrder) { - // 基础信息 - simplifiedOrder.put("orderedAt", subOrder.get("orderedAt")); - simplifiedOrder.put("timeSinceOrder", subOrder.get("timeSinceOrder")); - simplifiedOrder.put("productImage", subOrder.get("productImage")); - simplifiedOrder.put("createdAt", subOrder.get("createdAt")); - simplifiedOrder.put("poTrackingNumber", subOrder.get("poTrackingNumber")); - // 商品信息 - simplifiedOrder.put("productTitle", subOrder.get("productTitle")); - simplifiedOrder.put("shopOrderNumber", subOrder.get("shopOrderNumber")); - simplifiedOrder.put("priceJpy", subOrder.get("priceJpy")); - simplifiedOrder.put("productQuantity", subOrder.get("productQuantity")); - simplifiedOrder.put("shippingFeeJpy", subOrder.get("shippingFeeJpy")); - simplifiedOrder.put("productNumber", subOrder.get("productNumber")); - - // 采购信息 - simplifiedOrder.put("poNumber", subOrder.get("poNumber")); - simplifiedOrder.put("shippingFeeCny", subOrder.get("shippingFeeCny")); - simplifiedOrder.put("poLogisticsCompany", subOrder.get("poLogisticsCompany")); - } - - /** - * 获取斑马订单数据 - 异步方法 - */ - @SuppressWarnings("unchecked") - private CompletableFuture>> fetchOrdersFromApiAsync(int page, int size, String startDate, String endDate) { - return CompletableFuture.supplyAsync(() -> { - try { - HttpEntity entity = createHttpEntity(); - String url = buildApiUrl(page, size, startDate, endDate); - - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); - Map responseBody = response.getBody(); - - if (responseBody == null || !responseBody.containsKey("data")) { - return Collections.emptyList(); - } - - Map dataMap = (Map) responseBody.get("data"); - List> orders = Optional.ofNullable(dataMap.get("list")) - .map(list -> (List>) list) - .orElse(Collections.emptyList()); - - return orders; - } catch (Exception e) { - logger.error("获取订单数据失败: {}", e.getMessage()); - return Collections.emptyList(); - } - }, executorService); - } - - /** - * 构建API URL - */ - private String buildApiUrl(int page, int size, String startDate, String endDate) { - if (StringUtils.isNotEmpty(startDate) && StringUtils.isNotEmpty(endDate)) { - String startTime = startDate + " 00:00:00"; - String endTime = endDate + " 23:59:59"; - return String.format(API_URL_WITH_TIME, page, size, startTime, endTime, System.currentTimeMillis()); - } - return String.format(API_URL, page, size, System.currentTimeMillis()); - } - - /** - * 获取物流轨迹信息 - */ - @SuppressWarnings("unchecked") - private String getTrackingInfo(String trackingNumber) { - try { - R> sagawaResult = sagawaExpressController.getTrackingInfo(trackingNumber); - if (sagawaResult != null && sagawaResult.getCode() == 200) { - Map sagawaData = sagawaResult.getData(); - if (sagawaData != null && "success".equals(sagawaData.get("status"))) { - Map trackInfo = (Map) sagawaData.get("trackInfo"); - if (trackInfo != null) { - return String.format("%s - %s - %s", - trackInfo.get("status"), - trackInfo.get("dateTime"), - trackInfo.get("office")); - } - } - } - try { - String url = String.format(TRACKING_URL, trackingNumber); - ResponseEntity response = restTemplate.getForEntity(url, Map.class); - Map responseBody = response.getBody(); - if (responseBody != null && Integer.valueOf(0).equals(responseBody.get("code"))) { - return Optional.ofNullable(responseBody.get("data")) - .map(data -> (List>) data) - .filter(list -> !list.isEmpty()) - .map(list -> list.get(0)) - .map(track -> (String) track.get("track")) - .orElse(null); - } - } catch (Exception e) { - logger.error("从斑马API获取物流信息失败: {}", e.getMessage()); - } - } catch (Exception e) { - logger.error("获取物流信息失败: {}", e.getMessage()); - } - - return "暂无物流信息"; - } - - /** - * 获取物流轨迹信息 - */ - @ApiOperation("获取物流轨迹信息") - @GetMapping("/tracking/{trackingNumber}") - public R getTracking(@PathVariable("trackingNumber") String trackingNumber) { - try { - String trackInfo = getTrackingInfo(trackingNumber); - return trackInfo != null ? R.ok(trackInfo) : R.fail("未找到物流信息"); - } catch (Exception e) { - return R.fail("获取物流信息失败: " + e.getMessage()); - } - } - - /** - * 获取所有页的斑马订单 - 优化版本 - */ - @ApiOperation("获取所有页的斑马订单") - @GetMapping("/orders/all") - @SuppressWarnings("unchecked") - public DeferredResult>> getAllOrders( - @ApiParam("开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startDate, - @ApiParam("结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endDate) { - DeferredResult>> deferredResult = new DeferredResult<>(9999000L); - CompletableFuture.runAsync(() -> { - try { - HttpEntity entity = createHttpEntity(); - String url = buildApiUrl(1, DEFAULT_PAGE_SIZE, startDate, endDate); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); - Map responseBody = response.getBody(); - Map dataMap = (Map) responseBody.get("data"); - int totalCount = ((Number) dataMap.getOrDefault("total", 0)).intValue(); - - List> orders = Optional.ofNullable(dataMap.get("list")) - .map(list -> (List>) list) - .orElse(Collections.emptyList()); - - List>> futures = orders.stream() - .map(this::processOrderDataAsync) - .toList(); - - CompletableFuture allFutures = CompletableFuture.allOf( - futures.toArray(new CompletableFuture[0]) - ); - - - // 收集所有处理结果 - CompletableFuture>> resultsFuture = allFutures.thenApply(v -> - futures.stream() - .map(CompletableFuture::join) - .filter(Objects::nonNull) - .collect(Collectors.toList()) - ); - List> processedOrders = resultsFuture.get(); - int totalPages = (int) Math.ceil((double) totalCount / DEFAULT_PAGE_SIZE); - boolean hasMore = totalCount > 0 && 1 < totalPages; - Map resultMap = new HashMap<>(); - resultMap.put("orders", processedOrders); - resultMap.put("total", totalCount); - resultMap.put("totalPages", totalPages); - resultMap.put("hasMore", hasMore); - resultMap.put("nextPage", 2); - deferredResult.setResult(R.ok(resultMap)); - } catch (Exception e) { - logger.error("获取订单数据失败: {}", e.getMessage()); - deferredResult.setResult(R.fail("获取订单失败: " + e.getMessage())); - } - }, executorService); - return deferredResult; - } - - /** - * 获取下一页斑马订单 - 优化版本 - */ - @ApiOperation("获取下一页斑马订单") - @GetMapping("/orders/next") - public DeferredResult>> getNextPageOrders( - @RequestParam(value = "page", defaultValue = "1") Integer page, - @ApiParam("开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startDate, - @ApiParam("结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endDate) { - DeferredResult>> deferredResult = new DeferredResult<>(999999999L); - CompletableFuture.runAsync(() -> { - try { - // 获取总页数信息 - HttpEntity entity = createHttpEntity(); - String url = buildApiUrl(1, DEFAULT_PAGE_SIZE, startDate, endDate); - ResponseEntity countResponse = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); - Map countResponseBody = countResponse.getBody(); - int totalPages = 1; - - if (countResponseBody != null && countResponseBody.containsKey("data")) { - Map dataMap = (Map) countResponseBody.get("data"); - int totalCount = ((Number) dataMap.getOrDefault("total", 0)).intValue(); - totalPages = (int) Math.ceil((double) totalCount / DEFAULT_PAGE_SIZE); - } - - // 获取当前页数据 - CompletableFuture>> ordersFuture = fetchOrdersFromApiAsync(page, DEFAULT_PAGE_SIZE, startDate, endDate); - List> orders = ordersFuture.get(); - - // 并行处理订单数据 - List>> processFutures = orders.stream() - .map(this::processOrderDataAsync) - .collect(Collectors.toList()); - - // 等待所有处理完成 - CompletableFuture allFutures = CompletableFuture.allOf( - processFutures.toArray(new CompletableFuture[0]) - ); - - CompletableFuture>> resultsFuture = allFutures.thenApply(v -> - processFutures.stream() - .map(CompletableFuture::join) - .filter(order -> order != null) - .collect(Collectors.toList()) - ); - - List> processedOrders = resultsFuture.get(); - // 修改hasMore判断逻辑,根据当前页数和总页数判断 - boolean hasMore = page < totalPages; - Map resultMap = new HashMap<>(); - resultMap.put("orders", processedOrders); - resultMap.put("hasMore", hasMore); - resultMap.put("nextPage", page + 1); - resultMap.put("totalPages", totalPages); - - deferredResult.setResult(R.ok(resultMap)); - } catch (Exception e) { - logger.error("获取下一页订单失败: {}", e.getMessage()); - deferredResult.setResult(R.fail("获取订单失败: " + e.getMessage())); - } - }, executorService); - - return deferredResult; - } - - /** - * 图片代理接口 - */ - @ApiOperation("图片代理接口") - @GetMapping("/image-proxy") - public void imageProxy(@RequestParam("url") String imageUrl, javax.servlet.http.HttpServletResponse response) { - if (StringUtils.isEmpty(imageUrl)) { - return; - } - try { - HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); - factory.setConnectTimeout(999999999); - factory.setReadTimeout(999999999); - RestTemplate proxyTemplate = new RestTemplate(factory); - ResponseEntity imageResponse = proxyTemplate.getForEntity(imageUrl, byte[].class); - byte[] imageBytes = imageResponse.getBody(); - if (imageBytes != null) { - String contentType = Optional.ofNullable(imageResponse.getHeaders().getContentType()) - .map(Object::toString) - .orElse("image/jpeg"); - response.setContentType(contentType); - response.setContentLength(imageBytes.length); - response.getOutputStream().write(imageBytes); - response.getOutputStream().flush(); - } - } catch (Exception e) { - logger.error("图片代理请求失败: {}", e.getMessage()); - } - } - - /** - * 手动刷新token接口 - */ - @GetMapping("/refresh-token") - public R manualRefreshToken() { - refreshToken(); - return R.ok("Token刷新请求已执行"); - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java b/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java index a364728..1d1b552 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java @@ -1,9 +1,13 @@ package com.ruoyi.web.sse; +import com.ruoyi.system.domain.ClientDevice; +import com.ruoyi.system.mapper.ClientDeviceMapper; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -12,6 +16,9 @@ public class SseHubService { private final Map sessionEmitters = new ConcurrentHashMap<>(); + @Autowired + private ClientDeviceMapper clientDeviceMapper; + public String buildSessionKey(String username, String clientId) { return (username == null ? "" : username) + ":" + (clientId == null ? "" : clientId); } @@ -20,18 +27,36 @@ public class SseHubService { String key = buildSessionKey(username, clientId); SseEmitter emitter = new SseEmitter(timeoutMs != null ? timeoutMs : 0L); sessionEmitters.put(key, emitter); - emitter.onCompletion(() -> sessionEmitters.remove(key)); - emitter.onTimeout(() -> sessionEmitters.remove(key)); + + // SSE连接建立 = 设备上线 + updateDeviceStatus(clientId, "online"); + + emitter.onCompletion(() -> { + sessionEmitters.remove(key); + updateDeviceStatus(clientId, "offline"); + }); + emitter.onTimeout(() -> { + sessionEmitters.remove(key); + updateDeviceStatus(clientId, "offline"); + }); + emitter.onError((throwable) -> { + sessionEmitters.remove(key); + updateDeviceStatus(clientId, "offline"); + }); + return emitter; } public void sendEvent(String username, String clientId, String type, String message) { String key = buildSessionKey(username, clientId); SseEmitter emitter = sessionEmitters.get(key); + if (emitter == null) return; + try { String data = message != null ? message : "{}"; - emitter.send(SseEmitter.event().name("event").data("{\"type\":\"" + type + "\",\"message\":" + escapeJson(data) + "}")); + String eventData = "{\"type\":\"" + type + "\",\"message\":" + escapeJson(data) + "}"; + emitter.send(SseEmitter.event().data(eventData)); } catch (IOException e) { sessionEmitters.remove(key); try { emitter.complete(); } catch (Exception ignored) {} @@ -53,6 +78,39 @@ public class SseHubService { private String escapeJson(String raw) { return "\"" + raw.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } + + /** + * 强制断开指定设备的SSE连接 + */ + public void disconnectDevice(String username, String clientId) { + String key = buildSessionKey(username, clientId); + SseEmitter emitter = sessionEmitters.remove(key); + if (emitter != null) { + try { + emitter.complete(); + } catch (Exception ignored) {} + } + } + + /** + * 更新设备状态 + */ + private void updateDeviceStatus(String deviceId, String status) { + try { + ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId); + if (device != null) { + // 如果设备被移除,断开SSE连接 + if ("removed".equals(status)) { + disconnectDevice(device.getUsername(), deviceId); + } + device.setStatus(status); + device.setLastActiveAt(new Date()); + clientDeviceMapper.updateByDeviceId(device); + } + } catch (Exception ignored) { + // 静默处理,不影响SSE主流程 + } + } }