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:
WorkBuddy
2026-09-17 22:21:31 +08:00
parent 3f60611a74
commit 72b4e9b846
70 changed files with 5847 additions and 308 deletions
@@ -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);
}
}
@@ -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})
@@ -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(
@@ -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;
}
@@ -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()));
}
}
@@ -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 -> "公益婚恋咨询";
};
}
}
@@ -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(
@@ -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));
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
@@ -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());
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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;
}
@@ -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();
}
@@ -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";