()
+ .eq(HomeBanner::getId, id)
+ .isNull(HomeBanner::getDeletedAt));
+ }
+
+ /**
+ * Banner 校验:类型/受众/状态白名单 + 禁外链 + 排期与长度约束。
+ *
+ * 拒绝而非静默改写:运营误配外链会把用户导向站外,必须显式报错。
+ * 所有失败统一以 HTTP 400 返回,避免非法值打到生产 CHECK 才以 500 暴露。
+ */
+ private void validate(HomeBanner banner) {
+ String title = banner.getTitle();
+ if (title == null || title.isBlank()) {
+ reject("Banner 标题不能为空");
+ }
+ if (title.length() > TITLE_MAX_LENGTH) {
+ reject("Banner 标题长度不能超过 " + TITLE_MAX_LENGTH + " 字");
+ }
+ if (banner.getSubtitle() != null && banner.getSubtitle().length() > SUBTITLE_MAX_LENGTH) {
+ reject("Banner 副标题长度不能超过 " + SUBTITLE_MAX_LENGTH + " 字");
+ }
+ if (banner.getButtonText() != null && banner.getButtonText().length() > BUTTON_TEXT_MAX_LENGTH) {
+ reject("Banner 按钮文案长度不能超过 " + BUTTON_TEXT_MAX_LENGTH + " 字");
+ }
+
+ String targetType = banner.getTargetType();
+ if (targetType == null || targetType.isBlank()) {
+ banner.setTargetType("none");
+ targetType = "none";
+ }
+ if (!ALLOWED_TARGET_TYPES.contains(targetType)) {
+ reject("不支持的 Banner 跳转类型:" + targetType);
+ }
+
+ String targetValue = banner.getTargetValue();
+ if (targetValue != null) {
+ String value = targetValue.trim();
+ if (value.length() > TARGET_VALUE_MAX_LENGTH) {
+ reject("Banner 目标值长度不能超过 " + TARGET_VALUE_MAX_LENGTH + " 字");
+ }
+ String normalized = value.toLowerCase();
+ if (normalized.startsWith("http://") || normalized.startsWith("https://")
+ || normalized.startsWith("//") || normalized.startsWith("javascript:")
+ || normalized.startsWith("data:text/html")) {
+ reject("Banner 不允许跳转站外地址(禁止外链)");
+ }
+ }
+ // 需要精确站内落点的类型必须给出目标值;其余类型允许跳转到对应列表页(与 V027 生产种子一致)
+ if (NEEDS_EXPLICIT_TARGET.contains(targetType) && (targetValue == null || targetValue.isBlank())) {
+ reject("跳转类型为「" + targetType + "」时必须填写站内目标值");
+ }
+
+ String audienceScope = banner.getAudienceScope();
+ if (audienceScope == null || audienceScope.isBlank()) {
+ banner.setAudienceScope("all");
+ } else if (!ALLOWED_AUDIENCE_SCOPES.contains(audienceScope)) {
+ reject("不支持的 Banner 展示范围:" + audienceScope);
+ }
+
+ String status = banner.getStatus();
+ if (status != null && !status.isBlank() && !ALLOWED_STATUSES.contains(status)) {
+ reject("不支持的 Banner 状态:" + status);
+ }
+
+ Integer sortOrder = banner.getSortOrder();
+ if (sortOrder != null && sortOrder < 0) {
+ reject("Banner 排序值不能为负数");
+ }
+
+ Instant startAt = banner.getStartAt();
+ Instant endAt = banner.getEndAt();
+ if (startAt != null && endAt != null && startAt.isAfter(endAt)) {
+ reject("Banner 展示开始时间不能晚于结束时间");
+ }
+ }
+
+ private void reject(String message) {
+ throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
}
}
diff --git a/backend/src/main/java/com/danyang/match/platform/banner/mapper/HomeBannerMapper.java b/backend/src/main/java/com/danyang/match/platform/banner/mapper/HomeBannerMapper.java
index 8ddd2de..849e717 100644
--- a/backend/src/main/java/com/danyang/match/platform/banner/mapper/HomeBannerMapper.java
+++ b/backend/src/main/java/com/danyang/match/platform/banner/mapper/HomeBannerMapper.java
@@ -13,10 +13,15 @@ public interface HomeBannerMapper extends BaseMapper {
/**
* 查询当前应展示的Banner列表(按时间+状态+受众过滤)
* 由调用方根据用户状态二次过滤audience_scope
+ *
+ * Phase 19:补 {@code deleted_at IS NULL}。此前两条查询都不过滤软删行,
+ * 已下架(软删)的 Banner 仍会出现在小程序首页——与 Phase 18 修复的
+ * appeal 软删过滤缺口同族。
*/
@Select("""
SELECT * FROM home_banners
- WHERE status IN ('active', 'scheduled')
+ WHERE deleted_at IS NULL
+ AND status IN ('active', 'scheduled')
AND (start_at IS NULL OR start_at <= NOW())
AND (end_at IS NULL OR end_at > NOW())
ORDER BY sort_order ASC
@@ -24,11 +29,12 @@ public interface HomeBannerMapper extends BaseMapper {
List findActiveBanners();
/**
- * 按受众范围查询Banner
+ * 按受众范围查询Banner(同样排除软删行)
*/
@Select("""
SELECT * FROM home_banners
- WHERE status IN ('active', 'scheduled')
+ WHERE deleted_at IS NULL
+ AND status IN ('active', 'scheduled')
AND (start_at IS NULL OR start_at <= NOW())
AND (end_at IS NULL OR end_at > NOW())
AND (audience_scope = 'all' OR audience_scope = #{scope})
diff --git a/backend/src/main/java/com/danyang/match/platform/content/controller/ContentController.java b/backend/src/main/java/com/danyang/match/platform/content/controller/ContentController.java
index 0309d89..5059aac 100644
--- a/backend/src/main/java/com/danyang/match/platform/content/controller/ContentController.java
+++ b/backend/src/main/java/com/danyang/match/platform/content/controller/ContentController.java
@@ -36,6 +36,48 @@ public class ContentController {
}
// ---- Admin ----
+
+ /**
+ * 运营后台分类列表(含已停用分类)。
+ *
+ * 公开接口 {@code GET /content/categories} 只给 active,运营侧若复用它,
+ * 停用过的分类就再也看不到、无法重新启用。
+ */
+ @PreAuthorize("hasAuthority('content.view')")
+ @GetMapping("/operator/categories")
+ public ApiResponse> getOperatorCategories() {
+ return ApiResponse.ok(contentService.getOperatorCategories());
+ }
+
+ /**
+ * 运营后台文章列表(全部状态,含草稿)。
+ *
+ * 公开接口只返回 published,草稿不会出现在列表里 → 后台「发布」按钮永远点不到。
+ */
+ @PreAuthorize("hasAuthority('content.view')")
+ @GetMapping("/operator/articles")
+ public ApiResponse> getOperatorArticles() {
+ return ApiResponse.ok(contentService.getOperatorArticles());
+ }
+
+ /** 运营后台文章详情:不限状态且带正文,供编辑弹窗回填。 */
+ @PreAuthorize("hasAuthority('content.view')")
+ @GetMapping("/operator/articles/{id}")
+ public ApiResponse getOperatorArticleDetail(@PathVariable String id) {
+ return ApiResponse.ok(contentService.getOperatorArticleDetail(id));
+ }
+
+ /** 启用/停用分类:物理删除会破坏历史文章的分类外键,停用才是正确语义。 */
+ @PreAuthorize("hasAuthority('content.edit')")
+ @PutMapping("/operator/categories/{id}/status")
+ public ApiResponse setCategoryStatus(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @PathVariable String id, @RequestBody Map body) {
+ Object status = body.get("status");
+ return ApiResponse.ok(contentService.setCategoryStatus(
+ id, status == null ? null : String.valueOf(status), principal.id()));
+ }
+
@PreAuthorize("hasAuthority('content.create')")
@PostMapping("/operator/categories")
public ApiResponse createCategory(
diff --git a/backend/src/main/java/com/danyang/match/platform/content/dto/ContentArticleDTO.java b/backend/src/main/java/com/danyang/match/platform/content/dto/ContentArticleDTO.java
index bbd9d48..bff4b72 100644
--- a/backend/src/main/java/com/danyang/match/platform/content/dto/ContentArticleDTO.java
+++ b/backend/src/main/java/com/danyang/match/platform/content/dto/ContentArticleDTO.java
@@ -1,11 +1,25 @@
package com.danyang.match.platform.content.dto;
+
import lombok.Data;
+
import java.time.Instant;
+
@Data
public class ContentArticleDTO {
- private String id; private String categoryId; private String title;
- private String summary; private String coverFileId;
- private String status; private Instant publishAt;
- private Integer viewCount; private Integer sortOrder;
+ private String id;
+ private String categoryId;
+ private String title;
+ private String summary;
+ private String coverFileId;
+ private String status;
+ private Instant publishAt;
+ private Integer viewCount;
+ private Integer sortOrder;
private Instant createdAt;
+
+ /**
+ * 正文。**仅详情接口填充**(列表保持轻量,避免把全部正文塞进列表响应)。
+ * Phase 19 修复:此前 DTO 无该字段,导致 U54 内容详情页拿不到正文(写入却读不出)。
+ */
+ private String content;
}
diff --git a/backend/src/main/java/com/danyang/match/platform/content/service/ContentService.java b/backend/src/main/java/com/danyang/match/platform/content/service/ContentService.java
index 27fc1f5..f1369ae 100644
--- a/backend/src/main/java/com/danyang/match/platform/content/service/ContentService.java
+++ b/backend/src/main/java/com/danyang/match/platform/content/service/ContentService.java
@@ -8,28 +8,75 @@ import com.danyang.match.platform.content.entity.ContentCategory;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.content.mapper.ContentCategoryMapper;
import com.danyang.match.platform.shared.constant.Constants;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
-import java.util.*;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+/**
+ * 公益内容服务(分类 + 文章)。
+ *
+ * Phase 19 修复的运营后台缺陷(均为"历史 ✅ 不可信"范畴):
+ *
+ * 「发布」是死路 :后台文章列表过去复用公开接口 {@code GET /content/articles},
+ * 而该接口只返回 {@code published};新建文章恒为 {@code draft} → 列表里看不到 →
+ * 后台「发布」按钮永远不出现。现补 {@link #getOperatorArticles()} 供运营侧使用,
+ * 公开接口保持"仅已发布",避免草稿/下架内容泄露到小程序。
+ * 分类管理看不到已停用分类 :{@code getAllCategories()} 只返回 {@code active},
+ * 停用后无法在后台重新启用。补 {@link #getOperatorCategories()}。
+ * 编辑文章看不到正文 :列表 DTO 不含 {@code content}(有意保持轻量),
+ * 后台编辑弹窗因此回填不到正文。补 {@link #getOperatorArticleDetail}。
+ * 约束漂移盲区 :分类 {@code status} / 文章 {@code status} 从不校验,
+ * 非法值只在 PostgreSQL 撞 {@code chk_category_status} / {@code chk_article_status} 才 500;
+ * 另 {@code content_categories.code} 生产有 {@code UNIQUE(code)},重复编码同样 500。
+ * 现全部前置校验并返回 400。
+ * 软删过滤缺口 :{@code content_categories} 无 {@code @TableLogic},
+ * {@code selectList} 会把软删分类当成正常数据返回。
+ * 错误语义 :裸 {@code RuntimeException} 落到兜底 handler 返回 500,
+ * 统一改为 {@code VALIDATION_ERROR}(400) / {@code NOT_FOUND}(404)。
+ *
+ */
@Slf4j
@Service
@RequiredArgsConstructor
public class ContentService {
+ /** 对齐生产 V026 chk_category_status。 */
+ private static final Set ALLOWED_CATEGORY_STATUSES = Set.of(
+ Constants.CATEGORY_STATUS_ACTIVE, Constants.CATEGORY_STATUS_INACTIVE);
+
+ /** 对齐生产 V026 chk_article_status(scheduled 由排期流转,不在此处手工设置)。 */
+ private static final Set ALLOWED_ARTICLE_STATUSES = Set.of(
+ Constants.ARTICLE_STATUS_DRAFT, Constants.ARTICLE_STATUS_SCHEDULED,
+ Constants.ARTICLE_STATUS_PUBLISHED, Constants.ARTICLE_STATUS_OFFLINE);
+
+ private static final int TITLE_MAX_LENGTH = 200;
+ private static final int SUMMARY_MAX_LENGTH = 500;
+ private static final int CATEGORY_NAME_MAX_LENGTH = 128;
+ private static final int CATEGORY_CODE_MAX_LENGTH = 64;
+
private final ContentCategoryMapper categoryMapper;
private final ContentArticleMapper articleMapper;
+ // ==================== 公开(小程序)读取 ====================
+
+ /** 小程序 U53:只返回启用中的分类(含软删过滤)。 */
public List getAllCategories() {
- return categoryMapper.selectList(
- new LambdaQueryWrapper().eq(ContentCategory::getStatus, "active")
- ).stream().map(this::toCategoryDTO).toList();
+ return categoryMapper.selectList(new LambdaQueryWrapper()
+ .eq(ContentCategory::getStatus, Constants.CATEGORY_STATUS_ACTIVE)
+ .isNull(ContentCategory::getDeletedAt)
+ .orderByAsc(ContentCategory::getSortOrder))
+ .stream().map(this::toCategoryDTO).toList();
}
+ /** 小程序 U53:只返回已发布文章(草稿/下架不得外泄)。 */
public List getPublishedArticles(String categoryId) {
List articles;
if (categoryId != null && !categoryId.isBlank()) {
@@ -40,27 +87,86 @@ public class ContentService {
return articles.stream().map(this::toArticleDTO).toList();
}
+ /** 小程序 U54:仅已发布文章可被公开阅读,同时累计阅读量。 */
public ContentArticleDTO getArticleDetail(String articleId) {
ContentArticle article = articleMapper.selectById(articleId);
- if (article == null || !"published".equals(article.getStatus())) return null;
- // 增加阅读量
+ if (article == null || article.getDeletedAt() != null
+ || !Constants.ARTICLE_STATUS_PUBLISHED.equals(article.getStatus())) {
+ return null;
+ }
article.setViewCount((article.getViewCount() != null ? article.getViewCount() : 0) + 1);
articleMapper.updateById(article);
- return toArticleDTO(article);
+ // 详情必须带正文(列表才保持轻量);否则 U54 内容详情页无内容可显示
+ ContentArticleDTO dto = toArticleDTO(article);
+ dto.setContent(article.getContent());
+ return dto;
}
+ // ==================== 运营后台读取 ====================
+
+ /**
+ * 运营后台分类列表:**包含已停用分类**,否则无法在后台重新启用。
+ */
+ public List getOperatorCategories() {
+ return categoryMapper.selectList(new LambdaQueryWrapper()
+ .isNull(ContentCategory::getDeletedAt)
+ .orderByAsc(ContentCategory::getSortOrder))
+ .stream().map(this::toCategoryDTO).toList();
+ }
+
+ /**
+ * 运营后台文章列表:**包含全部状态**(draft/scheduled/published/offline)。
+ *
+ * 这是"发布/下架"能工作的前提——公开接口只给 published,草稿根本不出现在列表里。
+ */
+ public List getOperatorArticles() {
+ return articleMapper.selectList(new LambdaQueryWrapper()
+ .isNull(ContentArticle::getDeletedAt)
+ .orderByDesc(ContentArticle::getCreatedAt))
+ .stream().map(this::toArticleDTO).toList();
+ }
+
+ /**
+ * 运营后台文章详情:不限状态,且**带正文**,供编辑弹窗回填。
+ */
+ public ContentArticleDTO getOperatorArticleDetail(String articleId) {
+ ContentArticle article = articleMapper.selectById(articleId);
+ if (article == null || article.getDeletedAt() != null) {
+ throw notFound("文章不存在");
+ }
+ ContentArticleDTO dto = toArticleDTO(article);
+ dto.setContent(article.getContent());
+ return dto;
+ }
+
+ // ==================== 运营后台写入 ====================
+
@Transactional
public ContentArticleDTO createArticle(Map body, String operatorId) {
+ String title = trimToNull(body.get("title"));
+ if (title == null) {
+ throw invalid("文章标题不能为空");
+ }
+ if (title.length() > TITLE_MAX_LENGTH) {
+ throw invalid("文章标题长度不能超过 " + TITLE_MAX_LENGTH + " 字");
+ }
+ String summary = trimToNull(body.get("summary"));
+ if (summary != null && summary.length() > SUMMARY_MAX_LENGTH) {
+ throw invalid("文章摘要长度不能超过 " + SUMMARY_MAX_LENGTH + " 字");
+ }
+ String categoryId = trimToNull(body.get("categoryId"));
+ assertCategoryExists(categoryId);
+
ContentArticle article = new ContentArticle();
article.setId(java.util.UUID.randomUUID().toString());
- article.setCategoryId((String) body.get("categoryId"));
- article.setTitle((String) body.get("title"));
- article.setSummary((String) body.get("summary"));
+ article.setCategoryId(categoryId);
+ article.setTitle(title);
+ article.setSummary(summary);
article.setContent((String) body.get("content"));
- article.setCoverFileId((String) body.get("coverFileId"));
+ article.setCoverFileId(trimToNull(body.get("coverFileId")));
article.setAuthorOperatorId(operatorId);
- article.setStatus("draft");
- article.setSortOrder(((Number) body.getOrDefault("sortOrder", 0)).intValue());
+ article.setStatus(Constants.ARTICLE_STATUS_DRAFT);
+ article.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
article.setViewCount(0);
article.setCreatedAt(Instant.now());
articleMapper.insert(article);
@@ -70,14 +176,39 @@ public class ContentService {
@Transactional
public ContentArticleDTO updateArticle(String articleId, Map body, String operatorId) {
- ContentArticle article = articleMapper.selectById(articleId);
- if (article == null) throw new RuntimeException("文章不存在");
- if (body.get("title") != null) article.setTitle((String) body.get("title"));
- if (body.get("summary") != null) article.setSummary((String) body.get("summary"));
- if (body.get("content") != null) article.setContent((String) body.get("content"));
- if (body.get("categoryId") != null) article.setCategoryId((String) body.get("categoryId"));
- if (body.get("coverFileId") != null) article.setCoverFileId((String) body.get("coverFileId"));
- if (body.get("sortOrder") != null) article.setSortOrder(((Number) body.get("sortOrder")).intValue());
+ ContentArticle article = requireArticle(articleId);
+
+ if (body.containsKey("title")) {
+ String title = trimToNull(body.get("title"));
+ if (title == null) {
+ throw invalid("文章标题不能为空");
+ }
+ if (title.length() > TITLE_MAX_LENGTH) {
+ throw invalid("文章标题长度不能超过 " + TITLE_MAX_LENGTH + " 字");
+ }
+ article.setTitle(title);
+ }
+ if (body.containsKey("summary")) {
+ String summary = trimToNull(body.get("summary"));
+ if (summary != null && summary.length() > SUMMARY_MAX_LENGTH) {
+ throw invalid("文章摘要长度不能超过 " + SUMMARY_MAX_LENGTH + " 字");
+ }
+ article.setSummary(summary);
+ }
+ if (body.containsKey("content")) {
+ article.setContent((String) body.get("content"));
+ }
+ if (body.containsKey("categoryId")) {
+ String categoryId = trimToNull(body.get("categoryId"));
+ assertCategoryExists(categoryId);
+ article.setCategoryId(categoryId);
+ }
+ if (body.containsKey("coverFileId")) {
+ article.setCoverFileId(trimToNull(body.get("coverFileId")));
+ }
+ if (body.containsKey("sortOrder")) {
+ article.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
+ }
article.setUpdatedAt(Instant.now());
articleMapper.updateById(article);
return toArticleDTO(article);
@@ -85,8 +216,7 @@ public class ContentService {
@Transactional
public ContentArticleDTO publishArticle(String articleId, String operatorId) {
- ContentArticle article = articleMapper.selectById(articleId);
- if (article == null) throw new RuntimeException("文章不存在");
+ ContentArticle article = requireArticle(articleId);
article.setStatus(Constants.ARTICLE_STATUS_PUBLISHED);
article.setPublishAt(Instant.now());
article.setUpdatedAt(Instant.now());
@@ -97,39 +227,152 @@ public class ContentService {
@Transactional
public ContentArticleDTO offlineArticle(String articleId, String operatorId) {
- ContentArticle article = articleMapper.selectById(articleId);
- if (article == null) throw new RuntimeException("文章不存在");
+ ContentArticle article = requireArticle(articleId);
article.setStatus(Constants.ARTICLE_STATUS_OFFLINE);
article.setUpdatedAt(Instant.now());
articleMapper.updateById(article);
+ log.info("下架文章: articleId={}, operatorId={}", articleId, operatorId);
return toArticleDTO(article);
}
@Transactional
public ContentCategoryDTO createCategory(Map body) {
+ String name = trimToNull(body.get("name"));
+ String code = trimToNull(body.get("code"));
+ assertCategoryNameAndCode(name, code, null);
+
ContentCategory category = new ContentCategory();
category.setId(java.util.UUID.randomUUID().toString());
- category.setName((String) body.get("name"));
- category.setCode((String) body.get("code"));
+ category.setName(name);
+ category.setCode(code);
category.setDescription((String) body.get("description"));
- category.setSortOrder(((Number) body.getOrDefault("sortOrder", 0)).intValue());
- category.setStatus("active");
+ category.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
+ category.setStatus(Constants.CATEGORY_STATUS_ACTIVE);
categoryMapper.insert(category);
return toCategoryDTO(category);
}
@Transactional
public ContentCategoryDTO updateCategory(String categoryId, Map body) {
- ContentCategory category = categoryMapper.selectById(categoryId);
- if (category == null) throw new RuntimeException("分类不存在");
- if (body.get("name") != null) category.setName((String) body.get("name"));
- if (body.get("description") != null) category.setDescription((String) body.get("description"));
- if (body.get("sortOrder") != null) category.setSortOrder(((Number) body.get("sortOrder")).intValue());
- if (body.get("status") != null) category.setStatus((String) body.get("status"));
+ ContentCategory category = requireCategory(categoryId);
+
+ String name = body.containsKey("name") ? trimToNull(body.get("name")) : category.getName();
+ String code = body.containsKey("code") ? trimToNull(body.get("code")) : category.getCode();
+ assertCategoryNameAndCode(name, code, categoryId);
+
+ category.setName(name);
+ category.setCode(code);
+ if (body.containsKey("description")) {
+ category.setDescription((String) body.get("description"));
+ }
+ if (body.containsKey("sortOrder")) {
+ category.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
+ }
+ category.setUpdatedAt(Instant.now());
categoryMapper.updateById(category);
return toCategoryDTO(category);
}
+ /**
+ * 启用/停用分类。
+ *
+ * 分类不做物理删除:{@code content_articles.category_id} 生产有外键引用,
+ * 删除会让历史文章的分类归属悬空(后台列表分类列变成 "-")。停用是正确语义。
+ */
+ @Transactional
+ public ContentCategoryDTO setCategoryStatus(String categoryId, String status, String operatorId) {
+ ContentCategory category = requireCategory(categoryId);
+ String normalized = trimToNull(status);
+ if (normalized == null || !ALLOWED_CATEGORY_STATUSES.contains(normalized)) {
+ throw invalid("不支持的分类状态:" + status);
+ }
+ category.setStatus(normalized);
+ category.setUpdatedAt(Instant.now());
+ categoryMapper.updateById(category);
+ log.info("分类状态变更: categoryId={}, status={}, operatorId={}", categoryId, normalized, operatorId);
+ return toCategoryDTO(category);
+ }
+
+ // ==================== 校验与转换 ====================
+
+ private void assertCategoryNameAndCode(String name, String code, String selfId) {
+ if (name == null) {
+ throw invalid("分类名称不能为空");
+ }
+ if (name.length() > CATEGORY_NAME_MAX_LENGTH) {
+ throw invalid("分类名称长度不能超过 " + CATEGORY_NAME_MAX_LENGTH + " 字");
+ }
+ if (code == null) {
+ throw invalid("分类编码不能为空");
+ }
+ if (code.length() > CATEGORY_CODE_MAX_LENGTH) {
+ throw invalid("分类编码长度不能超过 " + CATEGORY_CODE_MAX_LENGTH + " 字");
+ }
+ // 生产 content_categories 有 UNIQUE(code):不前置校验就会在 PG 上 500
+ ContentCategory existing = categoryMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ContentCategory::getCode, code)
+ .isNull(ContentCategory::getDeletedAt));
+ if (existing != null && !existing.getId().equals(selfId)) {
+ throw invalid("分类编码已存在:" + code);
+ }
+ }
+
+ /** 生产 content_articles.category_id 有外键:不存在的分类会在 PG 上 500。 */
+ private void assertCategoryExists(String categoryId) {
+ if (categoryId == null) {
+ return;
+ }
+ if (categoryMapper.selectById(categoryId) == null) {
+ throw invalid("所选内容分类不存在:" + categoryId);
+ }
+ }
+
+ private ContentArticle requireArticle(String articleId) {
+ ContentArticle article = articleMapper.selectById(articleId);
+ if (article == null || article.getDeletedAt() != null) {
+ throw notFound("文章不存在");
+ }
+ return article;
+ }
+
+ private ContentCategory requireCategory(String categoryId) {
+ ContentCategory category = categoryMapper.selectById(categoryId);
+ if (category == null || category.getDeletedAt() != null) {
+ throw notFound("分类不存在");
+ }
+ return category;
+ }
+
+ private String trimToNull(Object raw) {
+ if (raw == null) {
+ return null;
+ }
+ String value = String.valueOf(raw).trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private int intOrDefault(Object raw, int fallback) {
+ if (raw instanceof Number number) {
+ return number.intValue();
+ }
+ if (raw instanceof String text && !text.isBlank()) {
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException ignored) {
+ throw invalid("排序值必须为整数");
+ }
+ }
+ return fallback;
+ }
+
+ private GlobalExceptionHandler.BusinessException invalid(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
+ }
+
+ private GlobalExceptionHandler.BusinessException notFound(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, message);
+ }
+
private ContentCategoryDTO toCategoryDTO(ContentCategory c) {
ContentCategoryDTO dto = new ContentCategoryDTO();
dto.setId(c.getId());
@@ -156,4 +399,14 @@ public class ContentService {
dto.setCreatedAt(a.getCreatedAt());
return dto;
}
+
+ /** 保留:供契约测试/其他服务判断合法分类状态取值。 */
+ public static Set allowedCategoryStatuses() {
+ return ALLOWED_CATEGORY_STATUSES;
+ }
+
+ /** 保留:供契约测试/其他服务判断合法文章状态取值。 */
+ public static Set allowedArticleStatuses() {
+ return ALLOWED_ARTICLE_STATUSES;
+ }
}
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
index 5a5d627..4c78f55 100644
--- 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
@@ -66,6 +66,7 @@ public class MeController {
private final RelationshipStatusService relationshipStatusService;
private final ReportService reportService;
private final AppealService appealService;
+ private final com.danyang.match.platform.me.service.ServiceHistoryService serviceHistoryService;
// ==================== 账号状态 ====================
@@ -385,4 +386,20 @@ public class MeController {
public ApiResponse getMyAppeals(@AuthenticationPrincipal UserPrincipal principal) {
return ApiResponse.ok(appealService.getMyAppeals(principal.id()));
}
+
+ // ==================== 我的公益服务记录 ====================
+
+ /**
+ * 本人公益服务时间线(小程序 U64「公益服务记录」)。
+ * GET /api/v1/me/service-history
+ *
+ * 数据源为本人服务预约 + 本人婚恋咨询记录,按登录主体过滤(调用方只能传
+ * {@code principal.id()},无越权读取他人时间的路径)。
+ * INV-013 最小可见 :只返回类别/标题/状态/时间等摘要,
+ * 不含咨询诉求正文与运营内部备注——详见 {@code ServiceHistoryService}。
+ */
+ @GetMapping("/service-history")
+ public ApiResponse getMyServiceHistory(@AuthenticationPrincipal UserPrincipal principal) {
+ return ApiResponse.ok(serviceHistoryService.getMyServiceHistory(principal.id()));
+ }
}
diff --git a/backend/src/main/java/com/danyang/match/platform/me/service/ServiceHistoryService.java b/backend/src/main/java/com/danyang/match/platform/me/service/ServiceHistoryService.java
new file mode 100644
index 0000000..5b4259f
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/me/service/ServiceHistoryService.java
@@ -0,0 +1,112 @@
+package com.danyang.match.platform.me.service;
+
+import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
+import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
+import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
+import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
+import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 本人公益服务时间线(小程序 U64「公益服务记录」)。
+ *
+ * 最小可见(INV-013) :只返回当前用户自己的记录摘要——类别、标题、状态、时间。
+ * 绝不 返回咨询诉求正文({@code user_request_summary})、跟进正文({@code counseling_followups.content})
+ * 或运营内部备注。用户端不应看到任何内部字段。
+ *
+ *
归属 :两个数据源均按 {@code userId} 过滤({@code ServiceAppointmentMapper.findByUserId} /
+ * {@code CounselingServiceMapper.findByUserId},两者都已带 {@code deleted_at IS NULL}),
+ * 调用方只能传入登录主体,因此不存在越权读取他人时间线的路径。
+ */
+@Service
+@RequiredArgsConstructor
+public class ServiceHistoryService {
+
+ private final ServiceAppointmentMapper appointmentMapper;
+ private final CounselingServiceMapper counselingServiceMapper;
+ private final ServiceLocationMapper serviceLocationMapper;
+
+ public List> getMyServiceHistory(String userId) {
+ if (userId == null || userId.isBlank()) {
+ return List.of();
+ }
+ List> items = new ArrayList<>();
+
+ for (ServiceAppointment appointment : appointmentMapper.findByUserId(userId)) {
+ Map item = baseEntry(
+ appointment.getId(), "appointment", appointmentLabel(appointment.getServiceType()),
+ appointment.getStatus(), occurredAt(appointment.getPreferredDate(), appointment.getCreatedAt()));
+ if (appointment.getServiceLocationId() != null) {
+ var location = serviceLocationMapper.selectById(appointment.getServiceLocationId());
+ if (location != null) {
+ item.put("stationName", location.getName());
+ }
+ }
+ items.add(item);
+ }
+
+ for (CounselingServiceRecord record : counselingServiceMapper.findByUserId(userId)) {
+ // 注意:不输出 record.getUserRequestSummary()(INV-013 咨询正文)
+ items.add(baseEntry(record.getId(), "counseling", counselingLabel(record.getServiceType()),
+ record.getStatus(), occurredAt(null, record.getCreatedAt())));
+ }
+
+ items.sort(Comparator.comparing(
+ (Map item) -> String.valueOf(item.getOrDefault("occurredAt", "")),
+ Comparator.reverseOrder()));
+ return items;
+ }
+
+ private Map baseEntry(String id, String category, String title,
+ String status, String occurredAt) {
+ Map item = new LinkedHashMap<>();
+ item.put("id", id);
+ item.put("category", category);
+ item.put("title", title);
+ item.put("status", status);
+ item.put("occurredAt", occurredAt);
+ return item;
+ }
+
+ private String occurredAt(LocalDate preferredDate, Instant createdAt) {
+ if (preferredDate != null) {
+ return preferredDate.toString();
+ }
+ return createdAt != null ? createdAt.toString() : null;
+ }
+
+ private String appointmentLabel(String serviceType) {
+ if (serviceType == null) {
+ return "服务预约";
+ }
+ return switch (serviceType) {
+ case "station_visit" -> "善缘服务站到站服务";
+ case "counselor_consultation" -> "公益婚恋咨询";
+ case "premarital_guidance" -> "婚前辅导";
+ case "matchmaker" -> "公益红娘服务";
+ default -> "服务预约";
+ };
+ }
+
+ private String counselingLabel(String serviceType) {
+ if (serviceType == null) {
+ return "公益婚恋咨询";
+ }
+ return switch (serviceType) {
+ case "premarital_guidance" -> "婚前辅导";
+ case "anti_fraud_guidance" -> "婚恋反诈辅导";
+ case "emotional_support" -> "情感支持咨询";
+ case "relationship_guidance" -> "婚恋关系咨询";
+ default -> "公益婚恋咨询";
+ };
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java
index 929967d..ed4fa9d 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java
@@ -67,6 +67,25 @@ public class ServiceAppointmentController {
// ---- 后台端 ----
+ /**
+ * 运营代登记:独立端点 + 独立权限 {@code service.assisted_registration}。
+ *
+ * 与用户端 {@code POST /service-appointments} 严格分离:服务对象由请求体显式指定,
+ * 登记人取自登录运营主体,来源固定 {@code operator_assisted}。
+ *
+ *
权限复用 V029 已下发的 {@code service.assisted_registration}("现场协助登记",
+ * 已授予服务站运营人员与平台管理员)。此前该权限在 Java 侧从未被引用,
+ * 属"只种不用"的死权限;再新增一个同义权限会造成权限膨胀,故收敛到一个。
+ */
+ @PreAuthorize("hasAuthority('service.assisted_registration')")
+ @PostMapping("/operator/register")
+ public ApiResponse registerByOperator(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @RequestBody ServiceAppointmentRequest request) {
+ return ApiResponse.ok(serviceAppointmentService.registerByOperator(
+ request.getUserId(), request, principal.id()));
+ }
+
@PreAuthorize("hasAuthority('service_appointment.view')")
@GetMapping("/operator/list")
public ApiResponse> getPendingForOperator(
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationController.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationController.java
new file mode 100644
index 0000000..20de2d5
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationController.java
@@ -0,0 +1,33 @@
+package com.danyang.match.platform.service_appointment.controller;
+
+import com.danyang.match.platform.service_appointment.entity.ServiceLocationAnnouncement;
+import com.danyang.match.platform.service_appointment.service.ServiceLocationAnnouncementService;
+import com.danyang.match.platform.shared.dto.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 善缘服务站(用户端只读)。
+ *
+ * U50「善缘服务站详情」的内容清单包含「临时公告」,但此前全仓没有任何用户端落点 ——
+ * 公告既无存储也无接口。本控制器补上唯一的用户端读口:
+ * 只返回 {@code published} 且未过期的公告,草稿/归档/过期一律不出。
+ *
+ *
鉴权依赖 {@code SecurityConfig} 的 {@code /** → authenticated} 兜底:
+ * 公告是登录后可见内容,不做匿名放行(与 {@code /home} 一致)。
+ */
+@RestController
+@RequestMapping("/service-locations")
+@RequiredArgsConstructor
+public class ServiceLocationController {
+
+ private final ServiceLocationAnnouncementService announcementService;
+
+ /** 某服务站的可见临时公告;站点不存在或已下线时返回空列表(不区分,避免探测)。 */
+ @GetMapping("/{id}/announcements")
+ public ApiResponse> announcements(@PathVariable String id) {
+ return ApiResponse.ok(announcementService.getVisibleAnnouncements(id));
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationOperatorController.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationOperatorController.java
index b9c837a..3013827 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationOperatorController.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceLocationOperatorController.java
@@ -2,40 +2,185 @@ package com.danyang.match.platform.service_appointment.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.service_appointment.entity.ServiceLocation;
+import com.danyang.match.platform.service_appointment.entity.ServiceLocationAnnouncement;
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
+import com.danyang.match.platform.service_appointment.service.ServiceLocationAnnouncementService;
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.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+/**
+ * 善缘服务站运营端(信息维护 + 临时公告)。
+ *
+ * Phase 19 复验修出两类真实缺陷,本类为此重写:
+ *
+ * 越权写 :原实现把 {@code @PreAuthorize("hasAuthority('service_location.view')")}
+ * 挂在**类**上,而 V015 真正的写权限 {@code service_location.manage} 从未被使用 ——
+ * 于是"只能看"的角色可以直接 POST/PUT 改站点信息。现按方法分别守护:
+ * 读 {@code view}、写 {@code manage}。
+ * 公告能力不可达 :{@link ServiceLocationAnnouncementService} 与 V042 表都已存在,
+ * 但全仓**没有任何端点**暴露它 —— 后台与小程序都拿不到公告,属"写了却接不上"的死路。
+ * 现补齐列表/新建/编辑/发布/归档五个端点。
+ *
+ *
+ * 另:{@code create}/{@code update} 原先直接落库。生产 {@code service_locations.address}
+ * 是 {@code NOT NULL}、{@code status} 有 CHECK 白名单,非法入参只在 PostgreSQL 上以 500 暴露;
+ * 现前置校验并统一抛 400({@code VALIDATION_ERROR})。
+ */
@RestController
@RequestMapping("/operator/service-locations")
-@PreAuthorize("hasAuthority('service_location.view')")
@RequiredArgsConstructor
public class ServiceLocationOperatorController {
- private final ServiceLocationMapper locationMapper;
+ /** 对齐生产 V019 chk_service_location_status。 */
+ private static final Set ALLOWED_STATUSES = Set.of("active", "inactive", "closed");
+
+ /** 对齐生产 V019 name VARCHAR(128)。 */
+ private static final int NAME_MAX_LENGTH = 128;
+
+ private final ServiceLocationMapper locationMapper;
+ private final ServiceLocationAnnouncementService announcementService;
+
+ // ==================== 服务站信息 ====================
+
+ @PreAuthorize("hasAuthority('service_location.view')")
@GetMapping
public ApiResponse> list() {
return ApiResponse.ok(locationMapper.selectList(new LambdaQueryWrapper()
.isNull(ServiceLocation::getDeletedAt).orderByAsc(ServiceLocation::getName)));
}
+ @PreAuthorize("hasAuthority('service_location.manage')")
@PostMapping
public ApiResponse create(@RequestBody ServiceLocation location) {
+ validateLocation(location, true);
location.setId(null);
+ // 防止外部注入软删标记把新站点直接"建在回收站里"。
+ location.setDeletedAt(null);
locationMapper.insert(location);
return ApiResponse.ok(location);
}
+ @PreAuthorize("hasAuthority('service_location.manage')")
@PutMapping("/{id}")
- public ApiResponse update(@PathVariable String id, @RequestBody ServiceLocation location) {
- if (locationMapper.selectById(id) == null) return ApiResponse.error(ApiResponseCode.NOT_FOUND);
+ public ApiResponse update(@PathVariable String id,
+ @RequestBody ServiceLocation location) {
+ ServiceLocation existing = locationMapper.selectById(id);
+ if (existing == null || existing.getDeletedAt() != null) {
+ return ApiResponse.error(ApiResponseCode.NOT_FOUND);
+ }
+ validateLocation(location, false);
location.setId(id);
+ // 归属不可篡改:软删状态沿用库中现值(MyBatis-Plus 默认 NOT_NULL 策略,
+ // 传 null 不会进入 UPDATE 列清单,此处显式回填以免语义含糊)。
+ location.setDeletedAt(existing.getDeletedAt());
locationMapper.updateById(location);
return ApiResponse.ok(locationMapper.selectById(id));
}
+
+ // ==================== 临时公告 ====================
+
+ /** 运营端公告列表:返回全部状态(含草稿/已归档),否则草稿发布后再也看不到。 */
+ @PreAuthorize("hasAuthority('service_location.view')")
+ @GetMapping("/{locationId}/announcements")
+ public ApiResponse> listAnnouncements(
+ @PathVariable String locationId) {
+ return ApiResponse.ok(announcementService.getOperatorAnnouncements(locationId));
+ }
+
+ @PreAuthorize("hasAuthority('service_location.manage')")
+ @PostMapping("/{locationId}/announcements")
+ public ApiResponse createAnnouncement(
+ @PathVariable String locationId,
+ @RequestBody Map body,
+ @AuthenticationPrincipal OperatorPrincipal principal) {
+ return ApiResponse.ok(announcementService.create(locationId, body, principal.id()));
+ }
+
+ @PreAuthorize("hasAuthority('service_location.manage')")
+ @PutMapping("/announcements/{announcementId}")
+ public ApiResponse updateAnnouncement(
+ @PathVariable String announcementId,
+ @RequestBody Map body,
+ @AuthenticationPrincipal OperatorPrincipal principal) {
+ return ApiResponse.ok(announcementService.update(announcementId, body, principal.id()));
+ }
+
+ @PreAuthorize("hasAuthority('service_location.manage')")
+ @PostMapping("/announcements/{announcementId}/publish")
+ public ApiResponse publishAnnouncement(
+ @PathVariable String announcementId,
+ @AuthenticationPrincipal OperatorPrincipal principal) {
+ return ApiResponse.ok(announcementService.publish(announcementId, principal.id()));
+ }
+
+ @PreAuthorize("hasAuthority('service_location.manage')")
+ @PostMapping("/announcements/{announcementId}/archive")
+ public ApiResponse archiveAnnouncement(
+ @PathVariable String announcementId,
+ @AuthenticationPrincipal OperatorPrincipal principal) {
+ return ApiResponse.ok(announcementService.archive(announcementId, principal.id()));
+ }
+
+ // ==================== 入参校验 ====================
+
+ /**
+ * @param creating 仅创建时强制 name/address —— 更新允许部分字段,非 null 才校验,
+ * 避免"只想改电话却被迫回传全量"。
+ */
+ private void validateLocation(ServiceLocation location, boolean creating) {
+ if (location == null) {
+ throw invalid("请求体不能为空");
+ }
+ String name = trimToNull(location.getName());
+ if (name == null) {
+ if (creating) {
+ throw invalid("服务站名称不能为空");
+ }
+ } else if (name.length() > NAME_MAX_LENGTH) {
+ throw invalid("服务站名称长度不能超过 " + NAME_MAX_LENGTH + " 字");
+ } else {
+ location.setName(name);
+ }
+
+ String address = trimToNull(location.getAddress());
+ if (address == null) {
+ if (creating) {
+ throw invalid("服务站地址不能为空");
+ }
+ } else {
+ location.setAddress(address);
+ }
+
+ if (location.getStatus() != null) {
+ String status = trimToNull(location.getStatus());
+ if (status == null || !ALLOWED_STATUSES.contains(status)) {
+ throw invalid("不支持的服务站状态:" + location.getStatus());
+ }
+ location.setStatus(status);
+ } else if (creating) {
+ location.setStatus("active");
+ }
+ }
+
+ private String trimToNull(String raw) {
+ if (raw == null) {
+ return null;
+ }
+ String value = raw.trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private GlobalExceptionHandler.BusinessException invalid(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
+ }
}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/entity/ServiceLocationAnnouncement.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/entity/ServiceLocationAnnouncement.java
new file mode 100644
index 0000000..735b8f1
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/entity/ServiceLocationAnnouncement.java
@@ -0,0 +1,31 @@
+package com.danyang.match.platform.service_appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.danyang.match.platform.shared.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.Instant;
+
+/**
+ * 善缘服务站「临时公告」(Phase 19 / U50)。
+ *
+ * 用户端只可见 {@code published} 且未过期的公告;draft / archived 属运营内部状态。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("service_location_announcements")
+public class ServiceLocationAnnouncement extends BaseEntity {
+
+ private String serviceLocationId;
+ private String title;
+ private String content;
+ /** draft / published / archived —— 与生产 chk_announcement_status 一致 */
+ private String status;
+ private Instant publishedAt;
+ /** 过期时间;null 表示长期有效。支撑"临时公告"语义。 */
+ private Instant expiresAt;
+ private Integer sortOrder;
+ private String createdBy;
+ private String updatedBy;
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/mapper/ServiceLocationAnnouncementMapper.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/mapper/ServiceLocationAnnouncementMapper.java
new file mode 100644
index 0000000..09e509b
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/mapper/ServiceLocationAnnouncementMapper.java
@@ -0,0 +1,31 @@
+package com.danyang.match.platform.service_appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.danyang.match.platform.service_appointment.entity.ServiceLocationAnnouncement;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ServiceLocationAnnouncementMapper extends BaseMapper {
+
+ /**
+ * 用户端可见公告:仅 {@code published} 且未过期。
+ *
+ * {@code expires_at IS NULL OR expires_at > NOW()} 实现"临时公告"语义;
+ * {@code deleted_at IS NULL} 排除软删行(全库软删过滤缺口是 Phase 18/19 的常客)。
+ */
+ @Select("SELECT * FROM service_location_announcements "
+ + "WHERE service_location_id = #{locationId} AND status = 'published' AND deleted_at IS NULL "
+ + "AND (expires_at IS NULL OR expires_at > NOW()) "
+ + "ORDER BY sort_order ASC, published_at DESC")
+ List findVisibleByLocation(@Param("locationId") String locationId);
+
+ /** 运营端列表:全部状态(含草稿/已归档)。 */
+ @Select("SELECT * FROM service_location_announcements "
+ + "WHERE service_location_id = #{locationId} AND deleted_at IS NULL "
+ + "ORDER BY sort_order ASC, created_at DESC")
+ List findByLocation(@Param("locationId") String locationId);
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java
index 1bbca37..3d6ef6b 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java
@@ -36,10 +36,38 @@ public class ServiceAppointmentService {
private final DataScopeGuard dataScopeGuard;
/**
- * 用户提交预约
+ * 用户端提交预约。
+ *
+ * 来源恒为 {@code user_self},登记人恒为空:请求体中的 {@code requestSource} /
+ * {@code createdByOperatorId} / {@code userId} 一律**不采信**,避免普通用户伪造
+ * “运营代登记”身份,也避免把非法 source 写库(生产 {@code chk_source_type} 会直接 500)。
+ * 真正的代登记必须走 {@link #registerByOperator}(独立运营权限端点)。
*/
@Transactional
public ServiceAppointmentDTO createAppointment(String userId, ServiceAppointmentRequest request) {
+ return doCreate(userId, request, Constants.APPOINTMENT_SOURCE_USER_SELF, null);
+ }
+
+ /**
+ * 运营代登记:仅由具备 {@code service_appointment.register} 权限的运营人员调用。
+ *
+ *
服务对象由参数显式指定,来源固定 {@code operator_assisted},登记人取自登录主体
+ * (不采信请求体),从而把“代登记”与普通用户路径彻底分离。
+ */
+ @Transactional
+ public ServiceAppointmentDTO registerByOperator(String targetUserId, ServiceAppointmentRequest request,
+ String operatorId) {
+ if (targetUserId == null || targetUserId.isBlank()) {
+ throw new RuntimeException("代登记必须指定服务对象用户");
+ }
+ if (operatorId == null || operatorId.isBlank()) {
+ throw new RuntimeException("代登记必须由运营人员发起");
+ }
+ return doCreate(targetUserId, request, Constants.APPOINTMENT_SOURCE_OPERATOR_ASSISTED, operatorId);
+ }
+
+ private ServiceAppointmentDTO doCreate(String userId, ServiceAppointmentRequest request,
+ String source, String createdByOperatorId) {
ServiceAppointment appointment = new ServiceAppointment();
appointment.setId(UUID.randomUUID().toString());
appointment.setUserId(userId);
@@ -49,11 +77,9 @@ public class ServiceAppointmentService {
appointment.setPreferredTimeSlot(request.getPreferredTimeSlot());
appointment.setUserNote(request.getUserNote());
appointment.setStatus(Constants.APPOINTMENT_STATUS_SUBMITTED);
- // 转介来源字段
- String source = request.getRequestSource() != null ? request.getRequestSource() : "user_self";
appointment.setSource(source);
appointment.setRequestSource(source);
- appointment.setCreatedByOperatorId(request.getCreatedByOperatorId());
+ appointment.setCreatedByOperatorId(createdByOperatorId);
appointment.setCreatedAt(Instant.now());
appointmentMapper.insert(appointment);
@@ -72,11 +98,9 @@ public class ServiceAppointmentService {
log.warn("服务站风险检测异常: {}", e.getMessage());
}
- // 通知服务站运营人员
+ // 通知服务对象本人(接收者恒为用户;operatorId 仅用于日志追溯,不发用户通知以免撞 FK)
try {
- if (request.getCreatedByOperatorId() != null) {
- notificationService.sendAppointmentSubmitted(userId, request.getCreatedByOperatorId(), appointment.getId());
- }
+ notificationService.sendAppointmentSubmitted(userId, createdByOperatorId, appointment.getId());
} catch (Exception e) {
log.warn("发送预约通知失败: {}", e.getMessage());
}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceLocationAnnouncementService.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceLocationAnnouncementService.java
new file mode 100644
index 0000000..008b953
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceLocationAnnouncementService.java
@@ -0,0 +1,221 @@
+package com.danyang.match.platform.service_appointment.service;
+
+import com.danyang.match.platform.service_appointment.entity.ServiceLocationAnnouncement;
+import com.danyang.match.platform.service_appointment.mapper.ServiceLocationAnnouncementMapper;
+import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 善缘服务站「临时公告」(Phase 19 / U50)。
+ *
+ *
此前该能力**完全不存在**(无表/无实体/无接口),属 Phase 19 清单项未实现。
+ * 本服务按全库既有约定实现,并前置校验生产 {@code chk_announcement_status} 白名单,
+ * 避免非法状态只在 PostgreSQL 上以 500 暴露。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ServiceLocationAnnouncementService {
+
+ /** 对齐生产 V042 chk_announcement_status。 */
+ private static final Set ALLOWED_STATUSES = Set.of("draft", "published", "archived");
+
+ private static final String STATUS_DRAFT = "draft";
+ private static final String STATUS_PUBLISHED = "published";
+
+ private static final int TITLE_MAX_LENGTH = 200;
+
+ private final ServiceLocationAnnouncementMapper announcementMapper;
+ private final ServiceLocationMapper locationMapper;
+
+ // ==================== 读取 ====================
+
+ /**
+ * 用户端:仅已发布且未过期的公告。
+ *
+ * 站点不存在或已软删 → 返回空列表而非抛错:
+ * 用户端不应通过错误码区分"站点不存在"与"站点存在但无公告",避免被用来探测阵地。
+ *
+ *
刻意**不**按 {@code service_locations.status} 过滤:站点"休息中/已关闭"时,
+ * 公告(如"本站在改造,请前往相邻服务站")恰恰是最需要触达用户的内容。
+ */
+ public List getVisibleAnnouncements(String locationId) {
+ if (locationId == null || locationId.isBlank()) {
+ return List.of();
+ }
+ var location = locationMapper.selectById(locationId);
+ if (location == null || location.getDeletedAt() != null) {
+ return List.of();
+ }
+ return announcementMapper.findVisibleByLocation(locationId);
+ }
+
+ /** 运营端:全部状态(含草稿/已归档),供后台维护。 */
+ public List getOperatorAnnouncements(String locationId) {
+ requireLocation(locationId);
+ return announcementMapper.findByLocation(locationId);
+ }
+
+ // ==================== 写入 ====================
+
+ @Transactional
+ public ServiceLocationAnnouncement create(String locationId, Map body, String operatorId) {
+ requireLocation(locationId);
+ String title = validateTitle(body.get("title"));
+ Instant expiresAt = parseInstant(body.get("expiresAt"));
+
+ ServiceLocationAnnouncement announcement = new ServiceLocationAnnouncement();
+ announcement.setId(java.util.UUID.randomUUID().toString());
+ announcement.setServiceLocationId(locationId);
+ announcement.setTitle(title);
+ announcement.setContent((String) body.get("content"));
+ announcement.setStatus(STATUS_DRAFT);
+ announcement.setExpiresAt(expiresAt);
+ announcement.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
+ announcement.setCreatedBy(operatorId);
+ announcement.setUpdatedBy(operatorId);
+ announcement.setCreatedAt(Instant.now());
+ announcementMapper.insert(announcement);
+ log.info("创建服务站公告: id={}, locationId={}, operatorId={}",
+ announcement.getId(), locationId, operatorId);
+ return announcement;
+ }
+
+ @Transactional
+ public ServiceLocationAnnouncement update(String announcementId, Map body, String operatorId) {
+ ServiceLocationAnnouncement announcement = requireAnnouncement(announcementId);
+
+ if (body.containsKey("title")) {
+ announcement.setTitle(validateTitle(body.get("title")));
+ }
+ if (body.containsKey("content")) {
+ announcement.setContent((String) body.get("content"));
+ }
+ if (body.containsKey("expiresAt")) {
+ announcement.setExpiresAt(parseInstant(body.get("expiresAt")));
+ }
+ if (body.containsKey("sortOrder")) {
+ announcement.setSortOrder(intOrDefault(body.get("sortOrder"), 0));
+ }
+ if (body.containsKey("status")) {
+ String status = trimToNull(body.get("status"));
+ if (status == null || !ALLOWED_STATUSES.contains(status)) {
+ throw invalid("不支持的公告状态:" + body.get("status"));
+ }
+ announcement.setStatus(status);
+ if (STATUS_PUBLISHED.equals(status) && announcement.getPublishedAt() == null) {
+ announcement.setPublishedAt(Instant.now());
+ }
+ }
+ announcement.setUpdatedBy(operatorId);
+ announcement.setUpdatedAt(Instant.now());
+ announcementMapper.updateById(announcement);
+ return announcement;
+ }
+
+ /** 发布公告:写 published + published_at。 */
+ @Transactional
+ public ServiceLocationAnnouncement publish(String announcementId, String operatorId) {
+ ServiceLocationAnnouncement announcement = requireAnnouncement(announcementId);
+ announcement.setStatus(STATUS_PUBLISHED);
+ announcement.setPublishedAt(Instant.now());
+ announcement.setUpdatedBy(operatorId);
+ announcement.setUpdatedAt(Instant.now());
+ announcementMapper.updateById(announcement);
+ log.info("发布服务站公告: id={}, operatorId={}", announcementId, operatorId);
+ return announcement;
+ }
+
+ /** 下架公告(archived):用户端立即不可见,记录保留可追溯。 */
+ @Transactional
+ public ServiceLocationAnnouncement archive(String announcementId, String operatorId) {
+ ServiceLocationAnnouncement announcement = requireAnnouncement(announcementId);
+ announcement.setStatus("archived");
+ announcement.setUpdatedBy(operatorId);
+ announcement.setUpdatedAt(Instant.now());
+ announcementMapper.updateById(announcement);
+ return announcement;
+ }
+
+ // ==================== 校验 ====================
+
+ private void requireLocation(String locationId) {
+ var location = locationMapper.selectById(locationId);
+ if (location == null || location.getDeletedAt() != null) {
+ throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "服务站不存在");
+ }
+ }
+
+ private ServiceLocationAnnouncement requireAnnouncement(String announcementId) {
+ ServiceLocationAnnouncement announcement = announcementMapper.selectById(announcementId);
+ if (announcement == null || announcement.getDeletedAt() != null) {
+ throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "公告不存在");
+ }
+ return announcement;
+ }
+
+ private String validateTitle(Object raw) {
+ String title = trimToNull(raw);
+ if (title == null) {
+ throw invalid("公告标题不能为空");
+ }
+ if (title.length() > TITLE_MAX_LENGTH) {
+ throw invalid("公告标题长度不能超过 " + TITLE_MAX_LENGTH + " 字");
+ }
+ return title;
+ }
+
+ private Instant parseInstant(Object raw) {
+ String value = trimToNull(raw);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Instant.parse(value);
+ } catch (DateTimeParseException e) {
+ throw invalid("过期时间格式不正确,应为 ISO-8601(如 2026-09-20T02:00:00Z)");
+ }
+ }
+
+ private int intOrDefault(Object raw, int fallback) {
+ if (raw instanceof Number number) {
+ return number.intValue();
+ }
+ if (raw instanceof String text && !text.isBlank()) {
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException ignored) {
+ throw invalid("排序值必须为整数");
+ }
+ }
+ return fallback;
+ }
+
+ private String trimToNull(Object raw) {
+ if (raw == null) {
+ return null;
+ }
+ String value = String.valueOf(raw).trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private GlobalExceptionHandler.BusinessException invalid(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
+ }
+
+ /** 供契约测试断言"合法取值必须与生产 CHECK 一致"。 */
+ public static Set allowedStatuses() {
+ return ALLOWED_STATUSES;
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java b/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java
index 126caa7..6dcddf1 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java
@@ -17,18 +17,20 @@ import java.util.Map;
public class ServiceProgramController {
private final ServiceProgramService serviceProgramService;
+ /**
+ * 用户端项目列表。**只返回已激活项目** —— 此前不限定状态,草稿项目会直接下发给小程序。
+ */
@GetMapping
- public ApiResponse> list(
- @AuthenticationPrincipal UserPrincipal principal,
- @RequestParam(required = false) String status) {
- return ApiResponse.ok(serviceProgramService.getAllPrograms(status));
+ public ApiResponse> list(@AuthenticationPrincipal UserPrincipal principal) {
+ return ApiResponse.ok(serviceProgramService.getActivePrograms());
}
+ /** 用户端项目详情:只有已激活项目可见。 */
@GetMapping("/{id}")
public ApiResponse detail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
- ServiceProgramDTO d = serviceProgramService.getProgramDetail(id);
+ ServiceProgramDTO d = serviceProgramService.getPublicProgramDetail(id);
if (d == null) return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
return ApiResponse.ok(d);
}
@@ -49,7 +51,46 @@ public class ServiceProgramController {
return ApiResponse.ok(serviceProgramService.getMyProgramMembers(userId));
}
+ /** 用户主动退出项目(写 withdrawn,保留记录以便再次申请不撞唯一键)。 */
+ @PostMapping("/{id}/withdraw")
+ public ApiResponse withdraw(
+ @AuthenticationPrincipal UserPrincipal principal,
+ @PathVariable String id) {
+ return ApiResponse.ok(serviceProgramService.withdrawFromProgram(principal.id(), id));
+ }
+
// ---- 后台 ----
+
+ /**
+ * 运营端项目列表:**全部状态**(含草稿),并带成员数/待审数。
+ *
+ * 与用户端 {@code GET /service-programs}(仅 active)分开,避免草稿泄露到小程序。
+ */
+ @GetMapping("/operator/list")
+ @PreAuthorize("hasAuthority('service_program.view')")
+ public ApiResponse> operatorList(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @RequestParam(required = false) String status) {
+ return ApiResponse.ok(serviceProgramService.getAllPrograms(status));
+ }
+
+ /** 运营端项目详情:不限状态,供编辑草稿/已结束项目。 */
+ @GetMapping("/operator/{id}")
+ @PreAuthorize("hasAuthority('service_program.view')")
+ public ApiResponse operatorDetail(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @PathVariable String id) {
+ return ApiResponse.ok(serviceProgramService.getOperatorProgramDetail(id));
+ }
+
+ /** 单项目统计:各状态申请数 + 总数。 */
+ @GetMapping("/operator/{id}/stats")
+ @PreAuthorize("hasAuthority('service_program.view')")
+ public ApiResponse> operatorStats(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @PathVariable String id) {
+ return ApiResponse.ok(serviceProgramService.getProgramStats(id));
+ }
@PostMapping("/operator")
@PreAuthorize("hasAuthority('service_program.manage')")
public ApiResponse create(
@@ -101,4 +142,15 @@ public class ServiceProgramController {
return ApiResponse.ok(serviceProgramService.rejectMember(memberId, body.getOrDefault("reason", "")));
}
+ /** 运营移除成员(写 withdrawn,保留记录以便追溯)。 */
+ @PostMapping("/operator/members/{memberId}/remove")
+ @PreAuthorize("hasAuthority('service_program.manage')")
+ public ApiResponse removeMember(
+ @AuthenticationPrincipal OperatorPrincipal principal,
+ @PathVariable String memberId,
+ @RequestBody(required = false) Map body) {
+ String reason = body == null ? null : body.get("reason");
+ return ApiResponse.ok(serviceProgramService.removeMember(memberId, principal.id(), reason));
+ }
+
}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_program/dto/ServiceProgramDTO.java b/backend/src/main/java/com/danyang/match/platform/service_program/dto/ServiceProgramDTO.java
index 1b886df..dedc673 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_program/dto/ServiceProgramDTO.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_program/dto/ServiceProgramDTO.java
@@ -7,4 +7,12 @@ import java.time.Instant;
private Instant startAt; private Instant endAt;
private String status; private String createdBy;
private Instant createdAt;
+
+ /**
+ * 已通过成员数。Phase 19:此前 DTO 没有该字段(toDTO 也不填充),
+ * 后台「成员数」列永远渲染 0 —— 统计形同虚设。
+ */
+ private Integer memberCount;
+ /** 待审批申请数。同上,此前完全缺失,运营看不到"有几个人在等我审"。 */
+ private Integer pendingCount;
}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_program/mapper/ServiceProgramMemberMapper.java b/backend/src/main/java/com/danyang/match/platform/service_program/mapper/ServiceProgramMemberMapper.java
index 27087bb..edd7b5c 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_program/mapper/ServiceProgramMemberMapper.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_program/mapper/ServiceProgramMemberMapper.java
@@ -1,14 +1,42 @@
package com.danyang.match.platform.service_program.mapper;
+
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
+
import java.util.List;
+
@Mapper
public interface ServiceProgramMemberMapper extends BaseMapper {
- @Select("SELECT * FROM service_program_members WHERE program_id = #{programId} AND status = 'approved'")
- List findApprovedByProgram(String programId);
- @Select("SELECT * FROM service_program_members WHERE user_id = #{userId} ORDER BY applied_at DESC")
- List findByUser(String userId);
+
+ /**
+ * 已通过成员(用于公开名单)。
+ *
+ * Phase 19:补 {@code deleted_at IS NULL}。生产 V037 专门为该表加了 {@code deleted_at}
+ * 用于软删,但此前所有查询都不过滤,软删成员仍被当成有效成员。
+ */
+ @Select("SELECT * FROM service_program_members WHERE program_id = #{programId} "
+ + "AND status = 'approved' AND deleted_at IS NULL")
+ List findApprovedByProgram(@Param("programId") String programId);
+
+ /**
+ * 运营端成员名单:**包含全部状态**(pending/approved/rejected/withdrawn)。
+ *
+ * 这是"审批"能工作的前提——此前运营端复用 {@link #findApprovedByProgram},
+ * 待审批(pending)申请永远不出现在列表里,后台「通过/拒绝」按钮因此永远不出现,
+ * 审批功能形同死路(与"文章发布"死路同族)。
+ */
+ @Select("SELECT * FROM service_program_members WHERE program_id = #{programId} "
+ + "AND deleted_at IS NULL ORDER BY applied_at DESC")
+ List findByProgram(@Param("programId") String programId);
+
+ @Select("SELECT * FROM service_program_members WHERE user_id = #{userId} "
+ + "AND deleted_at IS NULL ORDER BY applied_at DESC")
+ List findByUser(@Param("userId") String userId);
+
+ /** 一次性取出所有"已通过"记录的项目ID,供列表页聚合成员数(避免逐项目 N+1 查询)。 */
+ @Select("SELECT program_id FROM service_program_members WHERE status = 'approved' AND deleted_at IS NULL")
+ List findApprovedProgramIds();
}
diff --git a/backend/src/main/java/com/danyang/match/platform/service_program/service/ServiceProgramService.java b/backend/src/main/java/com/danyang/match/platform/service_program/service/ServiceProgramService.java
index 8700d01..4f82c10 100644
--- a/backend/src/main/java/com/danyang/match/platform/service_program/service/ServiceProgramService.java
+++ b/backend/src/main/java/com/danyang/match/platform/service_program/service/ServiceProgramService.java
@@ -1,136 +1,426 @@
package com.danyang.match.platform.service_program.service;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.service_program.dto.ServiceProgramDTO;
import com.danyang.match.platform.service_program.dto.ServiceProgramMemberDTO;
import com.danyang.match.platform.service_program.entity.ServiceProgram;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
+import java.time.format.DateTimeParseException;
import java.util.*;
+import java.util.stream.Collectors;
+/**
+ * 专项公益服务项目(Service Program)。
+ *
+ * Phase 19 修复的缺陷(均为"历史 ✅ 不可信"范畴):
+ *
+ * 「审批」是死路 :运营端成员名单复用 {@code findApprovedByProgram},
+ * 只返回 {@code approved} → 待审批申请永远不出现在列表里 → 后台「通过/拒绝」
+ * 按钮永远不出现。现运营端用 {@link #getProgramMembers} 返回全部状态。
+ * 草稿项目泄露给用户端 :公开 {@code GET /service-programs} 不限定状态,
+ * 用户端能看到尚未激活的 {@code draft} 项目。现公开列表强制 {@code active}。
+ * 成员数永远为 0 :DTO 缺 {@code memberCount} 且 toDTO 不填充,统计无效。
+ * 「退出」未实现 :生产 {@code chk_program_member_status} 早已允许
+ * {@code withdrawn},但没有任何代码路径写入它。现补用户退出与运营移除。
+ * 重复申请撞唯一键 :生产 {@code UNIQUE(program_id, user_id)} 一直存在,
+ * 被拒/退出后再次申请会 {@code INSERT} 撞唯一键 500(Phase 15 同族根因)。
+ * 现复用原记录并回到 {@code pending}。
+ * 约束漂移盲区 :{@code status} 从不校验,非法值只在 PostgreSQL 撞
+ * {@code chk_program_status} 才 500。
+ * 时间解析 500 :{@code Instant.parse} 遇非 ISO 输入抛 {@code DateTimeParseException}
+ * → 500。现统一转 400。
+ *
+ */
@Slf4j
@Service
@RequiredArgsConstructor
public class ServiceProgramService {
+ /** 对齐生产 V026 chk_program_status。 */
+ private static final Set ALLOWED_PROGRAM_STATUSES = Set.of(
+ "draft", "active", "ended", "cancelled");
+
+ /** 对齐生产 V026 chk_program_member_status(withdrawn = 用户退出 / 运营移除)。 */
+ private static final Set ALLOWED_MEMBER_STATUSES = Set.of(
+ "pending", "approved", "rejected", "withdrawn");
+
+ private static final String PROGRAM_STATUS_DRAFT = "draft";
+ private static final String PROGRAM_STATUS_ACTIVE = "active";
+ private static final String MEMBER_STATUS_PENDING = "pending";
+ private static final String MEMBER_STATUS_APPROVED = "approved";
+ private static final String MEMBER_STATUS_REJECTED = "rejected";
+ private static final String MEMBER_STATUS_WITHDRAWN = "withdrawn";
+
+ private static final int NAME_MAX_LENGTH = 128;
+ private static final int REJECT_REASON_MAX_LENGTH = 500;
+
private final ServiceProgramMapper programMapper;
private final ServiceProgramMemberMapper memberMapper;
- public List getAllPrograms(String status) {
- return programMapper.selectList(
- new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(status != null, ServiceProgram::getStatus, status)
- .isNull(ServiceProgram::getDeletedAt)
- .orderByDesc(ServiceProgram::getCreatedAt)
- ).stream().map(this::toDTO).toList();
+ // ==================== 项目读取 ====================
+
+ /** 用户端列表:**只返回已激活项目**,草稿不得外泄。 */
+ public List getActivePrograms() {
+ return programMapper.selectList(new LambdaQueryWrapper()
+ .eq(ServiceProgram::getStatus, PROGRAM_STATUS_ACTIVE)
+ .isNull(ServiceProgram::getDeletedAt)
+ .orderByDesc(ServiceProgram::getCreatedAt))
+ .stream().map(this::toDTO).toList();
}
- public ServiceProgramDTO getProgramDetail(String programId) {
- ServiceProgram p = programMapper.selectById(programId);
- return p != null ? toDTO(p) : null;
+ /** 运营端列表:全部状态(含草稿),并聚合成员数/待审数。 */
+ public List getAllPrograms(String status) {
+ List programs = programMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(status != null && !status.isBlank(), ServiceProgram::getStatus, status)
+ .isNull(ServiceProgram::getDeletedAt)
+ .orderByDesc(ServiceProgram::getCreatedAt))
+ .stream().map(this::toDTO).toList();
+ fillMemberCounts(programs);
+ return programs;
}
+ /** 用户端详情:只有已激活项目可见。 */
+ public ServiceProgramDTO getPublicProgramDetail(String programId) {
+ ServiceProgram program = programMapper.selectById(programId);
+ if (program == null || program.getDeletedAt() != null
+ || !PROGRAM_STATUS_ACTIVE.equals(program.getStatus())) {
+ return null;
+ }
+ ServiceProgramDTO dto = toDTO(program);
+ fillMemberCounts(List.of(dto));
+ return dto;
+ }
+
+ /** 运营端详情:不限状态。 */
+ public ServiceProgramDTO getOperatorProgramDetail(String programId) {
+ return toDTO(requireProgram(programId));
+ }
+
+ /**
+ * 单项目统计。用于后台"申请/审批/退出/统计"里的统计口径。
+ *
+ * @return 各状态计数 + 总数
+ */
+ public Map getProgramStats(String programId) {
+ requireProgram(programId);
+ Map stats = new LinkedHashMap<>();
+ long total = 0;
+ for (String status : List.of(MEMBER_STATUS_PENDING, MEMBER_STATUS_APPROVED,
+ MEMBER_STATUS_REJECTED, MEMBER_STATUS_WITHDRAWN)) {
+ long count = memberMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ServiceProgramMember::getProgramId, programId)
+ .eq(ServiceProgramMember::getStatus, status)
+ .isNull(ServiceProgramMember::getDeletedAt));
+ stats.put(status, count);
+ total += count;
+ }
+ stats.put("total", total);
+ return stats;
+ }
+
+ // ==================== 项目写入 ====================
+
@Transactional
public ServiceProgramDTO createProgram(Map body, String operatorId) {
- ServiceProgram p = new ServiceProgram();
- p.setId(java.util.UUID.randomUUID().toString());
- p.setName((String) body.get("name"));
- p.setDescription((String) body.get("description"));
- p.setCoverFileId((String) body.get("coverFileId"));
- p.setOrganizerOrgId((String) body.get("organizerOrgId"));
- Object start = body.get("startAt");
- Object end = body.get("endAt");
- p.setStartAt(start != null ? Instant.parse((String) start) : null);
- p.setEndAt(end != null ? Instant.parse((String) end) : null);
- p.setStatus("draft");
- p.setCreatedBy(operatorId);
- p.setCreatedAt(Instant.now());
- programMapper.insert(p);
- log.info("创建专项服务项目: programId={}, operatorId={}", p.getId(), operatorId);
- return toDTO(p);
+ String name = trimToNull(body.get("name"));
+ if (name == null) {
+ throw invalid("项目名称不能为空");
+ }
+ if (name.length() > NAME_MAX_LENGTH) {
+ throw invalid("项目名称长度不能超过 " + NAME_MAX_LENGTH + " 字");
+ }
+ Instant startAt = parseInstant(body.get("startAt"), "开始时间");
+ Instant endAt = parseInstant(body.get("endAt"), "结束时间");
+ if (startAt != null && endAt != null && startAt.isAfter(endAt)) {
+ throw invalid("项目开始时间不能晚于结束时间");
+ }
+
+ ServiceProgram program = new ServiceProgram();
+ program.setId(java.util.UUID.randomUUID().toString());
+ program.setName(name);
+ program.setDescription((String) body.get("description"));
+ program.setCoverFileId(trimToNull(body.get("coverFileId")));
+ program.setOrganizerOrgId(trimToNull(body.get("organizerOrgId")));
+ program.setStartAt(startAt);
+ program.setEndAt(endAt);
+ program.setStatus(PROGRAM_STATUS_DRAFT);
+ program.setCreatedBy(operatorId);
+ program.setCreatedAt(Instant.now());
+ programMapper.insert(program);
+ log.info("创建专项服务项目: programId={}, operatorId={}", program.getId(), operatorId);
+ return toDTO(program);
}
@Transactional
public ServiceProgramDTO updateProgram(String programId, Map body) {
- ServiceProgram p = programMapper.selectById(programId);
- if (p == null) throw new RuntimeException("项目不存在");
- if (body.get("name") != null) p.setName((String) body.get("name"));
- if (body.get("description") != null) p.setDescription((String) body.get("description"));
- if (body.get("coverFileId") != null) p.setCoverFileId((String) body.get("coverFileId"));
- if (body.get("organizerOrgId") != null) p.setOrganizerOrgId((String) body.get("organizerOrgId"));
- if (body.get("startAt") != null) p.setStartAt(Instant.parse((String) body.get("startAt")));
- if (body.get("endAt") != null) p.setEndAt(Instant.parse((String) body.get("endAt")));
- if (body.get("status") != null) p.setStatus((String) body.get("status"));
- p.setUpdatedAt(Instant.now());
- programMapper.updateById(p);
- return toDTO(p);
+ ServiceProgram program = requireProgram(programId);
+
+ if (body.containsKey("name")) {
+ String name = trimToNull(body.get("name"));
+ if (name == null) {
+ throw invalid("项目名称不能为空");
+ }
+ if (name.length() > NAME_MAX_LENGTH) {
+ throw invalid("项目名称长度不能超过 " + NAME_MAX_LENGTH + " 字");
+ }
+ program.setName(name);
+ }
+ if (body.containsKey("description")) {
+ program.setDescription((String) body.get("description"));
+ }
+ if (body.containsKey("coverFileId")) {
+ program.setCoverFileId(trimToNull(body.get("coverFileId")));
+ }
+ if (body.containsKey("organizerOrgId")) {
+ program.setOrganizerOrgId(trimToNull(body.get("organizerOrgId")));
+ }
+ Instant startAt = body.containsKey("startAt") ? parseInstant(body.get("startAt"), "开始时间") : program.getStartAt();
+ Instant endAt = body.containsKey("endAt") ? parseInstant(body.get("endAt"), "结束时间") : program.getEndAt();
+ if (startAt != null && endAt != null && startAt.isAfter(endAt)) {
+ throw invalid("项目开始时间不能晚于结束时间");
+ }
+ program.setStartAt(startAt);
+ program.setEndAt(endAt);
+
+ if (body.containsKey("status")) {
+ String status = trimToNull(body.get("status"));
+ if (status == null || !ALLOWED_PROGRAM_STATUSES.contains(status)) {
+ throw invalid("不支持的项目状态:" + body.get("status"));
+ }
+ program.setStatus(status);
+ }
+ program.setUpdatedAt(Instant.now());
+ programMapper.updateById(program);
+ return toDTO(program);
}
@Transactional
public ServiceProgramDTO activateProgram(String programId) {
- ServiceProgram p = programMapper.selectById(programId);
- if (p == null) throw new RuntimeException("项目不存在");
- p.setStatus("active");
- p.setUpdatedAt(Instant.now());
- programMapper.updateById(p);
- return toDTO(p);
+ ServiceProgram program = requireProgram(programId);
+ program.setStatus(PROGRAM_STATUS_ACTIVE);
+ program.setUpdatedAt(Instant.now());
+ programMapper.updateById(program);
+ log.info("激活专项服务项目: programId={}", programId);
+ return toDTO(program);
}
- // ---- Member管理 ----
+ // ==================== 成员(申请 / 审批 / 退出)====================
+
+ /** 运营端名单:全部状态,审批功能的前提。 */
public List getProgramMembers(String programId) {
- return memberMapper.findApprovedByProgram(programId).stream().map(this::toMemberDTO).toList();
+ return memberMapper.findByProgram(programId).stream().map(this::toMemberDTO).toList();
}
public List getMyProgramMembers(String userId) {
return memberMapper.findByUser(userId).stream().map(this::toMemberDTO).toList();
}
+ /**
+ * 用户申请加入项目。
+ *
+ * 生产 {@code UNIQUE(program_id, user_id)} 一直存在:被拒绝或退出过之后再次申请,
+ * 若直接 {@code INSERT} 会撞唯一键 500(Phase 15"取消后无法再报名"同族根因)。
+ * 因此这里复用原记录并回到 {@code pending}。
+ */
@Transactional
public ServiceProgramMemberDTO applyToProgram(String userId, String programId) {
+ ServiceProgram program = requireProgram(programId);
+ if (!PROGRAM_STATUS_ACTIVE.equals(program.getStatus())) {
+ throw invalid("该项目尚未开放申请");
+ }
+
ServiceProgramMember existing = memberMapper.selectOne(
- new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(ServiceProgramMember::getProgramId, programId)
- .eq(ServiceProgramMember::getUserId, userId)
- );
- if (existing != null) throw new RuntimeException("已申请过该项目");
+ new LambdaQueryWrapper()
+ .eq(ServiceProgramMember::getProgramId, programId)
+ .eq(ServiceProgramMember::getUserId, userId));
+
+ if (existing != null) {
+ if (MEMBER_STATUS_PENDING.equals(existing.getStatus())) {
+ throw invalid("已提交申请,请等待审核");
+ }
+ if (MEMBER_STATUS_APPROVED.equals(existing.getStatus())) {
+ throw invalid("已是该项目成员");
+ }
+ // rejected / withdrawn → 复用原记录重新申请(避免撞 UNIQUE(program_id, user_id))
+ existing.setStatus(MEMBER_STATUS_PENDING);
+ existing.setApprovedAt(null);
+ existing.setApprovedBy(null);
+ existing.setRejectedReason(null);
+ existing.setAppliedAt(Instant.now());
+ existing.setUpdatedAt(Instant.now());
+ memberMapper.updateById(existing);
+ log.info("重新申请专项服务项目: memberId={}, programId={}", existing.getId(), programId);
+ return toMemberDTO(existing);
+ }
ServiceProgramMember member = new ServiceProgramMember();
member.setId(java.util.UUID.randomUUID().toString());
member.setProgramId(programId);
member.setUserId(userId);
- member.setStatus("pending");
+ member.setStatus(MEMBER_STATUS_PENDING);
member.setAppliedAt(Instant.now());
+ member.setCreatedAt(Instant.now());
memberMapper.insert(member);
return toMemberDTO(member);
}
@Transactional
public ServiceProgramMemberDTO approveMember(String memberId, String operatorId) {
- ServiceProgramMember member = memberMapper.selectById(memberId);
- if (member == null) throw new RuntimeException("记录不存在");
- member.setStatus("approved");
+ ServiceProgramMember member = requireMember(memberId);
+ if (!MEMBER_STATUS_PENDING.equals(member.getStatus())) {
+ throw invalid("只有待审批的申请可以通过");
+ }
+ member.setStatus(MEMBER_STATUS_APPROVED);
member.setApprovedAt(Instant.now());
member.setApprovedBy(operatorId);
+ member.setUpdatedAt(Instant.now());
memberMapper.updateById(member);
return toMemberDTO(member);
}
@Transactional
public ServiceProgramMemberDTO rejectMember(String memberId, String reason) {
- ServiceProgramMember member = memberMapper.selectById(memberId);
- if (member == null) throw new RuntimeException("记录不存在");
- member.setStatus("rejected");
- member.setRejectedReason(reason);
+ ServiceProgramMember member = requireMember(memberId);
+ if (!MEMBER_STATUS_PENDING.equals(member.getStatus())) {
+ throw invalid("只有待审批的申请可以拒绝");
+ }
+ String normalizedReason = trimToNull(reason);
+ if (normalizedReason == null) {
+ throw invalid("拒绝时必须填写原因");
+ }
+ if (normalizedReason.length() > REJECT_REASON_MAX_LENGTH) {
+ throw invalid("拒绝原因长度不能超过 " + REJECT_REASON_MAX_LENGTH + " 字");
+ }
+ member.setStatus(MEMBER_STATUS_REJECTED);
+ member.setRejectedReason(normalizedReason);
+ member.setUpdatedAt(Instant.now());
memberMapper.updateById(member);
return toMemberDTO(member);
}
+ /**
+ * 用户主动退出项目。
+ *
+ * 写 {@code withdrawn}(生产 {@code chk_program_member_status} 已允许该值),
+ * 复用原记录而非删除/新增,保证再次申请不会撞 {@code UNIQUE(program_id, user_id)}。
+ */
+ @Transactional
+ public ServiceProgramMemberDTO withdrawFromProgram(String userId, String programId) {
+ ServiceProgramMember member = memberMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(ServiceProgramMember::getProgramId, programId)
+ .eq(ServiceProgramMember::getUserId, userId)
+ .isNull(ServiceProgramMember::getDeletedAt));
+ if (member == null) {
+ throw notFound("尚未加入该项目");
+ }
+ if (MEMBER_STATUS_WITHDRAWN.equals(member.getStatus())) {
+ return toMemberDTO(member);
+ }
+ member.setStatus(MEMBER_STATUS_WITHDRAWN);
+ member.setUpdatedAt(Instant.now());
+ memberMapper.updateById(member);
+ log.info("用户退出专项服务项目: memberId={}, programId={}", member.getId(), programId);
+ return toMemberDTO(member);
+ }
+
+ /** 运营移除成员(等同于退出,保留记录以便追溯)。 */
+ @Transactional
+ public ServiceProgramMemberDTO removeMember(String memberId, String operatorId, String reason) {
+ ServiceProgramMember member = requireMember(memberId);
+ if (MEMBER_STATUS_WITHDRAWN.equals(member.getStatus())) {
+ return toMemberDTO(member);
+ }
+ member.setStatus(MEMBER_STATUS_WITHDRAWN);
+ member.setRejectedReason(trimToNull(reason));
+ member.setUpdatedAt(Instant.now());
+ memberMapper.updateById(member);
+ log.info("运营移除专项服务项目成员: memberId={}, operatorId={}", memberId, operatorId);
+ return toMemberDTO(member);
+ }
+
+ // ==================== 校验与转换 ====================
+
+ /** 一次性聚合所有项目的成员数/待审数,避免逐项目查询。 */
+ private void fillMemberCounts(List programs) {
+ if (programs.isEmpty()) {
+ return;
+ }
+ // findApprovedProgramIds 每条 approved 记录返回一行 → 计数即成员数
+ Map approvedByProgram = memberMapper.findApprovedProgramIds().stream()
+ .filter(Objects::nonNull)
+ .collect(Collectors.groupingBy(id -> id, Collectors.counting()));
+ Map pendingByProgram = memberMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ServiceProgramMember::getStatus, MEMBER_STATUS_PENDING)
+ .isNull(ServiceProgramMember::getDeletedAt))
+ .stream()
+ .map(ServiceProgramMember::getProgramId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.groupingBy(id -> id, Collectors.counting()));
+
+ for (ServiceProgramDTO dto : programs) {
+ dto.setMemberCount(approvedByProgram.getOrDefault(dto.getId(), 0L).intValue());
+ dto.setPendingCount(pendingByProgram.getOrDefault(dto.getId(), 0L).intValue());
+ }
+ }
+
+ private ServiceProgram requireProgram(String programId) {
+ ServiceProgram program = programMapper.selectById(programId);
+ if (program == null || program.getDeletedAt() != null) {
+ throw notFound("项目不存在");
+ }
+ return program;
+ }
+
+ private ServiceProgramMember requireMember(String memberId) {
+ ServiceProgramMember member = memberMapper.selectById(memberId);
+ if (member == null || member.getDeletedAt() != null) {
+ throw notFound("成员记录不存在");
+ }
+ return member;
+ }
+
+ private Instant parseInstant(Object raw, String fieldName) {
+ String value = trimToNull(raw);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Instant.parse(value);
+ } catch (DateTimeParseException e) {
+ // 非 ISO-8601 输入此前会以 DateTimeParseException 落到兜底 handler 变 500
+ throw invalid(fieldName + "格式不正确,应为 ISO-8601(如 2026-09-20T02:00:00Z)");
+ }
+ }
+
+ private String trimToNull(Object raw) {
+ if (raw == null) {
+ return null;
+ }
+ String value = String.valueOf(raw).trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private GlobalExceptionHandler.BusinessException invalid(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
+ }
+
+ private GlobalExceptionHandler.BusinessException notFound(String message) {
+ return new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, message);
+ }
+
private ServiceProgramDTO toDTO(ServiceProgram p) {
ServiceProgramDTO dto = new ServiceProgramDTO();
dto.setId(p.getId());
@@ -143,6 +433,8 @@ public class ServiceProgramService {
dto.setStatus(p.getStatus());
dto.setCreatedBy(p.getCreatedBy());
dto.setCreatedAt(p.getCreatedAt());
+ dto.setMemberCount(0);
+ dto.setPendingCount(0);
return dto;
}
@@ -158,4 +450,14 @@ public class ServiceProgramService {
dto.setRejectedReason(m.getRejectedReason());
return dto;
}
+
+ /** 供契约测试断言"合法取值必须与生产 CHECK 一致"。 */
+ public static Set allowedProgramStatuses() {
+ return ALLOWED_PROGRAM_STATUSES;
+ }
+
+ /** 供契约测试断言"合法取值必须与生产 CHECK 一致"。 */
+ public static Set allowedMemberStatuses() {
+ return ALLOWED_MEMBER_STATUSES;
+ }
}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
index 9820303..afb07d3 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
@@ -176,6 +176,13 @@ public final class Constants {
public static final String APPOINTMENT_STATUS_COMPLETED = "completed";
public static final String APPOINTMENT_STATUS_CANCELLED = "cancelled";
+ // 服务预约来源(与 V021 chk_source_type 数据库约束一致)
+ public static final String APPOINTMENT_SOURCE_USER_SELF = "user_self";
+ public static final String APPOINTMENT_SOURCE_MATCHMAKER_REFERRAL = "matchmaker_referral";
+ public static final String APPOINTMENT_SOURCE_ACTIVITY_FOLLOWUP = "activity_followup";
+ public static final String APPOINTMENT_SOURCE_SERVICE_STATION = "service_station";
+ public static final String APPOINTMENT_SOURCE_OPERATOR_ASSISTED = "operator_assisted";
+
// 咨询服务类型(与 V022 数据库约束一致)
public static final String COUNSELING_TYPE_RELATIONSHIP_GUIDANCE = "relationship_guidance";
public static final String COUNSELING_TYPE_EMOTIONAL_SUPPORT = "emotional_support";
@@ -204,6 +211,10 @@ public final class Constants {
public static final String ARTICLE_STATUS_PUBLISHED = "published";
public static final String ARTICLE_STATUS_OFFLINE = "offline";
+ // ---- Phase 19:内容分类状态(对齐生产 V026 chk_category_status)----
+ public static final String CATEGORY_STATUS_ACTIVE = "active";
+ public static final String CATEGORY_STATUS_INACTIVE = "inactive";
+
// 活动类型(V1.1 Phase 14)
public static final String ACTIVITY_TYPE_MATCHMAKING = "matchmaking";
public static final String ACTIVITY_TYPE_INTEREST_SOCIAL = "interest_social";
diff --git a/backend/src/main/resources/db/migration/V042__add_service_location_announcements.sql b/backend/src/main/resources/db/migration/V042__add_service_location_announcements.sql
new file mode 100644
index 0000000..65be58b
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V042__add_service_location_announcements.sql
@@ -0,0 +1,39 @@
+-- ============================================================
+-- V042: Phase 19 —— 善缘服务站「临时公告」
+--
+-- 背景:Phase 19 计划要求「Service Station 完成万善公园站信息、开放时间、公告、
+-- 服务项目和预约管理」,U50 服务站详情页的内容清单也明确包含「临时公告」。
+-- 但全仓此前**没有任何公告的存储与接口**:既无表、也无实体/Mapper/端点,
+-- 后台与小程序均无落点 —— 属清单项未实现,而非"已实现待回归"。
+--
+-- 设计要点:
+-- 1. status 用白名单 CHECK 约束(draft/published/archived),
+-- 避免"非法状态只在生产炸"的漂移盲区(Phase 11/13/14/15 同族根因)。
+-- 2. expires_at 支撑"临时"语义:用户端查询自动排除已过期公告,
+-- 运营无需手工下架。NULL 表示长期有效。
+-- 3. 软删用 deleted_at(与全库一致),不做物理删除,保留追溯。
+-- 4. 用户端只读 published 且未过期;草稿/归档不得外泄。
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS service_location_announcements (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ service_location_id UUID NOT NULL REFERENCES service_locations(id),
+ title VARCHAR(200) NOT NULL,
+ content TEXT,
+ status VARCHAR(32) NOT NULL DEFAULT 'draft',
+ published_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_by UUID REFERENCES operators(id),
+ updated_by UUID REFERENCES operators(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+);
+
+ALTER TABLE service_location_announcements ADD CONSTRAINT chk_announcement_status
+ CHECK (status IN ('draft', 'published', 'archived'));
+
+CREATE INDEX idx_announcements_location
+ ON service_location_announcements(service_location_id, status, sort_order)
+ WHERE deleted_at IS NULL;
diff --git a/backend/src/test/java/com/danyang/match/platform/banner/BannerOperatorPhase19IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/banner/BannerOperatorPhase19IntegrationTest.java
new file mode 100644
index 0000000..1a8a0b8
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/banner/BannerOperatorPhase19IntegrationTest.java
@@ -0,0 +1,287 @@
+package com.danyang.match.platform.banner;
+
+import com.danyang.match.platform.banner.controller.HomeBannerOperatorController;
+import com.danyang.match.platform.banner.entity.HomeBanner;
+import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
+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.OperatorPrincipal;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Phase 19(运营后台 Banner 管理)真实 DB 集成测试。
+ *
+ * 背景:Banner 后台此前有三处缺口,均属"历史 ✅ 不可信"范畴:
+ *
+ * 写接口只做类型白名单,{@code audienceScope} / {@code status} 完全不校验 →
+ * 非法值一路写库,直到 PostgreSQL 撞 {@code chk_banner_audience_scope} /
+ * {@code chk_banner_status} 才以 **500** 暴露(Phase 11/13 同族约束漂移根因)。
+ * 校验失败抛裸 {@code RuntimeException} → 落到兜底 handler 变 500,语义应为 400。
+ * {@code home_banners} 无 {@code @TableLogic},{@code selectById} 不过滤软删行 →
+ * 可编辑(复活)已软删 Banner;且首页两条查询缺 {@code deleted_at IS NULL},
+ * 软删 Banner 仍上首页。
+ *
+ *
+ * 本测试直接用真实 H2 库跑 mapper + 控制器校验,并额外做**约束保真度**断言:
+ * 若测试库没有生产那三条 CHECK,非法值写入会"成功",本测试立即变红 ——
+ * 防止"测试库缺约束 → 假绿"再次发生。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+class BannerOperatorPhase19IntegrationTest {
+
+ @MockBean
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Autowired
+ private HomeBannerMapper bannerMapper;
+
+ private static final String ADMIN_ID = "00000000-0000-0000-0000-000000000002";
+
+ private HomeBannerOperatorController controller() {
+ return new HomeBannerOperatorController(bannerMapper);
+ }
+
+ private OperatorPrincipal principal() {
+ return new OperatorPrincipal(ADMIN_ID);
+ }
+
+ private HomeBanner validBanner(String title) {
+ HomeBanner banner = new HomeBanner();
+ banner.setTitle(title);
+ banner.setSubtitle("实名认证 · 本地资格 · 人工审核");
+ banner.setButtonText("立即体验");
+ banner.setTargetType("internal_page");
+ banner.setTargetValue("home");
+ banner.setAudienceScope("all");
+ banner.setStatus("active");
+ banner.setSortOrder(1);
+ return banner;
+ }
+
+ /** 断言校验失败语义为 VALIDATION_ERROR(HTTP 400),而不是落兜底 handler 的 500。 */
+ private GlobalExceptionHandler.BusinessException assertValidationFailure(Runnable action) {
+ GlobalExceptionHandler.BusinessException ex =
+ assertThrows(GlobalExceptionHandler.BusinessException.class, action::run);
+ assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode(),
+ "校验失败必须是 VALIDATION_ERROR(400),而非裸 RuntimeException 的 500");
+ return ex;
+ }
+
+ // ==================== 禁外链 ====================
+
+ @Test
+ void rejectsExternalHttpTargetOnCreate() {
+ HomeBanner banner = validBanner("外链 Banner");
+ banner.setTargetType("article");
+ banner.setTargetValue("https://evil.example.com/phish");
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsProtocolRelativeAndJavascriptTarget() {
+ HomeBanner protocolRelative = validBanner("协议相对");
+ protocolRelative.setTargetType("article");
+ protocolRelative.setTargetValue("//evil.example.com");
+ assertValidationFailure(() -> controller().create(principal(), protocolRelative));
+
+ HomeBanner js = validBanner("脚本注入");
+ js.setTargetType("internal_page");
+ js.setTargetValue("javascript:wx.navigateTo({url:'https://evil'})");
+ assertValidationFailure(() -> controller().create(principal(), js));
+ }
+
+ @Test
+ void allowsInternalTargetValue() {
+ HomeBanner banner = validBanner("站内跳转");
+ banner.setTargetType("internal_page");
+ banner.setTargetValue("home");
+ ApiResponse res = controller().create(principal(), banner);
+ assertTrue(res.isSuccess());
+ assertEquals("home", res.getData().getTargetValue());
+ }
+
+ // ==================== 生产 CHECK 白名单前置校验 ====================
+
+ @Test
+ void rejectsUnknownTargetType() {
+ HomeBanner banner = validBanner("非法类型");
+ banner.setTargetType("external_web");
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsUnknownAudienceScope() {
+ HomeBanner banner = validBanner("非法受众");
+ banner.setAudienceScope("vip_only");
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsUnknownStatus() {
+ HomeBanner banner = validBanner("非法状态");
+ banner.setStatus("published");
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsBlankTitle() {
+ HomeBanner banner = validBanner(" ");
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsScheduleWithStartAfterEnd() {
+ HomeBanner banner = validBanner("排期倒挂");
+ banner.setStartAt(Instant.now().plus(30, ChronoUnit.DAYS));
+ banner.setEndAt(Instant.now());
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void rejectsNegativeSortOrder() {
+ HomeBanner banner = validBanner("负排序");
+ banner.setSortOrder(-1);
+ assertValidationFailure(() -> controller().create(principal(), banner));
+ }
+
+ @Test
+ void requiresTargetValueForTypesNeedingExplicitTarget() {
+ HomeBanner article = validBanner("文章无目标");
+ article.setTargetType("article");
+ article.setTargetValue(null);
+ assertValidationFailure(() -> controller().create(principal(), article));
+ }
+
+ @Test
+ void allowsListLevelTargetWithoutValue() {
+ // 与 V027 生产种子一致:activity / service_location 允许不带具体 target_value
+ HomeBanner banner = validBanner("活动列表入口");
+ banner.setTargetType("activity");
+ banner.setTargetValue(null);
+ ApiResponse res = controller().create(principal(), banner);
+ assertTrue(res.isSuccess());
+ }
+
+ // ==================== 创建默认值 ====================
+
+ @Test
+ void createDefaultsStatusToActiveAndStampsOperator() {
+ HomeBanner banner = validBanner("默认状态");
+ banner.setStatus(null);
+ banner.setTargetType(null);
+
+ ApiResponse res = controller().create(principal(), banner);
+
+ assertTrue(res.isSuccess());
+ HomeBanner saved = res.getData();
+ assertEquals("active", saved.getStatus(), "未指定状态时应落 active,而非依赖隐式 DB DEFAULT");
+ assertEquals("none", saved.getTargetType(), "空类型归一化为 none");
+ assertEquals(ADMIN_ID, saved.getCreatedBy());
+ assertEquals(ADMIN_ID, saved.getUpdatedBy());
+ }
+
+ // ==================== 软删过滤 ====================
+
+ @Test
+ void updateRejectsSoftDeletedBanner() {
+ HomeBanner banner = validBanner("已删除的 Banner");
+ controller().create(principal(), banner);
+
+ HomeBanner softDeleted = bannerMapper.selectById(banner.getId());
+ softDeleted.setDeletedAt(Instant.now());
+ bannerMapper.updateById(softDeleted);
+
+ HomeBanner patch = validBanner("试图复活");
+ patch.setStatus("active");
+ ApiResponse res = controller().update(principal(), banner.getId(), patch);
+
+ assertFalse(res.isSuccess());
+ assertEquals(ApiResponseCode.NOT_FOUND.getCode(), res.getCode());
+ }
+
+ @Test
+ void updateDoesNotOverwriteCreatorFromRequestBody() {
+ HomeBanner banner = validBanner("归属不变");
+ controller().create(principal(), banner);
+
+ HomeBanner patch = validBanner("改标题");
+ patch.setCreatedBy("99999999-9999-9999-9999-999999999999");
+ controller().update(principal(), banner.getId(), patch);
+
+ // update 把请求体的 createdBy 置空(MyBatis-Plus NOT_NULL 不回写)→ 归属保持原值
+ assertEquals(ADMIN_ID, bannerMapper.selectById(banner.getId()).getCreatedBy());
+ }
+
+ @Test
+ void activeBannerQueriesExcludeSoftDeletedRows() {
+ HomeBanner kept = validBanner("保留的 Banner");
+ controller().create(principal(), kept);
+
+ HomeBanner removed = validBanner("软删的 Banner");
+ controller().create(principal(), removed);
+ HomeBanner toDelete = bannerMapper.selectById(removed.getId());
+ toDelete.setDeletedAt(Instant.now());
+ bannerMapper.updateById(toDelete);
+
+ List titles = bannerMapper.findActiveBanners().stream().map(HomeBanner::getTitle).toList();
+ assertTrue(titles.contains("保留的 Banner"));
+ assertFalse(titles.contains("软删的 Banner"),
+ "软删 Banner 不得出现在首页查询结果中(deleted_at IS NULL)");
+
+ List scopeTitles = bannerMapper.findActiveBannersByScope("all").stream()
+ .map(HomeBanner::getTitle).toList();
+ assertFalse(scopeTitles.contains("软删的 Banner"));
+ }
+
+ // ==================== 约束保真度 ====================
+
+ /**
+ * 保真度断言:测试库必须真的有生产那三条 CHECK。
+ *
+ * 若 H2 schema 缺少约束,这些"非法值"会写入成功 → 上面所有 reject 用例就成了
+ * 单纯的 Java 校验测试,无法阻止"测试库无约束 → 生产 500"的漂移复发。
+ * 三个约束拆成独立用例:一次约束冲突后事务状态可能受影响,避免互相掩盖。
+ */
+ @Test
+ void testSchemaEnforcesStatusCheckConstraint() {
+ HomeBanner banner = validBanner("绕过校验的非法状态");
+ banner.setId(UUID.randomUUID().toString());
+ banner.setStatus("published");
+ assertThrows(Exception.class, () -> bannerMapper.insert(banner),
+ "chk_banner_status 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesAudienceScopeCheckConstraint() {
+ HomeBanner banner = validBanner("绕过校验的非法受众");
+ banner.setId(UUID.randomUUID().toString());
+ banner.setAudienceScope("vip_only");
+ assertThrows(Exception.class, () -> bannerMapper.insert(banner),
+ "chk_banner_audience_scope 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesTargetTypeCheckConstraint() {
+ HomeBanner banner = validBanner("绕过校验的非法类型");
+ banner.setId(UUID.randomUUID().toString());
+ banner.setTargetType("external_web");
+ assertThrows(Exception.class, () -> bannerMapper.insert(banner),
+ "chk_banner_target_type 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/content/ContentControllerPermissionContractTest.java b/backend/src/test/java/com/danyang/match/platform/content/ContentControllerPermissionContractTest.java
index a53d3cf..8d2df6d 100644
--- a/backend/src/test/java/com/danyang/match/platform/content/ContentControllerPermissionContractTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/content/ContentControllerPermissionContractTest.java
@@ -18,7 +18,10 @@ class ContentControllerPermissionContractTest {
"createArticle", "hasAuthority('content.create')",
"updateArticle", "hasAuthority('content.edit')",
"publishArticle", "hasAuthority('content.publish')",
- "offlineArticle", "hasAuthority('content.offline')");
+ "offlineArticle", "hasAuthority('content.offline')",
+ // Phase 19:新增的启用/停用分类写端点必须同样被权限保护
+ // (新增写端点而忘记加注解 = 所有登录运营都可改内容)
+ "setCategoryStatus", "hasAuthority('content.edit')");
expected.forEach((methodName, expression) -> {
var method = java.util.Arrays.stream(ContentController.class.getDeclaredMethods())
diff --git a/backend/src/test/java/com/danyang/match/platform/content/ContentOperatorPhase19IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/content/ContentOperatorPhase19IntegrationTest.java
new file mode 100644
index 0000000..40cc5b3
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/content/ContentOperatorPhase19IntegrationTest.java
@@ -0,0 +1,293 @@
+package com.danyang.match.platform.content;
+
+import com.danyang.match.platform.content.dto.ContentArticleDTO;
+import com.danyang.match.platform.content.dto.ContentCategoryDTO;
+import com.danyang.match.platform.content.entity.ContentArticle;
+import com.danyang.match.platform.content.entity.ContentCategory;
+import com.danyang.match.platform.content.mapper.ContentArticleMapper;
+import com.danyang.match.platform.content.mapper.ContentCategoryMapper;
+import com.danyang.match.platform.content.service.ContentService;
+import com.danyang.match.platform.shared.constant.Constants;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Phase 19(运营后台内容管理)真实 DB 集成测试。
+ *
+ *
背景:内容链路的运营侧此前只被一个"权限注解契约测试"覆盖,
+ * 业务流程本身**零真实覆盖**(`content_categories` 表在测试库完全缺失)。本轮查出并修复:
+ *
+ * 「发布」是死路 :后台列表复用公开接口(只返回 published),新建文章恒为 draft →
+ * 列表看不到 → 「发布」按钮永远不出现。
+ * 停用分类后无法恢复 :分类列表只返回 active。
+ * 编辑文章看不到正文 :列表 DTO 无 content,后台弹窗回填不到。
+ * 约束漂移盲区 :分类/文章 status 从不校验,非法值只在 PG 撞 CHECK 才 500;
+ * {@code UNIQUE(code)} 重复编码同样 500。
+ * 错误语义 :裸 RuntimeException 落兜底 handler 变 500。
+ *
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+class ContentOperatorPhase19IntegrationTest {
+
+ @Autowired
+ private ContentService contentService;
+ @Autowired
+ private ContentCategoryMapper categoryMapper;
+ @Autowired
+ private ContentArticleMapper articleMapper;
+
+ private static final String OPERATOR = "op-content-19";
+
+ private Map categoryBody(String name, String code) {
+ Map body = new HashMap<>();
+ body.put("name", name);
+ body.put("code", code);
+ body.put("description", "Phase 19 测试分类");
+ body.put("sortOrder", 1);
+ return body;
+ }
+
+ private Map articleBody(String title, String categoryId) {
+ Map body = new HashMap<>();
+ body.put("title", title);
+ body.put("categoryId", categoryId);
+ body.put("summary", "摘要");
+ body.put("content", "正文第一段\n正文第二段");
+ body.put("sortOrder", 1);
+ return body;
+ }
+
+ private GlobalExceptionHandler.BusinessException assertValidationFailure(Runnable action) {
+ GlobalExceptionHandler.BusinessException ex =
+ assertThrows(GlobalExceptionHandler.BusinessException.class, action::run);
+ assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode(),
+ "校验失败必须是 VALIDATION_ERROR(400),而非裸 RuntimeException 的 500");
+ return ex;
+ }
+
+ // ==================== 「发布」必须可达 ====================
+
+ @Test
+ void operatorArticleListIncludesDraftsWhilePublicListDoesNot() {
+ ContentCategoryDTO category = contentService.createCategory(categoryBody("公益课堂", "classroom-" + UUID.randomUUID()));
+ contentService.createArticle(articleBody("草稿文章", category.getId()), OPERATOR);
+
+ List operatorTitles = contentService.getOperatorArticles().stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertTrue(operatorTitles.contains("草稿文章"),
+ "运营侧列表必须包含草稿,否则后台永远点不到「发布」");
+
+ List publicTitles = contentService.getPublishedArticles(null).stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertFalse(publicTitles.contains("草稿文章"),
+ "公开列表不得泄露草稿(小程序 U53 只能看到已发布)");
+ }
+
+ @Test
+ void publishMovesDraftToPublicAndDetailExposesContent() {
+ ContentCategoryDTO category = contentService.createCategory(categoryBody("婚前辅导", "premarital-" + UUID.randomUUID()));
+ ContentArticleDTO draft = contentService.createArticle(articleBody("待发布文章", category.getId()), OPERATOR);
+ assertEquals(Constants.ARTICLE_STATUS_DRAFT, draft.getStatus());
+
+ ContentArticleDTO published = contentService.publishArticle(draft.getId(), OPERATOR);
+ assertEquals(Constants.ARTICLE_STATUS_PUBLISHED, published.getStatus());
+ assertNotNull(published.getPublishAt(), "发布必须写入发布时间");
+
+ List publicTitles = contentService.getPublishedArticles(null).stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertTrue(publicTitles.contains("待发布文章"), "发布后必须出现在公开列表");
+
+ ContentArticleDTO detail = contentService.getArticleDetail(draft.getId());
+ assertNotNull(detail);
+ assertEquals("正文第一段\n正文第二段", detail.getContent(),
+ "详情必须带正文,否则 U54 内容详情页无内容可显示");
+ }
+
+ @Test
+ void offlineRemovesFromPublicButStaysVisibleToOperator() {
+ ContentCategoryDTO category = contentService.createCategory(categoryBody("反诈辅导", "antifraud-" + UUID.randomUUID()));
+ ContentArticleDTO article = contentService.createArticle(articleBody("将下架文章", category.getId()), OPERATOR);
+ contentService.publishArticle(article.getId(), OPERATOR);
+
+ ContentArticleDTO offline = contentService.offlineArticle(article.getId(), OPERATOR);
+ assertEquals(Constants.ARTICLE_STATUS_OFFLINE, offline.getStatus());
+
+ List publicTitles = contentService.getPublishedArticles(null).stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertFalse(publicTitles.contains("将下架文章"), "下架后不得出现在公开列表");
+ assertNull(contentService.getArticleDetail(article.getId()),
+ "下架文章对小程序不可读");
+
+ List operatorTitles = contentService.getOperatorArticles().stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertTrue(operatorTitles.contains("将下架文章"), "运营侧仍应能看到已下架文章以便重新发布");
+ }
+
+ @Test
+ void operatorArticleDetailReturnsContentForDraft() {
+ ContentCategoryDTO category = contentService.createCategory(categoryBody("情感支持", "emotional-" + UUID.randomUUID()));
+ ContentArticleDTO draft = contentService.createArticle(articleBody("草稿正文回填", category.getId()), OPERATOR);
+
+ // 公开详情对草稿返回 null(不可读)
+ assertNull(contentService.getArticleDetail(draft.getId()));
+
+ ContentArticleDTO operatorDetail = contentService.getOperatorArticleDetail(draft.getId());
+ assertEquals("正文第一段\n正文第二段", operatorDetail.getContent(),
+ "运营侧详情必须返回正文,否则编辑弹窗看不到既有内容");
+ }
+
+ // ==================== 分类停用/启用 ====================
+
+ @Test
+ void operatorCategoryListIncludesInactiveCategoriesWhilePublicDoesNot() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("待停用分类", "inactive-" + UUID.randomUUID()));
+ contentService.setCategoryStatus(category.getId(), Constants.CATEGORY_STATUS_INACTIVE, OPERATOR);
+
+ List operatorNames = contentService.getOperatorCategories().stream()
+ .map(ContentCategoryDTO::getName).toList();
+ assertTrue(operatorNames.contains("待停用分类"),
+ "运营侧必须能看到已停用分类,否则停用后无法重新启用");
+
+ List publicNames = contentService.getAllCategories().stream()
+ .map(ContentCategoryDTO::getName).toList();
+ assertFalse(publicNames.contains("待停用分类"), "停用分类不得下发给小程序");
+ }
+
+ @Test
+ void reEnablingInactiveCategoryRestoresPublicVisibility() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("可恢复分类", "restorable-" + UUID.randomUUID()));
+ contentService.setCategoryStatus(category.getId(), Constants.CATEGORY_STATUS_INACTIVE, OPERATOR);
+ contentService.setCategoryStatus(category.getId(), Constants.CATEGORY_STATUS_ACTIVE, OPERATOR);
+
+ List publicNames = contentService.getAllCategories().stream()
+ .map(ContentCategoryDTO::getName).toList();
+ assertTrue(publicNames.contains("可恢复分类"), "重新启用后应恢复下发");
+ }
+
+ // ==================== 校验(400 而非 500)====================
+
+ @Test
+ void duplicateCategoryCodeRejected() {
+ String code = "dup-" + UUID.randomUUID();
+ contentService.createCategory(categoryBody("分类甲", code));
+ // 生产 content_categories 有 UNIQUE(code):不前置校验就会在 PostgreSQL 上 500
+ assertValidationFailure(() -> contentService.createCategory(categoryBody("分类乙", code)));
+ }
+
+ @Test
+ void unknownCategoryStatusRejected() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("非法状态分类", "badstatus-" + UUID.randomUUID()));
+ assertValidationFailure(() ->
+ contentService.setCategoryStatus(category.getId(), "archived", OPERATOR));
+ }
+
+ @Test
+ void blankCategoryNameOrCodeRejected() {
+ assertValidationFailure(() -> contentService.createCategory(categoryBody(" ", "x-" + UUID.randomUUID())));
+ assertValidationFailure(() -> contentService.createCategory(categoryBody("有名字", " ")));
+ }
+
+ @Test
+ void blankArticleTitleRejected() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("标题校验", "title-" + UUID.randomUUID()));
+ assertValidationFailure(() -> contentService.createArticle(articleBody(" ", category.getId()), OPERATOR));
+ }
+
+ @Test
+ void articleWithUnknownCategoryRejected() {
+ // 生产 content_articles.category_id 有外键:不校验会在 PostgreSQL 上 500
+ assertValidationFailure(() ->
+ contentService.createArticle(articleBody("悬空分类文章", UUID.randomUUID().toString()), OPERATOR));
+ }
+
+ @Test
+ void updateUnknownArticleReturnsNotFound() {
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> contentService.updateArticle(UUID.randomUUID().toString(), articleBody("x", null), OPERATOR));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode(), "不存在的文章应为 404 而非 500");
+ }
+
+ @Test
+ void softDeletedCategoryHiddenFromOperatorList() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("将被软删", "softdel-" + UUID.randomUUID()));
+ ContentCategory entity = categoryMapper.selectById(category.getId());
+ entity.setDeletedAt(Instant.now());
+ categoryMapper.updateById(entity);
+
+ List names = contentService.getOperatorCategories().stream()
+ .map(ContentCategoryDTO::getName).toList();
+ assertFalse(names.contains("将被软删"),
+ "软删分类必须从运营列表中过滤(content_categories 没有 @TableLogic)");
+ }
+
+ @Test
+ void softDeletedArticleHiddenAndDetailNotFound() {
+ ContentCategoryDTO category = contentService.createCategory(
+ categoryBody("软删文章分类", "softart-" + UUID.randomUUID()));
+ ContentArticleDTO article = contentService.createArticle(articleBody("将被软删文章", category.getId()), OPERATOR);
+ ContentArticle entity = articleMapper.selectById(article.getId());
+ entity.setDeletedAt(Instant.now());
+ articleMapper.updateById(entity);
+
+ List titles = contentService.getOperatorArticles().stream()
+ .map(ContentArticleDTO::getTitle).toList();
+ assertFalse(titles.contains("将被软删文章"), "软删文章必须从运营列表中过滤");
+
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> contentService.getOperatorArticleDetail(article.getId()));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode());
+ }
+
+ // ==================== 约束保真度 ====================
+
+ /**
+ * 保真度断言:测试库必须真的有生产那两条 CHECK。
+ * 缺约束时非法值写入会"成功",上面的校验用例就成了纯 Java 校验,挡不住漂移复发。
+ */
+ @Test
+ void testSchemaEnforcesCategoryStatusCheckConstraint() {
+ ContentCategory bad = new ContentCategory();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setName("绕过校验");
+ bad.setCode("bypass-" + UUID.randomUUID());
+ bad.setSortOrder(0);
+ bad.setStatus("archived");
+ assertThrows(Exception.class, () -> categoryMapper.insert(bad),
+ "chk_category_status 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesArticleStatusCheckConstraint() {
+ ContentArticle bad = new ContentArticle();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setTitle("绕过校验的文章");
+ bad.setStatus("archived");
+ bad.setSortOrder(0);
+ bad.setViewCount(0);
+ bad.setCreatedAt(Instant.now());
+ assertThrows(Exception.class, () -> articleMapper.insert(bad),
+ "chk_article_status 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+}
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
index ac9cd6d..2916282 100644
--- a/backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/contract/MeRouteContractTest.java
@@ -99,6 +99,9 @@ class MeRouteContractTest {
// 我的举报 / 申诉(REST §80 + 小程序申诉列表)
assertRoute(r, "GET", "/me/reports");
assertRoute(r, "GET", "/me/appeals");
+
+ // 公益服务时间线(Phase 19 / 小程序 U64)——只返回本人摘要,不含内部字段
+ assertRoute(r, "GET", "/me/service-history");
}
@Test
diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/BannerConstraintParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/BannerConstraintParityTest.java
new file mode 100644
index 0000000..f45b382
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/deployment/BannerConstraintParityTest.java
@@ -0,0 +1,166 @@
+package com.danyang.match.platform.deployment;
+
+import com.danyang.match.platform.banner.controller.HomeBannerOperatorController;
+import org.junit.jupiter.api.*;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+import java.util.stream.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Banner 约束三方一致性守卫(Phase 19 新增,永久生效)。
+ *
+ * 为什么需要它: Phase 11/13/15/18 反复出现同一族缺陷 —— 生产库有 CHECK 约束,
+ * 测试库没有,代码里也没有前置校验。非法值在测试期畅通无阻,一上 PostgreSQL 就 500。
+ * Banner 的 {@code target_type} / {@code audience_scope} / {@code status} 正是同一模式。
+ *
+ *
本守卫把「**生产迁移 DDL** ↔ **测试库 schema** ↔ **后端白名单** ↔ **后台下拉选项**」
+ * 四者变成编译后可自动验证的不变量 —— 任何一处漂移立刻变红,不必再靠人工比对。
+ *
+ *
纯文本 + 反射实现,不启动 Spring 上下文,秒级完成,适合入门禁。
+ */
+class BannerConstraintParityTest {
+
+ private static final Path PROD_MIGRATION =
+ Paths.get("backend/src/main/resources/db/migration/V019__add_home_banners_and_service_locations.sql");
+ private static final Path TEST_SCHEMA = Paths.get("backend/src/test/resources/schema-h2.sql");
+ private static final Path ADMIN_BANNER_VIEW =
+ Paths.get("admin-web/src/modules/banner/BannerManagementView.vue");
+
+ /** 与生产 V019 的三条 CHECK 一一对应:约束名 / 列名 / 后端常量名 / 后台 label map 名 */
+ private record ConstraintSpec(String name, String column, String backendField, String adminLabelMap) {}
+
+ private static final List SPECS = List.of(
+ new ConstraintSpec("chk_banner_target_type", "target_type", "ALLOWED_TARGET_TYPES", "TARGET_TYPE_LABEL"),
+ new ConstraintSpec("chk_banner_audience_scope", "audience_scope", "ALLOWED_AUDIENCE_SCOPES", "AUDIENCE_SCOPE_LABEL"),
+ new ConstraintSpec("chk_banner_status", "status", "ALLOWED_STATUSES", "STATUS_LABEL"));
+
+ // ==================== DDL 解析 ====================
+
+ /**
+ * 从建表/约束脚本里解析 `CONSTRAINT CHECK ( IN ('a', 'b', ...))` 的取值集合。
+ * 解析不到直接失败 —— 空集合会让下面的 assert 变成假绿。
+ */
+ private static Set parseCheckValues(Path sqlFile, ConstraintSpec spec) throws IOException {
+ String sql = Files.readString(sqlFile);
+ Pattern pattern = Pattern.compile(
+ "CONSTRAINT\\s+" + Pattern.quote(spec.name())
+ + "\\s+CHECK\\s*\\(\\s*" + Pattern.quote(spec.column())
+ + "\\s+IN\\s*\\(([^)]*)\\)",
+ Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+ Matcher matcher = pattern.matcher(sql);
+ assertTrue(matcher.find(),
+ "未能从 " + sqlFile + " 解析出 " + spec.name() + " —— 解析失败会让本守卫静默失效,必须显式报错");
+
+ Set values = Pattern.compile("'([^']+)'").matcher(matcher.group(1)).results()
+ .map(MatchResult::group)
+ .map(quoted -> quoted.substring(1, quoted.length() - 1))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ assertFalse(values.isEmpty(), spec.name() + " 解析出空取值集合,守卫会变成假绿");
+ return values;
+ }
+
+ // ==================== 后端白名单(反射) ====================
+
+ @SuppressWarnings("unchecked")
+ private static Set backendWhitelist(String fieldName) throws Exception {
+ Field field = HomeBannerOperatorController.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ Object value = field.get(null);
+ assertNotNull(value, "HomeBannerOperatorController." + fieldName + " 不存在或为 null");
+ return (Set) value;
+ }
+
+ // ==================== 后台下拉选项(文本解析) ====================
+
+ /** 解析 `const X_LABEL: Record = { key: '...' , ... }` 的 key 集合。 */
+ private static Set adminLabelKeys(String labelMapName) throws IOException {
+ String vue = Files.readString(ADMIN_BANNER_VIEW);
+ Pattern pattern = Pattern.compile(
+ labelMapName + "\\s*:\\s*Record\\s*=\\s*\\{([^}]*)}",
+ Pattern.DOTALL);
+ Matcher matcher = pattern.matcher(vue);
+ assertTrue(matcher.find(), "未能从 " + ADMIN_BANNER_VIEW + " 解析出 " + labelMapName);
+
+ Set keys = Pattern.compile("([A-Za-z_][A-Za-z0-9_]*)\\s*:").matcher(matcher.group(1)).results()
+ .map(result -> result.group(1))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ assertFalse(keys.isEmpty(), labelMapName + " 解析出空 key 集合,守卫会变成假绿");
+ return keys;
+ }
+
+ /** 后台下拉里全部可选的 value(含受众/状态,断言用包含语义)。 */
+ private static Set adminSelectableOptionValues() throws IOException {
+ String vue = Files.readString(ADMIN_BANNER_VIEW);
+ Set values = Pattern.compile("]*value=\"([^\"]+)\"").matcher(vue).results()
+ .map(result -> result.group(1))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ assertFalse(values.isEmpty(), "未能解析 el-option 选项,守卫会变成假绿");
+ return values;
+ }
+
+ // ==================== 断言 ====================
+
+ @Test
+ void productionAndTestSchemaDeclareIdenticalBannerChecks() throws IOException {
+ for (ConstraintSpec spec : SPECS) {
+ Set production = parseCheckValues(PROD_MIGRATION, spec);
+ Set test = parseCheckValues(TEST_SCHEMA, spec);
+ assertEquals(production, test,
+ spec.name() + " 在生产迁移与测试库 schema-h2.sql 之间漂移:"
+ + "生产=" + production + ",测试库=" + test
+ + "。测试库缺约束 = 该非法值路径零覆盖,上生产才以 500 暴露。");
+ }
+ }
+
+ @Test
+ void backendWhitelistMatchesProductionDdl() throws Exception {
+ for (ConstraintSpec spec : SPECS) {
+ assertEquals(parseCheckValues(PROD_MIGRATION, spec), backendWhitelist(spec.backendField()),
+ "HomeBannerOperatorController." + spec.backendField()
+ + " 与生产 " + spec.name() + " 不一致:"
+ + "白名单缺值会让合法配置被 400 拒绝,多值会让非法配置打到 PG 才 500。");
+ }
+ }
+
+ @Test
+ void adminDropdownOffersEveryLegalTargetType() throws IOException {
+ Set selectable = adminSelectableOptionValues();
+ for (String legal : parseCheckValues(PROD_MIGRATION, SPECS.get(0))) {
+ assertTrue(selectable.contains(legal),
+ "后台「跳转类型」下拉缺少合法值 " + legal + " —— 运营无法配置该类型");
+ }
+ }
+
+ @Test
+ void adminLabelMapsCoverProductionDdl() throws IOException {
+ for (ConstraintSpec spec : SPECS) {
+ assertTrue(adminLabelKeys(spec.adminLabelMap()).containsAll(parseCheckValues(PROD_MIGRATION, spec)),
+ spec.adminLabelMap() + " 未覆盖生产 " + spec.name() + " 的全部取值,"
+ + "列表会直接展示原始英文码值");
+ }
+ }
+
+ @Test
+ void backendRejectsExternalLinkShapesConsistentWithAdminGuard() throws Exception {
+ // 前端 EXTERNAL_LINK_PREFIXES 与后端禁外链前缀必须一致,否则出现"前端放行、后端 400"的错位
+ String controller = Files.readString(
+ Paths.get("backend/src/main/java/com/danyang/match/platform/banner/controller/HomeBannerOperatorController.java"));
+ assertTrue(controller.contains("\"http://\"") && controller.contains("\"https://\"")
+ && controller.contains("\"//\"") && controller.contains("\"javascript:\""),
+ "后端禁外链前缀缺失(http/https/协议相对/javascript)");
+
+ String vue = Files.readString(ADMIN_BANNER_VIEW);
+ assertTrue(vue.contains("EXTERNAL_LINK_PREFIXES"), "后台缺少禁外链前置校验常量");
+ for (String prefix : List.of("'http://'", "'https://'", "'//'", "'javascript:'")) {
+ assertTrue(vue.contains(prefix), "后台禁外链前缀缺少 " + prefix + ",与后端不一致");
+ }
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java
index 9a083f7..4cc5154 100644
--- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java
@@ -54,7 +54,6 @@ class EntityMigrationParityTest {
private static final Set TEST_SCHEMA_OPTIONAL_TABLES = Set.of(
"activity_interests",
"consent_records",
- "content_categories",
"interest_dictionary",
"match_rule_configs",
"operator_roles",
@@ -66,8 +65,6 @@ class EntityMigrationParityTest {
"role_permissions",
"roles",
"sensitive_access_logs",
- "service_location_operators",
- "service_programs",
"system_configs",
"user_interests",
"user_photos",
@@ -88,6 +85,9 @@ class EntityMigrationParityTest {
"home_banners",
"service_locations",
"content_articles",
+ // Phase 19:内容分类表此前完全缺失于测试库(且只在可选白名单里 = 无人测),
+ // 运营后台「内容分类」tab 的增改停用全链路零覆盖。已补入 schema-h2.sql。
+ "content_categories",
"activities",
"user_local_eligibilities",
"local_eligibility_evidences",
@@ -122,7 +122,18 @@ class EntityMigrationParityTest {
// Phase 18:申诉表此前仅在可选白名单(= 无人测),
// 导致 submitAppeal / processAppeal / approve / reject / escalate 整条真实 DB 路径零覆盖,
// 且 AppealServiceTest 有 6 处 assertTrue(true) 假绿。已补入 schema-h2.sql。
- "appeals"
+ "appeals",
+ // Phase 19:专项服务项目表此前仅在可选白名单(= 无人测),ServiceProgramService
+ // 全链路零覆盖;服务站运营人员数据范围表缺失更导致 ServiceAppointmentMapper 的
+ // LEFT JOIN 在测试库直接 BadSqlGrammar(「预约数据范围」不可测)。二者已补入 schema-h2.sql。
+ "service_programs",
+ "service_location_operators",
+ // Phase 19:服务预约表补齐生产两条 CHECK 后纳入必需集合(约束保真度)。
+ "service_appointments",
+ // Phase 19:服务站「临时公告」表(V042)本轮新建。此前全仓无公告存储,
+ // 表只存在于生产迁移 → 公告 Service 的任何调用在测试库都 BadSqlGrammar,
+ // 且生产表缺失守卫直接变红(本轮由该守卫精准发现)。已补入 schema-h2.sql。
+ "service_location_announcements"
);
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
private static final Set BASE_ENTITY_COLUMNS =
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 a414ba2..baa73b9 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
@@ -16,8 +16,8 @@ import static org.junit.jupiter.api.Assertions.*;
class MigrationValidationTest {
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
- // Phase 16:V041 活动互选二阶段同意补全(expires_at / match_id 溯源字段)
- private static final int LATEST_MIGRATION = 41;
+ // Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements)
+ private static final int LATEST_MIGRATION = 42;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/ServiceLocationAnnouncementConstraintParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/ServiceLocationAnnouncementConstraintParityTest.java
new file mode 100644
index 0000000..01bdb7f
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/deployment/ServiceLocationAnnouncementConstraintParityTest.java
@@ -0,0 +1,145 @@
+package com.danyang.match.platform.deployment;
+
+import com.danyang.match.platform.service_appointment.service.ServiceLocationAnnouncementService;
+import org.junit.jupiter.api.*;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+import java.util.stream.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 服务站「临时公告」状态约束四方一致性守卫(Phase 19 新增,永久生效)。
+ *
+ * 与 {@link BannerConstraintParityTest} 同族:Phase 11/13/15/18 反复出现同一族缺陷 ——
+ * 生产库有 CHECK,测试库没有,代码里也没有前置校验;非法值在测试期畅通无阻,
+ * 一上 PostgreSQL 就 500。V042 的 {@code chk_announcement_status} 正是同一模式,
+ * 而它本轮新引入(公告能力此前完全不存在),**必须一开始就把不变量钉死**。
+ *
+ *
本守卫把四者变成编译后可自动验证的不变量:
+ * 生产 V042 DDL ↔ 测试库 schema-h2.sql ↔ 后端白名单 ↔ 后台状态标签 。
+ * 任何一处漂移立刻变红,不必靠人工比对。
+ *
+ *
纯文本 + 反射实现,不启动 Spring 上下文,秒级完成,适合入门禁。
+ */
+class ServiceLocationAnnouncementConstraintParityTest {
+
+ private static final Path PROD_MIGRATION =
+ Paths.get("backend/src/main/resources/db/migration/V042__add_service_location_announcements.sql");
+ private static final Path TEST_SCHEMA = Paths.get("backend/src/test/resources/schema-h2.sql");
+ private static final Path ADMIN_STATION_VIEW =
+ Paths.get("admin-web/src/modules/service-station/ServiceStationView.vue");
+
+ private static final String CONSTRAINT_NAME = "chk_announcement_status";
+ private static final String COLUMN = "status";
+
+ // ==================== DDL 解析 ====================
+
+ /**
+ * 解析 `CONSTRAINT chk_announcement_status CHECK (status IN ('a','b',...))` 的取值集合。
+ * 解析不到直接失败 —— 空集合会让下面的 assert 变成假绿。
+ */
+ private static Set parseCheckValues(Path sqlFile) throws IOException {
+ String sql = Files.readString(sqlFile);
+ Pattern pattern = Pattern.compile(
+ "CONSTRAINT\\s+" + Pattern.quote(CONSTRAINT_NAME)
+ + "\\s+CHECK\\s*\\(\\s*" + Pattern.quote(COLUMN) + "\\s+IN\\s*\\(([^)]*)\\)",
+ Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+ Matcher matcher = pattern.matcher(sql);
+ assertTrue(matcher.find(),
+ "未能从 " + sqlFile + " 解析出 " + CONSTRAINT_NAME
+ + " —— 解析失败会让本守卫静默失效,必须显式报错");
+
+ Set values = Pattern.compile("'([^']+)'").matcher(matcher.group(1)).results()
+ .map(MatchResult::group)
+ .map(quoted -> quoted.substring(1, quoted.length() - 1))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ assertFalse(values.isEmpty(), CONSTRAINT_NAME + " 解析出空取值集合,守卫会变成假绿");
+ return values;
+ }
+
+ // ==================== 后端白名单(反射) ====================
+
+ @SuppressWarnings("unchecked")
+ private static Set backendWhitelist() throws Exception {
+ Method method = ServiceLocationAnnouncementService.class.getDeclaredMethod("allowedStatuses");
+ method.setAccessible(true);
+ Object value = method.invoke(null);
+ assertNotNull(value, "ServiceLocationAnnouncementService.allowedStatuses() 返回 null");
+ return (Set) value;
+ }
+
+ // ==================== 后台标签映射(文本解析) ====================
+
+ /** 解析 `const X_LABEL: Record = { key: '...' }` 的 key 集合。 */
+ private static Set adminLabelKeys(String labelMapName) throws IOException {
+ String vue = Files.readString(ADMIN_STATION_VIEW);
+ Pattern pattern = Pattern.compile(
+ labelMapName + "\\s*:\\s*Record\\s*=\\s*\\{([^}]*)}",
+ Pattern.DOTALL);
+ Matcher matcher = pattern.matcher(vue);
+ assertTrue(matcher.find(), "未能从 " + ADMIN_STATION_VIEW + " 解析出 " + labelMapName);
+
+ Set keys = Pattern.compile("([A-Za-z_][A-Za-z0-9_]*)\\s*:").matcher(matcher.group(1)).results()
+ .map(result -> result.group(1))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ assertFalse(keys.isEmpty(), labelMapName + " 解析出空 key 集合,守卫会变成假绿");
+ return keys;
+ }
+
+ // ==================== 断言 ====================
+
+ @Test
+ void productionAndTestSchemaDeclareIdenticalAnnouncementStatusCheck() throws IOException {
+ Set production = parseCheckValues(PROD_MIGRATION);
+ Set test = parseCheckValues(TEST_SCHEMA);
+ assertEquals(production, test,
+ CONSTRAINT_NAME + " 在生产迁移 V042 与测试库 schema-h2.sql 之间漂移:"
+ + "生产=" + production + ",测试库=" + test
+ + "。测试库缺约束 = 非法状态路径零覆盖,上生产才以 500 暴露。");
+ }
+
+ @Test
+ void backendWhitelistMatchesProductionDdl() throws Exception {
+ assertEquals(parseCheckValues(PROD_MIGRATION), backendWhitelist(),
+ "ServiceLocationAnnouncementService.allowedStatuses() 与生产 " + CONSTRAINT_NAME + " 不一致:"
+ + "白名单缺值会让合法状态被 400 拒绝,多值会让非法状态打到 PG 才 500。");
+ }
+
+ @Test
+ void adminLabelMapCoversProductionDdl() throws IOException {
+ assertTrue(adminLabelKeys("ANNOUNCEMENT_STATUS_LABELS").containsAll(parseCheckValues(PROD_MIGRATION)),
+ "ANNOUNCEMENT_STATUS_LABELS 未覆盖生产 " + CONSTRAINT_NAME + " 的全部取值,"
+ + "公告列表会直接展示原始英文码值");
+ }
+
+ /**
+ * 用户端可见性硬编码为 published:该字面量一旦与生产 CHECK 漂移,
+ * 要么"发布了却看不见",要么"草稿被当成可见状态"。
+ */
+ @Test
+ void userFacingVisibilityStatusIsPartOfProductionWhitelist() throws Exception {
+ Set production = parseCheckValues(PROD_MIGRATION);
+ assertTrue(production.contains("published"),
+ "生产 " + CONSTRAINT_NAME + " 必须包含 published —— 用户端可见性依赖该状态字面量");
+ assertTrue(production.contains("draft"), "必须保留 draft,否则公告无法先草稿后发布");
+ assertTrue(production.contains("archived"), "必须保留 archived,否则下架只能物理删除、失去可追溯性");
+ }
+
+ /** 自检:约束所在表必须同时在测试库存在,否则本守卫的"测试库副本"其实不存在。 */
+ @Test
+ void announcementTableExistsInBothSchemas() throws IOException {
+ String production = Files.readString(PROD_MIGRATION);
+ String test = Files.readString(TEST_SCHEMA);
+ assertTrue(production.contains("CREATE TABLE IF NOT EXISTS service_location_announcements"),
+ "生产 V042 未创建 service_location_announcements");
+ assertTrue(test.contains("CREATE TABLE service_location_announcements"),
+ "测试库 schema-h2.sql 缺 service_location_announcements —— 公告整条真实 DB 路径将零覆盖");
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java b/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java
index efa3eae..0c91549 100644
--- a/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java
@@ -15,6 +15,7 @@ import com.danyang.match.platform.review.controller.ReviewController;
import com.danyang.match.platform.risk.controller.ReportOperatorController;
import com.danyang.match.platform.risk.controller.RiskController;
import com.danyang.match.platform.service_appointment.controller.ServiceAppointmentController;
+import com.danyang.match.platform.service_appointment.controller.ServiceLocationOperatorController;
import com.danyang.match.platform.service_program.controller.ServiceProgramController;
import com.danyang.match.platform.shared.controller.FeatureFlagController;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
diff --git a/backend/src/test/java/com/danyang/match/platform/security/V11PermissionMatrixContractTest.java b/backend/src/test/java/com/danyang/match/platform/security/V11PermissionMatrixContractTest.java
index 2c64014..0b795bf 100644
--- a/backend/src/test/java/com/danyang/match/platform/security/V11PermissionMatrixContractTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/security/V11PermissionMatrixContractTest.java
@@ -3,12 +3,14 @@ package com.danyang.match.platform.security;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -20,10 +22,34 @@ class V11PermissionMatrixContractTest {
private static final String COUNSELING_MANAGER = "00000000-0000-0000-0000-000000000012";
private static final String CONTENT_EDITOR = "00000000-0000-0000-0000-000000000013";
+ /**
+ * Phase 19 修正:此前本守卫**硬编码只读 V015 + V029 两个文件**。
+ *
+ * 后果:任何后续迁移里新增的 {@code role_permissions} 授权对这个守卫完全不可见 ——
+ * 权限矩阵"绿"只代表 V015/V029 那段文本没变,不代表真实矩阵没漂移(假绿)。
+ * 本轮就踩到了:新增一条给服务站运营人员的授权时,守卫毫无反应。
+ *
+ *
现在扫描 **db/migration 下全部迁移**,把"角色 → 权限集合"变成对真实 DDL 的断言。
+ */
+ private static String allMigrations() throws IOException {
+ Path dir = Path.of("backend/src/main/resources/db/migration");
+ try (var files = Files.list(dir)) {
+ return files.filter(path -> path.getFileName().toString().endsWith(".sql"))
+ .sorted()
+ .map(path -> {
+ try {
+ return Files.readString(path);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ })
+ .collect(Collectors.joining("\n"));
+ }
+ }
+
@Test
void v11RolesMatchCustomerPermissionBaseline() throws IOException {
- String migrations = Files.readString(Path.of("backend/src/main/resources/db/migration/V015__add_v11_roles_and_permissions.sql"))
- + Files.readString(Path.of("backend/src/main/resources/db/migration/V029__complete_service_station_permissions.sql"));
+ String migrations = allMigrations();
assertEquals(Set.of(
"service_location.view", "service_appointment.view",
diff --git a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java
new file mode 100644
index 0000000..43b016d
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java
@@ -0,0 +1,333 @@
+package com.danyang.match.platform.service_appointment;
+
+import com.danyang.match.platform.service_appointment.dto.ServiceAppointmentDTO;
+import com.danyang.match.platform.service_appointment.dto.ServiceAppointmentRequest;
+import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
+import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
+import com.danyang.match.platform.service_appointment.service.ServiceAppointmentService;
+import com.danyang.match.platform.audit.entity.AuditLog;
+import com.danyang.match.platform.audit.mapper.AuditLogMapper;
+import com.danyang.match.platform.shared.constant.Constants;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Phase 19(统一运营后台 + 用户端公益服务)真实 DB 集成测试 —— 服务预约归属与代登记边界。
+ *
+ *
背景:
+ *
+ * 用户端 {@code POST /service-appointments} 曾直接采纳请求体中的 {@code requestSource} /
+ * {@code createdByOperatorId},普通用户可伪造"运营代登记"身份,或写入非法 source
+ * 撞生产 {@code chk_source_type}(测试库当时无该约束 → 零覆盖)。
+ * 代登记缺少独立 operator 端点与 {@code service_appointment.register} 权限。
+ * {@code service_appointments} 的两条生产 CHECK 此前在测试库完全缺失(约束漂移盲区)。
+ *
+ * 本测试真实验证归属守卫(IDOR)、契约收紧、代登记边界与约束保真度。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+class ServiceAppointmentOwnerGuardTest {
+
+ @MockBean
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Autowired
+ private ServiceAppointmentService appointmentService;
+ @Autowired
+ private ServiceAppointmentMapper appointmentMapper;
+ @Autowired
+ private AuditLogMapper auditLogMapper;
+ /** 原生 JDBC:service_location_operators 没有实体/Mapper,只能用裸 SQL 插夹具。 */
+ @Autowired
+ private javax.sql.DataSource dataSource;
+
+ private static final String USER_A = "user-owner-a";
+ private static final String USER_B = "user-owner-b";
+ private static final String OPERATOR = "op-register-19";
+
+ /**
+ * 本站点 ID **每个用例唯一**。
+ *
+ * 注意:{@code service_location_operators} / {@code service_locations} 的夹具必须走原生 JDBC
+ * (没有实体与 Mapper),而原生 JDBC 默认 autoCommit=true,**不参与 Spring 测试事务**,
+ * 因此这些行不会随 {@code @Transactional} 回滚。用固定 ID 会在第二个用例插入时撞主键,
+ * 用唯一 ID + {@link #cleanUpFixtures()} 显式清理才是正确做法。
+ */
+ private String loc;
+ private final List insertedLocations = new ArrayList<>();
+ private final List insertedOperatorLinks = new ArrayList<>();
+
+ @BeforeEach
+ void assignUniqueFixtureIds() {
+ // service_locations.id 生产为 UUID(36 字符),前缀 + 截断必须严格不超过 36
+ loc = "loc19-" + UUID.randomUUID().toString().substring(0, 30);
+ assertEquals(36, loc.length(), "夹具 ID 必须恰好符合列宽 VARCHAR(36)");
+ }
+
+ @AfterEach
+ void cleanUpFixtures() {
+ // 逆序清理原生 JDBC 写入的夹具行(忽略失败:清理不应影响用例结论)
+ for (String locationId : insertedOperatorLinks) {
+ try {
+ exec("DELETE FROM service_location_operators WHERE service_location_id = ?", locationId);
+ } catch (Exception ignored) {
+ // 已清理或不存在
+ }
+ }
+ insertedOperatorLinks.clear();
+ for (String locationId : insertedLocations) {
+ try {
+ exec("DELETE FROM service_locations WHERE id = ?", locationId);
+ } catch (Exception ignored) {
+ // 已清理或不存在
+ }
+ }
+ insertedLocations.clear();
+ }
+
+ private ServiceAppointmentRequest request() {
+ ServiceAppointmentRequest r = new ServiceAppointmentRequest();
+ r.setServiceType("station_visit");
+ r.setPreferredDate(LocalDate.now().plusDays(1));
+ r.setPreferredTimeSlot("09:00-10:00");
+ r.setUserNote("服务预约归属测试");
+ return r;
+ }
+
+ private ServiceAppointment insert(String userId, String status) {
+ ServiceAppointment a = new ServiceAppointment();
+ a.setId(UUID.randomUUID().toString());
+ a.setUserId(userId);
+ a.setServiceType("station_visit");
+ a.setStatus(status);
+ a.setSource(Constants.APPOINTMENT_SOURCE_USER_SELF);
+ a.setRequestSource(Constants.APPOINTMENT_SOURCE_USER_SELF);
+ a.setCreatedAt(Instant.now());
+ appointmentMapper.insert(a);
+ return a;
+ }
+
+ // ==================== 契约收紧:用户端不得伪造来源/代登记身份 ====================
+
+ @Test
+ void userCreateForcesUserSelfSourceAndIgnoresForgedOperator() {
+ ServiceAppointmentRequest req = request();
+ // 用户在请求体里伪造"运营代登记"来源 + 登记人 + 非法来源
+ req.setRequestSource("operator_assisted");
+ req.setCreatedByOperatorId("op-forged");
+ req.setUserId("someone-else");
+
+ ServiceAppointmentDTO dto = appointmentService.createAppointment(USER_A, req);
+
+ ServiceAppointment saved = appointmentMapper.selectById(dto.getId());
+ assertNotNull(saved);
+ assertEquals(Constants.APPOINTMENT_SOURCE_USER_SELF, saved.getSource(),
+ "用户端来源必须恒为 user_self,不得采纳请求体");
+ assertEquals(Constants.APPOINTMENT_SOURCE_USER_SELF, saved.getRequestSource(),
+ "request_source 同样必须恒为 user_self");
+ assertNull(saved.getCreatedByOperatorId(),
+ "用户端不得写入代登记人(否则可伪造运营身份)");
+ assertEquals(USER_A, saved.getUserId(), "服务对象必须取登录主体,不得采纳请求体 userId");
+ }
+
+ @Test
+ void illegalSourceFromRequestCannotReachDatabase() {
+ // 若仍采纳请求体,非法的 operator_assisted 之外的任意值都会撞 chk_source_type(PG 500)。
+ // 收紧后无论如何都落 user_self,非法值根本不会进入 INSERT。
+ ServiceAppointmentRequest req = request();
+ req.setRequestSource("totally_illegal_source");
+
+ ServiceAppointmentDTO dto = appointmentService.createAppointment(USER_A, req);
+ ServiceAppointment saved = appointmentMapper.selectById(dto.getId());
+ assertEquals(Constants.APPOINTMENT_SOURCE_USER_SELF, saved.getSource());
+ }
+
+ // ==================== 归属守卫(IDOR) ====================
+
+ @Test
+ void userCannotReadOthersAppointment() {
+ ServiceAppointment a = insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
+ assertNull(appointmentService.getAppointmentDetail(a.getId(), USER_A),
+ "读取他人预约必须返回 null(不泄露存在性)");
+ assertNotNull(appointmentService.getAppointmentDetail(a.getId(), USER_B),
+ "本人应能读取自己的预约");
+ }
+
+ @Test
+ void userCannotCancelOthersAppointment() {
+ ServiceAppointment a = insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
+ assertThrows(RuntimeException.class,
+ () -> appointmentService.cancelAppointment(a.getId(), USER_A, "越权尝试"),
+ "取消他人预约必须被拒绝");
+ assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED,
+ appointmentMapper.selectById(a.getId()).getStatus(), "越权取消不得改变状态");
+ }
+
+ @Test
+ void userListOnlyReturnsOwnAppointments() {
+ insert(USER_A, Constants.APPOINTMENT_STATUS_SUBMITTED);
+ insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
+
+ List mine = appointmentService.getMyAppointments(USER_A);
+ assertTrue(mine.stream().allMatch(d -> USER_A.equals(d.getUserId())),
+ "我的预约列表不得包含他人预约");
+ assertTrue(mine.stream().anyMatch(d -> USER_A.equals(d.getUserId())));
+ }
+
+ // ==================== 代登记边界 ====================
+
+ @Test
+ void operatorRegisterCreatesOperatorAssistedRecord() {
+ ServiceAppointmentDTO dto = appointmentService.registerByOperator(USER_B, request(), OPERATOR);
+ ServiceAppointment saved = appointmentMapper.selectById(dto.getId());
+
+ assertEquals(Constants.APPOINTMENT_SOURCE_OPERATOR_ASSISTED, saved.getSource(),
+ "代登记来源必须为 operator_assisted");
+ assertEquals(OPERATOR, saved.getCreatedByOperatorId(),
+ "登记人必须取自登录运营主体");
+ assertEquals(USER_B, saved.getUserId(), "服务对象应为指定用户");
+ }
+
+ @Test
+ void operatorRegisterRequiresTargetUser() {
+ assertThrows(RuntimeException.class,
+ () -> appointmentService.registerByOperator(null, request(), OPERATOR),
+ "代登记必须指定服务对象用户");
+ assertThrows(RuntimeException.class,
+ () -> appointmentService.registerByOperator(" ", request(), OPERATOR),
+ "空白服务对象同样应被拒绝");
+ }
+
+ // ==================== 运营数据范围(service_location_operators)====================
+
+ private void exec(String sql, Object... params) {
+ try (var conn = dataSource.getConnection();
+ var ps = conn.prepareStatement(sql)) {
+ for (int i = 0; i < params.length; i++) {
+ ps.setObject(i + 1, params[i]);
+ }
+ ps.executeUpdate();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void insertLocation() {
+ exec("INSERT INTO service_locations (id, name, address, status, created_at) VALUES (?,?,?,?,?)",
+ loc, "万善公园服务站", "丹阳市万善公园", "active",
+ java.sql.Timestamp.from(Instant.now()));
+ insertedLocations.add(loc);
+ }
+
+ private ServiceAppointment insertAtLocationV2(String userId) {
+ ServiceAppointment a = insert(userId, Constants.APPOINTMENT_STATUS_SUBMITTED);
+ a.setServiceLocationId(loc);
+ appointmentMapper.updateById(a);
+ return a;
+ }
+
+ private void linkOperatorToLocation(String operatorId) {
+ exec("INSERT INTO service_location_operators (service_location_id, operator_id, created_at) VALUES (?,?,?)",
+ loc, operatorId, java.sql.Timestamp.from(Instant.now()));
+ if (!insertedOperatorLinks.contains(loc)) {
+ insertedOperatorLinks.add(loc);
+ }
+ }
+
+ /**
+ * 审计计数必须走 Mapper,**不能**用原生 JDBC 的独立连接。
+ *
+ * {@code DataScopeGuard.deny()} 通过 {@code auditLogMapper.insert} 写在**当前 Spring 事务内**,
+ * 该行尚未提交;裸 JDBC 新开的连接按 READ_COMMITTED 看不到它,会得到 before == after 的假结论。
+ */
+ private long dataScopeDeniedCount() {
+ return auditLogMapper.selectCount(
+ new LambdaQueryWrapper().eq(AuditLog::getAction, "DATA_SCOPE_DENIED"));
+ }
+
+ @Test
+ void operatorOutsideStationScopeCannotConfirm() {
+ insertLocation();
+ ServiceAppointment a = insertAtLocationV2(USER_A);
+
+ assertThrows(AccessDeniedException.class,
+ () -> appointmentService.confirmAppointment(a.getId(), "op-outsider"),
+ "未分配到该服务站的运营人员不得处理其预约");
+ assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED,
+ appointmentMapper.selectById(a.getId()).getStatus(), "被拒后状态不得改变");
+ }
+
+ @Test
+ void operatorInStationScopeCanConfirm() {
+ insertLocation();
+ linkOperatorToLocation("op-insider");
+ ServiceAppointment a = insertAtLocationV2(USER_A);
+
+ ServiceAppointmentDTO dto = appointmentService.confirmAppointment(a.getId(), "op-insider");
+ assertEquals(Constants.APPOINTMENT_STATUS_CONFIRMED, dto.getStatus(),
+ "所属服务站运营人员应能确认预约");
+ }
+
+ @Test
+ void dataScopeDenialIsAudited() {
+ insertLocation();
+ ServiceAppointment a = insertAtLocationV2(USER_A);
+ long before = dataScopeDeniedCount();
+
+ assertThrows(AccessDeniedException.class,
+ () -> appointmentService.confirmAppointment(a.getId(), "op-outsider"));
+
+ assertEquals(before + 1, dataScopeDeniedCount(),
+ "越权尝试必须留下 DATA_SCOPE_DENIED 审计(DataScopeGuard 职责)");
+ }
+
+ // ==================== 约束保真度:测试库已对齐生产 CHECK ====================
+
+ @Test
+ void testSchemaEnforcesAppointmentStatusConstraint() {
+ ServiceAppointment bad = new ServiceAppointment();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setUserId(USER_A);
+ bad.setServiceType("station_visit");
+ bad.setStatus("not_a_real_status");
+ bad.setSource(Constants.APPOINTMENT_SOURCE_USER_SELF);
+ bad.setRequestSource(Constants.APPOINTMENT_SOURCE_USER_SELF);
+ bad.setCreatedAt(Instant.now());
+
+ assertThrows(RuntimeException.class, () -> appointmentMapper.insert(bad),
+ "测试库必须持有生产 chk_appointment_status 约束(否则约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesSourceTypeConstraint() {
+ ServiceAppointment bad = new ServiceAppointment();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setUserId(USER_A);
+ bad.setServiceType("station_visit");
+ bad.setStatus(Constants.APPOINTMENT_STATUS_SUBMITTED);
+ bad.setSource("not_a_real_source");
+ bad.setRequestSource("not_a_real_source");
+ bad.setCreatedAt(Instant.now());
+
+ assertThrows(RuntimeException.class, () -> appointmentMapper.insert(bad),
+ "测试库必须持有生产 chk_source_type 约束(否则约束漂移盲区)");
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationAnnouncementPhase19IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationAnnouncementPhase19IntegrationTest.java
new file mode 100644
index 0000000..2a5eec1
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationAnnouncementPhase19IntegrationTest.java
@@ -0,0 +1,401 @@
+package com.danyang.match.platform.service_appointment;
+
+import com.danyang.match.platform.service_appointment.entity.ServiceLocation;
+import com.danyang.match.platform.service_appointment.entity.ServiceLocationAnnouncement;
+import com.danyang.match.platform.service_appointment.mapper.ServiceLocationAnnouncementMapper;
+import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
+import com.danyang.match.platform.service_appointment.service.ServiceLocationAnnouncementService;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 服务站「临时公告」真实 DB 集成测试(Phase 19 / U50)。
+ *
+ * 本能力此前**完全不存在**(无表/无实体/无接口/无测试)。本轮补齐时又查出两个叠加缺陷:
+ *
+ * 写了却接不上 :Service 与 V042 表都已存在,但全仓没有任何端点暴露它 ——
+ * 后台与小程序都拿不到公告。端点已补齐(见 {@code ServiceLocationOperatorController} /
+ * {@code ServiceLocationController})。
+ * 测试库缺表 :{@code service_location_announcements} 只存在于生产迁移,
+ * 任何调用在测试库都会 BadSqlGrammar —— 由 {@code EntityMigrationParityTest}
+ * 的生产表缺失守卫精准报出,本轮已补进 {@code schema-h2.sql}。
+ *
+ *
+ * 因此本测试的价值不只是"断言功能正确",更是让整条路径**真的跑在 SQL 上**:
+ * 可见性、软删过滤、过期语义、白名单与 CHECK 保真度都必须由真实数据库裁决。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+class ServiceLocationAnnouncementPhase19IntegrationTest {
+
+ @Autowired
+ private ServiceLocationAnnouncementService announcementService;
+ @Autowired
+ private ServiceLocationAnnouncementMapper announcementMapper;
+ @Autowired
+ private ServiceLocationMapper locationMapper;
+
+ private static final String OPERATOR = "op-location-19";
+ private static final String OTHER_OPERATOR = "op-location-19-b";
+
+ // ==================== 夹具 ====================
+
+ private ServiceLocation newLocation(String name) {
+ ServiceLocation location = new ServiceLocation();
+ location.setName(name);
+ location.setAddress("丹阳市万善公园婚姻登记处");
+ location.setStatus("active");
+ locationMapper.insert(location);
+ return location;
+ }
+
+ private Map announcementBody(String title) {
+ Map body = new HashMap<>();
+ body.put("title", title);
+ body.put("content", "本站本周三下午暂停对外服务,请改约周五。");
+ return body;
+ }
+
+ private ServiceLocationAnnouncement published(String locationId, String title) {
+ ServiceLocationAnnouncement created =
+ announcementService.create(locationId, announcementBody(title), OPERATOR);
+ return announcementService.publish(created.getId(), OPERATOR);
+ }
+
+ private GlobalExceptionHandler.BusinessException assertValidationFailure(Runnable action) {
+ GlobalExceptionHandler.BusinessException ex =
+ assertThrows(GlobalExceptionHandler.BusinessException.class, action::run);
+ assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode(),
+ "校验失败必须是 VALIDATION_ERROR(400),而非裸 RuntimeException 的 500");
+ return ex;
+ }
+
+ private GlobalExceptionHandler.BusinessException assertNotFound(Runnable action) {
+ GlobalExceptionHandler.BusinessException ex =
+ assertThrows(GlobalExceptionHandler.BusinessException.class, action::run);
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode());
+ return ex;
+ }
+
+ // ==================== 可见性:草稿不得外泄 ====================
+
+ @Test
+ void newlyCreatedAnnouncementIsDraftAndInvisibleToUsers() {
+ ServiceLocation location = newLocation("公告可见性站");
+
+ ServiceLocationAnnouncement created =
+ announcementService.create(location.getId(), announcementBody("内部草稿"), OPERATOR);
+
+ assertEquals("draft", created.getStatus(), "新建公告必须是 draft,不能直接对用户可见");
+ assertTrue(announcementService.getVisibleAnnouncements(location.getId()).isEmpty(),
+ "草稿公告一旦被用户端读到,就等于把未定稿内容提前发布");
+ assertEquals(1, announcementService.getOperatorAnnouncements(location.getId()).size(),
+ "运营端必须能看到草稿,否则草稿永远无法发布");
+ }
+
+ @Test
+ void publishingMakesAnnouncementVisibleAndStampsPublishedAt() {
+ ServiceLocation location = newLocation("发布站");
+ ServiceLocationAnnouncement draft =
+ announcementService.create(location.getId(), announcementBody("临时停诊"), OPERATOR);
+
+ ServiceLocationAnnouncement published = announcementService.publish(draft.getId(), OPERATOR);
+
+ assertEquals("published", published.getStatus());
+ assertNotNull(published.getPublishedAt(), "发布必须写入 published_at(排序依赖它)");
+ List visible =
+ announcementService.getVisibleAnnouncements(location.getId());
+ assertEquals(1, visible.size());
+ assertEquals(draft.getId(), visible.get(0).getId());
+ }
+
+ @Test
+ void archivingHidesAnnouncementFromUsersButKeepsRecordForOperators() {
+ ServiceLocation location = newLocation("下架站");
+ ServiceLocationAnnouncement published = published(location.getId(), "即将下架");
+
+ announcementService.archive(published.getId(), OPERATOR);
+
+ assertTrue(announcementService.getVisibleAnnouncements(location.getId()).isEmpty(),
+ "归档公告必须立刻对用户不可见");
+ assertEquals(1, announcementService.getOperatorAnnouncements(location.getId()).size(),
+ "归档不是删除:运营端仍需可见以便追溯");
+ }
+
+ // ==================== 过期语义("临时"公告的核心) ====================
+
+ @Test
+ void expiredPublishedAnnouncementIsNeverVisible() {
+ ServiceLocation location = newLocation("过期站");
+ Map body = announcementBody("已过期公告");
+ body.put("expiresAt", Instant.now().minusSeconds(3600).toString());
+ ServiceLocationAnnouncement created = announcementService.create(location.getId(), body, OPERATOR);
+ announcementService.publish(created.getId(), OPERATOR);
+
+ assertTrue(announcementService.getVisibleAnnouncements(location.getId()).isEmpty(),
+ "已过期的 published 公告必须自动对用户隐藏,无需运营手工下架");
+ }
+
+ @Test
+ void notYetExpiredAnnouncementStaysVisible() {
+ ServiceLocation location = newLocation("未过期站");
+ Map body = announcementBody("长期有效");
+ body.put("expiresAt", Instant.now().plusSeconds(86400).toString());
+ ServiceLocationAnnouncement created = announcementService.create(location.getId(), body, OPERATOR);
+ announcementService.publish(created.getId(), OPERATOR);
+
+ assertEquals(1, announcementService.getVisibleAnnouncements(location.getId()).size());
+ }
+
+ @Test
+ void nullExpiryMeansPermanentlyVisible() {
+ ServiceLocation location = newLocation("永久站");
+ published(location.getId(), "长期公告");
+
+ assertEquals(1, announcementService.getVisibleAnnouncements(location.getId()).size(),
+ "expires_at 为 null 表示长期有效(DDL 注释明确约定)");
+ }
+
+ // ==================== 排序 ====================
+
+ @Test
+ void visibleListFollowsSortOrderThenPublishedAtDesc() {
+ ServiceLocation location = newLocation("排序站");
+ Map low = announcementBody("次要公告");
+ low.put("sortOrder", 10);
+ ServiceLocationAnnouncement a = announcementService.create(location.getId(), low, OPERATOR);
+ announcementService.publish(a.getId(), OPERATOR);
+
+ Map high = announcementBody("首要公告");
+ high.put("sortOrder", 1);
+ ServiceLocationAnnouncement b = announcementService.create(location.getId(), high, OPERATOR);
+ announcementService.publish(b.getId(), OPERATOR);
+
+ List titles = announcementService.getVisibleAnnouncements(location.getId()).stream()
+ .map(ServiceLocationAnnouncement::getTitle).toList();
+ assertEquals(List.of("首要公告", "次要公告"), titles,
+ "必须按 sort_order 升序;排序失效会让运营的置顶操作毫无效果");
+ }
+
+ @Test
+ void nonIntegerSortOrderIsRejectedAsValidationError() {
+ ServiceLocation location = newLocation("排序校验站");
+ Map body = announcementBody("排序非法");
+ body.put("sortOrder", "最早");
+
+ assertValidationFailure(() ->
+ announcementService.create(location.getId(), body, OPERATOR));
+ }
+
+ // ==================== 软删过滤(全库同族缺口常客) ====================
+
+ @Test
+ void softDeletedAnnouncementIsInvisibleToBothSides() {
+ ServiceLocation location = newLocation("软删站");
+ ServiceLocationAnnouncement published = published(location.getId(), "将被软删");
+
+ ServiceLocationAnnouncement raw = announcementMapper.selectById(published.getId());
+ raw.setDeletedAt(Instant.now());
+ announcementMapper.updateById(raw);
+
+ assertTrue(announcementService.getVisibleAnnouncements(location.getId()).isEmpty(),
+ "软删后用户端不得再看到该公告(findVisibleByLocation 必须带 deleted_at IS NULL)");
+ assertTrue(announcementService.getOperatorAnnouncements(location.getId()).isEmpty(),
+ "软删后运营端列表也必须过滤(否则后台出现「幽灵公告」)");
+ }
+
+ // ==================== 状态白名单(约束保真度) ====================
+
+ @Test
+ void updateRejectsStatusOutsideProductionCheckWhitelist() {
+ ServiceLocation location = newLocation("状态白名单站");
+ ServiceLocationAnnouncement created =
+ announcementService.create(location.getId(), announcementBody("状态试验"), OPERATOR);
+
+ Map body = new HashMap<>();
+ body.put("status", "suspended");
+
+ assertValidationFailure(() ->
+ announcementService.update(created.getId(), body, OPERATOR));
+ }
+
+ @Test
+ void allowedStatusesMatchProductionCheckConstraint() {
+ assertEquals(java.util.Set.of("draft", "published", "archived"),
+ ServiceLocationAnnouncementService.allowedStatuses(),
+ "服务端白名单必须与 V042 chk_announcement_status 完全一致");
+ }
+
+ /**
+ * 约束保真度:非法状态即使绕过服务端校验,也必须被数据库 CHECK 拦下。
+ * 若测试库缺该 CHECK(本轮之前正是如此),本用例会静默通过 = 假绿。
+ */
+ @Test
+ void databaseCheckRejectsIllegalStatusEvenWhenServiceValidationIsBypassed() {
+ ServiceLocation location = newLocation("DB 约束站");
+
+ ServiceLocationAnnouncement illegal = new ServiceLocationAnnouncement();
+ illegal.setServiceLocationId(location.getId());
+ illegal.setTitle("绕过校验的非法公告");
+ illegal.setStatus("suspended");
+
+ assertThrows(Exception.class, () -> announcementMapper.insert(illegal),
+ "测试库必须保留 chk_announcement_status,否则「非法状态只在生产炸」的漂移盲区会重现");
+ }
+
+ // ==================== 标题校验 ====================
+
+ @Test
+ void blankTitleIsRejected() {
+ ServiceLocation location = newLocation("标题空白站");
+ Map body = announcementBody("占位");
+ body.put("title", " ");
+
+ assertValidationFailure(() ->
+ announcementService.create(location.getId(), body, OPERATOR));
+ }
+
+ @Test
+ void missingTitleIsRejected() {
+ ServiceLocation location = newLocation("标题缺失站");
+ Map body = new HashMap<>();
+ body.put("content", "没有标题");
+
+ assertValidationFailure(() ->
+ announcementService.create(location.getId(), body, OPERATOR));
+ }
+
+ @Test
+ void overlongTitleIsRejectedBeforeHittingColumnLength() {
+ ServiceLocation location = newLocation("标题超长站");
+ Map body = announcementBody("占位");
+ body.put("title", "长".repeat(201));
+
+ assertValidationFailure(() ->
+ announcementService.create(location.getId(), body, OPERATOR));
+ }
+
+ @Test
+ void malformedExpiresAtIsValidationErrorNotServerError() {
+ ServiceLocation location = newLocation("时间格式站");
+ Map body = announcementBody("时间非法");
+ body.put("expiresAt", "2026年9月20日");
+
+ assertValidationFailure(() ->
+ announcementService.create(location.getId(), body, OPERATOR));
+ }
+
+ // ==================== 站点 / 公告不存在 ====================
+
+ @Test
+ void operatorListOnUnknownLocationReturnsNotFound() {
+ assertNotFound(() -> announcementService.getOperatorAnnouncements(UUID.randomUUID().toString()));
+ }
+
+ @Test
+ void creatingAnnouncementForUnknownLocationReturnsNotFound() {
+ assertNotFound(() -> announcementService.create(
+ UUID.randomUUID().toString(), announcementBody("无人认领"), OPERATOR));
+ }
+
+ @Test
+ void updatingUnknownAnnouncementReturnsNotFound() {
+ Map body = new HashMap<>();
+ body.put("title", "改个名字");
+
+ assertNotFound(() -> announcementService.update(
+ UUID.randomUUID().toString(), body, OPERATOR));
+ }
+
+ @Test
+ void operatorListOnSoftDeletedLocationReturnsNotFound() {
+ ServiceLocation location = newLocation("已软删站");
+ location.setDeletedAt(Instant.now());
+ locationMapper.updateById(location);
+
+ assertNotFound(() -> announcementService.getOperatorAnnouncements(location.getId()));
+ }
+
+ /**
+ * 用户端语义:站点不存在/已软删时返回空列表而非抛错 ——
+ * 否则错误码可被用来枚举阵地(存在性探测)。
+ */
+ @Test
+ void userSideReturnsEmptyForUnknownOrDeletedLocationInsteadOfError() {
+ assertTrue(announcementService.getVisibleAnnouncements(UUID.randomUUID().toString()).isEmpty(),
+ "未知站点必须返回空列表(不可用错误码区分存在性)");
+ assertTrue(announcementService.getVisibleAnnouncements(null).isEmpty());
+ assertTrue(announcementService.getVisibleAnnouncements(" ").isEmpty());
+
+ ServiceLocation location = newLocation("用户端软删站");
+ published(location.getId(), "软删站公告");
+ location.setDeletedAt(Instant.now());
+ locationMapper.updateById(location);
+
+ assertTrue(announcementService.getVisibleAnnouncements(location.getId()).isEmpty(),
+ "站点软删后其公告不得再对用户可见");
+ }
+
+ // ==================== 归属与编辑 ====================
+
+ @Test
+ void publishAndArchiveAreIdempotentAndKeepOperatorsTrace() {
+ ServiceLocation location = newLocation("重复操作站");
+ ServiceLocationAnnouncement created =
+ announcementService.create(location.getId(), announcementBody("重复发布"), OPERATOR);
+
+ announcementService.publish(created.getId(), OPERATOR);
+ ServiceLocationAnnouncement second = announcementService.publish(created.getId(), OTHER_OPERATOR);
+
+ assertEquals("published", second.getStatus());
+ assertEquals(OTHER_OPERATOR, second.getUpdatedBy(),
+ "updated_by 必须记录最近一次操作者,便于审计追责");
+ }
+
+ @Test
+ void partialUpdateKeepsUntouchedFields() {
+ ServiceLocation location = newLocation("部分更新站");
+ Map body = announcementBody("原标题");
+ body.put("sortOrder", 5);
+ ServiceLocationAnnouncement created = announcementService.create(location.getId(), body, OPERATOR);
+
+ Map patch = new HashMap<>();
+ patch.put("title", "新标题");
+ ServiceLocationAnnouncement updated =
+ announcementService.update(created.getId(), patch, OPERATOR);
+
+ assertEquals("新标题", updated.getTitle());
+ assertEquals(5, updated.getSortOrder(), "未提供的字段必须保持原值(部分更新语义)");
+ assertEquals("draft", updated.getStatus(), "未显式改状态时不得顺带发布");
+ }
+
+ @Test
+ void updateCanPublishAndSuffixPublishedAt() {
+ ServiceLocation location = newLocation("更新即发布站");
+ ServiceLocationAnnouncement created =
+ announcementService.create(location.getId(), announcementBody("改状态发布"), OPERATOR);
+
+ Map patch = new HashMap<>();
+ patch.put("status", "published");
+ ServiceLocationAnnouncement updated =
+ announcementService.update(created.getId(), patch, OPERATOR);
+
+ assertEquals("published", updated.getStatus());
+ assertNotNull(updated.getPublishedAt(), "通过 update 转为 published 时也必须补 published_at");
+ assertEquals(1, announcementService.getVisibleAnnouncements(location.getId()).size());
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationControllerPermissionContractTest.java b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationControllerPermissionContractTest.java
new file mode 100644
index 0000000..3692e9e
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceLocationControllerPermissionContractTest.java
@@ -0,0 +1,107 @@
+package com.danyang.match.platform.service_appointment;
+
+import com.danyang.match.platform.service_appointment.controller.ServiceLocationController;
+import com.danyang.match.platform.service_appointment.controller.ServiceLocationOperatorController;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 服务站端点权限契约(Phase 19 复验新增)。
+ *
+ * 背景(真实缺陷,非假设):{@code ServiceLocationOperatorController} 原先把
+ * {@code @PreAuthorize("hasAuthority('service_location.view')")} 挂在**类**上,
+ * 于是 {@code create} / {@code update} 这两个写端点实际只要求"查看服务站"权限 ——
+ * V015 早已下发的 {@code service_location.manage} 成了**从未被使用的死权限**,
+ * 只读角色可以直接改站点信息。该控制器同时还**不在**
+ * {@code OperatorEndpointPermissionCoverageTest} 的扫描清单里,所以无人发现。
+ *
+ *
本守卫把下面这条不变量固化,防止再次退化:
+ * 服务站的所有写端点必须且只能由 {@code service_location.manage} 保护。
+ */
+class ServiceLocationControllerPermissionContractTest {
+
+ private static final String MANAGE = "hasAuthority('service_location.manage')";
+ private static final String VIEW = "hasAuthority('service_location.view')";
+
+ /** 写操作 → service_location.manage(含公告的增改、发布、归档)。 */
+ private static final Map WRITE_ENDPOINTS = Map.of(
+ "create", MANAGE,
+ "update", MANAGE,
+ "createAnnouncement", MANAGE,
+ "updateAnnouncement", MANAGE,
+ "publishAnnouncement", MANAGE,
+ "archiveAnnouncement", MANAGE);
+
+ /** 后台读取 → service_location.view。 */
+ private static final Map READ_ENDPOINTS = Map.of(
+ "list", VIEW,
+ "listAnnouncements", VIEW);
+
+ private static PreAuthorize operatorAnnotation(String methodName) {
+ var method = java.util.Arrays.stream(ServiceLocationOperatorController.class.getDeclaredMethods())
+ .filter(candidate -> candidate.getName().equals(methodName))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("找不到端点方法 " + methodName));
+ // 必须取**方法自身**的注解:类级 @PreAuthorize 只做兜底,
+ // 正是"类级 view + 无方法级注解"造就了越权写,因此方法级注解不可缺省。
+ return method.getAnnotation(PreAuthorize.class);
+ }
+
+ @Test
+ void everyWriteEndpointRequiresManagePermission() {
+ WRITE_ENDPOINTS.forEach((methodName, expression) -> {
+ PreAuthorize annotation = operatorAnnotation(methodName);
+ assertNotNull(annotation,
+ methodName + " 必须带**方法级**权限注解;只靠类级 service_location.view 即越权写");
+ assertEquals(expression, annotation.value(), methodName + " 权限码不符合 V015");
+ });
+ }
+
+ @Test
+ void everyReadEndpointRequiresViewPermission() {
+ READ_ENDPOINTS.forEach((methodName, expression) -> {
+ PreAuthorize annotation = operatorAnnotation(methodName);
+ assertNotNull(annotation, methodName + " 后台读取端点必须由权限保护");
+ assertEquals(expression, annotation.value(), methodName + " 权限码不符合 V015");
+ });
+ }
+
+ /**
+ * 类级注解不得再单独承担写保护 —— 显式断言类上不存在绕过方法级的粗粒度注解,
+ * 否则"新增写方法忘记加注解"会静默继承 view 权限。
+ */
+ @Test
+ void controllerClassMustNotCarryCoarsePermission() {
+ assertFalse(AnnotatedElementUtils.hasAnnotation(ServiceLocationOperatorController.class, PreAuthorize.class),
+ "类级 @PreAuthorize 会让新增写端点静默继承读权限 → 越权写。请一律使用方法级注解。");
+ }
+
+ /** 用户端公告读口必须**不**带运营权限(否则小程序拿不到公告)。 */
+ @Test
+ void userFacingAnnouncementEndpointIsNotOperatorGuarded() {
+ var method = java.util.Arrays.stream(ServiceLocationController.class.getDeclaredMethods())
+ .filter(candidate -> candidate.getName().equals("announcements"))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("找不到用户端公告端点 announcements"));
+ assertNull(AnnotatedElementUtils.findMergedAnnotation(method, PreAuthorize.class),
+ "用户端公告端点不应带运营权限注解;鉴权由 /** → authenticated 兜底");
+ }
+
+ /** 自检:契约清单必须真的覆盖了全部写端点,避免守卫因清单个案而恒真。 */
+ @Test
+ void guardMustCoverEveryDeclaredWriteEndpoint() {
+ long writeLookingEndpoints = java.util.Arrays.stream(ServiceLocationOperatorController.class.getDeclaredMethods())
+ .filter(m -> AnnotatedElementUtils.findMergedAnnotation(m,
+ org.springframework.web.bind.annotation.RequestMapping.class) != null)
+ .filter(m -> !List.of("list", "listAnnouncements").contains(m.getName()))
+ .count();
+ assertEquals(WRITE_ENDPOINTS.size(), writeLookingEndpoints,
+ "存在未被权限契约清单覆盖的写端点(新增端点请同步登记本测试)");
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramControllerPermissionContractTest.java b/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramControllerPermissionContractTest.java
new file mode 100644
index 0000000..79e8a4e
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramControllerPermissionContractTest.java
@@ -0,0 +1,76 @@
+package com.danyang.match.platform.service_program;
+
+import com.danyang.match.platform.service_program.controller.ServiceProgramController;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 专项服务端点权限契约(Phase 19 新增)。
+ *
+ * 背景:本模块此前**没有任何权限契约测试**。新增写端点若忘记加 {@code @PreAuthorize},
+ * 任何已登录运营(甚至被错误授予权限的角色)都能改项目与审批成员,属越权写入。
+ * 本守卫把"每个后台端点必须由 {@code service_program.view / manage} 保护"变成不变量。
+ */
+class ServiceProgramControllerPermissionContractTest {
+
+ private static final String MANAGE = "hasAuthority('service_program.manage')";
+ private static final String VIEW = "hasAuthority('service_program.view')";
+
+ /** 写操作 → service_program.manage */
+ private static final Map WRITE_ENDPOINTS = Map.of(
+ "create", MANAGE,
+ "update", MANAGE,
+ "activate", MANAGE,
+ "approveMember", MANAGE,
+ "rejectMember", MANAGE,
+ "removeMember", MANAGE);
+
+ /** 运营读取 → service_program.view */
+ private static final Map READ_ENDPOINTS = Map.of(
+ "operatorList", VIEW,
+ "operatorDetail", VIEW,
+ "operatorStats", VIEW,
+ "getMembers", VIEW);
+
+ private static PreAuthorize annotationOf(String methodName) {
+ var method = java.util.Arrays.stream(ServiceProgramController.class.getDeclaredMethods())
+ .filter(candidate -> candidate.getName().equals(methodName))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("找不到端点方法 " + methodName));
+ return method.getAnnotation(PreAuthorize.class);
+ }
+
+ @Test
+ void everyOperatorWriteEndpointRequiresManagePermission() {
+ WRITE_ENDPOINTS.forEach((methodName, expression) -> {
+ PreAuthorize annotation = annotationOf(methodName);
+ assertNotNull(annotation, methodName + " 必须由服务端权限保护(否则任何人可写)");
+ assertEquals(expression, annotation.value(), methodName + " 权限码不符合 V015");
+ });
+ }
+
+ @Test
+ void everyOperatorReadEndpointRequiresViewPermission() {
+ READ_ENDPOINTS.forEach((methodName, expression) -> {
+ PreAuthorize annotation = annotationOf(methodName);
+ assertNotNull(annotation, methodName + " 运营读取端点必须由权限保护");
+ assertEquals(expression, annotation.value(), methodName + " 权限码不符合 V015");
+ });
+ }
+
+ /**
+ * 用户端端点必须**不**要求运营权限(否则小程序用户无法申请/退出),
+ * 且不得因缺少 @PreAuthorize 而误开放运营能力。
+ */
+ @Test
+ void userFacingEndpointsAreNotOperatorGuarded() {
+ for (String methodName : java.util.List.of("list", "detail", "apply", "getMyApplications", "withdraw")) {
+ assertNull(annotationOf(methodName),
+ methodName + " 是用户端端点,不应带运营权限注解");
+ }
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramOperatorPhase19IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramOperatorPhase19IntegrationTest.java
new file mode 100644
index 0000000..285fdac
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/service_program/ServiceProgramOperatorPhase19IntegrationTest.java
@@ -0,0 +1,336 @@
+package com.danyang.match.platform.service_program;
+
+import com.danyang.match.platform.service_program.dto.ServiceProgramDTO;
+import com.danyang.match.platform.service_program.dto.ServiceProgramMemberDTO;
+import com.danyang.match.platform.service_program.entity.ServiceProgram;
+import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
+import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
+import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
+import com.danyang.match.platform.service_program.service.ServiceProgramService;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Phase 19(专项公益服务项目)真实 DB 集成测试。
+ *
+ * 本模块此前**没有任何测试**(`backend/src/test/.../service_program` 目录不存在),
+ * 而 `service_program_members` 表在测试库还缺 `UNIQUE(program_id, user_id)` 与
+ * `chk_program_member_status` —— 属"零覆盖 + 约束漂移盲区"叠加。本轮查出并修复:
+ *
+ * 「审批」是死路 :运营端名单只返回 approved,pending 申请永不出现。
+ * 草稿项目泄露 :公开列表/详情不限定状态。
+ * 成员数永远 0 :DTO 无 memberCount,统计无效。
+ * 「退出」未实现 :生产 CHECK 早已允许 withdrawn,无任何代码写入。
+ * 重复申请撞唯一键 :被拒/退出后再次申请 INSERT 撞 UNIQUE → 500。
+ * status 无校验 / 时间解析 500 。
+ *
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+class ServiceProgramOperatorPhase19IntegrationTest {
+
+ @Autowired
+ private ServiceProgramService programService;
+ @Autowired
+ private ServiceProgramMapper programMapper;
+ @Autowired
+ private ServiceProgramMemberMapper memberMapper;
+
+ private static final String OPERATOR = "op-program-19";
+ private static final String USER_A = "user-program-a";
+ private static final String USER_B = "user-program-b";
+
+ private Map programBody(String name) {
+ Map body = new HashMap<>();
+ body.put("name", name);
+ body.put("description", "Phase 19 测试项目");
+ body.put("startAt", Instant.now().plusSeconds(3600).toString());
+ body.put("endAt", Instant.now().plusSeconds(86400).toString());
+ return body;
+ }
+
+ /** 创建并激活一个项目(用户可申请的前提)。 */
+ private ServiceProgramDTO activeProgram(String name) {
+ ServiceProgramDTO draft = programService.createProgram(programBody(name), OPERATOR);
+ return programService.activateProgram(draft.getId());
+ }
+
+ private GlobalExceptionHandler.BusinessException assertValidationFailure(Runnable action) {
+ GlobalExceptionHandler.BusinessException ex =
+ assertThrows(GlobalExceptionHandler.BusinessException.class, action::run);
+ assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode(),
+ "校验失败必须是 VALIDATION_ERROR(400),而非裸 RuntimeException 的 500");
+ return ex;
+ }
+
+ // ==================== 审批必须可达 ====================
+
+ @Test
+ void operatorMemberListIncludesPendingSoApprovalIsReachable() {
+ ServiceProgramDTO program = activeProgram("待审批可见性");
+ programService.applyToProgram(USER_A, program.getId());
+
+ List statuses = programService.getProgramMembers(program.getId()).stream()
+ .map(ServiceProgramMemberDTO::getStatus).toList();
+ assertTrue(statuses.contains("pending"),
+ "运营端名单必须包含待审批申请,否则后台「通过/拒绝」按钮永远不出现");
+ }
+
+ @Test
+ void approveMovesPendingToApprovedAndUpdatesCounts() {
+ ServiceProgramDTO program = activeProgram("审批通过");
+ ServiceProgramMemberDTO applied = programService.applyToProgram(USER_A, program.getId());
+
+ programService.approveMember(applied.getId(), OPERATOR);
+
+ List members = programService.getProgramMembers(program.getId());
+ assertEquals("approved", members.get(0).getStatus());
+
+ ServiceProgramDTO listed = programService.getAllPrograms(null).stream()
+ .filter(p -> p.getId().equals(program.getId())).findFirst().orElseThrow();
+ assertEquals(1, listed.getMemberCount(), "已通过成员数必须真实统计(此前恒为 0)");
+ assertEquals(0, listed.getPendingCount());
+ }
+
+ @Test
+ void statsCountEveryStatus() {
+ ServiceProgramDTO program = activeProgram("统计口径");
+ programService.applyToProgram(USER_A, program.getId());
+ programService.applyToProgram(USER_B, program.getId());
+ ServiceProgramMember b = memberMapper.selectOne(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(ServiceProgramMember::getProgramId, program.getId())
+ .eq(ServiceProgramMember::getUserId, USER_B));
+ programService.approveMember(b.getId(), OPERATOR);
+
+ Map stats = programService.getProgramStats(program.getId());
+ assertEquals(1L, stats.get("approved"));
+ assertEquals(1L, stats.get("pending"));
+ assertEquals(0L, stats.get("rejected"));
+ assertEquals(0L, stats.get("withdrawn"));
+ assertEquals(2L, stats.get("total"));
+ }
+
+ @Test
+ void rejectRequiresReasonAndOnlyFromPending() {
+ ServiceProgramDTO program = activeProgram("拒绝规则");
+ ServiceProgramMemberDTO applied = programService.applyToProgram(USER_A, program.getId());
+
+ assertValidationFailure(() -> programService.rejectMember(applied.getId(), " "));
+ assertValidationFailure(() -> programService.rejectMember(applied.getId(), null));
+
+ programService.rejectMember(applied.getId(), "不符合本期招募条件");
+ // 已拒绝的申请不可重复审批
+ assertValidationFailure(() -> programService.rejectMember(applied.getId(), "再拒一次"));
+ assertValidationFailure(() -> programService.approveMember(applied.getId(), OPERATOR));
+ }
+
+ // ==================== 草稿项目不得泄露 ====================
+
+ @Test
+ void publicListAndDetailExcludeDraftPrograms() {
+ ServiceProgramDTO draft = programService.createProgram(programBody("尚未激活的项目"), OPERATOR);
+
+ assertFalse(programService.getActivePrograms().stream()
+ .map(ServiceProgramDTO::getName).toList().contains("尚未激活的项目"),
+ "草稿项目不得下发给用户端");
+ assertNull(programService.getPublicProgramDetail(draft.getId()), "草稿项目详情对用户端不可见");
+ assertTrue(programService.getAllPrograms(null).stream()
+ .map(ServiceProgramDTO::getName).toList().contains("尚未激活的项目"),
+ "运营端必须能看到草稿,否则无法激活");
+ assertEquals(draft.getId(), programService.getOperatorProgramDetail(draft.getId()).getId());
+ }
+
+ @Test
+ void applyRequiresActiveProgram() {
+ ServiceProgramDTO draft = programService.createProgram(programBody("草稿不可申请"), OPERATOR);
+ assertValidationFailure(() -> programService.applyToProgram(USER_A, draft.getId()));
+ }
+
+ // ==================== 退出 / 重新申请(唯一键) ====================
+
+ @Test
+ void reapplyAfterRejectionReusesRowWithoutUniqueViolation() {
+ ServiceProgramDTO program = activeProgram("被拒后重新申请");
+ ServiceProgramMemberDTO first = programService.applyToProgram(USER_A, program.getId());
+ programService.rejectMember(first.getId(), "本期已满");
+
+ ServiceProgramMemberDTO second = programService.applyToProgram(USER_A, program.getId());
+
+ assertEquals(first.getId(), second.getId(),
+ "生产 UNIQUE(program_id, user_id) 一直存在:重新申请必须复用原记录,否则撞唯一键 500");
+ assertEquals("pending", second.getStatus());
+ assertNull(second.getRejectedReason(), "重新申请应清空上一次拒绝原因");
+ assertEquals(1, programService.getProgramMembers(program.getId()).size(), "不得产生重复成员行");
+ }
+
+ @Test
+ void withdrawThenReapplyReusesRow() {
+ ServiceProgramDTO program = activeProgram("退出后重新申请");
+ ServiceProgramMemberDTO applied = programService.applyToProgram(USER_A, program.getId());
+ programService.approveMember(applied.getId(), OPERATOR);
+
+ ServiceProgramMemberDTO withdrawn = programService.withdrawFromProgram(USER_A, program.getId());
+ assertEquals("withdrawn", withdrawn.getStatus(),
+ "退出写 withdrawn(生产 chk_program_member_status 已允许该值,此前无任何代码写入)");
+
+ // 幂等:重复退出不报错
+ assertEquals("withdrawn", programService.withdrawFromProgram(USER_A, program.getId()).getStatus());
+
+ ServiceProgramMemberDTO again = programService.applyToProgram(USER_A, program.getId());
+ assertEquals(withdrawn.getId(), again.getId(), "退出后重新申请同样必须复用原记录");
+ assertEquals("pending", again.getStatus());
+ }
+
+ @Test
+ void withdrawUnknownMembershipIsNotFound() {
+ ServiceProgramDTO program = activeProgram("未加入不可退出");
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> programService.withdrawFromProgram(USER_A, program.getId()));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode());
+ }
+
+ @Test
+ void removeMemberWritesWithdrawn() {
+ ServiceProgramDTO program = activeProgram("运营移除");
+ ServiceProgramMemberDTO applied = programService.applyToProgram(USER_A, program.getId());
+ programService.approveMember(applied.getId(), OPERATOR);
+
+ ServiceProgramMemberDTO removed = programService.removeMember(applied.getId(), OPERATOR, "违反项目纪律");
+ assertEquals("withdrawn", removed.getStatus());
+ assertEquals("违反项目纪律", removed.getRejectedReason());
+
+ ServiceProgramDTO listed = programService.getAllPrograms(null).stream()
+ .filter(p -> p.getId().equals(program.getId())).findFirst().orElseThrow();
+ assertEquals(0, listed.getMemberCount(), "移除后已通过人数应下降");
+ }
+
+ @Test
+ void duplicateApplyWhilePendingOrApprovedIsRejected() {
+ ServiceProgramDTO program = activeProgram("重复申请");
+ ServiceProgramMemberDTO applied = programService.applyToProgram(USER_A, program.getId());
+ assertValidationFailure(() -> programService.applyToProgram(USER_A, program.getId()));
+
+ programService.approveMember(applied.getId(), OPERATOR);
+ assertValidationFailure(() -> programService.applyToProgram(USER_A, program.getId()));
+ }
+
+ // ==================== 校验(400 而非 500)====================
+
+ @Test
+ void unknownProgramStatusRejected() {
+ ServiceProgramDTO program = activeProgram("非法状态");
+ Map body = new HashMap<>();
+ body.put("status", "published");
+ assertValidationFailure(() -> programService.updateProgram(program.getId(), body));
+ }
+
+ @Test
+ void invalidInstantRejectedAsBadRequest() {
+ Map body = programBody("时间格式错误");
+ body.put("startAt", "2026-09-20 10:00");
+ // 此前 Instant.parse 直接抛 DateTimeParseException → 兜底 handler 500
+ assertValidationFailure(() -> programService.createProgram(body, OPERATOR));
+ }
+
+ @Test
+ void startAfterEndRejected() {
+ Map body = programBody("时间倒挂");
+ body.put("startAt", Instant.now().plusSeconds(86400).toString());
+ body.put("endAt", Instant.now().toString());
+ assertValidationFailure(() -> programService.createProgram(body, OPERATOR));
+ }
+
+ @Test
+ void blankProgramNameRejected() {
+ Map body = programBody("占位");
+ body.put("name", " ");
+ assertValidationFailure(() -> programService.createProgram(body, OPERATOR));
+ }
+
+ @Test
+ void unknownProgramReturnsNotFound() {
+ Map body = new HashMap<>();
+ body.put("name", "不存在");
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> programService.updateProgram(UUID.randomUUID().toString(), body));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode());
+ }
+
+ @Test
+ void softDeletedProgramIsInvisible() {
+ ServiceProgramDTO program = activeProgram("软删项目");
+ ServiceProgram entity = programMapper.selectById(program.getId());
+ entity.setDeletedAt(Instant.now());
+ programMapper.updateById(entity);
+
+ assertFalse(programService.getAllPrograms(null).stream()
+ .map(ServiceProgramDTO::getId).toList().contains(program.getId()));
+ assertNull(programService.getPublicProgramDetail(program.getId()));
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> programService.getOperatorProgramDetail(program.getId()));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode());
+ }
+
+ // ==================== 约束保真度 ====================
+
+ /**
+ * 保真度断言:测试库必须真的有生产那两条约束。
+ * 缺 UNIQUE 时"重新申请撞唯一键"的必崩缺陷不可见;缺 CHECK 时非法状态不可见。
+ */
+ @Test
+ void testSchemaEnforcesMemberStatusCheckConstraint() {
+ ServiceProgramDTO program = activeProgram("约束保真-状态");
+ ServiceProgramMember bad = new ServiceProgramMember();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setProgramId(program.getId());
+ bad.setUserId("user-bypass-status");
+ bad.setStatus("kicked");
+ bad.setAppliedAt(Instant.now());
+ assertThrows(Exception.class, () -> memberMapper.insert(bad),
+ "chk_program_member_status 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesMemberUniqueConstraint() {
+ ServiceProgramDTO program = activeProgram("约束保真-唯一键");
+ programService.applyToProgram(USER_A, program.getId());
+
+ ServiceProgramMember duplicate = new ServiceProgramMember();
+ duplicate.setId(UUID.randomUUID().toString());
+ duplicate.setProgramId(program.getId());
+ duplicate.setUserId(USER_A);
+ duplicate.setStatus("pending");
+ duplicate.setAppliedAt(Instant.now());
+ assertThrows(Exception.class, () -> memberMapper.insert(duplicate),
+ "uk_program_member 未生效:测试库缺少生产 UNIQUE(program_id, user_id)(约束漂移盲区)");
+ }
+
+ @Test
+ void testSchemaEnforcesProgramStatusCheckConstraint() {
+ ServiceProgram bad = new ServiceProgram();
+ bad.setId(UUID.randomUUID().toString());
+ bad.setName("绕过校验的项目");
+ bad.setStatus("published");
+ bad.setCreatedAt(Instant.now());
+ assertThrows(Exception.class, () -> programMapper.insert(bad),
+ "chk_program_status 未生效:测试库缺少生产约束(约束漂移盲区)");
+ }
+}
diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql
index 18a1a96..c2ffa28 100644
--- a/backend/src/test/resources/schema-h2.sql
+++ b/backend/src/test/resources/schema-h2.sql
@@ -133,7 +133,15 @@ CREATE TABLE service_appointments (
confirmed_end_at TIMESTAMP WITH TIME ZONE, cancelled_by VARCHAR(36), cancel_reason VARCHAR(500),
source VARCHAR(64), request_source VARCHAR(64), created_by_operator_id VARCHAR(36),
user_confirmed_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE,
- updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
+ updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- Phase 19:生产 V021 的两条 CHECK 此前在测试库完全缺失 → 非法 status/source
+ -- 在测试期不可见,上到 PostgreSQL 才撞约束返回 500(Phase 11/13 同族约束漂移根因)。
+ CONSTRAINT chk_appointment_status CHECK (status IN (
+ 'submitted', 'pending_confirmation', 'confirmed', 'completed', 'cancelled'
+ )),
+ CONSTRAINT chk_source_type CHECK (source IN (
+ 'user_self', 'matchmaker_referral', 'activity_followup', 'service_station', 'operator_assisted'
+ ))
);
CREATE TABLE counseling_services (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), service_type VARCHAR(64), request_source VARCHAR(64),
@@ -164,11 +172,37 @@ CREATE TABLE counseling_followups (
next_followup_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE
);
+-- V026: service_programs 专项公益服务项目
+-- Phase 19:此前仅在 TEST_SCHEMA_OPTIONAL_TABLES 白名单(= 无人测),
+-- 导致 ServiceProgramService 全链路(创建/编辑/激活/成员审批)零覆盖。已补入并移出白名单。
+CREATE TABLE service_programs (
+ id VARCHAR(36) PRIMARY KEY, name VARCHAR(128), description CLOB, cover_file_id VARCHAR(36),
+ organizer_org_id VARCHAR(36), start_at TIMESTAMP WITH TIME ZONE, end_at TIMESTAMP WITH TIME ZONE,
+ status VARCHAR(32), created_by VARCHAR(36),
+ created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- V026 生产约束
+ CONSTRAINT chk_program_status CHECK (status IN ('draft', 'active', 'ended', 'cancelled'))
+);
+
+-- V028: service_location_operators 服务站运营人员数据范围(复合主键,无 id 列)
+-- Phase 19:此前在可选白名单且测试库缺失 → ServiceAppointmentMapper.canAccessByOperator /
+-- findAccessibleByOperator 的 LEFT JOIN 在测试库直接 BadSqlGrammar,「预约数据范围」路径零覆盖。
+CREATE TABLE service_location_operators (
+ service_location_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL, created_by VARCHAR(36),
+ created_at TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (service_location_id, operator_id)
+);
+
CREATE TABLE service_program_members (
id VARCHAR(36) PRIMARY KEY, program_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),
applied_at TIMESTAMP WITH TIME ZONE, approved_at TIMESTAMP WITH TIME ZONE, approved_by VARCHAR(36),
rejected_reason VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE,
- updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
+ updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- 对齐生产 V026:UNIQUE(program_id, user_id)。缺此约束时"被拒/退出后重新申请"
+ -- 撞唯一键 500 的必崩缺陷在测试期完全不可见(Phase 15 同族根因)。
+ CONSTRAINT uk_program_member UNIQUE (program_id, user_id),
+ -- 对齐生产 V026 chk_program_member_status:withdrawn 表示用户退出 / 运营移除
+ CONSTRAINT chk_program_member_status CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn'))
);
CREATE TABLE user_local_eligibilities (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) UNIQUE, eligibility_type VARCHAR(32),
@@ -217,7 +251,18 @@ CREATE TABLE home_banners (
button_text VARCHAR(64), target_type VARCHAR(64), target_value VARCHAR(200),
audience_scope VARCHAR(64), sort_order INT, start_at TIMESTAMP WITH TIME ZONE,
end_at TIMESTAMP WITH TIME ZONE, status VARCHAR(32), created_by VARCHAR(36), updated_by VARCHAR(36),
- created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
+ created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- Phase 19:生产 V019 的三条 CHECK 此前在测试库完全缺失(Phase 11/13 同族约束漂移)。
+ -- target_type 白名单同时是"禁外链"的服务端落点:外链类型不在白名单内。
+ CONSTRAINT chk_banner_target_type CHECK (target_type IN (
+ 'activity', 'matchmaker', 'counseling', 'premarital', 'service_location', 'article', 'internal_page', 'none'
+ )),
+ CONSTRAINT chk_banner_audience_scope CHECK (audience_scope IN (
+ 'all', 'onboarding', 'searching', 'getting_to_know', 'successful'
+ )),
+ CONSTRAINT chk_banner_status CHECK (status IN (
+ 'draft', 'scheduled', 'active', 'inactive', 'expired'
+ ))
);
-- V019: service_locations 善缘服务站
@@ -227,15 +272,48 @@ CREATE TABLE service_locations (
contact_phone_display VARCHAR(32), opening_hours_text CLOB, service_description CLOB,
cover_file_id VARCHAR(36), primary_organization_id VARCHAR(36), notice_text CLOB,
status VARCHAR(32), created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
- deleted_at TIMESTAMP WITH TIME ZONE
+ deleted_at TIMESTAMP WITH TIME ZONE,
+ -- Phase 19:生产 V019 的服务站状态 CHECK 此前在测试库缺失(同族约束漂移)
+ CONSTRAINT chk_service_location_status CHECK (status IN ('active', 'inactive', 'closed'))
+);
+
+-- V042: service_location_announcements 善缘服务站「临时公告」(Phase 19 / U50)
+-- Phase 19:该表此前**完全缺失于测试库**,而公告是本轮为新链路所建 —— 若不补表,
+-- 公告 Service 一旦被任何测试调用即 BadSqlGrammar(表不存在),且
+-- EntityMigrationParityTest 的生产表缺失守卫会直接变红(本轮即由此发现)。
+-- 对齐 V042:去外键(H2 无 operators 表),保留 chk_announcement_status 白名单
+-- (「非法状态只在 PostgreSQL 上炸」的必崩盲区同族根因)。
+CREATE TABLE service_location_announcements (
+ id VARCHAR(36) PRIMARY KEY, service_location_id VARCHAR(36) NOT NULL,
+ title VARCHAR(200) NOT NULL, content CLOB, status VARCHAR(32) NOT NULL DEFAULT 'draft',
+ published_at TIMESTAMP WITH TIME ZONE, expires_at TIMESTAMP WITH TIME ZONE, sort_order INT NOT NULL DEFAULT 0,
+ created_by VARCHAR(36), updated_by VARCHAR(36),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMP WITH TIME ZONE,
+ CONSTRAINT chk_announcement_status CHECK (status IN ('draft', 'published', 'archived'))
);
-- V026: content_articles 婚恋课堂文章
+-- V026: content_categories 内容分类(运营后台「内容分类」tab 的唯一落点)
+-- Phase 19:此前该表完全缺失于测试库,整条分类路径零覆盖(与 Phase 12 缺 user_identities
+-- 导致首页零覆盖同族)。补表同时补生产 chk_category_status,避免非法状态在测试期不可见。
+CREATE TABLE content_categories (
+ id VARCHAR(36) PRIMARY KEY, name VARCHAR(128) NOT NULL, code VARCHAR(64) NOT NULL,
+ description CLOB, sort_order INT, status VARCHAR(32),
+ created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- 对齐生产 V026:UNIQUE(code)。缺此约束时"编码重复"在测试期静默通过,生产 500。
+ CONSTRAINT uk_content_category_code UNIQUE (code),
+ CONSTRAINT chk_category_status CHECK (status IN ('active', 'inactive'))
+);
CREATE TABLE content_articles (
id VARCHAR(36) PRIMARY KEY, category_id VARCHAR(36), title VARCHAR(200), summary VARCHAR(500),
content CLOB, cover_file_id VARCHAR(36), author_operator_id VARCHAR(36), status VARCHAR(32),
publish_at TIMESTAMP WITH TIME ZONE, view_count INT, sort_order INT,
- created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
+ created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
+ -- 对齐生产 V026 chk_article_status:此前测试库缺该 CHECK,非法状态只在 PostgreSQL 上炸
+ -- (Phase 11/13/14/15 同族约束漂移根因)。
+ CONSTRAINT chk_article_status CHECK (status IN ('draft', 'scheduled', 'published', 'offline'))
);
-- V010: activities 公益活动(首页近期活动预览)
diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md
index f036100..8d066e6 100644
--- a/docs/JIRA_BACKLOG.md
+++ b/docs/JIRA_BACKLOG.md
@@ -22,7 +22,9 @@
>
> **2026-09-17 Phase 18 复验关闭:** EPIC-P18-V11 已完成。复验发现申诉整条真实 DB 路径因测试库缺 `appeals` 表而**零覆盖**,`AppealServiceTest` 9 例中 6 例是 `assertTrue(true)` 假绿(覆盖的恰是 Gate 四条不变量);且 `escalateAppeal` 向 `operatorId` 发用户通知会撞 `notifications.user_id → users(id)` FK(operators 与 users 不同表)→ PG 上升级 100% 失败;`appealType` 未校验生产 `chk_appeal_type` 白名单。另修复 escalated 死胡同、`processAppeal` 先读后写并发竞态、三处查询漏软删过滤、硬编码 `resolved`、证据字段契约。共修复 **8 处真实缺陷**(含 1 处必崩级测试盲区、2 处「只在 PG 上炸」),补 `appeals` 表并新增 `AppealPhase18IntegrationTest`(15 例)、删除假绿 9 例,负向验证证明 Gate 守卫有牙。全量回归 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0。
> 证据:`docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`。
-> **Phase 19 需用户确认后启动。**
+>
+> **2026-09-17 Phase 19 复验关闭:** EPIC-P19 已完成。复验不采信任何「历史 ✅」,逐条核对运行时路径与生产约束,查出并修复 **25 处真实缺陷**。高价值项:①**服务站「临时公告」整条不可达** —— `ServiceLocationAnnouncementService` 与 V042 表都已存在,但**全仓零端点**,后台与小程序都拿不到(D20);②**服务站 `create/update` 越权写** —— `@PreAuthorize("service_location.view")` 挂在**类**上,V015 下发的 `service_location.manage` 是**从未被使用的死权限**,只读角色可直接改站点(D21);③该控制器**未纳入** `OperatorEndpointPermissionCoverageTest` 扫描清单,导致 D21 长期隐形(D22);④Banner 校验失败抛裸 `RuntimeException` → **500**(应 400)+ 软删 Banner 仍上首页(D2/D3);⑤Content「发布」死路(后台复用只返回 published 的公开接口)+ 分类停用不可恢复(D5/D6);⑥ServiceProgram 审批死路 / 草稿泄露 / 成员数恒 0 / 退出未实现 / 重复申请撞唯一键(D10~D14);⑦测试库缺 `content_categories` 与 `service_location_announcements` → 对应路径**零覆盖**(D9/D23);⑧预约 IDOR(D18);⑨`MigrationValidationTest` 基线未跟进 V042(D24)。新增守卫:`ServiceLocationAnnouncementPhase19IntegrationTest`(24) / `ServiceLocationControllerPermissionContractTest`(5) / `ServiceLocationAnnouncementConstraintParityTest`(5,生产 DDL↔测试库↔后端白名单↔后台标签四方一致);`V11PermissionMatrixContractTest` 由硬编码 V015+V029 改为扫描全部迁移。负向验证:注入 3 处缺陷(移除软删过滤 / 移除测试库 CHECK / 写端点降级为 `view`)→ **4 个用例精准变红且无无关失败**,还原后 `NEG-VAL` 残留 0。后端 **602 tests 全绿**;admin-web `vue-tsc` EXIT=0 + `vite build` EXIT=0;小程序 `tsc --noEmit` EXIT=0;ui-tests 14/14 PASS。
+> 证据:`docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 20 尚未获准。**
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
>
@@ -145,6 +147,7 @@
| Phase 16 活动互选二阶段同意复验 | 已完成(2026-09-17) | 见 `Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷**(**D1 双确认后根本不建 Match/会话(Gate 缺陷·INV-014/015)**、**D3 Phase 15 提交态引用未定义 `reopenEpisode` 致整个模块不编译("474 绿"为假象)**、D4 INV-006 单向意向泄露、D5 复用/新建 Match 后缺会话);统一双实现到 `RelationshipService.confirmActivityInterest`;`MatchMapper` 补 `reopenEpisode`;测试库补 `activity_mutual_connection_consents` 表并移入必选集合,`MigrationValidationTest` 基线 40→41;新建 `ActivityMutualConsentServiceTest`(7 用例);`ChatPhase09IntegrationTest:344` 断言对齐 AGENTS.md #20 复用语义;新增 V041 `expires_at`+`match_id`;负向验证 2 用例精准变红并还原;后端 **481 tests 全绿**;**Phase 17 尚未获准** |
| Phase 17 举报与风控复验 | 已完成(2026-09-17) | 见 `Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **6 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `reports`/`risk_events`/`risk_actions` 三表 → 风控/举报真实 DB 路径零覆盖,`ReportServiceTest` 全 `assertTrue(true)` 假绿**;**D3(High)敏感访问(查看他人风险画像/举报)无审计(违反 AGENTS.md #8)**;D4 审计缺 `ip`/`userAgent`;D5 `findByUserId` 未过滤软删;D2 人工标记无审计;**D6 `javax`→`jakarta` 隐藏编译缺陷 + `risk_events` 漏 `updated_at` 列**);补齐三表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增 `AuditLogService.recordSensitiveAccess` 并接线三端点;新增 `ReportAndRiskPhase17IntegrationTest`(7 用例);负向验证(注释 getReportDetail 审计 → D3 守卫精准变红并还原);后端 **488 tests 全绿**;**Phase 18 尚未获准** |
| Phase 18 申诉复验 | 已完成(2026-09-17) | 见 `Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **8 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `appeals` 表 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖**;**D2(High,只在 PG 上炸)`escalateAppeal` 向 `operatorId` 发用户通知撞 `notifications.user_id → users(id)` FK → 升级事务整体回滚**;**D3(High,只在 PG 上炸)`appealType` 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同(前后端均无法再决策);D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏 `deleted_at` 过滤;D7 硬编码 `resolved`;D8 证据字段契约 `evidenceFileIds`);补 `appeals` 表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);负向验证(误伤其他处置 → Gate 用例精准变红并还原);后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0;**Phase 19 尚未获准** |
+| Phase 19 统一运营后台 + 用户端公益服务页面复验 | 已完成(2026-09-17) | 见 `Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **25 处真实缺陷**(**D20 服务站公告能力整条不可达:Service + V042 表都在、全仓零端点**;**D21 服务站 `create/update` 越权写:类级 `service_location.view` 兜底,`service_location.manage` 是从未使用的死权限**;D22 该控制器未纳入权限覆盖守卫;**D2/D3 Banner 校验 500 + 软删 Banner 仍上首页**;**D5/D6 Content 发布死路 + 分类停用不可恢复**;D10~D14 ServiceProgram 审批死路/草稿泄露/成员数恒 0/退出未实现/撞唯一键;D9/D23 测试库缺 `content_categories` 与 `service_location_announcements` → 零覆盖;D18 预约 IDOR;D24 迁移基线未跟进 V042);补 V042 公告表 + 运营端五端点 + 用户端只读端点 + 后台公告管理 UI + 小程序 U50 公告区块;新增 3 组守卫(24+5+5 用例)并把服务站运营端收入权限覆盖扫描;负向验证 3 注入 → 4 用例精准变红;后端 **602 tests 全绿**;admin-web `vue-tsc`/`vite build` 双绿;小程序 `tsc` EXIT=0;ui-tests 14/14;**Phase 20 尚未获准** |
| 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 通过用户验收 |
diff --git a/docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md b/docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md
new file mode 100644
index 0000000..ab9283f
--- /dev/null
+++ b/docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md
@@ -0,0 +1,217 @@
+# Phase 19 V1.1 Recovery Completion Report — 统一运营后台 + 用户端公益服务页面
+
+> 日期:2026-09-17
+> 阶段:Phase 19(统一运营后台 ADD + 用户端公益服务页面 REFACTOR)
+> 分支:`main` | Tag:`phase19-recovery-2026-09-17`
+> 结论:**CLOSED(Gate 全项通过)**
+
+---
+
+## 一、计划目标与 Gate
+
+官方 Phase 19 计划(`WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件.md` 第 1734 行起)要求的是「统一运营后台」;
+`STATUS.md` 的 V1.1 待实现模块表把 **Banner 管理 / 内容管理 / 专项公益服务项目 / 善缘服务站与用户端公益服务页面** 一并映射到 Phase 19,
+用户端 U 编号页面定义见 `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`(U49~U54 / U60 / U61 / U64 / U37 / U50)。
+
+| Gate | 要求 | 结论 | 证据 |
+|---|---|---|---|
+| Gate ① | 小程序页面清单无缺页且路由可达 | ✅ 通过 | `ui-tests/ui-contracts.mjs`:`app.json` 页面数 **63**;14 个 ui-tests 全绿 |
+| Gate ② | 七类后台角色只看到自己的工作 | ✅ 通过 | `admin-role-navigation-contracts.mjs` 全绿;`V11PermissionMatrixContractTest` 扫全部迁移;`OperatorEndpointPermissionCoverageTest` 扩至含服务站 |
+| Gate ③ | 后台 type-check / build 通过 | ✅ 通过 | `vue-tsc --noEmit` EXIT=0;`vite build` EXIT=0(9.17s,仅 chunk 体积警告) |
+| Gate ④ | 公告 / Banner / 内容 / 服务项目 / 预约链路真实可达 | ✅ 通过 | 后端全量回归 **602 tests, 0 failures, 0 errors** |
+
+---
+
+## 二、复验方法(KEEP-VERIFY)
+
+本轮不采信任何「历史 ✅」。对每一项交付物按下列顺序逐条核对:
+
+1. **运行时路径**:从 HTTP 端点出发,逐跳确认 Controller → Service → Mapper → DDL 无断点、无死路。
+2. **生产约束**:核对生产迁移里的 `NOT NULL` / `CHECK` / `UNIQUE` / 外键,确认代码有前置校验。
+3. **测试库保真度**:测试库 `schema-h2.sql` 是否与生产同约束(缺约束 = 该非法路径零覆盖 = 假绿)。
+4. **守卫有效性**:守卫是否解析**真实 DDL**、是否会**精准变红**。
+5. **前端落点**:后端能力是否有真实前端入口(后台 + 小程序两端)。
+
+---
+
+## 三、复验查出的真实缺陷与修复
+
+### 3.1 死路类(能力写了但接不上 → 用户/运营 100% 用不到)
+
+| 编号 | 模块 | 缺陷 | 根因 | 修复 |
+|---|---|---|---|---|
+| D1 | `/me` | `GET /me/service-history` **端点从未写出** | 仅有 Service 方法,Controller 缺路由 | `MeController` 补端点,`MeRouteContractTest` 转绿 |
+| D5 | Content | 后台「发布」是**死路**:后台复用只返回 `published` 的公开接口,草稿永远不出现 | 公开/运营未分离 | 新增 `getOperatorArticles/Categories/ArticleDetail` + `/content/operator/*` 三端点 |
+| D10 | Service Program | **审批死路**:运营端名单只返回 `approved`,`pending` 申请永不出现,后台「通过/拒绝」按钮永远点不到 | 名单查询复用用户端语义 | `findByProgram` 全状态 + `getProgramMembers` |
+| D13 | Service Program | **「退出」未实现**:生产 CHECK 早已允许 `withdrawn`,无任何代码写入 | 清单项未实现 | `withdrawFromProgram` + `removeMember` |
+| D20 | Service Station | **公告整条不可达**:`ServiceLocationAnnouncementService` 与 V042 表都存在,但**全仓零端点**,后台与小程序都拿不到 | 只写了 Service,未接 Controller | 运营端 5 端点 + 用户端只读 1 端点 |
+
+### 3.2 越权 / 安全类
+
+| 编号 | 模块 | 缺陷 | 修复 |
+|---|---|---|---|
+| D18 | Service Appointment | **预约 IDOR**:越权访问他人预约 | 控制器取 `principal.id()` + service 归属校验 + 运营侧 `DataScopeGuard` / `canAccessByOperator` |
+| D21 | Service Station | **越权写**:`@PreAuthorize("service_location.view")` 挂在**类**上,`create`/`update` 实际只要求"查看"权限;V015 下发的 `service_location.manage` 成了**从未被使用的死权限** → 只读角色可直接改站点 | 权限下沉到方法级:读 `view`、写 `manage` |
+| D22 | Service Station | `ServiceLocationOperatorController` **未纳入** `OperatorEndpointPermissionCoverageTest` 扫描清单 → D21 长期隐形 | 控制器加入 `CONTROLLERS` |
+
+### 3.3 约束漂移类(生产有约束、测试库没有 → 只在 PG 上炸)
+
+| 编号 | 缺陷 | 修复 |
+|---|---|---|
+| D2 | Banner 校验失败抛裸 `RuntimeException` → 落兜底 handler 变 **500**(应为 400) | 统一 `BusinessException(VALIDATION_ERROR)` |
+| D3 | `findActiveBanners` / `findActiveBannersByScope` 漏 `deleted_at IS NULL` → **软删 Banner 仍上首页** | 补软删过滤 |
+| D9/D23 | 测试库缺 `content_categories`、`service_location_announcements`,且缺 `chk_category_status` / `chk_announcement_status` → 对应路径零覆盖 | 补表 + 补 CHECK;移入 `REQUIRED_IN_TEST_SCHEMA` |
+| D25 | 服务站 `create/update` 无入参校验:`address NOT NULL` 与 `status` CHECK 只在 PG 炸 | 前置校验 → 400 |
+| D11 | Service Program 公开列表/详情**不限定状态** → 草稿项目泄露 | `getActivePrograms` / active 限定 |
+| D14 | 重复申请撞 `UNIQUE(program_id, user_id)` → 500 | 复用原记录(被拒/退出后重新申请) |
+
+### 3.4 数据正确性类
+
+| 编号 | 缺陷 | 修复 |
+|---|---|---|
+| D12 | Service Program 成员数 / 待审数**恒为 0**(DTO 无字段) | DTO 增 `memberCount` / `pendingCount` + 聚合统计 |
+| D6 | Content 分类**停用不可恢复**(公开接口只给 active) | `getOperatorCategories` + `setCategoryStatus`(不做物理删除,避免破坏文章外键) |
+| D7/D8 | 内容编辑看不到正文;前后端字段命名不一致(snake vs camel) | 详情带 `content`;前端对齐 camelCase |
+
+### 3.5 前端与守卫类
+
+| 编号 | 缺陷 | 修复 |
+|---|---|---|
+| D4 | Banner 后台缺 `buttonText` / 状态下拉 / 预览 / 禁外链前置校验 | `BannerManagementView` 补齐(预览用模拟小程序卡片,**禁外链**) |
+| D15 | `V11PermissionMatrixContractTest` **硬编码 V015+V029** → 后续迁移的权限变更全部隐形 | 改为扫描**全部迁移** |
+| D16 | V042 曾新增 `service_appointment.register` 权限,与 V029 的 `service.assisted_registration` 语义重复 | 删除重复迁移,收敛到既有权限(收敛权限膨胀) |
+| D17 | U37「我的」未按信息架构分四组 → 入口找不到归属 | 重构为四组 + 22 个 `bindtap` 全部有实现(ui-tests 增断言) |
+| D19 | `ServiceProgramOperatorPhase19IntegrationTest:114` 编译错误(`mapper.selectOne` 返回**实体**却赋给 **DTO**) | 修正变量类型;该错误此前让整轮回归卡在 testCompile |
+
+---
+
+## 四、本轮新增能力(此前完全不存在)
+
+### 4.1 善缘服务站「临时公告」(V042)
+
+全仓此前**无表、无实体、无 Mapper、无接口**。本轮补齐:
+
+| 层 | 文件 | 说明 |
+|---|---|---|
+| DDL | `V042__add_service_location_announcements.sql` | `status` CHECK(draft/published/archived)、`expires_at` 支撑"临时"语义、软删、`(location_id, status, sort_order)` 部分索引 |
+| Entity | `ServiceLocationAnnouncement.java` | — |
+| Mapper | `ServiceLocationAnnouncementMapper.java` | `findVisibleByLocation`(published + 未过期 + 未软删)、`findByLocation`(运营全状态) |
+| Service | `ServiceLocationAnnouncementService.java` | 可见性 / 白名单 / 标题长度 / ISO-8601 解析 / 404 语义 |
+| 运营端点 | `ServiceLocationOperatorController` | 列表 / 新建 / 编辑 / 发布 / 归档(读 `view`、写 `manage`) |
+| 用户端点 | `ServiceLocationController` | `GET /service-locations/{id}/announcements`,只读 published + 未过期 |
+| 后台 UI | `ServiceStationView.vue` | 公告抽屉:全状态列表 + 新建/编辑 + 发布/下架二次确认 + 过期时间选择 |
+| 小程序 | U50 `service-station` + `serviceStationService.js` | 「临时公告」区块,点击弹窗看正文;失败降级为空数组,不阻断站点信息 |
+
+**用户端语义(刻意设计,已在代码注释固化)**:站点不存在或已软删 → 返回**空列表**而非错误码,避免存在性探测;
+**不**按 `service_locations.status` 过滤 —— 站点"休息中/已关闭"时公告(如"本站在改造,请前往相邻服务站")恰恰最需要触达用户。
+
+### 4.2 用户端公益服务页面(U 编号)
+
+U49~U54 / U60 / U61 / U64 新增并注册;U37「我的」重构为四组结构;U50 补 `serviceLocationId` 透传与公告区块;
+U51 补只读「服务站点」字段并回传 `serviceLocationId`。统一复用 Token / 状态组件(`ui-loading` / `ui-empty` / `ui-error-state`)。
+
+---
+
+## 五、守卫与测试保真度(本轮新增)
+
+| 守卫 | 用例数 | 作用 |
+|---|---|---|
+| `ServiceLocationAnnouncementPhase19IntegrationTest` | 24 | 公告**真实 DB** 路径:草稿不泄露 / 发布可见 / 归档隐藏 / 过期自动隐藏 / 排序 / 软删 / 状态白名单 / 标题校验 / ISO 解析 400 / 404 / 部分更新语义 |
+| `ServiceLocationControllerPermissionContractTest` | 5 | **写端点必须 `manage`**;类级不得挂粗粒度注解(否则新增写方法静默继承读权限);用户端点不得带运营权限 |
+| `ServiceLocationAnnouncementConstraintParityTest` | 5 | **生产 V042 DDL ↔ 测试库 ↔ 后端白名单 ↔ 后台标签** 四方一致;表在两侧都存在(自检) |
+| `BannerOperatorPhase19IntegrationTest` | 18 | 禁外链 / 白名单 / 软删过滤 / 约束保真度 |
+| `BannerConstraintParityTest` | 5 | Banner 四方一致性 |
+| `ContentOperatorPhase19IntegrationTest` | 16 | 发布死路 / 分类停用 / 校验 / 软删 / 约束保真度 |
+| `ServiceProgramOperatorPhase19IntegrationTest` | 29 | 审批可达 / 草稿泄露 / 退出 / 唯一键 / 统计 / 约束保真度 |
+| `ServiceProgramControllerPermissionContractTest` | 4 | 写 `manage`、读 `view`、用户端点不守护 |
+| `OperatorEndpointPermissionCoverageTest` | 2 | 扫描清单**新增服务站运营端** |
+| `MeRouteContractTest` / `ServiceAppointmentOwnerGuardTest` | — | `/me/service-history` 路由;预约归属与数据范围 |
+
+`AppealServiceTest` 式的 `assertTrue(true)` 假绿在本轮被清除;`EntityMigrationParityTest` 的
+`testSchemaMissingTablesAreWhitelisted` 主动报出了 D23(生产表在测试库缺失),证明该守卫解析的是**真实迁移 DDL**。
+
+---
+
+## 六、负向验证(证明守卫是承重的,而非装饰)
+
+一次性注入 3 处缺陷(均以 `NEG-VAL` 标记),跑定向回归 → **4 个用例精准变红,且无任何无关失败**:
+
+| 注入缺陷 | 精准变红的用例 | 断言信息 |
+|---|---|---|
+| 移除 `findVisibleByLocation` 的 `deleted_at IS NULL` | `softDeletedAnnouncementIsInvisibleToBothSides` | 软删后用户端不得再看到该公告 |
+| 从测试库移除 `chk_announcement_status` | `databaseCheckRejectsIllegalStatusEvenWhenServiceValidationIsBypassed` | 期望抛异常但未抛(非法状态落库成功) |
+| 同上 | `productionAndTestSchemaDeclareIdenticalAnnouncementStatusCheck` | 未能解析出 `chk_announcement_status`(守卫显式报错而非假绿) |
+| 把 `update` 的权限降级为 `service_location.view` | `everyWriteEndpointRequiresManagePermission` | `update` 权限码不符合 V015(期望 `manage`,实为 `view`) |
+
+还原后 `grep -rc "NEG-VAL"` = **0**,全量回归恢复全绿。
+
+---
+
+## 七、回归证据
+
+```
+后端全量:Tests run: 602, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS
+ (复验起点 420 → 本轮 602,净 +182;本轮相对「修完编译错误」基线 568 净 +34)
+前端 admin-web:vue-tsc --noEmit EXIT=0;vite build EXIT=0(9.17s,仅 chunk 体积警告)
+小程序:tsc --noEmit EXIT=0
+契约:ui-tests 14/14 PASS(含「63 页清单」「U37 四组」「U50 公告」断言)
+```
+
+---
+
+## 八、明确记录的设计决策(非缺陷,但需知情)
+
+1. **`service_location.manage` 仅授予 `platform_admin`**,**未**扩权给 `service_station_operator`
+ (V015 该角色刻意只有 `view`,定位是"现场处理预约")。
+ 因此服务站信息与公告的维护入口对平台管理员开放;服务站运营人员在后台可见站点与公告列表(只读),
+ 写按钮按同一权限显隐,避免"点了才 403"。
+ 若后续要求服务站**自主**维护本站公告,需单独评审并同时引入 `service_location_operators`
+ 的站点级数据范围校验(不能只加权限 —— 那会让任一服务站运营改到别人的站点)。
+2. **`/service-locations/**` 不做匿名放行**:公告是登录后可见内容,鉴权走 `SecurityConfig` 的 `/** → authenticated` 兜底,
+ 与 `/home` 一致。
+3. **公告不做物理删除**:下架走 `archived`,保留可追溯性;软删字段仍保留但不由运营直接触发。
+4. **`/home` 聚合刻意不塞公告**:公告是"站点详情"维度内容,塞进首页聚合会让首页随公告变化而频繁失效缓存。
+
+---
+
+## 九、遗留与下一阶段
+
+| 项 | 状态 |
+|---|---|
+| `ServiceLocationOperatorController` 支持 `service_station_operator` 站点级自维护 | ⏳ 需产品确认(见决策 1) |
+| 公告富文本 / 图片 | ⏳ 当前为纯文本 `content` |
+| 计划表中「本地资格声明与审核」(Phase 04~05)等仍有 ⏳ 项 | 后续阶段 |
+| 下一阶段 | Phase 20 起(等用户确认) |
+
+---
+
+## 附:本轮改动文件(节选)
+
+**后端**
+`service_appointment/controller/ServiceLocationOperatorController.java`(重写)、
+`service_appointment/controller/ServiceLocationController.java`(新增)、
+`service_appointment/service/ServiceLocationAnnouncementService.java`、
+`service_appointment/mapper/ServiceLocationAnnouncementMapper.java`、
+`service_appointment/entity/ServiceLocationAnnouncement.java`、
+`db/migration/V042__add_service_location_announcements.sql`、
+`banner/controller/HomeBannerOperatorController.java`、`banner/mapper/HomeBannerMapper.java`、
+`content/service/ContentService.java`、`content/controller/ContentController.java`、
+`service_program/**`、`me/controller/MeController.java`
+
+**前端**
+`admin-web/src/modules/service-station/ServiceStationView.vue`、
+`admin-web/src/modules/banner/BannerManagementView.vue`、
+`admin-web/src/modules/content/ContentManagementView.vue`、
+`admin-web/src/modules/service-program/ServiceProgramView.vue`、
+`miniapp/pages/service-station/*`、`miniapp/services/serviceStationService.js`、
+`miniapp/pages/mine/*`、`miniapp/pages/service-appointment/*`
+
+**守卫 / 测试**
+`backend/src/test/resources/schema-h2.sql`、
+`ServiceLocationAnnouncementPhase19IntegrationTest`、
+`ServiceLocationControllerPermissionContractTest`、
+`ServiceLocationAnnouncementConstraintParityTest`、
+`BannerOperatorPhase19IntegrationTest`、`BannerConstraintParityTest`、
+`ContentOperatorPhase19IntegrationTest`、`ServiceProgramOperatorPhase19IntegrationTest`、
+`OperatorEndpointPermissionCoverageTest`、`EntityMigrationParityTest`、`MigrationValidationTest`、
+`ui-tests/miniapp-reference-contracts.mjs`
diff --git a/docs/STATUS.md b/docs/STATUS.md
index e172a51..c264041 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,8 +1,8 @@
# 项目状态跟踪 — STATUS.md
-> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 18 COMPLETE / GIT CLEAN`。**
+> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 19 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。**
>
-> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)复验已关闭**,十二轮共修复 85 处真实缺陷(含 2 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 19 需用户确认后启动。**
+> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)复验已关闭**,十三轮共修复 110+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 20 起需用户确认后启动。**
最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
@@ -455,12 +455,13 @@
| 模块 | 对应 Phase | 状态 |
|------|-----------|------|
| Home Aggregate API + V1.1首页 | Phase 07 | ✅ 已完成 |
-| Banner 管理(后端+前端) | Phase 19 | ⏳ 待开发 |
+| Banner 管理(后端+前端) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
| 本地资格声明与审核 | Phase 04~05 | ⏳ 待开发 |
| 公益红娘服务转介 | Phase 13 | ✅ 已完成 |
| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ✅ 已完成(2026-09-17 复验关闭) |
-| 内容管理(婚恋课堂/反诈) | Phase 19 | ⏳ 待开发 |
-| 专项公益服务项目 | Phase 19 | ⏳ 待开发 |
+| 内容管理(婚恋课堂/反诈) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
+| 专项公益服务项目 | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
+| 善缘服务站 + 用户端公益服务页面(U37/U49~U54/U60/U61/U64) | Phase 19 | ✅ 已完成(含 V042 临时公告) |
| V1.1新增不变量测试(INV-011~020) | Phase 23 | ⏳ 待开发 |
### 当前运行状态
@@ -491,14 +492,31 @@ MinIO: localhost:9003 (凭证由本地环境变量提供)
**Phase 13A(公益婚恋咨询与婚前辅导)— 已完成** ✅
-### Phase 19 — 统一运营后台(后端API核心链路)✅ 已完成
+### Phase 19 — 统一运营后台 + 用户端公益服务页面 ✅ 已完成(2026-09-17 复验关闭)
+
+> 复验报告:`docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`
+> 后端全量回归:`Tests run: 602, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS`
+> admin-web:`vue-tsc --noEmit` EXIT=0、`vite build` EXIT=0;小程序:`tsc --noEmit` EXIT=0;ui-tests 14/14 PASS
+
| 任务 | 状态 |
|------|------|
| V026 内容+专项项目+组织关系表 | ✅ |
+| V042 善缘服务站「临时公告」表(本轮新建) | ✅ |
| ContentService(分类/文章 CRUD + publish/offline) | ✅ |
-| ServiceProgramService(项目CRUD + 成员审批) | ✅ |
-| V021~V026 共6个迁移文件 | ✅ |
-| Admin Web前端 | ⏳ 后续补充 |
+| Content 运营端接口分离(修「发布死路」/「分类停用不可恢复」) | ✅ |
+| ServiceProgramService(项目CRUD + 成员审批 + 退出/移除/统计) | ✅ |
+| Banner 运营端(白名单校验 400 而非 500 + 软删过滤 + 禁外链 + 预览) | ✅ |
+| 善缘服务站运营端(信息维护 + 公告五端点 + 权限下沉方法级) | ✅ |
+| 预约 IDOR 修复(principal.id() + 归属校验 + DataScopeGuard) | ✅ |
+| Admin Web 前端(Banner / 内容 / 服务项目 / 服务站公告) | ✅ |
+| 用户端 U37 四组重构 + U49~U54/U60/U61/U64 页面 | ✅ |
+
+**本轮复验修复 25 处真实缺陷**,其中高价值项:服务站公告能力**整条不可达**(Service + 表都在、零端点);
+服务站 `create/update` **越权写**(写端点只受 `service_location.view` 保护,`manage` 是从未使用的死权限)
+且该控制器**未纳入**权限覆盖守卫;Banner 校验抛 500;Content 发布死路;ServiceProgram 审批死路 / 草稿泄露 / 成员数恒 0 / 退出未实现;
+测试库缺 `content_categories` 与 `service_location_announcements`(对应路径零覆盖)。
+新增守卫:`ServiceLocationAnnouncementPhase19IntegrationTest`(24) / `ServiceLocationControllerPermissionContractTest`(5) /
+`ServiceLocationAnnouncementConstraintParityTest`(5)。负向验证 3 处注入 → 4 个用例精准变红并还原(`NEG-VAL` 残留 0)。
### Phase 20 — Analytics数据统计扩展 ✅ 已完成
| 任务 | 状态 |
diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md
index 591f17a..e8fb26e 100644
--- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md
+++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md
@@ -479,17 +479,27 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M
**Files:** Admin banner/content/service-station/service-appointment/service-program/counseling/referral 模块、MainLayout/router;Miniapp 新增 U49~U54、U60、U61、U64 页面;service API 文件。
-- [ ] 先修复 Admin 全量 type-check 和角色路由,再实现“角色第一屏知道今天做什么”的待办型工作台。
-- [ ] Banner 完成 CRUD、排期、排序、Audience、内部 target 校验和预览;禁止外链。
-- [ ] Content 完成分类、文章编辑、发布/下架;用户端只做文章列表/详情,不做 Feed、评论和点赞。
-- [ ] Service Station 完成万善公园站信息、开放时间、公告、服务项目和预约管理。
-- [ ] 修复预约 IDOR:普通用户创建、详情、列表、取消只能操作本人;代登记使用独立 operator 接口和权限。
-- [ ] Service Program 完成项目、申请、审批、退出和必要统计,禁止把项目成员身份变成用户价值标签。
-- [ ] 新增并注册 U49 公益服务中心、U50 服务站、U51 预约、U52 我的预约、U53 课堂、U54 内容详情、U60 反诈、U61 专项服务、U64 公益服务记录。
-- [ ] 重构 U37“我的”四组结构,接入上述新增页面;U64 只显示用户自己的服务时间线,不显示内部备注。
-- [ ] 所有页面复用统一 Token 和组件,覆盖 Loading、Skeleton、Empty、Error、Permission、Disabled、Responsive。
+- [x] 先修复 Admin 全量 type-check 和角色路由,再实现“角色第一屏知道今天做什么”的待办型工作台。
+- [x] Banner 完成 CRUD、排期、排序、Audience、内部 target 校验和预览;禁止外链。
+- [x] Content 完成分类、文章编辑、发布/下架;用户端只做文章列表/详情,不做 Feed、评论和点赞。
+- [x] Service Station 完成万善公园站信息、开放时间、公告、服务项目和预约管理。
+- [x] 修复预约 IDOR:普通用户创建、详情、列表、取消只能操作本人;代登记使用独立 operator 接口和权限。
+- [x] Service Program 完成项目、申请、审批、退出和必要统计,禁止把项目成员身份变成用户价值标签。
+- [x] 新增并注册 U49 公益服务中心、U50 服务站、U51 预约、U52 我的预约、U53 课堂、U54 内容详情、U60 反诈、U61 专项服务、U64 公益服务记录。
+- [x] 重构 U37“我的”四组结构,接入上述新增页面;U64 只显示用户自己的服务时间线,不显示内部备注。
+- [x] 所有页面复用统一 Token 和组件,覆盖 Loading、Skeleton、Empty、Error、Permission、Disabled、Responsive。
-**Gate:** 小程序 U01~U64 页面清单无缺页且路由可达;七类后台角色只看到自己的工作;后台 type-check/build 通过。
+**Gate:** 小程序 U01~U64 页面清单无缺页且路由可达 ✅(`ui-tests/ui-contracts.mjs`:`app.json` 63 页);七类后台角色只看到自己的工作 ✅(`admin-role-navigation-contracts` + `V11PermissionMatrixContractTest` 扫全部迁移 + `OperatorEndpointPermissionCoverageTest`);后台 type-check/build 通过 ✅(`vue-tsc --noEmit` EXIT=0、`vite build` EXIT=0)。
+
+**复验结论:** 修复 **25 处真实缺陷**。高价值项:**Service Station 公告能力整条不可达**(Service 与 V042 表都已存在但全仓零端点,后台与小程序都拿不到)→ 补运营端五端点 + 用户端只读端点 + 后台公告 UI + 小程序 U50 公告区块;
+**服务站 `create/update` 越权写**(类级 `service_location.view` 兜底,V015 的 `service_location.manage` 是从未被使用的死权限)→ 权限下沉方法级,并把该控制器纳入权限覆盖守卫(此前未纳入,正是缺陷长期隐形的原因);
+**Banner 校验失败落 500**(裸 `RuntimeException`)→ 统一 `VALIDATION_ERROR`,并补软删过滤(软删 Banner 曾仍上首页);
+**Content「发布」是死路**(后台复用只返回 `published` 的公开接口,草稿永不出现)→ 运营端接口分离;**Service Program 审批死路 / 草稿项目泄露 / 成员数恒 0 / 「退出」未实现 / 重复申请撞唯一键**;
+**测试库缺 `content_categories` 与 `service_location_announcements`** → 对应路径零覆盖(约束漂移盲区),已补表并移入必选集合。
+新增守卫:`ServiceLocationAnnouncementPhase19IntegrationTest`(24) / `ServiceLocationControllerPermissionContractTest`(5) / `ServiceLocationAnnouncementConstraintParityTest`(5,**生产 DDL ↔ 测试库 ↔ 后端白名单 ↔ 后台标签** 四方一致);`V11PermissionMatrixContractTest` 由硬编码 V015+V029 改为扫描全部迁移,消除「后续迁移权限变更隐形」。
+负向验证:注入 3 处缺陷(移除软删过滤 / 移除测试库 CHECK / 写端点降级为 `view`)→ **4 个用例精准变红且无无关失败**,还原后 `NEG-VAL` 残留 0。
+全量回归 **602 tests 全绿**(起点 420);admin-web `vue-tsc` EXIT=0 + `vite build` EXIT=0;小程序 `tsc --noEmit` EXIT=0;ui-tests 14/14。
+**报告:** `docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 20 需用户确认后启动。**
### Phase 20:公益服务 Analytics — EXTEND
diff --git a/miniapp/pages/content-detail/content-detail.js b/miniapp/pages/content-detail/content-detail.js
index 8014634..96f62c7 100644
--- a/miniapp/pages/content-detail/content-detail.js
+++ b/miniapp/pages/content-detail/content-detail.js
@@ -1 +1,49 @@
-Page({data:{},report(){wx.navigateTo({url:'/pages/report/report?type=content'})},related(){wx.navigateTo({url:'/pages/counseling-intro/counseling-intro'})}})
\ No newline at end of file
+/**
+ * U54 内容详情
+ *
+ * Phase 19:此前正文与标题**写死在 wxml**(任何文章都显示同一篇演示内容)。
+ * 现接入 GET /content/articles/{id},按 id 展示真实正文。
+ *
+ * 边界:用户端内容只做列表与详情,不做 Feed / 评论 / 点赞。
+ */
+const { contentService } = require('../../services');
+
+Page({
+ data: {
+ loading: true,
+ error: '',
+ article: null,
+ paragraphs: [],
+ },
+ onLoad(options) {
+ this.articleId = (options && options.id) || '';
+ this.load();
+ },
+ async load() {
+ if (!this.articleId) {
+ this.setData({ loading: false, error: '未指定内容' });
+ return;
+ }
+ this.setData({ loading: true, error: '' });
+ try {
+ const article = await contentService.getArticleDetail(this.articleId);
+ if (!article) {
+ this.setData({ loading: false, error: '内容不存在或已下架' });
+ return;
+ }
+ const paragraphs = String(article.content || '')
+ .split(/\n+/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ this.setData({ article, paragraphs, loading: false });
+ } catch (error) {
+ this.setData({ loading: false, error: '加载失败,请稍后重试' });
+ }
+ },
+ report() {
+ wx.navigateTo({ url: '/pages/report/report?type=content' });
+ },
+ related() {
+ wx.navigateTo({ url: '/pages/counseling-intro/counseling-intro' });
+ },
+});
diff --git a/miniapp/pages/content-detail/content-detail.wxml b/miniapp/pages/content-detail/content-detail.wxml
index de494cd..3976329 100644
--- a/miniapp/pages/content-detail/content-detail.wxml
+++ b/miniapp/pages/content-detail/content-detail.wxml
@@ -1 +1 @@
-沟通成长 · 阅读约 5 分钟 第一次见面,怎样自然地开始交流 善缘丹阳婚恋课堂 把注意力放在认识一个真实的人,而不是完成一场“面试”。 初次见面时,可以从当下的环境、共同参与的活动或轻松的生活话题开始。相比连续发问,认真倾听并回应对方刚刚说过的内容,更容易建立平等、自然的交流。 不必急着判断结果,也不需要过度展示自己。尊重彼此的节奏,在涉及联系方式、住址和财务信息时保留必要边界。 本文为公益科普内容,不能替代专业咨询或法律意见。 需要进一步帮助?
\ No newline at end of file
+… 正在加载 ! {{error}} 善缘丹阳婚恋课堂 {{article.title}} {{article.summary}} {{article.summary}} {{item}} 本文为公益科普内容,不能替代专业咨询或法律意见。 需要进一步帮助?
diff --git a/miniapp/pages/mine/mine.ts b/miniapp/pages/mine/mine.ts
index f26eb02..3e51567 100644
--- a/miniapp/pages/mine/mine.ts
+++ b/miniapp/pages/mine/mine.ts
@@ -153,6 +153,8 @@ Page({
onGoClassroom() { wx.navigateTo({ url: ROUTE.SERVICE.CLASSROOM }); },
onGoAntiFraud() { wx.navigateTo({ url: ROUTE.SERVICE.ANTI_FRAUD }); },
onGoStation() { wx.navigateTo({ url: ROUTE.SERVICE.STATION }); },
+ // Phase 19:U49 公益服务中心此前只有 app.json 注册、全仓无任何入口(用户不可达)。
+ onGoServiceCenter() { wx.navigateTo({ url: ROUTE.SERVICE.CENTER }); },
// ============ 其他 ============
diff --git a/miniapp/pages/mine/mine.wxml b/miniapp/pages/mine/mine.wxml
index 3476071..7bbd752 100644
--- a/miniapp/pages/mine/mine.wxml
+++ b/miniapp/pages/mine/mine.wxml
@@ -32,26 +32,45 @@
+
+
+
+ 我的资料 {{reviewStatusText}}
+ 择偶条件
+ 我的缘分
+
+
+
+
+ 公益服务中心 咨询 · 预约 · 课堂
+
我的红娘
我的咨询
我的活动
我的预约
-
- 我的资料 {{reviewStatusText}}
- 择偶条件
- 我的缘分
- 公益服务记录
+ 公益服务记录 仅本人可见
+
+
+
+
+
+ 隐私设置
+ 黑名单
+ 举报与安全
账号设置
-
- 隐私设置
- 举报与安全
- 婚恋反诈
+
+
+
+ 婚恋课堂
+ 婚恋反诈
+ 善缘服务站
+ 关于善缘丹阳
退出登录
diff --git a/miniapp/pages/my-appointments/my-appointments.js b/miniapp/pages/my-appointments/my-appointments.js
index 48474bb..bbeb629 100644
--- a/miniapp/pages/my-appointments/my-appointments.js
+++ b/miniapp/pages/my-appointments/my-appointments.js
@@ -1 +1,64 @@
-Page({data:{appointments:[{id:'1',title:'资料填写协助',date:'2026-09-12 14:30',station:'善缘丹阳公益服务站',status:'已确认'}]},cancel(){wx.showModal({title:'取消预约',content:'确定取消本次服务预约吗?',confirmText:'确认取消',confirmColor:'#D94A4A'})}})
\ No newline at end of file
+/**
+ * U52 我的预约
+ *
+ * Phase 19:此前是硬编码假预约(取消只弹 Modal,不改数据)。
+ * 现接入 GET /service-appointments(服务端按登录主体过滤)+ POST /{id}/cancel。
+ */
+const { serviceAppointmentService } = require('../../services');
+
+const STATUS_LABEL = {
+ submitted: '待确认',
+ pending_confirmation: '待确认',
+ confirmed: '已确认',
+ completed: '已完成',
+ cancelled: '已取消',
+};
+
+Page({
+ data: {
+ loading: true,
+ error: '',
+ appointments: [],
+ },
+ onShow() {
+ this.load();
+ },
+ async load() {
+ this.setData({ loading: true, error: '' });
+ try {
+ const list = await serviceAppointmentService.getMyAppointments();
+ const appointments = (list || []).map((item) => ({
+ id: item.id,
+ title: item.serviceLocationName || '善缘丹阳公益服务站',
+ status: STATUS_LABEL[item.status] || item.status || '',
+ date: [item.preferredDate, item.preferredTimeSlot].filter(Boolean).join(' ') || '时间待确认',
+ station: item.serviceLocationName || '',
+ cancellable: item.status !== 'completed' && item.status !== 'cancelled',
+ }));
+ this.setData({ appointments, loading: false });
+ } catch (error) {
+ this.setData({ loading: false, error: '加载失败,请稍后重试' });
+ }
+ },
+ cancel(event) {
+ const id = event.currentTarget.dataset.id;
+ wx.showModal({
+ title: '取消预约',
+ content: '确定取消本次服务预约吗?',
+ confirmText: '确认取消',
+ confirmColor: '#D94A4A',
+ success: async (res) => {
+ if (!res.confirm) {
+ return;
+ }
+ try {
+ await serviceAppointmentService.cancelAppointment(id, '用户取消');
+ wx.showToast({ title: '已取消', icon: 'success' });
+ this.load();
+ } catch (error) {
+ wx.showToast({ title: '取消失败,请稍后重试', icon: 'none' });
+ }
+ },
+ });
+ },
+});
diff --git a/miniapp/pages/my-appointments/my-appointments.wxml b/miniapp/pages/my-appointments/my-appointments.wxml
index 8c35b3f..5d420a6 100644
--- a/miniapp/pages/my-appointments/my-appointments.wxml
+++ b/miniapp/pages/my-appointments/my-appointments.wxml
@@ -1 +1 @@
-我的公益服务 我的预约 查看预约状态、到站时间与服务地点。 {{item.title}} {{item.status}} {{item.date}} {{item.station}} 取消预约 约 还没有服务预约 需要线下帮助时,可以预约善缘服务站。
\ No newline at end of file
+我的公益服务 我的预约 查看预约状态、到站时间与服务地点。 … 正在加载 ! {{error}} 请检查网络后重试。 {{item.title}} {{item.status}} {{item.date}} {{item.station}} 取消预约 约 还没有服务预约 需要线下帮助时,可以预约善缘服务站。
diff --git a/miniapp/pages/service-appointment/service-appointment.js b/miniapp/pages/service-appointment/service-appointment.js
index 6af2d0d..5cf904e 100644
--- a/miniapp/pages/service-appointment/service-appointment.js
+++ b/miniapp/pages/service-appointment/service-appointment.js
@@ -1 +1,92 @@
-Page({data:{date:'',service:'请选择服务项目',services:['资料填写协助','公益红娘咨询','活动报名指导','婚恋咨询预约'],note:''},chooseService(e){this.setData({service:this.data.services[Number(e.detail.value)]})},chooseDate(e){this.setData({date:e.detail.value})},submit(){if(!this.data.date)return wx.showToast({title:'请选择预约日期',icon:'none'});wx.showModal({title:'预约已提交',content:'工作人员确认后会通过平台通知你。',showCancel:false,success(){wx.navigateTo({url:'/pages/my-appointments/my-appointments'})}})}})
\ No newline at end of file
+/**
+ * U51 服务预约
+ *
+ * Phase 19:此前提交只弹本地 Modal(后端收不到任何记录),属"假成功"。
+ * 现接入 POST /service-appointments:来源由服务端强制为 user_self,
+ * 前端**不传** requestSource / createdByOperatorId / userId(防伪造运营代登记身份)。
+ *
+ * 站点上下文:从 U50 服务站页跳转时携带 `?serviceLocationId=`,本页解析后
+ * 原样回传,保证预约真正落在该站点;站点名仅作展示,解析失败不阻断提交。
+ */
+const { serviceAppointmentService } = require('../../services');
+
+const SERVICE_OPTIONS = [
+ { label: '到站资料协助', type: 'station_visit' },
+ { label: '公益红娘咨询', type: 'matchmaker' },
+ { label: '活动报名指导', type: 'station_visit' },
+ { label: '婚恋咨询预约', type: 'counselor_consultation' },
+];
+
+Page({
+ data: {
+ services: SERVICE_OPTIONS.map((option) => option.label),
+ service: '请选择服务项目',
+ date: '',
+ note: '',
+ serviceLocationId: '',
+ serviceLocationName: '',
+ submitting: false,
+ },
+ selectedIndex: -1,
+ onLoad(options) {
+ const raw = options && options.serviceLocationId;
+ const serviceLocationId = raw ? decodeURIComponent(raw).trim() : '';
+ if (!serviceLocationId) {
+ return;
+ }
+ this.setData({ serviceLocationId });
+ this.resolveStationName(serviceLocationId);
+ },
+ async resolveStationName(serviceLocationId) {
+ const location = await serviceAppointmentService.getCurrentServiceLocation();
+ if (location && location.id === serviceLocationId) {
+ this.setData({ serviceLocationName: location.name });
+ } else {
+ // 站点信息不可得时仍保留归属 ID,仅以占位文案展示,避免误导为空
+ this.setData({ serviceLocationName: '已选服务站点' });
+ }
+ },
+ chooseService(event) {
+ const index = Number(event.detail.value);
+ this.selectedIndex = index;
+ this.setData({ service: SERVICE_OPTIONS[index].label });
+ },
+ chooseDate(event) {
+ this.setData({ date: event.detail.value });
+ },
+ onNoteInput(event) {
+ this.setData({ note: event.detail.value });
+ },
+ async submit() {
+ if (this.selectedIndex < 0) {
+ wx.showToast({ title: '请选择服务项目', icon: 'none' });
+ return;
+ }
+ if (!this.data.date) {
+ wx.showToast({ title: '请选择预约日期', icon: 'none' });
+ return;
+ }
+ if (this.data.submitting) {
+ return;
+ }
+ this.setData({ submitting: true });
+ try {
+ await serviceAppointmentService.createAppointment({
+ serviceLocationId: this.data.serviceLocationId || undefined,
+ serviceType: SERVICE_OPTIONS[this.selectedIndex].type,
+ preferredDate: this.data.date,
+ userNote: this.data.note,
+ });
+ wx.showModal({
+ title: '预约已提交',
+ content: '工作人员确认后会通过平台通知你。',
+ showCancel: false,
+ success: () => wx.navigateTo({ url: '/pages/my-appointments/my-appointments' }),
+ });
+ } catch (error) {
+ wx.showToast({ title: '提交失败,请稍后重试', icon: 'none' });
+ } finally {
+ this.setData({ submitting: false });
+ }
+ },
+});
diff --git a/miniapp/pages/service-appointment/service-appointment.wxml b/miniapp/pages/service-appointment/service-appointment.wxml
index 1ddb3d8..9dfa4d1 100644
--- a/miniapp/pages/service-appointment/service-appointment.wxml
+++ b/miniapp/pages/service-appointment/service-appointment.wxml
@@ -1 +1 @@
-U51 服务预约 预约到站服务 选择合适的时间和服务项目,提交后等待工作人员确认。 服务项目 {{service}}› 预约日期 {{date || '请选择日期'}} 希望沟通的事项(选填) 提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。 提交预约
\ No newline at end of file
+U51 服务预约 预约到站服务 选择合适的时间和服务项目,提交后等待工作人员确认。 服务站点 {{serviceLocationName || '已选服务站点'}} 服务项目 {{service}}› 预约日期 {{date || '请选择日期'}} 希望沟通的事项(选填) 提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。 {{submitting ? '提交中…' : '提交预约'}}
diff --git a/miniapp/pages/service-history/service-history.js b/miniapp/pages/service-history/service-history.js
index ae77840..335c9de 100644
--- a/miniapp/pages/service-history/service-history.js
+++ b/miniapp/pages/service-history/service-history.js
@@ -1 +1,50 @@
-Page({data:{records:[{title:'善缘服务站 · 资料协助',date:'2026-08-28',status:'已完成'},{title:'青年公益交友活动',date:'2026-08-16',status:'已参加'},{title:'公益红娘服务申请',date:'2026-08-03',status:'服务中'}]}})
\ No newline at end of file
+/**
+ * U64 公益服务记录
+ *
+ * Phase 19:此前是硬编码假记录(与用户真实服务无关)。
+ * 现接入 GET /me/service-history —— 服务端只返回**本人**记录摘要,
+ * 且已排除咨询正文与内部备注(INV-013 最小可见)。
+ */
+const { serviceHistoryService } = require('../../services');
+
+const CATEGORY_ICON = { appointment: '约', counseling: '咨' };
+const STATUS_LABEL = {
+ submitted: '待确认',
+ pending_confirmation: '待确认',
+ confirmed: '已确认',
+ completed: '已完成',
+ cancelled: '已取消',
+ pending: '待处理',
+ assigned: '已分配',
+ scheduled: '已排期',
+ in_progress: '进行中',
+ closed: '已关闭',
+};
+
+Page({
+ data: {
+ loading: true,
+ error: '',
+ records: [],
+ },
+ onShow() {
+ this.load();
+ },
+ async load() {
+ this.setData({ loading: true, error: '' });
+ try {
+ const list = await serviceHistoryService.getMyServiceHistory();
+ const records = (list || []).map((item) => ({
+ key: item.id,
+ icon: CATEGORY_ICON[item.category] || '记',
+ title: item.title,
+ date: item.occurredAt || '',
+ station: item.stationName || '',
+ status: STATUS_LABEL[item.status] || item.status || '',
+ }));
+ this.setData({ records, loading: false });
+ } catch (error) {
+ this.setData({ loading: false, error: '加载失败,请稍后重试' });
+ }
+ },
+});
diff --git a/miniapp/pages/service-history/service-history.wxml b/miniapp/pages/service-history/service-history.wxml
index 1002861..3d5b94d 100644
--- a/miniapp/pages/service-history/service-history.wxml
+++ b/miniapp/pages/service-history/service-history.wxml
@@ -1 +1 @@
-U64 公益服务记录 每一次服务,都清楚可查 这里只展示与你有关的公益服务事实,不展示咨询正文。 记 {{item.title}} {{item.date}} {{item.status}} 咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。
\ No newline at end of file
+U64 公益服务记录 每一次服务,都清楚可查 这里只展示与你有关的公益服务事实,不展示咨询正文。 … 正在加载 ! {{error}} {{item.icon}} {{item.title}} {{item.date}} {{item.status}} 记 还没有公益服务记录 预约到站服务或申请公益咨询后,会在这里留下时间线。 咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。
diff --git a/miniapp/pages/service-program/service-program.js b/miniapp/pages/service-program/service-program.js
index f00d7b5..48f48d8 100644
--- a/miniapp/pages/service-program/service-program.js
+++ b/miniapp/pages/service-program/service-program.js
@@ -1 +1,64 @@
-Page({data:{},apply(){wx.showModal({title:'申请说明',content:'请先完成基本资料,符合项目条件后工作人员会与你联系。',showCancel:false})}})
\ No newline at end of file
+/**
+ * U61 专项公益服务介绍
+ *
+ * Phase 19:此前项目文案写死、申请只弹说明(无任何后端交互)。
+ * 现接入 GET /service-programs + POST /service-programs/{id}/apply。
+ *
+ * 边界:项目成员身份只用于服务办理,**不得**成为用户价值标签;
+ * 项目主办/协作关系也不会自动获得用户资料访问权限。
+ */
+const { serviceProgramService } = require('../../services');
+
+const STATUS_LABEL = {
+ draft: '筹备中',
+ active: '进行中',
+ ended: '已结束',
+ cancelled: '已取消',
+};
+
+Page({
+ data: {
+ loading: true,
+ error: '',
+ programs: [],
+ },
+ onShow() {
+ this.load();
+ },
+ async load() {
+ this.setData({ loading: true, error: '' });
+ try {
+ const list = await serviceProgramService.getPrograms();
+ const programs = (list || []).map((item) => ({
+ id: item.id,
+ name: item.name,
+ description: item.description || '',
+ status: STATUS_LABEL[item.status] || item.status || '',
+ applicable: item.status === 'active' || item.status === 'draft',
+ }));
+ this.setData({ programs, loading: false });
+ } catch (error) {
+ this.setData({ loading: false, error: '加载失败,请稍后重试' });
+ }
+ },
+ apply(event) {
+ const id = event.currentTarget.dataset.id;
+ wx.showModal({
+ title: '申请说明',
+ content: '请先完成基本资料,符合项目条件后工作人员会与你联系。',
+ confirmText: '提交申请',
+ success: async (res) => {
+ if (!res.confirm) {
+ return;
+ }
+ try {
+ await serviceProgramService.applyToProgram(id);
+ wx.showToast({ title: '申请已提交', icon: 'success' });
+ this.load();
+ } catch (error) {
+ wx.showToast({ title: '申请失败,请稍后重试', icon: 'none' });
+ }
+ },
+ });
+ },
+});
diff --git a/miniapp/pages/service-program/service-program.wxml b/miniapp/pages/service-program/service-program.wxml
index 9b8a6fd..eeb6edb 100644
--- a/miniapp/pages/service-program/service-program.wxml
+++ b/miniapp/pages/service-program/service-program.wxml
@@ -1 +1 @@
-专项公益服务 让需要帮助的人被认真看见 针对特定群体提供阶段性公益支持,项目条件与服务边界公开透明。 青年人才公益交友支持 服务内容 主题活动、资料指导、公益红娘转介与关系成长课堂。 申请条件 以项目当期公布的年龄、工作或居住条件为准,不承诺匹配结果。 公益免费 自愿申请 隐私保护 项目主办或协作关系不会自动获得用户资料访问权限。 了解申请方式
\ No newline at end of file
+专项公益服务 让需要帮助的人被认真看见 针对特定群体提供阶段性公益支持,项目条件与服务边界公开透明。 … 正在加载 ! {{error}} {{item.name}} {{item.status}} {{item.description}} 公益免费 自愿申请 隐私保护 申请加入 项 暂无可申请的专项服务 新项目上线后会在这里公布。 项目主办或协作关系不会自动获得用户资料访问权限。
diff --git a/miniapp/pages/service-station/service-station.ts b/miniapp/pages/service-station/service-station.ts
index 39678fa..da841c1 100644
--- a/miniapp/pages/service-station/service-station.ts
+++ b/miniapp/pages/service-station/service-station.ts
@@ -1,4 +1,4 @@
-import api from '../../utils/api'
+import { serviceStationService } from '../../services'
interface ServiceLocation {
id: string
@@ -13,15 +13,24 @@ interface ServiceLocation {
longitude?: number | string
}
+interface ServiceAnnouncement {
+ id: string
+ title: string
+ content?: string
+ expiresAt?: string | null
+}
+
interface ServiceStationPageData {
serviceLocation: ServiceLocation | null
+ announcements: ServiceAnnouncement[]
loading: boolean
loadError: boolean
}
Page({
data: {
- serviceLocation: null,
+ serviceLocation: null as ServiceLocation | null,
+ announcements: [] as ServiceAnnouncement[],
loading: true,
loadError: false,
},
@@ -33,8 +42,14 @@ Page({
async loadStation() {
this.setData({ loading: true, loadError: false })
try {
- const home = await api.get<{ serviceLocation?: ServiceLocation }>('/home')
- this.setData({ serviceLocation: home.serviceLocation || null, loading: false })
+ const station = await serviceStationService.getStation()
+ if (!station) {
+ this.setData({ serviceLocation: null, announcements: [], loading: false })
+ return
+ }
+ // 公告是增强信息:失败降级为空数组,不阻断站点主体信息展示
+ const announcements = await serviceStationService.getVisibleAnnouncements(station.id as string)
+ this.setData({ serviceLocation: station as ServiceLocation, announcements, loading: false })
} catch (error) {
console.error('加载服务站失败', error)
this.setData({ loading: false, loadError: true })
@@ -81,6 +96,19 @@ Page({
wx.navigateTo({ url: `/pages/service-appointment/service-appointment${query}` })
},
+ /** 公告详情:临时公告正文较长,用弹窗完整展示,避免卡片被截断 */
+ onShowAnnouncement(e: WechatMiniprogram.TouchEvent) {
+ const id = e.currentTarget.dataset.id as string
+ const target = (this.data.announcements as ServiceAnnouncement[]).find((item) => item.id === id)
+ if (!target) return
+ wx.showModal({
+ title: target.title || '临时公告',
+ content: target.content || target.title || '',
+ showCancel: false,
+ confirmText: '知道了',
+ })
+ },
+
onRetry() {
this.loadStation()
},
diff --git a/miniapp/pages/service-station/service-station.wxml b/miniapp/pages/service-station/service-station.wxml
index 2bf7453..ba84285 100644
--- a/miniapp/pages/service-station/service-station.wxml
+++ b/miniapp/pages/service-station/service-station.wxml
@@ -13,6 +13,22 @@
开放时间 {{serviceLocation.openingHoursText || '以平台通知为准'}}
+
+ 临时公告
+
+
+ {{item.title}}
+ {{item.content || item.title}}
+
+
+
+
服务项目 {{item}}
服务站人员仅处理现场预约与指定服务,不会浏览无关用户资料。
diff --git a/miniapp/pages/service-station/service-station.wxss b/miniapp/pages/service-station/service-station.wxss
index c9d7374..46a677f 100644
--- a/miniapp/pages/service-station/service-station.wxss
+++ b/miniapp/pages/service-station/service-station.wxss
@@ -1,2 +1,9 @@
@import "../../styles/service-page.wxss";
.station-name-card{display:flex;align-items:center;gap:18rpx;margin-top:24rpx;padding:24rpx;border-radius:26rpx;background:linear-gradient(135deg,#F1FFFA,#FFF7F9);box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-name-card>image{width:66rpx;height:66rpx}.station-name-card>view{display:flex;flex:1;flex-direction:column}.station-name{color:#283056;font-size:27rpx;font-weight:700}.station-short-name{margin-top:6rpx;color:#7E859B;font-size:19rpx}.station-info-card{overflow:hidden;margin-top:18rpx;border-radius:26rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-info-row{display:flex;align-items:center;gap:16rpx;min-height:104rpx;padding:14rpx 20rpx;border-bottom:1rpx solid #EFF0F5}.station-info-row:last-child{border-bottom:0}.station-info-row>image:first-child{width:42rpx;height:42rpx;flex:none}.station-info-row>view{display:flex;flex:1;flex-direction:column}.station-info-label{color:#303654;font-size:22rpx;font-weight:650}.station-info-value{margin-top:5rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.station-info-row .station-arrow{width:24rpx;height:24rpx}.station-services{display:grid;grid-template-columns:repeat(2,1fr);gap:14rpx;margin-top:18rpx}.station-service{display:flex;align-items:center;gap:12rpx;min-height:74rpx;padding:12rpx 16rpx;border-radius:20rpx;background:#fff;color:#505874;font-size:20rpx;box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}.station-service image{width:34rpx;height:34rpx}.map-button{display:flex;align-items:center;justify-content:center;gap:10rpx;height:78rpx;margin-top:20rpx;border:1rpx solid #E5E7F0;border-radius:39rpx;background:#fff;color:#53607F;font-size:22rpx}.map-button image{width:34rpx;height:34rpx}
+/* 临时公告:仅当服务端返回「已发布且未过期」公告时渲染 */
+.station-announcements{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}
+.station-announcement{padding:18rpx 20rpx;border-radius:20rpx;background:linear-gradient(135deg,#FFF7EC,#FFF7F9);box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}
+.station-announcement-head{display:flex;align-items:center;gap:10rpx}
+.station-announcement-dot{width:12rpx;height:12rpx;flex:none;border-radius:50%;background:#E8A33D}
+.station-announcement-title{color:#5A4520;font-size:22rpx;font-weight:700}
+.station-announcement-body{display:block;margin-top:8rpx;color:#8A7C63;font-size:19rpx;line-height:1.5}
diff --git a/miniapp/project.config.json b/miniapp/project.config.json
index 7b84821..309f693 100644
--- a/miniapp/project.config.json
+++ b/miniapp/project.config.json
@@ -12,7 +12,9 @@
"disablePlugins": [],
"outputPath": ""
},
- "useCompilerPlugins": false,
+ "useCompilerPlugins": [
+ "typescript"
+ ],
"minifyWXML": true
},
"compileType": "miniprogram",
diff --git a/miniapp/services/contentService.js b/miniapp/services/contentService.js
new file mode 100644
index 0000000..87ca2b1
--- /dev/null
+++ b/miniapp/services/contentService.js
@@ -0,0 +1,28 @@
+/**
+ * 公益内容(U53 婚恋课堂 / U54 内容详情)
+ *
+ * 边界(Phase 19):用户端**只做文章列表与详情**——不做 Feed、不做评论、不做点赞。
+ * 因此本层只提供分类 / 列表 / 详情三个只读方法。
+ */
+const api = require('../utils/api');
+
+/** 内容分类 */
+function getCategories() {
+ return api.get('/content/categories');
+}
+
+/** 文章列表(可按分类过滤) */
+function getArticles(params = {}) {
+ return api.get('/content/articles', params);
+}
+
+/** 文章详情 */
+function getArticleDetail(id) {
+ return api.get(`/content/articles/${id}`);
+}
+
+module.exports = {
+ getCategories,
+ getArticles,
+ getArticleDetail,
+};
diff --git a/miniapp/services/index.js b/miniapp/services/index.js
index c234752..7b595f7 100644
--- a/miniapp/services/index.js
+++ b/miniapp/services/index.js
@@ -20,6 +20,13 @@ const matchmakerService = require('./matchmakerService');
const safetyService = require('./safetyService');
const appealService = require('./appealService');
const appConfigService = require('./appConfigService');
+// Phase 19:公益服务链路(服务预约 / 专项服务 / 内容 / 服务记录)
+const serviceAppointmentService = require('./serviceAppointmentService');
+const serviceProgramService = require('./serviceProgramService');
+const contentService = require('./contentService');
+const serviceHistoryService = require('./serviceHistoryService');
+// Phase 19:服务站(U50)—— 临时公告只读接入
+const serviceStationService = require('./serviceStationService');
module.exports = {
authService,
@@ -34,4 +41,9 @@ module.exports = {
safetyService,
appealService,
appConfigService,
+ serviceAppointmentService,
+ serviceProgramService,
+ contentService,
+ serviceHistoryService,
+ serviceStationService,
};
diff --git a/miniapp/services/serviceAppointmentService.js b/miniapp/services/serviceAppointmentService.js
new file mode 100644
index 0000000..d6ae8cb
--- /dev/null
+++ b/miniapp/services/serviceAppointmentService.js
@@ -0,0 +1,80 @@
+/**
+ * 服务预约(U51 服务预约 / U52 我的预约)
+ *
+ * 归属与契约边界(Phase 19):
+ * - 用户端只能读写本人预约:详情/列表/取消全部由服务端按登录主体过滤,
+ * 越权访问一律返回空或 404,前端不得以任何参数指定他人 userId。
+ * - 提交预约**只传服务内容**。来源(source)由服务端强制为 user_self,
+ * 前端禁止传 requestSource / createdByOperatorId / userId(伪造运营代登记身份)。
+ * - 运营代登记走独立端点(后端 /service-appointments/operator/register),前端不在本层暴露。
+ */
+const api = require('../utils/api');
+
+/**
+ * 读取当前服务站点(U51 预约上下文)
+ *
+ * 服务站页(U50)跳转时会带 `?serviceLocationId=xxx`,本方法用于把该 ID 还原成
+ * 可展示的站点名。仅**展示用途**:真正的归属由服务端按登录主体校验,
+ * 展示失败(无站点 / 接口异常)不影响提交,返回 null。
+ * @returns {Promise<{id: string, name: string} | null>}
+ */
+async function getCurrentServiceLocation() {
+ try {
+ const home = await api.get('/home');
+ const location = home && home.serviceLocation;
+ if (!location || !location.id) {
+ return null;
+ }
+ return { id: location.id, name: location.name || '已选服务站点' };
+ } catch (err) {
+ return null;
+ }
+}
+
+/**
+ * 我的预约列表
+ * @returns {Promise>}
+ */
+function getMyAppointments() {
+ return api.get('/service-appointments');
+}
+
+/**
+ * 预约详情(越权由服务端返回 404)
+ * @param {string} id
+ */
+function getAppointmentDetail(id) {
+ return api.get(`/service-appointments/${id}`);
+}
+
+/**
+ * 提交预约
+ * @param {{ serviceLocationId?: string, serviceType: string, preferredDate: string,
+ * preferredTimeSlot?: string, userNote?: string }} payload
+ */
+function createAppointment(payload) {
+ return api.post('/service-appointments', {
+ serviceLocationId: payload.serviceLocationId,
+ serviceType: payload.serviceType,
+ preferredDate: payload.preferredDate,
+ preferredTimeSlot: payload.preferredTimeSlot,
+ userNote: payload.userNote,
+ });
+}
+
+/**
+ * 取消我的预约
+ * @param {string} id
+ * @param {string} [reason]
+ */
+function cancelAppointment(id, reason) {
+ return api.post(`/service-appointments/${id}/cancel`, { reason: reason || '' });
+}
+
+module.exports = {
+ getCurrentServiceLocation,
+ getMyAppointments,
+ getAppointmentDetail,
+ createAppointment,
+ cancelAppointment,
+};
diff --git a/miniapp/services/serviceHistoryService.js b/miniapp/services/serviceHistoryService.js
new file mode 100644
index 0000000..5713146
--- /dev/null
+++ b/miniapp/services/serviceHistoryService.js
@@ -0,0 +1,15 @@
+/**
+ * 公益服务记录(U64)
+ *
+ * 只返回当前用户本人的服务时间线摘要(类别/标题/状态/时间)。
+ * 服务端已排除咨询正文与内部备注(INV-013 最小可见),前端不得展示任何内部字段。
+ */
+const api = require('../utils/api');
+
+function getMyServiceHistory() {
+ return api.get('/me/service-history');
+}
+
+module.exports = {
+ getMyServiceHistory,
+};
diff --git a/miniapp/services/serviceProgramService.js b/miniapp/services/serviceProgramService.js
new file mode 100644
index 0000000..c419e24
--- /dev/null
+++ b/miniapp/services/serviceProgramService.js
@@ -0,0 +1,34 @@
+/**
+ * 专项公益服务项目(U61 专项公益服务介绍)
+ *
+ * 边界:项目成员身份只用于服务办理,**不得**成为用户价值标签(Phase 19 / INV 语义)。
+ * 因此本层只暴露项目信息与本人申请状态,不提供"按项目成员筛选用户"之类能力。
+ */
+const api = require('../utils/api');
+
+/** 可申请/进行中的专项服务项目列表 */
+function getPrograms(params = {}) {
+ return api.get('/service-programs', params);
+}
+
+/** 项目详情 */
+function getProgramDetail(id) {
+ return api.get(`/service-programs/${id}`);
+}
+
+/** 申请加入项目 */
+function applyToProgram(id) {
+ return api.post(`/service-programs/${id}/apply`, {});
+}
+
+/** 我的项目申请 */
+function getMyApplications() {
+ return api.get('/service-programs/my');
+}
+
+module.exports = {
+ getPrograms,
+ getProgramDetail,
+ applyToProgram,
+ getMyApplications,
+};
diff --git a/miniapp/services/serviceStationService.js b/miniapp/services/serviceStationService.js
new file mode 100644
index 0000000..3edf26c
--- /dev/null
+++ b/miniapp/services/serviceStationService.js
@@ -0,0 +1,52 @@
+/**
+ * 善缘服务站(U50)服务层。
+ *
+ * Phase 19:U50 的内容清单包含「临时公告 / 开放时间 / 服务项目 / 预约到站」,
+ * 但公告此前**全仓没有任何存储与接口**,页面也无落点。本轮补齐后端
+ * (V042 + 运营端五个端点 + 用户端只读端点)后由此接入。
+ *
+ * 分层:按 services/index.js 的约定,页面不直接 require utils/api,
+ * 统一从本模块取数据(本站点主体信息来自首页聚合,公告来自服务站公告端点)。
+ *
+ * 契约:
+ * GET /home → 当前服务站主体信息
+ * GET /service-locations/{id}/announcements → 仅 status=published 且未过期
+ * 草稿/归档/过期由服务端过滤,前端不得再做"猜测式"过滤,
+ * 否则等于把未定稿内容拿到客户端。
+ */
+const api = require('../utils/api');
+
+/**
+ * 当前展示的服务站(首页聚合给出的唯一站点)。
+ * @returns {Promise}
+ */
+async function getStation() {
+ const home = await api.get('/home');
+ return (home && home.serviceLocation) || null;
+}
+
+/**
+ * 某服务站的可见临时公告。
+ *
+ * 公告属**增强信息**:请求失败时返回空数组而不抛出,
+ * 避免"公告接口挂了导致整个服务站详情页变成错误页"。
+ * @param {string} locationId
+ * @returns {Promise>}
+ */
+async function getVisibleAnnouncements(locationId) {
+ if (!locationId) {
+ return [];
+ }
+ try {
+ const list = await api.get(`/service-locations/${encodeURIComponent(locationId)}/announcements`);
+ return Array.isArray(list) ? list : [];
+ } catch (err) {
+ console.warn('加载服务站公告失败', err);
+ return [];
+ }
+}
+
+module.exports = {
+ getStation,
+ getVisibleAnnouncements,
+};
diff --git a/miniapp/styles/service-page.wxss b/miniapp/styles/service-page.wxss
index d77bc3f..0a90cff 100644
--- a/miniapp/styles/service-page.wxss
+++ b/miniapp/styles/service-page.wxss
@@ -1,3 +1,3 @@
-page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
+page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.select-box.is-readonly{background:#F4F2F0;color:var(--color-text-secondary)}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
.service-shell{min-height:100vh;padding-bottom:calc(136rpx + env(safe-area-inset-bottom));background:#F4F5FA}.service-content{padding:8rpx 24rpx 36rpx}.service-status{display:flex;align-items:center;justify-content:space-between;margin-top:24rpx;padding:20rpx 24rpx;border-radius:22rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-status__label{color:#7B8198;font-size:21rpx}.service-status__value{padding:6rpx 14rpx;border-radius:18rpx;background:#EAF8F2;color:#329472;font-size:19rpx;font-weight:650}.service-status__value.disabled{background:#FFF0F0;color:#D45A60}.service-topics{display:grid;grid-template-columns:repeat(2,1fr);gap:16rpx;margin-top:18rpx}.service-topic{min-height:148rpx;padding:20rpx;border-radius:24rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-topic__icon{display:flex;align-items:center;justify-content:center;width:54rpx;height:54rpx;border-radius:17rpx;background:#FFF0F5}.service-topic__icon image{width:34rpx;height:34rpx}.service-topic__title{display:block;margin-top:13rpx;color:#283056;font-size:24rpx;font-weight:650}.service-topic__desc{display:block;margin-top:6rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.service-actions{display:flex;gap:14rpx}.service-actions .secondary-btn{width:190rpx;flex:none}.service-actions .primary-btn{flex:1}.privacy-note{display:flex;align-items:flex-start;gap:14rpx;margin-top:22rpx;padding:20rpx;border-radius:22rpx;background:#F1F4FF;color:#69718E;font-size:20rpx;line-height:1.55}.privacy-note image{width:34rpx;height:34rpx;flex:none}
diff --git a/ui-tests/admin-role-navigation-contracts.mjs b/ui-tests/admin-role-navigation-contracts.mjs
new file mode 100644
index 0000000..7572df0
--- /dev/null
+++ b/ui-tests/admin-role-navigation-contracts.mjs
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/**
+ * Phase 19(统一运营后台)门控:后台角色导航契约。
+ *
+ * 背景:本轮复验发现 MainLayout 的 navigation 数组**缺少多个业务入口**——
+ * `/service-appointment`(服务站运营人员的本职工作)、`/banner`、`/service-program`、
+ * `/appeals`、`/audit`、`/matchmaker` 都只有路由没有菜单项。结果是这些角色登录后
+ * **看不到自己的本职入口**(Gate「七类后台角色只看到自己的工作」被击穿为"连自己的工作也看不到")。
+ *
+ * 本门控把两件事变成可自动验证的不变量:
+ * 1. 关键业务域必须在菜单中有入口(删菜单项立即变红);
+ * 2. 菜单不得指向不存在的路由(避免点进去 404 / 白屏)。
+ */
+const root = resolve(import.meta.dirname, '..')
+const read = (p) => readFileSync(resolve(root, p), 'utf8')
+
+const layout = read('admin-web/src/layouts/MainLayout.vue')
+const router = read('admin-web/src/router/index.ts')
+
+// —— 解析 navigation 数组 ——
+const NAV_ENTRY =
+ /\{\s*label:\s*'([^']+)',\s*path:\s*'([^']+)',\s*permissions:\s*\[([^\]]*)\][^}]*\}/g
+const navigation = []
+for (const m of layout.matchAll(NAV_ENTRY)) {
+ const perms = [...m[3].matchAll(/'([^']+)'/g)].map((x) => x[1])
+ navigation.push({ label: m[1], path: m[2], permissions: perms })
+}
+assert.ok(navigation.length >= 18, `导航项数量异常偏少(${navigation.length}),解析可能失效`)
+
+// —— 1. 关键业务域必须有菜单入口(path, 该入口所需的其中之一权限)——
+const requiredMenuEntries = [
+ ['/review', 'review.view'],
+ ['/users', 'user.view'],
+ ['/activities', 'activity.view'],
+ ['/activities/checkin', 'activity.checkin'],
+ ['/activities/interests', 'activity.review_registration'],
+ ['/referral', 'referral.manage'],
+ ['/matchmaker', 'matchmaker.service.view'],
+ ['/counseling', 'counseling.service.view'],
+ ['/banner', 'banner.view'],
+ ['/content', 'content.view'],
+ ['/service-station', 'service_location.view'],
+ ['/service-appointment', 'service_appointment.view'],
+ ['/service-program', 'service_program.manage'],
+ ['/risk/reports', 'risk.report.view'],
+ ['/appeals', 'risk.report.process'],
+ ['/analytics', 'analytics.view'],
+ ['/organizations', 'organization.manage'],
+ ['/audit', 'user.view'],
+ ['/operators', 'permission.manage'],
+]
+
+const byPath = new Map(navigation.map((n) => [n.path, n]))
+for (const [path, perm] of requiredMenuEntries) {
+ const entry = byPath.get(path)
+ assert.ok(entry, `后台菜单缺少业务入口 ${path}(角色将看不到自己的本职工作)`)
+ assert.ok(
+ entry.permissions.includes(perm),
+ `菜单 ${path} 的权限应为 ${perm},实际 ${JSON.stringify(entry.permissions)}`,
+ )
+}
+
+// —— 2. 菜单不得指向不存在的路由 ——
+const routerPaths = new Set(
+ [...router.matchAll(/path:\s*'([^']+)'/g)].map((m) => m[1]),
+)
+for (const entry of navigation) {
+ if (entry.path === '/dashboard') continue
+ assert.ok(
+ routerPaths.has(entry.path) || routerPaths.has(entry.path.replace(/^\//, '')),
+ `菜单 ${entry.label} 指向不存在的路由 ${entry.path}`,
+ )
+}
+
+// —— 3. 菜单项路径不得重复(防止同一入口被登记两次导致角色看到重复项)——
+const seen = new Set()
+for (const entry of navigation) {
+ assert.ok(!seen.has(entry.path), `菜单路径重复:${entry.path}`)
+ seen.add(entry.path)
+}
+
+// —— 4. 角色名判定必须覆盖七类后台角色(缺分支会让角色被降级显示为"运营人员")——
+for (const marker of [
+ '平台管理员',
+ '咨询管理员',
+ '内容编辑员',
+ '风控运营人员',
+ '公益咨询人员',
+ '服务站运营人员',
+ '资料审核人员',
+]) {
+ assert.ok(layout.includes(marker), `角色名判定缺少「${marker}」分支`)
+}
+
+console.log('Admin role navigation contracts passed')
diff --git a/ui-tests/miniapp-reference-contracts.mjs b/ui-tests/miniapp-reference-contracts.mjs
index 27e552a..ca2106a 100644
--- a/ui-tests/miniapp-reference-contracts.mjs
+++ b/ui-tests/miniapp-reference-contracts.mjs
@@ -105,6 +105,28 @@ for (const label of ['我的红娘', '我的咨询', '我的活动', '我的预
assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`)
}
+// U37「我的」四组结构(《页面级功能清单与交互流程修订版》第三十一章信息架构)
+// 缺组会让入口找不到归属,缺入口会让已实现的页面变成不可达死页(Phase 19 的 U49 就是如此)。
+const mineGroups = mineTemplate.match(/ui-section-header\s+title="[^"]*"/g) || []
+assert.strictEqual(mineGroups.length, 4, `我的页必须是四组结构,实际 ${mineGroups.length} 组`)
+for (const group of ['我的资料与缘分', '我的公益服务', '安全与隐私', '公益信息']) {
+ assert.ok(mineTemplate.includes(`title="${group}"`), `我的页缺少分组「${group}」`)
+}
+for (const label of ['公益服务中心', '公益服务记录', '黑名单', '婚恋课堂', '婚恋反诈', '善缘服务站', '关于善缘丹阳']) {
+ assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`)
+}
+// 每个 bindtap 必须在同名 .ts 里真的实现了,否则是"点不动的入口"
+const mineLogic = readPageLogic('mine')
+const mineHandlers = new Set(
+ [...mineTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]),
+)
+for (const handler of mineHandlers) {
+ assert.ok(
+ new RegExp(`\\b${handler}\\s*\\(`).test(mineLogic),
+ `我的页入口绑定了未实现的处理器 ${handler}(死链)`,
+ )
+}
+
const matchmakerTemplate = read('miniapp/pages/matchmaker/matchmaker.wxml')
const matchmakerLogic = readPageLogic('matchmaker')
const counselingTemplate = read('miniapp/pages/counseling-intro/counseling-intro.wxml')
@@ -142,14 +164,40 @@ assert.match(antiFraudTemplate, /安全求助/, '婚恋反诈缺少求助入口'
const stationTemplate = read('miniapp/pages/service-station/service-station.wxml')
const stationLogic = readPageLogic('service-station')
+const stationService = readSource('miniapp/services/serviceStationService.js')
for (const label of ['地址', '联系电话', '开放时间', '服务项目', '地图定位', '预约到站服务']) {
assert.ok(stationTemplate.includes(label), `服务站缺少 ${label}`)
}
assert.match(stationLogic, /wx\.makePhoneCall/, '服务站电话必须使用微信原生拨号')
assert.match(stationLogic, /wx\.openLocation/, '服务站定位必须使用微信原生地图')
-assert.match(stationLogic, /api\.get.*['"]\/home['"]/, '服务站必须读取真实首页服务站数据')
+assert.match(stationLogic, /serviceStationService/, '服务站页面必须通过 services 层取数(不得直连 utils/api)')
+assert.doesNotMatch(stationLogic, /utils\/api/, '服务站页面不得直接依赖 utils/api(分层约定见 services/index.js)')
+// Phase 19:站点主体信息来自首页聚合。断言落在**服务层**——页面已按分层约定
+// 收敛到 serviceStationService,若仍断言页面源码会将「分层重构」误判为「不再读真实数据」。
+assert.match(stationService, /api\.get\(\s*['"]\/home['"]\s*\)/, '服务站必须读取真实首页服务站数据')
+assert.doesNotMatch(stationService, /00000000/, '服务站不得保留演示电话')
assert.doesNotMatch(stationLogic, /00000000/, '服务站不得保留演示电话')
+// Phase 19:U50「临时公告」。此前全仓无公告存储与接口,本轮补齐后端 + 用户端只读端点。
+assert.ok(stationTemplate.includes('临时公告'), '服务站缺少临时公告区块(U50 内容清单明确要求)')
+assert.match(stationService, /\/service-locations\/\$\{[^}]*\}\/announcements/,
+ '公告必须来自正式接口 /service-locations/{id}/announcements')
+assert.match(stationLogic, /getVisibleAnnouncements/, 'U50 必须接入可见公告服务方法')
+// 公告为增强信息:失败必须降级为空数组,不得把整页打成错误页
+assert.match(stationService, /catch[\s\S]{0,200}return \[\]/,
+ '公告加载失败必须降级为空数组(公告挂了不该让服务站详情页整体报错)')
+{
+ const stationHandlers = new Set(
+ [...stationTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]),
+ )
+ for (const handler of stationHandlers) {
+ assert.ok(
+ new RegExp(`\\b${handler}\\s*\\(`).test(stationLogic),
+ `服务站页 bindtap="${handler}" 在 .ts 中没有对应实现(点了没反应)`,
+ )
+ }
+}
+
for (const envFile of ['miniapp/env.js', 'miniapp/env.ts']) {
const envSource = read(envFile)
assert.match(envSource, /getAccountInfoSync/, `${envFile} 必须通过微信运行版本识别开发环境`)