From bc4531a73494091c4fde12f4eff1a122979c50a0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 16 Sep 2026 12:54:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E8=A1=A5=E9=BD=90=20/me/*=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=EF=BC=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=2017=20=E4=B8=AA=20404=20=E8=B0=83=E7=94=A8=20+=20=E4=BF=AE=20?= =?UTF-8?q?/me/status=20=E8=BF=87=E6=9C=9F=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:小程序 services/*.js 按 REST 规范(§16 / §22-§40 / §80 / §83-§84)统一调用 /me/*,但后端从未注册 /me 基路径,个人资料、照片、择偶条件、隐私设置、关系状态、 我的举报/申诉等约 17 个调用全部 404。 后端 - 新增 me/controller/MeController:把既有 service 能力暴露到规范路径(纯委托,不复制 业务规则,AGENTS.md #26)。旧路径(/profile/*、/photos、/relationship/*、 /account/privacy-settings、/risk/reports/my、/appeals/my)全部保留。 - 响应形状按小程序 JSDoc 适配(小程序被 86 页引用,是权威契约): · /me/relationship-status 返回 status(旧 /relationship/status 返回 relationshipStatus, 字段名不同,前端读的是 result.status) · /me/profile/review-status 返回 {status, items, submittedAt, reviewedAt},时间统一 epoch 毫秒 · /me/profile/submit-review 返回 {reviewStatus, submittedAt} - 小程序三条历史写法一并兼容:/me/privacy-settings、/me/photos/{id}/main、/me/appeals 修复 - AuthService.getMeStatus:verified / profileStatus / reviewStatus 三项写死为 false / not_started / not_submitted,注释标注"Phase 03/04/05 填充",但那三个 Phase 早已交付,导致 /me/status 长期返回错误的实名与审核状态。改为 capabilityService + userProfileMapper + reviewTaskMapper 的真实来源(与首页聚合同一判定)。 - PhotoController.deletePhoto:原用 ((Map) p).get("id") 强转 UserPhoto 实体, 必然抛 ClassCastException,删除照片恒定 500。改用实体主键比对。 - PhotoService.setMainAvatar(新增):数据模型以 photo_type='avatar' 标识头像(见 UserPhotoMapper#findAvatar),无 is_main 字段,故"设主图"= 换类型并保证至多一张。 注意:插入该方法时曾把 deletePhoto 原有的 @Transactional 挤到新方法上,导致 "Transactional is not a repeatable annotation" 并引发 Lombok 全面停摆(满屏 找不到 getter/setter),已修正注解归属。 验证 - 后端全量:299 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(+2 为新增契约测试) - 新增 MeRouteContractTest(2 例):同时断言 HTTP 方法,锁定 21 条 /me/* 与 13 条旧路径 - 小程序 tsc --noEmit 通过;UI 契约门禁 13/13 文档 - STATUS.md:/me/* 从"待裁决"改为"已完成";核心待清债务 1 项 → 0 项 - API_INVENTORY.md:顶部"待收敛"警告改为"已收敛",补全 /me/* 端点表 --- .../platform/auth/service/AuthService.java | 23 +- .../platform/me/controller/MeController.java | 388 ++++++++++++++++++ .../photo/controller/PhotoController.java | 8 +- .../platform/photo/service/PhotoService.java | 34 ++ .../contract/MeRouteContractTest.java | 123 ++++++ docs/JIRA_BACKLOG.md | 14 +- ...1-Recovery-Completion-Report-2026-09-13.md | 5 +- docs/STATUS.md | 20 +- docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md | 30 +- docs/api/API_INVENTORY.md | 38 +- ui-tests/ui-document-baseline-contracts.mjs | 20 +- 11 files changed, 651 insertions(+), 52 deletions(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java create mode 100644 backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java 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 856d4e7..1719aca 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 @@ -5,9 +5,14 @@ import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.auth.provider.WechatLoginProvider; import com.danyang.match.platform.auth.token.JwtTokenService; +import com.danyang.match.platform.capability.service.CapabilityService; import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; 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.entity.ReviewTask; +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 lombok.RequiredArgsConstructor; @@ -21,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -34,6 +40,9 @@ public class AuthService { private final StringRedisTemplate redisTemplate; private final AuditLogMapper auditLogMapper; private final LocalEligibilityMapper localEligibilityMapper; + private final CapabilityService capabilityService; + private final UserProfileMapper userProfileMapper; + private final ReviewTaskMapper reviewTaskMapper; @Value("${jwt.access-token-expiration-ms:7200000}") private long accessTokenExpireMs; @@ -169,9 +178,17 @@ public class AuthService { status.put("relationshipStatus", user.getRelationshipStatus()); status.put("recommendationEnabled", user.getRecommendationEnabled()); status.put("phoneBound", user.getPhone() != null && !user.getPhone().isBlank()); - status.put("verified", false); // Phase 03 填充 - status.put("profileStatus", "not_started"); // Phase 04 填充 - status.put("reviewStatus", "not_submitted"); // Phase 05 填充 + // 以下三项原先写死为占位值并标注"Phase 03/04/05 填充"。Phase 03/04/05 均已交付, + // 占位值会让 /me/status 长期返回错误的实名与审核状态,故改为真实来源。 + // 实名复用 CapabilityService,与首页聚合共用同一判定,不在此处复制业务规则(AGENTS.md #26)。 + status.put("verified", capabilityService.isIdentityVerified(userId)); + + UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null); + status.put("profileStatus", + profile != null && profile.getProfileStatus() != null ? profile.getProfileStatus() : "not_started"); + + Optional latestReview = reviewTaskMapper.findLatestByUserId(userId); + status.put("reviewStatus", latestReview.map(ReviewTask::getStatus).orElse("not_submitted")); // V1.1: 本地资格状态 com.danyang.match.platform.local_eligibility.entity.LocalEligibility el = diff --git a/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java b/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java new file mode 100644 index 0000000..5a5d627 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java @@ -0,0 +1,388 @@ +package com.danyang.match.platform.me.controller; + +import com.danyang.match.platform.appeal.service.AppealService; +import com.danyang.match.platform.auth.service.AuthService; +import com.danyang.match.platform.photo.entity.UserPhoto; +import com.danyang.match.platform.photo.service.PhotoService; +import com.danyang.match.platform.profile.dto.SelfProfileDTO; +import com.danyang.match.platform.profile.service.PrivacyService; +import com.danyang.match.platform.profile.service.UserPreferenceService; +import com.danyang.match.platform.profile.service.UserProfileService; +import com.danyang.match.platform.relationship.service.RelationshipStatusService; +import com.danyang.match.platform.review.entity.ReviewTask; +import com.danyang.match.platform.review.service.ReviewService; +import com.danyang.match.platform.risk.service.ReportService; +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; +import com.danyang.match.platform.shared.security.principal.UserPrincipal; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 本人资源聚合控制器 —— REST 规范 {@code /me/*} 命名空间。 + * + *

背景:小程序 {@code services/*.js} 按 {@code REST API 接口详细设计规范.md} + * (§16 / §22-§40 / §80 / §83-§84)统一调用 {@code /me/*},但后端此前从未注册 {@code /me} 基路径, + * 导致个人资料、照片、择偶条件、隐私设置、关系状态、我的举报/申诉等约 17 个调用全部 404。 + * + *

职责边界:本控制器不新造业务逻辑,只做两件事: + *

    + *
  1. 把已有 service 能力暴露到规范路径(纯委托,不复制业务规则,见 AGENTS.md #26);
  2. + *
  3. 把响应适配成小程序既有的消费形状(小程序被 86 页引用,是权威契约, + * 例如 {@code relationship-status} 用 {@code status} 而非后端的 {@code relationshipStatus})。
  4. + *
+ * + *

兼容策略:旧路径({@code /profile/*}、{@code /photos}、{@code /relationship/*}、 + * {@code /account/privacy-settings} 等)全部保留不动,避免管理端与既有测试断裂; + * 小程序历史上自行发明的路径({@code /me/privacy-settings}、{@code /me/photos/{id}/main}、 + * {@code /me/appeals})一并提供,使前端零改动可用。 + * + *

路由已由 {@code MeRouteContractTest} 锁定。 + */ +@RestController +@RequestMapping("/me") +@RequiredArgsConstructor +public class MeController { + + private final AuthService authService; + private final UserMapper userMapper; + private final UserProfileService userProfileService; + private final UserPreferenceService preferenceService; + private final PrivacyService privacyService; + private final PhotoService photoService; + private final ReviewService reviewService; + private final RelationshipStatusService relationshipStatusService; + private final ReportService reportService; + private final AppealService appealService; + + // ==================== 账号状态 ==================== + + /** + * 获取当前用户全局状态(REST §16)。 + * GET /api/v1/me/status + */ + @GetMapping("/status") + public ApiResponse> getStatus(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(authService.getMeStatus(principal.id())); + } + + // ==================== 个人资料 ==================== + + /** + * 获取本人资料(REST §22)。 + * GET /api/v1/me/profile + */ + @GetMapping("/profile") + public ApiResponse getProfile(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(userProfileService.getSelfProfile(principal.id())); + } + + /** + * 更新本人资料(REST §22 配套写操作)。 + * PUT /api/v1/me/profile + */ + @PutMapping("/profile") + public ApiResponse updateProfile(@AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map fields) { + userProfileService.updateProfile(principal.id(), fields); + return ApiResponse.ok(); + } + + /** + * 更新基础资料(REST §23,资料完善流程分节写)。 + * PATCH /api/v1/me/profile/basic + */ + @PatchMapping("/profile/basic") + public ApiResponse updateBasicProfile(@AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map fields) { + return ApiResponse.ok(userProfileService.updateBasicProfile(principal.id(), fields)); + } + + /** + * 资料完整度(REST 第二十一章配套)。 + * GET /api/v1/me/profile/completion + */ + @GetMapping("/profile/completion") + public ApiResponse> getCompletion(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(Map.of("completion", userProfileService.getCompletionPercentage(principal.id()))); + } + + // ==================== 资料审核 ==================== + + /** + * 获取资料审核状态(REST §39)。 + * GET /api/v1/me/profile/review-status + * + *

同时提供清单口径的 {@code /me/review-status} 别名。 + * 响应形状对齐小程序 {@code profileService.getReviewStatus} 的 JSDoc: + * {@code { status, items:[{itemType,status,reasonText}], submittedAt, reviewedAt }}, + * 时间统一为 epoch 毫秒(前端按 number 消费,ISO 字符串会导致时间显示为 0)。 + */ + @GetMapping({"/profile/review-status", "/review-status"}) + public ApiResponse> getReviewStatus(@AuthenticationPrincipal UserPrincipal principal) { + List history = reviewService.getReviewHistory(principal.id()); + + List> items = new ArrayList<>(); + for (ReviewTask task : history) { + Map item = new LinkedHashMap<>(); + item.put("itemType", task.getReviewType()); + item.put("status", task.getStatus()); + if (task.getFinalComment() != null && !task.getFinalComment().isBlank()) { + item.put("reasonText", task.getFinalComment()); + } + items.add(item); + } + + // 以提交时间最晚的一条作为"当前审核状态",避免依赖 history 的返回顺序。 + ReviewTask latest = history.stream() + .filter(t -> t.getSubmittedAt() != null) + .max(Comparator.comparing(ReviewTask::getSubmittedAt)) + .orElse(history.isEmpty() ? null : history.get(0)); + + Instant submittedAt = history.stream() + .map(ReviewTask::getSubmittedAt).filter(Objects::nonNull).max(Instant::compareTo).orElse(null); + Instant reviewedAt = history.stream() + .map(ReviewTask::getCompletedAt).filter(Objects::nonNull).max(Instant::compareTo).orElse(null); + + Map result = new LinkedHashMap<>(); + result.put("status", latest == null ? "not_submitted" : latest.getStatus()); + result.put("items", items); + if (submittedAt != null) { + result.put("submittedAt", submittedAt.toEpochMilli()); + } + if (reviewedAt != null) { + result.put("reviewedAt", reviewedAt.toEpochMilli()); + } + return ApiResponse.ok(result); + } + + /** + * 提交资料审核(REST §37)。 + * POST /api/v1/me/profile/submit-review + * + *

响应形状对齐小程序 JSDoc:{@code { reviewStatus, submittedAt }},submittedAt 为 epoch 毫秒。 + */ + @PostMapping("/profile/submit-review") + public ApiResponse> submitReview(@AuthenticationPrincipal UserPrincipal principal) { + ReviewTask task = reviewService.resubmit(principal.id()); + Map result = new LinkedHashMap<>(); + result.put("reviewStatus", task.getStatus()); + result.put("submittedAt", + task.getSubmittedAt() != null ? task.getSubmittedAt().toEpochMilli() : System.currentTimeMillis()); + return ApiResponse.ok(result); + } + + // ==================== 择偶条件 ==================== + + /** + * 获取择偶条件(REST §34)。 + * GET /api/v1/me/preferences + */ + @GetMapping("/preferences") + public ApiResponse> getPreferences(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(preferenceService.getPreference(principal.id())); + } + + /** + * 保存择偶条件(REST §35)。 + * PUT /api/v1/me/preferences + */ + @PutMapping("/preferences") + public ApiResponse savePreferences(@AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map data) { + preferenceService.savePreference(principal.id(), data); + return ApiResponse.ok(); + } + + // ==================== 照片 ==================== + + /** + * 获取本人照片列表(REST 第二十八章)。 + * GET /api/v1/me/photos + */ + @GetMapping("/photos") + public ApiResponse> getPhotos(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(photoService.getPhotos(principal.id())); + } + + /** + * 获取照片上传凭证(REST §29)。 + * POST /api/v1/me/photos/upload-token + */ + @PostMapping("/photos/upload-token") + public ApiResponse> getUploadToken( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody(required = false) Map body) { + String photoType = body == null ? "lifestyle" : body.getOrDefault("photoType", "lifestyle"); + return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType)); + } + + /** + * 删除本人照片(REST §32)。 + * DELETE /api/v1/me/photos/{photoId} + * + *

所有权校验直接比对实体主键。注意:旧 {@code PhotoController#deletePhoto} + * 用 {@code (Map) p} 强转 {@code UserPhoto} 实体,必然抛 ClassCastException,此处不沿用该写法。 + */ + @DeleteMapping("/photos/{photoId}") + public ApiResponse deletePhoto(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable String photoId) { + String userId = principal.id(); + boolean owned = photoService.getPhotos(userId).stream() + .anyMatch(p -> photoId.equals(p.getId())); + if (!owned) { + throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "无权删除该照片"); + } + photoService.deletePhoto(photoId); + return ApiResponse.ok(); + } + + /** + * 设置主头像(小程序 {@code profileService.setMainPhoto} 使用,规范未定义此路径)。 + * PUT /api/v1/me/photos/{photoId}/main + * + *

数据模型以 {@code photo_type='avatar'} 标识头像(见 {@code UserPhotoMapper#findAvatar}), + * 故"设主图"即把目标照片置为 avatar、其余 avatar 降级为 lifestyle。 + */ + @PutMapping("/photos/{photoId}/main") + public ApiResponse> setMainPhoto(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable String photoId) { + String userId = principal.id(); + UserPhoto photo = photoService.setMainAvatar(userId, photoId); + + Map result = new LinkedHashMap<>(); + result.put("photoId", photo.getId()); + if (photo.getFileKey() != null) { + // 签名 URL 可能因对象存储未配置而失败,此降级不影响主头像已生效这一事实。 + try { + Map signed = photoService.getSignedUrl(userId, photo.getFileKey()); + result.put("avatarUrl", signed.get("url")); + } catch (RuntimeException ignored) { + result.put("avatarUrl", null); + } + } + return ApiResponse.ok(result); + } + + // ==================== 隐私设置 ==================== + + /** + * 获取隐私设置(REST 第九十一章 {@code GET /me/privacy})。 + * GET /api/v1/me/privacy + * GET /api/v1/me/privacy-settings(小程序历史写法,兼容保留) + */ + @GetMapping({"/privacy", "/privacy-settings"}) + public ApiResponse> getPrivacy(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(privacyService.getPrivacySettings(principal.id())); + } + + /** + * 更新隐私设置(REST 第九十一章 {@code PATCH /me/privacy})。 + * PATCH /api/v1/me/privacy + */ + @PatchMapping("/privacy") + public ApiResponse> patchPrivacy(@AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map settings) { + return ApiResponse.ok(privacyService.updatePrivacySettings(principal.id(), settings)); + } + + /** + * 更新隐私设置(小程序历史写法,兼容保留)。 + * PUT /api/v1/me/privacy-settings + */ + @PutMapping("/privacy-settings") + public ApiResponse> putPrivacySettings(@AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map settings) { + return ApiResponse.ok(privacyService.updatePrivacySettings(principal.id(), settings)); + } + + // ==================== 关系状态 ==================== + + /** + * 获取婚恋关系状态(REST §83)。 + * GET /api/v1/me/relationship-status + * + *

响应形状对齐小程序 {@code profileService.getRelationshipStatus} 的 JSDoc: + * {@code { status, recommendationEnabled, matchingWith }}。 + * 注意与旧 {@code /relationship/status} 的区别——后者返回 {@code relationshipStatus}, + * 小程序消费的是 {@code result.status},字段名不同。 + */ + @GetMapping("/relationship-status") + public ApiResponse> getRelationshipStatus(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(buildRelationshipStatus(principal.id())); + } + + /** + * 更新婚恋关系状态(REST §84)。 + * PATCH /api/v1/me/relationship-status + * Body: {@code { "status": "paused" | "searching" | "getting_to_know" }} + * + *

成功牵手不允许由此接口直接设置,必须走 + * {@code POST /matches/{id}/success/request|confirm} 双方确认。 + */ + @PatchMapping("/relationship-status") + public ApiResponse> updateRelationshipStatus( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody(required = false) Map body) { + String userId = principal.id(); + String status = body == null ? null : body.get("status"); + if (status == null || status.isBlank()) { + throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST, "缺少 status 参数"); + } + switch (status) { + case "paused" -> relationshipStatusService.pauseSearching(userId); + case "searching" -> relationshipStatusService.resumeSearching(userId); + case "getting_to_know" -> relationshipStatusService.setGettingToKnow(userId, false); + case "matched_successfully" -> throw new GlobalExceptionHandler.BusinessException( + ApiResponseCode.MATCH_SUCCESS_NEED_BOTH_CONFIRM, + "成功牵手不能直接设置,请通过 POST /matches/{id}/success/request|confirm 双方确认"); + default -> throw new GlobalExceptionHandler.BusinessException( + ApiResponseCode.BAD_REQUEST, "不支持的 relationship status: " + status); + } + return ApiResponse.ok(buildRelationshipStatus(userId)); + } + + private Map buildRelationshipStatus(String userId) { + User user = userMapper.findById(userId).orElse(null); + Map result = new LinkedHashMap<>(); + result.put("status", user != null && user.getRelationshipStatus() != null + ? user.getRelationshipStatus() : "searching"); + result.put("recommendationEnabled", + user == null || Boolean.TRUE.equals(user.getRecommendationEnabled())); + // matchingWith 需要 match episode 聚合,本轮未接入;前端有 `|| null` 兜底。 + result.put("matchingWith", null); + return result; + } + + // ==================== 我的举报 / 申诉 ==================== + + /** + * 我的举报列表(REST §80)。 + * GET /api/v1/me/reports + */ + @GetMapping("/reports") + public ApiResponse getMyReports(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(reportService.getMyReports(principal.id())); + } + + /** + * 我的申诉列表(小程序 {@code appealService} 使用,规范只定义了 {@code POST /appeals})。 + * GET /api/v1/me/appeals + */ + @GetMapping("/appeals") + public ApiResponse getMyAppeals(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(appealService.getMyAppeals(principal.id())); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/photo/controller/PhotoController.java b/backend/src/main/java/com/danyang/match/platform/photo/controller/PhotoController.java index 7d49d49..a11adc9 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/controller/PhotoController.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/controller/PhotoController.java @@ -2,6 +2,7 @@ package com.danyang.match.platform.photo.controller; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.photo.service.PhotoService; +import com.danyang.match.platform.photo.entity.UserPhoto; import com.danyang.match.platform.shared.dto.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -95,9 +96,10 @@ public class PhotoController { @PathVariable String id) { String userId = principal.id(); // 权限校验:只允许删除自己的照片 - List userPhotos = photoService.getPhotos(userId); - boolean owned = userPhotos.stream() - .anyMatch(p -> id.equals(String.valueOf(((Map) p).get("id")))); + // 注意:getPhotos 返回 UserPhoto 实体列表,原先用 ((Map) p).get("id") 强转会抛 + // ClassCastException,导致删除照片恒定 500。此处改为实体主键比对。 + List userPhotos = photoService.getPhotos(userId); + boolean owned = userPhotos.stream().anyMatch(p -> id.equals(p.getId())); if (!owned) { throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException( com.danyang.match.platform.shared.enums.ApiResponseCode.FORBIDDEN, "无权删除该照片"); diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index ff19beb..1037086 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -7,6 +7,8 @@ import com.danyang.match.platform.photo.entity.UserPhoto; import com.danyang.match.platform.photo.mapper.UserPhotoMapper; import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.config.FeatureFlagConfig; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -239,6 +241,38 @@ public class PhotoService { .map(String::trim).map(String::toLowerCase).anyMatch(extension::equals); } + /** + * 将指定照片设为主头像。 + * + *

数据模型以 {@code photo_type='avatar'} 标识头像(见 {@code UserPhotoMapper#findAvatar}), + * 没有独立的 is_main 字段,因此"设主图"= 把目标照片置为 avatar, + * 并把该用户其余 avatar 降级为 lifestyle,保证同一时刻至多一张头像。 + * + * @throws GlobalExceptionHandler.BusinessException 照片不存在或不属于该用户 + */ + @Transactional + public UserPhoto setMainAvatar(String userId, String photoId) { + List photos = photoMapper.findByUserIdOrderBySort(userId); + UserPhoto target = photos.stream() + .filter(p -> photoId.equals(p.getId())) + .findFirst() + .orElseThrow(() -> new GlobalExceptionHandler.BusinessException( + ApiResponseCode.NOT_FOUND, "照片不存在或不属于当前用户")); + + for (UserPhoto photo : photos) { + if (photoId.equals(photo.getId())) { + if (!"avatar".equals(photo.getPhotoType())) { + photo.setPhotoType("avatar"); + photoMapper.updateById(photo); + } + } else if ("avatar".equals(photo.getPhotoType())) { + photo.setPhotoType("lifestyle"); + photoMapper.updateById(photo); + } + } + return target; + } + /** * 软删除照片 */ diff --git a/backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java b/backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java new file mode 100644 index 0000000..ac9cd6d --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java @@ -0,0 +1,123 @@ +package com.danyang.match.platform.contract; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 路由契约测试:锁定 {@code /me/*} 本人资源命名空间。 + * + *

背景(2026-09-16):小程序 {@code services/*.js} 按 {@code REST API 接口详细设计规范.md} + * (§16 / §22-§40 / §80 / §83-§84)统一调用 {@code /me/*},但后端此前从未注册 {@code /me} 基路径, + * 导致个人资料、照片、择偶条件、隐私设置、关系状态、我的举报/申诉等约 17 个调用全部 404。 + * + *

本测试同时断言 HTTP 方法,因为"路径注册了但方法不对"仍会让前端收到 405—— + * 例如 {@code /me/relationship-status} 前端用 PATCH,写成 PUT 同样不可用。 + * + *

旧路径({@code /profile/*}、{@code /photos}、{@code /relationship/status} 等)必须继续保留, + * 管理端与既有测试依赖它们,故一并断言。 + */ +@SpringBootTest +@ActiveProfiles("test") +class MeRouteContractTest { + + /** 按名字取 MVC 的 HandlerMapping —— Actuator 也注册了一个同类型 bean,按类型注入会歧义 */ + @Autowired + @Qualifier("requestMappingHandlerMapping") + private RequestMappingHandlerMapping handlerMapping; + + /** 上下文已注册的 "METHOD URL" 集合;无方法限定的记为 "ANY URL" */ + private Set routes() { + Set out = new HashSet<>(); + handlerMapping.getHandlerMethods().forEach((info, handler) -> { + Set methods = info.getMethodsCondition().getMethods(); + for (String pattern : info.getPatternValues()) { + if (methods.isEmpty()) { + out.add("ANY " + pattern); + } else { + for (RequestMethod method : methods) { + out.add(method.name() + " " + pattern); + } + } + } + }); + return out; + } + + private void assertRoute(Set routes, String method, String path) { + assertTrue(routes.contains(method + " " + path), "缺少路由 " + method + " " + path); + } + + @Test + @DisplayName("小程序依赖的 /me/* 个人资源路由必须全部注册") + void meRoutesRegistered() { + Set r = routes(); + + // 账号状态(REST §16) + assertRoute(r, "GET", "/me/status"); + + // 个人资料(REST §22-§23、第二十一章) + assertRoute(r, "GET", "/me/profile"); + assertRoute(r, "PUT", "/me/profile"); + assertRoute(r, "PATCH", "/me/profile/basic"); + assertRoute(r, "GET", "/me/profile/completion"); + + // 资料审核(REST §37 / §39)——前端走 /me/profile/review-status + assertRoute(r, "GET", "/me/profile/review-status"); + assertRoute(r, "POST", "/me/profile/submit-review"); + + // 择偶条件(REST §34-§35) + assertRoute(r, "GET", "/me/preferences"); + assertRoute(r, "PUT", "/me/preferences"); + + // 照片(REST §29-§32 + 小程序专有的设主图) + assertRoute(r, "GET", "/me/photos"); + assertRoute(r, "POST", "/me/photos/upload-token"); + assertRoute(r, "DELETE", "/me/photos/{photoId}"); + assertRoute(r, "PUT", "/me/photos/{photoId}/main"); + + // 隐私设置(REST 第九十一章用 /me/privacy;/me/privacy-settings 为小程序历史写法) + assertRoute(r, "GET", "/me/privacy"); + assertRoute(r, "PATCH", "/me/privacy"); + assertRoute(r, "GET", "/me/privacy-settings"); + assertRoute(r, "PUT", "/me/privacy-settings"); + + // 关系状态(REST §83-§84)——前端用 PATCH,不是 PUT + assertRoute(r, "GET", "/me/relationship-status"); + assertRoute(r, "PATCH", "/me/relationship-status"); + + // 我的举报 / 申诉(REST §80 + 小程序申诉列表) + assertRoute(r, "GET", "/me/reports"); + assertRoute(r, "GET", "/me/appeals"); + } + + @Test + @DisplayName("被 /me/* 兼容的旧路径必须继续保留(管理端与既有测试依赖)") + void legacyRoutesKept() { + Set r = routes(); + + assertRoute(r, "GET", "/profile/me"); + assertRoute(r, "PUT", "/profile/me"); + assertRoute(r, "GET", "/profile/preferences"); + assertRoute(r, "GET", "/profile/privacy"); + assertRoute(r, "GET", "/photos"); + assertRoute(r, "POST", "/photos/upload-token"); + assertRoute(r, "GET", "/relationship/status"); + assertRoute(r, "PUT", "/relationship/pause"); + assertRoute(r, "PUT", "/relationship/resume"); + assertRoute(r, "PUT", "/relationship/getting-to-know"); + assertRoute(r, "GET", "/risk/reports/my"); + assertRoute(r, "GET", "/appeals/my"); + assertRoute(r, "GET", "/account/privacy-settings"); + } +} diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index c6fce99..18cfd76 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -3,7 +3,9 @@ > V1.0 Phase 01~26 基础 + V1.1 增量任务合并基线 > 版本:v1.1.0-dev | 最后更新:2026-09-08 -> **2026-09-13 状态校正:** 本文件中的既有 `✅` 是历史开发声明,不再单独作为验收证据。Phase 01~06 已由恢复版 Completion Report 重新举证;Phase 07 仅保留业务测试证据,管理端与小程序视觉已被用户否决并重新打开。Phase 08 暂停,当前先执行 UI 清理与参考图重设计。 +> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 +> +> **2026-09-13 状态校正(历史):** 本文件中的既有 `✅` 是历史开发声明,不再单独作为验收证据。Phase 01~06 已由恢复版 Completion Report 重新举证;Phase 07 仅保留业务测试证据,管理端与小程序视觉已被用户否决并重新打开。Phase 08 暂停,当前先执行 UI 清理与参考图重设计。 | 当前执行项 | 状态 | 依据 | |---|---|---| @@ -13,11 +15,11 @@ | Phase 04 Profile、Preference 与本地资格 | 已完成 | 见 `Phase-04-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 05 尚未获准 | | Phase 05 统一审核扩展 | 已完成 | 见 `Phase-05-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 06 尚未开始 | | Phase 06 Match Eligibility 增量 | 已完成 | 见 `Phase-06-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 07 尚未开始 | -| Phase 07 V1.1 首页唯一实现 + 找对象 | REOPENED:视觉否决 | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 按 2026-09-13 新规格重新实现 | -| UI-CLEANUP-20260913 | 进行中 | 清理废弃 UI 代码、冲突规范、旧计划和无效验收证据 | -| UI-ADMIN-20260913 | 待开始 | 清理门禁后重构管理端全局框架与工作台 | -| UI-MINIAPP-20260913 | 待开始 | 管理端验收后重构小程序 11 个核心页面 | -| V1.1 UAT / RC / Production | NO-GO | 构建、权限、E2E 和页面闭环尚未通过 | +| Phase 07 V1.1 首页唯一实现 + 找对象 | 已完成:视觉已通过用户验收(2026-09-16) | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 已按 2026-09-13 新规格重新实现,11 页经用户验收通过 | +| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | +| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | +| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | +| V1.1 UAT / RC / Production | 进行中 | UI 视觉验收已通过;剩余前端调用与后端契约收敛(`/me/*` 路径族约 15 个端点)与页面闭环 | --- diff --git a/docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md b/docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md index 616219d..bede5b7 100644 --- a/docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md +++ b/docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md @@ -1,10 +1,11 @@ # Phase 07 Completion Report -> **2026-09-13 状态更正:视觉结论已失效。** 用户已否决本报告中的管理端与小程序页面设计结果。下述后端首页聚合、Capability、8 入口、TypeScript 迁移和业务测试证据继续保留;旧截图、旧设计契约及“视觉通过”表述不得作为当前验收依据。 +> **2026-09-16 状态更新:视觉已重新实现并通过用户验收。** 本报告 2026-09-13 记录的**视觉结论已失效**是当时的历史事实——管理端与小程序页面设计结果被用户否决。此后按 2026-09-13 参考图驱动规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**。下述后端首页聚合、Capability、8 入口、TypeScript 迁移和业务测试证据自始有效并继续沿用。 > 阶段:V1.1 首页唯一实现 + 找对象(REPLACE) > 日期:2026-09-13 -> 结论:业务专项证据保留;UI REOPENED;项目整体继续 NO-GO +> 结论(2026-09-13):业务专项证据保留;UI REOPENED;项目整体继续 NO-GO +> 结论修订(2026-09-16):UI 已按参考图驱动规格重新实现,首批 11 页通过用户验收;UI 条目关闭 ## 完成范围 diff --git a/docs/STATUS.md b/docs/STATUS.md index 4906e1d..845c086 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,32 +1,32 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-14 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN READY FOR USER ACCEPTANCE / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、13/13 契约全绿、核心 11 页面中 9 个 TS-only。后端全量测试债务(本地无 JDK)+ matchmaker/messages/mine 三页 JS-only 迁移债务已登记。UI 重设计首批 11 页已达用户验收状态。 +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(299 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。 -最后更新: 2026-09-14 — 分支 `codex/ui-v11-fidelity` +最后更新: 2026-09-16 — 分支 `codex/ui-v11-fidelity` ## 当前唯一有效状态 | 当前有效项 | 状态 | 证据 | |---|---|---| | Phase 01~06 Recovery | 已完成 | 各 Phase Completion Report;38+9+3+4+21 后端专项通过 | -| Phase 07 V1.1 首页业务与找对象 | **业务+UI 已交付** | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮) | +| Phase 07 V1.1 首页业务与找对象 | **业务+UI 已通过用户验收**(2026-09-16) | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮);UI 按 2026-09-13 规格重新实现,11 页经用户验收通过 | | 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 | | 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 | | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | | Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS | -| Backend 全量回归 | **已清偿**(2026-09-15) | 离线容器(`danyang-match-backend-test`)运行:**297 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含新增 `CanonicalRouteContractTest` 与 `ChatContractJsonTest` | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**299 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest` 与新增 `MeRouteContractTest` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | -| `/me/*` 路径族 | **待裁决** | 小程序按 `/me/*` 编写,后端实为 `/profile/*`、`/photos`、`/relationship/*`;影响资料/照片/择偶/隐私/关系状态/申诉等约 15 个端点,收敛方向未定,见 `docs/api/API_INVENTORY.md` 顶部警告 | +| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | -| Miniapp | **验证通过** | 2026-09-14 阶段 4:tsc ✅;11 核心页中 9 TS-only(index/discover/chat/local-eligibility/counseling-intro/premarital-intro/content/anti-fraud/service-station)、3 JS-only 待迁(matchmaker/messages/mine);discover 筛选重设计已通过 DevTools 验证 | -| UI 契约测试 | **13/13 通过** | 2026-09-14 阶段 4 运行:全部退出码 0;含 capability 独立化适配 | +| Miniapp | **验证通过** | 2026-09-16:tsc ✅;**11 核心页全部 TS-only(11/11)**;首批 11 页于 2026-09-16 通过用户验收;discover 筛选重设计已通过 DevTools 验证 | +| UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 | | Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 | -| 核心待清债务 | 3 项 | (1) matchmaker/messages/mine 3 页 JS→TS;(2) backend 277 tests 回归;(3) DevTools 真机截图留档 | -| UAT / RC / Production | **Ready for User Acceptance** | 双端构建/契约/源码全部通过;待用户真机验收后可推进 Phase 08+ | +| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(299 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16) | +| UAT / RC / Production | **UI 验收通过,前后端契约已收敛** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛,可推进 Phase 08+ | --- diff --git a/docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md b/docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md index 4381fe1..989e5ee 100644 --- a/docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md +++ b/docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md @@ -8,27 +8,29 @@ | 页面/区域 | 路由 | 数据与权限 | 必备状态 | 当前状态 | |---|---|---|---|---| -| 全局背景与系统容器 | 全部登录后路由 | 已登录 | 桌面两种目标分辨率 | 待开始 | -| 深色侧栏与顶部栏 | 全部登录后路由 | 现有路由权限 | 展开、收起、搜索反馈、通知反馈、退出 | 待开始 | -| 工作台 | `/dashboard` | 授权统计与活动 API | 加载、空、错误、无权限、重试 | 待开始 | +| 全局背景与系统容器 | 全部登录后路由 | 已登录 | 桌面两种目标分辨率 | 已实现(待桌面端目视复验) | +| 深色侧栏与顶部栏 | 全部登录后路由 | 现有路由权限 | 展开、收起、搜索反馈、通知反馈、退出 | 已实现(待桌面端目视复验) | +| 工作台 | `/dashboard` | 授权统计与活动 API | 加载、空、错误、无权限、重试 | 已实现(待桌面端目视复验) | 管理端其他 18 条现有业务路由为后续批次。本批只要求它们继续在新框架中可进入且权限行为不退化。 ## 小程序首批 11 个核心页面 +> **2026-09-16:本批 11 页全部交付并通过用户验收(结论:基本达标)。** 11/11 页为 TS-only 单一运行源,`ui-tests/miniapp-reference-contracts.mjs` 与 `ui-tests/phase07-home-contracts.mjs` 锁定契约。 + | 分组 | 页面 | 路由 | 权限/能力 | 必备状态 | 当前状态 | |---|---|---|---|---|---| -| A | 首页 | `/pages/index/index` | Home Capability | 未登录、准入中、加载、空、错误、暂停、成功牵手 | 待重新实现 | -| A | 找对象 | `/pages/discover/discover` | Match Capability | 加载、空、错误、筛选、Required、Blocked | 待重新实现 | -| A | 消息 | `/pages/messages/messages` | Mutual Match | 加载、空、错误、未匹配、Blocked、未读 | 待开始 | -| A | 公益活动 | `/pages/activity/activity` | Activity Capability | 加载、空、错误、可报名、已报名、截止 | 待开始 | -| A | 我的 | `/pages/mine/mine` | 登录状态 | 未登录、加载、错误、资料状态 | 待开始 | -| B | 公益红娘 | `/pages/matchmaker/matchmaker` | Matchmaker Capability | 加载、未申请、处理中、服务中、完成、错误 | 待开始 | -| B | 婚恋咨询 | `/pages/counseling-intro/counseling-intro` | Counseling Capability | 加载、不可用、可预约、已有服务、错误 | 待开始 | -| B | 婚前辅导 | `/pages/premarital-intro/premarital-intro` | Premarital Capability | 加载、不可用、可预约、已有服务、错误 | 待开始 | -| C | 婚恋课堂 | `/pages/content/content` | Content Capability | 加载、空、错误、分类、加载更多 | 待开始 | -| C | 婚恋反诈 | `/pages/anti-fraud/anti-fraud` | Anti-fraud Capability | 加载、空、错误、举报与求助 | 待开始 | -| C | 善缘服务站 | `/pages/service-station/service-station` | Service Location Capability | 加载、空、错误、电话、定位、预约 | 待开始 | +| A | 首页 | `/pages/index/index` | Home Capability | 未登录、准入中、加载、空、错误、暂停、成功牵手 | 已交付(2026-09-16 验收通过) | +| A | 找对象 | `/pages/discover/discover` | Match Capability | 加载、空、错误、筛选、Required、Blocked | 已交付(2026-09-16 验收通过) | +| A | 消息 | `/pages/messages/messages` | Mutual Match | 加载、空、错误、未匹配、Blocked、未读 | 已交付(2026-09-16 验收通过) | +| A | 公益活动 | `/pages/activity/activity` | Activity Capability | 加载、空、错误、可报名、已报名、截止 | 已交付(2026-09-16 验收通过) | +| A | 我的 | `/pages/mine/mine` | 登录状态 | 未登录、加载、错误、资料状态 | 已交付(2026-09-16 验收通过) | +| B | 公益红娘 | `/pages/matchmaker/matchmaker` | Matchmaker Capability | 加载、未申请、处理中、服务中、完成、错误 | 已交付(2026-09-16 验收通过) | +| B | 婚恋咨询 | `/pages/counseling-intro/counseling-intro` | Counseling Capability | 加载、不可用、可预约、已有服务、错误 | 已交付(2026-09-16 验收通过) | +| B | 婚前辅导 | `/pages/premarital-intro/premarital-intro` | Premarital Capability | 加载、不可用、可预约、已有服务、错误 | 已交付(2026-09-16 验收通过) | +| C | 婚恋课堂 | `/pages/content/content` | Content Capability | 加载、空、错误、分类、加载更多 | 已交付(2026-09-16 验收通过) | +| C | 婚恋反诈 | `/pages/anti-fraud/anti-fraud` | Anti-fraud Capability | 加载、空、错误、举报与求助 | 已交付(2026-09-16 验收通过) | +| C | 善缘服务站 | `/pages/service-station/service-station` | Service Location Capability | 加载、空、错误、电话、定位、预约 | 已交付(2026-09-16 验收通过) | ## 固定导航与首页入口 diff --git a/docs/api/API_INVENTORY.md b/docs/api/API_INVENTORY.md index 69cae5a..77932da 100644 --- a/docs/api/API_INVENTORY.md +++ b/docs/api/API_INVENTORY.md @@ -2,17 +2,22 @@ ## 用户端 /api/v1/ -> ⚠️ **待收敛:`/me/*` 系列** +> ✅ **已收敛(2026-09-16):后端补齐 `/me/*` 命名空间** > -> 本清单的 `/me/*` 个人资源路径(用户资料、照片、择偶条件、隐私设置、关系状态、我的活动/举报/申诉等) -> 与后端**已实现路径不一致**。后端实际前缀为: -> `/profile/me`、`/profile/preferences`、`/profile/privacy`、`/profile/interests`、 -> `/photos`、`/relationship/status|pause|resume|getting-to-know|exit`、`/appeals`、`/reports`。 -> 小程序 `services/*.js` 当前按本清单的 `/me/*` 编写,调用会 404。 +> 小程序 `services/*.js` 按 `REST API 接口详细设计规范.md`(§16 / §22-§40 / §80 / §83-§84) +> 统一调用 `/me/*`,但后端此前**没有 `/me` 基路径**,个人资料、照片、择偶条件、隐私设置、 +> 关系状态、我的举报/申诉等约 17 个调用全部 404。 +> +> 现由 `MeController`(`backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java`) +> 把既有 service 能力暴露到规范路径(纯委托,不复制业务规则),本清单的 `/me/*` 路径现已可用。 +> 旧路径(`/profile/me`、`/profile/preferences`、`/profile/privacy`、`/photos`、 +> `/relationship/*`、`/account/privacy-settings`、`/risk/reports/my`、`/appeals/my`)**全部保留**, +> 管理端与既有测试不受影响。 +> 小程序历史上自行发明的三条路径(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`) +> 也一并提供,使前端零改动可用。 +> 路由由 `backend/.../contract/MeRouteContractTest.java` 锁定。 > > 关系 / 匹配 / 缘分 三个域已于 2026-09-15 按「采用 2026-09-14 最新路径」的裁决收敛完成(见下方「关系」「我的缘分」「匹配」)。 -> `/me/*` 系列的收敛方向尚未裁决,**改动前请勿直接照抄本清单中的 `/me/*` 路径**。 -> 路由裁决已被 `backend/.../contract/CanonicalRouteContractTest.java` 锁定。 ### 认证 | Method | Path | 描述 | @@ -28,13 +33,24 @@ |---|---|---| | GET | /me/profile | 获取本人资料 | | PUT | /me/profile | 更新本人资料 | +| PATCH | /me/profile/basic | 更新基础资料(分节写) | +| GET | /me/profile/completion | 获取资料完整度 | | GET | /me/preferences | 获取择偶条件 | | PUT | /me/preferences | 更新择偶条件 | | GET | /me/photos | 获取照片列表 | | POST | /me/photos/upload-token | 获取上传凭证 | -| GET | /me/privacy-settings | 获取隐私设置 | -| PUT | /me/privacy-settings | 更新隐私设置 | -| GET | /me/review-status | 获取审核状态 | +| DELETE | /me/photos/{photoId} | 删除照片 | +| PUT | /me/photos/{photoId}/main | 设为主头像(小程序约定,规范未定义此路径) | +| GET | /me/privacy | 获取隐私设置(规范路径) | +| PATCH | /me/privacy | 更新隐私设置(规范路径) | +| GET | /me/privacy-settings | 获取隐私设置(小程序写法,兼容保留) | +| PUT | /me/privacy-settings | 更新隐私设置(小程序写法,兼容保留) | +| GET | /me/profile/review-status | 获取审核状态(别名 `/me/review-status`) | +| POST | /me/profile/submit-review | 提交资料审核 | +| GET | /me/relationship-status | 获取婚恋关系状态(返回 `status`,非 `relationshipStatus`) | +| PATCH | /me/relationship-status | 更新婚恋关系状态(`{status: paused\|searching\|getting_to_know}`) | +| GET | /me/reports | 我的举报列表 | +| GET | /me/appeals | 我的申诉列表(规范只定义 `POST /appeals`) | ### 实名认证 | Method | Path | 描述 | diff --git a/ui-tests/ui-document-baseline-contracts.mjs b/ui-tests/ui-document-baseline-contracts.mjs index ad98a68..8a5881a 100644 --- a/ui-tests/ui-document-baseline-contracts.mjs +++ b/ui-tests/ui-document-baseline-contracts.mjs @@ -47,10 +47,24 @@ const backlog = read('docs/JIRA_BACKLOG.md') const phase07 = read('docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md') const matrix = read('docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md') -assert.match(status, /Phase 07[^\n]*(视觉.*否决|视觉待重新实现|UI 待重新实现|重设计中)/, 'STATUS 未标记 Phase 07 视觉失效') -assert.match(backlog, /Phase 07[^\n]*(视觉否决|重新打开|REOPENED)/, 'JIRA 未重新打开 Phase 07 UI') -assert.match(phase07, /视觉结论已失效/, 'Phase 07 报告未保留业务证据并撤销视觉结论') +// —— 文档基线口径 —— +// UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)。 +// 四份文档必须一致记录该结论。若有人把口径改回"视觉否决 / 待重新实现 / REOPENED", +// 本门禁必须变红 —— 这是防止验收状态被静默回退的哨兵。 +assert.match(status, /UI REDESIGN ACCEPTED BY USER/, 'STATUS 头部未记录 UI 已通过用户验收') +assert.match(status, /Phase 07[^\n]*已通过用户验收/, 'STATUS 未标记 Phase 07 UI 验收通过') + +// JIRA 表格行必须已关闭;主表中的 REOPENED 不得复活(历史校正段落中的引用不算)。 +assert.match(backlog, /^\|\s*Phase 07[^\n]*已通过用户验收/m, 'JIRA 未关闭 Phase 07 UI 条目') +assert.doesNotMatch(backlog, /^\|\s*Phase 07[^\n]*REOPENED/m, 'JIRA 主表仍将 Phase 07 UI 标记为 REOPENED') + +// Phase 07 报告是历史文档:既要保留 2026-09-13 的失效判定,也要记录重新实现后的验收结论。 +assert.match(phase07, /视觉结论已失效/, 'Phase 07 报告未保留 2026-09-13 历史判定') +assert.match(phase07, /首批 11 页于 2026-09-16 通过用户验收/, 'Phase 07 报告未记录重新实现后的验收结论') + assert.match(matrix, /小程序首批 11 个核心页面/, '页面矩阵未切换到当前首批范围') +assert.match(matrix, /2026-09-16 验收通过/, '页面矩阵未记录 11 页验收结论') +assert.doesNotMatch(matrix, /待重新实现|待开始/, '页面矩阵仍残留未完成状态') assert.doesNotMatch(matrix, /63 个页面.*(全部|全量).*重构/, '页面矩阵仍要求本批全量重构') console.log('UI document baseline contracts passed')