From 6434ec3e000df7da6def44010b702c59376510a9 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Wed, 25 Mar 2026 21:33:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B4=AB=E9=B8=9F=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiimage/config/PropertiesConfig.java | 2 +- .../aiimage/config/ZiniaoProperties.java | 27 +++ .../modules/ziniao/client/ZiniaoClient.java | 9 + .../ziniao/client/ZiniaoClientImpl.java | 124 ++++++++++ .../controller/ZiniaoAuthController.java | 97 ++++++++ .../model/cache/ZiniaoSessionCacheDto.java | 15 ++ .../model/cache/ZiniaoShopCacheDto.java | 11 + .../model/dto/ZiniaoSwitchShopRequest.java | 13 ++ .../ziniao/model/vo/ZiniaoCurrentShopVo.java | 20 ++ .../ziniao/model/vo/ZiniaoLoginUrlVo.java | 17 ++ .../ziniao/model/vo/ZiniaoSessionVo.java | 32 +++ .../ziniao/model/vo/ZiniaoShopItemVo.java | 20 ++ .../ziniao/model/vo/ZiniaoShopListVo.java | 14 ++ .../ziniao/model/vo/ZiniaoSwitchShopVo.java | 20 ++ .../ziniao/service/ZiniaoAuthService.java | 211 ++++++++++++++++++ .../service/ZiniaoSessionCacheService.java | 95 ++++++++ .../resources/application-local.example.yml | 19 ++ .../src/main/resources/application.yml | 19 ++ 18 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopCacheDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/dto/ZiniaoSwitchShopRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoCurrentShopVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoLoginUrlVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSessionVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopListVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSwitchShopVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index 5a099af..f6c596d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class}) +@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, ZiniaoProperties.class}) public class PropertiesConfig { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java new file mode 100644 index 0000000..4f54a38 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.ziniao") +public class ZiniaoProperties { + private boolean enabled; + private String baseUrl; + private String apiKey; + private Long companyId; + private String shopsPath; + private String switchShopPath; + private String openStoreScheme; + private String openStoreUserId; + private String openStoreLaunchUrl; + private Boolean openStoreAutoOpen; + private Integer openStoreDebugPort; + private Boolean openStoreNotPromptForDownload; + private String openStoreForceDownloadPath; + private String openStoreExtraArgs; + private long sessionTtlHours; + private long shopsCacheMinutes; + private int connectTimeoutSeconds; + private int readTimeoutSeconds; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java new file mode 100644 index 0000000..2c2a1c2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.ziniao.client; + +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; + +import java.util.List; + +public interface ZiniaoClient { + List listShops(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java new file mode 100644 index 0000000..3ff2bc1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -0,0 +1,124 @@ +package com.nanri.aiimage.modules.ziniao.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.ZiniaoProperties; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Component +@RequiredArgsConstructor +public class ZiniaoClientImpl implements ZiniaoClient { + + private final ZiniaoProperties ziniaoProperties; + private final ObjectMapper objectMapper; + + @Override + public List listShops() { + if (ziniaoProperties.getCompanyId() == null || ziniaoProperties.getCompanyId() <= 0) { + throw new BusinessException("紫鸟 companyId 未配置"); + } + String raw = getRestClient().post() + .uri(joinUrl(ziniaoProperties.getBaseUrl(), ziniaoProperties.getShopsPath())) + .headers(headers -> { + headers.setBearerAuth(ziniaoProperties.getApiKey()); + headers.setContentType(MediaType.APPLICATION_JSON); + }) + .body(java.util.Map.of("companyId", ziniaoProperties.getCompanyId())) + .retrieve() + .body(String.class); + return parseShops(raw); + } + + private RestClient getRestClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(ziniaoProperties.getConnectTimeoutSeconds() * 1000); + requestFactory.setReadTimeout(ziniaoProperties.getReadTimeoutSeconds() * 1000); + return RestClient.builder().requestFactory(requestFactory).build(); + } + + private List parseShops(String raw) { + try { + JsonNode root = objectMapper.readTree(raw); + String code = text(root.get("code")); + if (code != null && !"0".equals(code)) { + throw new BusinessException("紫鸟店铺接口返回失败: " + Objects.toString(text(root.get("msg")), "未知错误")); + } + JsonNode wrapper = root.get("data"); + if (wrapper != null && wrapper.isObject()) { + String status = text(wrapper.get("status")); + Long ret = longValue(wrapper.get("ret")); + if ((ret != null && ret != 0L) || (status != null && !"success".equalsIgnoreCase(status))) { + throw new BusinessException("紫鸟店铺接口业务失败: " + Objects.toString(status, "unknown")); + } + } + JsonNode itemsNode = wrapper == null ? null : wrapper.get("data"); + List items = new ArrayList<>(); + if (itemsNode != null && itemsNode.isArray()) { + for (JsonNode itemNode : itemsNode) { + ZiniaoShopCacheDto item = new ZiniaoShopCacheDto(); + item.setShopId(text(firstNonNull(itemNode.get("shopId"), itemNode.get("id"), itemNode.get("accountId"), itemNode.get("account_id")))); + item.setShopName(text(firstNonNull(itemNode.get("shopName"), itemNode.get("name"), itemNode.get("accountName"), itemNode.get("account_name")))); + item.setPlatform(text(firstNonNull(itemNode.get("platform"), itemNode.get("platformName"), itemNode.get("site"), itemNode.get("siteName")))); + if (item.getShopId() != null && !item.getShopId().isBlank()) { + items.add(item); + } + } + } + return items; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("解析紫鸟店铺列表失败"); + } + } + + private String joinUrl(String baseUrl, String path) { + String base = Objects.toString(baseUrl, "").trim(); + String suffix = Objects.toString(path, "").trim(); + if (base.endsWith("/") && suffix.startsWith("/")) { + return base + suffix.substring(1); + } + if (!base.endsWith("/") && !suffix.startsWith("/")) { + return base + "/" + suffix; + } + return base + suffix; + } + + private JsonNode firstNonNull(JsonNode... nodes) { + for (JsonNode node : nodes) { + if (node != null && !node.isNull()) { + return node; + } + } + return null; + } + + private String text(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + String value = node.asText(); + return value == null || value.isBlank() ? null : value.trim(); + } + + private Long longValue(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + try { + return node.asLong(); + } catch (Exception ex) { + return null; + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java new file mode 100644 index 0000000..9164249 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java @@ -0,0 +1,97 @@ +package com.nanri.aiimage.modules.ziniao.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoSwitchShopRequest; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoCurrentShopVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoLoginUrlVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSwitchShopVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/ziniao") +@Tag(name = "紫鸟接入", description = "紫鸟 ApiKey 模式下的配置状态、店铺列表、当前店铺与切换店铺接口") +public class ZiniaoAuthController { + + private final ZiniaoAuthService ziniaoAuthService; + + @GetMapping("/login-url") + @Operation(summary = "获取紫鸟接入信息", description = "兼容保留接口,返回当前 ApiKey 模式的接入信息和 sessionId。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoLoginUrlVo.class))) + }) + public ApiResponse getLoginUrl() { + return ApiResponse.success(ziniaoAuthService.getLoginUrl()); + } + + @GetMapping("/callback") + @Operation(summary = "兼容保留回调接口", description = "ApiKey 模式下无真实登录回调,该接口仅兼容旧调用并返回 302。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "重定向到兼容地址") + }) + public ResponseEntity callback(@RequestParam(required = false) String code, @RequestParam(required = false) String state) { + try { + String redirectUrl = ziniaoAuthService.handleCallback(code, state); + return ResponseEntity.status(302) + .header(HttpHeaders.LOCATION, redirectUrl) + .build(); + } catch (Exception ex) { + return ResponseEntity.status(302) + .header(HttpHeaders.LOCATION, ziniaoAuthService.buildFailureRedirect(ex.getMessage())) + .build(); + } + } + + @GetMapping("/session") + @Operation(summary = "获取紫鸟接入状态", description = "根据 sessionId 获取当前 ApiKey 模式会话与当前店铺信息。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoSessionVo.class))) + }) + public ApiResponse getSession(@RequestParam String sessionId) { + return ApiResponse.success(ziniaoAuthService.getSession(sessionId)); + } + + @GetMapping("/shops") + @Operation(summary = "获取紫鸟店铺列表", description = "根据 sessionId 使用配置的 ApiKey 与 companyId 拉取最新店铺列表。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoShopListVo.class))) + }) + public ApiResponse listShops(@RequestParam String sessionId) { + return ApiResponse.success(ziniaoAuthService.listShops(sessionId)); + } + + @GetMapping("/shops/current") + @Operation(summary = "获取当前紫鸟店铺", description = "根据 sessionId 获取当前已选中的店铺。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoCurrentShopVo.class))) + }) + public ApiResponse getCurrentShop(@RequestParam String sessionId) { + return ApiResponse.success(ziniaoAuthService.getCurrentShop(sessionId)); + } + + @PostMapping("/shops/switch") + @Operation(summary = "切换当前紫鸟店铺", description = "根据 sessionId 和 shopId 切换当前选中的店铺。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "切换成功", content = @Content(schema = @Schema(implementation = ZiniaoSwitchShopVo.class))) + }) + public ApiResponse switchShop(@Valid @RequestBody ZiniaoSwitchShopRequest request) { + return ApiResponse.success(ziniaoAuthService.switchShop(request)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java new file mode 100644 index 0000000..cfc72e7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.ziniao.model.cache; + +import lombok.Data; + +@Data +public class ZiniaoSessionCacheDto { + private String sessionId; + private String accessToken; + private String refreshToken; + private String tokenType; + private Long expireAt; + private String ziniaoUserId; + private String nickname; + private String defaultShopId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopCacheDto.java new file mode 100644 index 0000000..16dbbdb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopCacheDto.java @@ -0,0 +1,11 @@ +package com.nanri.aiimage.modules.ziniao.model.cache; + +import lombok.Data; + +@Data +public class ZiniaoShopCacheDto { + private String shopId; + private String shopName; + private String platform; + private Long selectedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/dto/ZiniaoSwitchShopRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/dto/ZiniaoSwitchShopRequest.java new file mode 100644 index 0000000..eddeffd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/dto/ZiniaoSwitchShopRequest.java @@ -0,0 +1,13 @@ +package com.nanri.aiimage.modules.ziniao.model.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ZiniaoSwitchShopRequest { + @NotBlank(message = "sessionId 不能为空") + private String sessionId; + + @NotBlank(message = "shopId 不能为空") + private String shopId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoCurrentShopVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoCurrentShopVo.java new file mode 100644 index 0000000..9eac55c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoCurrentShopVo.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "当前紫鸟店铺") +public class ZiniaoCurrentShopVo { + @Schema(description = "店铺 ID") + private String shopId; + + @Schema(description = "店铺名称") + private String shopName; + + @Schema(description = "平台") + private String platform; + + @Schema(description = "选中时间戳(毫秒)") + private Long selectedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoLoginUrlVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoLoginUrlVo.java new file mode 100644 index 0000000..e44525c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoLoginUrlVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "紫鸟登录地址") +public class ZiniaoLoginUrlVo { + @Schema(description = "登录链接") + private String loginUrl; + + @Schema(description = "登录 state") + private String state; + + @Schema(description = "state 过期时间戳(毫秒)") + private Long expireAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSessionVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSessionVo.java new file mode 100644 index 0000000..05ed620 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSessionVo.java @@ -0,0 +1,32 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "紫鸟会话信息") +public class ZiniaoSessionVo { + @Schema(description = "会话 ID") + private String sessionId; + + @Schema(description = "是否已认证") + private Boolean authenticated; + + @Schema(description = "紫鸟用户 ID") + private String ziniaoUserId; + + @Schema(description = "紫鸟昵称") + private String nickname; + + @Schema(description = "会话过期时间戳(毫秒)") + private Long expireAt; + + @Schema(description = "店铺数量") + private Integer shopCount; + + @Schema(description = "当前店铺 ID") + private String currentShopId; + + @Schema(description = "当前店铺名称") + private String currentShopName; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopItemVo.java new file mode 100644 index 0000000..cd1f1bc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopItemVo.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "紫鸟店铺项") +public class ZiniaoShopItemVo { + @Schema(description = "店铺 ID") + private String shopId; + + @Schema(description = "店铺名称") + private String shopName; + + @Schema(description = "平台") + private String platform; + + @Schema(description = "是否为当前选中") + private Boolean selected; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopListVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopListVo.java new file mode 100644 index 0000000..f5b35f5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopListVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "紫鸟店铺列表") +public class ZiniaoShopListVo { + @Schema(description = "店铺列表") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSwitchShopVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSwitchShopVo.java new file mode 100644 index 0000000..5691cd0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoSwitchShopVo.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "切换紫鸟店铺结果") +public class ZiniaoSwitchShopVo { + @Schema(description = "是否切换成功") + private Boolean success; + + @Schema(description = "当前店铺 ID") + private String currentShopId; + + @Schema(description = "当前店铺名称") + private String currentShopName; + + @Schema(description = "紫鸟启动链接") + private String openStoreUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java new file mode 100644 index 0000000..9535f15 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -0,0 +1,211 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.ZiniaoProperties; +import com.nanri.aiimage.modules.ziniao.client.ZiniaoClient; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoSwitchShopRequest; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoCurrentShopVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoLoginUrlVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopItemVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSwitchShopVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.Base64; + +@Service +@RequiredArgsConstructor +public class ZiniaoAuthService { + + private final ZiniaoProperties ziniaoProperties; + private final ZiniaoClient ziniaoClient; + private final ZiniaoSessionCacheService ziniaoSessionCacheService; + + public ZiniaoLoginUrlVo getLoginUrl() { + ensureEnabled(); + String sessionId = ensureSession(); + ZiniaoLoginUrlVo vo = new ZiniaoLoginUrlVo(); + vo.setState(sessionId); + vo.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); + vo.setLoginUrl(ziniaoProperties.getBaseUrl()); + return vo; + } + + public String handleCallback(String code, String state) { + ensureEnabled(); + String sessionId = ensureSession(); + return ziniaoProperties.getBaseUrl() + "?sessionId=" + sessionId; + } + + public ZiniaoSessionVo getSession(String sessionId) { + ZiniaoSessionCacheDto session = requireSession(sessionId); + List shops = ziniaoSessionCacheService.getShops(sessionId); + ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, shops); + ZiniaoSessionVo vo = new ZiniaoSessionVo(); + vo.setSessionId(session.getSessionId()); + vo.setAuthenticated(true); + vo.setZiniaoUserId(session.getZiniaoUserId()); + vo.setNickname(session.getNickname()); + vo.setExpireAt(session.getExpireAt()); + vo.setShopCount(shops.size()); + if (currentShop != null) { + vo.setCurrentShopId(currentShop.getShopId()); + vo.setCurrentShopName(currentShop.getShopName()); + } + return vo; + } + + public ZiniaoShopListVo listShops(String sessionId) { + requireSession(sessionId); + List shops = ziniaoClient.listShops(); + ziniaoSessionCacheService.saveShops(sessionId, shops); + ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, shops); + ZiniaoShopListVo vo = new ZiniaoShopListVo(); + for (ZiniaoShopCacheDto shop : shops) { + ZiniaoShopItemVo item = new ZiniaoShopItemVo(); + item.setShopId(shop.getShopId()); + item.setShopName(shop.getShopName()); + item.setPlatform(shop.getPlatform()); + item.setSelected(currentShop != null && shop.getShopId().equals(currentShop.getShopId())); + vo.getItems().add(item); + } + return vo; + } + + public ZiniaoCurrentShopVo getCurrentShop(String sessionId) { + requireSession(sessionId); + ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, ziniaoSessionCacheService.getShops(sessionId)); + if (currentShop == null) { + throw new BusinessException("当前会话没有可用店铺"); + } + ZiniaoCurrentShopVo vo = new ZiniaoCurrentShopVo(); + vo.setShopId(currentShop.getShopId()); + vo.setShopName(currentShop.getShopName()); + vo.setPlatform(currentShop.getPlatform()); + vo.setSelectedAt(currentShop.getSelectedAt()); + return vo; + } + + public ZiniaoSwitchShopVo switchShop(ZiniaoSwitchShopRequest request) { + requireSession(request.getSessionId()); + List shops = ziniaoSessionCacheService.getShops(request.getSessionId()); + ZiniaoShopCacheDto targetShop = shops.stream() + .filter(item -> item.getShopId() != null && item.getShopId().equals(request.getShopId())) + .findFirst() + .orElseThrow(() -> new BusinessException("店铺不存在或不属于当前会话")); + targetShop.setSelectedAt(Instant.now().toEpochMilli()); + ziniaoSessionCacheService.saveCurrentShop(request.getSessionId(), targetShop); + ZiniaoSwitchShopVo vo = new ZiniaoSwitchShopVo(); + vo.setSuccess(true); + vo.setCurrentShopId(targetShop.getShopId()); + vo.setCurrentShopName(targetShop.getShopName()); + vo.setOpenStoreUrl(buildOpenStoreUrl(targetShop)); + return vo; + } + + public String buildFailureRedirect(String message) { + return ziniaoProperties.getBaseUrl() + "?message=" + (message == null || message.isBlank() ? "紫鸟授权失败" : message); + } + + private String ensureSession() { + String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", ""); + ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto(); + session.setSessionId(sessionId); + session.setAccessToken(maskApiKey(ziniaoProperties.getApiKey())); + session.setTokenType("Bearer"); + session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); + session.setZiniaoUserId(String.valueOf(defaultCompanyId())); + session.setNickname("ApiKey模式"); + ziniaoSessionCacheService.saveSession(session); + return sessionId; + } + + private ZiniaoSessionCacheDto requireSession(String sessionId) { + ensureEnabled(); + if (sessionId == null || sessionId.isBlank()) { + throw new BusinessException("sessionId 不能为空"); + } + ZiniaoSessionCacheDto session = ziniaoSessionCacheService.getSession(sessionId); + if (session == null) { + throw new BusinessException("当前会话不存在或已过期"); + } + return session; + } + + private ZiniaoShopCacheDto getOrInitCurrentShop(String sessionId, List shops) { + ZiniaoShopCacheDto current = ziniaoSessionCacheService.getCurrentShop(sessionId); + if (current != null) { + return current; + } + if (shops == null || shops.isEmpty()) { + return null; + } + ZiniaoShopCacheDto first = shops.get(0); + first.setSelectedAt(Instant.now().toEpochMilli()); + ziniaoSessionCacheService.saveCurrentShop(sessionId, first); + return first; + } + + private void ensureEnabled() { + if (!ziniaoProperties.isEnabled()) { + throw new BusinessException("紫鸟集成未启用,请先配置环境变量"); + } + if (ziniaoProperties.getApiKey() == null || ziniaoProperties.getApiKey().isBlank() || "change-me".equalsIgnoreCase(ziniaoProperties.getApiKey())) { + throw new BusinessException("紫鸟 ApiKey 未配置"); + } + } + + private long defaultCompanyId() { + return ziniaoProperties.getCompanyId() == null ? 0L : ziniaoProperties.getCompanyId(); + } + + private String maskApiKey(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return ""; + } + if (apiKey.length() <= 8) { + return apiKey; + } + return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4); + } + + private String buildOpenStoreUrl(ZiniaoShopCacheDto shop) { + String scheme = requireText(ziniaoProperties.getOpenStoreScheme(), "紫鸟 open-store-scheme 未配置"); + String userId = requireText(ziniaoProperties.getOpenStoreUserId(), "紫鸟 open-store-user-id 未配置"); + String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 ApiKey 未配置"); + String launchUrl = requireText(ziniaoProperties.getOpenStoreLaunchUrl(), "紫鸟 open-store-launch-url 未配置"); + String encodedLaunchUrl = Base64.getEncoder().encodeToString(launchUrl.getBytes(StandardCharsets.UTF_8)); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(scheme) + .queryParam("storeId", shop.getShopId()) + .queryParam("openapiToken", apiKey) + .queryParam("userId", userId) + .queryParam("lanuchUrl", encodedLaunchUrl) + .queryParam("autoopen", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreAutoOpen())) + .queryParam("debuggPort", ziniaoProperties.getOpenStoreDebugPort()) + .queryParam("notPromptForDownload", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreNotPromptForDownload()) ? 1 : 0); + if (ziniaoProperties.getOpenStoreForceDownloadPath() != null && !ziniaoProperties.getOpenStoreForceDownloadPath().isBlank()) { + String encodedPath = Base64.getEncoder().encodeToString(ziniaoProperties.getOpenStoreForceDownloadPath().getBytes(StandardCharsets.UTF_8)); + builder.queryParam("forceDownloadPath", encodedPath); + } + if (ziniaoProperties.getOpenStoreExtraArgs() != null && !ziniaoProperties.getOpenStoreExtraArgs().isBlank()) { + builder.queryParam("extraArgs", ziniaoProperties.getOpenStoreExtraArgs()); + } + return builder.build(true).toUriString(); + } + + private String requireText(String value, String message) { + if (value == null || value.isBlank()) { + throw new BusinessException(message); + } + return value.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java new file mode 100644 index 0000000..61b4a51 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java @@ -0,0 +1,95 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.ZiniaoProperties; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ZiniaoSessionCacheService { + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final ZiniaoProperties ziniaoProperties; + + public void saveSession(ZiniaoSessionCacheDto session) { + try { + stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); + } catch (Exception ex) { + throw new BusinessException("保存紫鸟会话失败"); + } + } + + public ZiniaoSessionCacheDto getSession(String sessionId) { + String raw = stringRedisTemplate.opsForValue().get(buildSessionKey(sessionId)); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return objectMapper.readValue(raw, ZiniaoSessionCacheDto.class); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟会话失败"); + } + } + + public void saveShops(String sessionId, List shops) { + try { + stringRedisTemplate.opsForValue().set(buildShopsKey(sessionId), objectMapper.writeValueAsString(shops), Duration.ofMinutes(ziniaoProperties.getShopsCacheMinutes())); + } catch (Exception ex) { + throw new BusinessException("保存紫鸟店铺列表失败"); + } + } + + public List getShops(String sessionId) { + String raw = stringRedisTemplate.opsForValue().get(buildShopsKey(sessionId)); + if (raw == null || raw.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(raw, new TypeReference>() {}); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟店铺列表失败"); + } + } + + public void saveCurrentShop(String sessionId, ZiniaoShopCacheDto shop) { + try { + stringRedisTemplate.opsForValue().set(buildCurrentShopKey(sessionId), objectMapper.writeValueAsString(shop), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); + } catch (Exception ex) { + throw new BusinessException("保存当前紫鸟店铺失败"); + } + } + + public ZiniaoShopCacheDto getCurrentShop(String sessionId) { + String raw = stringRedisTemplate.opsForValue().get(buildCurrentShopKey(sessionId)); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return objectMapper.readValue(raw, ZiniaoShopCacheDto.class); + } catch (Exception ex) { + throw new BusinessException("读取当前紫鸟店铺失败"); + } + } + + private String buildSessionKey(String sessionId) { + return "ziniao:session:" + sessionId; + } + + private String buildShopsKey(String sessionId) { + return "ziniao:shops:" + sessionId; + } + + private String buildCurrentShopKey(String sessionId) { + return "ziniao:current-shop:" + sessionId; + } +} diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index 274c75c..7642af0 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -14,3 +14,22 @@ AIIMAGE_OSS_ACCESS_KEY_ID=LTAI5tNpyvzMNz9f2dHarsm8 AIIMAGE_OSS_ACCESS_KEY_SECRET=bQSZnFH455i8tzyOgeahJmUzwmhynz AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp + +AIIMAGE_ZINIAO_ENABLED=false +AIIMAGE_ZINIAO_BASE_URL=https://sbappstoreapi.ziniao.com/openapi-router +AIIMAGE_ZINIAO_API_KEY=change-me +AIIMAGE_ZINIAO_COMPANY_ID=0 +AIIMAGE_ZINIAO_SHOPS_PATH=/superbrowser/rest/v1/erp/shop/list +AIIMAGE_ZINIAO_SWITCH_SHOP_PATH= +AIIMAGE_ZINIAO_OPEN_STORE_SCHEME=superbrowser://OpenStrore +AIIMAGE_ZINIAO_OPEN_STORE_USER_ID= +AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL=https://www.baidu.com +AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN=true +AIIMAGE_ZINIAO_OPEN_STORE_DEBUG_PORT=60001 +AIIMAGE_ZINIAO_OPEN_STORE_NOT_PROMPT_FOR_DOWNLOAD=true +AIIMAGE_ZINIAO_OPEN_STORE_FORCE_DOWNLOAD_PATH= +AIIMAGE_ZINIAO_OPEN_STORE_EXTRA_ARGS=--disable-gpu start-maximized +AIIMAGE_ZINIAO_SESSION_TTL_HOURS=2 +AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES=30 +AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS=5 +AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS=15 diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 92262f2..dbee479 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -72,3 +72,22 @@ aiimage: failed-ttl-hours: ${AIIMAGE_BRAND_PROGRESS_FAILED_TTL_HOURS:2} heartbeat-timeout-minutes: ${AIIMAGE_BRAND_PROGRESS_HEARTBEAT_TIMEOUT_MINUTES:15} stale-check-cron: ${AIIMAGE_BRAND_PROGRESS_STALE_CHECK_CRON:0 */2 * * * *} + ziniao: + enabled: ${AIIMAGE_ZINIAO_ENABLED:false} + base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router} + api-key: ${AIIMAGE_ZINIAO_API_KEY:change-me} + company-id: ${AIIMAGE_ZINIAO_COMPANY_ID:0} + shops-path: ${AIIMAGE_ZINIAO_SHOPS_PATH:/superbrowser/rest/v1/erp/shop/list} + switch-shop-path: ${AIIMAGE_ZINIAO_SWITCH_SHOP_PATH:} + open-store-scheme: ${AIIMAGE_ZINIAO_OPEN_STORE_SCHEME:superbrowser://OpenStrore} + open-store-user-id: ${AIIMAGE_ZINIAO_OPEN_STORE_USER_ID:} + open-store-launch-url: ${AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL:https://www.baidu.com} + open-store-auto-open: ${AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN:true} + open-store-debug-port: ${AIIMAGE_ZINIAO_OPEN_STORE_DEBUG_PORT:60001} + open-store-not-prompt-for-download: ${AIIMAGE_ZINIAO_OPEN_STORE_NOT_PROMPT_FOR_DOWNLOAD:true} + open-store-force-download-path: ${AIIMAGE_ZINIAO_OPEN_STORE_FORCE_DOWNLOAD_PATH:} + open-store-extra-args: ${AIIMAGE_ZINIAO_OPEN_STORE_EXTRA_ARGS:--disable-gpu start-maximized} + session-ttl-hours: ${AIIMAGE_ZINIAO_SESSION_TTL_HOURS:2} + shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30} + connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5} + read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15}