From 69ebf33e187f924b68394f521188ef92c836ccdb Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Mon, 19 Oct 2026 20:36:22 +0800 Subject: [PATCH] release: freeze demo v1.1.0 2026-10-19 --- .../appeal/service/AppealService.java | 44 + .../provider/MockWechatLoginProvider.java | 11 + .../service/RecommendationEngineService.java | 16 +- .../match/service/RecommendationService.java | 16 +- .../controller/MatchmakerController.java | 17 +- .../entity/MatchmakerServiceRecord.java | 4 + .../mapper/MatchmakerServiceMapper.java | 13 +- .../service/MatchmakerAssignmentService.java | 30 +- .../matchmaker/service/MatchmakerService.java | 21 + .../risk/controller/ReportController.java | 21 + .../platform/risk/service/ReportService.java | 291 +++++- .../shared/config/SecurityConfig.java | 3 +- .../platform/shared/filter/JwtAuthFilter.java | 20 +- .../src/main/resources/db/demo-seed-final.sql | 29 +- .../resources/db/demo-seed-v11-increment.sql | 487 ++++++++++ .../src/main/resources/db/demo-seed-v4.sql | 8 +- .../V061__extend_matchmaker_assistance.sql | 12 + .../appeal/AppealPhase18IntegrationTest.java | 42 + .../deployment/MigrationValidationTest.java | 3 +- .../MatchmakerAssignmentA1Test.java | 8 +- .../ReportAndRiskPhase17IntegrationTest.java | 62 ++ backend/src/test/resources/schema-h2.sql | 6 +- docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md | 77 ++ .../2026-10-19-demo-release-repair-plan.md | 349 +++++++ h5-demo/.gitignore | 4 + h5-demo/Dockerfile | 13 + h5-demo/index.html | 13 + h5-demo/nginx-h5.conf | 49 + h5-demo/package.json | 28 + h5-demo/src/App.vue | 23 + h5-demo/src/api/client.ts | 174 ++++ h5-demo/src/api/idempotency.ts | 45 + h5-demo/src/api/operatorClient.ts | 89 ++ h5-demo/src/api/services/activity.ts | 74 ++ h5-demo/src/api/services/auth.ts | 47 + h5-demo/src/api/services/index.ts | 14 + h5-demo/src/api/services/match.ts | 103 ++ h5-demo/src/api/services/matchmaker.ts | 127 +++ h5-demo/src/api/services/me.ts | 121 +++ h5-demo/src/api/services/misc.ts | 27 + h5-demo/src/api/services/service.ts | 104 +++ h5-demo/src/components/UiAvatar.vue | 52 ++ h5-demo/src/components/UiEmpty.vue | 12 + h5-demo/src/components/UiErrorState.vue | 26 + h5-demo/src/components/UiLoading.vue | 18 + h5-demo/src/components/UiSectionHeader.vue | 32 + h5-demo/src/components/UiTag.vue | 39 + h5-demo/src/components/UiUserCard.vue | 81 ++ h5-demo/src/layouts/OperatorLayout.vue | 56 ++ h5-demo/src/layouts/PageLayout.vue | 40 + h5-demo/src/layouts/TabLayout.vue | 35 + h5-demo/src/main.ts | 35 + h5-demo/src/pages/NotFoundPage.vue | 19 + .../src/pages/account/AccountLogoutPage.vue | 116 +++ .../pages/account/AccountStatusDetailPage.vue | 128 +++ .../src/pages/account/AccountStatusPage.vue | 164 ++++ .../pages/activity/ActivityCheckinPage.vue | 181 ++++ .../src/pages/activity/ActivityDetailPage.vue | 385 ++++++++ .../pages/activity/ActivityFeedbackPage.vue | 277 ++++++ .../pages/activity/ActivityMatchesPage.vue | 140 +++ .../activity/ActivityMutualResultPage.vue | 309 ++++++ .../activity/ActivityMutualWaitingPage.vue | 229 +++++ h5-demo/src/pages/activity/ActivityPage.vue | 235 +++++ .../pages/activity/ActivityRegisterPage.vue | 147 +++ .../src/pages/activity/MyActivitiesPage.vue | 245 +++++ h5-demo/src/pages/auth/BindPhonePage.vue | 177 ++++ h5-demo/src/pages/auth/ContactConsentPage.vue | 231 +++++ h5-demo/src/pages/auth/LoginPage.vue | 202 ++++ h5-demo/src/pages/auth/VerificationPage.vue | 228 +++++ h5-demo/src/pages/content/AntiFraudPage.vue | 295 ++++++ .../src/pages/content/ContentDetailPage.vue | 206 ++++ h5-demo/src/pages/content/ContentPage.vue | 224 +++++ .../src/pages/counseling/CounselingPage.vue | 256 +++++ .../counseling/CounselingRequestPage.vue | 211 +++++ .../src/pages/counseling/MyCounselingPage.vue | 318 +++++++ h5-demo/src/pages/discover/DiscoverPage.vue | 425 +++++++++ h5-demo/src/pages/home/HomePage.vue | 878 ++++++++++++++++++ .../src/pages/local/LocalEligibilityPage.vue | 217 +++++ .../local/LocalEligibilitySupplementPage.vue | 326 +++++++ .../local/MatchmakerAuthorizationPage.vue | 126 +++ h5-demo/src/pages/match/FateCenterPage.vue | 136 +++ .../pages/matchmaker/MatchmakerApplyPage.vue | 172 ++++ .../src/pages/matchmaker/MatchmakerPage.vue | 390 ++++++++ .../matchmaker/MatchmakerProgressPage.vue | 369 ++++++++ .../MatchmakerRecommendationsPage.vue | 195 ++++ .../matchmaker/ReferralConfirmationPage.vue | 181 ++++ h5-demo/src/pages/mine/MinePage.vue | 383 ++++++++ .../src/pages/onboarding/ProfileBasicPage.vue | 289 ++++++ .../pages/onboarding/ProfileConfirmPage.vue | 324 +++++++ .../pages/onboarding/ProfileIntentPage.vue | 213 +++++ .../pages/onboarding/ProfileInterestsPage.vue | 173 ++++ .../src/pages/onboarding/ProfileIntroPage.vue | 177 ++++ .../pages/onboarding/ProfileLifestylePage.vue | 242 +++++ .../pages/onboarding/ProfilePhotosPage.vue | 279 ++++++ .../onboarding/ProfilePreferencePage.vue | 435 +++++++++ .../src/pages/operator/OperatorLoginPage.vue | 140 +++ .../operator/OperatorServiceDetailPage.vue | 374 ++++++++ .../pages/operator/OperatorWorkbenchPage.vue | 700 ++++++++++++++ .../src/pages/premarital/PremaritalPage.vue | 238 +++++ .../premarital/PremaritalRequestPage.vue | 203 ++++ .../src/pages/profile/ProfileDetailPage.vue | 277 ++++++ .../src/pages/review/ReviewPendingPage.vue | 238 +++++ .../src/pages/review/ReviewRejectedPage.vue | 267 ++++++ h5-demo/src/pages/safety/AppealPage.vue | 242 +++++ h5-demo/src/pages/safety/MyReportsPage.vue | 145 +++ h5-demo/src/pages/safety/ReportDetailPage.vue | 276 ++++++ h5-demo/src/pages/safety/ReportPage.vue | 394 ++++++++ .../src/pages/service/MyAppointmentsPage.vue | 268 ++++++ .../pages/service/MyServiceHistoryPage.vue | 197 ++++ .../pages/service/ServiceAppointmentPage.vue | 264 ++++++ .../src/pages/service/ServiceCenterPage.vue | 245 +++++ .../src/pages/service/ServiceProgramPage.vue | 270 ++++++ .../src/pages/service/ServiceStationPage.vue | 332 +++++++ h5-demo/src/pages/settings/AboutPage.vue | 118 +++ h5-demo/src/pages/settings/BlacklistPage.vue | 134 +++ h5-demo/src/pages/settings/HelpCenterPage.vue | 120 +++ .../src/pages/settings/NotificationsPage.vue | 203 ++++ .../pages/settings/PrivacySettingsPage.vue | 111 +++ .../pages/settings/RelationshipStatusPage.vue | 237 +++++ h5-demo/src/pages/settings/SettingsPage.vue | 79 ++ h5-demo/src/router/guard.ts | 50 + h5-demo/src/router/index.ts | 15 + h5-demo/src/router/routes.ts | 119 +++ h5-demo/src/stores/activity.ts | 52 ++ h5-demo/src/stores/app.ts | 61 ++ h5-demo/src/stores/auth.ts | 55 ++ h5-demo/src/stores/match.ts | 35 + h5-demo/src/stores/matchmaker.ts | 64 ++ h5-demo/src/stores/operator.ts | 27 + h5-demo/src/stores/user.ts | 56 ++ h5-demo/src/theme/index.css | 69 ++ h5-demo/src/theme/tokens.css | 40 + h5-demo/src/utils/datetime.ts | 28 + h5-demo/src/utils/storage.ts | 61 ++ h5-demo/src/vite-env.d.ts | 7 + h5-demo/tests/smoke-h5.cjs | 93 ++ h5-demo/tsconfig.json | 26 + h5-demo/tsconfig.node.json | 11 + h5-demo/vite.config.ts | 28 + infrastructure/docker-compose.prod.yml | 2 +- infrastructure/docker-compose.yml | 23 +- infrastructure/nginx.conf | 15 + miniapp/app.json | 8 + .../assets/matchmaker-authorization-test.js | 9 + miniapp/pages/discover/discover.ts | 16 +- miniapp/pages/login/login.ts | 6 +- .../matchmaker-authorization.js | 43 + .../matchmaker-authorization.json | 4 + .../matchmaker-authorization.wxml | 11 + .../matchmaker-authorization.wxss | 60 ++ .../pages/profile-detail/profile-detail.js | 9 + .../pages/profile-detail/profile-detail.wxml | 5 + .../pages/profile-detail/profile-detail.wxss | 13 + miniapp/services/matchmakerService.js | 2 +- miniapp/services/recommendationService.js | 3 +- scripts/generate_demo_seed.py | 9 +- scripts/seed-demo.ps1 | 52 ++ 157 files changed, 21111 insertions(+), 72 deletions(-) create mode 100644 backend/src/main/resources/db/demo-seed-v11-increment.sql create mode 100644 backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql create mode 100644 docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md create mode 100644 docs/superpowers/plans/2026-10-19-demo-release-repair-plan.md create mode 100644 h5-demo/.gitignore create mode 100644 h5-demo/Dockerfile create mode 100644 h5-demo/index.html create mode 100644 h5-demo/nginx-h5.conf create mode 100644 h5-demo/package.json create mode 100644 h5-demo/src/App.vue create mode 100644 h5-demo/src/api/client.ts create mode 100644 h5-demo/src/api/idempotency.ts create mode 100644 h5-demo/src/api/operatorClient.ts create mode 100644 h5-demo/src/api/services/activity.ts create mode 100644 h5-demo/src/api/services/auth.ts create mode 100644 h5-demo/src/api/services/index.ts create mode 100644 h5-demo/src/api/services/match.ts create mode 100644 h5-demo/src/api/services/matchmaker.ts create mode 100644 h5-demo/src/api/services/me.ts create mode 100644 h5-demo/src/api/services/misc.ts create mode 100644 h5-demo/src/api/services/service.ts create mode 100644 h5-demo/src/components/UiAvatar.vue create mode 100644 h5-demo/src/components/UiEmpty.vue create mode 100644 h5-demo/src/components/UiErrorState.vue create mode 100644 h5-demo/src/components/UiLoading.vue create mode 100644 h5-demo/src/components/UiSectionHeader.vue create mode 100644 h5-demo/src/components/UiTag.vue create mode 100644 h5-demo/src/components/UiUserCard.vue create mode 100644 h5-demo/src/layouts/OperatorLayout.vue create mode 100644 h5-demo/src/layouts/PageLayout.vue create mode 100644 h5-demo/src/layouts/TabLayout.vue create mode 100644 h5-demo/src/main.ts create mode 100644 h5-demo/src/pages/NotFoundPage.vue create mode 100644 h5-demo/src/pages/account/AccountLogoutPage.vue create mode 100644 h5-demo/src/pages/account/AccountStatusDetailPage.vue create mode 100644 h5-demo/src/pages/account/AccountStatusPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityCheckinPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityDetailPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityFeedbackPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityMatchesPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityMutualResultPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityMutualWaitingPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityPage.vue create mode 100644 h5-demo/src/pages/activity/ActivityRegisterPage.vue create mode 100644 h5-demo/src/pages/activity/MyActivitiesPage.vue create mode 100644 h5-demo/src/pages/auth/BindPhonePage.vue create mode 100644 h5-demo/src/pages/auth/ContactConsentPage.vue create mode 100644 h5-demo/src/pages/auth/LoginPage.vue create mode 100644 h5-demo/src/pages/auth/VerificationPage.vue create mode 100644 h5-demo/src/pages/content/AntiFraudPage.vue create mode 100644 h5-demo/src/pages/content/ContentDetailPage.vue create mode 100644 h5-demo/src/pages/content/ContentPage.vue create mode 100644 h5-demo/src/pages/counseling/CounselingPage.vue create mode 100644 h5-demo/src/pages/counseling/CounselingRequestPage.vue create mode 100644 h5-demo/src/pages/counseling/MyCounselingPage.vue create mode 100644 h5-demo/src/pages/discover/DiscoverPage.vue create mode 100644 h5-demo/src/pages/home/HomePage.vue create mode 100644 h5-demo/src/pages/local/LocalEligibilityPage.vue create mode 100644 h5-demo/src/pages/local/LocalEligibilitySupplementPage.vue create mode 100644 h5-demo/src/pages/local/MatchmakerAuthorizationPage.vue create mode 100644 h5-demo/src/pages/match/FateCenterPage.vue create mode 100644 h5-demo/src/pages/matchmaker/MatchmakerApplyPage.vue create mode 100644 h5-demo/src/pages/matchmaker/MatchmakerPage.vue create mode 100644 h5-demo/src/pages/matchmaker/MatchmakerProgressPage.vue create mode 100644 h5-demo/src/pages/matchmaker/MatchmakerRecommendationsPage.vue create mode 100644 h5-demo/src/pages/matchmaker/ReferralConfirmationPage.vue create mode 100644 h5-demo/src/pages/mine/MinePage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileBasicPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileConfirmPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileIntentPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileInterestsPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileIntroPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfileLifestylePage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfilePhotosPage.vue create mode 100644 h5-demo/src/pages/onboarding/ProfilePreferencePage.vue create mode 100644 h5-demo/src/pages/operator/OperatorLoginPage.vue create mode 100644 h5-demo/src/pages/operator/OperatorServiceDetailPage.vue create mode 100644 h5-demo/src/pages/operator/OperatorWorkbenchPage.vue create mode 100644 h5-demo/src/pages/premarital/PremaritalPage.vue create mode 100644 h5-demo/src/pages/premarital/PremaritalRequestPage.vue create mode 100644 h5-demo/src/pages/profile/ProfileDetailPage.vue create mode 100644 h5-demo/src/pages/review/ReviewPendingPage.vue create mode 100644 h5-demo/src/pages/review/ReviewRejectedPage.vue create mode 100644 h5-demo/src/pages/safety/AppealPage.vue create mode 100644 h5-demo/src/pages/safety/MyReportsPage.vue create mode 100644 h5-demo/src/pages/safety/ReportDetailPage.vue create mode 100644 h5-demo/src/pages/safety/ReportPage.vue create mode 100644 h5-demo/src/pages/service/MyAppointmentsPage.vue create mode 100644 h5-demo/src/pages/service/MyServiceHistoryPage.vue create mode 100644 h5-demo/src/pages/service/ServiceAppointmentPage.vue create mode 100644 h5-demo/src/pages/service/ServiceCenterPage.vue create mode 100644 h5-demo/src/pages/service/ServiceProgramPage.vue create mode 100644 h5-demo/src/pages/service/ServiceStationPage.vue create mode 100644 h5-demo/src/pages/settings/AboutPage.vue create mode 100644 h5-demo/src/pages/settings/BlacklistPage.vue create mode 100644 h5-demo/src/pages/settings/HelpCenterPage.vue create mode 100644 h5-demo/src/pages/settings/NotificationsPage.vue create mode 100644 h5-demo/src/pages/settings/PrivacySettingsPage.vue create mode 100644 h5-demo/src/pages/settings/RelationshipStatusPage.vue create mode 100644 h5-demo/src/pages/settings/SettingsPage.vue create mode 100644 h5-demo/src/router/guard.ts create mode 100644 h5-demo/src/router/index.ts create mode 100644 h5-demo/src/router/routes.ts create mode 100644 h5-demo/src/stores/activity.ts create mode 100644 h5-demo/src/stores/app.ts create mode 100644 h5-demo/src/stores/auth.ts create mode 100644 h5-demo/src/stores/match.ts create mode 100644 h5-demo/src/stores/matchmaker.ts create mode 100644 h5-demo/src/stores/operator.ts create mode 100644 h5-demo/src/stores/user.ts create mode 100644 h5-demo/src/theme/index.css create mode 100644 h5-demo/src/theme/tokens.css create mode 100644 h5-demo/src/utils/datetime.ts create mode 100644 h5-demo/src/utils/storage.ts create mode 100644 h5-demo/src/vite-env.d.ts create mode 100644 h5-demo/tests/smoke-h5.cjs create mode 100644 h5-demo/tsconfig.json create mode 100644 h5-demo/tsconfig.node.json create mode 100644 h5-demo/vite.config.ts create mode 100644 miniapp/assets/matchmaker-authorization-test.js create mode 100644 miniapp/pages/matchmaker-authorization/matchmaker-authorization.js create mode 100644 miniapp/pages/matchmaker-authorization/matchmaker-authorization.json create mode 100644 miniapp/pages/matchmaker-authorization/matchmaker-authorization.wxml create mode 100644 miniapp/pages/matchmaker-authorization/matchmaker-authorization.wxss create mode 100644 scripts/seed-demo.ps1 diff --git a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java index 4bc9a39..fa575e1 100644 --- a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java +++ b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java @@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.risk.entity.RiskAction; import com.danyang.match.platform.risk.mapper.RiskActionMapper; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; import com.danyang.match.platform.shared.config.FeatureFlagConfig; import com.danyang.match.platform.shared.constant.Constants; import com.fasterxml.jackson.databind.ObjectMapper; @@ -46,6 +48,7 @@ public class AppealService { private final AppealMapper appealMapper; private final RiskActionMapper riskActionMapper; + private final UserMapper userMapper; private final UserProfileMapper userProfileMapper; private final AuditLogMapper auditLogMapper; private final NotificationService notificationService; @@ -281,6 +284,11 @@ public class AppealService { log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId()); } + // 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。 + // 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的 + // users.account_status=banned/suspended 无人回滚。 + restoreAccountStatusIfNoActiveRestriction(appeal.getUserId()); + // 通知用户 notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT, "申诉审核通过", @@ -371,6 +379,38 @@ public class AppealService { return "limited"; } + /** + * 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。 + * + *

