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) {