fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green
Backend - Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint. - Fix privilege escalation: create/update were guarded only by the class-level service_location.view, making V015's service_location.manage a never-used dead permission -> read-only roles could edit stations. Permissions moved to method level. - Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest (it was NOT scanned, which is why the escalation stayed invisible). - Add create/update validation (address NOT NULL + status CHECK only blew up on PG). - Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live). - Content: operator list/detail split (admin «publish» was a dead end because it reused the published-only public API); category deactivate/reactivate; article body on edit. - Service Program: operator member list now includes pending (approval reachable); active-only public visibility (draft leak); memberCount/pendingCount aggregation; withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash). - MeController: add the missing GET /me/service-history route. Schema / guards - schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and content_categories (+ uk_content_category_code, chk_category_status); move both into REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42. - V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029. - New: ServiceLocationAnnouncementPhase19IntegrationTest (24), ServiceLocationControllerPermissionContractTest (5), ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <-> backend whitelist <-> admin labels). Frontend - admin-web: ServiceStationView gains announcement management (statuses, create/edit, publish/archive with confirm, expiry); write buttons gated by service_location.manage. - miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer talks to utils/api directly; announcement failure degrades to [] instead of erroring. - ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to the service layer. Verification - Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no collateral failures; restored, NEG-VAL residue = 0. - Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420). - admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0. - ui-tests 14/14 PASS.
This commit is contained in:
+144
-2
@@ -5,19 +5,63 @@ 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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 首页 Banner 运营管理。
|
||||
*
|
||||
* <p>权限粒度:查看 {@code banner.view}、创建 {@code banner.create}、编辑 {@code banner.edit}。
|
||||
* 类级只声明 {@code banner.view},写操作各自覆盖为更细粒度权限——避免"能看即能改"。
|
||||
*
|
||||
* <p>Phase 19 补齐的落点:
|
||||
* <ul>
|
||||
* <li><b>内部 target 校验</b>:{@code targetType} 必须落在与生产 {@code chk_banner_target_type}
|
||||
* 一致的白名单内,且 {@code targetValue} 禁止外链(http/https/协议相对/javascript)。</li>
|
||||
* <li><b>约束漂移盲区收敛</b>:{@code audienceScope} / {@code status} 也按生产
|
||||
* {@code chk_banner_audience_scope} / {@code chk_banner_status} 白名单前置校验。
|
||||
* 此前非法值会一路写库、直到 PostgreSQL 抛约束异常变成 **500**(Phase 11/13 同族根因)。</li>
|
||||
* <li><b>错误语义</b>:校验失败统一抛 {@code BusinessException(VALIDATION_ERROR)} → HTTP 400,
|
||||
* 而非裸 {@code RuntimeException} 落到兜底 handler 返回 500。</li>
|
||||
* <li><b>软删过滤</b>:{@code home_banners} 的 {@code deleted_at} 无 {@code @TableLogic},
|
||||
* {@code selectById} 不过滤软删行;编辑前显式排除,避免"编辑已删除 Banner"把它复活。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/banners")
|
||||
@PreAuthorize("hasAuthority('banner.view')")
|
||||
@RequiredArgsConstructor
|
||||
public class HomeBannerOperatorController {
|
||||
|
||||
/** 与 V019 chk_banner_target_type 保持一致;外链类型不在其中(禁外链的服务端落点)。 */
|
||||
private static final Set<String> ALLOWED_TARGET_TYPES = Set.of(
|
||||
"activity", "matchmaker", "counseling", "premarital",
|
||||
"service_location", "article", "internal_page", "none");
|
||||
|
||||
/** 与 V019 chk_banner_audience_scope 保持一致。 */
|
||||
private static final Set<String> ALLOWED_AUDIENCE_SCOPES = Set.of(
|
||||
"all", "onboarding", "searching", "getting_to_know", "successful");
|
||||
|
||||
/** 与 V019 chk_banner_status 保持一致。scheduled/expired 由排期任务计算,但白名单同样接受。 */
|
||||
private static final Set<String> ALLOWED_STATUSES = Set.of(
|
||||
"draft", "scheduled", "active", "inactive", "expired");
|
||||
|
||||
/** 必须有明确站内落点的跳转类型(V027 生产种子里这两类都带 target_value)。 */
|
||||
private static final Set<String> NEEDS_EXPLICIT_TARGET = Set.of("internal_page", "article");
|
||||
|
||||
private static final int TITLE_MAX_LENGTH = 200;
|
||||
private static final int SUBTITLE_MAX_LENGTH = 300;
|
||||
private static final int TARGET_VALUE_MAX_LENGTH = 200;
|
||||
private static final int BUTTON_TEXT_MAX_LENGTH = 64;
|
||||
|
||||
private final HomeBannerMapper bannerMapper;
|
||||
|
||||
@GetMapping
|
||||
@@ -26,24 +70,122 @@ public class HomeBannerOperatorController {
|
||||
.isNull(HomeBanner::getDeletedAt).orderByAsc(HomeBanner::getSortOrder)));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('banner.create')")
|
||||
@PostMapping
|
||||
public ApiResponse<HomeBanner> create(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody HomeBanner banner) {
|
||||
validate(banner);
|
||||
banner.setId(null);
|
||||
banner.setDeletedAt(null);
|
||||
if (banner.getStatus() == null || banner.getStatus().isBlank()) {
|
||||
// 未显式指定时与生产默认值一致,避免依赖隐式 DB DEFAULT
|
||||
banner.setStatus("active");
|
||||
}
|
||||
banner.setCreatedBy(principal.id());
|
||||
banner.setUpdatedBy(principal.id());
|
||||
bannerMapper.insert(banner);
|
||||
return ApiResponse.ok(banner);
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('banner.edit')")
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<HomeBanner> update(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody HomeBanner banner) {
|
||||
if (bannerMapper.selectById(id) == null) return ApiResponse.error(ApiResponseCode.NOT_FOUND);
|
||||
HomeBanner existing = findActiveBanner(id);
|
||||
if (existing == null) {
|
||||
return ApiResponse.error(ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
validate(banner);
|
||||
banner.setId(id);
|
||||
// 归属与软删状态不由请求体改写
|
||||
banner.setCreatedBy(null);
|
||||
banner.setDeletedAt(null);
|
||||
banner.setUpdatedBy(principal.id());
|
||||
bannerMapper.updateById(banner);
|
||||
return ApiResponse.ok(bannerMapper.selectById(id));
|
||||
return ApiResponse.ok(findActiveBanner(id));
|
||||
}
|
||||
|
||||
/** 软删过滤后的单条查询:绕开 selectById 不过滤 deleted_at 的缺口。 */
|
||||
private HomeBanner findActiveBanner(String id) {
|
||||
return bannerMapper.selectOne(new LambdaQueryWrapper<HomeBanner>()
|
||||
.eq(HomeBanner::getId, id)
|
||||
.isNull(HomeBanner::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner 校验:类型/受众/状态白名单 + 禁外链 + 排期与长度约束。
|
||||
*
|
||||
* <p>拒绝而非静默改写:运营误配外链会把用户导向站外,必须显式报错。
|
||||
* 所有失败统一以 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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -13,10 +13,15 @@ public interface HomeBannerMapper extends BaseMapper<HomeBanner> {
|
||||
/**
|
||||
* 查询当前应展示的Banner列表(按时间+状态+受众过滤)
|
||||
* 由调用方根据用户状态二次过滤audience_scope
|
||||
*
|
||||
* <p>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<HomeBanner> {
|
||||
List<HomeBanner> 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})
|
||||
|
||||
+42
@@ -36,6 +36,48 @@ public class ContentController {
|
||||
}
|
||||
|
||||
// ---- Admin ----
|
||||
|
||||
/**
|
||||
* 运营后台分类列表(含已停用分类)。
|
||||
*
|
||||
* <p>公开接口 {@code GET /content/categories} 只给 active,运营侧若复用它,
|
||||
* 停用过的分类就再也看不到、无法重新启用。
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('content.view')")
|
||||
@GetMapping("/operator/categories")
|
||||
public ApiResponse<List<ContentCategoryDTO>> getOperatorCategories() {
|
||||
return ApiResponse.ok(contentService.getOperatorCategories());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营后台文章列表(全部状态,含草稿)。
|
||||
*
|
||||
* <p>公开接口只返回 published,草稿不会出现在列表里 → 后台「发布」按钮永远点不到。
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('content.view')")
|
||||
@GetMapping("/operator/articles")
|
||||
public ApiResponse<List<ContentArticleDTO>> getOperatorArticles() {
|
||||
return ApiResponse.ok(contentService.getOperatorArticles());
|
||||
}
|
||||
|
||||
/** 运营后台文章详情:不限状态且带正文,供编辑弹窗回填。 */
|
||||
@PreAuthorize("hasAuthority('content.view')")
|
||||
@GetMapping("/operator/articles/{id}")
|
||||
public ApiResponse<ContentArticleDTO> getOperatorArticleDetail(@PathVariable String id) {
|
||||
return ApiResponse.ok(contentService.getOperatorArticleDetail(id));
|
||||
}
|
||||
|
||||
/** 启用/停用分类:物理删除会破坏历史文章的分类外键,停用才是正确语义。 */
|
||||
@PreAuthorize("hasAuthority('content.edit')")
|
||||
@PutMapping("/operator/categories/{id}/status")
|
||||
public ApiResponse<ContentCategoryDTO> setCategoryStatus(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id, @RequestBody Map<String, Object> 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<ContentCategoryDTO> createCategory(
|
||||
|
||||
+18
-4
@@ -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;
|
||||
}
|
||||
|
||||
+288
-35
@@ -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;
|
||||
|
||||
/**
|
||||
* 公益内容服务(分类 + 文章)。
|
||||
*
|
||||
* <p>Phase 19 修复的运营后台缺陷(均为"历史 ✅ 不可信"范畴):
|
||||
* <ul>
|
||||
* <li><b>「发布」是死路</b>:后台文章列表过去复用公开接口 {@code GET /content/articles},
|
||||
* 而该接口只返回 {@code published};新建文章恒为 {@code draft} → 列表里看不到 →
|
||||
* 后台「发布」按钮永远不出现。现补 {@link #getOperatorArticles()} 供运营侧使用,
|
||||
* 公开接口保持"仅已发布",避免草稿/下架内容泄露到小程序。</li>
|
||||
* <li><b>分类管理看不到已停用分类</b>:{@code getAllCategories()} 只返回 {@code active},
|
||||
* 停用后无法在后台重新启用。补 {@link #getOperatorCategories()}。</li>
|
||||
* <li><b>编辑文章看不到正文</b>:列表 DTO 不含 {@code content}(有意保持轻量),
|
||||
* 后台编辑弹窗因此回填不到正文。补 {@link #getOperatorArticleDetail}。</li>
|
||||
* <li><b>约束漂移盲区</b>:分类 {@code status} / 文章 {@code status} 从不校验,
|
||||
* 非法值只在 PostgreSQL 撞 {@code chk_category_status} / {@code chk_article_status} 才 500;
|
||||
* 另 {@code content_categories.code} 生产有 {@code UNIQUE(code)},重复编码同样 500。
|
||||
* 现全部前置校验并返回 400。</li>
|
||||
* <li><b>软删过滤缺口</b>:{@code content_categories} 无 {@code @TableLogic},
|
||||
* {@code selectList} 会把软删分类当成正常数据返回。</li>
|
||||
* <li><b>错误语义</b>:裸 {@code RuntimeException} 落到兜底 handler 返回 500,
|
||||
* 统一改为 {@code VALIDATION_ERROR}(400) / {@code NOT_FOUND}(404)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ContentService {
|
||||
|
||||
/** 对齐生产 V026 chk_category_status。 */
|
||||
private static final Set<String> ALLOWED_CATEGORY_STATUSES = Set.of(
|
||||
Constants.CATEGORY_STATUS_ACTIVE, Constants.CATEGORY_STATUS_INACTIVE);
|
||||
|
||||
/** 对齐生产 V026 chk_article_status(scheduled 由排期流转,不在此处手工设置)。 */
|
||||
private static final Set<String> 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<ContentCategoryDTO> getAllCategories() {
|
||||
return categoryMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentCategory>().eq(ContentCategory::getStatus, "active")
|
||||
).stream().map(this::toCategoryDTO).toList();
|
||||
return categoryMapper.selectList(new LambdaQueryWrapper<ContentCategory>()
|
||||
.eq(ContentCategory::getStatus, Constants.CATEGORY_STATUS_ACTIVE)
|
||||
.isNull(ContentCategory::getDeletedAt)
|
||||
.orderByAsc(ContentCategory::getSortOrder))
|
||||
.stream().map(this::toCategoryDTO).toList();
|
||||
}
|
||||
|
||||
/** 小程序 U53:只返回已发布文章(草稿/下架不得外泄)。 */
|
||||
public List<ContentArticleDTO> getPublishedArticles(String categoryId) {
|
||||
List<ContentArticle> 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<ContentCategoryDTO> getOperatorCategories() {
|
||||
return categoryMapper.selectList(new LambdaQueryWrapper<ContentCategory>()
|
||||
.isNull(ContentCategory::getDeletedAt)
|
||||
.orderByAsc(ContentCategory::getSortOrder))
|
||||
.stream().map(this::toCategoryDTO).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营后台文章列表:**包含全部状态**(draft/scheduled/published/offline)。
|
||||
*
|
||||
* <p>这是"发布/下架"能工作的前提——公开接口只给 published,草稿根本不出现在列表里。
|
||||
*/
|
||||
public List<ContentArticleDTO> getOperatorArticles() {
|
||||
return articleMapper.selectList(new LambdaQueryWrapper<ContentArticle>()
|
||||
.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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/停用分类。
|
||||
*
|
||||
* <p>分类不做物理删除:{@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<ContentCategory>()
|
||||
.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<String> allowedCategoryStatuses() {
|
||||
return ALLOWED_CATEGORY_STATUSES;
|
||||
}
|
||||
|
||||
/** 保留:供契约测试/其他服务判断合法文章状态取值。 */
|
||||
public static Set<String> allowedArticleStatuses() {
|
||||
return ALLOWED_ARTICLE_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object> getMyAppeals(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(appealService.getMyAppeals(principal.id()));
|
||||
}
|
||||
|
||||
// ==================== 我的公益服务记录 ====================
|
||||
|
||||
/**
|
||||
* 本人公益服务时间线(小程序 U64「公益服务记录」)。
|
||||
* GET /api/v1/me/service-history
|
||||
*
|
||||
* <p>数据源为本人服务预约 + 本人婚恋咨询记录,按登录主体过滤(调用方只能传
|
||||
* {@code principal.id()},无越权读取他人时间的路径)。
|
||||
* <b>INV-013 最小可见</b>:只返回类别/标题/状态/时间等摘要,
|
||||
* 不含咨询诉求正文与运营内部备注——详见 {@code ServiceHistoryService}。
|
||||
*/
|
||||
@GetMapping("/service-history")
|
||||
public ApiResponse<Object> getMyServiceHistory(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(serviceHistoryService.getMyServiceHistory(principal.id()));
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -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「公益服务记录」)。
|
||||
*
|
||||
* <p><b>最小可见(INV-013)</b>:只返回当前用户自己的记录摘要——类别、标题、状态、时间。
|
||||
* <b>绝不</b>返回咨询诉求正文({@code user_request_summary})、跟进正文({@code counseling_followups.content})
|
||||
* 或运营内部备注。用户端不应看到任何内部字段。
|
||||
*
|
||||
* <p><b>归属</b>:两个数据源均按 {@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<Map<String, Object>> getMyServiceHistory(String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
|
||||
for (ServiceAppointment appointment : appointmentMapper.findByUserId(userId)) {
|
||||
Map<String, Object> 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<String, Object> item) -> String.valueOf(item.getOrDefault("occurredAt", "")),
|
||||
Comparator.reverseOrder()));
|
||||
return items;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseEntry(String id, String category, String title,
|
||||
String status, String occurredAt) {
|
||||
Map<String, Object> 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 -> "公益婚恋咨询";
|
||||
};
|
||||
}
|
||||
}
|
||||
+19
@@ -67,6 +67,25 @@ public class ServiceAppointmentController {
|
||||
|
||||
// ---- 后台端 ----
|
||||
|
||||
/**
|
||||
* 运营代登记:独立端点 + 独立权限 {@code service.assisted_registration}。
|
||||
*
|
||||
* <p>与用户端 {@code POST /service-appointments} 严格分离:服务对象由请求体显式指定,
|
||||
* 登记人取自登录运营主体,来源固定 {@code operator_assisted}。
|
||||
*
|
||||
* <p>权限复用 V029 已下发的 {@code service.assisted_registration}("现场协助登记",
|
||||
* 已授予服务站运营人员与平台管理员)。此前该权限在 Java 侧从未被引用,
|
||||
* 属"只种不用"的死权限;再新增一个同义权限会造成权限膨胀,故收敛到一个。
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('service.assisted_registration')")
|
||||
@PostMapping("/operator/register")
|
||||
public ApiResponse<ServiceAppointmentDTO> 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<List<ServiceAppointmentDTO>> getPendingForOperator(
|
||||
|
||||
+33
@@ -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;
|
||||
|
||||
/**
|
||||
* 善缘服务站(用户端只读)。
|
||||
*
|
||||
* <p>U50「善缘服务站详情」的内容清单包含「临时公告」,但此前全仓没有任何用户端落点 ——
|
||||
* 公告既无存储也无接口。本控制器补上唯一的用户端读口:
|
||||
* 只返回 {@code published} 且未过期的公告,草稿/归档/过期一律不出。
|
||||
*
|
||||
* <p>鉴权依赖 {@code SecurityConfig} 的 {@code /** → authenticated} 兜底:
|
||||
* 公告是登录后可见内容,不做匿名放行(与 {@code /home} 一致)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/service-locations")
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceLocationController {
|
||||
|
||||
private final ServiceLocationAnnouncementService announcementService;
|
||||
|
||||
/** 某服务站的可见临时公告;站点不存在或已下线时返回空列表(不区分,避免探测)。 */
|
||||
@GetMapping("/{id}/announcements")
|
||||
public ApiResponse<List<ServiceLocationAnnouncement>> announcements(@PathVariable String id) {
|
||||
return ApiResponse.ok(announcementService.getVisibleAnnouncements(id));
|
||||
}
|
||||
}
|
||||
+149
-4
@@ -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;
|
||||
|
||||
/**
|
||||
* 善缘服务站运营端(信息维护 + 临时公告)。
|
||||
*
|
||||
* <p>Phase 19 复验修出两类真实缺陷,本类为此重写:
|
||||
* <ol>
|
||||
* <li><b>越权写</b>:原实现把 {@code @PreAuthorize("hasAuthority('service_location.view')")}
|
||||
* 挂在**类**上,而 V015 真正的写权限 {@code service_location.manage} 从未被使用 ——
|
||||
* 于是"只能看"的角色可以直接 POST/PUT 改站点信息。现按方法分别守护:
|
||||
* 读 {@code view}、写 {@code manage}。</li>
|
||||
* <li><b>公告能力不可达</b>:{@link ServiceLocationAnnouncementService} 与 V042 表都已存在,
|
||||
* 但全仓**没有任何端点**暴露它 —— 后台与小程序都拿不到公告,属"写了却接不上"的死路。
|
||||
* 现补齐列表/新建/编辑/发布/归档五个端点。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>另:{@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<String> 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<ServiceLocation>> list() {
|
||||
return ApiResponse.ok(locationMapper.selectList(new LambdaQueryWrapper<ServiceLocation>()
|
||||
.isNull(ServiceLocation::getDeletedAt).orderByAsc(ServiceLocation::getName)));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('service_location.manage')")
|
||||
@PostMapping
|
||||
public ApiResponse<ServiceLocation> 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<ServiceLocation> update(@PathVariable String id, @RequestBody ServiceLocation location) {
|
||||
if (locationMapper.selectById(id) == null) return ApiResponse.error(ApiResponseCode.NOT_FOUND);
|
||||
public ApiResponse<ServiceLocation> 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<List<ServiceLocationAnnouncement>> listAnnouncements(
|
||||
@PathVariable String locationId) {
|
||||
return ApiResponse.ok(announcementService.getOperatorAnnouncements(locationId));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('service_location.manage')")
|
||||
@PostMapping("/{locationId}/announcements")
|
||||
public ApiResponse<ServiceLocationAnnouncement> createAnnouncement(
|
||||
@PathVariable String locationId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(announcementService.create(locationId, body, principal.id()));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('service_location.manage')")
|
||||
@PutMapping("/announcements/{announcementId}")
|
||||
public ApiResponse<ServiceLocationAnnouncement> updateAnnouncement(
|
||||
@PathVariable String announcementId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(announcementService.update(announcementId, body, principal.id()));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('service_location.manage')")
|
||||
@PostMapping("/announcements/{announcementId}/publish")
|
||||
public ApiResponse<ServiceLocationAnnouncement> 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<ServiceLocationAnnouncement> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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)。
|
||||
*
|
||||
* <p>用户端只可见 {@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;
|
||||
}
|
||||
+31
@@ -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<ServiceLocationAnnouncement> {
|
||||
|
||||
/**
|
||||
* 用户端可见公告:仅 {@code published} 且未过期。
|
||||
*
|
||||
* <p>{@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<ServiceLocationAnnouncement> 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<ServiceLocationAnnouncement> findByLocation(@Param("locationId") String locationId);
|
||||
}
|
||||
+32
-8
@@ -36,10 +36,38 @@ public class ServiceAppointmentService {
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
|
||||
/**
|
||||
* 用户提交预约
|
||||
* 用户端提交预约。
|
||||
*
|
||||
* <p>来源恒为 {@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} 权限的运营人员调用。
|
||||
*
|
||||
* <p>服务对象由参数显式指定,来源固定 {@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());
|
||||
}
|
||||
|
||||
+221
@@ -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)。
|
||||
*
|
||||
* <p>此前该能力**完全不存在**(无表/无实体/无接口),属 Phase 19 清单项未实现。
|
||||
* 本服务按全库既有约定实现,并前置校验生产 {@code chk_announcement_status} 白名单,
|
||||
* 避免非法状态只在 PostgreSQL 上以 500 暴露。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceLocationAnnouncementService {
|
||||
|
||||
/** 对齐生产 V042 chk_announcement_status。 */
|
||||
private static final Set<String> 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;
|
||||
|
||||
// ==================== 读取 ====================
|
||||
|
||||
/**
|
||||
* 用户端:仅已发布且未过期的公告。
|
||||
*
|
||||
* <p>站点不存在或已软删 → 返回空列表而非抛错:
|
||||
* 用户端不应通过错误码区分"站点不存在"与"站点存在但无公告",避免被用来探测阵地。
|
||||
*
|
||||
* <p>刻意**不**按 {@code service_locations.status} 过滤:站点"休息中/已关闭"时,
|
||||
* 公告(如"本站在改造,请前往相邻服务站")恰恰是最需要触达用户的内容。
|
||||
*/
|
||||
public List<ServiceLocationAnnouncement> 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<ServiceLocationAnnouncement> getOperatorAnnouncements(String locationId) {
|
||||
requireLocation(locationId);
|
||||
return announcementMapper.findByLocation(locationId);
|
||||
}
|
||||
|
||||
// ==================== 写入 ====================
|
||||
|
||||
@Transactional
|
||||
public ServiceLocationAnnouncement create(String locationId, Map<String, Object> 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<String, Object> 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<String> allowedStatuses() {
|
||||
return ALLOWED_STATUSES;
|
||||
}
|
||||
}
|
||||
+57
-5
@@ -17,18 +17,20 @@ import java.util.Map;
|
||||
public class ServiceProgramController {
|
||||
private final ServiceProgramService serviceProgramService;
|
||||
|
||||
/**
|
||||
* 用户端项目列表。**只返回已激活项目** —— 此前不限定状态,草稿项目会直接下发给小程序。
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<List<ServiceProgramDTO>> list(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) String status) {
|
||||
return ApiResponse.ok(serviceProgramService.getAllPrograms(status));
|
||||
public ApiResponse<List<ServiceProgramDTO>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(serviceProgramService.getActivePrograms());
|
||||
}
|
||||
|
||||
/** 用户端项目详情:只有已激活项目可见。 */
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ServiceProgramDTO> 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<ServiceProgramMemberDTO> withdraw(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(serviceProgramService.withdrawFromProgram(principal.id(), id));
|
||||
}
|
||||
|
||||
// ---- 后台 ----
|
||||
|
||||
/**
|
||||
* 运营端项目列表:**全部状态**(含草稿),并带成员数/待审数。
|
||||
*
|
||||
* <p>与用户端 {@code GET /service-programs}(仅 active)分开,避免草稿泄露到小程序。
|
||||
*/
|
||||
@GetMapping("/operator/list")
|
||||
@PreAuthorize("hasAuthority('service_program.view')")
|
||||
public ApiResponse<List<ServiceProgramDTO>> 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<ServiceProgramDTO> operatorDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(serviceProgramService.getOperatorProgramDetail(id));
|
||||
}
|
||||
|
||||
/** 单项目统计:各状态申请数 + 总数。 */
|
||||
@GetMapping("/operator/{id}/stats")
|
||||
@PreAuthorize("hasAuthority('service_program.view')")
|
||||
public ApiResponse<Map<String, Object>> operatorStats(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(serviceProgramService.getProgramStats(id));
|
||||
}
|
||||
@PostMapping("/operator")
|
||||
@PreAuthorize("hasAuthority('service_program.manage')")
|
||||
public ApiResponse<ServiceProgramDTO> 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<ServiceProgramMemberDTO> removeMember(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String memberId,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
String reason = body == null ? null : body.get("reason");
|
||||
return ApiResponse.ok(serviceProgramService.removeMember(memberId, principal.id(), reason));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
|
||||
+32
-4
@@ -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<ServiceProgramMember> {
|
||||
@Select("SELECT * FROM service_program_members WHERE program_id = #{programId} AND status = 'approved'")
|
||||
List<ServiceProgramMember> findApprovedByProgram(String programId);
|
||||
@Select("SELECT * FROM service_program_members WHERE user_id = #{userId} ORDER BY applied_at DESC")
|
||||
List<ServiceProgramMember> findByUser(String userId);
|
||||
|
||||
/**
|
||||
* 已通过成员(用于公开名单)。
|
||||
*
|
||||
* <p>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<ServiceProgramMember> findApprovedByProgram(@Param("programId") String programId);
|
||||
|
||||
/**
|
||||
* 运营端成员名单:**包含全部状态**(pending/approved/rejected/withdrawn)。
|
||||
*
|
||||
* <p>这是"审批"能工作的前提——此前运营端复用 {@link #findApprovedByProgram},
|
||||
* 待审批(pending)申请永远不出现在列表里,后台「通过/拒绝」按钮因此永远不出现,
|
||||
* 审批功能形同死路(与"文章发布"死路同族)。
|
||||
*/
|
||||
@Select("SELECT * FROM service_program_members WHERE program_id = #{programId} "
|
||||
+ "AND deleted_at IS NULL ORDER BY applied_at DESC")
|
||||
List<ServiceProgramMember> 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<ServiceProgramMember> 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<String> findApprovedProgramIds();
|
||||
}
|
||||
|
||||
+361
-59
@@ -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)。
|
||||
*
|
||||
* <p>Phase 19 修复的缺陷(均为"历史 ✅ 不可信"范畴):
|
||||
* <ul>
|
||||
* <li><b>「审批」是死路</b>:运营端成员名单复用 {@code findApprovedByProgram},
|
||||
* 只返回 {@code approved} → 待审批申请永远不出现在列表里 → 后台「通过/拒绝」
|
||||
* 按钮永远不出现。现运营端用 {@link #getProgramMembers} 返回全部状态。</li>
|
||||
* <li><b>草稿项目泄露给用户端</b>:公开 {@code GET /service-programs} 不限定状态,
|
||||
* 用户端能看到尚未激活的 {@code draft} 项目。现公开列表强制 {@code active}。</li>
|
||||
* <li><b>成员数永远为 0</b>:DTO 缺 {@code memberCount} 且 toDTO 不填充,统计无效。</li>
|
||||
* <li><b>「退出」未实现</b>:生产 {@code chk_program_member_status} 早已允许
|
||||
* {@code withdrawn},但没有任何代码路径写入它。现补用户退出与运营移除。</li>
|
||||
* <li><b>重复申请撞唯一键</b>:生产 {@code UNIQUE(program_id, user_id)} 一直存在,
|
||||
* 被拒/退出后再次申请会 {@code INSERT} 撞唯一键 500(Phase 15 同族根因)。
|
||||
* 现复用原记录并回到 {@code pending}。</li>
|
||||
* <li><b>约束漂移盲区</b>:{@code status} 从不校验,非法值只在 PostgreSQL 撞
|
||||
* {@code chk_program_status} 才 500。</li>
|
||||
* <li><b>时间解析 500</b>:{@code Instant.parse} 遇非 ISO 输入抛 {@code DateTimeParseException}
|
||||
* → 500。现统一转 400。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceProgramService {
|
||||
|
||||
/** 对齐生产 V026 chk_program_status。 */
|
||||
private static final Set<String> ALLOWED_PROGRAM_STATUSES = Set.of(
|
||||
"draft", "active", "ended", "cancelled");
|
||||
|
||||
/** 对齐生产 V026 chk_program_member_status(withdrawn = 用户退出 / 运营移除)。 */
|
||||
private static final Set<String> 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<ServiceProgramDTO> getAllPrograms(String status) {
|
||||
return programMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ServiceProgram>()
|
||||
.eq(status != null, ServiceProgram::getStatus, status)
|
||||
.isNull(ServiceProgram::getDeletedAt)
|
||||
.orderByDesc(ServiceProgram::getCreatedAt)
|
||||
).stream().map(this::toDTO).toList();
|
||||
// ==================== 项目读取 ====================
|
||||
|
||||
/** 用户端列表:**只返回已激活项目**,草稿不得外泄。 */
|
||||
public List<ServiceProgramDTO> getActivePrograms() {
|
||||
return programMapper.selectList(new LambdaQueryWrapper<ServiceProgram>()
|
||||
.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<ServiceProgramDTO> getAllPrograms(String status) {
|
||||
List<ServiceProgramDTO> programs = programMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgram>()
|
||||
.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<String, Object> getProgramStats(String programId) {
|
||||
requireProgram(programId);
|
||||
Map<String, Object> 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<ServiceProgramMember>()
|
||||
.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<String, Object> 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<String, Object> 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<ServiceProgramMemberDTO> getProgramMembers(String programId) {
|
||||
return memberMapper.findApprovedByProgram(programId).stream().map(this::toMemberDTO).toList();
|
||||
return memberMapper.findByProgram(programId).stream().map(this::toMemberDTO).toList();
|
||||
}
|
||||
|
||||
public List<ServiceProgramMemberDTO> getMyProgramMembers(String userId) {
|
||||
return memberMapper.findByUser(userId).stream().map(this::toMemberDTO).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户申请加入项目。
|
||||
*
|
||||
* <p>生产 {@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<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getProgramId, programId)
|
||||
.eq(ServiceProgramMember::getUserId, userId)
|
||||
);
|
||||
if (existing != null) throw new RuntimeException("已申请过该项目");
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动退出项目。
|
||||
*
|
||||
* <p>写 {@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<ServiceProgramMember>()
|
||||
.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<ServiceProgramDTO> programs) {
|
||||
if (programs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// findApprovedProgramIds 每条 approved 记录返回一行 → 计数即成员数
|
||||
Map<String, Long> approvedByProgram = memberMapper.findApprovedProgramIds().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.groupingBy(id -> id, Collectors.counting()));
|
||||
Map<String, Long> pendingByProgram = memberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.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<String> allowedProgramStatuses() {
|
||||
return ALLOWED_PROGRAM_STATUSES;
|
||||
}
|
||||
|
||||
/** 供契约测试断言"合法取值必须与生产 CHECK 一致"。 */
|
||||
public static Set<String> allowedMemberStatuses() {
|
||||
return ALLOWED_MEMBER_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
+287
@@ -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 集成测试。
|
||||
*
|
||||
* <p>背景:Banner 后台此前有三处缺口,均属"历史 ✅ 不可信"范畴:
|
||||
* <ul>
|
||||
* <li>写接口只做类型白名单,{@code audienceScope} / {@code status} 完全不校验 →
|
||||
* 非法值一路写库,直到 PostgreSQL 撞 {@code chk_banner_audience_scope} /
|
||||
* {@code chk_banner_status} 才以 **500** 暴露(Phase 11/13 同族约束漂移根因)。</li>
|
||||
* <li>校验失败抛裸 {@code RuntimeException} → 落到兜底 handler 变 500,语义应为 400。</li>
|
||||
* <li>{@code home_banners} 无 {@code @TableLogic},{@code selectById} 不过滤软删行 →
|
||||
* 可编辑(复活)已软删 Banner;且首页两条查询缺 {@code deleted_at IS NULL},
|
||||
* 软删 Banner 仍上首页。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>本测试直接用真实 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<HomeBanner> 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<HomeBanner> res = controller().create(principal(), banner);
|
||||
assertTrue(res.isSuccess());
|
||||
}
|
||||
|
||||
// ==================== 创建默认值 ====================
|
||||
|
||||
@Test
|
||||
void createDefaultsStatusToActiveAndStampsOperator() {
|
||||
HomeBanner banner = validBanner("默认状态");
|
||||
banner.setStatus(null);
|
||||
banner.setTargetType(null);
|
||||
|
||||
ApiResponse<HomeBanner> 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<HomeBanner> 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<String> titles = bannerMapper.findActiveBanners().stream().map(HomeBanner::getTitle).toList();
|
||||
assertTrue(titles.contains("保留的 Banner"));
|
||||
assertFalse(titles.contains("软删的 Banner"),
|
||||
"软删 Banner 不得出现在首页查询结果中(deleted_at IS NULL)");
|
||||
|
||||
List<String> scopeTitles = bannerMapper.findActiveBannersByScope("all").stream()
|
||||
.map(HomeBanner::getTitle).toList();
|
||||
assertFalse(scopeTitles.contains("软删的 Banner"));
|
||||
}
|
||||
|
||||
// ==================== 约束保真度 ====================
|
||||
|
||||
/**
|
||||
* 保真度断言:测试库必须真的有生产那三条 CHECK。
|
||||
*
|
||||
* <p>若 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 未生效:测试库缺少生产约束(约束漂移盲区)");
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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())
|
||||
|
||||
+293
@@ -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 集成测试。
|
||||
*
|
||||
* <p>背景:内容链路的运营侧此前只被一个"权限注解契约测试"覆盖,
|
||||
* 业务流程本身**零真实覆盖**(`content_categories` 表在测试库完全缺失)。本轮查出并修复:
|
||||
* <ul>
|
||||
* <li><b>「发布」是死路</b>:后台列表复用公开接口(只返回 published),新建文章恒为 draft →
|
||||
* 列表看不到 → 「发布」按钮永远不出现。</li>
|
||||
* <li><b>停用分类后无法恢复</b>:分类列表只返回 active。</li>
|
||||
* <li><b>编辑文章看不到正文</b>:列表 DTO 无 content,后台弹窗回填不到。</li>
|
||||
* <li><b>约束漂移盲区</b>:分类/文章 status 从不校验,非法值只在 PG 撞 CHECK 才 500;
|
||||
* {@code UNIQUE(code)} 重复编码同样 500。</li>
|
||||
* <li><b>错误语义</b>:裸 RuntimeException 落兜底 handler 变 500。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> categoryBody(String name, String code) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", name);
|
||||
body.put("code", code);
|
||||
body.put("description", "Phase 19 测试分类");
|
||||
body.put("sortOrder", 1);
|
||||
return body;
|
||||
}
|
||||
|
||||
private Map<String, Object> articleBody(String title, String categoryId) {
|
||||
Map<String, Object> 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<String> operatorTitles = contentService.getOperatorArticles().stream()
|
||||
.map(ContentArticleDTO::getTitle).toList();
|
||||
assertTrue(operatorTitles.contains("草稿文章"),
|
||||
"运营侧列表必须包含草稿,否则后台永远点不到「发布」");
|
||||
|
||||
List<String> 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<String> 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<String> publicTitles = contentService.getPublishedArticles(null).stream()
|
||||
.map(ContentArticleDTO::getTitle).toList();
|
||||
assertFalse(publicTitles.contains("将下架文章"), "下架后不得出现在公开列表");
|
||||
assertNull(contentService.getArticleDetail(article.getId()),
|
||||
"下架文章对小程序不可读");
|
||||
|
||||
List<String> 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<String> operatorNames = contentService.getOperatorCategories().stream()
|
||||
.map(ContentCategoryDTO::getName).toList();
|
||||
assertTrue(operatorNames.contains("待停用分类"),
|
||||
"运营侧必须能看到已停用分类,否则停用后无法重新启用");
|
||||
|
||||
List<String> 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<String> 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<String> 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<String> 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 未生效:测试库缺少生产约束(约束漂移盲区)");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+166
@@ -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 新增,永久生效)。
|
||||
*
|
||||
* <p><b>为什么需要它:</b>Phase 11/13/15/18 反复出现同一族缺陷 —— 生产库有 CHECK 约束,
|
||||
* 测试库没有,代码里也没有前置校验。非法值在测试期畅通无阻,一上 PostgreSQL 就 500。
|
||||
* Banner 的 {@code target_type} / {@code audience_scope} / {@code status} 正是同一模式。
|
||||
*
|
||||
* <p>本守卫把「**生产迁移 DDL** ↔ **测试库 schema** ↔ **后端白名单** ↔ **后台下拉选项**」
|
||||
* 四者变成编译后可自动验证的不变量 —— 任何一处漂移立刻变红,不必再靠人工比对。
|
||||
*
|
||||
* <p>纯文本 + 反射实现,不启动 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<ConstraintSpec> 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 <name> CHECK (<column> IN ('a', 'b', ...))` 的取值集合。
|
||||
* 解析不到直接失败 —— 空集合会让下面的 assert 变成假绿。
|
||||
*/
|
||||
private static Set<String> 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<String> 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<String> 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<String>) value;
|
||||
}
|
||||
|
||||
// ==================== 后台下拉选项(文本解析) ====================
|
||||
|
||||
/** 解析 `const X_LABEL: Record<string, string> = { key: '...' , ... }` 的 key 集合。 */
|
||||
private static Set<String> adminLabelKeys(String labelMapName) throws IOException {
|
||||
String vue = Files.readString(ADMIN_BANNER_VIEW);
|
||||
Pattern pattern = Pattern.compile(
|
||||
labelMapName + "\\s*:\\s*Record<string,\\s*string>\\s*=\\s*\\{([^}]*)}",
|
||||
Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(vue);
|
||||
assertTrue(matcher.find(), "未能从 " + ADMIN_BANNER_VIEW + " 解析出 " + labelMapName);
|
||||
|
||||
Set<String> 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<String> adminSelectableOptionValues() throws IOException {
|
||||
String vue = Files.readString(ADMIN_BANNER_VIEW);
|
||||
Set<String> values = Pattern.compile("<el-option[^>]*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<String> production = parseCheckValues(PROD_MIGRATION, spec);
|
||||
Set<String> 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<String> 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 + ",与后端不一致");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -54,7 +54,6 @@ class EntityMigrationParityTest {
|
||||
private static final Set<String> 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<String> BASE_ENTITY_COLUMNS =
|
||||
|
||||
+2
-2
@@ -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/";
|
||||
|
||||
+145
@@ -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 新增,永久生效)。
|
||||
*
|
||||
* <p>与 {@link BannerConstraintParityTest} 同族:Phase 11/13/15/18 反复出现同一族缺陷 ——
|
||||
* 生产库有 CHECK,测试库没有,代码里也没有前置校验;非法值在测试期畅通无阻,
|
||||
* 一上 PostgreSQL 就 500。V042 的 {@code chk_announcement_status} 正是同一模式,
|
||||
* 而它本轮新引入(公告能力此前完全不存在),**必须一开始就把不变量钉死**。
|
||||
*
|
||||
* <p>本守卫把四者变成编译后可自动验证的不变量:
|
||||
* <b>生产 V042 DDL ↔ 测试库 schema-h2.sql ↔ 后端白名单 ↔ 后台状态标签</b>。
|
||||
* 任何一处漂移立刻变红,不必靠人工比对。
|
||||
*
|
||||
* <p>纯文本 + 反射实现,不启动 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<String> 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<String> 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<String> backendWhitelist() throws Exception {
|
||||
Method method = ServiceLocationAnnouncementService.class.getDeclaredMethod("allowedStatuses");
|
||||
method.setAccessible(true);
|
||||
Object value = method.invoke(null);
|
||||
assertNotNull(value, "ServiceLocationAnnouncementService.allowedStatuses() 返回 null");
|
||||
return (Set<String>) value;
|
||||
}
|
||||
|
||||
// ==================== 后台标签映射(文本解析) ====================
|
||||
|
||||
/** 解析 `const X_LABEL: Record<string, string> = { key: '...' }` 的 key 集合。 */
|
||||
private static Set<String> adminLabelKeys(String labelMapName) throws IOException {
|
||||
String vue = Files.readString(ADMIN_STATION_VIEW);
|
||||
Pattern pattern = Pattern.compile(
|
||||
labelMapName + "\\s*:\\s*Record<string,\\s*string>\\s*=\\s*\\{([^}]*)}",
|
||||
Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(vue);
|
||||
assertTrue(matcher.find(), "未能从 " + ADMIN_STATION_VIEW + " 解析出 " + labelMapName);
|
||||
|
||||
Set<String> 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<String> production = parseCheckValues(PROD_MIGRATION);
|
||||
Set<String> 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<String> 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 路径将零覆盖");
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
+28
-2
@@ -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 两个文件**。
|
||||
*
|
||||
* <p>后果:任何后续迁移里新增的 {@code role_permissions} 授权对这个守卫完全不可见 ——
|
||||
* 权限矩阵"绿"只代表 V015/V029 那段文本没变,不代表真实矩阵没漂移(假绿)。
|
||||
* 本轮就踩到了:新增一条给服务站运营人员的授权时,守卫毫无反应。
|
||||
*
|
||||
* <p>现在扫描 **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",
|
||||
|
||||
+333
@@ -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 集成测试 —— 服务预约归属与代登记边界。
|
||||
*
|
||||
* <p>背景:
|
||||
* <ul>
|
||||
* <li>用户端 {@code POST /service-appointments} 曾直接采纳请求体中的 {@code requestSource} /
|
||||
* {@code createdByOperatorId},普通用户可伪造"运营代登记"身份,或写入非法 source
|
||||
* 撞生产 {@code chk_source_type}(测试库当时无该约束 → 零覆盖)。</li>
|
||||
* <li>代登记缺少独立 operator 端点与 {@code service_appointment.register} 权限。</li>
|
||||
* <li>{@code service_appointments} 的两条生产 CHECK 此前在测试库完全缺失(约束漂移盲区)。</li>
|
||||
* </ul>
|
||||
* 本测试真实验证归属守卫(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 **每个用例唯一**。
|
||||
*
|
||||
* <p>注意:{@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<String> insertedLocations = new ArrayList<>();
|
||||
private final List<String> 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<ServiceAppointmentDTO> 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 的独立连接。
|
||||
*
|
||||
* <p>{@code DataScopeGuard.deny()} 通过 {@code auditLogMapper.insert} 写在**当前 Spring 事务内**,
|
||||
* 该行尚未提交;裸 JDBC 新开的连接按 READ_COMMITTED 看不到它,会得到 before == after 的假结论。
|
||||
*/
|
||||
private long dataScopeDeniedCount() {
|
||||
return auditLogMapper.selectCount(
|
||||
new LambdaQueryWrapper<AuditLog>().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 约束(否则约束漂移盲区)");
|
||||
}
|
||||
}
|
||||
+401
@@ -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)。
|
||||
*
|
||||
* <p>本能力此前**完全不存在**(无表/无实体/无接口/无测试)。本轮补齐时又查出两个叠加缺陷:
|
||||
* <ul>
|
||||
* <li><b>写了却接不上</b>:Service 与 V042 表都已存在,但全仓没有任何端点暴露它 ——
|
||||
* 后台与小程序都拿不到公告。端点已补齐(见 {@code ServiceLocationOperatorController} /
|
||||
* {@code ServiceLocationController})。</li>
|
||||
* <li><b>测试库缺表</b>:{@code service_location_announcements} 只存在于生产迁移,
|
||||
* 任何调用在测试库都会 BadSqlGrammar —— 由 {@code EntityMigrationParityTest}
|
||||
* 的生产表缺失守卫精准报出,本轮已补进 {@code schema-h2.sql}。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>因此本测试的价值不只是"断言功能正确",更是让整条路径**真的跑在 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<String, Object> announcementBody(String title) {
|
||||
Map<String, Object> 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<ServiceLocationAnnouncement> 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<String, Object> 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<String, Object> 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<String, Object> low = announcementBody("次要公告");
|
||||
low.put("sortOrder", 10);
|
||||
ServiceLocationAnnouncement a = announcementService.create(location.getId(), low, OPERATOR);
|
||||
announcementService.publish(a.getId(), OPERATOR);
|
||||
|
||||
Map<String, Object> high = announcementBody("首要公告");
|
||||
high.put("sortOrder", 1);
|
||||
ServiceLocationAnnouncement b = announcementService.create(location.getId(), high, OPERATOR);
|
||||
announcementService.publish(b.getId(), OPERATOR);
|
||||
|
||||
List<String> titles = announcementService.getVisibleAnnouncements(location.getId()).stream()
|
||||
.map(ServiceLocationAnnouncement::getTitle).toList();
|
||||
assertEquals(List.of("首要公告", "次要公告"), titles,
|
||||
"必须按 sort_order 升序;排序失效会让运营的置顶操作毫无效果");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonIntegerSortOrderIsRejectedAsValidationError() {
|
||||
ServiceLocation location = newLocation("排序校验站");
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> body = announcementBody("占位");
|
||||
body.put("title", " ");
|
||||
|
||||
assertValidationFailure(() ->
|
||||
announcementService.create(location.getId(), body, OPERATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTitleIsRejected() {
|
||||
ServiceLocation location = newLocation("标题缺失站");
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("content", "没有标题");
|
||||
|
||||
assertValidationFailure(() ->
|
||||
announcementService.create(location.getId(), body, OPERATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongTitleIsRejectedBeforeHittingColumnLength() {
|
||||
ServiceLocation location = newLocation("标题超长站");
|
||||
Map<String, Object> body = announcementBody("占位");
|
||||
body.put("title", "长".repeat(201));
|
||||
|
||||
assertValidationFailure(() ->
|
||||
announcementService.create(location.getId(), body, OPERATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedExpiresAtIsValidationErrorNotServerError() {
|
||||
ServiceLocation location = newLocation("时间格式站");
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> body = announcementBody("原标题");
|
||||
body.put("sortOrder", 5);
|
||||
ServiceLocationAnnouncement created = announcementService.create(location.getId(), body, OPERATOR);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
+107
@@ -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 复验新增)。
|
||||
*
|
||||
* <p>背景(真实缺陷,非假设):{@code ServiceLocationOperatorController} 原先把
|
||||
* {@code @PreAuthorize("hasAuthority('service_location.view')")} 挂在**类**上,
|
||||
* 于是 {@code create} / {@code update} 这两个写端点实际只要求"查看服务站"权限 ——
|
||||
* V015 早已下发的 {@code service_location.manage} 成了**从未被使用的死权限**,
|
||||
* 只读角色可以直接改站点信息。该控制器同时还**不在**
|
||||
* {@code OperatorEndpointPermissionCoverageTest} 的扫描清单里,所以无人发现。
|
||||
*
|
||||
* <p>本守卫把下面这条不变量固化,防止再次退化:
|
||||
* <b>服务站的所有写端点必须且只能由 {@code service_location.manage} 保护。</b>
|
||||
*/
|
||||
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<String, String> WRITE_ENDPOINTS = Map.of(
|
||||
"create", MANAGE,
|
||||
"update", MANAGE,
|
||||
"createAnnouncement", MANAGE,
|
||||
"updateAnnouncement", MANAGE,
|
||||
"publishAnnouncement", MANAGE,
|
||||
"archiveAnnouncement", MANAGE);
|
||||
|
||||
/** 后台读取 → service_location.view。 */
|
||||
private static final Map<String, String> 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,
|
||||
"存在未被权限契约清单覆盖的写端点(新增端点请同步登记本测试)");
|
||||
}
|
||||
}
|
||||
+76
@@ -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 新增)。
|
||||
*
|
||||
* <p>背景:本模块此前**没有任何权限契约测试**。新增写端点若忘记加 {@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<String, String> WRITE_ENDPOINTS = Map.of(
|
||||
"create", MANAGE,
|
||||
"update", MANAGE,
|
||||
"activate", MANAGE,
|
||||
"approveMember", MANAGE,
|
||||
"rejectMember", MANAGE,
|
||||
"removeMember", MANAGE);
|
||||
|
||||
/** 运营读取 → service_program.view */
|
||||
private static final Map<String, String> 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 + " 是用户端端点,不应带运营权限注解");
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -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 集成测试。
|
||||
*
|
||||
* <p>本模块此前**没有任何测试**(`backend/src/test/.../service_program` 目录不存在),
|
||||
* 而 `service_program_members` 表在测试库还缺 `UNIQUE(program_id, user_id)` 与
|
||||
* `chk_program_member_status` —— 属"零覆盖 + 约束漂移盲区"叠加。本轮查出并修复:
|
||||
* <ul>
|
||||
* <li><b>「审批」是死路</b>:运营端名单只返回 approved,pending 申请永不出现。</li>
|
||||
* <li><b>草稿项目泄露</b>:公开列表/详情不限定状态。</li>
|
||||
* <li><b>成员数永远 0</b>:DTO 无 memberCount,统计无效。</li>
|
||||
* <li><b>「退出」未实现</b>:生产 CHECK 早已允许 withdrawn,无任何代码写入。</li>
|
||||
* <li><b>重复申请撞唯一键</b>:被拒/退出后再次申请 INSERT 撞 UNIQUE → 500。</li>
|
||||
* <li><b>status 无校验 / 时间解析 500</b>。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> programBody(String name) {
|
||||
Map<String, Object> 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<String> 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<ServiceProgramMemberDTO> 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<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getProgramId, program.getId())
|
||||
.eq(ServiceProgramMember::getUserId, USER_B));
|
||||
programService.approveMember(b.getId(), OPERATOR);
|
||||
|
||||
Map<String, Object> 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<String, Object> body = new HashMap<>();
|
||||
body.put("status", "published");
|
||||
assertValidationFailure(() -> programService.updateProgram(program.getId(), body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidInstantRejectedAsBadRequest() {
|
||||
Map<String, Object> body = programBody("时间格式错误");
|
||||
body.put("startAt", "2026-09-20 10:00");
|
||||
// 此前 Instant.parse 直接抛 DateTimeParseException → 兜底 handler 500
|
||||
assertValidationFailure(() -> programService.createProgram(body, OPERATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startAfterEndRejected() {
|
||||
Map<String, Object> 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<String, Object> body = programBody("占位");
|
||||
body.put("name", " ");
|
||||
assertValidationFailure(() -> programService.createProgram(body, OPERATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownProgramReturnsNotFound() {
|
||||
Map<String, Object> 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 未生效:测试库缺少生产约束(约束漂移盲区)");
|
||||
}
|
||||
}
|
||||
@@ -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 公益活动(首页近期活动预览)
|
||||
|
||||
Reference in New Issue
Block a user