否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的 + * users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。 + * + *

仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。 + */ + private void restoreAccountStatusIfNoActiveRestriction(String userId) { + boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream() + .anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()) + || Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType())); + if (stillRestricted) { + return; + } + User user = userMapper.selectById(userId); + if (user == null) { + return; + } + String oldStatus = user.getAccountStatus(); + if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus) + && !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) { + return; + } + user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.updateById(user); + // audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。 + saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId, + "{\"accountStatus\":\"" + oldStatus + "\"}", + "{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}"); + } + private void saveAuditLog(String operatorId, String action, String resourceType, String resourceId, String beforeData, String afterData) { AuditLog auditLog = new AuditLog(); @@ -378,6 +418,10 @@ public class AppealService { auditLog.setAction(action); auditLog.setResourceType(resourceType); auditLog.setResourceId(resourceId); + // 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意 + // audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。 + auditLog.setBeforeData(beforeData); + auditLog.setAfterData(afterData); auditLogMapper.insert(auditLog); } } diff --git a/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java b/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java index 1b38c49..6b72b47 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java @@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider { public Map getCodeSession(String code) { log.debug("[Mock] 微信登录 code: {}", code); + // 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。 + // 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。 + if (code != null && code.startsWith("demo_openid_")) { + return Map.of( + "openid", code, + "unionid", "mock_unionid_" + code, + "session_key", "mock_session_key", + "appid", appId + ); + } + // 模拟:相同 code 返回相同 openid String openid = configuredOpenid != null && !configuredOpenid.isBlank() ? configuredOpenid diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java index eda5f66..f799a09 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java @@ -310,7 +310,11 @@ public class RecommendationEngineService { record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setScore(java.math.BigDecimal.valueOf(score)); record.setLevel(toLevel(score)); - record.setReasons(String.join(";", reasons)); + // recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。 + // 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会 + // 触发 PostgreSQL「invalid input syntax for type json」,导致整个 + // /home 聚合接口 500。改为序列化为 JSON 数组。 + record.setReasons(writeReasonsJson(reasons)); record.setShownAt(Instant.now()); record.setRuleVersion(matchConfig.getRuleVersion()); // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) @@ -342,6 +346,16 @@ public class RecommendationEngineService { } } + /** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */ + private String writeReasonsJson(List reasons) { + try { + return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons); + } catch (Exception e) { + log.warn("序列化推荐理由失败", e); + return "[]"; + } + } + /** * 内部评分结果 */ diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java index 03d4382..7b6df28 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java @@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.service.UserProfileService; import com.danyang.match.platform.shared.constant.Constants; import lombok.RequiredArgsConstructor; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -28,6 +29,7 @@ import java.util.stream.Collectors; public class RecommendationService { private static final Logger log = LoggerFactory.getLogger(RecommendationService.class); + private final ObjectMapper objectMapper = new ObjectMapper(); private final MatchEligibilityService eligibilityService; private final UserProfileMapper userProfileMapper; @@ -61,7 +63,9 @@ public class RecommendationService { record.setTargetUserId(targetUserId); record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setLevel(level); - record.setReasons(new java.util.ArrayList<>(reasons).toString()); + // recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON + // (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。 + record.setReasons(writeReasonsJson(reasons)); record.setShownAt(Instant.now()); record.setRuleVersion(matchConfig.getRuleVersion()); // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) @@ -70,6 +74,16 @@ public class RecommendationService { log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level); } + /** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */ + private String writeReasonsJson(List reasons) { + try { + return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons); + } catch (Exception e) { + log.warn("序列化推荐理由失败", e); + return "[]"; + } + } + /** * 记录用户对推荐的响应(Like/Pass/Block) */ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java index 84ccd33..f05f9e0 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java @@ -54,7 +54,8 @@ public class MatchmakerController { String serviceType = (req.serviceType == null || req.serviceType.isBlank()) ? "recommendation" : req.serviceType; String description = req.description != null ? req.description : req.requirement; - return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description)); + return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description, + req.targetUserId, req.authorizationAccepted)); } @GetMapping("/services") @@ -72,6 +73,15 @@ public class MatchmakerController { return ApiResponse.ok(matchmakerService.confirmService(id, principal.id())); } + @Idempotent + @PostMapping("/services/{id}/withdraw") + public ApiResponse withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason); + return ApiResponse.ok(); + } + @GetMapping("/recommendations") public ApiResponse> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); @@ -111,5 +121,10 @@ public class MatchmakerController { private String description; /** 兼容字段:小程序约定的需求描述(requirement -> description) */ private String requirement; + private String targetUserId; + private Boolean authorizationAccepted; } + + @Data + public static class WithdrawRequest { private String reason; } } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java index 837ace6..bc80a34 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java @@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity { private String userId; private String serviceType; private String description; + private String targetUserId; + private java.time.Instant authorizationAt; + private java.time.Instant withdrawnAt; + private String withdrawReason; private String status; private String organizationId; private String assignedOperatorId; diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java index 100e3e8..b6db5f6 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java @@ -14,13 +14,16 @@ import java.util.Optional; public interface MatchmakerServiceMapper extends BaseMapper { @Select("") @@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper findByUser(@Param("userId") String userId, @Param("limit") int limit); + @Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " + + "AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL") + long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId); + + @Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " + + "WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL") + int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason); + /** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */ @Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " + "WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL") diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java index 273e7c8..c5cfbac 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java @@ -12,6 +12,8 @@ import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.security.DataScopeGuard; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; @@ -55,6 +57,7 @@ import java.util.stream.Collectors; public class MatchmakerAssignmentService { private static final String RESOURCE_TYPE = "matchmaker_assignment"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final MatchmakerAssignmentMapper assignmentMapper; private final MatchmakerServiceMapper serviceMapper; @@ -87,6 +90,20 @@ public class MatchmakerAssignmentService { "组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false); assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> { + // 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。 + // 仍在进行中的服务继续按唯一主责约束返回 409。 + MatchmakerServiceRecord linked = existing.getServiceId() == null + ? null : serviceMapper.selectById(existing.getServiceId()); + if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus()) + || Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) { + existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED); + existing.setEndedAt(Instant.now()); + existing.setEndReason("关联服务已完成或取消,自动结束旧分配"); + assignmentMapper.updateById(existing); + saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(), + auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason())); + return; + } throw new BusinessException(ApiResponseCode.CONFLICT, "该用户已存在生效中的主责分配,请先转分配或撤销"); }); @@ -388,10 +405,15 @@ public class MatchmakerAssignmentService { /** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */ private String auditSummary(String matchmakerOperatorId, String scope, String reason) { - String summary = "matchmakerOperatorId=" + matchmakerOperatorId - + ";scope=" + scope - + ";reason=" + (reason == null ? "" : reason); - return summary.length() > 500 ? summary.substring(0, 500) : summary; + Map summary = Map.of( + "matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId, + "scope", scope == null ? "" : scope, + "reason", reason == null ? "" : reason); + try { + return OBJECT_MAPPER.writeValueAsString(summary); + } catch (JsonProcessingException e) { + throw new IllegalStateException("audit afterData JSON serialization failed", e); + } } private void saveAudit(String operatorId, String action, String resourceId, String afterData) { diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java index 49bfaea..f2c5984 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java @@ -64,17 +64,38 @@ public class MatchmakerService { */ @Transactional public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) { + return submitServiceRequest(userId, serviceType, description, null, null); + } + + @Transactional + public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description, + String targetUserId, Boolean authorizationAccepted) { + boolean assistance = "recommendation_assistance".equals(serviceType); + if (assistance) { + if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象"); + if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料"); + if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己"); + if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助"); + if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请"); + } MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); svc.setId(UUID.randomUUID().toString()); svc.setUserId(userId); svc.setServiceType(serviceType); svc.setDescription(description); + svc.setTargetUserId(targetUserId); + if (assistance) svc.setAuthorizationAt(Instant.now()); svc.setStatus("submitted"); serviceMapper.insert(svc); log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType); return svc; } + @Transactional + public void withdrawService(String serviceId, String userId, String reason) { + if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回"); + } + /** 查询我的红娘服务历史 */ public List myServices(String userId, int limit) { return serviceMapper.findByUser(userId, Math.min(limit, 50)); diff --git a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java index 6de30cb..a9a948e 100644 --- a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java +++ b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java @@ -45,5 +45,26 @@ public class ReportController { return ApiResponse.ok(reportService.getMyReports(userId)); } + /** + * 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL) + * GET /api/v1/risk/reports/{reportId} + */ + @GetMapping("/{reportId}") + public ApiResponse> getMyReportDetail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String reportId) { + return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId)); + } + + /** + * 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报) + * POST /api/v1/risk/reports/evidence/upload-token + */ + @PostMapping("/evidence/upload-token") + public ApiResponse> getEvidenceUploadToken( + @AuthenticationPrincipal UserPrincipal principal) { + String userId = principal.id(); + return ApiResponse.ok(reportService.getEvidenceUploadToken(userId)); + } } diff --git a/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java b/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java index 26fae9b..82a9d36 100644 --- a/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java +++ b/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java @@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.audit.service.AuditLogService; import com.danyang.match.platform.notification.service.NotificationService; import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; import com.danyang.match.platform.risk.dto.ReportItemDTO; import com.danyang.match.platform.risk.dto.ReportProcessDTO; import com.danyang.match.platform.risk.dto.ReportSubmitDTO; @@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskEventMapper; import com.danyang.match.platform.shared.constant.Constants; -import lombok.RequiredArgsConstructor; +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.RequestContextHolder; @@ -30,9 +40,11 @@ import java.time.Instant; import java.util.Map; import java.util.List; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.exception.ServerErrorException; /** * 举报与风控服务 @@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine * - 封禁需要人工复核,不能自动永久封禁 */ @Service -@RequiredArgsConstructor public class ReportService { private static final Logger log = LoggerFactory.getLogger(ReportService.class); + /** 补充说明字数上限(与前端 maxlength 对齐)。 */ + private static final int MAX_DESCRIPTION_LENGTH = 500; + + /** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */ + private static final int DEFAULT_MAX_EVIDENCE = 9; + + /** 运营端按状态列表查询的单页上限。 */ + private static final int STATUS_LIST_LIMIT = 200; + + /** 高风险举报原因:必须补充文字说明。 */ + private static final Set HIGH_RISK_REASONS = Set.of( + Constants.REPORT_REASON_FRAUD, + Constants.REPORT_REASON_MONEY_INDUCEMENT, + Constants.REPORT_REASON_IMPERSONATION, + Constants.REPORT_REASON_FAKE_MARITAL_STATUS + ); + private final ReportMapper reportMapper; private final RiskEventMapper riskEventMapper; private final RiskActionMapper riskActionMapper; private final UserProfileMapper userProfileMapper; + private final UserMapper userMapper; private final AuditLogMapper auditLogMapper; + private final AuditLogService auditLogService; private final NotificationService notificationService; + private final ObjectMapper objectMapper; + /** 证据对象的归属/存在性校验走内网客户端。 */ + private final AmazonS3 amazonS3; + /** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */ + private final AmazonS3 amazonS3Signer; + + @Value("${object-storage.bucket-private}") + private String privateBucket; + + @Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}") + private int maxEvidenceCount; + + @Autowired + public ReportService( + ReportMapper reportMapper, + RiskEventMapper riskEventMapper, + RiskActionMapper riskActionMapper, + UserProfileMapper userProfileMapper, + UserMapper userMapper, + AuditLogMapper auditLogMapper, + AuditLogService auditLogService, + NotificationService notificationService, + ObjectMapper objectMapper, + @Qualifier("amazonS3") AmazonS3 amazonS3, + @Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) { + this.reportMapper = reportMapper; + this.riskEventMapper = riskEventMapper; + this.riskActionMapper = riskActionMapper; + this.userProfileMapper = userProfileMapper; + this.userMapper = userMapper; + this.auditLogMapper = auditLogMapper; + this.auditLogService = auditLogService; + this.notificationService = notificationService; + this.objectMapper = objectMapper; + this.amazonS3 = amazonS3; + this.amazonS3Signer = amazonS3Signer; + } // 风险评分权重 private static final Map REASON_SCORES = Map.of( @@ -75,10 +142,30 @@ public class ReportService { */ @Transactional public Map submitReport(String reporterUserId, ReportSubmitDTO dto) { + // 举报对象校验 + if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象"); + } // 不允许举报自己 if (reporterUserId.equals(dto.getReportedUserId())) { throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己"); } + // 举报原因白名单(REASON_SCORES 的 key 即合法枚举) + String reasonType = dto.getReasonType(); + if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) { + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法"); + } + // 补充说明长度 + 高风险必填 + String description = dto.getDescription() == null ? "" : dto.getDescription().trim(); + if (description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, + "补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)"); + } + if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) { + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明"); + } + // 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象) + List evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds()); // 检查是否已被封禁 List activeActions = riskActionMapper.findActiveByUserId(reporterUserId); @@ -91,34 +178,35 @@ public class ReportService { Report report = new Report(); report.setReporterUserId(reporterUserId); report.setReportedUserId(dto.getReportedUserId()); - report.setReasonType(dto.getReasonType()); - report.setDescription(dto.getDescription()); - if (dto.getEvidenceFileIds() != null) { - report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString()); - } + report.setReasonType(reasonType); + report.setDescription(description.isEmpty() ? null : description); + // evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString() + report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds)); report.setStatus(Constants.REPORT_STATUS_PENDING); reportMapper.insert(report); // 触发风险事件:被举报用户得分增加 - int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5); + int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5); RiskEvent riskEvent = new RiskEvent(); riskEvent.setUserId(dto.getReportedUserId()); riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT); riskEvent.setScore(scoreAdd); riskEvent.setSource("report"); riskEvent.setSourceId(report.getId()); - riskEvent.setNote("举报触发,原因: " + dto.getReasonType()); + riskEvent.setNote("举报触发,原因: " + reasonType); riskEventMapper.insert(riskEvent); // 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充) if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT, - Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) { + Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) { report.setStatus(Constants.REPORT_STATUS_PROCESSING); reportMapper.updateById(report); } - // 审计 - saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null); + // 审计(C 端用户主体:写 subject_user_id 且 operator_id=null) + // 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators) + // 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。 + auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null); Map result = new HashMap<>(); result.put("reportId", report.getId()); @@ -135,6 +223,45 @@ public class ReportService { .collect(Collectors.toList()); } + /** + * 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。 + * 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。 + */ + public Map getMyReportDetail(String userId, String reportId) { + Report report = reportMapper.selectById(reportId); + if (report == null || !userId.equals(report.getReporterUserId())) { + throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在"); + } + Map detail = new HashMap<>(); + detail.put("report", toDTO(report)); + List evidenceFileKeys = parseEvidence(report.getEvidenceFileIds()); + detail.put("evidenceFileKeys", evidenceFileKeys); + detail.put("evidenceUrls", evidenceFileKeys.stream() + .map(this::signEvidenceUrl) + .collect(Collectors.toList())); + return detail; + } + + /** + * 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。 + * fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。 + */ + public Map getEvidenceUploadToken(String userId) { + String fileKey = String.format("reports/%s/evidence/%s.jpg", + userId, UUID.randomUUID().toString().replace("-", "")); + Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); + GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey) + .withMethod(HttpMethod.PUT) + .withExpiration(expiration); + String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString(); + + Map token = new HashMap<>(); + token.put("fileKey", fileKey); + token.put("uploadUrl", uploadUrl); + token.put("expiresIn", 3600); + return token; + } + // ==================== 管理端举报处理 ==================== /** @@ -157,6 +284,13 @@ public class ReportService { Map detail = new HashMap<>(); detail.put("report", toDTO(report)); + // 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录) + List evidenceFileKeys = parseEvidence(report.getEvidenceFileIds()); + detail.put("evidenceFileKeys", evidenceFileKeys); + detail.put("evidenceUrls", evidenceFileKeys.stream() + .map(this::signEvidenceUrl) + .collect(Collectors.toList())); + // 被举报人风险记录 String reportedUserId = report.getReportedUserId(); if (reportedUserId != null) { @@ -215,7 +349,10 @@ public class ReportService { } // 通知被举报用户 - sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote()); + sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId); + + // 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节) + sendReporterResultNotification(report.getReporterUserId(), result, reportId); // 高风险举报(婚姻状态欺诈)额外记录风险事件 if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) { @@ -257,8 +394,20 @@ public class ReportService { return m; }).collect(java.util.stream.Collectors.toList()); } - // For other statuses, return empty or implement full query - return List.of(); + // 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空) + return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> { + Map m = new java.util.HashMap<>(); + m.put("id", report.getId()); + m.put("reasonType", report.getReasonType()); + m.put("reasonText", getReasonText(report.getReasonType())); + m.put("reportedUserId", report.getReportedUserId()); + m.put("status", report.getStatus()); + m.put("description", report.getDescription()); + m.put("createdAt", report.getCreatedAt()); + m.put("processResult", report.getProcessResult()); + m.put("processNote", report.getProcessNote()); + return m; + }).collect(java.util.stream.Collectors.toList()); } public Map getReportStats() { @@ -298,6 +447,83 @@ public class ReportService { // ==================== 私有辅助方法 ==================== + /** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */ + private List validateEvidence(String reporterUserId, List evidenceFileIds) { + if (evidenceFileIds == null || evidenceFileIds.isEmpty()) { + return List.of(); + } + if (evidenceFileIds.size() > maxEvidenceCount) { + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, + "证据截图最多 " + maxEvidenceCount + " 张"); + } + String prefix = "reports/" + reporterUserId + "/"; + for (String key : evidenceFileIds) { + if (key == null || !key.startsWith(prefix)) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识"); + } + if (!amazonS3.doesObjectExist(privateBucket, key)) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成"); + } + } + return evidenceFileIds; + } + + /** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */ + private String writeEvidenceJson(List evidenceFileIds) { + if (evidenceFileIds == null || evidenceFileIds.isEmpty()) { + return "[]"; + } + try { + return objectMapper.writeValueAsString(evidenceFileIds); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new ServerErrorException("证据序列化失败", e); + } + } + + /** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */ + private List parseEvidence(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json); + return List.of(); + } + } + + /** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */ + private String signEvidenceUrl(String fileKey) { + Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10)); + return amazonS3Signer.generatePresignedUrl( + new GeneratePresignedUrlRequest(privateBucket, fileKey) + .withMethod(HttpMethod.GET) + .withExpiration(expiration)).toString(); + } + + /** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */ + private String displayNameOf(String userId) { + if (userId == null) return null; + return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null); + } + + /** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */ + private void sendReporterResultNotification(String reporterUserId, String result, String reportId) { + if (reporterUserId == null) return; + String title = "举报处理结果"; + String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result) + ? "您提交的举报经核实未发现问题,感谢您的监督。" + : "您提交的举报已处理完成,感谢您对社区安全的贡献。"; + try { + // business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串) + notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content, + "report", reportId); + } catch (Exception e) { + log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e); + } + } + private void applyRiskAction(String userId, String result, String note, String operatorId) { RiskAction action = new RiskAction(); action.setUserId(userId); @@ -329,6 +555,34 @@ public class ReportService { action.setEffectiveAt(Instant.now()); action.setReasonDetail(note); riskActionMapper.insert(action); + + // 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 —— + // 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定 + // 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。 + String newAccountStatus = switch (result) { + case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED; + case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED; + default -> null; // warning / feature_limit 不改变账号状态 + }; + if (newAccountStatus != null) { + syncAccountStatus(userId, newAccountStatus, operatorId); + } + } + + /** + * 把处置结果写入 users.account_status 并留痕。 + * 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。 + */ + private void syncAccountStatus(String userId, String newStatus, String operatorId) { + User user = userMapper.selectById(userId); + if (user == null) return; + String oldStatus = user.getAccountStatus(); + if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return; + user.setAccountStatus(newStatus); + userMapper.updateById(user); + // audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。 + saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId, + "{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}"); } private String calculateRiskLevel(int score) { @@ -351,6 +605,8 @@ public class ReportService { dto.setProcessNote(report.getProcessNote()); // 填充显示名称 dto.setReasonText(getReasonText(report.getReasonType())); + dto.setReporterName(displayNameOf(report.getReporterUserId())); + dto.setReportedName(displayNameOf(report.getReportedUserId())); return dto; } @@ -368,7 +624,7 @@ public class ReportService { }; } - private void sendProcessNotification(String userId, String result, String note) { + private void sendProcessNotification(String userId, String result, String note, String businessId) { if (userId == null) return; String title = "账号处置通知"; String content = switch (result) { @@ -378,8 +634,9 @@ public class ReportService { case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。"; default -> "您的账号相关处置已完成。"; }; + // business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚) notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content, - "risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis()); + "risk_action", businessId); } private void saveAuditLog(String operatorId, String action, String resourceType, 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 64ffbf2..b84396c 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,8 @@ public class SecurityConfig { .accessDeniedHandler(forbiddenHandler(objectMapper))) .authorizeHttpRequests(auth -> auth // 公开接口(order=0 最高优先级) - .requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll() + .requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login") + .permitAll() .requestMatchers("/health/**").permitAll() .requestMatchers("/version").permitAll() .requestMatchers("/app/config").permitAll() diff --git a/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java b/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java index 02d0434..31788c3 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java @@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter { private static final List PERMITTED_PATHS = List.of( "/api/v1/auth/**", + "/auth/**", "/api/v1/health/**", + "/health/**", "/api/v1/version", + "/version", "/api/v1/operator/auth/**", "/api/v1/app/**", - "/api/v1/activities" + "/app/**", + "/api/v1/activities", + "/activities" ); @Override @@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter { FilterChain chain) throws ServletException, IOException { String contextPath = request.getContextPath(); String servletPath = request.getServletPath(); + if (servletPath == null || servletPath.isEmpty()) { + servletPath = request.getRequestURI(); + } String effectivePath = (contextPath != null && !contextPath.isEmpty()) ? contextPath + servletPath : servletPath; String method = request.getMethod(); - if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) { + if ("OPTIONS".equals(method) + || isPermittedPath(effectivePath) + || isPermittedPath(request.getRequestURI())) { chain.doFilter(request, response); return; } @@ -136,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter { } private boolean isPermittedPath(String path) { + String normalizedPath = path.startsWith("/api/v1") + ? path + : "/api/v1" + (path.startsWith("/") ? path : "/" + path); return PERMITTED_PATHS.stream() - .anyMatch(pattern -> pathMatcher.match(pattern, path)); + .anyMatch(pattern -> pathMatcher.match(pattern, path) + || pathMatcher.match(pattern, normalizedPath)); } } diff --git a/backend/src/main/resources/db/demo-seed-final.sql b/backend/src/main/resources/db/demo-seed-final.sql index 8582a50..47d37e9 100644 --- a/backend/src/main/resources/db/demo-seed-final.sql +++ b/backend/src/main/resources/db/demo-seed-final.sql @@ -2,7 +2,6 @@ -- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL) -- ============================================================ BEGIN; -SET session_replication_role = 'replica'; -- 1. 组织 INSERT INTO organizations (id, name, type, contact_name, status) VALUES @@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES (gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active'); -- 2. 工作人员(V1.0原有 + V1.1新增) -INSERT INTO operators (id, organization_id, name, username, password_hash, status) -SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status) +SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='women_federation' UNION ALL -SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='women_federation' UNION ALL -SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='charity_center' UNION ALL -SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='trade_union' -- V1.1 新增演示操作员 UNION ALL -SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='women_federation' UNION ALL -SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='charity_center' UNION ALL -SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' +SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active' FROM organizations WHERE type='women_federation'; -- 操作员角色绑定(V1.0原有 + V1.1新增) @@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty -- ============================================================ -- 4. 审核任务 -- ============================================================ -INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version) +INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id) SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved', (SELECT id FROM operators WHERE username='reviewer01'), - NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1 + NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1, + 'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0') FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05'); -- ============================================================ @@ -331,17 +331,17 @@ VALUES WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1), trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1), mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1) -INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text, +INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text, start_at, end_at, location_name, location_address, male_limit, female_limit, total_limit, min_age, max_age, registration_start_at, registration_end_at, need_review, status, created_by) VALUES - (gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', + (gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001', '面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。', (SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会', '2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号', 15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)), - (gen_random_uuid(), '七夕特别活动·丹阳缘来是你', + (gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002', '七夕节特别策划,浪漫主题相亲活动。', (SELECT id FROM trade_org), '丹阳市新时代文明实践中心', '2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号', @@ -442,7 +442,6 @@ VALUES ('manual_review_needed', '需人工复核', 'quality', true, 7) ON CONFLICT (code) DO NOTHING; -SET session_replication_role = 'origin'; DROP TABLE IF EXISTS _conv_result; -- ============================================================ diff --git a/backend/src/main/resources/db/demo-seed-v11-increment.sql b/backend/src/main/resources/db/demo-seed-v11-increment.sql new file mode 100644 index 0000000..69fbd73 --- /dev/null +++ b/backend/src/main/resources/db/demo-seed-v11-increment.sql @@ -0,0 +1,487 @@ +-- ============================================================ +-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL) +-- ============================================================ +-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白: +-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、 +-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、 +-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格), +-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。 +-- +-- 约束(与任务一致): +-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。 +-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING, +-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。 +-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。 +-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。 +-- ============================================================ +BEGIN; + +-- ============================================================ +-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动) +-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条) +-- created_by 引用既有操作员 contenteditor +-- ============================================================ +INSERT INTO home_banners + (id, title, subtitle, button_text, target_type, target_value, + audience_scope, sort_order, start_at, end_at, status, created_by) +VALUES + ('a1111111-1111-1111-1111-111111110001'::uuid, + '婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导', + 'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active', + (SELECT id FROM operators WHERE username = 'contenteditor')), + ('a1111111-1111-1111-1111-111111110002'::uuid, + '婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南', + 'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5, + NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active', + (SELECT id FROM operators WHERE username = 'contenteditor')), + ('a1111111-1111-1111-1111-111111110003'::uuid, + '婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询', + 'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active', + (SELECT id FROM operators WHERE username = 'contenteditor')) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. 婚恋课堂分类 content_categories(4 个) +-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture), +-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。 +-- ============================================================ +INSERT INTO content_categories (id, name, code, description, sort_order, status) +SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active' +FROM (VALUES + ('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class', + '恋爱沟通、关系经营与择偶知识', 1), + ('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud', + '婚恋诈骗识别与防范知识', 2), + ('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu', + '婚前准备与关系建立指导', 3), + ('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture', + '传统婚俗与文明新风尚', 4) +) AS v(id, name, code, description, sort_order) +WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code); + +-- ============================================================ +-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题) +-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件) +-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor +-- ============================================================ +INSERT INTO content_articles + (id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order) +VALUES + ('a3333333-3333-3333-3333-333333330001'::uuid, + (SELECT id FROM content_categories WHERE code = 'marriage_class'), + '初次见面聊什么?五个不冷场的话题', + '第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。', + '很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。' + || E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。' + || E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。' + || E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。' + || E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。' + || E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。' + || E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1), + ('a3333333-3333-3333-3333-333333330002'::uuid, + (SELECT id FROM content_categories WHERE code = 'marriage_class'), + '如何判断两个人是否合适', + '合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。', + '判断两个人是否合适,可以从三个维度观察。' + || E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。' + || E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。' + || E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。' + || E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2), + ('a3333333-3333-3333-3333-333333330003'::uuid, + (SELECT id FROM content_categories WHERE code = 'marriage_class'), + '从喜欢到结婚:健康关系的发展节奏', + '好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。', + '从喜欢到结婚,往往要经历几个自然阶段。' + || E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。' + || E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。' + || E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。' + || E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。' + || E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。' + || E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3), + ('a3333333-3333-3333-3333-333333330004'::uuid, + (SELECT id FROM content_categories WHERE code = 'anti_fraud'), + '婚恋反诈指南:识别常见情感骗局', + '刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。', + '婚恋交友中,以下情况需要高度警惕。' + || E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。' + || E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。' + || E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。' + || E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。' + || E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。' + || E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1), + ('a3333333-3333-3333-3333-333333330005'::uuid, + (SELECT id FROM content_categories WHERE code = 'anti_fraud'), + '「杀猪盘」套路全解析', + '温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。', + '「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。' + || E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。' + || E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。' + || E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。' + || E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。' + || E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2), + ('a3333333-3333-3333-3333-333333330006'::uuid, + (SELECT id FROM content_categories WHERE code = 'premarital_edu'), + '婚前一定要聊清楚的十个问题', + '在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。', + '走进婚姻前,建议和对方认真聊清以下问题。' + || E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?' + || E'\n' || '二、婚后居住:和父母同住还是独立居住?' + || E'\n' || '三、家务分工:如何共同承担家庭事务?' + || E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?' + || E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?' + || E'\n' || '六、职业规划:是否支持对方的职业发展?' + || E'\n' || '七、冲突处理:吵架时如何避免伤害对方?' + || E'\n' || '八、双方底线:哪些事情无法接受?' + || E'\n' || '九、家庭应急:突发状况如何共同应对?' + || E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?' + || E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1), + ('a3333333-3333-3333-3333-333333330007'::uuid, + (SELECT id FROM content_categories WHERE code = 'custom_culture'), + '婚事新办,文明婚俗新风尚', + '低彩礼、简仪式、重情义,让婚礼回归幸福本身。', + '近年来,婚事新办、喜事简办逐渐成为新风尚。' + || E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。' + || E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。' + || E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。' + || E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。', + (SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动) +-- ============================================================ +INSERT INTO service_locations + (id, name, short_name, address, latitude, longitude, + contact_phone_display, opening_hours_text, service_description, + primary_organization_id, status) +VALUES + ('a4444444-4444-4444-4444-444444440001'::uuid, + '丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号', + 31.9210000, 119.6090000, '0511-86666888', + '周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)', + '提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验', + (SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'), + ('a4444444-4444-4444-4444-444444440002'::uuid, + '丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号', + 31.8600000, 119.4900000, '0511-86688866', + '周一至周五 9:00-16:30(法定节假日除外)', + '面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传', + (SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿) +-- 用户端公开列表只展示 status = active +-- ============================================================ +INSERT INTO service_programs + (id, name, description, organizer_org_id, start_at, end_at, status, created_by) +VALUES + ('a5555555-5555-5555-5555-555555550001'::uuid, + '公益红娘一对一服务计划', + '由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。', + (SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), + NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active', + (SELECT id FROM operators WHERE username = 'admin')), + ('a5555555-5555-5555-5555-555555550002'::uuid, + '婚恋反诈宣传进社区', + '联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。', + (SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1), + NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active', + (SELECT id FROM operators WHERE username = 'admin')), + ('a5555555-5555-5555-5555-555555550003'::uuid, + '婚前辅导公益课堂(筹备中)', + '面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。', + (SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), + NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft', + (SELECT id FROM operators WHERE username = 'admin')) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 6. 婚恋咨询 counseling_services(2 条) +-- service_type / status 取值对齐生产 CHECK 约束 +-- ============================================================ +INSERT INTO counseling_services + (id, user_id, service_type, request_source, user_request_summary, + organization_id, assigned_operator_id, status, + preferred_service_mode, scheduled_start_at, completed_at) +VALUES + ('a6666666-6666-6666-6666-666666660001'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'), + 'relationship_guidance', 'user_self', + '离异后重新开始,希望了解如何建立健康的新关系。', + (SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1), + (SELECT id FROM operators WHERE username = 'counselor'), + 'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'), + ('a6666666-6666-6666-6666-666666660002'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'), + 'emotional_support', 'user_self', + '工作压力较大,希望有人倾听并给出情绪调节建议。', + (SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1), + (SELECT id FROM operators WHERE username = 'counselor'), + 'assigned', 'online', NULL, NULL) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条) +-- 婚前辅导复用 counseling_services 表,无独立表 +-- ============================================================ +INSERT INTO counseling_services + (id, user_id, service_type, request_source, user_request_summary, + organization_id, assigned_operator_id, status, + companion_user_id, scheduled_start_at, user_confirmed_at, completed_at) +VALUES + ('a6666666-6666-6666-6666-666666660003'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'), + 'premarital_guidance', 'user_self', + '计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。', + (SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1), + (SELECT id FROM operators WHERE username = 'counselor'), + 'completed', + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'), + NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'), + ('a6666666-6666-6666-6666-666666660004'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'), + 'premarital_guidance', 'user_self', + '希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。', + (SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1), + (SELECT id FROM operators WHERE username = 'counselor'), + 'scheduled', + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'), + NOW() + INTERVAL '5 days', NULL, NULL) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 8. 本地资格 user_local_eligibilities(3 条) +-- eligibility_type / verification_status 对齐生产 CHECK 约束 +-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等 +-- ============================================================ +INSERT INTO user_local_eligibilities + (id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by) +VALUES + ('a7777777-7777-7777-7777-777777770001'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'), + 'household_registration', '321181', 'verified', + NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')), + ('a7777777-7777-7777-7777-777777770002'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'), + 'employment', '321181', 'verified', + NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')), + ('a7777777-7777-7777-7777-777777770003'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'), + 'residence', '321100', 'pending_review', NULL, NULL) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 9. 本地资格佐证 local_eligibility_evidences(2 条) +-- ============================================================ +INSERT INTO local_eligibility_evidences + (id, local_eligibility_id, file_key, evidence_type, status) +VALUES + ('a8888888-8888-8888-8888-888888880001'::uuid, + (SELECT id FROM user_local_eligibilities + WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')), + 'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'), + ('a8888888-8888-8888-8888-888888880002'::uuid, + (SELECT id FROM user_local_eligibilities + WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')), + 'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09) +-- ------------------------------------------------------------ +-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为 +-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻 +-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required +-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。 +-- +-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向): +-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在 +-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college; +-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异), +-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。 +-- * 账号满足 RecommendationEngineService.isParticipantEligible: +-- account_status=normal / relationship_status=searching / recommendation_enabled=true / +-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。 +-- ============================================================ +INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at) +VALUES + ('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'), + ('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'), + ('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days') +ON CONFLICT DO NOTHING; + +INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm, + region_code, region_name, marital_status, children_status, + education_code, occupation_code, smoking_status, drinking_status, + long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status) +VALUES + ('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172, + '321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes', + '是', '是', '结婚', 93, 'approved'), + ('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170, + '321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes', + '是', '是', '结婚', 91, 'approved'), + ('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168, + '321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes', + '是', '是', '结婚', 94, 'approved') +ON CONFLICT (user_id) DO NOTHING; + +-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容 +INSERT INTO user_preferences + (user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority, + region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority) +VALUES + ('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred', + '["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'), + ('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred', + '["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'), + ('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred', + '["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred') +ON CONFLICT (user_id) DO NOTHING; + +-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除 +INSERT INTO user_privacy_settings + (user_id, visible_in_recommendations, visible_in_search, show_occupation, + show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations) +VALUES + ('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true), + ('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true), + ('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true) +ON CONFLICT (user_id) DO NOTHING; + +-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识) +INSERT INTO user_identities + (id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at) +VALUES + ('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, + encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07', + 'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'), + ('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, + encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08', + 'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'), + ('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, + encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09', + 'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day') +ON CONFLICT DO NOTHING; + +-- 照片(头像 + 生活照,与既有演示用户一致) +INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status) +VALUES + ('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'), + ('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'), + ('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'), + ('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'), + ('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'), + ('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved') +ON CONFLICT DO NOTHING; + +-- ============================================================ +-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments) +-- ------------------------------------------------------------ +-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在 +-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my +-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。 +-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id +-- 保持一致(均指向演示红娘 wanghongniang)。 +-- ============================================================ +INSERT INTO matchmaker_assignments + (id, user_id, matchmaker_operator_id, service_id, assigned_by, + assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at) +VALUES + -- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中 + ('a9944444-4444-4444-4444-444444440001'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'), + (SELECT id FROM operators WHERE username = 'wanghongniang'), + (SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'), + (SELECT id FROM operators WHERE username = 'admin'), + 'primary', '演示:红娘小王主责跟进', 'active', + NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'), + -- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中 + ('a9944444-4444-4444-4444-444444440002'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'), + (SELECT id FROM operators WHERE username = 'wanghongniang'), + (SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'), + (SELECT id FROM operators WHERE username = 'admin'), + 'primary', '演示:红娘小王主责跟进', 'active', + NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'), + -- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配) + ('a9944444-4444-4444-4444-444444440003'::uuid, + (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'), + (SELECT id FROM operators WHERE username = 'wanghongniang'), + (SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'), + (SELECT id FROM operators WHERE username = 'admin'), + 'primary', '演示:服务已完结', 'ended', + NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 12. 服务站运营人员数据范围(service_location_operators) +-- ------------------------------------------------------------ +-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list) +-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source = +-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。 +-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围 +-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。 +-- ============================================================ +INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at) +VALUES + ((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'), + (SELECT id FROM operators WHERE username = 'stationop'), + (SELECT id FROM operators WHERE username = 'admin'), NOW()), + ((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'), + (SELECT id FROM operators WHERE username = 'stationop'), + (SELECT id FROM operators WHERE username = 'admin'), NOW()), + ((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'), + (SELECT id FROM operators WHERE username = 'stationop'), + (SELECT id FROM operators WHERE username = 'admin'), NOW()) +ON CONFLICT (service_location_id, operator_id) DO NOTHING; + +-- ============================================================ +-- 验证 +-- ============================================================ +DO $$ +DECLARE + b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER; + sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER; + rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER; +BEGIN + SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO pm_cnt FROM counseling_services + WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL; + SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO rec_cnt FROM users + WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09') + AND deleted_at IS NULL; + SELECT COUNT(*) INTO rec_pref FROM user_preferences p + WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid, + 'a9900000-0000-0000-0000-000000000008'::uuid, + 'a9900000-0000-0000-0000-000000000009'::uuid); + SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL; + SELECT COUNT(*) INTO slo_cnt FROM service_location_operators; + RAISE NOTICE '========================================'; + RAISE NOTICE 'V1.1 增量演示数据填充完成!'; + RAISE NOTICE '========================================'; + RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt; + RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt; + RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt; + RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt; + RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref; + RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt; + RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt; + RAISE NOTICE '========================================'; +END $$; + +COMMIT; diff --git a/backend/src/main/resources/db/demo-seed-v4.sql b/backend/src/main/resources/db/demo-seed-v4.sql index 57c04a8..96354de 100644 --- a/backend/src/main/resources/db/demo-seed-v4.sql +++ b/backend/src/main/resources/db/demo-seed-v4.sql @@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES -('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), +('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'), -('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), +('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'), -('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), +('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'), -('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'); +('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'); INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001'); diff --git a/backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql b/backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql new file mode 100644 index 0000000..3ab0577 --- /dev/null +++ b/backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql @@ -0,0 +1,12 @@ +-- 指定对象的公益红娘协助申请(不交换联系方式) +ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID; +ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ; +ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; +ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500); +ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type; +ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN ( + 'recommend_partner', 'review_preference', 'contact_intention', 'join_activity', + 'no_match_help', 'chat_help', 'other', 'recommendation', + 'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station' +)); +CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC); diff --git a/backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java index ea51bec..0e47ceb 100644 --- a/backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java @@ -15,6 +15,8 @@ import com.danyang.match.platform.risk.entity.RiskAction; import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.shared.config.FeatureFlagConfig; import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -68,6 +70,8 @@ class AppealPhase18IntegrationTest { @Autowired private NotificationMapper notificationMapper; @Autowired + private UserMapper userMapper; + @Autowired private FeatureFlagConfig featureFlagConfig; private boolean originalAppealEnabled; @@ -201,6 +205,44 @@ class AppealPhase18IntegrationTest { assertEquals("suspended", status.get("accountStatus")); } + // ==================== 申诉通过回滚 users.account_status(避免"通过仍锁死") ==================== + + private User insertUser(String accountStatus) { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus(accountStatus); + userMapper.insert(user); + return user; + } + + @Test + void approveRestoresAccountStatusToNormal() { + User user = insertUser(Constants.ACCOUNT_STATUS_BANNED); + RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE); + + String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN); + process(appealId, "approve"); + + assertEquals(Constants.ACCOUNT_STATUS_NORMAL, + userMapper.selectById(user.getId()).getAccountStatus(), + "申诉通过且该用户已无其他生效封禁/暂停时,必须把 users.account_status 恢复为 normal"); + } + + @Test + void approveKeepsAccountStatusWhenOtherRestrictionRemains() { + User user = insertUser(Constants.ACCOUNT_STATUS_BANNED); + RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE); + insertAction(user.getId(), Constants.RISK_ACTION_TEMPORARY_SUSPEND, Constants.RISK_ACTION_STATUS_ACTIVE); + + String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN); + process(appealId, "approve"); + + assertEquals(Constants.ACCOUNT_STATUS_BANNED, + userMapper.selectById(user.getId()).getAccountStatus(), + "仍有其他生效处置时,账号状态不得被回滚为 normal"); + } + // ==================== Gate B:拒绝后原处置保持有效 ==================== @Test diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index a8f83e0..f88a075 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -40,7 +40,8 @@ class MigrationValidationTest { // (六态状态机 + active 部分唯一索引) // 用户展示编号:V058 users.display_number 及历史数据回填。 // 公共身份展示编号:V059 operators.display_id 及历史数据回填。 - private static final int LATEST_MIGRATION = 60; + // 红娘协助对象与授权字段:V061 matchmaker_services 扩展。 + private static final int LATEST_MIGRATION = 61; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java index 2fe6724..d56be37 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java @@ -260,11 +260,11 @@ class MatchmakerAssignmentA1Test { Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count()); assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())), "resourceType 必须是 matchmaker_assignment"); - // afterData 摘要口径:只含 matchmakerOperatorId/scope/reason + // afterData 摘要口径:只含 matchmakerOperatorId/scope/reason,且必须是合法 JSON assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null - && a.getAfterData().contains("matchmakerOperatorId=") - && a.getAfterData().contains("scope=") - && a.getAfterData().contains("reason=")), + && a.getAfterData().contains("\"matchmakerOperatorId\"") + && a.getAfterData().contains("\"scope\"") + && a.getAfterData().contains("\"reason\"")), "afterData 必须记录 operatorId/scope/reason 摘要"); } diff --git a/backend/src/test/java/com/danyang/match/platform/risk/ReportAndRiskPhase17IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/risk/ReportAndRiskPhase17IntegrationTest.java index 4fb7948..edbbff0 100644 --- a/backend/src/test/java/com/danyang/match/platform/risk/ReportAndRiskPhase17IntegrationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/risk/ReportAndRiskPhase17IntegrationTest.java @@ -17,6 +17,8 @@ import com.danyang.match.platform.risk.service.ReportService; import com.danyang.match.platform.risk.service.RiskReferralService; import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -76,6 +78,8 @@ class ReportAndRiskPhase17IntegrationTest { @Autowired private AuditLogMapper auditLogMapper; @Autowired + private UserMapper userMapper; + @Autowired private ReportOperatorController reportOperatorController; @Autowired private RiskController riskController; @@ -164,6 +168,64 @@ class ReportAndRiskPhase17IntegrationTest { assertEquals(auditBefore + 1, auditAfter, "PROCESS_REPORT 审计应新增一条"); } + // ==================== 处置必须真正生效:同步 users.account_status ==================== + + /** + * 封禁 / 暂停若只写 risk_actions 而不落 users.account_status,则登录门禁、推荐引擎、 + * 匹配资格、能力判定全部不认这个处置 —— 被封禁用户仍可登录、仍出现在推荐/发现中。 + * 本测试用真实 users 行锁定「处置 → users.account_status」这条链路。 + */ + @Test + void processReportSyncsAccountStatusForBanAndSuspendButNotWarning() { + User banUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL); + User suspendUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL); + User warnUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL); + + processReportFor(banUser.getId(), Constants.PROCESS_RESULT_BAN); + processReportFor(suspendUser.getId(), Constants.PROCESS_RESULT_TEMPORARY_SUSPEND); + processReportFor(warnUser.getId(), Constants.PROCESS_RESULT_WARNING); + + assertEquals(Constants.ACCOUNT_STATUS_BANNED, + userMapper.selectById(banUser.getId()).getAccountStatus(), + "封禁处置必须把 users.account_status 置为 banned,否则封禁不真正生效"); + assertEquals(Constants.ACCOUNT_STATUS_SUSPENDED, + userMapper.selectById(suspendUser.getId()).getAccountStatus(), + "暂停处置必须把 users.account_status 置为 suspended"); + assertEquals(Constants.ACCOUNT_STATUS_NORMAL, + userMapper.selectById(warnUser.getId()).getAccountStatus(), + "警告处置不改变账号状态"); + + // 审计 before/after 为 JSONB:必须落合法 JSON 文本(裸字符串在 PG 上会被拒绝)。 + AuditLog statusAudit = auditLogMapper.selectOne(new QueryWrapper() + .eq("action", "ACCOUNT_STATUS_CHANGE").eq("resource_id", banUser.getId())); + assertNotNull(statusAudit, "处置同步账号状态应写入 ACCOUNT_STATUS_CHANGE 审计"); + assertTrue(statusAudit.getAfterData().contains("\"accountStatus\":\"banned\""), + "审计 after_data 必须是合法 JSON 且记录新状态,实际: " + statusAudit.getAfterData()); + } + + private User insertUser(String accountStatus) { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus(accountStatus); + userMapper.insert(user); + return user; + } + + private void processReportFor(String reportedUserId, String processResult) { + ReportSubmitDTO dto = new ReportSubmitDTO(); + dto.setReportedUserId(reportedUserId); + dto.setReasonType(Constants.REPORT_REASON_FRAUD); + dto.setDescription("处置同步账号状态测试"); + String reportId = (String) reportService.submitReport(UUID.randomUUID().toString(), dto) + .get("reportId"); + + ReportProcessDTO pdto = new ReportProcessDTO(); + pdto.setProcessResult(processResult); + pdto.setProcessNote("处置同步账号状态测试"); + reportService.processReport(reportId, pdto, "op-account-sync"); + } + // ==================== D2:人工风险标记必须审计 ==================== @Test diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 8158582..08a876e 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -453,7 +453,9 @@ CREATE TABLE activity_staff_assignments ( -- V009: matchmaker_services 红娘服务表 CREATE TABLE matchmaker_services ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL, - description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted', + description VARCHAR(1000), target_user_id VARCHAR(36), authorization_at TIMESTAMP WITH TIME ZONE, + withdrawn_at TIMESTAMP WITH TIME ZONE, withdraw_reason VARCHAR(500), + status VARCHAR(32) NOT NULL DEFAULT 'submitted', organization_id VARCHAR(36), assigned_operator_id VARCHAR(36), assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE, referral_target_type VARCHAR(32), referral_target_id VARCHAR(36), @@ -464,7 +466,7 @@ CREATE TABLE matchmaker_services ( CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN ( 'recommend_partner', 'review_preference', 'contact_intention', 'join_activity', 'no_match_help', 'chat_help', 'other', - 'recommendation', + 'recommendation', 'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station' )), CONSTRAINT chk_matchmaker_status CHECK (status IN ( diff --git a/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md b/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md new file mode 100644 index 0000000..a0daddd --- /dev/null +++ b/docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md @@ -0,0 +1,77 @@ +# V1.1 演示版基线(2026-10-19) + +## 范围 + +本批次目标是形成可重复启动、可验证的本地演示版本。允许使用 Mock 微信登录和演示账号;不把演示结果当作正式生产发布证据。 + +本批次明确不验收:真实微信账号、生产证书、正式域名、真实用户 UAT、外部备份介质。 + +## 当前基线证据 + +| 门禁 | 当前结果 | +|---|---| +| 后端 Maven 全量 | PASS:919 tests,0 failures,0 errors,17 skipped | +| Admin type-check | PASS | +| Admin build | PASS,但有 CSS URL 解析警告和大 chunk 警告 | +| Miniapp type-check | PASS | +| 空壳测试守卫 | PASS | +| 本地 Compose 解析 | PASS | +| 生产 Compose 解析 | PASS:使用临时演示变量验证通过 | +| 活栈 Compose | PASS:`danyang-demo` 隔离栈健康,PostgreSQL/Redis/SeaweedFS/Backend/Nginx 均就绪 | +| E2E | PASS:全新卷迁移/seed 后真实本地 PostgreSQL 活栈 `72 passed`(2026-10-19 重新验证) | +| UI 契约与空壳守卫 | PASS:TabBar/页面/WXML/WXSS/API 行为契约通过 | +| H5 浏览器冒烟 | PASS:活栈 `/h5` 下未登录守卫、演示用户登录、6 个核心页、Like、红娘工作台均通过(2026-10-19 重新验证) | + +## 演示候选范围 + +- 后端 Java、数据库迁移、后端测试和 E2E fixture。 +- `infrastructure/docker-compose.yml`、staging 配置、Nginx 和演示环境变量示例。 +- `admin-web` 构建产物和类型检查范围。 +- `miniapp` 类型检查及演示路径相关代码。 +- 必要的演示验收、部署和运行文档。 + +## 明确排除 + +- `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 等机器本地临时目录。 +- 未经本批次审查的 H5 或演示新增文件。 +- 真实微信 Provider、生产证书、正式域名、真实用户数据和外部备份介质。 +- 与演示闭环无关的功能扩展和大范围重构。 + +## 演示验证命令 + +```powershell +mvn -B -f backend/pom.xml test +pnpm --dir admin-web type-check +pnpm --dir admin-web build +npm --prefix miniapp run type-check +python scripts/check-no-empty-tests.py +docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet +$env:E2E_BASE_URL='http://localhost:8085/api/v1' +$env:E2E_OPERATOR_PASSWORD='演示环境密码' +python -m pytest tests/e2e -q +$env:DEMO_H5_BASE_URL='http://localhost:6689/h5' +npm --prefix h5-demo run smoke +``` + +本次全新卷验证还确认:首次迁移与 Demo seed 成功,`seed-demo.ps1 -E2E` 第二次重跑幂等成功。当前 `danyang-demo` 数据卷使用创建时的临时凭据(PostgreSQL `demo-db`、Redis `demo-redis`);重建 backend 时必须向 Compose 注入同一组卷凭据,并使用长度至少 32 字节的 JWT 临时密钥。该凭据仅用于本机演示,不得用于生产。 + +## 演示数据初始化 + +在 Compose 栈健康后执行一次即可;脚本可重复运行,不会清空数据库: + +```powershell +.\scripts\seed-demo.ps1 +``` + +需要同时准备 E2E 运营账号时: + +```powershell +$env:E2E_OPERATOR_PASSWORD='DemoPass123!' +.\scripts\seed-demo.ps1 -E2E +``` + +## 当前判断 + +演示版达到 `CONDITIONAL GO (DEMO ONLY)`:可在本机 Docker 环境演示核心用户、运营、红娘、咨询、服务站和活动互选流程。该结论不覆盖真实微信、生产证书、正式域名、真实用户 UAT 或外部备份介质,也不等于正式生产 `GO`。 + +正式生产仍保持 `NO-GO`,待上述排除项完成后另行执行生产部署、真实集成、UAT、恢复演练和发布审批。 diff --git a/docs/superpowers/plans/2026-10-19-demo-release-repair-plan.md b/docs/superpowers/plans/2026-10-19-demo-release-repair-plan.md new file mode 100644 index 0000000..8e86f25 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-demo-release-repair-plan.md @@ -0,0 +1,349 @@ +# V1.1 演示发布修复实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不依赖真实微信账号、生产证书、正式域名、真实用户 UAT 和外部备份介质的前提下,修复当前代码并形成可重复启动、可验证的 V1.1 演示版本。 + +**Architecture:** 保留现有模块化单体、Mock 微信 Provider、PostgreSQL、Redis、SeaweedFS、管理端和小程序结构。演示环境与生产环境配置分开;演示验收使用真实 HTTP、真实 PostgreSQL 和真实容器,不把 H2 或静态断言当作完整演示证据。 + +**Tech Stack:** Java 21 / Spring Boot 3 / Maven / PostgreSQL 16 / Redis 7 / SeaweedFS 4.47 / Docker Compose / Vue 3 / TypeScript / 微信原生小程序 / pytest。 + +**Spec:** `AGENTS.md`、`README.md`、`docs/PHASE26-GO-LIVE-CHECKLIST.md`、`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。 + +## Global Constraints + +- 演示环境允许 Mock 微信登录,但必须显式使用演示 profile,不能让生产 profile 误启用 Mock。 +- PostgreSQL 是 Source of Truth;演示 E2E 必须使用真实 PostgreSQL,不得以 H2 结果替代。 +- 活动互选必须经过双方确认后才创建 Match;红娘权限必须由服务端分配范围控制。 +- 不修改真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质相关范围;这些作为正式发布前遗留项记录。 +- 不清理或回滚用户现有未提交改动;发布候选树必须通过显式文件清单建立。 +- 不新增依赖;优先使用已有 Maven、pnpm、npm、pytest 和 Compose 工具链。 +- 每个任务完成后先跑该任务的最小测试,再进入下一任务;全量回归最后执行。 + +--- + +### Task 1: 建立演示发布基线并隔离工作区变更 + +**Files:** +- Modify: `docs/STATUS.md`(追加本次演示修复批次状态) +- Modify: `docs/REMAINING_WORK.md`(把真实生产风险与演示阻塞分开) +- Create: `docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md` +- Inspect: `git status --short`、`git diff --stat`、`VERSION`、现有 release tag + +**Interfaces:** +- Produces: 演示候选版本的文件清单、排除清单和验证命令。 +- Later tasks consume: 候选版本范围,避免把 `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 和未审查演示文件混入发布。 + +- [ ] **Step 1: 记录当前失败基线** + + 记录当前命令及结果: + + ```powershell + git status --short + git diff --stat + mvn -B test -f backend/pom.xml + pnpm --dir admin-web type-check + pnpm --dir admin-web build + npm --prefix miniapp run type-check + python scripts/check-no-empty-tests.py + ``` + +- [ ] **Step 2: 建立候选文件清单** + + 将生产代码、测试、迁移、演示 Compose、E2E 和文档分组;明确机器临时目录、H5 演示目录和新增迁移是否纳入本批次。 + +- [ ] **Step 3: 记录演示版接受边界** + + 在 `DEMO-RELEASE-BASELINE-2026-10-19.md` 写明:Mock 微信可用、HTTPS/正式域名不验收、演示账号不代表真实用户验收、备份只做本机可恢复性检查。 + +- [ ] **Step 4: 验证文档不再互相矛盾** + + 检查 README、STATUS、REMAINING_WORK、PHASE26 清单中的演示状态一致;不得把历史全绿报告写成当前证据。 + +**验收:** 文档能准确指出当前失败项、演示范围和排除范围;不改业务代码。 + +--- + +### Task 2: 修复后端当前 9 个失败门禁 + +**Files:** +- Modify: `backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java` 或实际配置匿名路径的文件 +- Modify: `backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java`(仅在测试证明登录入口路径配置错误时) +- Modify: `backend/src/main/java/com/danyang/match/platform/health/controller/HealthController.java`(仅在映射存在但未放行时) +- Modify: `backend/src/test/java/com/danyang/match/platform/auth/AuthControllerTest.java` +- Modify: `backend/src/test/java/com/danyang/match/platform/auth/HealthControllerTest.java` +- Modify: `backend/src/test/java/com/danyang/match/platform/auth/PublicMiniappApiTest.java` + +**Interfaces:** +- Produces: 未认证健康检查和公开小程序接口返回约定的 200;受保护接口仍返回 401/403。 +- Preserves: `Authorization: Bearer`、运营端和用户端角色隔离、统一响应体格式。 + +- [ ] **Step 1: 先定位过滤链原因** + + 用失败测试和 `SecurityConfig` 的 request matcher 对照,列出每个失败 URI、期望状态、当前 matcher。不要通过降低测试断言来消除失败。 + +- [ ] **Step 2: 写最小回归断言** + + 为每个公开 URI 保留匿名 200 用例,并为一个受保护业务 URI 保留匿名 401 用例,确保放行规则没有扩大到用户数据接口。 + +- [ ] **Step 3: 修改一次共享安全配置** + + 在统一的 Security matcher 中补齐健康、版本、微信登录和公开小程序资源;不在各 Controller 增加重复鉴权绕过。 + +- [ ] **Step 4: 运行专项测试** + + ```powershell + mvn -B -f backend/pom.xml test ` + "-Dtest=AuthControllerTest,HealthControllerTest,PublicMiniappApiTest" + ``` + +- [ ] **Step 5: 运行权限回归** + + ```powershell + mvn -B -f backend/pom.xml test ` + "-Dtest=SecurityPathCoverageTest,OperatorEndpointPermissionCoverageTest,V11PermissionMatrixContractTest" + ``` + +**验收:** 上述专项测试全绿;匿名公开接口不泄露用户数据;受保护接口仍不能匿名访问。 + +--- + +### Task 3: 修复 V061 迁移与基线守卫 + +**Files:** +- Modify: `backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql` +- Modify: `backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java` +- Modify: `backend/src/test/resources/schema-h2.sql`(只有实体/测试 SQL 需要同步时才改) +- Modify: `docs/database/MIGRATION_GUIDE.md` + +**Interfaces:** +- Produces: 唯一、连续、可被 Flyway 识别的 V061 迁移;测试基线与生产迁移目录一致。 +- Preserves: 已部署 V001-V060,不重写历史迁移,不复用已发布版本号。 + +- [ ] **Step 1: 用现有正则定位失败原因** + + 现有守卫要求 `V\\d{3}__[^_]+(?:_[^_]+)*\\.sql`;确认 `extend_matchmaker_assistance` 中的双下划线是否导致描述段不符合当前规则,并记录选择。 + +- [ ] **Step 2: 选择兼容命名方案** + + 优先把文件名改为满足现有命名规则的单下划线描述;如果业务确实需要允许双下划线,则修改守卫并新增正例/负例,不能只把 `LATEST_MIGRATION` 改成 61。 + +- [ ] **Step 3: 更新迁移基线说明** + + 在测试注释和迁移指南中登记 V061 的用途、不可重复执行要求和 `matchmaker_services` 新字段。 + +- [ ] **Step 4: 做源码和构建产物双重验证** + + ```powershell + mvn -B -f backend/pom.xml -DskipTests package + mvn -B -f backend/pom.xml test ` + "-Dtest=MigrationValidationTest,EntityMigrationParityTest,DeploymentTests" + ``` + +**验收:** V061 命名守卫通过;构建产物中无重复版本;空库 Flyway 可从头执行到 V061。 + +--- + +### Task 4: 修复并验证演示 Compose + +**Files:** +- Modify: `infrastructure/docker-compose.yml` +- Modify: `infrastructure/docker-compose.staging.yml` +- Modify: `infrastructure/docker-compose.prod.yml`(只修语法和清晰的演示/生产边界,不把生产风险伪装成已解决) +- Modify: `infrastructure/.env.example` +- Modify: `infrastructure/nginx.conf` +- Modify: `h5-demo/`(仅当容器构建或 `/h5/` 路由失败时) +- Create: `infrastructure/.env.demo.example`(若现有 `.env.example` 无法表达演示必需变量) + +**Interfaces:** +- Produces: `danyang-demo` 项目可独立启动,端口、容器名、健康检查和演示 Mock 配置一致。 +- Preserves: 生产 Compose 使用必需变量和 secret;不把演示密码复制进生产文件。 + +- [ ] **Step 1: 修正生产 Compose 插值语法** + + 把 `${MINIO_PUBLIC_ENDPOINT:http://seaweedfs:8333}` 改成 Compose 合法形式 `${MINIO_PUBLIC_ENDPOINT:-http://seaweedfs:8333}`,并扫描同类错误。 + +- [ ] **Step 2: 固化演示环境变量** + + 演示 `.env` 至少提供 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET`、`ENCRYPTION_KEY` 和 `WECHAT_MOCK_OPENID`;文件中的值只用于本机演示。 + +- [ ] **Step 3: 隔离 Compose 项目名** + + 所有演示命令统一使用: + + ```powershell + docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet + docker compose -p danyang-demo -f infrastructure/docker-compose.yml up -d --build + ``` + +- [ ] **Step 4: 验证容器健康和路由** + + 检查 PostgreSQL、Redis、SeaweedFS、storage-init、backend、admin-web、h5-web、nginx;验证 `/api/v1/health/live`、`/admin/`、`/h5/`。 + +- [ ] **Step 5: 清理演示栈** + + ```powershell + docker compose -p danyang-demo -f infrastructure/docker-compose.yml down + ``` + + 仅删除演示项目容器,不使用跨项目的 `--remove-orphans`。 + +**验收:** 本地 Compose 解析成功;演示栈可重复启动和停止;生产 Compose 至少能在提供必需变量后解析成功。 + +--- + +### Task 5: 修复演示 E2E 运行条件并完成核心闭环 + +**Files:** +- Modify: `tests/e2e/conftest.py`(仅修环境变量、URL 或账号种子问题) +- Modify: `tests/e2e/sql/seed_e2e_operators.sql` +- Modify: `tests/e2e/home_v11_test.py` +- Modify: `tests/e2e/local_eligibility_test.py` +- Modify: `tests/e2e/activity_mutual_consent_test.py` +- Modify: `tests/e2e/service_appointment_test.py` +- Modify: `tests/e2e/referral_v11_test.py` +- Modify: `tests/e2e/service_station_permission_test.py` +- Modify: `tests/e2e/counseling_permission_test.py` +- Modify: `tests/e2e/content_e2e_test.py` +- Inspect: `tests/e2e/run_v11_tests.py`(不得把未收集脚本算作 pytest 通过) + +**Interfaces:** +- Produces: 面向演示栈的真实 HTTP + PostgreSQL E2E 结果。 +- Preserves: 活栈不可达退出码 4;不使用 skip 伪装未执行;Mock 只替代微信登录。 + +- [ ] **Step 1: 先验证活栈失败路径** + + 在未启动栈时运行 `python -m pytest tests/e2e -q`,确认是明确的活栈退出,而不是大量测试被跳过。 + +- [ ] **Step 2: 启动演示栈并初始化 E2E 账号** + + 使用隔离 Compose 项目名;通过 SQL seed 创建 `e2e_admin`、`e2e_platform`、`e2e_counselor`、`e2e_station`、`e2e_matchmaker`、`e2e_content`,密码仅通过环境变量传入。 + +- [ ] **Step 3: 运行 E2E** + + ```powershell + $env:E2E_BASE_URL='http://localhost:8085/api/v1' + $env:E2E_OPERATOR_PASSWORD='演示环境密码' + python -m pytest tests/e2e -q + ``` + +- [ ] **Step 4: 修复真实失败根因** + + 按失败类型处理:登录/fixture 配置、权限范围、迁移数据、HTTP 状态码、业务状态机。禁止直接放宽断言或给测试增加 skip。 + +- [ ] **Step 5: 验证核心跨端路径** + + 至少保留一条可复现证据:运营端分配红娘服务 → 用户端看到服务记录 → 用户提交服务操作 → Web 查询结果;确认服务端依据 `matchmaker_assignments` 判定范围。 + +**验收:** `tests/e2e` 在演示活栈中通过;活栈停止时套件明确失败;核心红娘跨端链路有日志和结果记录。 + +--- + +### Task 6: 演示功能冒烟与前端构建收口 + +**Files:** +- Modify: `infrastructure/scripts/smoke-test.sh`(仅补演示端口或健康接口) +- Modify: `docs/DEMO-VALIDATION-2026-10-13.md`(更新为当前命令和结果) +- Modify: `docs/PHASE26-GO-LIVE-CHECKLIST.md`(增加演示版状态,不删除正式发布要求) +- Inspect: `admin-web/src/`、`miniapp/`、`h5-demo/` + +**Interfaces:** +- Produces: 管理端、小程序静态检查、H5 演示和后端健康检查的统一演示报告。 +- Preserves: 不把 CSS 警告或构建成功误报为完整 UI 验收。 + +- [ ] **Step 1: 运行前端门禁** + + ```powershell + pnpm --dir admin-web type-check + pnpm --dir admin-web build + npm --prefix miniapp run type-check + node ui-tests/ui-document-baseline-contracts.mjs + python scripts/check-no-empty-tests.py + ``` + +- [ ] **Step 2: 做演示端人工路径** + + 用浏览器验证 `/admin/` 登录、首页、红娘工作台、咨询、服务站、内容管理;用微信开发者工具验证 Mock 登录、首页、推荐、活动、服务记录和退出登录。 + +- [ ] **Step 3: 记录构建警告** + + CSS `url(var(--...))` 警告和大 chunk 先作为 P2 记录;只有造成页面空白、资源加载失败或路由不可用时才提升为 P0。 + +- [ ] **Step 4: 输出演示验收报告** + + 报告按 PASS / PARTIAL / FAIL / NOT_VERIFIED 分类,附命令、时间、环境、失败截图或日志路径。 + +**验收:** 所有演示核心路径可操作;未通过项明确列出,不用“构建通过”替代人工路径。 + +--- + +### Task 7: 演示版冻结与回归门禁 + +**Files:** +- Create: `docs/demo/DEMO-RELEASE-REPORT-2026-10-19.md` +- Modify: `VERSION`(仅在验收通过后) +- Inspect: `git diff --check`、`git status --short`、发布 tag 和 bundle + +**Interfaces:** +- Produces: 一个干净、可追溯、可重新部署的演示版本。 +- Preserves: 不宣称正式生产发布;正式风险继续列在独立的 release blocker 区域。 + +- [ ] **Step 1: 执行全量回归** + + ```powershell + mvn -B -f backend/pom.xml test + pnpm --dir admin-web type-check + pnpm --dir admin-web build + npm --prefix miniapp run type-check + python -m pytest tests/e2e -q + docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet + git diff --check + ``` + +- [ ] **Step 2: 检查发布候选树** + + 只允许本次修复和必要测试/文档进入候选树;临时目录、密码、token、数据库 dump 不得进入。 + +- [ ] **Step 3: 生成演示 tag 和 bundle** + + ```powershell + git tag demo-v1.1.0-20261019 + git bundle create demo-v1.1.0-20261019.bundle demo-v1.1.0-20261019 + git show demo-v1.1.0-20261019 --stat + ``` + +- [ ] **Step 4: 回读验证 tag 和 bundle** + + ```powershell + git tag --verify demo-v1.1.0-20261019 + git bundle verify demo-v1.1.0-20261019.bundle + git status --short + ``` + +**验收:** 全量门禁满足演示版标准;tag、bundle、报告三者指向同一提交;工作区没有未解释的发布变更。 + +--- + +## 依赖关系 + +```text +Task 1 + ↓ +Task 2 ──┐ +Task 3 ──┼─> Task 4 ─> Task 5 ─> Task 6 ─> Task 7 + ┘ +``` + +## 演示版最终门禁 + +- **PASS:** 后端全量测试 0 failure / 0 error。 +- **PASS:** 演示 Compose 解析、启动、健康检查、停止均成功。 +- **PASS:** E2E 使用真实 PostgreSQL,核心场景通过,未使用 skip 伪绿。 +- **PASS:** Admin 类型检查和构建、小程序类型检查、UI 契约守卫通过。 +- **PASS:** Mock 微信登录只在演示 profile 生效。 +- **PASS:** 演示账号、密钥和数据库数据均与生产隔离。 +- **NOT_VERIFIED(允许保留):** 真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质。 + +## 本计划明确跳过的内容 + +本批次不接入真实微信、不申请或验证生产证书、不配置正式域名、不进行真实用户验收、不验证外部备份介质恢复,也不借此宣称正式生产可发布。 diff --git a/h5-demo/.gitignore b/h5-demo/.gitignore new file mode 100644 index 0000000..5b290a0 --- /dev/null +++ b/h5-demo/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.mimosa +*.local diff --git a/h5-demo/Dockerfile b/h5-demo/Dockerfile new file mode 100644 index 0000000..2776117 --- /dev/null +++ b/h5-demo/Dockerfile @@ -0,0 +1,13 @@ +# H5 演示端 Dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install --ignore-scripts +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-h5.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/h5-demo/index.html b/h5-demo/index.html new file mode 100644 index 0000000..c9e70d2 --- /dev/null +++ b/h5-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + 善缘丹阳 + + +

