From 39a18a50ee45fba77665d5435116b75c75e7a544 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Wed, 21 Oct 2026 23:41:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=8F=8C=E7=AB=AF?= =?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=B3=A8=E5=86=8C=E4=B8=8E=E7=BA=A2=E5=A8=98?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 9 + .../controller/OperatorAuthController.java | 8 + .../platform/auth/service/AuthService.java | 40 +++ .../DemoOperatorRegistrationService.java | 49 +++ .../MatchmakerOperatorController.java | 7 + .../service/MatchmakerAssignmentService.java | 5 + .../service/MatchmakerServiceService.java | 18 + .../service/NotificationService.java | 26 ++ .../shared/config/SecurityConfig.java | 2 +- .../platform/shared/filter/JwtAuthFilter.java | 3 +- .../DemoOperatorRegistrationServiceTest.java | 58 ++++ .../auth/DemoUserRegistrationFieldsTest.java | 70 ++++ .../MatchmakerDataScope005Test.java | 22 ++ .../notification/NotificationServiceTest.java | 58 +++- h5-demo/package.json | 1 + h5-demo/src/api/services/auth.ts | 6 + h5-demo/src/api/services/matchmaker.ts | 9 + h5-demo/src/components/UiUserCard.vue | 38 ++- h5-demo/src/layouts/TabLayout.vue | 6 +- h5-demo/src/pages/auth/DemoRegisterPage.vue | 57 ++++ h5-demo/src/pages/auth/LoginPage.vue | 1 + h5-demo/src/pages/content/AntiFraudPage.vue | 2 +- h5-demo/src/pages/discover/DiscoverPage.vue | 319 +++++++++++------- h5-demo/src/pages/home/HomePage.vue | 2 +- .../local/MatchmakerAuthorizationPage.vue | 2 +- .../pages/matchmaker/MatchmakerApplyPage.vue | 2 +- .../src/pages/matchmaker/MatchmakerPage.vue | 2 +- .../src/pages/operator/OperatorLoginPage.vue | 3 + .../pages/operator/OperatorRegisterPage.vue | 54 +++ .../operator/OperatorServiceDetailPage.vue | 27 +- .../pages/operator/OperatorWorkbenchPage.vue | 120 +++++-- .../src/pages/profile/ProfileDetailPage.vue | 2 +- .../src/pages/settings/NotificationsPage.vue | 38 ++- .../pages/settings/RelationshipStatusPage.vue | 6 +- h5-demo/src/pages/settings/SettingsPage.vue | 2 +- h5-demo/src/router/routes.ts | 5 +- h5-demo/src/stores/auth.ts | 8 +- h5-demo/tests/demo-flow-contract.cjs | 19 ++ h5-demo/tests/demo-register-contract.cjs | 19 ++ h5-demo/tests/ui-contract.cjs | 27 +- miniapp/app.json | 2 + miniapp/constants/routeNames.js | 2 + miniapp/pages.json | 2 + miniapp/pages/demo-register/demo-register.js | 44 +++ .../pages/demo-register/demo-register.json | 1 + .../pages/demo-register/demo-register.wxml | 11 + .../pages/demo-register/demo-register.wxss | 8 + miniapp/pages/login/login.ts | 6 + miniapp/pages/login/login.wxml | 1 + .../matchmaker-login/matchmaker-login.js | 9 +- .../matchmaker-login/matchmaker-login.wxml | 1 + .../matchmaker-register.js | 31 ++ .../matchmaker-register.json | 1 + .../matchmaker-register.wxml | 10 + .../matchmaker-register.wxss | 8 + .../matchmaker-service-detail.js | 26 +- .../matchmaker-service-detail.wxml | 11 +- .../matchmaker-workbench.js | 90 ++++- .../matchmaker-workbench.wxml | 31 +- .../matchmaker-workbench.wxss | 3 + miniapp/pages/notifications/notifications.js | 28 +- miniapp/services/authService.js | 20 ++ miniapp/stores/authStore.js | 17 + 63 files changed, 1298 insertions(+), 217 deletions(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/auth/service/DemoOperatorRegistrationService.java create mode 100644 backend/src/test/java/com/danyang/match/platform/auth/DemoOperatorRegistrationServiceTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/auth/DemoUserRegistrationFieldsTest.java create mode 100644 h5-demo/src/pages/auth/DemoRegisterPage.vue create mode 100644 h5-demo/src/pages/operator/OperatorRegisterPage.vue create mode 100644 h5-demo/tests/demo-flow-contract.cjs create mode 100644 h5-demo/tests/demo-register-contract.cjs create mode 100644 miniapp/pages/demo-register/demo-register.js create mode 100644 miniapp/pages/demo-register/demo-register.json create mode 100644 miniapp/pages/demo-register/demo-register.wxml create mode 100644 miniapp/pages/demo-register/demo-register.wxss create mode 100644 miniapp/pages/matchmaker-register/matchmaker-register.js create mode 100644 miniapp/pages/matchmaker-register/matchmaker-register.json create mode 100644 miniapp/pages/matchmaker-register/matchmaker-register.wxml create mode 100644 miniapp/pages/matchmaker-register/matchmaker-register.wxss diff --git a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java index 1248ab9..800df41 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java @@ -42,6 +42,15 @@ public class AuthController { return ApiResponse.ok(authService.demoLogin(code)); } + @PostMapping("/demo/register") + public ApiResponse> demoRegister(@RequestBody Map body) { + String code = body.get("code"); + if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}")) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号编号无效"); + } + return ApiResponse.ok(authService.demoRegister(code, body.get("displayName"), body.get("phone"))); + } + /** * 绑定手机号 * POST /api/v1/auth/phone/bind diff --git a/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java b/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java index 7115d4c..c34c8c0 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java @@ -2,6 +2,7 @@ package com.danyang.match.platform.auth.controller; import com.danyang.match.platform.auth.cookie.OperatorCookieWriter; import com.danyang.match.platform.auth.service.OperatorAuthService; +import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; @@ -23,8 +24,15 @@ import java.util.Map; public class OperatorAuthController { private final OperatorAuthService operatorAuthService; + private final DemoOperatorRegistrationService demoOperatorRegistrationService; private final OperatorCookieWriter cookieWriter; + @PostMapping("/demo/register") + public ApiResponse> demoRegister(@RequestBody Map body) { + return ApiResponse.ok(demoOperatorRegistrationService.register( + body.get("name"), body.get("username"), body.get("password"))); + } + @PostMapping("/login") public ApiResponse> login(@RequestBody Map body, HttpServletRequest request, HttpServletResponse response) { diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java index 31b0be3..055705c 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java @@ -80,6 +80,46 @@ public class AuthService { return loginWithSession(session); } + @Transactional + public Map demoRegister(String code, String displayName, String phone) { + if (!demoLoginEnabled) throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用"); + if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}") + || displayName == null || displayName.isBlank() || displayName.length() > 40) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号或昵称格式无效"); + } + if (phone != null && !phone.isBlank()) { + if (!phone.matches("1[3-9]\\d{9}")) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示手机号格式无效"); + } + if (userMapper.findByPhone(phone).isPresent()) { + throw new BusinessException(ApiResponseCode.CONFLICT, "演示手机号已注册"); + } + } + if (userMapper.findByWechatOpenid(code).isPresent()) { + throw new BusinessException(ApiResponseCode.CONFLICT, "演示账号编号已注册"); + } + Map result = loginWithSession(Map.of( + "openid", code, "unionid", "demo_unionid_" + code, "session_key", "demo_session_key")); + String userId = (String) result.get("userId"); + if (phone != null && !phone.isBlank()) { + User user = new User(); + user.setId(userId); + user.setPhone(phone); + userMapper.updateById(user); + } + // 演示档案保持未完成;必填占位字段不代表完成实名认证或婚恋资料审核。 + UserProfile profile = new UserProfile(); + profile.setUserId(userId); + profile.setDisplayName(displayName.trim()); + profile.setGender("other"); + profile.setBirthDate(java.time.LocalDate.of(2000, 1, 1)); + profile.setProfileCompletion(0); + profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS); + userProfileMapper.insert(profile); + result.put("displayName", displayName.trim()); + return result; + } + private Map loginWithSession(Map session) { String openid = session.get("openid"); String unionid = session.get("unionid"); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/DemoOperatorRegistrationService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/DemoOperatorRegistrationService.java new file mode 100644 index 0000000..de73a81 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/DemoOperatorRegistrationService.java @@ -0,0 +1,49 @@ +package com.danyang.match.platform.auth.service; + +import com.danyang.match.platform.operator.service.OperatorManagementService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class DemoOperatorRegistrationService { + + private final OperatorManagementService operatorManagementService; + private final JdbcTemplate jdbcTemplate; + + @Value("${demo.login.enabled:false}") + private boolean demoLoginEnabled; + + @Transactional + public Map register(String name, String username, String password) { + if (!demoLoginEnabled) { + throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用"); + } + if (name == null || name.isBlank() || name.length() > 100 + || username == null || !username.matches("[a-zA-Z][a-zA-Z0-9_]{3,29}") + || password == null || password.length() < 8 || password.length() > 72) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "姓名、用户名或密码格式无效"); + } + if (jdbcTemplate.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, username) > 0) { + throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在"); + } + + Operator operator = operatorManagementService.createOperator(name.trim(), username, password, null); + jdbcTemplate.update("UPDATE operators SET status = 'active' WHERE id = ?", UUID.fromString(operator.getId())); + operator.setStatus("active"); + String roleId = jdbcTemplate.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class); + jdbcTemplate.update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)", + UUID.fromString(operator.getId()), UUID.fromString(roleId)); + return Map.of("operatorId", operator.getId(), "username", operator.getUsername(), + "name", operator.getName(), "status", operator.getStatus(), "role", "matchmaker"); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java index af31854..60f31e2 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java @@ -155,4 +155,11 @@ public class MatchmakerOperatorController { return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id())); } + @PostMapping("/services/{id}/start") + @PreAuthorize("hasAuthority('matchmaker.service')") + public ApiResponse startService(@AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String id) { + return ApiResponse.ok(matchmakerServiceService.startService(id, principal.id())); + } + } 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 c5cfbac..8372aff 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 @@ -6,6 +6,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.notification.service.NotificationService; import com.danyang.match.platform.permission.entity.Operator; import com.danyang.match.platform.permission.mapper.OperatorMapper; import com.danyang.match.platform.shared.constant.Constants; @@ -61,6 +62,7 @@ public class MatchmakerAssignmentService { private final MatchmakerAssignmentMapper assignmentMapper; private final MatchmakerServiceMapper serviceMapper; + private final NotificationService notificationService; private final OperatorMapper operatorMapper; private final DataScopeGuard dataScopeGuard; private final AuditLogMapper auditLogMapper; @@ -134,6 +136,9 @@ public class MatchmakerAssignmentService { assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId); saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(), auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason)); + if (serviceId != null && !serviceId.isBlank()) { + notificationService.sendMatchmakerServiceAssigned(userId, serviceId); + } return assignment; } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java index 87a43b9..42bc08a 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java @@ -247,6 +247,22 @@ public class MatchmakerServiceService { return followup; } + @Transactional + public MatchmakerServiceRecord startService(String serviceId, String operatorId) { + MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); + MatchmakerAssignment assignment = assertServiceAccess(operatorId, service); + if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) { + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能开始服务"); + } + service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); + serviceMapper.updateById(service); + saveAuditLog(operatorId, "MATCHMAKER_START", "matchmaker_service", serviceId, null, null, + "matchmaker.service.start", assignment.getId()); + notificationService.sendMatchmakerServiceStarted(service.getUserId(), serviceId); + return service; + } + /** * 完成服务 * @@ -271,6 +287,8 @@ public class MatchmakerServiceService { // ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null, "matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null); + notificationService.sendMatchmakerServiceCompleted(service.getUserId(), serviceId); + notificationService.sendMatchmakerServiceReviewInvite(service.getUserId(), serviceId); return service; } diff --git a/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java b/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java index 0c82280..667ddde 100644 --- a/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java +++ b/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java @@ -3,6 +3,7 @@ package com.danyang.match.platform.notification.service; import com.danyang.match.platform.notification.entity.Notification; import com.danyang.match.platform.notification.mapper.NotificationMapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -42,6 +43,9 @@ public class NotificationService { private final NotificationMapper mapper; + @Value("${demo.login.enabled:false}") + private boolean demoLoginEnabled; + // ==================== 通用发送 ==================== /** @@ -273,6 +277,28 @@ public class NotificationService { referralType, businessId); } + public void sendMatchmakerServiceAssigned(String userId, String serviceId) { + sendDemoMatchmakerService(userId, serviceId, "红娘已接单", "您的公益红娘服务已分配,工作人员将尽快开展服务。"); + } + + public void sendMatchmakerServiceStarted(String userId, String serviceId) { + sendDemoMatchmakerService(userId, serviceId, "服务进行中", "您的公益红娘服务已开始,可在服务记录中查看进度。"); + } + + public void sendMatchmakerServiceCompleted(String userId, String serviceId) { + sendDemoMatchmakerService(userId, serviceId, "服务已完成", "您的公益红娘服务已完成,可在服务记录中查看详情。"); + } + + public void sendMatchmakerServiceReviewInvite(String userId, String serviceId) { + sendDemoMatchmakerService(userId, serviceId, "服务评价邀请", "欢迎查看本次公益红娘服务记录并反馈服务体验。"); + } + + private void sendDemoMatchmakerService(String userId, String serviceId, String title, String content) { + if (demoLoginEnabled) { + sendAsync(userId, "matchmaker", title, content, "matchmaker_service", serviceId); + } + } + // ==================== 外部推送(微信模板消息)— P11-V11-T11 ==================== /** 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 470e275..064f0b9 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java @@ -65,7 +65,7 @@ public class SecurityConfig { .accessDeniedHandler(forbiddenHandler(objectMapper))) .authorizeHttpRequests(auth -> auth // 公开接口(order=0 最高优先级) - .requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/refresh", "/auth/operator/login") + .requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/demo/register", "/auth/refresh", "/auth/operator/login", "/auth/operator/demo/register") .permitAll() .requestMatchers("/health/**").permitAll() .requestMatchers("/version").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 31788c3..50a4fed 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 @@ -106,7 +106,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { // 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL), // 运营账号禁用/待激活均不得通过过滤器。 var operator = operatorMapper.findById(subject); - if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) { + if (operator.isEmpty() || !("active".equals(operator.get().getStatus()) + || "normal".equals(operator.get().getStatus()))) { sendUnauthorized(response); return; } diff --git a/backend/src/test/java/com/danyang/match/platform/auth/DemoOperatorRegistrationServiceTest.java b/backend/src/test/java/com/danyang/match/platform/auth/DemoOperatorRegistrationServiceTest.java new file mode 100644 index 0000000..9308881 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/auth/DemoOperatorRegistrationServiceTest.java @@ -0,0 +1,58 @@ +package com.danyang.match.platform.auth; + +import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService; +import com.danyang.match.platform.operator.service.OperatorManagementService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class DemoOperatorRegistrationServiceTest { + + @Test + void disabledDemoRegistrationDoesNotCreateAnOperator() { + OperatorManagementService management = mock(OperatorManagementService.class); + DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class)); + ReflectionTestUtils.setField(service, "demoLoginEnabled", false); + + assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "password123")); + verifyNoInteractions(management); + } + + @Test + void rejectsShortPasswordsBeforeWriting() { + OperatorManagementService management = mock(OperatorManagementService.class); + DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class)); + ReflectionTestUtils.setField(service, "demoLoginEnabled", true); + + assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "short")); + verifyNoInteractions(management); + } + + @Test + void newDemoOperatorUsesTheExistingMatchmakerRole() { + // Registration service writes only the role link; status remains the shared normal account state. + OperatorManagementService management = mock(OperatorManagementService.class); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, jdbc); + ReflectionTestUtils.setField(service, "demoLoginEnabled", true); + Operator operator = new Operator(); + operator.setId("916afc99-1cee-4115-8978-a93a497cb40f"); + operator.setUsername("demo_user"); + operator.setName("红娘"); + operator.setStatus("active"); + when(jdbc.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, "demo_user")).thenReturn(0); + when(jdbc.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class)).thenReturn("00000000-0000-0000-0000-000000000005"); + when(management.createOperator("红娘", "demo_user", "password123", null)).thenReturn(operator); + + service.register("红娘", "demo_user", "password123"); + + verify(jdbc).update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)", + java.util.UUID.fromString(operator.getId()), java.util.UUID.fromString("00000000-0000-0000-0000-000000000005")); + verify(jdbc).update("UPDATE operators SET status = 'active' WHERE id = ?", java.util.UUID.fromString(operator.getId())); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/auth/DemoUserRegistrationFieldsTest.java b/backend/src/test/java/com/danyang/match/platform/auth/DemoUserRegistrationFieldsTest.java new file mode 100644 index 0000000..3cf58cd --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/auth/DemoUserRegistrationFieldsTest.java @@ -0,0 +1,70 @@ +package com.danyang.match.platform.auth; + +import com.danyang.match.platform.auth.service.AuthService; +import com.danyang.match.platform.auth.token.JwtTokenService; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.capability.service.CapabilityService; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.review.mapper.ReviewTaskMapper; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.danyang.match.platform.auth.provider.WechatLoginProvider; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DemoUserRegistrationFieldsTest { + @Test + void registrationPersistsDemoNicknameAndOptionalPhone() { + UserMapper users = mock(UserMapper.class); + UserProfileMapper profiles = mock(UserProfileMapper.class); + JwtTokenService jwt = mock(JwtTokenService.class); + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(users.findByWechatOpenid("demo_openid_fixed_01")).thenReturn(Optional.empty()); + when(users.findByPhone("13800138001")).thenReturn(Optional.empty()); + when(users.insert(any(User.class))).thenAnswer(call -> { + ((User) call.getArgument(0)).setId("0f11d10a-befa-4d78-a934-e98046fd3c44"); + return 1; + }); + when(jwt.generateAccessToken(anyString(), isNull())).thenReturn("access"); + when(jwt.generateRefreshToken(anyString())).thenReturn("refresh"); + when(jwt.getRefreshExpirationMs()).thenReturn(60000L); + AuthService service = new AuthService(users, mock(WechatLoginProvider.class), jwt, redis, + mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class), mock(CapabilityService.class), + profiles, mock(ReviewTaskMapper.class)); + ReflectionTestUtils.setField(service, "demoLoginEnabled", true); + + var result = service.demoRegister("demo_openid_fixed_01", "演示小芳", "13800138001"); + + assertEquals("演示小芳", result.get("displayName")); + verify(users).findByPhone("13800138001"); + verify(users).updateById(argThat(user -> "13800138001".equals(user.getPhone()))); + verify(profiles).insert(argThat(profile -> "演示小芳".equals(profile.getDisplayName()) + && "in_progress".equals(profile.getProfileStatus()))); + } + + @Test + void invalidPhoneIsRejectedBeforeWriting() { + UserMapper users = mock(UserMapper.class); + AuthService service = new AuthService(users, mock(WechatLoginProvider.class), mock(JwtTokenService.class), + mock(StringRedisTemplate.class), mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class), + mock(CapabilityService.class), mock(UserProfileMapper.class), mock(ReviewTaskMapper.class)); + ReflectionTestUtils.setField(service, "demoLoginEnabled", true); + + assertThrows(BusinessException.class, + () -> service.demoRegister("demo_openid_fixed_01", "演示小芳", "123")); + verifyNoInteractions(users); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java index 08e60dc..9c4ad6e 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java @@ -12,6 +12,8 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapp import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.notification.entity.Notification; +import com.danyang.match.platform.notification.mapper.NotificationMapper; import com.danyang.match.platform.permission.entity.Operator; import com.danyang.match.platform.permission.mapper.OperatorMapper; import com.danyang.match.platform.referral.service.ReferralService; @@ -36,6 +38,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; @@ -63,6 +66,7 @@ import static org.junit.jupiter.api.Assertions.*; */ @SpringBootTest @ActiveProfiles("test") +@TestPropertySource(properties = "demo.login.enabled=true") @Transactional class MatchmakerDataScope005Test { @@ -77,6 +81,7 @@ class MatchmakerDataScope005Test { @Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper; @Autowired private AuditLogMapper auditLogMapper; @Autowired private MatchmakerAssignmentController assignmentController; + @Autowired private NotificationMapper notificationMapper; @Autowired private ReferralService referralService; private String leaderA; // 组长,org X @@ -190,6 +195,23 @@ class MatchmakerDataScope005Test { throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了"); } + @Test + @DisplayName("演示服务分配、开始、完成均通知用户,完成后邀请评价") + void demoServiceLifecycleCreatesUserNotifications() { + String serviceId = createService(user1, mmB); + createAssignment(mmB, user1, serviceId); + matchmakerServiceService.startService(serviceId, mmB); + matchmakerServiceService.completeService(serviceId, mmB); + + List notes = notificationMapper.selectList(new LambdaQueryWrapper() + .eq(Notification::getUserId, user1) + .eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER) + .eq(Notification::getBusinessType, "matchmaker_service") + .eq(Notification::getBusinessId, serviceId)); + assertEquals(Set.of("红娘已接单", "服务进行中", "服务已完成", "服务评价邀请"), + notes.stream().map(Notification::getTitle).collect(java.util.stream.Collectors.toSet())); + } + // ==================== MM-PRIVACY-001(负向×3) ==================== @Test diff --git a/backend/src/test/java/com/danyang/match/platform/notification/NotificationServiceTest.java b/backend/src/test/java/com/danyang/match/platform/notification/NotificationServiceTest.java index c394914..ad55640 100644 --- a/backend/src/test/java/com/danyang/match/platform/notification/NotificationServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/notification/NotificationServiceTest.java @@ -1,12 +1,36 @@ package com.danyang.match.platform.notification; import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.notification.entity.Notification; +import com.danyang.match.platform.notification.mapper.NotificationMapper; +import com.danyang.match.platform.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.*; +@ExtendWith(MockitoExtension.class) class NotificationServiceTest { + @Mock + private NotificationMapper mapper; + + private NotificationService notificationService; + + @BeforeEach + void setUp() { + notificationService = new NotificationService(mapper); + } + @Test void testNotificationTypeConstants() { assertEquals("review", Constants.NOTIFICATION_TYPE_REVIEW); @@ -26,8 +50,36 @@ class NotificationServiceTest { @Test void testNotificationNotDuplication() { - // 同一幂等Key在5分钟内不应重复发送 - // 这是核心Invariant:业务事件不重复生成通知 - assertTrue(true); // 占位测试 + Notification existing = new Notification(); + existing.setId("notification-1"); + when(mapper.findExisting("user-1", "matchmaker", "matchmaker_service", "service-1", "服务已完成")) + .thenReturn(existing); + + assertSame(existing, notificationService.send("user-1", "matchmaker", "服务已完成", "服务已完成", + "matchmaker_service", "service-1")); + org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never()).insert(any()); + } + + @Test + void matchmakerServiceEventsUseServiceNotificationTypeAndBusinessId() { + ReflectionTestUtils.setField(notificationService, "demoLoginEnabled", true); + when(mapper.findExisting(any(), any(), any(), any(), any())).thenReturn(null); + notificationService.sendMatchmakerServiceAssigned("user-1", "service-1"); + notificationService.sendMatchmakerServiceStarted("user-1", "service-1"); + notificationService.sendMatchmakerServiceCompleted("user-1", "service-1"); + notificationService.sendMatchmakerServiceReviewInvite("user-1", "service-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(mapper, org.mockito.Mockito.times(4)).insert(captor.capture()); + assertEquals(4, captor.getAllValues().stream() + .filter(n -> "matchmaker".equals(n.getType())) + .count()); + assertTrue(captor.getAllValues().stream().allMatch(n -> "service-1".equals(n.getBusinessId()))); + } + + @Test + void matchmakerServiceEventsStayDisabledOutsideDemo() { + notificationService.sendMatchmakerServiceAssigned("user-1", "service-1"); + org.mockito.Mockito.verifyNoInteractions(mapper); } } diff --git a/h5-demo/package.json b/h5-demo/package.json index 80cfafd..65183b7 100644 --- a/h5-demo/package.json +++ b/h5-demo/package.json @@ -10,6 +10,7 @@ "type-check": "vue-tsc --noEmit", "preview": "vite preview", "demo-login-contract": "node tests/demo-login-contract.cjs", + "demo-register-contract": "node tests/demo-register-contract.cjs", "ui-contract": "node tests/ui-contract.cjs", "smoke": "node tests/smoke-h5.cjs" }, diff --git a/h5-demo/src/api/services/auth.ts b/h5-demo/src/api/services/auth.ts index c26edaf..62c1f51 100644 --- a/h5-demo/src/api/services/auth.ts +++ b/h5-demo/src/api/services/auth.ts @@ -9,6 +9,9 @@ export const authService = { demoLogin(payload: { code: string }) { return post('/auth/demo/login', payload, { idempotencyKey: payload.code }) }, + demoRegister(payload: { code: string; displayName: string; phone?: string }) { + return post('/auth/demo/register', payload, { idempotencyKey: payload.code }) + }, bindPhone(payload: Record) { return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData }) }, @@ -22,6 +25,9 @@ export const authService = { /** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */ export const operatorService = { + registerDemo(payload: { name: string; username: string; password: string }) { + return post('/auth/operator/demo/register', payload) + }, async login(username: string, password: string): Promise { const data = await post('/auth/operator/login', { username, password }) if (data?.accessToken) { diff --git a/h5-demo/src/api/services/matchmaker.ts b/h5-demo/src/api/services/matchmaker.ts index 6b6ee52..57739d9 100644 --- a/h5-demo/src/api/services/matchmaker.ts +++ b/h5-demo/src/api/services/matchmaker.ts @@ -68,6 +68,12 @@ export const operatorMatchmakerService = { serviceDetail(id: string) { return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`) }, + managementServiceDetail(id: string) { + return opGet(`/operator/matchmaker/management/services/${encodeURIComponent(id)}`) + }, + assign(id: string, payload: { operatorId: string; reason: string }) { + return opPost(`/operator/matchmaker/services/${encodeURIComponent(id)}/assign`, payload) + }, recommend(serviceId: string, payload: Record) { return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload) }, @@ -77,6 +83,9 @@ export const operatorMatchmakerService = { complete(serviceId: string) { return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {}) }, + start(serviceId: string) { + return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/start`, {}) + }, myContactAuthorizations() { return opGet('/operator/matchmaker/contact-authorizations/my') }, diff --git a/h5-demo/src/components/UiUserCard.vue b/h5-demo/src/components/UiUserCard.vue index 3d95b78..fb0b0f0 100644 --- a/h5-demo/src/components/UiUserCard.vue +++ b/h5-demo/src/components/UiUserCard.vue @@ -1,23 +1,31 @@ @@ -25,11 +33,13 @@ @@ -37,12 +47,11 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>() .ui-user-card { display: flex; align-items: center; - gap: 12px; + gap: 8px; background: var(--mini-surface); - border-radius: var(--mini-radius); - padding: 12px; - margin-bottom: 10px; - box-shadow: var(--mini-shadow); + min-height: 74px; + padding: 7px 9px; + border-bottom: 1px solid var(--mini-border); } .ui-user-card__main { flex: 1; @@ -58,6 +67,7 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>() font-weight: 600; color: var(--mini-text-strong); } +.ui-user-card__online { margin-left: auto; color: var(--mini-success, #198754); font-size: 12px; } .ui-user-card__meta { display: flex; gap: 10px; @@ -78,4 +88,6 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>() flex-direction: column; gap: 6px; } +.ui-user-card__like { width: 36px; height: 36px; padding: 0; border-radius: 50%; } +.ui-user-card__like img { width: 21px; height: 21px; object-fit: contain; filter: brightness(0) invert(1); } diff --git a/h5-demo/src/layouts/TabLayout.vue b/h5-demo/src/layouts/TabLayout.vue index 8eac138..850d7f5 100644 --- a/h5-demo/src/layouts/TabLayout.vue +++ b/h5-demo/src/layouts/TabLayout.vue @@ -10,7 +10,7 @@ 首页 找对象 - 消息 + 通知 活动 我的 @@ -18,8 +18,8 @@ diff --git a/h5-demo/src/pages/auth/LoginPage.vue b/h5-demo/src/pages/auth/LoginPage.vue index e7aa535..070cea2 100644 --- a/h5-demo/src/pages/auth/LoginPage.vue +++ b/h5-demo/src/pages/auth/LoginPage.vue @@ -27,6 +27,7 @@ diff --git a/h5-demo/src/pages/content/AntiFraudPage.vue b/h5-demo/src/pages/content/AntiFraudPage.vue index 1949283..4edfaf4 100644 --- a/h5-demo/src/pages/content/AntiFraudPage.vue +++ b/h5-demo/src/pages/content/AntiFraudPage.vue @@ -80,7 +80,7 @@ const RISKS = [ ] const STEPS = [ { title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' }, - { title: '保存相关证据', description: '保留聊天、付款和对方账号信息' }, + { title: '保存相关证据', description: '保留沟通记录、付款和对方账号信息' }, { title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' }, ] diff --git a/h5-demo/src/pages/discover/DiscoverPage.vue b/h5-demo/src/pages/discover/DiscoverPage.vue index 6251852..bbe7ac4 100644 --- a/h5-demo/src/pages/discover/DiscoverPage.vue +++ b/h5-demo/src/pages/discover/DiscoverPage.vue @@ -1,30 +1,28 @@ @@ -125,6 +126,7 @@ const route = useRoute() const appStore = useAppStore() const serviceId = typeof route.query.id === 'string' ? route.query.id : '' +const isManagementService = route.query.source === 'management' const followupTypes = [ { value: 'followup', label: '跟进记录' }, @@ -157,6 +159,7 @@ const error = ref(false) const recommending = ref(false) const submitting = ref(false) const completing = ref(false) +const starting = ref(false) const recommendForm = reactive({ targetUserId: '', reason: '' }) const followupForm = reactive({ followupType: 'followup', content: '', nextFollowupAt: '' }) @@ -186,7 +189,9 @@ async function load() { loading.value = true error.value = false try { - detail.value = await operatorMatchmakerService.serviceDetail(serviceId) + detail.value = isManagementService + ? await operatorMatchmakerService.managementServiceDetail(serviceId) + : await operatorMatchmakerService.serviceDetail(serviceId) } catch (err: any) { error.value = true appStore.toastError(err?.message || '加载失败') @@ -262,6 +267,20 @@ async function onComplete() { } } +async function onStart() { + if (!serviceId || starting.value) return + starting.value = true + try { + await operatorMatchmakerService.start(serviceId) + appStore.toastSuccess('已开始服务') + await load() + } catch (err: any) { + appStore.toastError(err?.message || '开始服务失败') + } finally { + starting.value = false + } +} + onMounted(load) diff --git a/h5-demo/src/pages/operator/OperatorWorkbenchPage.vue b/h5-demo/src/pages/operator/OperatorWorkbenchPage.vue index ba5b001..e504c81 100644 --- a/h5-demo/src/pages/operator/OperatorWorkbenchPage.vue +++ b/h5-demo/src/pages/operator/OperatorWorkbenchPage.vue @@ -20,34 +20,34 @@ - - -