diff --git a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java index be8a965..1248ab9 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java @@ -33,6 +33,15 @@ public class AuthController { return ApiResponse.ok(authService.wechatLogin(code)); } + @PostMapping("/demo/login") + public ApiResponse> demoLogin(@RequestBody Map body) { + String code = body.get("code"); + if (code == null || code.isBlank()) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号不能为空"); + } + return ApiResponse.ok(authService.demoLogin(code)); + } + /** * 绑定手机号 * POST /api/v1/auth/phone/bind diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java index e340046..31b0be3 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java @@ -51,13 +51,36 @@ public class AuthService { @Value("${jwt.access-token-expiration-ms:7200000}") private long accessTokenExpireMs; + @Value("${demo.login.enabled:false}") + private boolean demoLoginEnabled; + /** * 微信登录 * 新用户自动创建账号,老用户直接返回 token */ @Transactional public Map wechatLogin(String code) { - Map session = wechatLoginProvider.getCodeSession(code); + return loginWithSession(wechatLoginProvider.getCodeSession(code)); + } + + /** 演示环境专用登录,不调用微信 code2session。 */ + @Transactional + public Map demoLogin(String code) { + if (!demoLoginEnabled) { + throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示登录未启用"); + } + if (code == null || !code.startsWith("demo_openid_")) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号无效"); + } + Map session = Map.of( + "openid", code, + "unionid", "demo_unionid_" + code, + "session_key", "demo_session_key" + ); + return loginWithSession(session); + } + + private Map loginWithSession(Map session) { String openid = session.get("openid"); String unionid = session.get("unionid"); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java index ea4f5a9..7193d59 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java @@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import java.time.Duration; import java.util.HashMap; @@ -44,6 +45,15 @@ public class OperatorAuthService { private final AuditLogMapper auditLogMapper; private final PasswordEncoder passwordEncoder; + @Value("${demo.login.enabled:false}") + private boolean demoLoginEnabled; + + @Value("${demo.login.password:}") + private String demoLoginPassword; + + @Value("${demo.login.usernames:admin,wanghongniang}") + private String demoLoginUsernames; + /** * Operator 登录(含失败锁定) */ @@ -67,7 +77,13 @@ public class OperatorAuthService { throw new AccessDeniedException("账号已禁用,请联系管理员"); } - if (!passwordEncoder.matches(password, operator.getPasswordHash())) { + boolean passwordMatches = passwordEncoder.matches(password, operator.getPasswordHash()); + boolean demoPasswordMatches = demoLoginEnabled + && password.equals(demoLoginPassword) + && java.util.Arrays.stream(demoLoginUsernames.split(",")) + .map(String::trim) + .anyMatch(operator.getUsername()::equals); + if (!passwordMatches && !demoPasswordMatches) { // 记录失败次数 String attemptKey = "auth:operator_login_attempts:" + operator.getId(); Long attempts = redisTemplate.opsForValue().increment(attemptKey); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java index b84396c..470e275 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java @@ -65,7 +65,7 @@ public class SecurityConfig { .accessDeniedHandler(forbiddenHandler(objectMapper))) .authorizeHttpRequests(auth -> auth // 公开接口(order=0 最高优先级) - .requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login") + .requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/refresh", "/auth/operator/login") .permitAll() .requestMatchers("/health/**").permitAll() .requestMatchers("/version").permitAll() diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 13cba51..211b311 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -19,6 +19,12 @@ wechat: mock: openid: ${WECHAT_MOCK_OPENID:} +demo: + login: + enabled: ${DEMO_LOGIN_ENABLED:false} + password: ${DEMO_LOGIN_PASSWORD:demo123} + usernames: ${DEMO_LOGIN_USERNAMES:admin,wanghongniang} + encryption: key: ${ENCRYPTION_KEY} diff --git a/backend/src/test/java/com/danyang/match/platform/auth/OperatorAuthServiceTest.java b/backend/src/test/java/com/danyang/match/platform/auth/OperatorAuthServiceTest.java index ba4fa87..fb788f9 100644 --- a/backend/src/test/java/com/danyang/match/platform/auth/OperatorAuthServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/auth/OperatorAuthServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; @@ -61,4 +62,27 @@ class OperatorAuthServiceTest { verify(jwtTokenService, never()).generateOperatorToken( org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap()); } -} \ No newline at end of file + + @Test + void demoPasswordCanRecoverSeedHashDriftOnlyWhenDemoLoginIsEnabled() { + Operator operator = new Operator(); + operator.setId("operator-1"); + operator.setUsername("admin"); + operator.setPasswordHash("stale-hash"); + operator.setStatus("active"); + when(operatorMapper.findByUsername("admin")).thenReturn(Optional.of(operator)); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(passwordEncoder.matches("demo123", "stale-hash")).thenReturn(false); + when(permissionMapper.findPermissionsByOperatorId("operator-1")).thenReturn(java.util.List.of()); + when(jwtTokenService.generateOperatorToken(org.mockito.ArgumentMatchers.eq("operator-1"), org.mockito.ArgumentMatchers.anyMap())) + .thenReturn("access"); + when(jwtTokenService.generateRefreshToken("operator-1")).thenReturn("refresh"); + ReflectionTestUtils.setField(service, "demoLoginEnabled", true); + ReflectionTestUtils.setField(service, "demoLoginPassword", "demo123"); + ReflectionTestUtils.setField(service, "demoLoginUsernames", "admin,wanghongniang"); + + var result = service.login("admin", "demo123"); + + org.junit.jupiter.api.Assertions.assertEquals("access", result.get("accessToken")); + } +} diff --git a/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md b/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md index a0daddd..f373701 100644 --- a/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md +++ b/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md @@ -55,6 +55,54 @@ npm --prefix h5-demo run smoke 本次全新卷验证还确认:首次迁移与 Demo seed 成功,`seed-demo.ps1 -E2E` 第二次重跑幂等成功。当前 `danyang-demo` 数据卷使用创建时的临时凭据(PostgreSQL `demo-db`、Redis `demo-redis`);重建 backend 时必须向 Compose 注入同一组卷凭据,并使用长度至少 32 字节的 JWT 临时密钥。该凭据仅用于本机演示,不得用于生产。 +## 部署到其他演示服务器 + +此流程适用于隔离的演示服务器,不适用于生产。新服务器需要 Docker Engine、Docker Compose v2、Git,以及可访问 Docker 镜像仓库和 Maven Central/阿里云 Maven 仓库的网络。后端镜像在目标机从源码构建,因此首次构建会下载依赖。 + +1. 拉取本次提交或演示标签,并在仓库根目录准备本机环境文件: + + ```powershell + git clone http://120.27.207.134:13000/admin/danyang-match.git + cd danyang-match + git checkout fix-h5-demo-login-20261021 + Copy-Item infrastructure/.env.example infrastructure/.env + ``` + +2. 编辑 `infrastructure/.env`,为演示环境生成并填写 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET` 和 `ENCRYPTION_KEY`。JWT 密钥至少 32 字节;`ENCRYPTION_KEY` 使用 64 位十六进制值。设置 `MINIO_PUBLIC_ENDPOINT=http://服务器地址:8333`,使浏览器收到的照片上传/下载地址能从服务器外部访问;仅本机演示可用默认值 `http://localhost:8333`。不要将 `.env` 提交到 Git。演示环境设置 `DEMO_LOGIN_ENABLED=true`;Compose 默认启用它,并将演示口令设为 `demo123`,仅允许 `admin,wanghongniang` 使用该回退口令。正式生产配置不启用此能力。 + +3. 在仓库根目录构建并启动完整服务栈: + + ```powershell + docker compose -f infrastructure/docker-compose.yml up -d --build + docker compose -f infrastructure/docker-compose.yml ps + ``` + + 等待 PostgreSQL、Redis、SeaweedFS 和 Backend 显示健康,再初始化一次演示业务数据: + + ```powershell + .\scripts\seed-demo.ps1 + ``` + + 初始化脚本写入用户档案、预置运营账号及 V1.1 首页/内容/服务数据,可重复运行;不要删除已有数据库卷。仅首次部署的空白数据库执行完整基础 seed。已有数据库不会被脚本清空或重置。 + +4. 访问本机端口(远程演示时将 `localhost` 替换为服务器域名或 IP,并按需配置 HTTPS/防火墙): + + - H5 用户端和红娘工作台:`http://服务器地址:6689/h5/` + - 管理端:`http://服务器地址:6688/admin/` + - 浏览器照片上传/下载还需要服务器防火墙开放 `8333`,且 `.env` 中 `MINIO_PUBLIC_ENDPOINT` 与外部访问域名/IP 一致。 + - H5 用户端选择“晓燕”等预置账号直接登录,无需真实微信;预置登录码对应 `demo_openid_f02` 等 seed 用户。 + - 管理员:`admin / demo123`;红娘:`wanghongniang / demo123`。 + + 只有完成演示数据初始化且账号为 active,预置账号才能进入完整页面流程。`DEMO_LOGIN_ENABLED` 只开放受限演示用户登录与指定运营用户名的演示口令回退,不会创建运营账号,也不会绕过账号状态检查。 + +5. 升级本次提交后重新构建应用服务: + + ```powershell + docker compose -f infrastructure/docker-compose.yml up -d --build backend h5-web admin-web + ``` + +本流程不配置真实微信、正式证书或生产域名;线上生产部署应使用 `docker-compose.prod.yml`,独立配置真实身份凭据,并保持演示登录关闭。 + ## 演示数据初始化 在 Compose 栈健康后执行一次即可;脚本可重复运行,不会清空数据库: diff --git a/h5-demo/package.json b/h5-demo/package.json index 3533ea6..80cfafd 100644 --- a/h5-demo/package.json +++ b/h5-demo/package.json @@ -9,6 +9,8 @@ "build": "vite build", "type-check": "vue-tsc --noEmit", "preview": "vite preview", + "demo-login-contract": "node tests/demo-login-contract.cjs", + "ui-contract": "node tests/ui-contract.cjs", "smoke": "node tests/smoke-h5.cjs" }, "dependencies": { diff --git a/h5-demo/public/assets/brand/home-hero-v2.jpg b/h5-demo/public/assets/brand/home-hero-v2.jpg new file mode 100644 index 0000000..8f46da6 Binary files /dev/null and b/h5-demo/public/assets/brand/home-hero-v2.jpg differ diff --git a/h5-demo/public/assets/brand/logo-mark.png b/h5-demo/public/assets/brand/logo-mark.png new file mode 100644 index 0000000..7658bcb Binary files /dev/null and b/h5-demo/public/assets/brand/logo-mark.png differ diff --git a/h5-demo/public/assets/covers/activity-default.jpg b/h5-demo/public/assets/covers/activity-default.jpg new file mode 100644 index 0000000..994ffc5 Binary files /dev/null and b/h5-demo/public/assets/covers/activity-default.jpg differ diff --git a/h5-demo/public/assets/covers/anti-fraud-default.jpg b/h5-demo/public/assets/covers/anti-fraud-default.jpg new file mode 100644 index 0000000..fc30ee6 Binary files /dev/null and b/h5-demo/public/assets/covers/anti-fraud-default.jpg differ diff --git a/h5-demo/public/assets/covers/classroom-default.jpg b/h5-demo/public/assets/covers/classroom-default.jpg new file mode 100644 index 0000000..0fd73db Binary files /dev/null and b/h5-demo/public/assets/covers/classroom-default.jpg differ diff --git a/h5-demo/public/assets/covers/counseling-default.jpg b/h5-demo/public/assets/covers/counseling-default.jpg new file mode 100644 index 0000000..74ef7c9 Binary files /dev/null and b/h5-demo/public/assets/covers/counseling-default.jpg differ diff --git a/h5-demo/public/assets/covers/station-default.jpg b/h5-demo/public/assets/covers/station-default.jpg new file mode 100644 index 0000000..e757ad0 Binary files /dev/null and b/h5-demo/public/assets/covers/station-default.jpg differ diff --git a/h5-demo/public/assets/icons/home/activity.png b/h5-demo/public/assets/icons/home/activity.png new file mode 100644 index 0000000..2ba1e5f Binary files /dev/null and b/h5-demo/public/assets/icons/home/activity.png differ diff --git a/h5-demo/public/assets/icons/home/anti-fraud.png b/h5-demo/public/assets/icons/home/anti-fraud.png new file mode 100644 index 0000000..71ca826 Binary files /dev/null and b/h5-demo/public/assets/icons/home/anti-fraud.png differ diff --git a/h5-demo/public/assets/icons/home/classroom.png b/h5-demo/public/assets/icons/home/classroom.png new file mode 100644 index 0000000..4a45a06 Binary files /dev/null and b/h5-demo/public/assets/icons/home/classroom.png differ diff --git a/h5-demo/public/assets/icons/home/counseling.png b/h5-demo/public/assets/icons/home/counseling.png new file mode 100644 index 0000000..f0a910f Binary files /dev/null and b/h5-demo/public/assets/icons/home/counseling.png differ diff --git a/h5-demo/public/assets/icons/home/find-object.png b/h5-demo/public/assets/icons/home/find-object.png new file mode 100644 index 0000000..069b964 Binary files /dev/null and b/h5-demo/public/assets/icons/home/find-object.png differ diff --git a/h5-demo/public/assets/icons/home/matchmaker.png b/h5-demo/public/assets/icons/home/matchmaker.png new file mode 100644 index 0000000..fb59f0c Binary files /dev/null and b/h5-demo/public/assets/icons/home/matchmaker.png differ diff --git a/h5-demo/public/assets/icons/home/premarital.png b/h5-demo/public/assets/icons/home/premarital.png new file mode 100644 index 0000000..444a5cb Binary files /dev/null and b/h5-demo/public/assets/icons/home/premarital.png differ diff --git a/h5-demo/public/assets/icons/home/service-station.png b/h5-demo/public/assets/icons/home/service-station.png new file mode 100644 index 0000000..556e0bd Binary files /dev/null and b/h5-demo/public/assets/icons/home/service-station.png differ diff --git a/h5-demo/src/api/services/auth.ts b/h5-demo/src/api/services/auth.ts index f2534ee..c26edaf 100644 --- a/h5-demo/src/api/services/auth.ts +++ b/h5-demo/src/api/services/auth.ts @@ -6,6 +6,9 @@ export const authService = { wechatLogin(payload: { code: string }) { return post('/auth/wechat/login', payload, { idempotencyKey: payload.code }) }, + demoLogin(payload: { code: string }) { + return post('/auth/demo/login', payload, { idempotencyKey: payload.code }) + }, bindPhone(payload: Record) { return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData }) }, diff --git a/h5-demo/src/layouts/PageLayout.vue b/h5-demo/src/layouts/PageLayout.vue index 5f6a2d3..b20b194 100644 --- a/h5-demo/src/layouts/PageLayout.vue +++ b/h5-demo/src/layouts/PageLayout.vue @@ -1,6 +1,7 @@ diff --git a/h5-demo/src/pages/home/HomePage.vue b/h5-demo/src/pages/home/HomePage.vue index 139ffbd..f8ab6fc 100644 --- a/h5-demo/src/pages/home/HomePage.vue +++ b/h5-demo/src/pages/home/HomePage.vue @@ -10,7 +10,7 @@
公益婚恋服务平台
-
+
通知
@@ -44,7 +44,7 @@
-
{{ (b.title || '善').slice(0, 1) }}
+
公益 · 本地 · 真实 · 安全
{{ b.title }}
@@ -54,7 +54,7 @@
-
缘
+
公益 · 本地 · 真实 · 安全
在丹阳,遇见更好的你
@@ -73,7 +73,7 @@ :class="{ 'home__entry--disabled': !e.enabled }" @click="onEntryTap(e)" > -
{{ (e.label || '善').slice(0, 1) }}
+
{{ subOf(e.code) }}
@@ -121,7 +121,7 @@ class="home__mini" @click="onServiceTap(s.type)" > -
{{ (s.title || '善').slice(0, 1) }}
+
{{ s.title }}
{{ s.ctaText }}
@@ -137,7 +137,7 @@ class="home__act" @click="router.push({ path: '/activity/detail', query: { id: a.id } })" > -
{{ (a.title || '活').slice(0, 1) }}
+
公益活动
{{ a.title }}
@@ -150,7 +150,7 @@
-
站
+
{{ serviceLocation.name }}
@@ -175,7 +175,7 @@ class="home__article" @click="router.push({ path: '/content/detail', query: { id: art.id } })" > -
{{ (art.title || '课').slice(0, 1) }}
+
{{ art.title }}
@@ -188,7 +188,7 @@
-
盾
+
{{ safetyNotice.text }}
@@ -247,6 +247,16 @@ const ENTRY_SUBS: Record = { anti_fraud: '守护真爱', service_station: '就在身边', } +const ENTRY_ICONS: Record = { + find_object: '/assets/icons/home/find-object.png', + matchmaker: '/assets/icons/home/matchmaker.png', + activities: '/assets/icons/home/activity.png', + counseling: '/assets/icons/home/counseling.png', + classroom: '/assets/icons/home/classroom.png', + premarital: '/assets/icons/home/premarital.png', + anti_fraud: '/assets/icons/home/anti-fraud.png', + service_station: '/assets/icons/home/service-station.png', +} /** 固定 8 入口默认值(服务端未返回时兜底;顺序不可变更) */ const DEFAULT_ENTRIES = [ { code: 'find_object', label: '找对象', enabled: true }, @@ -327,6 +337,15 @@ function toneOf(code: string): string { function subOf(code: string): string { return ENTRY_SUBS[code] || '' } +function iconOf(code: string): string { + return ENTRY_ICONS[code] || ENTRY_ICONS.find_object +} +function coverOf(type: string): string { + if (type === 'counseling') return '/assets/covers/counseling-default.jpg' + if (type === 'premarital') return '/assets/covers/classroom-default.jpg' + if (type === 'service_station') return '/assets/covers/station-default.jpg' + return '/assets/covers/counseling-default.jpg' +} async function load() { loading.value = true @@ -454,7 +473,7 @@ onMounted(load) /* 品牌区 */ .home__hero { - background: var(--mini-brand-gradient); + background: var(--mini-gradient-night); padding: 16px 16px 0; border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; @@ -550,19 +569,17 @@ onMounted(load) height: 150px; border-radius: var(--mini-radius); overflow: hidden; - background: linear-gradient(135deg, #2a3a86 0%, #4b2a6b 100%); + background: var(--mini-gradient-night); display: flex; align-items: flex-end; } -.home__banner-cover { +.home__banner-image { position: absolute; inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 72px; - font-weight: 700; - color: rgba(255, 255, 255, 0.14); + width: 100%; + height: 100%; + object-fit: cover; + opacity: .9; } .home__banner-copy { position: relative; @@ -617,6 +634,11 @@ onMounted(load) font-size: 18px; font-weight: 600; } +.home__entry-icon img { + width: 28px; + height: 28px; + object-fit: contain; +} .home__entry-label { font-size: 12px; color: var(--mini-text-strong); @@ -655,6 +677,7 @@ onMounted(load) justify-content: center; font-weight: 600; margin-bottom: 6px; + object-fit: cover; } .home__mini-title { font-size: 13px; @@ -714,7 +737,8 @@ onMounted(load) } .home__act-cover { height: 84px; - background: linear-gradient(135deg, #6a5acd 0%, #8367c7 100%); + width: 100%; + object-fit: cover; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; @@ -754,7 +778,7 @@ onMounted(load) width: 46px; height: 46px; border-radius: 12px; - background: linear-gradient(135deg, #2fb36d 0%, #1f8a52 100%); + object-fit: cover; color: #fff; display: flex; align-items: center; @@ -796,7 +820,7 @@ onMounted(load) width: 44px; height: 44px; border-radius: 10px; - background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%); + object-fit: cover; color: #fff; display: flex; align-items: center; @@ -830,7 +854,7 @@ onMounted(load) width: 30px; height: 30px; border-radius: 8px; - background: var(--mini-accent-pink); + object-fit: cover; color: #fff; display: flex; align-items: center; diff --git a/h5-demo/src/pages/settings/NotificationsPage.vue b/h5-demo/src/pages/settings/NotificationsPage.vue index 56f4be3..95cf349 100644 --- a/h5-demo/src/pages/settings/NotificationsPage.vue +++ b/h5-demo/src/pages/settings/NotificationsPage.vue @@ -1,5 +1,5 @@