feat: 帖子生命周期——草稿/编辑/发布/软删/详情/我的列表(ADR-019,T3-04)
CI / backend-test (push) Successful in 6m33s

- patbond-community 帖子域五端点:POST/GET/PATCH/DELETE /api/v1/posts(/{postId}) + GET /api/v1/me/posts
- 创建型幂等按 ADR-019:Idempotency-Key 必带(1~128)落 uq_posts_author_idempotency,
  规范化 request_hash 比对——同键同 hash 返回首帖、异 hash 40905、键按作者隔离
- 权限/错误语义定型(T3-10 冻结输入):403/40301 仅发给可见者,一切不可见合并
  404/40403 防枚举(hidden/archived 对作者同样 404);version 乐观锁 40902
- 发布 = PATCH 状态迁移 draft→published(publishedAt 恰写一次,重复发布幂等 no-op)
- 软删 deleted_at 为唯一判定基准,published 行归档为 archived 满足 ck_posts_publish_state
- post_media:仅本人 ready asset(同库只读 media.assets,ADR-017 先例;40405/42203),
  position 全给或全不给、isCover 至多一、封面缺省落 position 0;PATCH media 整组替换
- 读取侧图片 URL 由 community 本地预签名 GET(与 user 共用 PATBOND_MINIO_* 配置,
  compose 已注入;未配置降级 url=null)
