diff --git a/docs/dev-ops/nginx/html/css/login.css b/docs/dev-ops/nginx/html/css/login.css index 6a08fb1..9988d61 100644 --- a/docs/dev-ops/nginx/html/css/login.css +++ b/docs/dev-ops/nginx/html/css/login.css @@ -92,3 +92,27 @@ body { .pulse { animation: pulse 2s infinite; } + +/* ==================== 无痕登录按钮 ==================== */ +.stealth-login-btn { + margin-top: 25px; + padding: 12px 24px; + background-color: #007bff; /* 蓝底 */ + color: #fff; /* 白字 */ + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.25); + transition: background-color 0.25s ease, transform 0.25s ease; +} + +.stealth-login-btn:hover { + background-color: #0056b3; /* 深一点的蓝 */ + transform: translateY(-2px); +} + +.stealth-login-btn:active { + transform: translateY(0); +} diff --git a/docs/dev-ops/nginx/html/js/index.js b/docs/dev-ops/nginx/html/js/index.js index 691df20..387a64e 100644 --- a/docs/dev-ops/nginx/html/js/index.js +++ b/docs/dev-ops/nginx/html/js/index.js @@ -90,28 +90,47 @@ document.addEventListener('DOMContentLoaded', () => { initCountdown(); } + function obfuscateUserId(userId) { + if (userId.length < 4) { + if (userId.length <= 1) { + return userId; + } + const first = userId.charAt(0); + const last = userId.charAt(userId.length - 1); + const middle = '*'.repeat(userId.length - 2); + return first + middle + last; + } else { + const start = userId.slice(0, 2); + const end = userId.slice(-1); + const middle = '*'.repeat(userId.length - 4); + return start + middle + end; + } + } + // 把 teamId / activityId 写到 user-info 的 dataset 上,便于点击时读取 function makeItem(team, price) { - const { userId, targetCount, lockCount, - validTimeCountdown, teamId, activityId: tActId } = team; + const { userId, targetCount, lockCount, validTimeCountdown } = team; + + // ① 先对 userId 脱敏 + const maskedId = obfuscateUserId(userId); + + // ② 继续原本逻辑 const leftNum = Math.max(targetCount - lockCount, 0); const timeText = validTimeCountdown || '00:00:00'; const div = document.createElement('div'); div.className = 'user-item'; div.innerHTML = ` -
-
-
${userId}
-
- 仅剩${leftNum}人成团 - ${timeText} -
+
+
+
${maskedId}
+
+ 仅剩 ${leftNum} 人成团 + ${timeText}
- - `; +
+ + `; return div; } diff --git a/docs/dev-ops/nginx/html/js/login.js b/docs/dev-ops/nginx/html/js/login.js index 501f585..054c1ae 100644 --- a/docs/dev-ops/nginx/html/js/login.js +++ b/docs/dev-ops/nginx/html/js/login.js @@ -1,7 +1,7 @@ /* -------------------- 配置 -------------------- */ const sPayMallUrl = "http://127.0.0.1:8092"; -/* -------------------- 工具函数 -------------------- */ +/* -------------------- Cookie 工具 -------------------- */ function setCookie(name, value, days) { const date = new Date(); date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); @@ -10,36 +10,61 @@ function setCookie(name, value, days) { /* -------------------- 主逻辑 -------------------- */ document.addEventListener("DOMContentLoaded", () => { - // 1) 先拿二维码 ticket - fetch(`${sPayMallUrl}/api/v1/login/weixin_qrcode_ticket`) - .then(res => res.json()) - .then(data => { - if (data.code !== "0000") { - console.error("获取二维码 ticket 失败:", data.info); - return; + + // ① 异步加载 FingerprintJS 并计算浏览器指纹 + import("https://openfpcdn.io/fingerprintjs/v4") + .then(FingerprintJS => FingerprintJS.load()) + .then(fp => fp.get()) + .then(result => { + const sceneStr = result.visitorId.toUpperCase(); // 作为场景值 + + /* ---------- 无痕登录按钮 ---------- */ + const stealthBtn = document.getElementById("stealth-login-btn"); + if (stealthBtn) { + stealthBtn.addEventListener("click", () => { + /** + * 建议:此处可调用后端 “auto_login?sceneStr=xxx” 接口。 + * 下面仅示例:直接把指纹写 cookie 后跳首页。 + */ + setCookie("loginToken", sceneStr, 30); + window.location.href = "./index.html"; + }); } - const ticket = data.data; - const qrCodeImg = document.getElementById("qr-code-img"); - qrCodeImg.src = `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${ticket}`; - qrCodeImg.classList.remove("pulse"); + /* ---------- 获取二维码 ticket ---------- */ + fetch(`${sPayMallUrl}/api/v1/login/weixin_qrcode_ticket_scene?sceneStr=${sceneStr}`) + .then(res => res.json()) + .then(data => { + if (data.code !== "0000") { + console.error("获取二维码 ticket 失败:", data.info); + return; + } - // 2) 轮询确认登录 - const intervalId = setInterval(() => checkLoginStatus(ticket, intervalId), 3000); + const ticket = data.data; + const qrCodeImg = document.getElementById("qr-code-img"); + qrCodeImg.src = `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${ticket}`; + qrCodeImg.classList.remove("pulse"); + + // ② 开始轮询检查登录状态 + const intervalId = setInterval(() => { + checkLoginStatus(ticket, sceneStr, intervalId); + }, 3000); // 每 3 秒检查一次 + }) + .catch(err => console.error("请求失败:", err)); }) - .catch(err => console.error("请求失败:", err)); + .catch(err => console.error("FingerprintJS 加载失败:", err)); }); /* -------------------- 轮询检查登录 -------------------- */ -function checkLoginStatus(ticket, intervalId) { - fetch(`${sPayMallUrl}/api/v1/login/check_login?ticket=${ticket}`) +function checkLoginStatus(ticket, sceneStr, intervalId) { + fetch(`${sPayMallUrl}/api/v1/login/check_login_scene?ticket=${ticket}&sceneStr=${sceneStr}`) .then(res => res.json()) .then(data => { if (data.code === "0000") { - // 登录成功,停轮询 + console.info("login success"); clearInterval(intervalId); - // 把 token 写入 cookie(30 天) + // 把后端返回的登录凭证写入 cookie(30 天) setCookie("loginToken", data.data, 30); // 跳转首页 diff --git a/docs/dev-ops/nginx/html/login.html b/docs/dev-ops/nginx/html/login.html index 488b2c1..ef895e4 100644 --- a/docs/dev-ops/nginx/html/login.html +++ b/docs/dev-ops/nginx/html/login.html @@ -2,10 +2,11 @@ - + S Pay Mall 商城登录 - + @@ -15,16 +16,24 @@
- 微信二维码 + 微信二维码

请使用微信扫描二维码登录
扫码后自动登录商城

+ + +
- + diff --git a/docs/tag/tagv1.0/docker-compose-app-v2.0.yml b/docs/tag/tagv1.0/docker-compose-app-v2.0.yml new file mode 100644 index 0000000..2e78cc2 --- /dev/null +++ b/docs/tag/tagv1.0/docker-compose-app-v2.0.yml @@ -0,0 +1,26 @@ +version: '3.8' + +networks: + group-buy-network: + external: true + +services: + pay-mall: + build: + context: . + dockerfile: Dockerfile + image: smile/pay-mall:latest + container_name: pay-mall + restart: on-failure + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + ports: + - '8092:8092' + environment: + - TZ=Asia/Shanghai + - SPRING_PROFILES_ACTIVE=prod + networks: + - group-buy-network diff --git a/pay-mall-app/Dockerfile b/pay-mall-app/Dockerfile index 326c3ab..db85525 100644 --- a/pay-mall-app/Dockerfile +++ b/pay-mall-app/Dockerfile @@ -1,17 +1,45 @@ -# 基础镜像 -FROM openjdk:8-jre-slim +# —— 第一阶段:Maven 构建 —— +FROM maven:3.8.7-eclipse-temurin-17-alpine AS builder +WORKDIR /workspace -# 作者 -MAINTAINER smile +# 把项目级 settings.xml 复制到容器里 +COPY .mvn/settings.xml /root/.m2/settings.xml -# 配置 -ENV PARAMS="" +# 1. 先只拷贝父 POM 及各模块的 pom.xml,加速依赖下载 +COPY pom.xml ./pom.xml +COPY pay-mall-api/pom.xml ./pay-mall-api/pom.xml +COPY pay-mall-domain/pom.xml ./pay-mall-domain/pom.xml +COPY pay-mall-infrastructure/pom.xml ./pay-mall-infrastructure/pom.xml +COPY pay-mall-trigger/pom.xml ./pay-mall-trigger/pom.xml +COPY pay-mall-types/pom.xml ./pay-mall-types/pom.xml +COPY pay-mall-app/pom.xml ./pay-mall-app/pom.xml -# 时区 -ENV TZ=PRC +# 离线下载所有依赖 +RUN mvn dependency:go-offline -B + +# 2. 拷贝所有源码 +COPY . . + +# 3. 只打包 main 应用模块(连带编译它依赖的模块),跳过测试,加速构建 +RUN mvn \ + -f pom.xml clean package \ + -pl pay-mall-app -am \ + -DskipTests -B + +# —— 第二阶段:运行时镜像 —— +FROM openjdk:17-jdk-slim +LABEL maintainer="smile" + +# 可选:设置时区 +ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 添加应用 -ADD target/pay-mall-app.jar /pay-mall-app.jar +# 把构建产物拷过来 +COPY --from=builder \ + /workspace/pay-mall-app/target/pay-mall-app.jar \ + app.jar -ENTRYPOINT ["sh","-c","java -jar $JAVA_OPTS /pay-mall-app.jar $PARAMS"] \ No newline at end of file +# 暴露端口,按需改 +EXPOSE 8092 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/pay-mall-app/src/main/resources/application-prod.yml b/pay-mall-app/src/main/resources/application-prod.yml index 26df58f..91be27b 100644 --- a/pay-mall-app/src/main/resources/application-prod.yml +++ b/pay-mall-app/src/main/resources/application-prod.yml @@ -1,41 +1,63 @@ +# src/main/resources/application-prod.yml server: port: 8092 -# 线程池配置 -thread: - pool: - executor: - config: - core-pool-size: 20 - max-pool-size: 50 - keep-alive-time: 5000 - block-queue-size: 5000 - policy: CallerRunsPolicy +app: + config: + api-version: v1 + cross-origin: '*' # 如果生产环境需要限域名,这里再改 + group-buy-market: + # 从 127.0.0.1:8091 → group-buying-sys:8091(容器名) + api-url: http://group-buying-sys:8091 + # 回调给自己,通常要是公网可访问的域名或网关地址 + # 如果 pay-mall 服务外部也同 host:8092,可以写: + notify-url: http://124.71.159.195:8092/api/v1/alipay/group_buy_notify + source: s01 + chanel: c01 -# 数据库配置 -#spring: -# datasource: -# username: root -# password: 123456 -# url: jdbc:mysql://127.0.0.1:3306/pay-mall?useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&serverTimezone=UTC&useSSL=true -# driver-class-name: com.mysql.cj.jdbc.Driver -# hikari: -# pool-name: Retail_HikariCP -# minimum-idle: 15 #最小空闲连接数量 -# idle-timeout: 180000 #空闲连接存活最大时间,默认600000(10分钟) -# maximum-pool-size: 25 #连接池最大连接数,默认是10 -# auto-commit: true #此属性控制从池返回的连接的默认自动提交行为,默认值:true -# max-lifetime: 1800000 #此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟 -# connection-timeout: 30000 #数据库连接超时时间,默认30秒,即30000 -# connection-test-query: SELECT 1 -# type: com.zaxxer.hikari.HikariDataSource +spring: + datasource: + # 从本地 127.0.0.1:13306/pay-mall 改为容器 mysql:3306/pay-mall + url: jdbc:mysql://mysql:3306/pay-mall?useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&useSSL=false + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: PayMall_HikariCP + minimum-idle: 15 + maximum-pool-size: 25 + idle-timeout: 180000 + max-lifetime: 1800000 + connection-timeout: 30000 + connection-test-query: SELECT 1 + type: com.zaxxer.hikari.HikariDataSource -#mybatis: -# mapper-locations: classpath:/mybatis/mapper/*.xml -# config-location: classpath:/mybatis/config/mybatis-config.xml +# 如果你想在 application.yml 里集中管理,也可以在 prod profile 下覆盖: +# spring.redis.host: redis +# spring.redis.port: 6379 + +mybatis: + mapper-locations: classpath:/mybatis/mapper/*.xml + config-location: classpath:/mybatis/config/mybatis-config.xml + +weixin: + config: + original-id: ${paymall.wechat.original-id} + token: ${paymall.wechat.token} + app-id: ${paymall.wechat.app-id} + app-secret: ${paymall.wechat.app-secret} + template_id: ${paymall.wechat.template-id} + +alipay: + enabled: true + app_id: ${paymall.alipay.app-id} + merchant_private_key: ${paymall.alipay.merchant-private-key} + alipay_public_key: ${paymall.alipay.alipay-public-key} + notify_url: ${paymall.alipay.notify-url} + return_url: ${paymall.alipay.return-url} + gateway_url: ${paymall.alipay.gateway-url} -# 日志 logging: level: root: info - config: classpath:logback-spring.xml \ No newline at end of file + config: classpath:logback-spring.xml diff --git a/pay-mall-app/src/main/resources/application.yml b/pay-mall-app/src/main/resources/application.yml index 7f350e7..999d6e4 100644 --- a/pay-mall-app/src/main/resources/application.yml +++ b/pay-mall-app/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - active: dev,local + active: prod,local diff --git a/pay-mall-infrastructure/src/main/java/edu/whut/infrastructure/adapter/port/LoginPort.java b/pay-mall-infrastructure/src/main/java/edu/whut/infrastructure/adapter/port/LoginPort.java index 4f3ce82..f93fa14 100644 --- a/pay-mall-infrastructure/src/main/java/edu/whut/infrastructure/adapter/port/LoginPort.java +++ b/pay-mall-infrastructure/src/main/java/edu/whut/infrastructure/adapter/port/LoginPort.java @@ -1,4 +1,5 @@ package edu.whut.infrastructure.adapter.port; +import cn.hutool.core.util.IdUtil; import com.google.common.cache.Cache; import edu.whut.domain.auth.adapter.port.ILoginPort; import edu.whut.infrastructure.gateway.IWeixinApiService; @@ -32,11 +33,15 @@ public class LoginPort implements ILoginPort { private final IWeixinApiService weixinApiService; /** - * 生成二维码登录凭证 ticket - * 获取或刷新 access_token 后,调用微信接口创建带参数的二维码,返回 ticket。 + * 生成带业务标识的二维码登录凭证 ticket。 + * + * @param sceneStr 本次登录会话的唯一标识(字符串场景),会被微信原样返回到回调的 EventKey 字段, + * 用于在回调时直接定位到对应的登录请求,无需再额外映射 ticket。 + * @return 本次二维码请求的 ticket,用于前端换取二维码图片。 + * @throws IOException */ @Override - public String createQrCodeTicket() throws IOException { + public String createQrCodeTicket(String sceneStr) throws IOException { // 1. 获取 access_token,优先从缓存读取,缓存失效时调用微信接口获取并更新缓存 String accessToken = weixinAccessToken.getIfPresent(appid); if (null == accessToken) { @@ -50,10 +55,10 @@ public class LoginPort implements ILoginPort { // 2. 构造二维码请求对象,设置过期时间及业务参数 WeixinQrCodeRequestDTO weixinQrCodeReq = WeixinQrCodeRequestDTO.builder() .expire_seconds(2592000) - .action_name(WeixinQrCodeRequestDTO.ActionNameTypeVO.QR_SCENE.getCode()) + .action_name(WeixinQrCodeRequestDTO.ActionNameTypeVO.QR_STR_SCENE.getCode()) .action_info(WeixinQrCodeRequestDTO.ActionInfo.builder() .scene(WeixinQrCodeRequestDTO.ActionInfo.Scene.builder() - .scene_id(100601) + .scene_str(sceneStr) .build()) .build()) .build(); @@ -65,6 +70,12 @@ public class LoginPort implements ILoginPort { return weixinQrCodeRes.getTicket(); } + @Override + public String createQrCodeTicket() throws IOException { + String sceneStr = IdUtil.getSnowflake().nextIdStr(); + return createQrCodeTicket(sceneStr); + } + /** * 发送登录成功模板消息 * 获取或刷新 access_token 后,封装模板数据,调用微信接口发送模板消息。 diff --git a/pay-mall-trigger/src/main/java/edu/whut/trigger/http/LoginController.java b/pay-mall-trigger/src/main/java/edu/whut/trigger/http/LoginController.java index 27fd0dc..35b2ef9 100644 --- a/pay-mall-trigger/src/main/java/edu/whut/trigger/http/LoginController.java +++ b/pay-mall-trigger/src/main/java/edu/whut/trigger/http/LoginController.java @@ -41,6 +41,9 @@ public class LoginController implements IAuthService { } } + /** + * 生成并返回一个带浏览器指纹的微信扫码登录的凭证(ticket)。 + */ @GetMapping("/weixin_qrcode_ticket_scene") @Override public Response weixinQrCodeTicket(@RequestParam String sceneStr) {