diff --git a/docker-compose.yml b/docker-compose.yml index 31b6738..83b14ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,6 +125,11 @@ services: PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem + # 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一 + # 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。 + PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000} + PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env} + PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} volumes: - ./patbond-community/src/main/resources/application.yml.sample:/config/application.yml:ro - ./deploy/keys:/run/patbond/keys:ro diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index 89980e3..bc8256b 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -14,17 +14,21 @@ public enum ErrorCode { TOKEN_INVALID(40101, 401, "token 无效或过期"), REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"), PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"), + POST_ACCESS_DENIED(40301, 403, "无权限执行该操作"), USER_NOT_FOUND(40400, 404, "用户不存在"), PET_NOT_FOUND(40401, 404, "宠物不存在"), RECORD_NOT_FOUND(40402, 404, "记录不存在"), + POST_NOT_FOUND(40403, 404, "帖子不存在"), USERNAME_EXISTS(40900, 409, "用户名已存在"), PHONE_EXISTS(40901, 409, "手机号已被使用"), VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"), MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"), VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"), + IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"), MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"), VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"), REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"), + MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"), MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), INTERNAL_ERROR(50000, 500, "服务器内部错误"), diff --git a/patbond-community/pom.xml b/patbond-community/pom.xml index 156907f..a45af07 100644 --- a/patbond-community/pom.xml +++ b/patbond-community/pom.xml @@ -44,6 +44,14 @@ postgresql runtime + + + software.amazon.awssdk + s3 + diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/access/PetVisibilityGateway.java b/patbond-community/src/main/java/com/patbond/patbond/community/access/PetVisibilityGateway.java new file mode 100644 index 0000000..2bf7125 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/access/PetVisibilityGateway.java @@ -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); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/MediaConfig.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/MediaConfig.java new file mode 100644 index 0000000..ec48689 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/MediaConfig.java @@ -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); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java new file mode 100644 index 0000000..c38113f --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java @@ -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 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 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 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 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> 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)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreatePostRequest.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreatePostRequest.java new file mode 100644 index 0000000..511a389 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreatePostRequest.java @@ -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 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 getMedia() { + return media; + } + + public void setMedia(List media) { + this.media = media; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CursorPage.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CursorPage.java new file mode 100644 index 0000000..ff617b8 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CursorPage.java @@ -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( + List items, + String nextCursor, + boolean hasMore) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaAttachRequest.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaAttachRequest.java new file mode 100644 index 0000000..b6f5db3 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaAttachRequest.java @@ -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; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaItemResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaItemResponse.java new file mode 100644 index 0000000..1cdadb0 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostMediaItemResponse.java @@ -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) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java new file mode 100644 index 0000000..b704969 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java @@ -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 media, + long likeCount, + long commentCount, + long bookmarkCount, + boolean likedByMe, + boolean bookmarkedByMe, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + OffsetDateTime publishedAt, + int version) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/UpdatePostRequest.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/UpdatePostRequest.java new file mode 100644 index 0000000..521dc25 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/UpdatePostRequest.java @@ -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 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 getMedia() { + return media; + } + + public void setMedia(List media) { + this.media = media; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/media/CommunityMediaProperties.java b/patbond-community/src/main/java/com/patbond/patbond/community/media/CommunityMediaProperties.java new file mode 100644 index 0000000..094a1d1 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/media/CommunityMediaProperties.java @@ -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; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetGateway.java b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetGateway.java new file mode 100644 index 0000000..e83228a --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetGateway.java @@ -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 findByIds(Collection 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)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetRef.java b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetRef.java new file mode 100644 index 0000000..22032d1 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaAssetRef.java @@ -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) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaUrlSigner.java b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaUrlSigner.java new file mode 100644 index 0000000..0b82703 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/media/MediaUrlSigner.java @@ -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(); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java new file mode 100644 index 0000000..48047d9 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java @@ -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 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 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 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 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 findMediaByPostIds(Collection 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) { + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java new file mode 100644 index 0000000..d490b97 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java @@ -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: + * + *
    + *
  • Visibility — 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).
  • + *
  • 403 vs 404 — 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.
  • + *
  • Idempotent create (ADR-019) — 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).
  • + *
  • Publish — 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).
  • + *
+ */ +@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 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 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 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 rows = postRepository.pageByAuthor(userId, status, after, limit + 1); + boolean hasMore = rows.size() > limit; + List 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 normalizeMedia(List 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 items = new ArrayList<>(requested.size()); + Set seenPositions = new HashSet<>(); + Set 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 media) { + if (media.isEmpty()) { + return; + } + Map 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 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 assemble(List rows) { + Map> 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); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/PostCursor.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/PostCursor.java new file mode 100644 index 0000000..47eccf9 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/PostCursor.java @@ -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 无效"); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/RequestHashes.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/RequestHashes.java new file mode 100644 index 0000000..c7b0ec0 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/RequestHashes.java @@ -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); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/UuidV7.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/UuidV7.java new file mode 100644 index 0000000..e028336 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/UuidV7.java @@ -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); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java b/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java index 22e2f32..00dd4c2 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java @@ -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> handleMalformedRequest(Exception e) { return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); } diff --git a/patbond-community/src/main/resources/application.yml.sample b/patbond-community/src/main/resources/application.yml.sample index 70b16a9..426ed29 100644 --- a/patbond-community/src/main/resources/application.yml.sample +++ b/patbond-community/src/main/resources/application.yml.sample @@ -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} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java new file mode 100644 index 0000000..7d5635a --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java @@ -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"); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostIdempotencyIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostIdempotencyIntegrationTest.java new file mode 100644 index 0000000..cd4cc79 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostIdempotencyIntegrationTest.java @@ -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)); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java new file mode 100644 index 0000000..2ddfe0c --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java @@ -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> 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 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)); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostMediaAttachIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostMediaAttachIntegrationTest.java new file mode 100644 index 0000000..946ad7f --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostMediaAttachIntegrationTest.java @@ -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 + "\"}]}"; + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java index e1abb07..1f0f11f 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java @@ -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)); diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java b/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java new file mode 100644 index 0000000..fd8f15c --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java @@ -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; + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java index 23f97a8..fbaa7ad 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java @@ -77,16 +77,18 @@ public class MediaProperties { return accessKey; } - public void setAccessKey(String accessKey) { - this.accessKey = 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 secretKey) { - this.secretKey = secretKey; + public void setSecretKey(String value) { + this.secretKey = value; } public String getBucket() {