- 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:
@@ -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>
|
||||
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
@@ -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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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) {
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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) {
|
||||
}
|
||||
+100
@@ -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;
|
||||
}
|
||||
}
|
||||
+79
@@ -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 形参名取 value:check-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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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));
|
||||
}
|
||||
}
|
||||
+17
@@ -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) {
|
||||
}
|
||||
+61
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+324
@@ -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) {
|
||||
}
|
||||
}
|
||||
+413
@@ -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 无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+94
@@ -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");
|
||||
}
|
||||
}
|
||||
+101
@@ -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));
|
||||
}
|
||||
}
|
||||
+376
@@ -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));
|
||||
}
|
||||
}
|
||||
+171
@@ -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 + "\"}]}";
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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));
|
||||
|
||||
+68
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user