- ErrorCode 增 40301/40403/40905/42203;异常处理补 MissingRequestHeaderException→40000
- 集成测试 +25(六类路径/幂等专项/草稿可见性矩阵/并发 PATCH 真竞争),全套 251 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 09:54:23 +08:00
parent 263cd88451
commit 101ac0fbbc
30 changed files with 2380 additions and 8 deletions
+8
View File
@@ -44,6 +44,14 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Read-side media URL signing only (presigned GET is a local SigV4
computation): this service never talks to the object store, the
media write flow stays in patbond-user (ADR-016/017). Version
managed by the root pom's awssdk bom. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth/pet. -->
<dependency>
@@ -0,0 +1,44 @@
package com.patbond.patbond.community.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Cross-schema visibility probe for posts.pet_id references. Semantics
* follow patbond-pet's PetAccessService anti-enumeration rule: a pet the
* caller has no pet_owners row for is indistinguishable from a nonexistent
* one — both answer 404/40401. Any role (owner/caregiver/viewer) may
* reference a visible pet from a post; referencing needs no write power
* over the pet itself.
*/
@Service
public class PetVisibilityGateway {
private final JdbcClient jdbcClient;
public PetVisibilityGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** @throws BusinessException 40401 when the pet is invisible to the caller */
public void requireVisible(UUID userId, UUID petId) {
boolean visible = jdbcClient.sql("""
SELECT 1
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
WHERE p.id = :petId AND p.status <> 'deleted'
""")
.param("userId", userId)
.param("petId", petId)
.query(Integer.class)
.optional()
.isPresent();
if (!visible) {
throw new BusinessException(ErrorCode.PET_NOT_FOUND);
}
}
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.config;
import com.patbond.patbond.community.media.CommunityMediaProperties;
import com.patbond.patbond.community.media.MediaUrlSigner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Read-side media wiring: a presigned-GET signer over the same MinIO
* configuration patbond-user uses (ADR-016). Bean destruction closes the
* underlying presigner.
*/
@Configuration
@EnableConfigurationProperties(CommunityMediaProperties.class)
public class MediaConfig {
@Bean(destroyMethod = "close")
public MediaUrlSigner mediaUrlSigner(CommunityMediaProperties properties) {
return new MediaUrlSigner(properties);
}
}
@@ -0,0 +1,89 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostResponse;
import com.patbond.patbond.community.dto.UpdatePostRequest;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.PostService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Post lifecycle endpoints (T3-04). Idempotency-Key is MANDATORY on create
* (ADR-019 — deliberately different from the pets domain's optional key;
* a missing header answers 400/40000). Publishing is a PATCH state
* transition, not a separate endpoint. All permission and error semantics
* live in PostService.
*/
@RestController
@Validated
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@PostMapping("/api/v1/posts")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<PostResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreatePostRequest request) {
return ApiResponse.success(postService.create(userId, idempotencyKey, request));
}
@GetMapping("/api/v1/posts/{postId}")
public ApiResponse<PostResponse> get(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(postService.get(userId, postId));
}
@PatchMapping("/api/v1/posts/{postId}")
public ApiResponse<PostResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId,
@Valid @RequestBody UpdatePostRequest request) {
return ApiResponse.success(postService.update(userId, postId, request));
}
@DeleteMapping("/api/v1/posts/{postId}")
public ApiResponse<Void> delete(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
postService.delete(userId, postId);
return ApiResponse.success(null);
}
@GetMapping("/api/v1/me/posts")
public ApiResponse<CursorPage<PostResponse>> listMine(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
}
}
@@ -0,0 +1,86 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/**
* POST /api/v1/posts. Field widths mirror the ck_posts_* constraints;
* category and status enums are the write-side whitelists (ai_creation is an
* M4 read-side reservation and hidden/archived are operational states with
* no open endpoint, D3-7).
*/
public class CreatePostRequest {
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
private String title;
@NotBlank(message = "content 不能为空")
@Size(max = 10000, message = "content 最长 10000 字符")
private String content;
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
private String category;
@Pattern(regexp = "draft|published", message = "status 仅支持 draft/published")
private String status;
/** Optional pet reference; must be a pet visible to the caller (40401). */
private UUID petId;
@Size(max = 9, message = "media 最多 9 张图")
@Valid
private List<PostMediaAttachRequest> media;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public UUID getPetId() {
return petId;
}
public void setPetId(UUID petId) {
this.petId = petId;
}
public List<PostMediaAttachRequest> getMedia() {
return media;
}
public void setMedia(List<PostMediaAttachRequest> media) {
this.media = media;
}
}
@@ -0,0 +1,14 @@
package com.patbond.patbond.community.dto;
import java.util.List;
/**
* Cursor-pagination envelope body — the pagination canon of the whole API
* (openapi v1.2.0 通用约定): {@code nextCursor} is null exactly when
* {@code hasMore} is false.
*/
public record CursorPage<T>(
List<T> items,
String nextCursor,
boolean hasMore) {
}
@@ -0,0 +1,66 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* One attached image in a create/update request. The asset must be owned by
* the caller and {@code status='ready'} (T3-03 联调协议), enforced in
* PostService against a read-only view of media.assets.
*/
public class PostMediaAttachRequest {
@NotNull(message = "assetId 不能为空")
private UUID assetId;
/**
* 0-based position. Either every item carries a position (together
* forming exactly 0..n-1) or none does (array order applies) — a mix is
* a 40000.
*/
@Min(value = 0, message = "position 最小为 0")
@Max(value = 8, message = "position 最大为 8")
private Integer position;
/** At most one true per post (uq_post_media_cover); none → position 0. */
private Boolean isCover;
@Size(max = 300, message = "caption 最长 300 字符")
private String caption;
public UUID getAssetId() {
return assetId;
}
public void setAssetId(UUID assetId) {
this.assetId = assetId;
}
public Integer getPosition() {
return position;
}
public void setPosition(Integer position) {
this.position = position;
}
public Boolean getIsCover() {
return isCover;
}
public void setIsCover(Boolean isCover) {
this.isCover = isCover;
}
public String getCaption() {
return caption;
}
public void setCaption(String caption) {
this.caption = caption;
}
}
@@ -0,0 +1,18 @@
package com.patbond.patbond.community.dto;
import java.util.UUID;
/**
* One attached image in a post response. {@code url} is a presigned GET URL
* signed per response (T3-03: the bucket stays private, clients never
* persist it); null when object storage is unconfigured in this service.
*/
public record PostMediaItemResponse(
UUID assetId,
int position,
boolean isCover,
String url,
Integer widthPx,
Integer heightPx,
String caption) {
}
@@ -0,0 +1,34 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
/**
* Full post shape (detail / my-posts list / write responses). Deviation from
* the contract draft, recorded for the T3-10 freeze: the draft's
* {@code author: AuthorSummary} is placeheld by {@code authorId} until T3-05
* lands the public-profile aggregation (工单口径:author 字段可先占位
* authorId). Trimmed fields (region/generationJob/topics …) do not appear at
* all (ADR-018 + ADR-010 precedent).
*/
public record PostResponse(
UUID id,
UUID authorId,
UUID petId,
String category,
String title,
String content,
String status,
String visibility,
List<PostMediaItemResponse> media,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime publishedAt,
int version) {
}
@@ -0,0 +1,100 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/**
* PATCH /api/v1/posts/{postId}. Partial update: an absent (or null) field is
* left unchanged — no clearing back to null (M2 惯例). {@code version} is
* mandatory, it is the optimistic lock. {@code status} accepts only
* "published": draft→published is the single open state transition (发布即
* 状态迁移, no separate /publish endpoint); publishing an already-published
* post is a no-op. {@code media}, when present, replaces the whole set
* (整组替换).
*/
public class UpdatePostRequest {
@NotNull(message = "version 不能为空")
@PositiveOrZero(message = "version 必须为非负整数")
private Integer version;
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
private String title;
@Size(min = 1, max = 10000, message = "content 长度须在 1~10000 字符")
private String content;
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
private String category;
private UUID petId;
@Pattern(regexp = "published", message = "status 仅支持 published(唯一开放的状态迁移)")
private String status;
@Size(max = 9, message = "media 最多 9 张图")
@Valid
private List<PostMediaAttachRequest> media;
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public UUID getPetId() {
return petId;
}
public void setPetId(UUID petId) {
this.petId = petId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public List<PostMediaAttachRequest> getMedia() {
return media;
}
public void setMedia(List<PostMediaAttachRequest> media) {
this.media = media;
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.community.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (the write
* side — upload flow, whitelists — lives in patbond-user's MediaProperties).
* This service only signs GET URLs, a purely local SigV4 computation, so no
* bucket/HEAD client is needed. Values reuse the same PATBOND_MINIO_* /
* PATBOND_MEDIA_* environment variables as patbond-user, keeping one set of
* knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class CommunityMediaProperties {
/**
* Endpoint presigned GET URLs are issued against — the address CLIENTS
* can reach. Empty means media is unconfigured for this service:
* responses carry {@code url: null} (same degradation precedent as the
* missing JWT public key).
*/
private String publicEndpoint = "";
/** S3 access key; injected via environment, never committed (ADR-021). */
private String accessKey = "";
/** S3 secret key; injected via environment, never committed (ADR-021). */
private String secretKey = "";
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
private String region = "us-east-1";
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
private Duration downloadTtl = Duration.ofHours(1);
public String getPublicEndpoint() {
return publicEndpoint;
}
public void setPublicEndpoint(String publicEndpoint) {
this.publicEndpoint = publicEndpoint;
}
public String getAccessKey() {
return accessKey;
}
// setter 形参名取 valuecheck-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
public void setAccessKey(String value) {
this.accessKey = value;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String value) {
this.secretKey = value;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public Duration getDownloadTtl() {
return downloadTtl;
}
public void setDownloadTtl(Duration downloadTtl) {
this.downloadTtl = downloadTtl;
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.community.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Read-only cross-schema access to media.assets — the community side of the
* T3-03 联调协议 (business references accept only assets owned by the caller
* with status='ready'). Same-database read was chosen over an internal HTTP
* call to patbond-user (ADR-017 precedent: author data is likewise a
* cross-schema read while the schemas share one database; splitting the
* database later moves both to internal APIs together). This class never
* writes media.assets — the media state machine belongs to patbond-user.
*/
@Repository
public class MediaAssetGateway {
private final JdbcClient jdbcClient;
public MediaAssetGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public Map<UUID, MediaAssetRef> findByIds(Collection<UUID> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return jdbcClient.sql("""
SELECT id, owner_user_id, status, bucket, object_key, width_px, height_px
FROM media.assets
WHERE id IN (:ids)
""")
.param("ids", List.copyOf(ids))
.query(MediaAssetGateway::mapRef)
.list()
.stream()
.collect(Collectors.toMap(MediaAssetRef::id, Function.identity()));
}
private static MediaAssetRef mapRef(ResultSet rs, int rowNum) throws SQLException {
return new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("status"),
rs.getString("bucket"),
rs.getString("object_key"),
rs.getObject("width_px", Integer.class),
rs.getObject("height_px", Integer.class));
}
}
@@ -0,0 +1,17 @@
package com.patbond.patbond.community.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row — exactly the columns the post
* domain needs for attach validation and response URL signing.
*/
public record MediaAssetRef(
UUID id,
UUID ownerUserId,
String status,
String bucket,
String objectKey,
Integer widthPx,
Integer heightPx) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.community.media;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
/**
* Signs presigned GET URLs for media objects referenced by posts (T3-03
* 定型:private bucket + presigned GET, TTL configurable, signed fresh on
* every response — clients never persist the URL). Presigning is a local
* SigV4 computation against the public endpoint; this service never talks
* to the object store itself. Path-style addressing is forced because MinIO
* has no wildcard DNS for virtual-host-style buckets (same as
* patbond-user's S3ObjectStorage). When unconfigured, {@link #signGet}
* returns null and post responses degrade to {@code url: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final CommunityMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(CommunityMediaProperties properties) {
this.properties = properties;
if (properties.getPublicEndpoint().isBlank()) {
this.presigner = null;
return;
}
this.presigner = S3Presigner.builder()
.endpointOverride(URI.create(properties.getPublicEndpoint()))
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** @return a presigned GET URL, or null when storage is unconfigured */
public String signGet(String bucket, String objectKey) {
if (presigner == null || bucket == null || objectKey == null) {
return null;
}
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(properties.getDownloadTtl())
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
.build())
.url()
.toString();
}
@Override
public void close() {
if (presigner != null) {
presigner.close();
}
}
}
@@ -0,0 +1,324 @@
package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.support.PostCursor;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* community.posts / community.post_media access. Visibility and authorship
* decisions live in PostService — every query here is still explicitly
* scoped (viewer-dependent flags are parameters, never session state).
*/
@Repository
public class PostRepository {
/**
* The full post projection: row columns plus the two viewer-relative
* flags, each a primary-key probe into its relation table (post_likes /
* post_bookmarks composite PKs), so no N+1 and no separate round trip.
*/
private static final String SELECT_POST = """
SELECT p.id, p.author_user_id, p.pet_id, p.category, p.title, p.content,
p.status, p.visibility, p.like_count, p.comment_count, p.bookmark_count,
p.created_at, p.updated_at, p.published_at, p.deleted_at, p.version, p.request_hash,
EXISTS (SELECT 1 FROM community.post_likes pl
WHERE pl.post_id = p.id AND pl.user_id = :viewerId) AS liked_by_me,
EXISTS (SELECT 1 FROM community.post_bookmarks pb
WHERE pb.post_id = p.id AND pb.user_id = :viewerId) AS bookmarked_by_me
FROM community.posts p
""";
private final JdbcClient jdbcClient;
public PostRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one post; {@code ON CONFLICT ON CONSTRAINT
* uq_posts_author_idempotency DO NOTHING} makes a keyed replay a no-op —
* the caller then loads the first-write row by (author, key) and settles
* the retry-vs-mismatch question on request_hash (ADR-019).
*
* @return rows inserted — 0 means this author already used the key
*/
public int insertPost(UUID id, UUID authorUserId, UUID petId, String category, String title,
String content, String status, OffsetDateTime publishedAt,
String idempotencyKey, byte[] requestHash) {
return jdbcClient.sql("""
INSERT INTO community.posts
(id, author_user_id, pet_id, category, title, content, status,
published_at, idempotency_key, request_hash)
VALUES (:id, :authorUserId, :petId, :category, :title, :content, :status,
:publishedAt, :idempotencyKey, :requestHash)
ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING
""")
.param("id", id)
.param("authorUserId", authorUserId)
.param("petId", petId)
.param("category", category)
.param("title", title)
.param("content", content)
.param("status", status)
.param("publishedAt", publishedAt)
.param("idempotencyKey", idempotencyKey)
.param("requestHash", requestHash)
.update();
}
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
public Optional<PostRow> findByAuthorAndIdempotencyKey(UUID authorUserId, String idempotencyKey,
UUID viewerId) {
return jdbcClient.sql(SELECT_POST
+ " WHERE p.author_user_id = :authorUserId AND p.idempotency_key = :idempotencyKey")
.param("authorUserId", authorUserId)
.param("idempotencyKey", idempotencyKey)
.param("viewerId", viewerId)
.query(PostRepository::mapPost)
.optional();
}
/** Any live (not soft-deleted) row by id; visibility is the service's call. */
public Optional<PostRow> findLiveById(UUID id, UUID viewerId) {
return jdbcClient.sql(SELECT_POST + " WHERE p.id = :id AND p.deleted_at IS NULL")
.param("id", id)
.param("viewerId", viewerId)
.query(PostRepository::mapPost)
.optional();
}
/**
* Locks the live row for a write (PATCH/DELETE): concurrent writers
* serialize here, so the later one sees the earlier one's version bump
* and fails its version condition deterministically.
*/
public Optional<LockedPost> lockLiveById(UUID id) {
return jdbcClient.sql("""
SELECT id, author_user_id, pet_id, category, title, content, status,
published_at, version
FROM community.posts
WHERE id = :id AND deleted_at IS NULL
FOR UPDATE
""")
.param("id", id)
.query((rs, rowNum) -> new LockedPost(
rs.getObject("id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("category"),
rs.getString("title"),
rs.getString("content"),
rs.getString("status"),
rs.getObject("published_at", OffsetDateTime.class),
rs.getInt("version")))
.optional();
}
/**
* The optimistic-locked merge write: one conditional UPDATE, version
* bumped only when the expected version still stands.
*
* @return rows updated — 0 means the version went stale
*/
public int updatePost(UUID id, int expectedVersion, UUID petId, String category, String title,
String content, String status, OffsetDateTime publishedAt) {
return jdbcClient.sql("""
UPDATE community.posts
SET pet_id = :petId, category = :category, title = :title,
content = :content, status = :status, published_at = :publishedAt,
version = version + 1
WHERE id = :id AND deleted_at IS NULL AND version = :expectedVersion
""")
.param("id", id)
.param("expectedVersion", expectedVersion)
.param("petId", petId)
.param("category", category)
.param("title", title)
.param("content", content)
.param("status", status)
.param("publishedAt", publishedAt)
.update();
}
/**
* Soft delete (deleted_at is THE deletion marker everywhere). A deleted
* published post must also leave status='published' to satisfy
* ck_posts_publish_state, so it is parked as 'archived'; drafts keep
* their status. Once deleted the post answers 404 on every read path,
* so the parked status is internal bookkeeping only (D3-7 定型).
*/
public int softDelete(UUID id) {
return jdbcClient.sql("""
UPDATE community.posts
SET deleted_at = now(),
status = CASE WHEN status = 'published' THEN 'archived' ELSE status END,
version = version + 1
WHERE id = :id AND deleted_at IS NULL
""")
.param("id", id)
.update();
}
/**
* One page of the author's own posts in (created_at DESC, id DESC) — the
* exact key of ix_posts_author_created. Soft-deleted rows never appear;
* hidden/archived are excluded (the contract exposes draft/published
* only). The caller asks for limit+1 rows to learn whether more exist.
*/
public List<PostRow> pageByAuthor(UUID authorUserId, String statusFilter, PostCursor after,
int limitPlusOne) {
String sql = SELECT_POST + """
WHERE p.author_user_id = :authorUserId AND p.deleted_at IS NULL
AND p.status IN ('draft', 'published')
""";
if (statusFilter != null) {
sql += " AND p.status = :statusFilter";
}
if (after != null) {
sql += " AND (p.created_at, p.id) < (:cursorCreatedAt, :cursorId)";
}
sql += " ORDER BY p.created_at DESC, p.id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("authorUserId", authorUserId)
.param("viewerId", authorUserId)
.param("limit", limitPlusOne);
if (statusFilter != null) {
spec = spec.param("statusFilter", statusFilter);
}
if (after != null) {
spec = spec.param("cursorCreatedAt", after.createdAt())
.param("cursorId", after.id());
}
return spec.query(PostRepository::mapPost).list();
}
public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
jdbcClient.sql("""
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
VALUES (:postId, :position, :assetId, :isCover, :caption)
""")
.param("postId", postId)
.param("position", position)
.param("assetId", assetId)
.param("isCover", isCover)
.param("caption", caption)
.update();
}
/** Whole-set replacement (PATCH media 整组替换): clear, then re-insert. */
public void deleteMedia(UUID postId) {
jdbcClient.sql("DELETE FROM community.post_media WHERE post_id = :postId")
.param("postId", postId)
.update();
}
/**
* Media of many posts in one query (position order within each post),
* joined with media.assets for the response-side url/dimension fields.
*/
public List<PostMediaRow> findMediaByPostIds(Collection<UUID> postIds) {
if (postIds.isEmpty()) {
return List.of();
}
return jdbcClient.sql("""
SELECT pm.post_id, pm.position, pm.asset_id, pm.is_cover, pm.caption,
a.bucket, a.object_key, a.width_px, a.height_px
FROM community.post_media pm
JOIN media.assets a ON a.id = pm.asset_id
WHERE pm.post_id IN (:postIds)
ORDER BY pm.post_id, pm.position
""")
.param("postIds", List.copyOf(postIds))
.query((rs, rowNum) -> new PostMediaRow(
rs.getObject("post_id", UUID.class),
rs.getInt("position"),
rs.getObject("asset_id", UUID.class),
rs.getBoolean("is_cover"),
rs.getString("caption"),
rs.getString("bucket"),
rs.getString("object_key"),
rs.getObject("width_px", Integer.class),
rs.getObject("height_px", Integer.class)))
.list();
}
private static PostRow mapPost(ResultSet rs, int rowNum) throws SQLException {
return new PostRow(
rs.getObject("id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("category"),
rs.getString("title"),
rs.getString("content"),
rs.getString("status"),
rs.getString("visibility"),
rs.getLong("like_count"),
rs.getLong("comment_count"),
rs.getLong("bookmark_count"),
rs.getBoolean("liked_by_me"),
rs.getBoolean("bookmarked_by_me"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getObject("published_at", OffsetDateTime.class),
rs.getObject("deleted_at", OffsetDateTime.class),
rs.getInt("version"),
rs.getBytes("request_hash"));
}
/** Full projection of one post as seen by a given viewer. */
public record PostRow(
UUID id,
UUID authorUserId,
UUID petId,
String category,
String title,
String content,
String status,
String visibility,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime publishedAt,
OffsetDateTime deletedAt,
int version,
byte[] requestHash) {
}
/** Row image under FOR UPDATE, the merge base of a PATCH. */
public record LockedPost(
UUID id,
UUID authorUserId,
UUID petId,
String category,
String title,
String content,
String status,
OffsetDateTime publishedAt,
int version) {
}
/** One post_media row joined with its asset's storage location. */
public record PostMediaRow(
UUID postId,
int position,
UUID assetId,
boolean isCover,
String caption,
String bucket,
String objectKey,
Integer widthPx,
Integer heightPx) {
}
}
@@ -0,0 +1,413 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway;
import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
import com.patbond.patbond.community.dto.PostMediaItemResponse;
import com.patbond.patbond.community.dto.PostResponse;
import com.patbond.patbond.community.dto.UpdatePostRequest;
import com.patbond.patbond.community.media.MediaAssetGateway;
import com.patbond.patbond.community.media.MediaAssetRef;
import com.patbond.patbond.community.media.MediaUrlSigner;
import com.patbond.patbond.community.repository.PostRepository;
import com.patbond.patbond.community.repository.PostRepository.LockedPost;
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
import com.patbond.patbond.community.repository.PostRepository.PostRow;
import com.patbond.patbond.community.support.PostCursor;
import com.patbond.patbond.community.support.RequestHashes;
import com.patbond.patbond.community.support.UuidV7;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Post lifecycle use-cases (T3-04): draft/edit/publish/soft-delete/detail/
* my-posts. The permission and error semantics implemented here are the
* T3-10 freeze input:
*
* <ul>
* <li><b>Visibility</b> — published is visible to every authenticated
* user; draft only to its author; hidden/archived (operational
* states, D3-7) and soft-deleted answer 404/40403 to EVERYONE, the
* author included. Every invisible case is byte-identical
* (anti-enumeration).</li>
* <li><b>403 vs 404</b> — 403/40301 goes only to callers the post is
* VISIBLE to (non-author PATCH/DELETE of a published post); anything
* invisible is 404/40403, never 403.</li>
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory;
* same key + same normalized payload returns the first write (201
* again), same key + different payload answers 409/40905, keys are
* scoped per author (two users may reuse a key).</li>
* <li><b>Publish</b> — a PATCH carrying {@code status: published}; the
* only open transition is draft→published (publishedAt written once);
* re-publishing a published post is a no-op. published→draft does not
* exist (the request enum rejects it as 40000).</li>
* </ul>
*/
@Service
public class PostService {
private static final int MAX_MEDIA = 9;
private final PostRepository postRepository;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
private final PetVisibilityGateway petVisibilityGateway;
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway) {
this.postRepository = postRepository;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
this.petVisibilityGateway = petVisibilityGateway;
}
@Transactional
public PostResponse create(UUID userId, String idempotencyKey, CreatePostRequest request) {
String key = normalizeIdempotencyKey(idempotencyKey);
String title = requireTitleOrNull(request.getTitle());
String content = requireContent(request.getContent());
String category = request.getCategory() == null ? "general" : request.getCategory();
String status = request.getStatus() == null ? "draft" : request.getStatus();
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
if (request.getPetId() != null) {
petVisibilityGateway.requireVisible(userId, request.getPetId());
}
validateAssets(userId, media);
byte[] requestHash = RequestHashes.sha256(
canonicalize(category, status, title, content, request.getPetId(), media));
UUID id = UuidV7.generate();
OffsetDateTime publishedAt = "published".equals(status) ? OffsetDateTime.now() : null;
int inserted = postRepository.insertPost(id, userId, request.getPetId(), category, title,
content, status, publishedAt, key, requestHash);
if (inserted == 0) {
// The author used this key before (or a concurrent retry won the
// race): settle retry-vs-mismatch on the stored request_hash.
PostRow first = postRepository.findByAuthorAndIdempotencyKey(userId, key, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (!Arrays.equals(first.requestHash(), requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
}
if (first.deletedAt() != null) {
// The first write was deleted meanwhile — the resource the
// retry asks about is gone, same anti-enumeration 404.
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
return assembleOne(first);
}
for (NormalizedMedia item : media) {
postRepository.insertMedia(id, item.position(), item.assetId(), item.isCover(),
item.caption());
}
PostRow row = postRepository.findLiveById(id, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assembleOne(row);
}
@Transactional(readOnly = true)
public PostResponse get(UUID userId, UUID postId) {
PostRow row = postRepository.findLiveById(postId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
if (!visibleTo(row.status(), row.authorUserId(), userId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
return assembleOne(row);
}
@Transactional
public PostResponse update(UUID userId, UUID postId, UpdatePostRequest request) {
LockedPost current = requireAuthorEditable(userId, postId);
if (request.getVersion() != current.version()) {
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
String title = request.getTitle() != null
? requireTitleOrNull(request.getTitle())
: current.title();
String content = request.getContent() != null
? requireContent(request.getContent())
: current.content();
String category = request.getCategory() != null ? request.getCategory() : current.category();
UUID petId = current.petId();
if (request.getPetId() != null) {
petVisibilityGateway.requireVisible(userId, request.getPetId());
petId = request.getPetId();
}
String status = current.status();
OffsetDateTime publishedAt = current.publishedAt();
if ("published".equals(request.getStatus()) && "draft".equals(current.status())) {
// The single open transition: draft→published, publishedAt
// written exactly once (ck_posts_publish_state). Publishing an
// already-published post falls through as a no-op.
status = "published";
publishedAt = OffsetDateTime.now();
}
if (request.getMedia() != null) {
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
validateAssets(userId, media);
postRepository.deleteMedia(postId);
for (NormalizedMedia item : media) {
postRepository.insertMedia(postId, item.position(), item.assetId(), item.isCover(),
item.caption());
}
}
int updated = postRepository.updatePost(postId, request.getVersion(), petId, category,
title, content, status, publishedAt);
if (updated == 0) {
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
PostRow row = postRepository.findLiveById(postId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assembleOne(row);
}
@Transactional
public void delete(UUID userId, UUID postId) {
requireAuthorEditable(userId, postId);
postRepository.softDelete(postId);
}
@Transactional(readOnly = true)
public CursorPage<PostResponse> listMine(UUID userId, String status, int limit, String cursor) {
if (status != null && !status.equals("draft") && !status.equals("published")) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "status 仅支持 draft/published");
}
PostCursor after = cursor == null ? null : PostCursor.decode(cursor);
List<PostRow> rows = postRepository.pageByAuthor(userId, status, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new PostCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assemble(page), nextCursor, hasMore);
}
/**
* The shared write gate of PATCH/DELETE: locks the live row, then walks
* the 403/404 boundary — invisible (absent, deleted, hidden/archived,
* someone else's draft) → 40403; visible but not the author's
* (published, someone else's) → 40301.
*/
private LockedPost requireAuthorEditable(UUID userId, UUID postId) {
LockedPost current = postRepository.lockLiveById(postId)
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
boolean visible = visibleTo(current.status(), current.authorUserId(), userId);
if (!visible) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
if (!current.authorUserId().equals(userId)) {
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
}
return current;
}
/**
* The visibility matrix (T3-10 freeze input): published → everyone;
* draft → author only; hidden/archived → no one, the author included
* (no operational state leaks through the M3 contract, whose status
* enum stays [draft, published]).
*/
private static boolean visibleTo(String status, UUID authorUserId, UUID viewerId) {
return switch (status) {
case "published" -> true;
case "draft" -> authorUserId.equals(viewerId);
default -> false;
};
}
// ---------- create-side normalization ----------
private static String normalizeIdempotencyKey(String idempotencyKey) {
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
if (key.isEmpty() || key.length() > 128) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"Idempotency-Key 必带且长度须在 1~128 字符");
}
return key;
}
/**
* A provided title must survive trimming (ck_posts_title width) — a
* whitespace-only title is a 40000, NOT a clear-to-null: PATCH does not
* support clearing optional fields back to null (M2 惯例), and create
* stays symmetric.
*/
private static String requireTitleOrNull(String title) {
if (title == null) {
return null;
}
String trimmed = title.trim();
if (trimmed.isEmpty() || trimmed.length() > 120) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "title 长度须在 1~120 字符");
}
return trimmed;
}
private static String requireContent(String content) {
String trimmed = content == null ? "" : content.trim();
if (trimmed.isEmpty() || trimmed.length() > 10000) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~10000 字符");
}
return trimmed;
}
/**
* Resolves positions and the cover flag: either every item names a
* position (together exactly 0..n-1) or none does (array order); at
* most one isCover=true (uq_post_media_cover), none → position 0 gets
* the flag (草案预设:全 false 服务端取 position 0).
*/
private static List<NormalizedMedia> normalizeMedia(List<PostMediaAttachRequest> requested) {
if (requested == null || requested.isEmpty()) {
return List.of();
}
long withPosition = requested.stream().filter(m -> m.getPosition() != null).count();
if (withPosition != 0 && withPosition != requested.size()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"media position 须全部提供或全部省略");
}
long covers = requested.stream().filter(m -> Boolean.TRUE.equals(m.getIsCover())).count();
if (covers > 1) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "isCover 至多一个");
}
List<NormalizedMedia> items = new ArrayList<>(requested.size());
Set<Integer> seenPositions = new HashSet<>();
Set<UUID> seenAssets = new HashSet<>();
for (int i = 0; i < requested.size(); i++) {
PostMediaAttachRequest m = requested.get(i);
int position = m.getPosition() != null ? m.getPosition() : i;
if (!seenPositions.add(position) || position >= requested.size()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"media position 须为 0 起连续且不重复");
}
if (!seenAssets.add(m.getAssetId())) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 中 assetId 重复");
}
items.add(new NormalizedMedia(m.getAssetId(), position,
Boolean.TRUE.equals(m.getIsCover()), trimOrNull(m.getCaption())));
}
items.sort((a, b) -> Integer.compare(a.position(), b.position()));
if (covers == 0) {
items.set(0, items.get(0).asCover());
}
if (items.size() > MAX_MEDIA) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 最多 9 张图");
}
return items;
}
/**
* The T3-03 联调协议 on the referencing side: an asset that does not
* exist, is not the caller's or is deleted answers 404/40405 (one merged
* anti-enumeration case); the caller's own asset in uploading/failed
* answers 422/42203.
*/
private void validateAssets(UUID userId, List<NormalizedMedia> media) {
if (media.isEmpty()) {
return;
}
Map<UUID, MediaAssetRef> assets = mediaAssetGateway.findByIds(
media.stream().map(NormalizedMedia::assetId).collect(Collectors.toSet()));
for (NormalizedMedia item : media) {
MediaAssetRef ref = assets.get(item.assetId());
if (ref == null || !userId.equals(ref.ownerUserId()) || "deleted".equals(ref.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!"ready".equals(ref.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
}
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
private static String canonicalize(String category, String status, String title, String content,
UUID petId, List<NormalizedMedia> media) {
StringBuilder sb = new StringBuilder("post.v1\n")
.append(category).append('\n')
.append(status).append('\n')
.append(title == null ? "" : title).append('\n')
.append(content).append('\n')
.append(petId == null ? "" : petId).append('\n');
for (NormalizedMedia item : media) {
sb.append(item.assetId()).append(':').append(item.position()).append(':')
.append(item.isCover()).append(':')
.append(item.caption() == null ? "" : item.caption()).append('\n');
}
return sb.toString();
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
// ---------- response assembly ----------
private PostResponse assembleOne(PostRow row) {
return assemble(List.of(row)).get(0);
}
private List<PostResponse> assemble(List<PostRow> rows) {
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
.stream()
.collect(Collectors.groupingBy(PostMediaRow::postId));
return rows.stream().map(row -> new PostResponse(
row.id(),
row.authorUserId(),
row.petId(),
row.category(),
row.title(),
row.content(),
row.status(),
row.visibility(),
mediaByPost.getOrDefault(row.id(), List.of()).stream()
.map(m -> new PostMediaItemResponse(
m.assetId(),
m.position(),
m.isCover(),
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
m.widthPx(),
m.heightPx(),
m.caption()))
.toList(),
row.likeCount(),
row.commentCount(),
row.bookmarkCount(),
row.likedByMe(),
row.bookmarkedByMe(),
row.createdAt(),
row.updatedAt(),
row.publishedAt(),
row.version())).toList();
}
private record NormalizedMedia(UUID assetId, int position, boolean isCover, String caption) {
NormalizedMedia asCover() {
return new NormalizedMedia(assetId, position, true, caption);
}
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor for the my-posts list (created_at DESC, id DESC — the exact
* key of ix_posts_author_created), isomorphic to patbond-pet's EventCursor:
* base64url("epochMicros:id"), next page selects
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by id
* and rows are neither lost nor repeated across page boundaries.
*/
public record PostCursor(OffsetDateTime createdAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
+ createdAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static PostCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID id = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime createdAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new PostCursor(createdAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,30 @@
package com.patbond.patbond.community.support;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* SHA-256 of the canonical form of a create request (ADR-019: creation-type
* writes carry a mandatory Idempotency-Key and the request body's hash is
* stored next to it — a keyed retry with the same payload returns the first
* write, a different payload answers 40905). Hashing the NORMALIZED command
* (trimmed fields, defaults applied, media positions resolved) rather than
* the raw bytes makes the comparison insensitive to JSON formatting while
* still catching every semantic difference. 32 bytes, matching
* ck_posts_idempotency's octet_length(request_hash) = 32.
*/
public final class RequestHashes {
private RequestHashes() {
}
public static byte[] sha256(String canonical) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,30 @@
package com.patbond.patbond.community.support;
import java.security.SecureRandom;
import java.util.UUID;
/**
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
* B-tree page churn low on uuid primary keys; the database DEFAULT
* gen_random_uuid() remains the fallback for rows not inserted through the
* application. Third copy after patbond-user/pet — the services deploy
* independently and patbond-common stays contract-only.
*/
public final class UuidV7 {
private static final SecureRandom RANDOM = new SecureRandom();
private UuidV7() {
}
public static UUID generate() {
long timestampMs = System.currentTimeMillis();
long randA = RANDOM.nextLong() & 0x0FFFL;
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
long msb = (timestampMs << 16) | 0x7000L | randA;
long lsb = 0x8000000000000000L | randB;
return new UUID(msb, lsb);
}
}
@@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
@@ -43,7 +44,8 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
ConstraintViolationException.class, HandlerMethodValidationException.class})
ConstraintViolationException.class, HandlerMethodValidationException.class,
MissingRequestHeaderException.class})
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
}
@@ -20,3 +20,12 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
# 环境变量与 patbond-user 共用同一组(一套部署一套旋钮)。
# public-endpoint 为空时服务照常启动,帖子响应中 media[].url 为 null。
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
@@ -0,0 +1,94 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.TestJwtKeys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* Shared plumbing of the T3-04 post-lifecycle tests: one cached Spring
* context over a disposable postgres:18 with the full V1..V5 chain, JWT
* material minted per run, and media signing configured with fake
* credentials — presigning a GET URL is a local SigV4 computation, so URL
* assertions need no MinIO container.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
public abstract class PostApiTestBase {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected JdbcClient jdbcClient;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
protected UUID newUser() {
return CommunityTestData.insertUser(jdbcClient,
"u" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
}
protected String token(UUID userId) {
return TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId,
Duration.ofMinutes(15));
}
protected MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
UUID userId) {
return builder.header("Authorization", "Bearer " + token(userId))
.contentType(APPLICATION_JSON)
.characterEncoding("UTF-8");
}
protected MockHttpServletRequestBuilder createPostRequest(UUID userId, String idempotencyKey,
String body) {
return authed(post("/api/v1/posts"), userId)
.header("Idempotency-Key", idempotencyKey)
.content(body);
}
/** Creates a post and returns the response `data` node. */
protected JsonNode createPost(UUID userId, String body) throws Exception {
MvcResult result = mockMvc.perform(
createPostRequest(userId, UUID.randomUUID().toString(), body))
.andReturn();
if (result.getResponse().getStatus() != 201) {
throw new AssertionError("createPost failed: " + result.getResponse().getStatus()
+ " " + result.getResponse().getContentAsString());
}
return data(result);
}
protected JsonNode data(MvcResult result) throws Exception {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
}
}
@@ -0,0 +1,101 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ADR-019 幂等专项: creation-type idempotency on the posts table itself —
* mandatory Idempotency-Key falling on uq_posts_author_idempotency with a
* stored request_hash. Same key + same payload replays the first write,
* same key + different payload answers 40905, keys are scoped per author.
*/
class PostIdempotencyIntegrationTest extends PostApiTestBase {
@Test
void sameKeySamePayloadReturnsTheFirstWrite() throws Exception {
UUID author = newUser();
String key = UUID.randomUUID().toString();
String body = "{\"title\": \"幂等\", \"content\": \"同键同体\"}";
JsonNode first = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn());
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn());
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
Long rows = jdbcClient.sql(
"SELECT count(*) FROM community.posts WHERE author_user_id = :author")
.param("author", author)
.query(Long.class)
.single();
assertThat(rows).isEqualTo(1);
}
@Test
void semanticallyIdenticalPayloadStillReplays() throws Exception {
// The hash covers the NORMALIZED command, so whitespace-only
// differences (or a spelled-out default) do not break a retry.
UUID author = newUser();
String key = UUID.randomUUID().toString();
JsonNode first = data(mockMvc.perform(createPostRequest(author, key,
"{\"content\": \"规范化\"}"))
.andExpect(status().isCreated())
.andReturn());
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key,
"{\"content\": \" 规范化 \", \"category\": \"general\", \"status\": \"draft\"}"))
.andExpect(status().isCreated())
.andReturn());
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
}
@Test
void sameKeyDifferentPayloadAnswers40905() throws Exception {
UUID author = newUser();
String key = UUID.randomUUID().toString();
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本甲\"}"))
.andExpect(status().isCreated());
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本乙\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40905));
}
@Test
void keysAreScopedPerAuthor() throws Exception {
UUID alice = newUser();
UUID bob = newUser();
String key = "shared-client-key";
JsonNode alicesPost = data(mockMvc.perform(createPostRequest(alice, key,
"{\"content\": \"撞键\"}"))
.andExpect(status().isCreated())
.andReturn());
JsonNode bobsPost = data(mockMvc.perform(createPostRequest(bob, key,
"{\"content\": \"撞键\"}"))
.andExpect(status().isCreated())
.andReturn());
assertThat(bobsPost.get("id").asText()).isNotEqualTo(alicesPost.get("id").asText());
}
@Test
void retryAfterTheFirstWriteWasDeletedAnswers404() throws Exception {
// Edge frozen for the contract: the retry asks about a resource that
// is gone — same anti-enumeration 404 as any other deleted post.
UUID author = newUser();
String key = UUID.randomUUID().toString();
String body = "{\"content\": \"建了又删\"}";
String id = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isOk());
mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
}
}
@@ -0,0 +1,376 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-04 lifecycle walk on the real database: draft → edit → publish →
* detail → delete, the 403/404 permission boundary, the draft-visibility
* matrix, the optimistic lock (including a true concurrent race) and the
* my-posts cursor list. These assertions ARE the T3-10 freeze input for the
* post domain's permission and error semantics.
*/
class PostLifecycleIntegrationTest extends PostApiTestBase {
@Test
void createDraftReturnsFullShape() throws Exception {
UUID author = newUser();
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"""
{"title": "第一帖", "content": "大家好"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.authorId").value(author.toString()))
.andExpect(jsonPath("$.data.title").value("第一帖"))
.andExpect(jsonPath("$.data.content").value("大家好"))
.andExpect(jsonPath("$.data.category").value("general"))
.andExpect(jsonPath("$.data.status").value("draft"))
.andExpect(jsonPath("$.data.visibility").value("public"))
.andExpect(jsonPath("$.data.media").isEmpty())
.andExpect(jsonPath("$.data.likeCount").value(0))
.andExpect(jsonPath("$.data.commentCount").value(0))
.andExpect(jsonPath("$.data.bookmarkCount").value(0))
.andExpect(jsonPath("$.data.likedByMe").value(false))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false))
.andExpect(jsonPath("$.data.publishedAt").isEmpty())
.andExpect(jsonPath("$.data.version").value(0));
}
@Test
void createPublishedDirectlyWritesPublishedAt() throws Exception {
UUID author = newUser();
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"""
{"content": "直接发布", "status": "published", "category": "help"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.status").value("published"))
.andExpect(jsonPath("$.data.category").value("help"))
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty());
}
@Test
void createWithVisiblePetAttachesIt() throws Exception {
UUID author = newUser();
UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author);
JsonNode created = createPost(author,
"{\"content\": \"我家猫\", \"petId\": \"" + petId + "\"}");
assertThat(created.get("petId").asText()).isEqualTo(petId.toString());
}
@Test
void createWithInvisibleOrMissingPetAnswers40401() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
UUID strangersPet = CommunityTestData.insertPetOwnedBy(jdbcClient, stranger);
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"petId\": \"" + strangersPet + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"petId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
@Test
void createValidationFailuresAnswer40000() throws Exception {
UUID author = newUser();
// content is mandatory
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"title\": \"无正文\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// ai_creation is an M4 read-side reservation, not writable in M3
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"category\": \"ai_creation\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// hidden/archived are not creatable states
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"status\": \"hidden\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// a whitespace-only title is rejected, not silently cleared to null
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"title\": \" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void idempotencyKeyHeaderIsMandatoryAndBounded() throws Exception {
UUID author = newUser();
mockMvc.perform(authed(
org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post("/api/v1/posts"), author)
.content("{\"content\": \"没带幂等键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(createPostRequest(author, " ", "{\"content\": \"空白键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(createPostRequest(author, "k".repeat(129), "{\"content\": \"超长键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void draftVisibilityMatrix() throws Exception {
UUID author = newUser();
UUID other = newUser();
JsonNode draft = createPost(author, "{\"content\": \"草稿\"}");
String draftId = draft.get("id").asText();
JsonNode published = createPost(author,
"{\"content\": \"已发布\", \"status\": \"published\"}");
String publishedId = published.get("id").asText();
// author sees the draft; anyone else gets the anti-enumeration 404
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("draft"));
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), other))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// published is open to any authenticated user
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), other))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("published"));
// hidden (operational state, D3-7) answers 404 to EVERYONE — the
// author included: the M3 contract's status enum stays two-valued
jdbcClient.sql("UPDATE community.posts SET status = 'hidden', published_at = NULL WHERE id = :id")
.param("id", UUID.fromString(publishedId))
.update();
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// nonexistent id — identical 404; malformed id — 40000
mockMvc.perform(authed(get("/api/v1/posts/" + UUID.randomUUID()), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
mockMvc.perform(authed(get("/api/v1/posts/not-a-uuid"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void patchEditsFieldsAndBumpsVersion() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"title\": \"旧标题\", \"content\": \"旧正文\"}")
.get("id").asText();
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"新标题\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title").value("新标题"))
.andExpect(jsonPath("$.data.content").value("旧正文"))
.andExpect(jsonPath("$.data.version").value(1));
// the spent version is stale now — optimistic lock answers 40902
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"迟到的编辑\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
// version is mandatory
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"title\": \"没带 version\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void publishIsThePatchStateTransition() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"content\": \"待发布\"}").get("id").asText();
String publishedAt = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"status\": \"published\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("published"))
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty())
.andReturn()).get("publishedAt").asText();
// re-publishing an already-published post is a no-op (publishedAt
// written exactly once), not an error
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 1, \"status\": \"published\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publishedAt").value(publishedAt))
.andExpect(jsonPath("$.data.version").value(2));
// published→draft does not exist: the request enum rejects it
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 2, \"status\": \"draft\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void nonAuthorWritesWalkThe403404Boundary() throws Exception {
UUID author = newUser();
UUID other = newUser();
String draftId = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String publishedId = createPost(author,
"{\"content\": \"已发布\", \"status\": \"published\"}").get("id").asText();
// visible but not yours → 403/40301
mockMvc.perform(authed(patch("/api/v1/posts/" + publishedId), other)
.content("{\"version\": 0, \"title\": \"篡改\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/posts/" + publishedId), other))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
// invisible (someone else's draft) → 404/40403, never 403
mockMvc.perform(authed(patch("/api/v1/posts/" + draftId), other)
.content("{\"version\": 0, \"title\": \"篡改\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
mockMvc.perform(authed(delete("/api/v1/posts/" + draftId), other))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
}
@Test
void deleteIsSoftAndMergesWithNotFoundAfterwards() throws Exception {
UUID author = newUser();
String id = createPost(author,
"{\"content\": \"要删的\", \"status\": \"published\"}").get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// gone from every read path…
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// …and a repeated delete merges with not-found (anti-enumeration)
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// the row survives as a soft-deleted tombstone; the published row is
// parked as archived so ck_posts_publish_state holds
var row = jdbcClient.sql(
"SELECT status, deleted_at FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(id))
.query((rs, n) -> List.of(rs.getString("status"),
String.valueOf(rs.getObject("deleted_at") != null)))
.single();
assertThat(row).containsExactly("archived", "true");
}
@Test
void concurrentPatchesLetExactlyOneWin() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"content\": \"并发对象\"}").get("id").asText();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
List<Future<Integer>> results = List.of("", "").stream()
.map(tag -> pool.submit(() -> {
start.await();
return mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"" + tag + "\"}"))
.andReturn().getResponse().getStatus();
}))
.toList();
start.countDown();
List<Integer> statuses = List.of(results.get(0).get(30, TimeUnit.SECONDS),
results.get(1).get(30, TimeUnit.SECONDS));
assertThat(statuses).containsExactlyInAnyOrder(200, 409);
} finally {
pool.shutdownNow();
}
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(jsonPath("$.data.version").value(1));
}
@Test
void myPostsListPagesWithCursorAndFilters() throws Exception {
UUID author = newUser();
UUID other = newUser();
String draft1 = createPost(author, "{\"content\": \"\"}").get("id").asText();
String published = createPost(author,
"{\"content\": \"\", \"status\": \"published\"}").get("id").asText();
String draft2 = createPost(author, "{\"content\": \"\"}").get("id").asText();
String deleted = createPost(author, "{\"content\": \"已删\"}").get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
createPost(other, "{\"content\": \"别人的\"}");
// full list: own drafts + published, deleted excluded, newest first
JsonNode page = data(mockMvc.perform(authed(get("/api/v1/me/posts"), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.hasMore").value(false))
.andExpect(jsonPath("$.data.nextCursor").isEmpty())
.andReturn());
assertThat(page.get("items")).hasSize(3);
assertThat(page.get("items").findValues("id").stream().map(JsonNode::asText))
.containsExactly(draft2, published, draft1);
// keyset pagination: limit 2 → cursor → remaining 1, no loss/overlap
JsonNode first = data(mockMvc.perform(authed(get("/api/v1/me/posts?limit=2"), author))
.andExpect(jsonPath("$.data.hasMore").value(true))
.andReturn());
JsonNode second = data(mockMvc.perform(authed(
get("/api/v1/me/posts?limit=2&cursor=" + first.get("nextCursor").asText()),
author))
.andExpect(jsonPath("$.data.hasMore").value(false))
.andReturn());
assertThat(second.get("items")).hasSize(1);
assertThat(second.get("items").get(0).get("id").asText()).isEqualTo(draft1);
// status filter, bad filter, bad cursor, bad limit
mockMvc.perform(authed(get("/api/v1/me/posts?status=draft"), author))
.andExpect(jsonPath("$.data.items.length()").value(2));
mockMvc.perform(authed(get("/api/v1/me/posts?status=hidden"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/posts?cursor=%21%21"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/posts?limit=0"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void viewerRelativeFlagsReflectRelationRows() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String id = createPost(author,
"{\"content\": \"有人点赞\", \"status\": \"published\"}").get("id").asText();
jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id) VALUES (:postId, :userId)
""")
.param("postId", UUID.fromString(id))
.param("userId", fan)
.update();
jdbcClient.sql("UPDATE community.posts SET like_count = 1 WHERE id = :id")
.param("id", UUID.fromString(id))
.update();
mockMvc.perform(authed(get("/api/v1/posts/" + id), fan))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false));
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(jsonPath("$.data.likedByMe").value(false))
.andExpect(jsonPath("$.data.likeCount").value(1));
}
}
@@ -0,0 +1,171 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* post_media attach semantics (T3-03 联调协议 on the referencing side):
* only the caller's own ready assets; position/is_cover normalization
* consistent with uq_post_media_cover; PATCH media replaces the whole set.
*/
class PostMediaAttachIntegrationTest extends PostApiTestBase {
@Test
void attachOrdersItemsAndDefaultsTheCover() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
JsonNode post = createPost(author, """
{"content": "两张图", "media": [
{"assetId": "%s", "caption": " 封面图 "},
{"assetId": "%s"}
]}
""".formatted(assetA, assetB));
JsonNode media = post.get("media");
assertThat(media).hasSize(2);
// array order → positions 0/1; no isCover given → position 0 is cover
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetA.toString());
assertThat(media.get(0).get("position").asInt()).isZero();
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
assertThat(media.get(0).get("caption").asText()).isEqualTo("封面图");
assertThat(media.get(1).get("isCover").asBoolean()).isFalse();
// presigned GET URL, signed against the configured public endpoint
assertThat(media.get(0).get("url").asText())
.startsWith("http://127.0.0.1:9000/patbond-media/post_image/")
.contains("X-Amz-Signature=");
assertThat(media.get(0).get("widthPx").asInt()).isEqualTo(640);
assertThat(media.get(0).get("heightPx").asInt()).isEqualTo(480);
}
@Test
void explicitPositionsAndCoverAreRespected() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
JsonNode post = createPost(author, """
{"content": "指定顺序", "media": [
{"assetId": "%s", "position": 1},
{"assetId": "%s", "position": 0, "isCover": true}
]}
""".formatted(assetA, assetB));
JsonNode media = post.get("media");
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetB.toString());
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
assertThat(media.get(1).get("assetId").asText()).isEqualTo(assetA.toString());
}
@Test
void foreignMissingOrDeletedAssetsMergeInto40405() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
UUID strangersAsset = CommunityTestData.insertReadyAsset(jdbcClient, stranger);
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(strangersAsset)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40405));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(UUID.randomUUID())))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40405));
}
@Test
void ownButNotReadyAssetAnswers42203() throws Exception {
UUID author = newUser();
UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading");
UUID failed = CommunityTestData.insertAsset(jdbcClient, author, "failed");
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(uploading)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(failed)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
}
@Test
void mediaShapeViolationsAnswer40000() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
// duplicate assetId
expectBadRequest(author, """
{"content": "x", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
""".formatted(assetA, assetA));
// two covers (uq_post_media_cover)
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "isCover": true}, {"assetId": "%s", "isCover": true}]}
""".formatted(assetA, assetB));
// positions must be 0-based contiguous
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "position": 0}, {"assetId": "%s", "position": 2}]}
""".formatted(assetA, assetB));
// all-or-none positions
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "position": 0}, {"assetId": "%s"}]}
""".formatted(assetA, assetB));
}
@Test
void patchMediaReplacesTheWholeSet() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetC = CommunityTestData.insertReadyAsset(jdbcClient, author);
String id = createPost(author, """
{"content": "初始两图", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
""".formatted(assetA, assetB)).get("id").asText();
// media present in the PATCH → whole-set replacement (整组替换)
JsonNode replaced = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"media\": [{\"assetId\": \"" + assetC + "\"}]}"))
.andExpect(status().isOk())
.andReturn());
assertThat(replaced.get("media")).hasSize(1);
assertThat(replaced.get("media").get(0).get("assetId").asText())
.isEqualTo(assetC.toString());
assertThat(replaced.get("media").get(0).get("isCover").asBoolean()).isTrue();
// media absent → untouched
JsonNode untouched = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 1, \"title\": \"只改标题\"}"))
.andExpect(status().isOk())
.andReturn());
assertThat(untouched.get("media")).hasSize(1);
// empty array → clears down to a text-only post
JsonNode cleared = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 2, \"media\": []}"))
.andExpect(status().isOk())
.andReturn());
assertThat(cleared.get("media")).isEmpty();
Long rows = jdbcClient.sql(
"SELECT count(*) FROM community.post_media WHERE post_id = :id")
.param("id", UUID.fromString(id))
.query(Long.class)
.single();
assertThat(rows).isZero();
}
private void expectBadRequest(UUID author, String body) throws Exception {
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(), body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
private static String mediaBody(UUID assetId) {
return "{\"content\": \"带图\", \"media\": [{\"assetId\": \"" + assetId + "\"}]}";
}
}
@@ -74,11 +74,12 @@ class BearerAuthIntegrationTest {
@Test
void validTokenPassesTheFilter() throws Exception {
// No business routes exist in the skeleton, so an authenticated
// request reaches the 404 envelope — proving the filter let it in.
// An unmapped route: an authenticated request reaches the 404
// envelope — proving the filter let it in. (/api/v1/posts is a real
// route since T3-04, so the probe moved to a path that stays free.)
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID(), Duration.ofMinutes(15));
mockMvc.perform(get("/api/v1/posts")
mockMvc.perform(get("/api/v1/not-a-route")
.header("Authorization", "Bearer " + token))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
@@ -0,0 +1,68 @@
package com.patbond.patbond.community.support;
import org.springframework.jdbc.core.simple.JdbcClient;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Direct-SQL fixtures for the cross-schema rows community tests depend on
* (identity.users, media.assets, pet_health.pets/pet_owners). The community
* service never writes those schemas in production — tests seed them the
* way the owning services would.
*/
public final class CommunityTestData {
private CommunityTestData() {
}
public static UUID insertUser(JdbcClient jdbc, String username) {
UUID id = UuidV7.generate();
jdbc.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
/** One ready image asset owned by the given user, as T3-03 would leave it. */
public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) {
return insertAsset(jdbc, ownerUserId, "ready");
}
public static UUID insertAsset(JdbcClient jdbc, UUID ownerUserId, String status) {
UUID id = UuidV7.generate();
jdbc.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, width_px, height_px, status, ready_at)
VALUES (:id, :owner, 'image', 'post_image', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 123, 640, 480, :status, :readyAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("objectKey", "post_image/2026/09/" + id + ".jpg")
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) {
UUID id = UuidV7.generate();
jdbc.sql("""
INSERT INTO pet_health.pets (id, name, species, custom_breed_name)
VALUES (:id, '毛毛', 'cat', '中华田园猫')
""")
.param("id", id)
.update();
jdbc.sql("""
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
VALUES (:petId, :userId, 'owner', true)
""")
.param("petId", id)
.param("userId", ownerUserId)
.update();
return id;
}
}