+ + + diff --git a/h5-demo/nginx-h5.conf b/h5-demo/nginx-h5.conf new file mode 100644 index 0000000..299493a --- /dev/null +++ b/h5-demo/nginx-h5.conf @@ -0,0 +1,49 @@ +# H5 演示端 Nginx 配置(单页应用) +# +# 与 admin-web/nginx-admin.conf 同构:产物挂在 /h5/ 子路径下(vite build base=/h5/)。 +# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /h5 前缀地转发到本容器, +# 因此这里必须自己认识这个前缀。 +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 根路径没有内容:H5 产物挂在 /h5/ 子路径下。显式 301 避免"打根路径拿到空白/错页"的假成功。 + location = / { + return 301 /h5/; + } + + # SPA:/h5/** 映射到产物根目录。 + # try_files 兜底写成 URI(/h5/index.html)而不是文件路径:alias + try_files 的已知坑。 + location /h5/ { + alias /usr/share/nginx/html/; + try_files $uri $uri/ /h5/index.html; + } + + # API proxy —— 演示端与管理端共用同一后端,数据天然一致(联动的关键)。 + # 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 未就绪就 [emerg] 退出。 + resolver 127.0.0.11 valid=30s ipv6=off; + set $backend_upstream "http://backend:8080"; + location /api/ { + proxy_pass $backend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 用户间即时聊天已下线:与顶层 nginx 一致返回 410,避免演示端误以为可聊。 + location /ws/ { + default_type application/json; + return 410 '{"success":false,"code":"CHAT_DISABLED","message":"平台未提供用户之间的即时聊天功能","data":null}'; + } +} diff --git a/h5-demo/package.json b/h5-demo/package.json new file mode 100644 index 0000000..3533ea6 --- /dev/null +++ b/h5-demo/package.json @@ -0,0 +1,28 @@ +{ + "name": "danyang-match-h5-demo", + "version": "1.1.0", + "private": true, + "type": "module", + "description": "善缘丹阳 H5 演示端(小程序用户端 + 红娘端网页版,用于快速演示验证)", + "scripts": { + "dev": "vite", + "build": "vite build", + "type-check": "vue-tsc --noEmit", + "preview": "vite preview", + "smoke": "node tests/smoke-h5.cjs" + }, + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "axios": "^1.6.8", + "dayjs": "^1.11.10", + "vant": "^4.9.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.3.3", + "vite": "^5.1.6", + "vue-tsc": "^2.0.6" + } +} diff --git a/h5-demo/src/App.vue b/h5-demo/src/App.vue new file mode 100644 index 0000000..07487bc --- /dev/null +++ b/h5-demo/src/App.vue @@ -0,0 +1,23 @@ + + + diff --git a/h5-demo/src/api/client.ts b/h5-demo/src/api/client.ts new file mode 100644 index 0000000..2f35dc1 --- /dev/null +++ b/h5-demo/src/api/client.ts @@ -0,0 +1,174 @@ +import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' +import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage' +import { releaseIdempotencyKey, generateRequestIdForHeader } from './idempotency' + +export class ApiError extends Error { + code: string + requestId?: string + status?: number + constructor(code: string, message: string, requestId?: string, status?: number) { + super(message) + this.code = code + this.requestId = requestId + this.status = status + } +} + +export interface RequestOptions { + idempotencyKey?: string + silent?: boolean +} + +/** 会话失效回调(由 authStore 注入)。 */ +export let onSessionExpired: (() => void) | null = null +export function setOnSessionExpired(fn: (() => void) | null): void { + onSessionExpired = fn +} + +/** 维护态回调(由 appStore 注入)。 */ +export let onMaintenance: ((body: any) => void) | null = null +export function setOnMaintenance(fn: ((body: any) => void) | null): void { + onMaintenance = fn +} + +const client = axios.create({ + baseURL: '/api/v1', + timeout: 15000, +}) + +function isAuthEndpoint(url?: string): boolean { + return typeof url === 'string' && url.indexOf('/auth/') === 0 +} + +client.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getItem(StorageKeys.accessToken) + config.headers = config.headers || {} + config.headers['X-Request-Id'] = generateRequestIdForHeader() + if (token) config.headers['Authorization'] = `Bearer ${token}` + return config +}) + +let refreshPromise: Promise | null = null + +/** 单飞刷新:并发 401 共享同一次刷新请求。refresh 只走 body,不依赖 Cookie。 */ +async function refreshToken(): Promise { + if (refreshPromise) return refreshPromise + refreshPromise = (async () => { + const refreshTokenValue = getItem(StorageKeys.refreshToken) + const userId = getItem(StorageKeys.userId) + if (!refreshTokenValue) return false + try { + const resp = await axios.post('/api/v1/auth/refresh', { userId, refreshToken: refreshTokenValue }) + const body = resp.data || {} + if (body.success && body.data?.accessToken) { + setItem(StorageKeys.accessToken, body.data.accessToken) + if (body.data.refreshToken) setItem(StorageKeys.refreshToken, body.data.refreshToken) + return true + } + return false + } catch { + return false + } finally { + refreshPromise = null + } + })() + return refreshPromise +} + +client.interceptors.response.use( + (resp) => { + const config = resp.config as InternalAxiosRequestConfig & { __idempotencyKey?: string } + const body = resp.data || {} + if (body.success) { + const data = body.data + // 先取值,再做收尾:释放幂等键失败绝不能升级为业务失败(对齐 miniapp 语义)。 + try { + releaseIdempotencyKey(config.__idempotencyKey) + } catch { + /* ignore */ + } + return data + } + throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status) + }, + async (error) => { + const resp = error?.response + if (!resp) { + throw new ApiError('NETWORK_ERROR', '网络连接失败,请检查网络后重试', undefined, undefined) + } + const body = resp.data || {} + const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean } + + if (resp.status === 503) { + if (typeof onMaintenance === 'function') onMaintenance(body) + throw new ApiError('MAINTENANCE', '平台正在维护,请稍后再试', body.requestId, 503) + } + + if (resp.status === 401) { + // 认证端点自身 401 不触发刷新(否则自我递归);由调用方处理。 + if (!isAuthEndpoint(config?.url) && !config?.__retried) { + const ok = await refreshToken() + if (ok) { + config.__retried = true + const token = getItem(StorageKeys.accessToken) + if (token) config.headers['Authorization'] = `Bearer ${token}` + return client.request(config) + } + removeItem(StorageKeys.accessToken) + removeItem(StorageKeys.refreshToken) + if (typeof onSessionExpired === 'function') onSessionExpired() + } + throw new ApiError(body.code || 'UNAUTHORIZED', body.message || '请重新登录', body.requestId, 401) + } + + throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status) + }, +) + +export interface ApiOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + data?: any + headers?: Record + idempotencyKey?: string +} + +export async function request(url: string, options: ApiOptions = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { + url, + method: options.method || 'GET', + data: options.data, + headers: options.headers, + } + config.__idempotencyKey = options.idempotencyKey + return client.request(config) as Promise +} + +export function get(url: string, params?: Record): Promise { + return client.get(url, { params }) as Promise +} + +export function post(url: string, data?: any, opts: RequestOptions = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data } + config.__idempotencyKey = opts.idempotencyKey + return client.request(config) as Promise +} + +export function put(url: string, data?: any, opts: RequestOptions = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PUT', data } + config.__idempotencyKey = opts.idempotencyKey + return client.request(config) as Promise +} + +export function patch(url: string, data?: any, opts: RequestOptions = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PATCH', data } + config.__idempotencyKey = opts.idempotencyKey + return client.request(config) as Promise +} + +export function del(url: string, data?: any, opts: RequestOptions = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'DELETE', data } + config.__idempotencyKey = opts.idempotencyKey + return client.request(config) as Promise +} + +export default client diff --git a/h5-demo/src/api/idempotency.ts b/h5-demo/src/api/idempotency.ts new file mode 100644 index 0000000..5b78cce --- /dev/null +++ b/h5-demo/src/api/idempotency.ts @@ -0,0 +1,45 @@ +/** + * 幂等键托管(复刻 miniapp/utils/api.js 语义) + * + * 语义:同一次**尚未成功**的逻辑操作复用同一把键;成功后释放,下一次点击才是新的一次。 + * 「先 resolve 再收尾」——释放失败绝不能升级为业务失败(见 client.ts)。 + * 未成功操作的键保留 10 分钟。 + */ +const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000 + +interface PendingEntry { + key: string + createdAt: number +} + +const pending = new Map() + +function generateRequestId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 8) +} + +export function idempotencyKey(scope: string, subject?: string | number | null): string { + const fingerprint = `${scope}::${subject == null ? '' : subject}` + const now = Date.now() + const cached = pending.get(fingerprint) + if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) { + return cached.key + } + const key = `${scope}:${subject == null ? '' : subject}:${generateRequestId()}` + pending.set(fingerprint, { key, createdAt: now }) + return key +} + +export function releaseIdempotencyKey(key?: string): void { + if (!key) return + for (const [fingerprint, entry] of pending.entries()) { + if (entry.key === key) { + pending.delete(fingerprint) + return + } + } +} + +export function generateRequestIdForHeader(): string { + return generateRequestId() +} diff --git a/h5-demo/src/api/operatorClient.ts b/h5-demo/src/api/operatorClient.ts new file mode 100644 index 0000000..3509e86 --- /dev/null +++ b/h5-demo/src/api/operatorClient.ts @@ -0,0 +1,89 @@ +import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' +import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage' +import { ApiError } from './client' + +/** + * 红娘/工作人员端独立请求实例。 + * + * 与用户端 client 的关键区别(见实施计划 R3): + * - Bearer 取自 h5.operatorToken(与用户 accessToken 隔离); + * - 刷新一律走请求体 `POST /auth/operator/refresh { operatorId, refreshToken }`, + * **绝不读 Cookie** —— 避免与同浏览器内的 admin-web 会话串号。 + */ +const operatorClient = axios.create({ + baseURL: '/api/v1', + timeout: 15000, +}) + +operatorClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getItem(StorageKeys.operatorToken) + config.headers = config.headers || {} + config.headers['X-Request-Id'] = Date.now().toString(36) + Math.random().toString(36).substring(2, 8) + if (token) config.headers['Authorization'] = `Bearer ${token}` + return config +}) + +let refreshing: Promise | null = null + +async function refreshOperatorToken(): Promise { + if (refreshing) return refreshing + refreshing = (async () => { + const refreshToken = getItem(StorageKeys.refreshToken + '.operator') + const operatorId = getItem(StorageKeys.operatorId) + if (!refreshToken) return false + try { + const resp = await axios.post('/api/v1/auth/operator/refresh', { operatorId, refreshToken }) + const body = resp.data || {} + if (body.success && body.data?.accessToken) { + setItem(StorageKeys.operatorToken, body.data.accessToken) + if (body.data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', body.data.refreshToken) + return true + } + return false + } catch { + return false + } finally { + refreshing = null + } + })() + return refreshing +} + +operatorClient.interceptors.response.use( + (resp) => { + const body = resp.data || {} + if (body.success) return body.data + throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status) + }, + async (error) => { + const resp = error?.response + if (!resp) throw new ApiError('NETWORK_ERROR', '网络连接失败,请重试') + const body = resp.data || {} + const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean } + if (resp.status === 401 && !config?.__retried) { + const ok = await refreshOperatorToken() + if (ok) { + config.__retried = true + const token = getItem(StorageKeys.operatorToken) + if (token) config.headers['Authorization'] = `Bearer ${token}` + return operatorClient.request(config) + } + removeItem(StorageKeys.operatorToken) + removeItem(StorageKeys.refreshToken + '.operator') + } + throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status) + }, +) + +export function opGet(url: string, params?: Record): Promise { + return operatorClient.get(url, { params }) as Promise +} +export function opPost(url: string, data?: any, opts: { idempotencyKey?: string } = {}): Promise { + const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data } + return operatorClient.request(config) as Promise +} +export function opPut(url: string, data?: any): Promise { + return operatorClient.put(url, data) as Promise +} + +export default operatorClient diff --git a/h5-demo/src/api/services/activity.ts b/h5-demo/src/api/services/activity.ts new file mode 100644 index 0000000..fe72c05 --- /dev/null +++ b/h5-demo/src/api/services/activity.ts @@ -0,0 +1,74 @@ +import { get, post, del } from '../client' +import { idempotencyKey } from '../idempotency' + +/** 活动列表/详情/报名/我的活动/反馈/签到 */ +export const activityService = { + getActivities(params: Record = {}) { + return get('/activities', params) + }, + getActivityDetail(activityId: string) { + return get(`/activities/${encodeURIComponent(activityId)}`) + }, + register(activityId: string, payload: Record = {}) { + return post(`/activities/${encodeURIComponent(activityId)}/register`, payload, { + idempotencyKey: idempotencyKey('activity-register', activityId), + }) + }, + cancelRegistration(activityId: string) { + return del(`/activities/${encodeURIComponent(activityId)}/register`) + }, + getMyActivities(params: Record = {}) { + // 后端实际路由为 GET /activities/my(原 /my-activities 无对应 handler → 404) + return get('/activities/my', params) + }, + submitFeedback(activityId: string, payload: Record) { + return post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, { + idempotencyKey: idempotencyKey('activity-feedback', activityId), + }) + }, + generateCheckinToken(activityId: string) { + return post(`/activities/${encodeURIComponent(activityId)}/checkin-token`, {}, { + idempotencyKey: idempotencyKey('checkin-token', activityId), + }) + }, + getMutualMatches(activityId: string) { + return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual`) + }, + checkMutualInterest(activityId: string, partnerId: string) { + return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual/${encodeURIComponent(partnerId)}`) + }, +} + +/** + * 活动互选二阶段同意(INV-014/015): + * 互选结果 → 双方各自 confirm → 才创建 Match/沟通入口。 + * 单向意向保密:结果接口只返回双相交集。 + */ +export const activityMutualService = { + getResults(activityId: string) { + return get(`/activities/${encodeURIComponent(activityId)}/mutual/results`) + }, + createConsent(activityId: string, partnerId: string) { + return post(`/activities/${encodeURIComponent(activityId)}/mutual/consent`, { partnerId }, { + idempotencyKey: idempotencyKey('mutual-consent', `${activityId}:${partnerId}`), + }) + }, + confirmConnection(activityId: string, partnerId: string) { + return post(`/activities/${encodeURIComponent(activityId)}/mutual/confirm`, { partnerId }, { + idempotencyKey: idempotencyKey('mutual-confirm', `${activityId}:${partnerId}`), + }) + }, + declineConnection(activityId: string, partnerId: string) { + return post(`/activities/${encodeURIComponent(activityId)}/mutual/decline`, { partnerId }, { + idempotencyKey: idempotencyKey('mutual-decline', `${activityId}:${partnerId}`), + }) + }, + getMyConsentStatus(activityId: string, partnerId: string) { + return get(`/activities/${encodeURIComponent(activityId)}/mutual/status/${encodeURIComponent(partnerId)}`) + }, + confirmInterest(activityId: string, partnerId: string) { + return post(`/activities/${encodeURIComponent(activityId)}/interests/confirm`, { partnerId }, { + idempotencyKey: idempotencyKey('interest-confirm', `${activityId}:${partnerId}`), + }) + }, +} diff --git a/h5-demo/src/api/services/auth.ts b/h5-demo/src/api/services/auth.ts new file mode 100644 index 0000000..f2534ee --- /dev/null +++ b/h5-demo/src/api/services/auth.ts @@ -0,0 +1,47 @@ +import { get, post } from '../client' +import { setItem, removeItem, StorageKeys } from '@/utils/storage' + +/** 用户端认证(对齐 miniapp/services/authService.js) */ +export const authService = { + wechatLogin(payload: { code: string }) { + return post('/auth/wechat/login', payload, { idempotencyKey: payload.code }) + }, + bindPhone(payload: Record) { + return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData }) + }, + refresh(userId?: string | null, refreshToken?: string | null) { + return post('/auth/refresh', { userId, refreshToken }) + }, + logout() { + return post('/auth/logout', {}, { idempotencyKey: 'logout' }) + }, +} + +/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */ +export const operatorService = { + async login(username: string, password: string): Promise { + const data = await post('/auth/operator/login', { username, password }) + if (data?.accessToken) { + setItem(StorageKeys.operatorToken, data.accessToken) + if (data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', data.refreshToken) + if (data.operatorId) setItem(StorageKeys.operatorId, data.operatorId) + if (data.name) setItem(StorageKeys.operatorName, data.name) + } + return data + }, + refresh(operatorId?: string | null, refreshToken?: string | null) { + return post('/auth/operator/refresh', { operatorId, refreshToken }) + }, + logout() { + return post('/auth/operator/logout', {}).catch(() => {}) + }, + clearSession() { + removeItem(StorageKeys.operatorToken) + removeItem(StorageKeys.refreshToken + '.operator') + removeItem(StorageKeys.operatorId) + removeItem(StorageKeys.operatorName) + }, + get token(): string | null { + return localStorage.getItem('h5.' + StorageKeys.operatorToken) + }, +} diff --git a/h5-demo/src/api/services/index.ts b/h5-demo/src/api/services/index.ts new file mode 100644 index 0000000..9273249 --- /dev/null +++ b/h5-demo/src/api/services/index.ts @@ -0,0 +1,14 @@ +export { authService, operatorService } from './auth' +export { userService, profileService, verificationService, localEligibilityService, notificationService, serviceHistoryService } from './me' +export { recommendationService, matchService, safetyService, appealService } from './match' +export { activityService, activityMutualService } from './activity' +export { + serviceAppointmentService, + serviceStationService, + serviceProgramService, + contentService, + counselingService, +} from './service' +export { matchmakerService, operatorMatchmakerService, referralService, contactConsentService } from './matchmaker' +export { homeService, capabilityService, appConfigService } from './misc' +export { ApiError } from '../client' diff --git a/h5-demo/src/api/services/match.ts b/h5-demo/src/api/services/match.ts new file mode 100644 index 0000000..13d9588 --- /dev/null +++ b/h5-demo/src/api/services/match.ts @@ -0,0 +1,103 @@ +import { get, post, del } from '../client' +import { idempotencyKey } from '../idempotency' + +/** 推荐与搜索 */ +export const recommendationService = { + getRecommendations(params: Record = {}) { + return get('/recommendations', params) + }, + getRecommendationDetail(userId: string) { + return get(`/recommendations/${encodeURIComponent(userId)}`) + }, + discover(params: Record = {}) { + return get('/discover', params) + }, + getUserDetail(userId: string) { + return get(`/discover/users/${encodeURIComponent(userId)}`) + }, +} + +/** 关系与匹配(Like/Pass/Block/缘分三榜/Unmatch/成功牵手双确认) */ +export const matchService = { + like(userId: string) { + return post('/likes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('like', userId) }) + }, + unlike(userId: string) { + return del(`/likes/${encodeURIComponent(userId)}`) + }, + pass(userId: string) { + return post('/passes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('pass', userId) }) + }, + block(userId: string, payload: { reason?: string } = {}) { + return post('/blocks', { targetUserId: userId, reason: payload.reason }, { idempotencyKey: idempotencyKey('block', userId) }) + }, + unblock(userId: string) { + return del(`/blocks/${encodeURIComponent(userId)}`) + }, + getMutualList(params: Record = {}) { + return get('/relations/fate/mutual', params) + }, + getLikedByMe(params: Record = {}) { + return get('/relations/fate/liked', params) + }, + getLikers(params: Record = {}) { + return get('/relations/fate/likers', params) + }, + getBlockedList(params: Record = {}) { + return get('/blocks', params) + }, + getMatches(params: Record = {}) { + return get('/matches', params) + }, + unmatch(matchId: string) { + return post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, { idempotencyKey: idempotencyKey('unmatch', matchId) }) + }, + requestSuccess(matchId: string) { + return post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, { idempotencyKey: idempotencyKey('success-req', matchId) }) + }, + confirmSuccess(matchId: string) { + return post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, { idempotencyKey: idempotencyKey('success-confirm', matchId) }) + }, + declineSuccess(matchId: string) { + return post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, { idempotencyKey: idempotencyKey('success-decline', matchId) }) + }, +} + +/** 举报(不自动 Block) */ +export const safetyService = { + submitReport(payload: { targetUserId: string; reportType: string; description: string; evidenceFileIds?: string[] }) { + // 后端真实路由 POST /risk/reports,DTO 字段为 reportedUserId / reasonType(原 /reports + targetUserId/reportType 会导致 404) + return post( + '/risk/reports', + { + reportedUserId: payload.targetUserId, + reasonType: payload.reportType, + description: payload.description, + evidenceFileIds: payload.evidenceFileIds, + }, + { idempotencyKey: idempotencyKey('report', `${payload.targetUserId}:${payload.reportType}`) }, + ) + }, + getMyReports(params: Record = {}) { + return get('/me/reports', params) + }, + /** 举报详情(GET /risk/reports/{reportId},仅举报人本人可见):返回 { report, evidenceFileKeys, evidenceUrls } */ + getReportDetail(reportId: string) { + return get(`/risk/reports/${encodeURIComponent(reportId)}`) + }, + /** 举报证据直传凭证(POST /risk/reports/evidence/upload-token):返回 { fileKey, uploadUrl, expiresIn } */ + requestEvidenceUploadToken() { + return post('/risk/reports/evidence/upload-token', {}) + }, +} + +/** 申诉 */ +export const appealService = { + submitAppeal(payload: Record) { + const actionKey = payload.relatedActionId || 'no_action' + return post('/appeals', payload, { idempotencyKey: idempotencyKey('appeal', `${payload.appealType}:${actionKey}`) }) + }, + getMyAppeals(params: Record = {}) { + return get('/me/appeals', params) + }, +} diff --git a/h5-demo/src/api/services/matchmaker.ts b/h5-demo/src/api/services/matchmaker.ts new file mode 100644 index 0000000..6b6ee52 --- /dev/null +++ b/h5-demo/src/api/services/matchmaker.ts @@ -0,0 +1,127 @@ +import { get, post, put } from '../client' +import { opGet, opPost } from '../operatorClient' +import { idempotencyKey } from '../idempotency' + +/** 用户端红娘服务(申请/我的服务/详情/推荐) */ +export const matchmakerService = { + applyService(payload: Record) { + return post('/matchmaker/services', payload, { idempotencyKey: idempotencyKey('matchmaker-apply') }) + }, + getMyServices(params: Record = {}) { + return get('/matchmaker/services', params) + }, + async getServiceDetail(serviceId: string) { + // 用户端后端未注册 GET /matchmaker/services/{id}(仅列表接口)—— + // 由列表按 id 派生,避免对不存在路由发起 404 请求。 + const result: any = await get('/matchmaker/services', { limit: 50 }) + const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : []) + return list.find((s: any) => (s.serviceId || s.id) === serviceId) ?? null + }, + confirmService(serviceId: string) { + return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/confirm`, {}, { + idempotencyKey: idempotencyKey('matchmaker-confirm', serviceId), + }) + }, + withdrawService(serviceId: string) { + return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/withdraw`, {}, { + idempotencyKey: idempotencyKey('matchmaker-withdraw', serviceId), + }) + }, + getRecommendations(params: Record = {}) { + return get('/matchmaker/recommendations', params) + }, + markRecommendationViewed(recommendationId: string) { + return put(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/viewed`) + }, + acceptRecommendation(recommendationId: string) { + return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/accept`, {}, { + idempotencyKey: idempotencyKey('mm-rec-accept', recommendationId), + }) + }, + passRecommendation(recommendationId: string) { + return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/pass`, {}, { + idempotencyKey: idempotencyKey('mm-rec-pass', recommendationId), + }) + }, +} + +/** + * 红娘工作台(Operator 会话)。 + * 不变量:红娘只做推荐/跟进,**不可替用户 Like/Pass**(AGENTS 第 5 条)。 + */ +export const operatorMatchmakerService = { + dashboard() { + return opGet('/operator/matchmaker/dashboard') + }, + myAssignments() { + return opGet('/operator/matchmaker/assignments/my') + }, + recommendations() { + return opGet('/operator/matchmaker/recommendations') + }, + pending() { + return opGet('/operator/matchmaker/pending') + }, + managementServices() { + return opGet('/operator/matchmaker/management/services') + }, + serviceDetail(id: string) { + return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`) + }, + recommend(serviceId: string, payload: Record) { + return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload) + }, + followup(serviceId: string, payload: Record) { + return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/followup`, payload) + }, + complete(serviceId: string) { + return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {}) + }, + myContactAuthorizations() { + return opGet('/operator/matchmaker/contact-authorizations/my') + }, + requestContactAuthorization(payload: Record) { + return opPost('/operator/matchmaker/contact-authorizations', payload) + }, + revokeContactAuthorization(id: string, reason: string) { + return opPost(`/operator/matchmaker/contact-authorizations/${encodeURIComponent(id)}/revoke`, { reason }) + }, + referralHistory() { + return opGet('/referrals/operator/history') + }, + referToServiceStation(payload: Record) { + return opPost('/referrals/service-station', payload) + }, + referToActivity(payload: Record) { + return opPost('/referrals/activity', payload) + }, +} + +/** 红娘转介(用户侧确认) */ +export const referralService = { + getMyReferralHistory() { + return get('/referrals/my-history') + }, + confirmReferral(targetId: string, targetType: 'service_station' | 'activity') { + return post(`/referrals/${encodeURIComponent(targetId)}/confirm`, { targetType }, { + idempotencyKey: idempotencyKey('referral-confirm', `${targetId}:${targetType}`), + }) + }, +} + +/** 受控联系授权(用户单独明示同意,INV) */ +export const contactConsentService = { + my() { + return get('/contact-authorizations/my') + }, + consent(id: string) { + return post(`/contact-authorizations/${encodeURIComponent(id)}/consent`, {}, { + idempotencyKey: idempotencyKey('contact-consent', id), + }) + }, + decline(id: string) { + return post(`/contact-authorizations/${encodeURIComponent(id)}/decline`, {}, { + idempotencyKey: idempotencyKey('contact-decline', id), + }) + }, +} diff --git a/h5-demo/src/api/services/me.ts b/h5-demo/src/api/services/me.ts new file mode 100644 index 0000000..de81073 --- /dev/null +++ b/h5-demo/src/api/services/me.ts @@ -0,0 +1,121 @@ +import { get, post, put, patch, del } from '../client' +import { idempotencyKey } from '../idempotency' + +/** 本人全局状态(GET /me/status) */ +export const userService = { + getStatus() { + return get('/me/status') + }, +} + +/** 本人资料 / 择偶条件 / 照片 / 隐私 / 审核 / 关系状态(对齐 miniapp/services/profileService.js) */ +export const profileService = { + getProfile() { + return get('/me/profile') + }, + updateProfile(payload: Record) { + return put('/me/profile', payload, { idempotencyKey: idempotencyKey('profile-put', payload?.userId || 'me') }) + }, + updateBasicProfile(payload: Record) { + return patch('/me/profile/basic', payload, { idempotencyKey: idempotencyKey('profile-basic', 'me') }) + }, + getCompletion() { + return get('/me/profile/completion') + }, + getPreferences() { + return get('/me/preferences') + }, + updatePreferences(payload: Record) { + return put('/me/preferences', payload, { idempotencyKey: idempotencyKey('preferences-put') }) + }, + getPhotos() { + return get('/me/photos') + }, + deletePhoto(photoId: string) { + return del(`/me/photos/${encodeURIComponent(photoId)}`) + }, + setMainPhoto(photoId: string) { + return put(`/me/photos/${encodeURIComponent(photoId)}/main`, {}, { idempotencyKey: idempotencyKey('photo-main', photoId) }) + }, + requestUploadToken(payload: { fileName: string; contentType?: string }) { + return post('/me/photos/upload-token', payload, { idempotencyKey: payload.fileName }) + }, + confirmPhoto(payload: { fileKey: string; photoType: string }) { + return post('/me/photos/confirm', payload, { idempotencyKey: idempotencyKey('photo-confirm', payload.fileKey) }) + }, + getPrivacySettings() { + return get('/me/privacy-settings') + }, + updatePrivacySettings(payload: Record) { + return put('/me/privacy-settings', payload, { idempotencyKey: idempotencyKey('privacy-put') }) + }, + getReviewStatus() { + return get('/me/profile/review-status') + }, + submitReview() { + return post('/me/profile/submit-review', {}, { idempotencyKey: idempotencyKey('review-submit') }) + }, + getRelationshipStatus() { + return get('/me/relationship-status') + }, + pauseSearching() { + return patch('/me/relationship-status', { status: 'paused' }, { idempotencyKey: idempotencyKey('rel-pause') }) + }, + resumeSearching() { + return patch('/me/relationship-status', { status: 'searching' }, { idempotencyKey: idempotencyKey('rel-resume') }) + }, + markGettingToKnow() { + return patch('/me/relationship-status', { status: 'getting_to_know' }, { idempotencyKey: idempotencyKey('rel-gtk') }) + }, +} + +/** 实名认证 */ +export const verificationService = { + submit(payload: { realName: string; idCardNumber: string; faceImageUrl?: string }) { + const tail = (payload.idCardNumber || '').slice(-4).toUpperCase() + return post('/verification/submit', payload, { idempotencyKey: idempotencyKey('verification-submit', tail) }) + }, + getStatus() { + return get('/verification/status') + }, +} + +/** 本地资格核验 */ +export const localEligibilityService = { + getStatus() { + return get('/local-eligibility') + }, + declare(payload: Record) { + return post('/local-eligibility/declaration', payload) + }, + uploadEvidence(payload: Record) { + return post('/local-eligibility/evidences', payload) + }, + submit() { + return post('/local-eligibility/submit', {}, { idempotencyKey: idempotencyKey('local-eligibility-submit') }) + }, +} + +/** 通知(用户端不提供聊天,仅通知) */ +export const notificationService = { + getNotifications(params: Record = {}) { + return get('/notifications', params) + }, + markNotificationRead(id: string) { + return put(`/notifications/${encodeURIComponent(id)}/read`) + }, + markAllNotificationsRead() { + return put('/notifications/read-all') + }, + getNotificationsUnreadCount() { + // 后端实际路由为 GET /notifications/unread(返回 {count}) + return get('/notifications/unread') + }, +} + +/** 公益服务记录(我的服务时间线) */ +export const serviceHistoryService = { + getMyServiceHistory() { + return get('/me/service-history') + }, +} diff --git a/h5-demo/src/api/services/misc.ts b/h5-demo/src/api/services/misc.ts new file mode 100644 index 0000000..4014ee1 --- /dev/null +++ b/h5-demo/src/api/services/misc.ts @@ -0,0 +1,27 @@ +import { get } from '../client' + +/** 首页聚合(GET /home)—— 段序与 8 入口顺序由后端返回决定(AGENTS 第 14/15 条)。 */ +export const homeService = { + getHome() { + return get('/home') + }, +} + +/** 能力开关(AGENTS 第 26 条:入口可见性只读服务端) */ +export const capabilityService = { + getCapabilities() { + return get('/capabilities') + }, +} + +/** App 运行时配置(数量/阈值等,未确认参数一律配置化) */ +export const appConfigService = { + async fetchAppConfig(fallback: Record = {}) { + try { + const overrides = await get('/app/config') + return { ...fallback, ...(overrides || {}) } + } catch { + return fallback + } + }, +} diff --git a/h5-demo/src/api/services/service.ts b/h5-demo/src/api/services/service.ts new file mode 100644 index 0000000..f05cca4 --- /dev/null +++ b/h5-demo/src/api/services/service.ts @@ -0,0 +1,104 @@ +import { get, post } from '../client' +import { idempotencyKey } from '../idempotency' + +/** 服务预约 */ +export const serviceAppointmentService = { + async getCurrentServiceLocation() { + try { + const home: any = await get('/home') + const location = home?.serviceLocation + if (!location?.id) return null + return { id: location.id, name: location.name || '已选服务站点' } + } catch { + return null + } + }, + getMyAppointments() { + return get('/service-appointments') + }, + getAppointmentDetail(id: string) { + return get(`/service-appointments/${encodeURIComponent(id)}`) + }, + createAppointment(payload: Record) { + return post('/service-appointments', { + serviceLocationId: payload.serviceLocationId, + serviceType: payload.serviceType, + preferredDate: payload.preferredDate, + preferredTimeSlot: payload.preferredTimeSlot, + userNote: payload.userNote, + }) + }, + cancelAppointment(id: string, reason?: string) { + return post(`/service-appointments/${encodeURIComponent(id)}/cancel`, { reason: reason || '' }) + }, +} + +/** 善缘服务站 */ +export const serviceStationService = { + async getStation() { + const home: any = await get('/home') + return home?.serviceLocation || null + }, + async getVisibleAnnouncements(locationId: string) { + if (!locationId) return [] + try { + const list = await get(`/service-locations/${encodeURIComponent(locationId)}/announcements`) + return Array.isArray(list) ? list : [] + } catch { + return [] + } + }, +} + +/** 专项公益服务项目 */ +export const serviceProgramService = { + getPrograms(params: Record = {}) { + return get('/service-programs', params) + }, + getProgramDetail(id: string) { + return get(`/service-programs/${encodeURIComponent(id)}`) + }, + applyToProgram(id: string) { + return post(`/service-programs/${encodeURIComponent(id)}/apply`, {}, { idempotencyKey: idempotencyKey('program-apply', id) }) + }, + getMyApplications() { + return get('/service-programs/my') + }, +} + +/** 公益内容(婚恋课堂 / 反诈) */ +export const contentService = { + getCategories() { + return get('/content/categories') + }, + getArticles(params: Record = {}) { + return get('/content/articles', params) + }, + getArticleDetail(id: string) { + return get(`/content/articles/${encodeURIComponent(id)}`) + }, +} + +/** 婚恋咨询 / 婚前辅导 */ +export const counselingService = { + intro() { + return get('/counseling/services/intro') + }, + apply(payload: Record) { + return post('/counseling/services', payload, { idempotencyKey: idempotencyKey('counseling-apply', payload.serviceType) }) + }, + myServices(limit = 20) { + return get('/counseling/services', { limit }) + }, + detail(id: string) { + return get(`/counseling/services/${encodeURIComponent(id)}`) + }, + cancel(id: string, reason?: string) { + return post(`/counseling/services/${encodeURIComponent(id)}/cancel`, { reason: reason || '' }) + }, + confirmCompanion(id: string) { + return post(`/counseling/services/${encodeURIComponent(id)}/companion-confirm`, {}, { + idempotencyKey: idempotencyKey('companion-confirm', id), + }) + }, +} diff --git a/h5-demo/src/components/UiAvatar.vue b/h5-demo/src/components/UiAvatar.vue new file mode 100644 index 0000000..841d0b7 --- /dev/null +++ b/h5-demo/src/components/UiAvatar.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/h5-demo/src/components/UiEmpty.vue b/h5-demo/src/components/UiEmpty.vue new file mode 100644 index 0000000..0488bfb --- /dev/null +++ b/h5-demo/src/components/UiEmpty.vue @@ -0,0 +1,12 @@ + + + diff --git a/h5-demo/src/components/UiErrorState.vue b/h5-demo/src/components/UiErrorState.vue new file mode 100644 index 0000000..3ff9a61 --- /dev/null +++ b/h5-demo/src/components/UiErrorState.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/h5-demo/src/components/UiLoading.vue b/h5-demo/src/components/UiLoading.vue new file mode 100644 index 0000000..4d56b05 --- /dev/null +++ b/h5-demo/src/components/UiLoading.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/h5-demo/src/components/UiSectionHeader.vue b/h5-demo/src/components/UiSectionHeader.vue new file mode 100644 index 0000000..886aa49 --- /dev/null +++ b/h5-demo/src/components/UiSectionHeader.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/h5-demo/src/components/UiTag.vue b/h5-demo/src/components/UiTag.vue new file mode 100644 index 0000000..8cca4b7 --- /dev/null +++ b/h5-demo/src/components/UiTag.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/h5-demo/src/components/UiUserCard.vue b/h5-demo/src/components/UiUserCard.vue new file mode 100644 index 0000000..3d95b78 --- /dev/null +++ b/h5-demo/src/components/UiUserCard.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/h5-demo/src/layouts/OperatorLayout.vue b/h5-demo/src/layouts/OperatorLayout.vue new file mode 100644 index 0000000..b92ceae --- /dev/null +++ b/h5-demo/src/layouts/OperatorLayout.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/h5-demo/src/layouts/PageLayout.vue b/h5-demo/src/layouts/PageLayout.vue new file mode 100644 index 0000000..5f6a2d3 --- /dev/null +++ b/h5-demo/src/layouts/PageLayout.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/h5-demo/src/layouts/TabLayout.vue b/h5-demo/src/layouts/TabLayout.vue new file mode 100644 index 0000000..94aa10a --- /dev/null +++ b/h5-demo/src/layouts/TabLayout.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/h5-demo/src/main.ts b/h5-demo/src/main.ts new file mode 100644 index 0000000..4e7b8f4 --- /dev/null +++ b/h5-demo/src/main.ts @@ -0,0 +1,35 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import Vant from 'vant' +import 'vant/lib/index.css' +import './theme/index.css' + +import App from './App.vue' +import router from './router' +import { setOnSessionExpired, setOnMaintenance } from './api/client' +import { useAuthStore } from './stores/auth' +import { useUserStore } from './stores/user' +import { useAppStore } from './stores/app' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(Vant) +app.use(router) + +// 会话失效:清空内存与本地登录态后回登录页(由 api client 的 401 分支触发)。 +setOnSessionExpired(() => { + const authStore = useAuthStore(pinia) + const userStore = useUserStore(pinia) + authStore.clearSession() + userStore.userState = null + router.replace('/login') +}) + +// 维护态:统一提示。 +setOnMaintenance((body) => { + useAppStore(pinia).setMaintenance(body) +}) + +app.mount('#app') diff --git a/h5-demo/src/pages/NotFoundPage.vue b/h5-demo/src/pages/NotFoundPage.vue new file mode 100644 index 0000000..396f72c --- /dev/null +++ b/h5-demo/src/pages/NotFoundPage.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/h5-demo/src/pages/account/AccountLogoutPage.vue b/h5-demo/src/pages/account/AccountLogoutPage.vue new file mode 100644 index 0000000..573fc87 --- /dev/null +++ b/h5-demo/src/pages/account/AccountLogoutPage.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/h5-demo/src/pages/account/AccountStatusDetailPage.vue b/h5-demo/src/pages/account/AccountStatusDetailPage.vue new file mode 100644 index 0000000..7e7f4d9 --- /dev/null +++ b/h5-demo/src/pages/account/AccountStatusDetailPage.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/h5-demo/src/pages/account/AccountStatusPage.vue b/h5-demo/src/pages/account/AccountStatusPage.vue new file mode 100644 index 0000000..1fe8012 --- /dev/null +++ b/h5-demo/src/pages/account/AccountStatusPage.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityCheckinPage.vue b/h5-demo/src/pages/activity/ActivityCheckinPage.vue new file mode 100644 index 0000000..8f0cc5f --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityCheckinPage.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityDetailPage.vue b/h5-demo/src/pages/activity/ActivityDetailPage.vue new file mode 100644 index 0000000..cfeca07 --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityDetailPage.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityFeedbackPage.vue b/h5-demo/src/pages/activity/ActivityFeedbackPage.vue new file mode 100644 index 0000000..48e8983 --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityFeedbackPage.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityMatchesPage.vue b/h5-demo/src/pages/activity/ActivityMatchesPage.vue new file mode 100644 index 0000000..5bb7e6c --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityMatchesPage.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityMutualResultPage.vue b/h5-demo/src/pages/activity/ActivityMutualResultPage.vue new file mode 100644 index 0000000..3700095 --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityMutualResultPage.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityMutualWaitingPage.vue b/h5-demo/src/pages/activity/ActivityMutualWaitingPage.vue new file mode 100644 index 0000000..01ce472 --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityMutualWaitingPage.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityPage.vue b/h5-demo/src/pages/activity/ActivityPage.vue new file mode 100644 index 0000000..cf001b5 --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityPage.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/h5-demo/src/pages/activity/ActivityRegisterPage.vue b/h5-demo/src/pages/activity/ActivityRegisterPage.vue new file mode 100644 index 0000000..fb74e0a --- /dev/null +++ b/h5-demo/src/pages/activity/ActivityRegisterPage.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/h5-demo/src/pages/activity/MyActivitiesPage.vue b/h5-demo/src/pages/activity/MyActivitiesPage.vue new file mode 100644 index 0000000..01e8d64 --- /dev/null +++ b/h5-demo/src/pages/activity/MyActivitiesPage.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/h5-demo/src/pages/auth/BindPhonePage.vue b/h5-demo/src/pages/auth/BindPhonePage.vue new file mode 100644 index 0000000..246f0a4 --- /dev/null +++ b/h5-demo/src/pages/auth/BindPhonePage.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/h5-demo/src/pages/auth/ContactConsentPage.vue b/h5-demo/src/pages/auth/ContactConsentPage.vue new file mode 100644 index 0000000..6755199 --- /dev/null +++ b/h5-demo/src/pages/auth/ContactConsentPage.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/h5-demo/src/pages/auth/LoginPage.vue b/h5-demo/src/pages/auth/LoginPage.vue new file mode 100644 index 0000000..e7aa535 --- /dev/null +++ b/h5-demo/src/pages/auth/LoginPage.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/h5-demo/src/pages/auth/VerificationPage.vue b/h5-demo/src/pages/auth/VerificationPage.vue new file mode 100644 index 0000000..ed6bd87 --- /dev/null +++ b/h5-demo/src/pages/auth/VerificationPage.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/h5-demo/src/pages/content/AntiFraudPage.vue b/h5-demo/src/pages/content/AntiFraudPage.vue new file mode 100644 index 0000000..1949283 --- /dev/null +++ b/h5-demo/src/pages/content/AntiFraudPage.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/h5-demo/src/pages/content/ContentDetailPage.vue b/h5-demo/src/pages/content/ContentDetailPage.vue new file mode 100644 index 0000000..cdbd6f1 --- /dev/null +++ b/h5-demo/src/pages/content/ContentDetailPage.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/h5-demo/src/pages/content/ContentPage.vue b/h5-demo/src/pages/content/ContentPage.vue new file mode 100644 index 0000000..435f9c0 --- /dev/null +++ b/h5-demo/src/pages/content/ContentPage.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/h5-demo/src/pages/counseling/CounselingPage.vue b/h5-demo/src/pages/counseling/CounselingPage.vue new file mode 100644 index 0000000..5ebfd64 --- /dev/null +++ b/h5-demo/src/pages/counseling/CounselingPage.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/h5-demo/src/pages/counseling/CounselingRequestPage.vue b/h5-demo/src/pages/counseling/CounselingRequestPage.vue new file mode 100644 index 0000000..f3022ee --- /dev/null +++ b/h5-demo/src/pages/counseling/CounselingRequestPage.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/h5-demo/src/pages/counseling/MyCounselingPage.vue b/h5-demo/src/pages/counseling/MyCounselingPage.vue new file mode 100644 index 0000000..9753a49 --- /dev/null +++ b/h5-demo/src/pages/counseling/MyCounselingPage.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/h5-demo/src/pages/discover/DiscoverPage.vue b/h5-demo/src/pages/discover/DiscoverPage.vue new file mode 100644 index 0000000..6251852 --- /dev/null +++ b/h5-demo/src/pages/discover/DiscoverPage.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/h5-demo/src/pages/home/HomePage.vue b/h5-demo/src/pages/home/HomePage.vue new file mode 100644 index 0000000..139ffbd --- /dev/null +++ b/h5-demo/src/pages/home/HomePage.vue @@ -0,0 +1,878 @@ + + + + + diff --git a/h5-demo/src/pages/local/LocalEligibilityPage.vue b/h5-demo/src/pages/local/LocalEligibilityPage.vue new file mode 100644 index 0000000..27599b3 --- /dev/null +++ b/h5-demo/src/pages/local/LocalEligibilityPage.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/h5-demo/src/pages/local/LocalEligibilitySupplementPage.vue b/h5-demo/src/pages/local/LocalEligibilitySupplementPage.vue new file mode 100644 index 0000000..513f62c --- /dev/null +++ b/h5-demo/src/pages/local/LocalEligibilitySupplementPage.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/h5-demo/src/pages/local/MatchmakerAuthorizationPage.vue b/h5-demo/src/pages/local/MatchmakerAuthorizationPage.vue new file mode 100644 index 0000000..e5071b8 --- /dev/null +++ b/h5-demo/src/pages/local/MatchmakerAuthorizationPage.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/h5-demo/src/pages/match/FateCenterPage.vue b/h5-demo/src/pages/match/FateCenterPage.vue new file mode 100644 index 0000000..3a006df --- /dev/null +++ b/h5-demo/src/pages/match/FateCenterPage.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/h5-demo/src/pages/matchmaker/MatchmakerApplyPage.vue b/h5-demo/src/pages/matchmaker/MatchmakerApplyPage.vue new file mode 100644 index 0000000..6b25b4b --- /dev/null +++ b/h5-demo/src/pages/matchmaker/MatchmakerApplyPage.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/h5-demo/src/pages/matchmaker/MatchmakerPage.vue b/h5-demo/src/pages/matchmaker/MatchmakerPage.vue new file mode 100644 index 0000000..172e56c --- /dev/null +++ b/h5-demo/src/pages/matchmaker/MatchmakerPage.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/h5-demo/src/pages/matchmaker/MatchmakerProgressPage.vue b/h5-demo/src/pages/matchmaker/MatchmakerProgressPage.vue new file mode 100644 index 0000000..b9d5628 --- /dev/null +++ b/h5-demo/src/pages/matchmaker/MatchmakerProgressPage.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/h5-demo/src/pages/matchmaker/MatchmakerRecommendationsPage.vue b/h5-demo/src/pages/matchmaker/MatchmakerRecommendationsPage.vue new file mode 100644 index 0000000..b905932 --- /dev/null +++ b/h5-demo/src/pages/matchmaker/MatchmakerRecommendationsPage.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/h5-demo/src/pages/matchmaker/ReferralConfirmationPage.vue b/h5-demo/src/pages/matchmaker/ReferralConfirmationPage.vue new file mode 100644 index 0000000..4dca8a0 --- /dev/null +++ b/h5-demo/src/pages/matchmaker/ReferralConfirmationPage.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/h5-demo/src/pages/mine/MinePage.vue b/h5-demo/src/pages/mine/MinePage.vue new file mode 100644 index 0000000..14a7b0d --- /dev/null +++ b/h5-demo/src/pages/mine/MinePage.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/h5-demo/src/pages/onboarding/ProfileBasicPage.vue b/h5-demo/src/pages/onboarding/ProfileBasicPage.vue new file mode 100644 index 0000000..32ad866 --- /dev/null +++ b/h5-demo/src/pages/onboarding/ProfileBasicPage.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/h5-demo/src/pages/onboarding/ProfileConfirmPage.vue b/h5-demo/src/pages/onboarding/ProfileConfirmPage.vue new file mode 100644 index 0000000..63c9de2 --- /dev/null +++ b/h5-demo/src/pages/onboarding/ProfileConfirmPage.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/h5-demo/src/pages/onboarding/ProfileIntentPage.vue b/h5-demo/src/pages/onboarding/ProfileIntentPage.vue new file mode 100644 index 0000000..b277c9a --- /dev/null +++ b/h5-demo/src/pages/onboarding/ProfileIntentPage.vue @@ -0,0 +1,213 @@ +