diff --git a/README.md b/README.md index 9ee22137..7b9c315c 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ APP_ROLE=worker PORT=4001 yarn start:dev # (선택) 이벤트 소비까지 보 | **문서 · 메트릭** | `DOCS_ACCESS_TOKEN`(`/gql-docs`와 `/rest-docs` 접근 토큰, 운영 필수), `METRICS_ACCESS_TOKEN`(`/metrics` Bearer 토큰, 운영 필수) | | **경보 (선택)** | `DISCORD_ALERT_WEBHOOK_URL`(없으면 로그로만 남깁니다), `ALERT_DEDUPE_WINDOW_MS`(기본 5분), `BOOT_ALERT_STATE_DIR`(부팅 실패 경보의 억제 파일 위치, 기본 `~/.caquick/boot-alert`) | | **Outbox (선택)** | `OUTBOX_DISPATCH_ENABLED`(`false`로 끄는 용도만 있으며 켜는 것은 worker 역할이 결정), `OUTBOX_POLL_INTERVAL_MS`, `OUTBOX_BATCH_SIZE`, `OUTBOX_MAX_ATTEMPTS`, `OUTBOX_PARTITION_CONCURRENCY` | +| **푸시 (선택)** | `EXPO_PUSH_ENABLED`(`true`일 때만 worker가 판매자 앱 푸시를 보내고, 기본 `false`면 소비자가 전송 없이 ack합니다), `EXPO_PUSH_ACCESS_TOKEN`(Expo 액세스 토큰, 없으면 인증 헤더 없이 보냅니다), `EXPO_PUSH_TIMEOUT_MS`(기본 5000) | | **시드 (선택)** | `ADMIN_SEED_USERNAME`, `ADMIN_SEED_PASSWORD`, `SELLER_SEED_PASSWORD` | ### 자주 쓰는 스크립트 diff --git a/infra/app.env.example b/infra/app.env.example index eed45ea7..9ba651e3 100644 --- a/infra/app.env.example +++ b/infra/app.env.example @@ -46,3 +46,8 @@ DISCORD_ALERT_WEBHOOK_URL= ALERT_DEDUPE_WINDOW_MS= OUTBOX_MAX_ATTEMPTS= OUTBOX_PARTITION_CONCURRENCY= + +# ---- 앱: 판매자 푸시(Expo Push Service) ---- +EXPO_PUSH_ENABLED= # true일 때만 worker가 전송한다(기본 false — 소비자가 전송 없이 ack) +EXPO_PUSH_ACCESS_TOKEN= +EXPO_PUSH_TIMEOUT_MS= diff --git a/prisma/migrations/20261005135631_conversation_seller_read_marker/migration.sql b/prisma/migrations/20261005135631_conversation_seller_read_marker/migration.sql new file mode 100644 index 00000000..6b3ad1d5 --- /dev/null +++ b/prisma/migrations/20261005135631_conversation_seller_read_marker/migration.sql @@ -0,0 +1,10 @@ +-- 판매자 읽음 마커(명시 mutation·답장이 전진)와 구매자 닉네임 표시 스냅샷. 마커는 NULL = "읽은 적 없음"으로 시작한다. +-- AlterTable +ALTER TABLE `store_conversation` ADD COLUMN `buyer_nickname_snapshot` VARCHAR(50) NULL, + ADD COLUMN `seller_last_read_at` DATETIME(3) NULL; + +-- backfill: 생성 시점 값은 복원할 수 없어 현재 활성 프로필의 닉네임으로 채운다. 탈퇴 프로필(deleted_at)은 조인에서 빠져 NULL로 남는다. +UPDATE `store_conversation` c + JOIN `user_profile` up ON up.account_id = c.account_id AND up.deleted_at IS NULL + SET c.buyer_nickname_snapshot = NULLIF(up.nickname, ''); +-- end backfill diff --git a/prisma/migrations/20261005145157_seller_push_device/migration.sql b/prisma/migrations/20261005145157_seller_push_device/migration.sql new file mode 100644 index 00000000..daa171d5 --- /dev/null +++ b/prisma/migrations/20261005145157_seller_push_device/migration.sql @@ -0,0 +1,43 @@ +-- 판매자 앱 Expo 푸시 디바이스(토큰 unique, 소유 계정은 마지막 등록자)와 이벤트×디바이스 전달 추적 행. 계정·매장 FK 없음, 활성 판정은 disabled_at IS NULL. +-- expo_push_token은 utf8mb4_bin: 대소문자만 다른 토큰이 같은 디바이스로 합쳐져 소유권이 넘어가지 않게. + +-- CreateTable +CREATE TABLE `seller_push_device` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `account_id` BIGINT UNSIGNED NOT NULL, + `store_id` BIGINT UNSIGNED NOT NULL, + `expo_push_token` VARCHAR(200) COLLATE utf8mb4_bin NOT NULL, + `platform` ENUM('IOS', 'ANDROID') NOT NULL, + `client_device_id` VARCHAR(128) NULL, + `last_seen_at` DATETIME(3) NOT NULL, + `disabled_at` DATETIME(3) NULL, + `disabled_reason` VARCHAR(32) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `idx_seller_push_device_store_active`(`store_id`, `disabled_at`), + INDEX `idx_seller_push_device_account`(`account_id`), + UNIQUE INDEX `uk_seller_push_device_token`(`expo_push_token`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `seller_push_delivery` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `source_event_id` CHAR(36) NOT NULL, + `push_device_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('PENDING', 'TICKET_OK', 'TICKET_ERROR', 'RECEIPT_OK', 'RECEIPT_ERROR', 'RECEIPT_UNKNOWN') NOT NULL DEFAULT 'PENDING', + `ticket_id` VARCHAR(64) NULL, + `error_code` VARCHAR(64) NULL, + `sent_at` DATETIME(3) NULL, + `receipt_checked_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_seller_push_delivery_receipt`(`status`, `sent_at`), + UNIQUE INDEX `uk_seller_push_delivery_event_device`(`source_event_id`, `push_device_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `seller_push_delivery` ADD CONSTRAINT `seller_push_delivery_push_device_id_fkey` FOREIGN KEY (`push_device_id`) REFERENCES `seller_push_device`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d37abe2b..f3aa0aba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,21 @@ enum NotificationEvent { ORDER_CANCELED } +enum PushPlatform { + IOS + ANDROID +} + +// Expo 푸시 전달 단계. send 응답(ticket) → 영수증(receipt) 순으로 전진한다. +enum SellerPushDeliveryStatus { + PENDING + TICKET_OK + TICKET_ERROR + RECEIPT_OK + RECEIPT_ERROR + RECEIPT_UNKNOWN +} + enum AuditTargetType { STORE PRODUCT @@ -1157,6 +1172,57 @@ model NotificationBroadcast { @@map("notification_broadcast") } +// 판매자 앱 푸시 디바이스. 토큰이 기기·설치 단위 식별자라 토큰 unique, 소유 계정은 마지막 등록자다. +// 계정·매장 FK는 두지 않는다(감사·이력 성격, NotificationBroadcast와 같은 선택). 활성 판정은 disabled_at IS NULL. +model SellerPushDevice { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + account_id BigInt @db.UnsignedBigInt + // 등록 시점 판매자 매장. 소비자가 이벤트 storeId로 바로 찾는다 — Store 조인 없음. + store_id BigInt @db.UnsignedBigInt + + /// 바이너리 collation(utf8mb4_bin) — 마이그레이션 SQL 참조 + expo_push_token String @db.VarChar(200) + platform PushPlatform + // 앱이 부여한 기기 식별자(표시·추적용). 서버 로직은 토큰만 본다. + client_device_id String? @db.VarChar(128) + + last_seen_at DateTime @db.DateTime(3) + disabled_at DateTime? @db.DateTime(3) + // UNREGISTERED | DEVICE_NOT_REGISTERED + disabled_reason String? @db.VarChar(32) + + created_at DateTime @default(now()) @db.DateTime(3) + updated_at DateTime @updatedAt @db.DateTime(3) + + deliveries SellerPushDelivery[] + + @@unique([expo_push_token], map: "uk_seller_push_device_token") + @@index([store_id, disabled_at], map: "idx_seller_push_device_store_active") + @@index([account_id], map: "idx_seller_push_device_account") + @@map("seller_push_device") +} + +// outbox 이벤트 1건 × 디바이스 1개 = 1행. at-least-once 재전달 dedupe 키이자 ticket → receipt 추적 행. +model SellerPushDelivery { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + source_event_id String @db.Char(36) + push_device_id BigInt @db.UnsignedBigInt + + status SellerPushDeliveryStatus @default(PENDING) + ticket_id String? @db.VarChar(64) + error_code String? @db.VarChar(64) + + sent_at DateTime? @db.DateTime(3) + receipt_checked_at DateTime? @db.DateTime(3) + created_at DateTime @default(now()) @db.DateTime(3) + + device SellerPushDevice @relation(fields: [push_device_id], references: [id]) + + @@unique([source_event_id, push_device_id], map: "uk_seller_push_delivery_event_device") + @@index([status, sent_at], map: "idx_seller_push_delivery_receipt") + @@map("seller_push_delivery") +} + /** * ========================= * 11) Search @@ -1348,7 +1414,11 @@ model StoreConversation { store_id BigInt @db.UnsignedBigInt last_message_at DateTime? @db.DateTime(3) - last_read_at DateTime? @db.DateTime(3) + // 구매자 읽음 마커(조회 시 전진). 판매자 마커는 명시 mutation·답장이 전진시킨다. + last_read_at DateTime? @db.DateTime(3) + seller_last_read_at DateTime? @db.DateTime(3) + // 대화 생성 시점 구매자 닉네임(표시용 스냅샷). 도입 전 대화는 백필로 못 채우면 null. + buyer_nickname_snapshot String? @db.VarChar(50) created_at DateTime @default(now()) @db.DateTime(3) updated_at DateTime @updatedAt @db.DateTime(3) diff --git a/src/app.module.ts b/src/app.module.ts index 456e0133..ad74fb8a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import appConfig, { import authConfig from '@/config/auth.config'; import databaseConfig from '@/config/database.config'; import docsConfig from '@/config/docs.config'; +import expoPushConfig from '@/config/expo-push.config'; import kakaoLocalConfig from '@/config/kakao-local.config'; import metricsConfig from '@/config/metrics.config'; import oidcConfig from '@/config/oidc.config'; @@ -124,6 +125,7 @@ export class AppModule implements NestModule { authConfig, databaseConfig, docsConfig, + expoPushConfig, kakaoLocalConfig, metricsConfig, oidcConfig, diff --git a/src/common/errors/error-catalog.spec.ts b/src/common/errors/error-catalog.spec.ts index fdc19f41..9f1b73d9 100644 --- a/src/common/errors/error-catalog.spec.ts +++ b/src/common/errors/error-catalog.spec.ts @@ -35,7 +35,9 @@ const RENDER_PARAMS: Partial< IDS_LENGTH_MISMATCH: { field: 'imageIds' }, INVALID_IDS: { field: 'imageIds' }, PRODUCT_IMAGE_LIMIT_EXCEEDED: { max: 10 }, + PRODUCT_TAG_LIMIT_EXCEEDED: { max: 20 }, UNSUPPORTED_OIDC_PROVIDER: { provider: 'facebook' }, + LOGIN_RATE_LIMITED: { minutes: 15 }, }; // 필터 밖 경로(Apollo가 리졸버 진입 전에 만드는 에러)는 카탈로그 코드가 아니라 Apollo 자체 code를 낸다: diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index ccc356e2..16cc3910 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -31,6 +31,11 @@ export const ERROR_CATALOG = { status: HttpStatus.TOO_MANY_REQUESTS, message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', }, + LOGIN_RATE_LIMITED: { + status: HttpStatus.TOO_MANY_REQUESTS, + message: ({ minutes }) => + `로그인 시도가 너무 많습니다. ${minutes}분 뒤 다시 시도해 주세요.`, + }, ROUTE_NOT_FOUND: { status: HttpStatus.NOT_FOUND, message: '요청한 경로를 찾을 수 없습니다.', @@ -212,6 +217,10 @@ export const ERROR_CATALOG = { status: HttpStatus.NOT_FOUND, message: '알림을 찾을 수 없습니다.', }, + INVALID_PUSH_TOKEN: { + status: HttpStatus.BAD_REQUEST, + message: '푸시 토큰 형식이 올바르지 않습니다.', + }, // ── 카탈로그(매장·상품·지역) STORE_NOT_FOUND: { @@ -391,6 +400,10 @@ export const ERROR_CATALOG = { status: HttpStatus.BAD_REQUEST, message: ({ max }) => `상품 이미지는 최대 ${max}장까지 등록할 수 있습니다.`, }, + PRODUCT_TAG_LIMIT_EXCEEDED: { + status: HttpStatus.BAD_REQUEST, + message: ({ max }) => `태그는 최대 ${max}개까지 등록할 수 있습니다.`, + }, PRODUCT_IMAGE_MIN_REQUIRED: { status: HttpStatus.BAD_REQUEST, message: '상품 이미지는 1장 이상 필요합니다.', diff --git a/src/config/expo-push.config.spec.ts b/src/config/expo-push.config.spec.ts new file mode 100644 index 00000000..b61ef2cd --- /dev/null +++ b/src/config/expo-push.config.spec.ts @@ -0,0 +1,51 @@ +import { readExpoPushConfig } from '@/config/expo-push.config'; + +describe('expoPushConfig', () => { + it('미설정이면 꺼짐·토큰 없음·타임아웃 5000', () => { + expect(readExpoPushConfig({})).toEqual({ + enabled: false, + accessToken: null, + requestTimeoutMs: 5_000, + }); + }); + + // 전송 스위치 — true/false(대소문자·공백 무시)만 인정하고 나머지는 꺼짐 + it.each([ + ['true', true], + ['TRUE', true], + [' true ', true], + ['false', false], + ['', false], + ['yes', false], + ['1', false], + ['on', false], + [undefined, false], + ])('EXPO_PUSH_ENABLED=%p → %s', (value, expected) => { + expect(readExpoPushConfig({ EXPO_PUSH_ENABLED: value }).enabled).toBe( + expected, + ); + }); + + it.each([ + [' tok ', 'tok'], + ['', null], + [' ', null], + [undefined, null], + ])('EXPO_PUSH_ACCESS_TOKEN=%p → %p', (value, expected) => { + expect( + readExpoPushConfig({ EXPO_PUSH_ACCESS_TOKEN: value }).accessToken, + ).toBe(expected); + }); + + it.each([ + ['250', 250], + ['0', 5_000], + ['-1', 5_000], + ['abc', 5_000], + ['', 5_000], + ])('EXPO_PUSH_TIMEOUT_MS=%p → %s', (value, expected) => { + expect( + readExpoPushConfig({ EXPO_PUSH_TIMEOUT_MS: value }).requestTimeoutMs, + ).toBe(expected); + }); +}); diff --git a/src/config/expo-push.config.ts b/src/config/expo-push.config.ts new file mode 100644 index 00000000..6aea978b --- /dev/null +++ b/src/config/expo-push.config.ts @@ -0,0 +1,35 @@ +import { registerAs } from '@nestjs/config'; + +import { + parseEnvBoolean, + parseEnvNumber, + parseEnvString, +} from '@/common/utils/env-parse'; + +export interface ExpoPushConfig { + /** 꺼져 있으면 소비자가 전송 없이 ack만 한다(이력도 남기지 않는다). 기본 false. */ + enabled: boolean; + /** Expo 액세스 토큰(선택). 있으면 Authorization: Bearer. */ + accessToken: string | null; + requestTimeoutMs: number; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +/** 선택 설정 — 부팅을 막는 조건이 없다. 비표준 값은 기본값으로 떨어진다. */ +export function readExpoPushConfig( + env: NodeJS.ProcessEnv = process.env, +): ExpoPushConfig { + return { + enabled: parseEnvBoolean(env.EXPO_PUSH_ENABLED, false), + accessToken: parseEnvString(env.EXPO_PUSH_ACCESS_TOKEN) ?? null, + requestTimeoutMs: parseEnvNumber( + env.EXPO_PUSH_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ), + }; +} + +export default registerAs('expoPush', (): ExpoPushConfig => + readExpoPushConfig(), +); diff --git a/src/features/auth/auth-seller-account.graphql b/src/features/auth/auth-seller-account.graphql new file mode 100644 index 00000000..3fbabf15 --- /dev/null +++ b/src/features/auth/auth-seller-account.graphql @@ -0,0 +1,37 @@ +extend type Query { + """ + 내 판매자 계정을 조회한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN. + 초기 비밀번호 상태(mustChangePassword=true)면 다른 판매자 API와 같이 FORBIDDEN이므로, 초기 비밀번호 여부는 refresh 응답의 mustChangePassword로 판단한다. + """ + sellerMe: SellerAccount! +} + +""" +판매자 본인 계정. 로그인 자격증명·계정 상태·보유 매장 ID를 함께 보여준다. 매장명은 sellerMyStore로 받는다. +""" +type SellerAccount { + """ + 계정 ID. + """ + accountId: ID! + """ + 로그인 username. 자격증명이 없는 개발용 계정이면 null. + """ + username: String + """ + 표시 이름. 계정 이름이 있으면 그것, 없으면 사업자명, 둘 다 없으면 null. + """ + displayName: String + """ + 보유 매장 ID. 매장이 없거나 삭제됐으면 null. + """ + storeId: ID + """ + 초기 비밀번호 상태. true면 본인이 비밀번호를 바꾸기 전까지 판매자 API가 FORBIDDEN이다. + """ + mustChangePassword: Boolean! + """ + 계정 상태. + """ + accountStatus: AccountStatus! +} diff --git a/src/features/auth/auth.module.ts b/src/features/auth/auth.module.ts index 2ac07e80..4c0b8984 100644 --- a/src/features/auth/auth.module.ts +++ b/src/features/auth/auth.module.ts @@ -17,10 +17,12 @@ import { AdminAccountMutationResolver } from '@/features/auth/resolvers/auth-adm import { AdminAccountQueryResolver } from '@/features/auth/resolvers/auth-admin-account-query.resolver'; import { AdminUserMutationResolver } from '@/features/auth/resolvers/auth-admin-user-mutation.resolver'; import { AdminUserQueryResolver } from '@/features/auth/resolvers/auth-admin-user-query.resolver'; +import { SellerAccountQueryResolver } from '@/features/auth/resolvers/auth-seller-account-query.resolver'; import { UserProfileMutationResolver } from '@/features/auth/resolvers/auth-user-profile-mutation.resolver'; import { UserProfileQueryResolver } from '@/features/auth/resolvers/auth-user-profile-query.resolver'; import { AdminAccountService } from '@/features/auth/services/auth-admin-account.service'; import { AdminUserService } from '@/features/auth/services/auth-admin-user.service'; +import { SellerAccountService } from '@/features/auth/services/auth-seller-account.service'; import { UserProfileService } from '@/features/auth/services/auth-user-profile.service'; import { BlacklistRebuildService } from '@/features/auth/services/blacklist-rebuild.service'; import { CredentialAuthService } from '@/features/auth/services/credential-auth.service'; @@ -63,6 +65,9 @@ import { AuthGlobalModule } from '@/global/auth/auth-global.module'; AdminAccountMutationResolver, AdminUserQueryResolver, AdminUserMutationResolver, + // 판매자 본인 계정(sellerMe) — identity가 소유한다 + SellerAccountService, + SellerAccountQueryResolver, // 구매자 계정·프로필(me·온보딩·수정·탈퇴) — identity가 소유한다 AccountUserRepository, UserProfileService, diff --git a/src/features/auth/auth.service.spec.ts b/src/features/auth/auth.service.spec.ts index c12f3759..083831ae 100644 --- a/src/features/auth/auth.service.spec.ts +++ b/src/features/auth/auth.service.spec.ts @@ -19,7 +19,7 @@ import { } from '@/features/auth/repositories/refresh-session.repository.interface'; import { TokenService } from '@/features/auth/services/token.service'; import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; -import { TEST_AUTH_CONFIG } from '@/test/auth-config'; +import { TEST_AUTH_CONFIG, testAuthConfig } from '@/test/auth-config'; describe('AuthService', () => { let service: AuthService; @@ -134,7 +134,10 @@ describe('AuthService', () => { mockRefreshSessions.findActiveRefreshSessionByHash, ).toHaveBeenCalled(); expect(mockRefreshSessions.rotateRefreshSession).toHaveBeenCalled(); - expect(result).toEqual({ accessToken: 'new-access-token' }); + expect(result).toEqual({ + accessToken: 'new-access-token', + expiresInSeconds: 900, + }); expect(mockRes.cookie).toHaveBeenCalledWith( REFRESH_COOKIE.USER, expect.any(String), @@ -142,6 +145,28 @@ describe('AuthService', () => { ); }); + it('expiresInSeconds는 authConfig의 jwtAccessExpiresSeconds를 그대로 싣는다', async () => { + const mockReq = { + cookies: { caquick_rt: 'old-refresh-token' }, + headers: { 'user-agent': 'Mozilla/5.0' }, + ip: '127.0.0.1', + } as unknown as Request; + const mockRes = { cookie: jest.fn() } as unknown as Response; + mockConfig.getOrThrow.mockReturnValue( + testAuthConfig({ jwtAccessExpiresSeconds: 60 }), + ); + mockRefreshSessions.findActiveRefreshSessionByHash.mockResolvedValue({ + id: BigInt(1), + account_id: BigInt(1), + } as never); + mockRefreshSessions.rotateRefreshSession.mockResolvedValue({} as never); + mockJwt.sign.mockReturnValue('new-access-token'); + + const result = await service.refresh(mockReq, mockRes); + + expect(result.expiresInSeconds).toBe(60); + }); + it('refresh 토큰이 없으면 UnauthorizedException을 던져야 한다', async () => { const mockReq = { cookies: {}, diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index a48c5c33..ac9a89e3 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { Request, Response } from 'express'; import { DomainException } from '@/common/errors/error-catalog'; +import { readPresentedRefreshToken } from '@/features/auth/helpers/refresh-transport.helper'; import { ACCOUNT_REPOSITORY, type IAccountRepository, @@ -23,9 +24,15 @@ export class AuthService { private readonly refreshSessions: IRefreshSessionRepository, ) {} - async refresh(req: Request, res: Response): Promise<{ accessToken: string }> { + async refresh( + req: Request, + res: Response, + ): Promise<{ accessToken: string; expiresInSeconds: number }> { const { accessToken } = await this.tokens.rotateRefresh('USER', req, res); - return { accessToken }; + return { + accessToken, + expiresInSeconds: this.tokens.getAccessExpiresSeconds(), + }; } /** 시드 데이터의 accountId로 OIDC 흐름 없이 GraphQL API를 시험하기 위한 것. production은 controller 입구에서 차단된다. */ @@ -54,10 +61,10 @@ export class AuthService { } async logout(req: Request, res: Response): Promise { - const refreshToken = this.tokens.readRefreshCookie('USER', req); + const presented = readPresentedRefreshToken('USER', req); - if (refreshToken) { - const tokenHash = this.tokens.sha256Hex(refreshToken); + if (presented) { + const tokenHash = this.tokens.sha256Hex(presented.token); const session = await this.refreshSessions.findActiveRefreshSessionByHash(tokenHash); // 쿠키 분리 전 caquick_rt에 남은 판매자·관리자 세션은 폐기하지 않고 쿠키만 지운다 diff --git a/src/features/auth/controllers/__snapshots__/auth-swagger-docs.spec.ts.snap b/src/features/auth/controllers/__snapshots__/auth-swagger-docs.spec.ts.snap index bfbb9265..50d6e076 100644 --- a/src/features/auth/controllers/__snapshots__/auth-swagger-docs.spec.ts.snap +++ b/src/features/auth/controllers/__snapshots__/auth-swagger-docs.spec.ts.snap @@ -96,6 +96,10 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 ], "type": "string", }, + "expiresInSeconds": { + "example": 900, + "type": "number", + }, "mustChangePassword": { "type": "boolean", }, @@ -107,6 +111,7 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "required": [ "accessToken", "tokenType", + "expiresInSeconds", "accountStatus", "mustChangePassword", ], @@ -116,6 +121,9 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 }, "description": "관리자 로그인 결과", }, + "429": { + "description": "로그인 시도 초과(errorCode LOGIN_RATE_LIMITED). 같은 아이디+IP 5회/15분, IP 30회/15분 — 성공 시도도 센다.", + }, }, "summary": "관리자 로그인", "tags": [ @@ -166,6 +174,10 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 ], "type": "string", }, + "expiresInSeconds": { + "example": 900, + "type": "number", + }, "mustChangePassword": { "type": "boolean", }, @@ -177,6 +189,7 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "required": [ "accessToken", "tokenType", + "expiresInSeconds", "accountStatus", "mustChangePassword", ], @@ -351,6 +364,10 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "accessToken": { "type": "string", }, + "expiresInSeconds": { + "example": 900, + "type": "number", + }, "tokenType": { "example": "Bearer", "type": "string", @@ -359,6 +376,7 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "required": [ "accessToken", "tokenType", + "expiresInSeconds", ], "type": "object", }, @@ -426,9 +444,22 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 }, "/auth/seller/login": { "post": { - "description": "판매자 username/password로 로그인한다.", + "description": "판매자 username/password로 로그인한다. \`X-Client: mobile\`이면 refresh 토큰을 쿠키 대신 응답 바디(\`refreshToken\`·\`refreshExpiresAt\`)로 돌려준다.", "operationId": "AuthController_sellerLogin", - "parameters": [], + "parameters": [ + { + "description": "앱은 mobile. 웹은 보내지 않는다(쿠키 모드).", + "in": "header", + "name": "X-Client", + "required": false, + "schema": { + "enum": [ + "mobile", + ], + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -456,9 +487,22 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 ], "type": "string", }, + "expiresInSeconds": { + "example": 900, + "type": "number", + }, "mustChangePassword": { "type": "boolean", }, + "refreshExpiresAt": { + "description": "바디 모드에서만. refresh 토큰 만료 시각.", + "format": "date-time", + "type": "string", + }, + "refreshToken": { + "description": "바디 모드에서만. 재발급마다 바뀌므로 교체 저장한다.", + "type": "string", + }, "tokenType": { "example": "Bearer", "type": "string", @@ -467,6 +511,7 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "required": [ "accessToken", "tokenType", + "expiresInSeconds", "accountStatus", "mustChangePassword", ], @@ -476,6 +521,9 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 }, "description": "판매자 로그인 결과", }, + "429": { + "description": "로그인 시도 초과(errorCode LOGIN_RATE_LIMITED). 같은 아이디+IP 5회/15분, IP 30회/15분 — 성공 시도도 센다.", + }, }, "summary": "판매자 로그인", "tags": [ @@ -485,9 +533,26 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 }, "/auth/seller/logout": { "post": { - "description": "판매자 refresh 세션을 폐기하고 쿠키를 제거한다.", + "description": "판매자 refresh 세션을 폐기하고 쿠키를 제거한다. 바디 \`refreshToken\`이 있으면 그것을(쿠키는 읽지 않음), 없으면 쿠키를 쓴다.", "operationId": "AuthController_sellerLogout", "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "refreshToken": { + "pattern": "^[0-9a-f]{64}$", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "판매자 앱 전용. 비우면 쿠키 모드.", + "required": false, + }, "responses": { "204": { "description": "판매자 로그아웃 완료", @@ -506,9 +571,26 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 }, "/auth/seller/refresh": { "post": { - "description": "판매자 refresh 쿠키를 사용해 access token을 재발급한다.", + "description": "판매자 refresh 쿠키를 사용해 access token을 재발급한다. 바디 \`refreshToken\`이 있으면 그것을(쿠키는 읽지 않음), 없으면 쿠키를 쓴다.", "operationId": "AuthController_sellerRefresh", "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "refreshToken": { + "pattern": "^[0-9a-f]{64}$", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "판매자 앱 전용. 비우면 쿠키 모드.", + "required": false, + }, "responses": { "200": { "content": { @@ -526,9 +608,22 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 ], "type": "string", }, + "expiresInSeconds": { + "example": 900, + "type": "number", + }, "mustChangePassword": { "type": "boolean", }, + "refreshExpiresAt": { + "description": "바디 모드에서만. refresh 토큰 만료 시각.", + "format": "date-time", + "type": "string", + }, + "refreshToken": { + "description": "바디 모드에서만. 재발급마다 바뀌므로 교체 저장한다.", + "type": "string", + }, "tokenType": { "example": "Bearer", "type": "string", @@ -537,6 +632,7 @@ exports[`Auth REST Swagger 문서 auth REST operation 14개(JWKS 포함)의 문 "required": [ "accessToken", "tokenType", + "expiresInSeconds", "accountStatus", "mustChangePassword", ], diff --git a/src/features/auth/controllers/auth-login-rate-limit.spec.ts b/src/features/auth/controllers/auth-login-rate-limit.spec.ts new file mode 100644 index 00000000..736b8c8b --- /dev/null +++ b/src/features/auth/controllers/auth-login-rate-limit.spec.ts @@ -0,0 +1,143 @@ +import type { INestApplication } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import type Redis from 'ioredis'; +import request from 'supertest'; +import type { App } from 'supertest/types'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { ClockService } from '@/common/providers/clock.service'; +import { AuthService } from '@/features/auth/auth.service'; +import { AuthController } from '@/features/auth/controllers/auth.controller'; +import { CredentialAuthService } from '@/features/auth/services/credential-auth.service'; +import { OidcLoginService } from '@/features/auth/services/oidc-login.service'; +import { HttpExceptionFilter } from '@/global/filters/global-exception.filter'; +import { GraphQLExceptionFilter } from '@/global/filters/graphql-exception.filter'; +import { CustomLoggerService } from '@/global/logger/custom-logger.service'; +import { MetricsService } from '@/global/metrics'; +import { connectTestRedis } from '@/test/db/redis-test-client'; +import { listenOnLoopback } from '@/test/http-app'; +import { redisTestProviders } from '@/test/redis'; + +const T0 = new Date('2026-10-03T12:00:00.000Z'); +const WINDOW_SECONDS = 900; + +class FixedClock extends ClockService { + at = T0; + override now(): Date { + return this.at; + } +} + +type ErrorBody = { code: number; errorCode: string | null; message: string }; + +// 가드가 파싱된 바디의 username을 읽는다는 건 HTTP로만 증명된다(가드 단위 spec은 req를 손으로 만든다). +describe('로그인 rate limit (real app + real Redis)', () => { + let app: INestApplication; + let redis: Redis; + const clock = new FixedClock(); + const login = jest.fn(); + + beforeAll(async () => { + redis = await connectTestRedis(); + const module = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: {} }, + { provide: OidcLoginService, useValue: {} }, + { provide: CredentialAuthService, useValue: { login } }, + { provide: ClockService, useValue: clock }, + ...redisTestProviders(redis), + ], + }).compile(); + app = module.createNestApplication>(); + const logger = new CustomLoggerService(); + logger.txError = jest.fn(); + app.useGlobalFilters( + new HttpExceptionFilter( + app.get(HttpAdapterHost).httpAdapter, + logger, + new GraphQLExceptionFilter(logger, new MetricsService()), + ), + ); + await listenOnLoopback(app); + }); + afterAll(async () => { + await app?.close(); + await redis.quit(); + }); + beforeEach(async () => { + await redis.flushdb(); + clock.at = T0; + login.mockReset(); + login.mockRejectedValue(new DomainException('INVALID_CREDENTIALS')); + }); + + function attempt( + path: '/auth/seller/login' | '/auth/admin/login', + username: string, + ): request.Test { + return request(app.getHttpServer()) + .post(path) + .send({ username, password: 'wrong-password' }); + } + + async function expectRejectedTimes( + path: '/auth/seller/login' | '/auth/admin/login', + username: string, + times: number, + ): Promise { + for (let i = 0; i < times; i++) { + await attempt(path, username).expect(401); + } + } + + it('같은 username은 5회까지 401, 6회째는 429 LOGIN_RATE_LIMITED이고 서비스까지 가지 않는다', async () => { + await expectRejectedTimes('/auth/seller/login', 'alice', 5); + + const res = await attempt('/auth/seller/login', 'alice').expect(429); + + const body = res.body as ErrorBody; + expect(body.errorCode).toBe('LOGIN_RATE_LIMITED'); + expect(body.message).toContain('15분'); + expect(login).toHaveBeenCalledTimes(5); + }); + + it('반증: 다른 username은 6회째도 401이다', async () => { + await expectRejectedTimes('/auth/seller/login', 'alice', 5); + + await attempt('/auth/seller/login', 'bob').expect(401); + }); + + it('username 대소문자·공백이 달라도 같은 계정으로 센다', async () => { + await expectRejectedTimes('/auth/seller/login', 'Alice', 5); + + await attempt('/auth/seller/login', ' alice ').expect(429); + }); + + it('관리자 로그인은 판매자와 따로 세고 같은 한도를 갖는다', async () => { + await expectRejectedTimes('/auth/seller/login', 'alice', 5); + + await expectRejectedTimes('/auth/admin/login', 'alice', 5); + const res = await attempt('/auth/admin/login', 'alice').expect(429); + expect((res.body as ErrorBody).errorCode).toBe('LOGIN_RATE_LIMITED'); + }); + + it('username을 바꿔 가며 찍어도 IP 30회를 넘기면 429다', async () => { + for (let i = 0; i < 30; i++) { + await attempt('/auth/seller/login', `user-${i}`).expect(401); + } + + const res = await attempt('/auth/seller/login', 'user-30').expect(429); + expect((res.body as ErrorBody).errorCode).toBe('LOGIN_RATE_LIMITED'); + }); + + it('창이 지나면 다시 401이다', async () => { + await expectRejectedTimes('/auth/seller/login', 'alice', 5); + await attempt('/auth/seller/login', 'alice').expect(429); + + clock.at = new Date(T0.getTime() + WINDOW_SECONDS * 1000); + + await attempt('/auth/seller/login', 'alice').expect(401); + }); +}); diff --git a/src/features/auth/controllers/auth-swagger-docs.spec.ts b/src/features/auth/controllers/auth-swagger-docs.spec.ts index eff898c7..8a579b10 100644 --- a/src/features/auth/controllers/auth-swagger-docs.spec.ts +++ b/src/features/auth/controllers/auth-swagger-docs.spec.ts @@ -8,6 +8,7 @@ import { AuthController } from '@/features/auth/controllers/auth.controller'; import { JwksController } from '@/features/auth/controllers/jwks.controller'; import { CredentialAuthService } from '@/features/auth/services/credential-auth.service'; import { OidcLoginService } from '@/features/auth/services/oidc-login.service'; +import { RateLimitGuard } from '@/global/rate-limit'; import { TEST_AUTH_CONFIG } from '@/test/auth-config'; /** 공용 데코레이터 묶음이 operation의 summary·description·security·response 스키마를 바꾸지 않는지 스냅샷으로 고정한다(JWKS 포함 14개). */ @@ -26,7 +27,11 @@ describe('Auth REST Swagger 문서', () => { useValue: { getOrThrow: () => TEST_AUTH_CONFIG }, }, ], - }).compile(); + }) + // 문서만 본다 — 가드 동작은 auth-login-rate-limit.spec이 real Redis로 증명 + .overrideGuard(RateLimitGuard) + .useValue({ canActivate: () => true }) + .compile(); app = module.createNestApplication(); await app.init(); }); diff --git a/src/features/auth/controllers/auth.controller.spec.ts b/src/features/auth/controllers/auth.controller.spec.ts index 315a5c91..2aeb275b 100644 --- a/src/features/auth/controllers/auth.controller.spec.ts +++ b/src/features/auth/controllers/auth.controller.spec.ts @@ -1,3 +1,4 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; import { Test, TestingModule } from '@nestjs/testing'; import type { Request, Response } from 'express'; @@ -6,6 +7,8 @@ import { AuthController } from '@/features/auth/controllers/auth.controller'; import { CredentialAuthService } from '@/features/auth/services/credential-auth.service'; import { OidcLoginService } from '@/features/auth/services/oidc-login.service'; import type { JwtUser } from '@/global/auth'; +import { RateLimitGuard } from '@/global/rate-limit'; +import { RATE_LIMIT_METADATA_KEY } from '@/global/rate-limit/rate-limit.guard'; function mockRes(): Response { return { @@ -48,11 +51,44 @@ describe('AuthController', () => { { provide: OidcLoginService, useValue: oidcLogin }, { provide: CredentialAuthService, useValue: credentialAuth }, ], - }).compile(); + }) + // 핸들러만 본다 — 가드 동작은 auth-login-rate-limit.spec이 real Redis로 증명 + .overrideGuard(RateLimitGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(AuthController); }); + it.each([ + ['sellerLogin', 'seller'], + ['adminLogin', 'admin'], + ] as const)( + '%s에는 아이디+IP 5회·IP 30회/15분 정책과 가드가 걸려 있다', + (handlerName, role) => { + const handler = AuthController.prototype[handlerName]; + + expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toEqual([ + RateLimitGuard, + ]); + expect(Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, handler)).toEqual([ + { + name: `${role}-login`, + subject: 'ip+username', + limit: 5, + windowSeconds: 900, + code: 'LOGIN_RATE_LIMITED', + }, + { + name: `${role}-login-ip`, + limit: 30, + windowSeconds: 900, + code: 'LOGIN_RATE_LIMITED', + }, + ]); + }, + ); + it('start는 OIDC 인증 URL로 리다이렉트해야 한다', async () => { const res = { redirect: jest.fn(), @@ -101,11 +137,12 @@ describe('AuthController', () => { expect(res.redirect).toHaveBeenCalledWith('https://caquick.site/mypage'); }); - it('refresh는 200 + accessToken JSON으로 응답한다', async () => { + it('refresh는 200 + accessToken·expiresInSeconds JSON으로 응답한다', async () => { const res = mockRes(); const req = {} as Request; auth.refresh.mockResolvedValue({ accessToken: 'new-access', + expiresInSeconds: 600, }); await controller.refresh(req, res); @@ -115,6 +152,7 @@ describe('AuthController', () => { expect(res.json).toHaveBeenCalledWith({ accessToken: 'new-access', tokenType: 'Bearer', + expiresInSeconds: 600, }); }); @@ -135,8 +173,8 @@ describe('AuthController', () => { prefix: 'seller', pick: (c: AuthController) => ({ login: c.sellerLogin.bind(c), - refresh: c.sellerRefresh.bind(c), - logout: c.sellerLogout.bind(c), + refresh: (req: Request, res: Response) => c.sellerRefresh({}, req, res), + logout: (req: Request, res: Response) => c.sellerLogout({}, req, res), changePassword: c.sellerChangePassword.bind(c), }), }, @@ -151,11 +189,12 @@ describe('AuthController', () => { }), }, ])('$prefix 자격증명 엔드포인트', ({ role, prefix, pick }) => { - it(`${prefix}Login은 role=${role}로 위임하고 accessToken·accountStatus·mustChangePassword를 응답한다`, async () => { + it(`${prefix}Login은 role=${role}로 위임하고 accessToken·expiresInSeconds·accountStatus·mustChangePassword를 응답한다`, async () => { const res = mockRes(); const req = {} as Request; credentialAuth.login.mockResolvedValue({ accessToken: 'access', + expiresInSeconds: 600, accountStatus: 'ACTIVE', mustChangePassword: true, }); @@ -177,6 +216,7 @@ describe('AuthController', () => { expect(res.json).toHaveBeenCalledWith({ accessToken: 'access', tokenType: 'Bearer', + expiresInSeconds: 600, accountStatus: 'ACTIVE', mustChangePassword: true, }); @@ -187,6 +227,7 @@ describe('AuthController', () => { const req = {} as Request; credentialAuth.refresh.mockResolvedValue({ accessToken: 'rotated', + expiresInSeconds: 600, accountStatus: 'ACTIVE', mustChangePassword: false, }); @@ -198,6 +239,7 @@ describe('AuthController', () => { expect(res.json).toHaveBeenCalledWith({ accessToken: 'rotated', tokenType: 'Bearer', + expiresInSeconds: 600, accountStatus: 'ACTIVE', mustChangePassword: false, }); @@ -253,6 +295,96 @@ describe('AuthController', () => { }); }); + describe('판매자 바디 모드', () => { + const issued = { + accessToken: 'access', + expiresInSeconds: 600, + accountStatus: 'ACTIVE' as const, + mustChangePassword: false, + refreshToken: 'a'.repeat(64), + refreshExpiresAt: new Date('2026-11-04T01:02:03.456Z'), + }; + + it('sellerLogin은 X-Client: mobile 요청을 그대로 위임하고 refreshToken·refreshExpiresAt(ISO)을 싣는다', async () => { + const res = mockRes(); + const req = { headers: { 'x-client': 'mobile' } } as unknown as Request; + credentialAuth.login.mockResolvedValue(issued); + + await controller.sellerLogin( + { username: 'who', password: 'pw1234!A' }, + req, + res, + ); + + expect(credentialAuth.login).toHaveBeenCalledWith( + expect.objectContaining({ role: 'SELLER', req }), + ); + expect(res.json).toHaveBeenCalledWith({ + accessToken: 'access', + tokenType: 'Bearer', + expiresInSeconds: 600, + accountStatus: 'ACTIVE', + mustChangePassword: false, + refreshToken: 'a'.repeat(64), + refreshExpiresAt: '2026-11-04T01:02:03.456Z', + }); + }); + + it('sellerRefresh는 바디 토큰을 req로 넘기고 새 refreshToken·refreshExpiresAt을 싣는다', async () => { + const res = mockRes(); + const body = { refreshToken: 'b'.repeat(64) }; + const req = { body, cookies: {} } as unknown as Request; + credentialAuth.refresh.mockResolvedValue(issued); + + await controller.sellerRefresh(body, req, res); + + expect(credentialAuth.refresh).toHaveBeenCalledWith({ + role: 'SELLER', + req, + res, + }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + refreshToken: 'a'.repeat(64), + refreshExpiresAt: '2026-11-04T01:02:03.456Z', + }), + ); + }); + + it.each([ + ['sellerLogin 헤더 없음', 'sellerLogin' as const, {}], + [ + 'adminLogin + X-Client: mobile', + 'adminLogin' as const, + { + 'x-client': 'mobile', + }, + ], + ])( + '%s — 서비스가 토큰을 싣지 않으면 refreshToken·refreshExpiresAt 키가 없다', + async (_label, handler, headers) => { + const res = mockRes(); + const req = { headers } as unknown as Request; + const { + refreshToken: _t, + refreshExpiresAt: _e, + ...cookieMode + } = issued; + credentialAuth.login.mockResolvedValue(cookieMode); + + await controller[handler]( + { username: 'who', password: 'pw1234!A' }, + req, + res, + ); + + const json = (res.json as jest.Mock).mock.calls[0][0] as object; + expect(json).not.toHaveProperty('refreshToken'); + expect(json).not.toHaveProperty('refreshExpiresAt'); + }, + ); + }); + describe('devIssueToken', () => { const ORIGINAL_NODE_ENV = process.env.NODE_ENV; diff --git a/src/features/auth/controllers/auth.controller.ts b/src/features/auth/controllers/auth.controller.ts index aab2f73c..e293983c 100644 --- a/src/features/auth/controllers/auth.controller.ts +++ b/src/features/auth/controllers/auth.controller.ts @@ -32,6 +32,7 @@ import { import { ChangePasswordInput } from '@/features/auth/dto/inputs/change-password.input'; import { CredentialLoginInput } from '@/features/auth/dto/inputs/credential-login.input'; import { DevIssueTokenInput } from '@/features/auth/dto/inputs/dev-issue-token.input'; +import { RefreshTokenInput } from '@/features/auth/dto/inputs/refresh-token.input'; import { CredentialAuthService, type CredentialLoginResult, @@ -44,18 +45,48 @@ import { parseAccountId, type JwtUser, } from '@/global/auth'; +import { RateLimit } from '@/global/rate-limit'; + +const LOGIN_WINDOW_SECONDS = 900; + +/** 같은 아이디를 한 곳에서 찍는 공격(아이디+IP)과 아이디를 바꿔 가며 찍는 공격(IP)을 따로 막는다. 성공 시도도 센다. */ +function LoginRateLimit(role: 'seller' | 'admin'): MethodDecorator { + return RateLimit( + { + name: `${role}-login`, + subject: 'ip+username', + limit: 5, + windowSeconds: LOGIN_WINDOW_SECONDS, + code: 'LOGIN_RATE_LIMITED', + }, + { + name: `${role}-login-ip`, + limit: 30, + windowSeconds: LOGIN_WINDOW_SECONDS, + code: 'LOGIN_RATE_LIMITED', + }, + ); +} function toCredentialLoginResponse(result: CredentialLoginResult): { accessToken: string; tokenType: 'Bearer'; + expiresInSeconds: number; accountStatus: CredentialLoginResult['accountStatus']; mustChangePassword: boolean; + refreshToken?: string; + refreshExpiresAt?: string; } { return { accessToken: result.accessToken, tokenType: 'Bearer', + expiresInSeconds: result.expiresInSeconds, accountStatus: result.accountStatus, mustChangePassword: result.mustChangePassword, + ...(result.refreshToken !== undefined && { + refreshToken: result.refreshToken, + refreshExpiresAt: result.refreshExpiresAt?.toISOString(), + }), }; } @@ -148,14 +179,17 @@ export class AuthController { properties: { accessToken: { type: 'string' }, tokenType: { type: 'string', example: 'Bearer' }, + expiresInSeconds: { type: 'number', example: 900 }, }, - required: ['accessToken', 'tokenType'], + required: ['accessToken', 'tokenType', 'expiresInSeconds'], }, }) @Post('refresh') async refresh(@Req() req: Request, @Res() res: Response): Promise { - const { accessToken } = await this.auth.refresh(req, res); - res.status(200).json({ accessToken, tokenType: 'Bearer' }); + const { accessToken, expiresInSeconds } = await this.auth.refresh(req, res); + res + .status(200) + .json({ accessToken, tokenType: 'Bearer', expiresInSeconds }); } @ApiOperation({ @@ -171,6 +205,7 @@ export class AuthController { } @ApiCredentialLogin('판매자') + @LoginRateLimit('seller') @Post('seller/login') async sellerLogin( @Body() body: CredentialLoginInput, @@ -187,9 +222,11 @@ export class AuthController { res.status(200).json(toCredentialLoginResponse(result)); } + // 바디는 ValidationPipe 통과용 — 토큰은 서비스가 req.body에서 읽는다(쿠키와 같은 입구) @ApiCredentialRefresh('판매자') @Post('seller/refresh') async sellerRefresh( + @Body() _body: RefreshTokenInput, @Req() req: Request, @Res() res: Response, ): Promise { @@ -203,7 +240,11 @@ export class AuthController { @ApiCredentialLogout('판매자') @Post('seller/logout') - async sellerLogout(@Req() req: Request, @Res() res: Response): Promise { + async sellerLogout( + @Body() _body: RefreshTokenInput, + @Req() req: Request, + @Res() res: Response, + ): Promise { await this.credentialAuth.logout({ role: 'SELLER', req, res }); res.status(204).send(); } @@ -261,6 +302,7 @@ export class AuthController { } @ApiCredentialLogin('관리자') + @LoginRateLimit('admin') @Post('admin/login') async adminLogin( @Body() body: CredentialLoginInput, diff --git a/src/features/auth/decorators/credential-auth-docs.decorator.ts b/src/features/auth/decorators/credential-auth-docs.decorator.ts index 8eceed9a..5fc91bab 100644 --- a/src/features/auth/decorators/credential-auth-docs.decorator.ts +++ b/src/features/auth/decorators/credential-auth-docs.decorator.ts @@ -1,29 +1,78 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBearerAuth, + ApiBody, ApiCookieAuth, + ApiHeader, ApiNoContentResponse, ApiOkResponse, ApiOperation, + ApiTooManyRequestsResponse, } from '@nestjs/swagger'; -/** 판매자·관리자 로그인/재발급 응답 스키마(Swagger). 두 경로가 같은 모양을 쓴다. */ +/** 판매자·관리자 공통 로그인/재발급 응답 스키마(Swagger). */ const CREDENTIAL_LOGIN_RESPONSE_SCHEMA = { type: 'object', properties: { accessToken: { type: 'string' }, tokenType: { type: 'string', example: 'Bearer' }, + expiresInSeconds: { type: 'number', example: 900 }, accountStatus: { type: 'string', enum: ['PENDING', 'ACTIVE', 'SUSPENDED'], }, mustChangePassword: { type: 'boolean' }, }, - required: ['accessToken', 'tokenType', 'accountStatus', 'mustChangePassword'], + required: [ + 'accessToken', + 'tokenType', + 'expiresInSeconds', + 'accountStatus', + 'mustChangePassword', + ], }; export type CredentialRoleLabel = '판매자' | '관리자'; +/** 판매자만 바디 전달(앱)이 있어 refresh 토큰 필드가 붙는다. */ +const SELLER_LOGIN_RESPONSE_SCHEMA = { + ...CREDENTIAL_LOGIN_RESPONSE_SCHEMA, + properties: { + ...CREDENTIAL_LOGIN_RESPONSE_SCHEMA.properties, + refreshToken: { + type: 'string', + description: '바디 모드에서만. 재발급마다 바뀌므로 교체 저장한다.', + }, + refreshExpiresAt: { + type: 'string', + format: 'date-time', + description: '바디 모드에서만. refresh 토큰 만료 시각.', + }, + }, +}; + +function loginResponseSchemaOf(role: CredentialRoleLabel) { + return role === '판매자' + ? SELLER_LOGIN_RESPONSE_SCHEMA + : CREDENTIAL_LOGIN_RESPONSE_SCHEMA; +} + +const SELLER_MOBILE_LOGIN_NOTE = + ' `X-Client: mobile`이면 refresh 토큰을 쿠키 대신 응답 바디(`refreshToken`·`refreshExpiresAt`)로 돌려준다.'; +const SELLER_BODY_TOKEN_NOTE = + ' 바디 `refreshToken`이 있으면 그것을(쿠키는 읽지 않음), 없으면 쿠키를 쓴다.'; + +const REFRESH_TOKEN_BODY = ApiBody({ + required: false, + description: '판매자 앱 전용. 비우면 쿠키 모드.', + schema: { + type: 'object', + properties: { + refreshToken: { type: 'string', pattern: '^[0-9a-f]{64}$' }, + }, + }, +}); + /** main.ts의 addCookieAuth 스킴 이름 — 역할마다 refresh 쿠키 이름이 다르다. */ const REFRESH_COOKIE_SCHEME: Record = { 판매자: 'seller-refresh-cookie', @@ -41,11 +90,25 @@ export function ApiCredentialLogin(role: CredentialRoleLabel): MethodDecorator { description: role === '관리자' ? '관리자 username/password로 로그인한다. mustChangePassword=true면 비밀번호를 바꾸기 전까지 관리자 API가 FORBIDDEN이다.' - : '판매자 username/password로 로그인한다.', + : '판매자 username/password로 로그인한다.' + SELLER_MOBILE_LOGIN_NOTE, }), + ...(role === '판매자' + ? [ + ApiHeader({ + name: 'X-Client', + required: false, + enum: ['mobile'], + description: '앱은 mobile. 웹은 보내지 않는다(쿠키 모드).', + }), + ] + : []), ApiOkResponse({ description: `${role} 로그인 결과`, - schema: CREDENTIAL_LOGIN_RESPONSE_SCHEMA, + schema: loginResponseSchemaOf(role), + }), + ApiTooManyRequestsResponse({ + description: + '로그인 시도 초과(errorCode LOGIN_RATE_LIMITED). 같은 아이디+IP 5회/15분, IP 30회/15분 — 성공 시도도 센다.', }), ); } @@ -56,12 +119,15 @@ export function ApiCredentialRefresh( return applyDecorators( ApiOperation({ summary: `${role} Access/Refresh 재발급`, - description: `${role} refresh 쿠키를 사용해 access token을 재발급한다.`, + description: + `${role} refresh 쿠키를 사용해 access token을 재발급한다.` + + (role === '판매자' ? SELLER_BODY_TOKEN_NOTE : ''), }), ApiCookieAuth(REFRESH_COOKIE_SCHEME[role]), + ...(role === '판매자' ? [REFRESH_TOKEN_BODY] : []), ApiOkResponse({ description: `${role} 재발급 결과`, - schema: CREDENTIAL_LOGIN_RESPONSE_SCHEMA, + schema: loginResponseSchemaOf(role), }), ); } @@ -72,9 +138,12 @@ export function ApiCredentialLogout( return applyDecorators( ApiOperation({ summary: `${role} 로그아웃`, - description: `${role} refresh 세션을 폐기하고 쿠키를 제거한다.`, + description: + `${role} refresh 세션을 폐기하고 쿠키를 제거한다.` + + (role === '판매자' ? SELLER_BODY_TOKEN_NOTE : ''), }), ApiCookieAuth(REFRESH_COOKIE_SCHEME[role]), + ...(role === '판매자' ? [REFRESH_TOKEN_BODY] : []), ApiNoContentResponse({ description: `${role} 로그아웃 완료` }), ); } diff --git a/src/features/auth/dto/inputs/refresh-token.input.spec.ts b/src/features/auth/dto/inputs/refresh-token.input.spec.ts new file mode 100644 index 00000000..b13f98b4 --- /dev/null +++ b/src/features/auth/dto/inputs/refresh-token.input.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { generateRandomToken } from '@/common/utils/crypto'; +import { RefreshTokenInput } from '@/features/auth/dto/inputs/refresh-token.input'; + +function build(plain: object): RefreshTokenInput { + return plainToInstance(RefreshTokenInput, plain); +} + +describe('RefreshTokenInput', () => { + it.each([ + ['발급 형식(hex 64)', generateRandomToken(32)], + ['0·f 경계', '0'.repeat(32) + 'f'.repeat(32)], + ])('허용: %s', async (_label, value) => { + expect(await validate(build({ refreshToken: value }))).toHaveLength(0); + }); + + it.each([ + ['누락(쿠키 모드)', {}], + ['undefined', { refreshToken: undefined }], + ['null', { refreshToken: null }], + ])('허용: %s', async (_label, plain) => { + expect(await validate(build(plain))).toHaveLength(0); + }); + + it.each([ + ['빈 문자열', ''], + ['63자', 'a'.repeat(63)], + ['65자', 'a'.repeat(65)], + ['대문자 hex', 'A'.repeat(64)], + ['hex 아닌 문자', 'g'.repeat(64)], + ['앞 공백', ' ' + 'a'.repeat(63)], + ['숫자 타입', 1], + ['객체', { token: 'a'.repeat(64) }], + ])('거절: %s', async (_label, value) => { + const errors = await validate(build({ refreshToken: value })); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('refreshToken'); + }); +}); diff --git a/src/features/auth/dto/inputs/refresh-token.input.ts b/src/features/auth/dto/inputs/refresh-token.input.ts new file mode 100644 index 00000000..baf50dc2 --- /dev/null +++ b/src/features/auth/dto/inputs/refresh-token.input.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, Matches } from 'class-validator'; + +/** 판매자 앱의 refresh·logout 바디. 없으면 쿠키 모드. 토큰은 generateRandomToken(32)의 hex 64자. */ +export class RefreshTokenInput { + @IsOptional() + @IsString() + @Matches(/^[0-9a-f]{64}$/) + refreshToken?: string; +} diff --git a/src/features/auth/helpers/refresh-transport.helper.spec.ts b/src/features/auth/helpers/refresh-transport.helper.spec.ts new file mode 100644 index 00000000..51dacb14 --- /dev/null +++ b/src/features/auth/helpers/refresh-transport.helper.spec.ts @@ -0,0 +1,149 @@ +import type { Request } from 'express'; + +import { + BODY_TRANSPORT_ROLES, + loginTransportOf, + readPresentedRefreshToken, +} from '@/features/auth/helpers/refresh-transport.helper'; +import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; +import type { AccountRole } from '@/global/auth/types/jwt-payload.type'; + +const ROLES: AccountRole[] = ['USER', 'SELLER', 'ADMIN']; +const HEADERS: [string, string | undefined][] = [ + ['mobile', 'mobile'], + ['Mobile (대소문자·공백)', ' Mobile '], + ['없음', undefined], + ['web', 'web'], +]; +const BODY_TOKEN = 'b'.repeat(64); +const COOKIE_TOKEN = 'c'.repeat(64); +const BODIES: [string, unknown][] = [ + ['있음', BODY_TOKEN], + ['빈 문자열', ''], + ['없음', undefined], +]; +const COOKIES: [string, boolean][] = [ + ['있음', true], + ['없음', false], +]; + +function reqOf(args: { + role: AccountRole; + header?: string; + body?: unknown; + cookie?: boolean; +}): Request { + return { + headers: args.header === undefined ? {} : { 'x-client': args.header }, + body: args.body === undefined ? {} : { refreshToken: args.body }, + cookies: args.cookie ? { [REFRESH_COOKIE[args.role]]: COOKIE_TOKEN } : {}, + } as unknown as Request; +} + +describe('refresh-transport.helper', () => { + it('바디 전달 허용 역할은 SELLER뿐이다', () => { + expect([...BODY_TRANSPORT_ROLES]).toEqual(['SELLER']); + }); + + describe('loginTransportOf — 역할 × X-Client 헤더 전수', () => { + it.each( + ROLES.flatMap((role) => + HEADERS.map( + ([label, header]): [ + AccountRole, + string, + string | undefined, + string, + ] => [ + role, + label, + header, + role === 'SELLER' && header?.trim().toLowerCase() === 'mobile' + ? 'body' + : 'cookie', + ], + ), + ), + )('%s + 헤더 %s → %s', (role, _label, header, expected) => { + expect(loginTransportOf(role, reqOf({ role, header }))).toBe(expected); + }); + + it('헤더가 배열이면 첫 값으로 판정한다', () => { + const req = { + headers: { 'x-client': ['mobile', 'web'] }, + } as unknown as Request; + expect(loginTransportOf('SELLER', req)).toBe('body'); + }); + + it('headers가 없는 요청은 쿠키 모드', () => { + expect(loginTransportOf('SELLER', {} as Request)).toBe('cookie'); + }); + }); + + describe('readPresentedRefreshToken — 역할 × 바디 × 쿠키 전수', () => { + it.each( + ROLES.flatMap((role) => + BODIES.flatMap(([bodyLabel, body]) => + COOKIES.map( + ([cookieLabel, cookie]): [ + AccountRole, + string, + string, + unknown, + boolean, + { token: string; transport: string } | undefined, + ] => [ + role, + bodyLabel, + cookieLabel, + body, + cookie, + role === 'SELLER' && body === BODY_TOKEN + ? { token: BODY_TOKEN, transport: 'body' } + : cookie + ? { token: COOKIE_TOKEN, transport: 'cookie' } + : undefined, + ], + ), + ), + ), + )( + '%s + 바디 %s + 쿠키 %s → %o', + (role, _bodyLabel, _cookieLabel, body, cookie, expected) => { + expect( + readPresentedRefreshToken(role, reqOf({ role, body, cookie })), + ).toEqual(expected); + }, + ); + + it.each([ + ['숫자', 123], + ['객체', { token: BODY_TOKEN }], + ['배열', [BODY_TOKEN]], + ['null', null], + ])( + '바디 refreshToken이 문자열이 아니면(%s) 무시하고 쿠키를 읽는다', + (_label, body) => { + const req = reqOf({ role: 'SELLER', body, cookie: true }); + expect(readPresentedRefreshToken('SELLER', req)).toEqual({ + token: COOKIE_TOKEN, + transport: 'cookie', + }); + }, + ); + + it('다른 역할의 쿠키는 읽지 않는다', () => { + const req = { + body: {}, + cookies: { [REFRESH_COOKIE.ADMIN]: COOKIE_TOKEN }, + } as unknown as Request; + expect(readPresentedRefreshToken('SELLER', req)).toBeUndefined(); + }); + + it('body·cookies가 없는 요청은 undefined', () => { + expect( + readPresentedRefreshToken('SELLER', {} as Request), + ).toBeUndefined(); + }); + }); +}); diff --git a/src/features/auth/helpers/refresh-transport.helper.ts b/src/features/auth/helpers/refresh-transport.helper.ts new file mode 100644 index 00000000..3d7371ac --- /dev/null +++ b/src/features/auth/helpers/refresh-transport.helper.ts @@ -0,0 +1,51 @@ +import type { Request } from 'express'; + +import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; +import type { AccountRole } from '@/global/auth/types/jwt-payload.type'; + +export type RefreshTransport = 'cookie' | 'body'; + +/** 바디 전달을 허용하는 역할. 구매자는 OIDC 302 콜백이라 바디에 실을 수 없고, 관리자는 모바일이 없다. */ +export const BODY_TRANSPORT_ROLES: ReadonlySet = + new Set(['SELLER']); + +export const CLIENT_HEADER = 'x-client'; +const MOBILE_CLIENT = 'mobile'; + +/** 로그인 응답의 전달 방식 — 허용 역할이 `X-Client: mobile`을 보낼 때만 바디. */ +export function loginTransportOf( + role: AccountRole, + req: Request, +): RefreshTransport { + if (!BODY_TRANSPORT_ROLES.has(role)) return 'cookie'; + const header = req.headers?.[CLIENT_HEADER]; + const value = Array.isArray(header) ? header[0] : header; + return typeof value === 'string' && + value.trim().toLowerCase() === MOBILE_CLIENT + ? 'body' + : 'cookie'; +} + +export interface PresentedRefreshToken { + token: string; + transport: RefreshTransport; +} + +/** + * refresh·logout이 받은 토큰과 그 출처. 허용 역할의 바디 `refreshToken`이 있으면 바디 모드이고 + * 쿠키는 읽지 않는다 — 웹뷰가 쿠키를 함께 보내도 앱 세션만 회전·폐기한다. + */ +export function readPresentedRefreshToken( + role: AccountRole, + req: Request, +): PresentedRefreshToken | undefined { + if (BODY_TRANSPORT_ROLES.has(role)) { + const token = (req.body as { refreshToken?: unknown } | undefined) + ?.refreshToken; + if (typeof token === 'string' && token) return { token, transport: 'body' }; + } + const cookie = req.cookies?.[REFRESH_COOKIE[role]] as unknown; + return typeof cookie === 'string' && cookie + ? { token: cookie, transport: 'cookie' } + : undefined; +} diff --git a/src/features/auth/repositories/account-admin.repository.ts b/src/features/auth/repositories/account-admin.repository.ts index 034a375b..5e6f99c5 100644 --- a/src/features/auth/repositories/account-admin.repository.ts +++ b/src/features/auth/repositories/account-admin.repository.ts @@ -92,6 +92,23 @@ const adminAccountInclude = { }, } as const; +/** 판매자 본인 조회(sellerMe)용 좁은 행. account_type·status 판정은 서비스가 한다. */ +export type SellerSelfRow = Prisma.AccountGetPayload<{ + select: typeof sellerSelfSelect; +}>; + +const sellerSelfSelect = { + id: true, + account_type: true, + status: true, + name: true, + credential: { + select: { username: true, must_change_password: true, deleted_at: true }, + }, + seller_profile: { select: { business_name: true, deleted_at: true } }, + store: { select: { id: true, deleted_at: true } }, +} as const; + /** 관리자가 다루는 계정(관리자·판매자·구매자)의 조회·생성·상태 변경. 자격증명·세션 write는 identity(auth)에만 있다. */ @Injectable() export class AccountAdminRepository { @@ -270,6 +287,14 @@ export class AccountAdminRepository { }); } + /** 타입 조건 없이 읽는다 — 비판매자 토큰을 SELLER_ONLY로 구분하기 위해. */ + async findSellerSelfById(accountId: bigint): Promise { + return this.prisma.account.findFirst({ + where: { id: accountId }, + select: sellerSelfSelect, + }); + } + /** * username 충돌(P2002)은 도메인 예외로 좁힌다(createAdminAccount와 같은 이유). * 매장 행은 같은 tx를 넘겨 호출자(catalog 코드)가 만든다 — identity는 store를 import하지 않고 tx만 조정한다(P1-7). diff --git a/src/features/auth/resolvers/auth-seller-account-query.resolver.ts b/src/features/auth/resolvers/auth-seller-account-query.resolver.ts new file mode 100644 index 00000000..52c8c52c --- /dev/null +++ b/src/features/auth/resolvers/auth-seller-account-query.resolver.ts @@ -0,0 +1,25 @@ +import { UseGuards } from '@nestjs/common'; +import { Query, Resolver } from '@nestjs/graphql'; + +import { SellerAccountService } from '@/features/auth/services/auth-seller-account.service'; +import type { SellerAccountOutput } from '@/features/auth/types/auth-seller-output.type'; +import { + CurrentUser, + JwtAuthGuard, + Roles, + RolesGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('SELLER') +export class SellerAccountQueryResolver { + constructor(private readonly accountService: SellerAccountService) {} + + @Query('sellerMe') + sellerMe(@CurrentUser() user: JwtUser): Promise { + return this.accountService.sellerMe(parseAccountId(user)); + } +} diff --git a/src/features/auth/resolvers/auth-seller-account.resolver.spec.ts b/src/features/auth/resolvers/auth-seller-account.resolver.spec.ts new file mode 100644 index 00000000..0066d65c --- /dev/null +++ b/src/features/auth/resolvers/auth-seller-account.resolver.spec.ts @@ -0,0 +1,83 @@ +// 분기 세부 검증은 service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로와 가드 계약만 본다. + +import type { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; +import { SellerAccountQueryResolver } from '@/features/auth/resolvers/auth-seller-account-query.resolver'; +import { SellerAccountService } from '@/features/auth/services/auth-seller-account.service'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { RolesGuard, type JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccountCredential, + setupSellerWithStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('Seller Account Resolvers (real DB)', () => { + let resolver: SellerAccountQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerAccountQueryResolver, + SellerAccountService, + AccountAdminRepository, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ], + }); + resolver = module.get(SellerAccountQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('Query.sellerMe: 본인 판매자 계정과 매장 ID를 반환한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const credential = await createAccountCredential(prisma, { + account_id: account.id, + username: 'me.seller', + }); + + const result = await resolver.sellerMe({ + accountId: account.id.toString(), + accountType: 'SELLER', + }); + + expect(result.accountId).toBe(account.id.toString()); + expect(result.username).toBe(credential.username); + expect(result.storeId).toBe(store.id.toString()); + }); + + it('초기 비밀번호 상태의 판매자는 RolesGuard가 PASSWORD_CHANGE_REQUIRED로 막는다', () => { + const user: JwtUser = { + accountId: '1', + accountType: 'SELLER', + mustChangePassword: true, + }; + const gqlArgs = [undefined, {}, { req: { user } }, {}]; + const ctx = { + getType: () => 'graphql', + getArgs: () => gqlArgs, + getArgByIndex: (i: number) => gqlArgs[i], + getHandler: () => SellerAccountQueryResolver.prototype.sellerMe, + getClass: () => SellerAccountQueryResolver, + } as unknown as ExecutionContext; + + expect(() => + new RolesGuard(new Reflector()).canActivate(ctx), + ).toThrowDomain('PASSWORD_CHANGE_REQUIRED'); + }); +}); diff --git a/src/features/auth/services/auth-seller-account.service.spec.ts b/src/features/auth/services/auth-seller-account.service.spec.ts new file mode 100644 index 00000000..16ea08fe --- /dev/null +++ b/src/features/auth/services/auth-seller-account.service.spec.ts @@ -0,0 +1,185 @@ +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; +import { SellerAccountService } from '@/features/auth/services/auth-seller-account.service'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createAccountCredential, + createSellerProfile, + createStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SellerAccountService (real DB)', () => { + let service: SellerAccountService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerAccountService, + AccountAdminRepository, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ], + }); + service = module.get(SellerAccountService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function makeSeller( + overrides: { + name?: string | null; + status?: 'ACTIVE' | 'SUSPENDED'; + mustChangePassword?: boolean; + } = {}, + ) { + const account = await createAccount(prisma, { + account_type: 'SELLER', + name: overrides.name, + status: overrides.status, + }); + const credential = await createAccountCredential(prisma, { + account_id: account.id, + must_change_password: overrides.mustChangePassword, + }); + await createSellerProfile(prisma, { + account_id: account.id, + business_name: '케이베이커리', + }); + return { account, credential }; + } + + describe('sellerMe', () => { + it('자격증명·프로필·매장을 합쳐 전 필드를 반환한다', async () => { + const { account, credential } = await makeSeller({ name: '김사장' }); + const store = await createStore(prisma, { + seller_account_id: account.id, + }); + + const result = await service.sellerMe(account.id); + + expect(result).toEqual({ + accountId: account.id.toString(), + username: credential.username, + displayName: '김사장', + storeId: store.id.toString(), + mustChangePassword: false, + accountStatus: 'ACTIVE', + }); + }); + + it('계정 이름이 없으면 사업자명을 displayName으로 쓴다', async () => { + const { account } = await makeSeller({ name: null }); + + const result = await service.sellerMe(account.id); + + expect(result.displayName).toBe('케이베이커리'); + }); + + it('계정 이름도 프로필도 없으면 displayName은 null이다', async () => { + const account = await createAccount(prisma, { + account_type: 'SELLER', + name: null, + }); + + const result = await service.sellerMe(account.id); + + expect(result.displayName).toBeNull(); + expect(result.username).toBeNull(); + }); + + it('삭제된 자격증명은 없는 것으로 본다', async () => { + const { account, credential } = await makeSeller({ + mustChangePassword: true, + }); + await prisma.accountCredential.update({ + where: { id: credential.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.sellerMe(account.id); + + expect(result.username).toBeNull(); + expect(result.mustChangePassword).toBe(false); + }); + + it('삭제된 매장은 storeId null로 답한다', async () => { + const { account } = await makeSeller(); + await createStore(prisma, { + seller_account_id: account.id, + deleted_at: new Date(), + }); + + const result = await service.sellerMe(account.id); + + expect(result.storeId).toBeNull(); + }); + + it('매장이 없어도 STORE_NOT_FOUND가 아니라 storeId null로 답한다', async () => { + const { account } = await makeSeller(); + + const result = await service.sellerMe(account.id); + + expect(result.storeId).toBeNull(); + expect(result.accountId).toBe(account.id.toString()); + }); + + it('초기 비밀번호 상태도 서비스는 답한다(차단은 RolesGuard 몫)', async () => { + const { account } = await makeSeller({ mustChangePassword: true }); + + const result = await service.sellerMe(account.id); + + expect(result.mustChangePassword).toBe(true); + }); + + it.each(['USER', 'ADMIN'] as const)( + '%s 계정이면 SELLER_ONLY', + async (accountType) => { + const account = await createAccount(prisma, { + account_type: accountType, + }); + + await expect(service.sellerMe(account.id)).rejects.toThrowDomain( + 'SELLER_ONLY', + ); + }, + ); + + it('정지된 계정이면 ACCOUNT_NOT_ACTIVE', async () => { + const { account } = await makeSeller({ status: 'SUSPENDED' }); + + await expect(service.sellerMe(account.id)).rejects.toThrowDomain( + 'ACCOUNT_NOT_ACTIVE', + ); + }); + + it('없는 계정이면 SESSION_ACCOUNT_MISSING', async () => { + await expect(service.sellerMe(BigInt(999_999))).rejects.toThrowDomain( + 'SESSION_ACCOUNT_MISSING', + ); + }); + + it('탈퇴(soft-delete)한 계정이면 SESSION_ACCOUNT_MISSING', async () => { + const account = await createAccount(prisma, { + account_type: 'SELLER', + deleted_at: new Date(), + }); + + await expect(service.sellerMe(account.id)).rejects.toThrowDomain( + 'SESSION_ACCOUNT_MISSING', + ); + }); + }); +}); diff --git a/src/features/auth/services/auth-seller-account.service.ts b/src/features/auth/services/auth-seller-account.service.ts new file mode 100644 index 00000000..8aeb8af4 --- /dev/null +++ b/src/features/auth/services/auth-seller-account.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; +import { toSellerAccountOutput } from '@/features/auth/services/auth-seller-mappers.helper'; +import type { SellerAccountOutput } from '@/features/auth/types/auth-seller-output.type'; +import { AccountType } from '@/generated/prisma/client'; + +/** RolesGuard가 1차로 막고, 여기서 DB 기준으로 타입·상태를 한 번 더 확인한다(adminMe와 같은 역할). */ +@Injectable() +export class SellerAccountService { + constructor(private readonly accounts: AccountAdminRepository) {} + + async sellerMe(accountId: bigint): Promise { + const row = await this.accounts.findSellerSelfById(accountId); + if (!row) throw new DomainException('SESSION_ACCOUNT_MISSING'); + if (row.account_type !== AccountType.SELLER) { + throw new DomainException('SELLER_ONLY'); + } + if (row.status !== 'ACTIVE') { + throw new DomainException('ACCOUNT_NOT_ACTIVE'); + } + // 매장이 없어도 계정 정보는 답한다(storeId null) — requireSellerContext의 STORE_NOT_FOUND와 다른 점 + return toSellerAccountOutput(row); + } +} diff --git a/src/features/auth/services/auth-seller-mappers.helper.ts b/src/features/auth/services/auth-seller-mappers.helper.ts new file mode 100644 index 00000000..427ddd3f --- /dev/null +++ b/src/features/auth/services/auth-seller-mappers.helper.ts @@ -0,0 +1,17 @@ +import { activeOrNull } from '@/common/utils/active-or-null'; +import type { SellerSelfRow } from '@/features/auth/repositories/account-admin.repository'; +import type { SellerAccountOutput } from '@/features/auth/types/auth-seller-output.type'; + +/** nested relation은 soft-delete 자동 필터 밖이라 deleted_at을 직접 본다. */ +export function toSellerAccountOutput(row: SellerSelfRow): SellerAccountOutput { + const credential = activeOrNull(row.credential); + return { + accountId: row.id.toString(), + username: credential?.username ?? null, + displayName: + row.name ?? activeOrNull(row.seller_profile)?.business_name ?? null, + storeId: activeOrNull(row.store)?.id.toString() ?? null, + mustChangePassword: credential?.must_change_password ?? false, + accountStatus: row.status, + }; +} diff --git a/src/features/auth/services/credential-auth.service.spec.ts b/src/features/auth/services/credential-auth.service.spec.ts index de9fdec1..e850b23f 100644 --- a/src/features/auth/services/credential-auth.service.spec.ts +++ b/src/features/auth/services/credential-auth.service.spec.ts @@ -5,6 +5,7 @@ import argon2 from 'argon2'; import type { Request, Response } from 'express'; import { ClockService } from '@/common/providers/clock.service'; +import { sha256Hex } from '@/common/utils/crypto'; import { AUDIT_LOG_REPOSITORY, type IAuditLogRepository, @@ -27,7 +28,7 @@ import { TokenService } from '@/features/auth/services/token.service'; import { AccountType } from '@/generated/prisma/client'; import { TokenBlacklistService } from '@/global/auth'; import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; -import { TEST_AUTH_CONFIG } from '@/test/auth-config'; +import { TEST_AUTH_CONFIG, testAuthConfig } from '@/test/auth-config'; function makeCredential( overrides: Partial & { @@ -180,7 +181,7 @@ describe('CredentialAuthService', () => { }); it.each(['SELLER', 'ADMIN'])( - '%s 로그인 성공 시 accessToken·accountStatus·mustChangePassword를 반환한다', + '%s 로그인 성공 시 accessToken·expiresInSeconds·accountStatus·mustChangePassword를 반환한다', async (role) => { jest.spyOn(argon2, 'verify').mockResolvedValue(true); credentials.findCredentialByUsername.mockResolvedValue( @@ -200,6 +201,7 @@ describe('CredentialAuthService', () => { const result = await login({ role }); expect(result.accessToken).toBe('mock-access-token'); + expect(result.expiresInSeconds).toBe(900); expect(result.accountStatus).toBe('ACTIVE'); expect(result.mustChangePassword).toBe(true); expect(credentials.updateLastLogin).toHaveBeenCalledWith( @@ -209,6 +211,18 @@ describe('CredentialAuthService', () => { }, ); + it('expiresInSeconds는 authConfig의 jwtAccessExpiresSeconds를 그대로 싣는다', async () => { + jest.spyOn(argon2, 'verify').mockResolvedValue(true); + credentials.findCredentialByUsername.mockResolvedValue(makeCredential()); + mockConfig.getOrThrow.mockReturnValue( + testAuthConfig({ jwtAccessExpiresSeconds: 60 }), + ); + + const result = await login(); + + expect(result.expiresInSeconds).toBe(60); + }); + it.each([ ['username 공백', { username: ' ' }], ['password 빈 문자열', { password: '' }], @@ -296,11 +310,28 @@ describe('CredentialAuthService', () => { expect(rotate).toHaveBeenCalledWith('SELLER', reqWithCookie, mockRes); expect(result).toEqual({ accessToken: 'rotated', + expiresInSeconds: 900, accountStatus: 'ACTIVE', mustChangePassword: true, }); }); + it('expiresInSeconds는 authConfig의 jwtAccessExpiresSeconds를 그대로 싣는다', async () => { + refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue(session); + credentials.findCredentialByAccountId.mockResolvedValue(makeCredential()); + mockConfig.getOrThrow.mockReturnValue( + testAuthConfig({ jwtAccessExpiresSeconds: 60 }), + ); + + const result = await service.refresh({ + role: 'SELLER', + req: reqWithCookie, + res: mockRes, + }); + + expect(result.expiresInSeconds).toBe(60); + }); + it('refresh 쿠키가 없으면 회전 없이 MISSING_REFRESH_TOKEN', async () => { await expect( service.refresh({ role: 'SELLER', req: mockReq, res: mockRes }), @@ -384,6 +415,176 @@ describe('CredentialAuthService', () => { }); }); + describe('바디 전달(판매자 앱)', () => { + const BODY_TOKEN = 'b'.repeat(64); + const COOKIE_TOKEN = 'c'.repeat(64); + const session = { id: BigInt(77), account_id: BigInt(10) } as never; + const reqOf = (args: { + header?: string; + body?: string; + cookie?: CredentialRole; + }) => + ({ + headers: { + 'user-agent': 'app', + ...(args.header && { 'x-client': args.header }), + }, + ip: '127.0.0.1', + body: args.body === undefined ? {} : { refreshToken: args.body }, + cookies: args.cookie + ? { [REFRESH_COOKIE[args.cookie]]: COOKIE_TOKEN } + : {}, + }) as unknown as Request; + + beforeEach(() => { + (mockRes.cookie as jest.Mock).mockClear(); + (mockRes.clearCookie as jest.Mock).mockClear(); + }); + + it('판매자 로그인에 X-Client: mobile이면 쿠키 없이 refreshToken·refreshExpiresAt을 돌려준다', async () => { + jest.spyOn(argon2, 'verify').mockResolvedValue(true); + credentials.findCredentialByUsername.mockResolvedValue(makeCredential()); + + const result = await service.login({ + role: 'SELLER', + username: 'seller01', + password: 'Password!123', + req: reqOf({ header: 'mobile' }), + res: mockRes, + }); + + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(result.refreshToken).toMatch(/^[0-9a-f]{64}$/); + expect(result.refreshExpiresAt).toBeInstanceOf(Date); + expect(refreshSessions.createRefreshSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenHash: sha256Hex(result.refreshToken!), + }), + ); + }); + + it.each([ + ['판매자 헤더 없음', 'SELLER' as const, undefined], + ['판매자 X-Client: web', 'SELLER' as const, 'web'], + ['관리자 X-Client: mobile', 'ADMIN' as const, 'mobile'], + ])( + '%s — 쿠키를 굽고 refreshToken 키가 없다', + async (_label, role, header) => { + jest.spyOn(argon2, 'verify').mockResolvedValue(true); + credentials.findCredentialByUsername.mockResolvedValue( + makeCredential({ accountType: role }), + ); + + const result = await service.login({ + role, + username: 'seller01', + password: 'Password!123', + req: reqOf({ header }), + res: mockRes, + }); + + expect(mockRes.cookie).toHaveBeenCalledTimes(1); + expect(result).not.toHaveProperty('refreshToken'); + expect(result).not.toHaveProperty('refreshExpiresAt'); + }, + ); + + it('바디 토큰으로 재발급하면 회전 결과의 refreshToken·refreshExpiresAt을 싣는다', async () => { + const expiresAt = new Date('2026-11-04T00:00:00Z'); + const rotate = jest + .spyOn(TokenService.prototype, 'rotateRefresh') + .mockResolvedValue({ + accessToken: 'rotated', + accountId: BigInt(10), + refreshToken: 'n'.repeat(64), + refreshExpiresAt: expiresAt, + }); + refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue(session); + credentials.findCredentialByAccountId.mockResolvedValue(makeCredential()); + const req = reqOf({ body: BODY_TOKEN }); + + const result = await service.refresh({ + role: 'SELLER', + req, + res: mockRes, + }); + + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledWith(sha256Hex(BODY_TOKEN)); + expect(rotate).toHaveBeenCalledWith('SELLER', req, mockRes); + expect(result).toEqual({ + accessToken: 'rotated', + expiresInSeconds: 900, + accountStatus: 'ACTIVE', + mustChangePassword: false, + refreshToken: 'n'.repeat(64), + refreshExpiresAt: expiresAt, + }); + }); + + it('바디와 쿠키가 함께 오면 바디 토큰의 세션을 확인한다', async () => { + jest + .spyOn(TokenService.prototype, 'rotateRefresh') + .mockResolvedValue({ accessToken: 'rotated', accountId: BigInt(10) }); + refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue(session); + credentials.findCredentialByAccountId.mockResolvedValue(makeCredential()); + + await service.refresh({ + role: 'SELLER', + req: reqOf({ body: BODY_TOKEN, cookie: 'SELLER' }), + res: mockRes, + }); + + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledTimes(1); + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledWith(sha256Hex(BODY_TOKEN)); + }); + + it('바디 토큰으로 로그아웃하면 세션만 폐기하고 쿠키는 지우지 않는다', async () => { + refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue(session); + credentials.findCredentialByAccountId.mockResolvedValue(makeCredential()); + + await service.logout({ + role: 'SELLER', + req: reqOf({ body: BODY_TOKEN }), + res: mockRes, + }); + + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledWith(sha256Hex(BODY_TOKEN)); + expect(refreshSessions.revokeRefreshSession).toHaveBeenCalledWith( + BigInt(77), + ); + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'refresh', + (req: Request) => service.refresh({ role: 'ADMIN', req, res: mockRes }), + ], + [ + 'logout', + (req: Request) => service.logout({ role: 'ADMIN', req, res: mockRes }), + ], + ])( + '관리자 %s는 바디 토큰을 무시한다 — 쿠키가 없으면 MISSING_REFRESH_TOKEN', + async (_label, call) => { + await expect(call(reqOf({ body: BODY_TOKEN }))).rejects.toThrowDomain( + 'MISSING_REFRESH_TOKEN', + ); + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).not.toHaveBeenCalled(); + }, + ); + }); + describe('changePassword', () => { const change = ( overrides: Partial<{ diff --git a/src/features/auth/services/credential-auth.service.ts b/src/features/auth/services/credential-auth.service.ts index ce34a354..de46f6ae 100644 --- a/src/features/auth/services/credential-auth.service.ts +++ b/src/features/auth/services/credential-auth.service.ts @@ -9,6 +9,11 @@ import { AUDIT_LOG_REPOSITORY, type IAuditLogRepository, } from '@/features/audit-log'; +import { + loginTransportOf, + readPresentedRefreshToken, + type RefreshTransport, +} from '@/features/auth/helpers/refresh-transport.helper'; import { ACCOUNT_CREDENTIAL_REPOSITORY, type AccountCredentialWithAccount, @@ -18,7 +23,10 @@ import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, } from '@/features/auth/repositories/refresh-session.repository.interface'; -import { TokenService } from '@/features/auth/services/token.service'; +import { + type IssuedTokens, + TokenService, +} from '@/features/auth/services/token.service'; import { type AccountStatus, AuditActionType, @@ -30,9 +38,14 @@ export type CredentialRole = Exclude; export interface CredentialLoginResult { accessToken: string; + /** 액세스 토큰 TTL(초) — 클라이언트가 JWT를 디코드하지 않고 선제 refresh 시점을 잡는다. */ + expiresInSeconds: number; accountStatus: AccountStatus; /** 관리자가 지정한 초기/초기화 비밀번호 상태. true면 변경 전까지 다른 API가 거부된다. */ mustChangePassword: boolean; + /** 바디 전달(판매자 앱)일 때만. 쿠키 전달은 Set-Cookie로 나간다. */ + refreshToken?: string; + refreshExpiresAt?: Date; } /** @@ -88,14 +101,15 @@ export class CredentialAuthService { await this.credentials.updateLastLogin(credential.account_id, now); // 버전은 검증한 행의 것 — 검증 뒤 커밋된 변경이 있으면 이 세션·토큰은 버전이 낡아 막힌다 - const { accessToken } = await this.tokens.issueAuthTokens({ + const issued = await this.tokens.issueAuthTokens({ accountId: credential.account_id, credentialVersion: credential.password_updated_at, req: args.req, res: args.res, + transport: loginTransportOf(args.role, args.req), }); - return this.toResult(accessToken, credential); + return this.toResult(issued, credential); } async refresh(args: { @@ -109,12 +123,12 @@ export class CredentialAuthService { args.role, args.req, ); - const { accessToken } = await this.tokens.rotateRefresh( + const rotated = await this.tokens.rotateRefresh( args.role, args.req, args.res, ); - return this.toResult(accessToken, credential); + return this.toResult(rotated, credential); } async logout(args: { @@ -122,12 +136,15 @@ export class CredentialAuthService { req: Request; res: Response; }): Promise { - const { session } = await this.requireSessionCredential( + const { session, transport } = await this.requireSessionCredential( args.role, args.req, ); await this.refreshSessions.revokeRefreshSession(session.id); - this.tokens.clearRefreshCookie(args.role, args.res); + // 바디 모드는 Set-Cookie를 내지 않는다 — 앱 저장소의 토큰만 폐기한 것이다 + if (transport === 'cookie') { + this.tokens.clearRefreshCookie(args.role, args.res); + } } async changePassword(args: { @@ -201,14 +218,15 @@ export class CredentialAuthService { ): Promise<{ session: { id: bigint; account_id: bigint }; credential: AccountCredentialWithAccount; + transport: RefreshTransport; }> { - const refreshToken = this.tokens.readRefreshCookie(role, req); - if (!refreshToken) { + const presented = readPresentedRefreshToken(role, req); + if (!presented) { throw new DomainException('MISSING_REFRESH_TOKEN'); } const session = await this.refreshSessions.findActiveRefreshSessionByHash( - this.tokens.sha256Hex(refreshToken), + this.tokens.sha256Hex(presented.token), ); if (!session) { throw new DomainException('INVALID_REFRESH_TOKEN'); @@ -220,17 +238,22 @@ export class CredentialAuthService { if (!credential || credential.account.account_type !== role) { throw new DomainException('INVALID_REFRESH_TOKEN'); } - return { session, credential }; + return { session, credential, transport: presented.transport }; } private toResult( - accessToken: string, + issued: IssuedTokens, credential: AccountCredentialWithAccount, ): CredentialLoginResult { return { - accessToken, + accessToken: issued.accessToken, + expiresInSeconds: this.tokens.getAccessExpiresSeconds(), accountStatus: credential.account.status, mustChangePassword: credential.must_change_password, + ...(issued.refreshToken !== undefined && { + refreshToken: issued.refreshToken, + refreshExpiresAt: issued.refreshExpiresAt, + }), }; } } diff --git a/src/features/auth/services/refresh-cookie.service.spec.ts b/src/features/auth/services/refresh-cookie.service.spec.ts index 9953e9f1..270378e8 100644 --- a/src/features/auth/services/refresh-cookie.service.spec.ts +++ b/src/features/auth/services/refresh-cookie.service.spec.ts @@ -240,6 +240,130 @@ describe('역할별 refresh 쿠키 (real DB)', () => { }); }); + describe('판매자 바디 전달(앱)', () => { + function reqWithBody( + refreshToken: string, + cookies: Record = {}, + ): Request { + return { + body: { refreshToken }, + cookies, + headers: {}, + ip: '127.0.0.1', + } as unknown as Request; + } + + async function mobileLogin() { + const { account_id } = await createAccountCredential(prisma, { + account_type: 'SELLER', + }); + const { res, set } = jar(); + const issued = await tokens.issueAuthTokens({ + accountId: account_id, + credentialVersion: null, + req: reqWith({}), + res, + transport: 'body', + }); + expect(set).toEqual({}); + return { accountId: account_id, refreshToken: issued.refreshToken! }; + } + + async function sessionIdOf(raw: string): Promise { + const session = await prisma.authRefreshSession.findFirstOrThrow({ + where: { token_hash: sha256Hex(raw) }, + }); + return session.id; + } + + it('바디 로그인 → 바디 재발급 → 구 토큰 거절 → 바디 로그아웃까지 쿠키를 굽지도 지우지도 않는다', async () => { + const { refreshToken: first } = await mobileLogin(); + const firstId = await sessionIdOf(first); + + const refreshed = jar(); + const result = await credentialAuth.refresh({ + role: 'SELLER', + req: reqWithBody(first), + res: refreshed.res, + }); + expect(refreshed.set).toEqual({}); + expect(result.refreshToken).toMatch(/^[0-9a-f]{64}$/); + expect(result.refreshToken).not.toBe(first); + expect(await isRevoked(firstId)).toBe(true); + const secondId = await sessionIdOf(result.refreshToken!); + const second = await prisma.authRefreshSession.findUniqueOrThrow({ + where: { id: secondId }, + }); + expect(result.refreshExpiresAt).toEqual(second.expires_at); + + await expect( + credentialAuth.refresh({ + role: 'SELLER', + req: reqWithBody(first), + res: jar().res, + }), + ).rejects.toThrowDomain('INVALID_REFRESH_TOKEN'); + expect(await isRevoked(secondId)).toBe(false); + + const loggedOut = jar(); + await credentialAuth.logout({ + role: 'SELLER', + req: reqWithBody(result.refreshToken!), + res: loggedOut.res, + }); + expect(loggedOut.cleared).toEqual([]); + expect(await isRevoked(secondId)).toBe(true); + + await expect( + credentialAuth.logout({ + role: 'SELLER', + req: reqWithBody(result.refreshToken!), + res: jar().res, + }), + ).rejects.toThrowDomain('INVALID_REFRESH_TOKEN'); + }); + + it('같은 계정의 웹(쿠키)·앱(바디) 세션은 각자 자기 세션만 회전한다', async () => { + const { accountId, refreshToken: appToken } = await mobileLogin(); + const web = jar(); + await tokens.issueAuthTokens({ + accountId, + credentialVersion: null, + req: reqWith({}), + res: web.res, + }); + const webToken = web.set[COOKIE.SELLER]; + const appId = await sessionIdOf(appToken); + const webId = await sessionIdOf(webToken); + + // 바디 + 쿠키 동시 — 바디 세션만 회전 + const appRefresh = jar(); + const rotatedApp = await credentialAuth.refresh({ + role: 'SELLER', + req: reqWithBody(appToken, { [COOKIE.SELLER]: webToken }), + res: appRefresh.res, + }); + expect(appRefresh.set).toEqual({}); + expect(rotatedApp.refreshToken).toBeDefined(); + expect(await isRevoked(appId)).toBe(true); + expect(await isRevoked(webId)).toBe(false); + + // 쿠키만 — 웹 세션 회전, 응답은 쿠키 + const webRefresh = jar(); + const rotatedWeb = await credentialAuth.refresh({ + role: 'SELLER', + req: reqWith({ [COOKIE.SELLER]: webToken }), + res: webRefresh.res, + }); + expect(Object.keys(webRefresh.set)).toEqual([COOKIE.SELLER]); + expect(rotatedWeb).not.toHaveProperty('refreshToken'); + expect(await isRevoked(webId)).toBe(true); + expect(await isRevoked(await sessionIdOf(rotatedApp.refreshToken!))).toBe( + false, + ); + }); + }); + it('세 역할 쿠키가 함께 있어도 각자 자기 세션만 회전한다', async () => { const sessions = { USER: await login('USER'), diff --git a/src/features/auth/services/token.service.spec.ts b/src/features/auth/services/token.service.spec.ts index b0735807..f75dd3d1 100644 --- a/src/features/auth/services/token.service.spec.ts +++ b/src/features/auth/services/token.service.spec.ts @@ -3,12 +3,17 @@ import { JwtService } from '@nestjs/jwt'; import { Test, type TestingModule } from '@nestjs/testing'; import type { Request, Response } from 'express'; -import { ACCOUNT_REPOSITORY } from '@/features/auth/repositories/account.repository.interface'; +import { sha256Hex } from '@/common/utils/crypto'; +import { + ACCOUNT_REPOSITORY, + type IAccountRepository, +} from '@/features/auth/repositories/account.repository.interface'; import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, } from '@/features/auth/repositories/refresh-session.repository.interface'; import { TokenService } from '@/features/auth/services/token.service'; +import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; import { TEST_AUTH_CONFIG, testAuthConfig } from '@/test/auth-config'; describe('TokenService', () => { @@ -16,6 +21,7 @@ describe('TokenService', () => { let config: jest.Mocked; let jwt: jest.Mocked; let refreshSessions: jest.Mocked; + let accounts: jest.Mocked>; const mockReq = { headers: { 'user-agent': 'Mozilla/5.0 TokenSpec' }, @@ -47,6 +53,16 @@ describe('TokenService', () => { revokeAllRefreshSessions: jest.fn(), }; + accounts = { + findAccountForJwt: jest.fn().mockResolvedValue({ + id: BigInt(1), + status: 'ACTIVE', + account_type: 'USER', + credential: null, + store: null, + }), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ TokenService, @@ -56,18 +72,7 @@ describe('TokenService', () => { provide: REFRESH_SESSION_REPOSITORY, useValue: refreshSessions, }, - { - provide: ACCOUNT_REPOSITORY, - useValue: { - findAccountForJwt: jest.fn().mockResolvedValue({ - id: BigInt(1), - status: 'ACTIVE', - account_type: 'USER', - credential: null, - store: null, - }), - }, - }, + { provide: ACCOUNT_REPOSITORY, useValue: accounts }, ], }).compile(); @@ -196,6 +201,37 @@ describe('TokenService', () => { ); expect(mockRes.cookie).toHaveBeenCalledTimes(1); }); + + it('쿠키 전달(기본)은 반환에 refreshToken·refreshExpiresAt 키가 없다', async () => { + const result = await service.issueAuthTokens({ + accountId: BigInt(1), + credentialVersion: null, + req: mockReq, + res: mockRes, + transport: 'cookie', + }); + + expect(result).not.toHaveProperty('refreshToken'); + expect(result).not.toHaveProperty('refreshExpiresAt'); + expect(mockRes.cookie).toHaveBeenCalledTimes(1); + }); + + it('바디 전달은 쿠키를 굽지 않고 저장 해시와 맞는 refreshToken·세션 만료와 같은 refreshExpiresAt을 반환한다', async () => { + const result = await service.issueAuthTokens({ + accountId: BigInt(1), + credentialVersion: null, + req: mockReq, + res: mockRes, + transport: 'body', + }); + + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(result.accessToken).toBe('signed-token'); + expect(result.refreshToken).toMatch(/^[0-9a-f]{64}$/); + const created = refreshSessions.createRefreshSession.mock.calls[0][0]; + expect(created.tokenHash).toBe(sha256Hex(result.refreshToken!)); + expect(result.refreshExpiresAt).toEqual(created.expiresAt); + }); }); describe('rotateRefresh', () => { @@ -262,6 +298,98 @@ describe('TokenService', () => { }); }); + describe('rotateRefresh — 바디 모드', () => { + const BODY_TOKEN = 'b'.repeat(64); + const COOKIE_TOKEN = 'c'.repeat(64); + const sellerAccount = { + id: BigInt(10), + status: 'ACTIVE', + account_type: 'SELLER', + credential: { must_change_password: false, password_updated_at: null }, + store: null, + } as never; + + beforeEach(() => { + accounts.findAccountForJwt.mockResolvedValue(sellerAccount); + refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue({ + id: BigInt(7), + account_id: BigInt(10), + credential_version: null, + } as never); + refreshSessions.rotateRefreshSession.mockResolvedValue({} as never); + }); + + it('바디 토큰으로 회전하면 쿠키를 굽지 않고 새 refreshToken·refreshExpiresAt을 반환한다', async () => { + const req = { + body: { refreshToken: BODY_TOKEN }, + cookies: {}, + headers: {}, + } as unknown as Request; + + const result = await service.rotateRefresh('SELLER', req, mockRes); + + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledWith(sha256Hex(BODY_TOKEN)); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(result.accountId).toBe(BigInt(10)); + expect(result.refreshToken).toMatch(/^[0-9a-f]{64}$/); + expect(result.refreshToken).not.toBe(BODY_TOKEN); + const rotated = refreshSessions.rotateRefreshSession.mock.calls[0][0]; + expect(rotated.newTokenHash).toBe(sha256Hex(result.refreshToken!)); + expect(result.refreshExpiresAt).toEqual(rotated.newExpiresAt); + }); + + it('바디와 쿠키가 함께 오면 바디 세션만 회전한다 — 쿠키는 읽지도 굽지도 않는다', async () => { + const req = { + body: { refreshToken: BODY_TOKEN }, + cookies: { [REFRESH_COOKIE.SELLER]: COOKIE_TOKEN }, + headers: {}, + } as unknown as Request; + + await service.rotateRefresh('SELLER', req, mockRes); + + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledTimes(1); + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).toHaveBeenCalledWith(sha256Hex(BODY_TOKEN)); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('쿠키 모드 회전은 반환에 refreshToken 키가 없다', async () => { + const req = { + body: {}, + cookies: { [REFRESH_COOKIE.SELLER]: COOKIE_TOKEN }, + headers: {}, + } as unknown as Request; + + const result = await service.rotateRefresh('SELLER', req, mockRes); + + expect(result).not.toHaveProperty('refreshToken'); + expect(mockRes.cookie).toHaveBeenCalledTimes(1); + }); + + it.each(['USER', 'ADMIN'] as const)( + '%s는 바디 토큰을 무시한다 — 쿠키가 없으면 MISSING_REFRESH_TOKEN', + async (role) => { + const req = { + body: { refreshToken: BODY_TOKEN }, + cookies: {}, + headers: {}, + } as unknown as Request; + + await expect( + service.rotateRefresh(role, req, mockRes), + ).rejects.toThrowDomain('MISSING_REFRESH_TOKEN'); + expect( + refreshSessions.findActiveRefreshSessionByHash, + ).not.toHaveBeenCalled(); + }, + ); + }); + describe('clearRefreshCookie', () => { it('refresh 쿠키를 삭제한다', () => { config.get.mockReturnValue(undefined); diff --git a/src/features/auth/services/token.service.ts b/src/features/auth/services/token.service.ts index cdf70cc6..da942ebb 100644 --- a/src/features/auth/services/token.service.ts +++ b/src/features/auth/services/token.service.ts @@ -12,6 +12,10 @@ import { tryClientIp, tryUserAgent } from '@/common/utils/http-meta'; import type { AuthConfig } from '@/config/auth.config'; import { AuthCookieOptions } from '@/features/auth/helpers/auth-cookie-options.helper'; import { AuthCookie } from '@/features/auth/helpers/auth-cookie.helper'; +import { + readPresentedRefreshToken, + type RefreshTransport, +} from '@/features/auth/helpers/refresh-transport.helper'; import { ACCOUNT_REPOSITORY, type AccountForJwt, @@ -22,12 +26,18 @@ import { type IRefreshSessionRepository, } from '@/features/auth/repositories/refresh-session.repository.interface'; import type { AuthRefreshSession } from '@/generated/prisma/client'; -import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; import type { AccessTokenClaims, AccountRole, } from '@/global/auth/types/jwt-payload.type'; +export interface IssuedTokens { + accessToken: string; + /** 바디 전달일 때만 — 쿠키 전달은 Set-Cookie로 나간다. */ + refreshToken?: string; + refreshExpiresAt?: Date; +} + @Injectable() export class TokenService { constructor( @@ -84,7 +94,8 @@ export class TokenService { credentialVersion: Date | null; req: Request; res: Response; - }): Promise<{ accessToken: string }> { + transport?: RefreshTransport; + }): Promise { const account = await this.requireActiveAccount(args.accountId); const accessToken = this.signAccessToken(account, args.credentialVersion); @@ -103,19 +114,42 @@ export class TokenService { credentialVersion: args.credentialVersion, }); - AuthCookie.setRefreshCookie(args.res, account.account_type, { - refreshToken, - refreshMaxAgeMs: refreshDays * 86400 * 1000, + return { + accessToken, + ...this.deliverRefresh(args.res, account.account_type, { + transport: args.transport ?? 'cookie', + refreshToken, + expiresAt, + refreshDays, + }), + }; + } + + /** 전달 방식은 세션 커밋 뒤 응답 조립에서만 갈린다 — 쿠키면 Set-Cookie, 바디면 반환값. */ + private deliverRefresh( + res: Response, + role: AccountRole, + args: { + transport: RefreshTransport; + refreshToken: string; + expiresAt: Date; + refreshDays: number; + }, + ): Pick { + if (args.transport === 'body') { + return { + refreshToken: args.refreshToken, + refreshExpiresAt: args.expiresAt, + }; + } + AuthCookie.setRefreshCookie(res, role, { + refreshToken: args.refreshToken, + refreshMaxAgeMs: args.refreshDays * 86400 * 1000, cookieDomain: AuthCookieOptions.getCookieDomain(this.config), secure: AuthCookieOptions.isCookieSecure(this.config), sameSite: AuthCookieOptions.getCookieSameSite(this.config), }); - - return { accessToken }; - } - - readRefreshCookie(role: AccountRole, req: Request): string | undefined { - return req.cookies?.[REFRESH_COOKIE[role]] as string | undefined; + return {}; } /** 이름이 나뉘기 전에 구운 쿠키에는 다른 역할의 세션이 들어 있다 — 그 세션은 회전·폐기하지 않는다. */ @@ -152,14 +186,14 @@ export class TokenService { role: AccountRole, req: Request, res: Response, - ): Promise<{ accessToken: string; accountId: bigint }> { - const refreshToken = this.readRefreshCookie(role, req); + ): Promise { + const presented = readPresentedRefreshToken(role, req); - if (!refreshToken) { + if (!presented) { throw new DomainException('MISSING_REFRESH_TOKEN'); } - const tokenHash = this.sha256Hex(refreshToken); + const tokenHash = this.sha256Hex(presented.token); const session = await this.refreshSessions.findActiveRefreshSessionByHash(tokenHash); if (!session) throw new DomainException('INVALID_REFRESH_TOKEN'); @@ -188,17 +222,15 @@ export class TokenService { session.credential_version, ); - AuthCookie.setRefreshCookie(res, role, { - refreshToken: newRefreshToken, - refreshMaxAgeMs: refreshDays * 86400 * 1000, - cookieDomain: AuthCookieOptions.getCookieDomain(this.config), - secure: AuthCookieOptions.isCookieSecure(this.config), - sameSite: AuthCookieOptions.getCookieSameSite(this.config), - }); - return { accessToken, accountId: session.account_id, + ...this.deliverRefresh(res, role, { + transport: presented.transport, + refreshToken: newRefreshToken, + expiresAt: newExpiresAt, + refreshDays, + }), }; } diff --git a/src/features/auth/types/auth-seller-output.type.ts b/src/features/auth/types/auth-seller-output.type.ts new file mode 100644 index 00000000..c59902e6 --- /dev/null +++ b/src/features/auth/types/auth-seller-output.type.ts @@ -0,0 +1,10 @@ +import type { AccountStatus } from '@/generated/prisma/client'; + +export interface SellerAccountOutput { + accountId: string; + username: string | null; + displayName: string | null; + storeId: string | null; + mustChangePassword: boolean; + accountStatus: AccountStatus; +} diff --git a/src/features/conversation/conversation-seller.graphql b/src/features/conversation/conversation-seller.graphql index 8978ae00..f08ced36 100644 --- a/src/features/conversation/conversation-seller.graphql +++ b/src/features/conversation/conversation-seller.graphql @@ -19,6 +19,13 @@ extend type Mutation { sellerSendConversationMessage( input: SellerSendConversationMessageInput! ): SellerConversationMessage! + """ + 대화방을 판매자가 읽었다고 표시한다. 호출 시점에 존재하는 가장 최근 메시지의 작성 시각까지 sellerLastReadAt을 + 전진시키며 과거로 되돌리지 않는다(반복·동시 호출 안전). 메시지가 없으면 변경 없이 현재 상태를 돌려준다. + 조회만으로는 읽음 처리되지 않으므로 앱이 상세 진입 시 호출한다. 판매자 답장은 서버가 마커를 함께 전진시킨다. + 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않거나 내 매장의 대화가 아니면 NOT_FOUND. + """ + sellerMarkConversationRead(conversationId: ID!): SellerConversation! } """ @@ -32,13 +39,30 @@ type SellerConversation { accountId: ID! storeId: ID! """ + 대화 상대인 구매자의 표시명. 대화 생성 시점 스냅샷이라 이후 닉네임 변경·탈퇴가 반영되지 않는다. + 스냅샷 도입 이전 대화 중 복원하지 못한 건은 null. + """ + buyerNickname: String + """ + 마지막 메시지 미리보기(HTML은 태그 제거). 메시지가 없으면 null. + """ + lastMessagePreview: String + """ 마지막 메시지 시각. 메시지가 없으면 null. 목록 정렬 기준은 이 값이 아니라 updated_at이다. """ lastMessageAt: DateTime """ - 구매자가 이 대화방을 마지막으로 읽은 시각(구매자 조회 시 전진). 판매자 읽음 마커는 없다. 읽은 적이 없으면 null. + 구매자가 이 대화방을 마지막으로 읽은 시각(구매자 조회 시 전진). 읽은 적이 없으면 null. """ lastReadAt: DateTime + """ + 판매자가 마지막으로 읽은 시각. sellerMarkConversationRead 호출과 판매자 답장이 전진시킨다. 읽은 적이 없으면 null. + """ + sellerLastReadAt: DateTime + """ + 판매자 기준 읽지 않은 구매자 메시지 수(senderType USER이고 sellerLastReadAt 이후 작성). 0이면 답변 필요 대상이 아니다. + """ + unreadCount: Int! updatedAt: DateTime! } diff --git a/src/features/conversation/conversation-subscription.graphql b/src/features/conversation/conversation-subscription.graphql index 8a5f21aa..1ad4fffb 100644 --- a/src/features/conversation/conversation-subscription.graphql +++ b/src/features/conversation/conversation-subscription.graphql @@ -36,13 +36,23 @@ type ConversationListUpdate { unreadCount: Int! } -"""판매자 대화 목록 갱신 이벤트. 도착 순서 비보장 — lastMessageAt 기준 폐기 규칙 동일.""" +""" +판매자 대화 목록 갱신 이벤트(구매자 메시지 도착·판매자 답장). 도착 순서 비보장 — (lastMessageAt, sellerLastReadAt) +사전식 비교로 폐기한다. 읽음 mutation은 이벤트를 내지 않으므로 로컬 읽음 처리 뒤 도착한 이벤트의 unreadCount는 +로컬 sellerLastReadAt이 더 크면 0으로 본다. +""" type SellerConversationListUpdate { conversationId: ID! """대화 상대인 구매자 계정 ID.""" accountId: ID! + """대화 상대 구매자 표시명(대화 생성 시점 스냅샷). 복원 불가 건은 null.""" + buyerNickname: String """마지막 메시지 미리보기(HTML은 태그 제거)""" lastMessagePreview: String - """마지막 메시지 시각. 폐기 규칙의 비교 키다.""" + """마지막 메시지 시각. 폐기 규칙의 1차 비교 키다.""" lastMessageAt: DateTime! + """이벤트 스냅샷 시점의 판매자 마지막 읽음 시각(폐기 규칙 비교용).""" + sellerLastReadAt: DateTime + """이벤트 시점의 판매자 기준 미읽음 구매자 메시지 수.""" + unreadCount: Int! } diff --git a/src/features/conversation/conversation.module.ts b/src/features/conversation/conversation.module.ts index 92b81e92..53532264 100644 --- a/src/features/conversation/conversation.module.ts +++ b/src/features/conversation/conversation.module.ts @@ -14,11 +14,13 @@ import { ConversationEventsService } from '@/features/conversation/services/conv import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; import { SellerConversationService } from '@/features/conversation/services/conversation-seller.service'; import { ConversationSubscriptionService } from '@/features/conversation/services/conversation-subscription.service'; +import { OutboxModule } from '@/features/outbox'; import { StoreModule } from '@/features/store'; @Module({ - // 판매자 컨텍스트·catalog 읽기 포트(StoreModule), 감사 기록(AuditLogModule), 구매자 상태 판정(AuthModule) - imports: [StoreModule, AuditLogModule, AuthModule], + // 판매자 컨텍스트·catalog 읽기 포트(StoreModule), 감사 기록(AuditLogModule), 구매자 상태 판정(AuthModule), + // 구매자 메시지 이벤트 발행(OutboxModule) + imports: [StoreModule, AuditLogModule, AuthModule, OutboxModule], providers: [ ConversationRepository, ConversationEventsService, diff --git a/src/features/conversation/events/conversation-buyer-message-sent.event.spec.ts b/src/features/conversation/events/conversation-buyer-message-sent.event.spec.ts new file mode 100644 index 00000000..c1691218 --- /dev/null +++ b/src/features/conversation/events/conversation-buyer-message-sent.event.spec.ts @@ -0,0 +1,105 @@ +import { + CONVERSATION_BUYER_MESSAGE_SENT, + conversationBuyerMessageSentEvent, + parseConversationBuyerMessageSentPayload, + toBuyerMessagePreview, +} from '@/features/conversation/events/conversation-buyer-message-sent.event'; +import type { Prisma } from '@/generated/prisma/client'; + +const CREATED_AT = new Date('2026-09-16T07:00:00.000Z'); + +const VALID = { + conversationId: '11', + storeId: '5', + buyerAccountId: '3', + messageId: '42', + preview: '픽업 시간 변경 가능한가요?', + messageCreatedAt: CREATED_AT.toISOString(), +}; + +describe('conversation.buyer_message_sent 이벤트', () => { + it('bigint·Date를 문자열로 싣고 conversation aggregate에 묶는다', () => { + const event = conversationBuyerMessageSentEvent({ + conversationId: 11n, + storeId: 5n, + buyerAccountId: 3n, + message: { + id: 42n, + body_format: 'TEXT', + body_text: VALID.preview, + body_html: null, + created_at: CREATED_AT, + }, + }); + + expect(event).toEqual({ + aggregateType: 'conversation', + aggregateId: '11', + eventType: CONVERSATION_BUYER_MESSAGE_SENT, + payload: VALID, + occurredAt: CREATED_AT, + actorAccountId: 3n, + }); + expect( + parseConversationBuyerMessageSentPayload( + event.payload as Prisma.JsonValue, + ), + ).toEqual(VALID); + }); + + it.each([ + ['TEXT 본문 그대로', 'TEXT', '안녕하세요', null, '안녕하세요'], + [ + 'HTML은 태그를 걷어낸다', + 'HTML', + null, + '

냉장보관시 최대 3일

', + '냉장보관시 최대 3일', + ], + [ + 'TEXT 본문 101자는 100자로 자른다', + 'TEXT', + 'a'.repeat(101), + null, + 'a'.repeat(100), + ], + [ + 'HTML은 태그 제거 뒤 자른다', + 'HTML', + null, + `

${'b'.repeat(150)}

`, + 'b'.repeat(100), + ], + ['본문 없으면 빈 문자열', 'HTML', null, null, ''], + ] as const)('preview: %s', (_label, format, text, html, expected) => { + expect( + toBuyerMessagePreview({ + body_format: format, + body_text: text, + body_html: html, + }), + ).toBe(expected); + }); + + it.each([ + ['null', null], + ['배열', [VALID]], + ['문자열', 'x'], + ['conversationId 누락', { ...VALID, conversationId: undefined }], + ['conversationId 숫자', { ...VALID, conversationId: 11 }], + ['storeId null', { ...VALID, storeId: null }], + ['buyerAccountId 숫자', { ...VALID, buyerAccountId: 3 }], + ['messageId 누락', { ...VALID, messageId: undefined }], + ['preview null', { ...VALID, preview: null }], + ['messageCreatedAt 숫자', { ...VALID, messageCreatedAt: 1_700_000_000 }], + ['messageCreatedAt 비ISO', { ...VALID, messageCreatedAt: 'yesterday' }], + ])('반증: %s payload는 던진다', (_label, payload) => { + expect(() => + parseConversationBuyerMessageSentPayload( + payload as Parameters< + typeof parseConversationBuyerMessageSentPayload + >[0], + ), + ).toThrow('payload 형식 오류'); + }); +}); diff --git a/src/features/conversation/events/conversation-buyer-message-sent.event.ts b/src/features/conversation/events/conversation-buyer-message-sent.event.ts new file mode 100644 index 00000000..8988fe36 --- /dev/null +++ b/src/features/conversation/events/conversation-buyer-message-sent.event.ts @@ -0,0 +1,92 @@ +import { toLastMessagePreview } from '@/features/conversation/services/conversation-center-mappers.helper'; +import type { OutboxEventInput } from '@/features/outbox'; +import type { ConversationBodyFormat, Prisma } from '@/generated/prisma/client'; + +/** 구매자 메시지 전송 이벤트(판매자 푸시 원천). 전송 호출 1회당 1건 — 인사말·FAQ 자동응답(STORE)은 대상이 아니다. */ +export const CONVERSATION_BUYER_MESSAGE_SENT = + 'conversation.buyer_message_sent'; + +/** 푸시 본문 한 줄 분량. HTML은 태그를 걷어낸 뒤 자른다. */ +export const BUYER_MESSAGE_PREVIEW_MAX_LENGTH = 100; + +export interface ConversationBuyerMessageSentPayload { + conversationId: string; + storeId: string; + buyerAccountId: string; + messageId: string; + preview: string; + /** ISO 8601 */ + messageCreatedAt: string; +} + +export function toBuyerMessagePreview(message: { + body_format: ConversationBodyFormat; + body_text: string | null; + body_html: string | null; +}): string { + return (toLastMessagePreview(message) ?? '').slice( + 0, + BUYER_MESSAGE_PREVIEW_MAX_LENGTH, + ); +} + +export function conversationBuyerMessageSentEvent(args: { + conversationId: bigint; + storeId: bigint; + buyerAccountId: bigint; + message: { + id: bigint; + body_format: ConversationBodyFormat; + body_text: string | null; + body_html: string | null; + created_at: Date; + }; +}): OutboxEventInput { + const payload: ConversationBuyerMessageSentPayload = { + conversationId: args.conversationId.toString(), + storeId: args.storeId.toString(), + buyerAccountId: args.buyerAccountId.toString(), + messageId: args.message.id.toString(), + preview: toBuyerMessagePreview(args.message), + messageCreatedAt: args.message.created_at.toISOString(), + }; + return { + aggregateType: 'conversation', + aggregateId: payload.conversationId, + eventType: CONVERSATION_BUYER_MESSAGE_SENT, + payload: { ...payload }, + occurredAt: args.message.created_at, + actorAccountId: args.buyerAccountId, + }; +} + +/** 소비자용 방어적 파싱 — 형태가 어긋난 payload는 던져서 재시도·FAILED로 드러낸다. */ +export function parseConversationBuyerMessageSentPayload( + value: Prisma.JsonValue, +): ConversationBuyerMessageSentPayload { + const p = value as Partial< + Record + >; + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + typeof p.conversationId !== 'string' || + typeof p.storeId !== 'string' || + typeof p.buyerAccountId !== 'string' || + typeof p.messageId !== 'string' || + typeof p.preview !== 'string' || + typeof p.messageCreatedAt !== 'string' || + Number.isNaN(Date.parse(p.messageCreatedAt)) + ) { + throw new Error(`${CONVERSATION_BUYER_MESSAGE_SENT} payload 형식 오류`); + } + return { + conversationId: p.conversationId, + storeId: p.storeId, + buyerAccountId: p.buyerAccountId, + messageId: p.messageId, + preview: p.preview, + messageCreatedAt: p.messageCreatedAt, + }; +} diff --git a/src/features/conversation/index.ts b/src/features/conversation/index.ts index 880928ad..fdaf7037 100644 --- a/src/features/conversation/index.ts +++ b/src/features/conversation/index.ts @@ -1,2 +1,9 @@ // cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). export { ConversationModule } from '@/features/conversation/conversation.module'; +export { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +// 구매자 메시지 전송 이벤트 계약(outbox). 판매자 푸시 소비자(notification)가 payload 스냅샷만 읽는다. +export { + CONVERSATION_BUYER_MESSAGE_SENT, + type ConversationBuyerMessageSentPayload, + parseConversationBuyerMessageSentPayload, +} from '@/features/conversation/events/conversation-buyer-message-sent.event'; diff --git a/src/features/conversation/repositories/conversation-buyer-nickname-snapshot.migration.spec.ts b/src/features/conversation/repositories/conversation-buyer-nickname-snapshot.migration.spec.ts new file mode 100644 index 00000000..763d8a2f --- /dev/null +++ b/src/features/conversation/repositories/conversation-buyer-nickname-snapshot.migration.spec.ts @@ -0,0 +1,111 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 판매자 읽음 마커 마이그레이션의 닉네임 백필 SQL을 그대로 실행해, 활성 프로필만 현재 닉네임으로 채워지는지 본다. +// 마이그레이션은 빈 DB에 적용되므로 여기서만 검증된다. +const MIGRATION = join( + __dirname, + '../../../../prisma/migrations/20261005135631_conversation_seller_read_marker/migration.sql', +); + +function backfillStatements(): string[] { + const sql = readFileSync(MIGRATION, 'utf8'); + const body = sql.slice( + sql.indexOf('-- backfill'), + sql.indexOf('-- end backfill'), + ); + return body + .split(';') + .map((stmt) => stmt.replace(/^\s*--[^\n]*$/gm, '').trim()) + .filter((stmt) => stmt.length > 0); +} + +describe('store_conversation 구매자 닉네임 스냅샷 백필 (real DB)', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + ({ prisma } = await createTestingModuleWithRealDb({ providers: [] })); + }); + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + }); + + async function runBackfill(): Promise { + const statements = backfillStatements(); + expect(statements).toHaveLength(1); + for (const stmt of statements) await prisma.$executeRawUnsafe(stmt); + } + + async function createConversation(args: { + nickname: string; + profileDeletedAt?: Date | null; + }) { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { + account_id: account.id, + nickname: args.nickname, + }); + if (args.profileDeletedAt) { + await prisma.userProfile.update({ + where: { account_id: account.id }, + data: { deleted_at: args.profileDeletedAt }, + }); + } + const store = await createStore(prisma); + return prisma.storeConversation.create({ + data: { account_id: account.id, store_id: store.id }, + }); + } + + async function snapshotOf(id: bigint): Promise { + const row = await prisma.storeConversation.findUniqueOrThrow({ + where: { id }, + }); + return row.buyer_nickname_snapshot; + } + + it('활성 프로필의 닉네임만 채우고, 탈퇴 프로필·빈 닉네임은 null로 남긴다', async () => { + const active = await createConversation({ nickname: '현진' }); + // 탈퇴는 닉네임 덮어쓰기(deleted_)와 deleted_at을 같은 트랜잭션에 쓴다 — 조인 조건만으로 빠진다 + const withdrawn = await createConversation({ + nickname: 'deleted_42', + profileDeletedAt: new Date(), + }); + const empty = await createConversation({ nickname: '' }); + + // 대상 수 선확인: 마이그레이션 전 상태(스냅샷 전부 null) + expect( + await prisma.storeConversation.count({ + where: { buyer_nickname_snapshot: null }, + }), + ).toBe(3); + + await runBackfill(); + + expect(await snapshotOf(active.id)).toBe('현진'); + expect(await snapshotOf(withdrawn.id)).toBeNull(); + expect(await snapshotOf(empty.id)).toBeNull(); + }); + + it('반증: 활성 프로필의 deleted_ 접두 닉네임은 그대로 채워진다(접두어 가드 없음)', async () => { + const conv = await createConversation({ nickname: 'deleted_abc' }); + + await runBackfill(); + + expect(await snapshotOf(conv.id)).toBe('deleted_abc'); + }); +}); diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index b92b54df..4584daa7 100644 --- a/src/features/conversation/repositories/conversation.repository.spec.ts +++ b/src/features/conversation/repositories/conversation.repository.spec.ts @@ -1,12 +1,18 @@ import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { + CONVERSATION_BUYER_MESSAGE_SENT, + parseConversationBuyerMessageSentPayload, +} from '@/features/conversation/events/conversation-buyer-message-sent.event'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { OutboxPublisher } from '@/features/outbox'; import { AuditActionType, AuditTargetType } from '@/generated/prisma/client'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, createStore } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; /** repository가 조작과 같은 트랜잭션에 남기는 감사 항목 — 내용 자체는 서비스 spec이 본다. */ const AUDIT_ENTRY = { @@ -19,16 +25,20 @@ const AUDIT_ENTRY = { describe('ConversationRepository (real DB)', () => { let repo: ConversationRepository; + let outbox: OutboxPublisher; let prisma: PrismaClient; beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ providers: [ ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, ], }); repo = module.get(ConversationRepository); + outbox = module.get(OutboxPublisher); prisma = p; }); @@ -41,15 +51,68 @@ describe('ConversationRepository (real DB)', () => { await truncateAll(); }); - async function setupConversation() { + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function setupConversation( + overrides: { + storeId?: bigint; + sellerLastReadAt?: Date | null; + lastMessageAt?: Date | null; + updatedAt?: Date; + deletedAt?: Date | null; + } = {}, + ) { const customer = await createAccount(prisma, { account_type: 'USER' }); - const store = await createStore(prisma); + const store = overrides.storeId + ? await prisma.store.findUniqueOrThrow({ + where: { id: overrides.storeId }, + }) + : await createStore(prisma); const conv = await prisma.storeConversation.create({ - data: { account_id: customer.id, store_id: store.id }, + data: { + account_id: customer.id, + store_id: store.id, + seller_last_read_at: overrides.sellerLastReadAt ?? null, + last_message_at: overrides.lastMessageAt ?? null, + updated_at: overrides.updatedAt, + deleted_at: overrides.deletedAt ?? null, + }, }); return { customer, store, conversation: conv }; } + function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); + } + + async function addMessage(args: { + conversationId: bigint; + senderType?: 'USER' | 'STORE' | 'SYSTEM'; + bodyFormat?: 'TEXT' | 'HTML'; + bodyText?: string | null; + bodyHtml?: string | null; + createdAt?: Date; + deletedAt?: Date | null; + }) { + return prisma.storeConversationMessage.create({ + data: { + conversation_id: args.conversationId, + sender_type: args.senderType ?? 'USER', + body_format: args.bodyFormat ?? 'TEXT', + body_text: args.bodyText === undefined ? '메시지' : args.bodyText, + body_html: args.bodyHtml ?? null, + created_at: args.createdAt ?? new Date(), + deleted_at: args.deletedAt ?? null, + }, + }); + } + + async function conversationRow(id: bigint) { + return prisma.storeConversation.findUniqueOrThrow({ where: { id } }); + } + describe('listConversationsByStore', () => { it('특정 store의 conversation만 반환한다', async () => { const a = await setupConversation(); @@ -235,6 +298,59 @@ describe('ConversationRepository (real DB)', () => { expect(updatedConv.store_id).toBe(store.id); }); + it('답장은 seller_last_read_at을 메시지 시각으로 전진시킨다(답장 = 읽음)', async () => { + const { conversation } = await setupConversation({ + sellerLastReadAt: hoursAgo(3), + }); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + + const message = await repo.createSellerConversationMessage( + { + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '답장', + bodyHtml: null, + }, + () => AUDIT_ENTRY, + ); + + const row = await conversationRow(conversation.id); + expect(row.seller_last_read_at?.getTime()).toBe( + message.created_at.getTime(), + ); + expect(await repo.countUnansweredConversationsByStore(row.store_id)).toBe( + 0, + ); + }); + + it('판매자 마커만 미래 시각이어도 새 메시지는 그보다 뒤 시각을 받는다(GREATEST 항 반증)', async () => { + const futureMarker = new Date(Date.now() + 60 * 60 * 1000); + const { conversation } = await setupConversation({ + sellerLastReadAt: futureMarker, + }); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + + const message = await repo.createSellerConversationMessage( + { + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '컷오버 이후 답장', + bodyHtml: null, + }, + () => AUDIT_ENTRY, + ); + + expect(message.created_at.getTime()).toBeGreaterThan( + futureMarker.getTime(), + ); + }); + it('기존 마커가 미래 시각이어도 새 메시지는 그보다 뒤 시각을 받는다(시계 컷오버 보정)', async () => { const { conversation } = await setupConversation(); const seller = await createAccount(prisma, { account_type: 'SELLER' }); @@ -262,4 +378,544 @@ describe('ConversationRepository (real DB)', () => { ); }); }); + + describe('markSellerRead', () => { + it('최신 메시지의 created_at까지 전진하고 미읽음 0·미리보기를 돌려준다', async () => { + const { conversation } = await setupConversation(); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(2), + }); + const newest = await addMessage({ + conversationId: conversation.id, + bodyFormat: 'HTML', + bodyText: null, + bodyHtml: '

최신 문의

', + createdAt: hoursAgo(1), + }); + + const result = await repo.markSellerRead(conversation.id); + + expect(result?.conversation.seller_last_read_at?.getTime()).toBe( + newest.created_at.getTime(), + ); + expect(result?.extra?.unreadCount).toBe(0); + expect(result?.extra?.lastMessage?.body_html).toBe( + '

최신 문의

', + ); + const row = await conversationRow(conversation.id); + expect(row.seller_last_read_at?.getTime()).toBe( + newest.created_at.getTime(), + ); + }); + + it('메시지가 없으면 마커를 바꾸지 않고 현재 상태를 돌려준다', async () => { + const { conversation } = await setupConversation(); + + const result = await repo.markSellerRead(conversation.id); + + expect(result?.conversation.seller_last_read_at).toBeNull(); + expect(result?.extra?.unreadCount).toBe(0); + expect(result?.extra?.lastMessage).toBeNull(); + }); + + it('반복 호출해도 같은 값이다(멱등)', async () => { + const { conversation } = await setupConversation(); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + + const first = await repo.markSellerRead(conversation.id); + const second = await repo.markSellerRead(conversation.id); + + expect(second?.conversation.seller_last_read_at?.getTime()).toBe( + first?.conversation.seller_last_read_at?.getTime(), + ); + }); + + it('마커가 최신 메시지보다 뒤면 후퇴하지 않는다', async () => { + const marker = hoursAgo(1); + const { conversation } = await setupConversation({ + sellerLastReadAt: marker, + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(2), + }); + + await repo.markSellerRead(conversation.id); + + const row = await conversationRow(conversation.id); + expect(row.seller_last_read_at?.getTime()).toBe(marker.getTime()); + }); + + it('읽음은 updated_at을 바꾸지 않는다(목록 정렬 보존)', async () => { + const updatedAt = new Date('2026-09-01T00:00:00.000Z'); + const { conversation } = await setupConversation({ updatedAt }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + + const result = await repo.markSellerRead(conversation.id); + + expect(result?.conversation.seller_last_read_at).not.toBeNull(); + const row = await conversationRow(conversation.id); + expect(row.updated_at.getTime()).toBe(updatedAt.getTime()); + }); + + it('soft-delete된 대화는 갱신하지 않고 null을 돌려준다', async () => { + const { conversation } = await setupConversation({ + deletedAt: new Date(), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + + expect(await repo.markSellerRead(conversation.id)).toBeNull(); + + const row = await conversationRow(conversation.id); + expect(row.seller_last_read_at).toBeNull(); + }); + + it('미커밋 전송이 잠금을 쥐고 있으면 커밋을 기다린 뒤 그 메시지까지 전진한다', async () => { + const { conversation } = await setupConversation(); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(2), + }); + const inFlightAt = hoursAgo(1); + + let lockHeld!: () => void; + let release!: () => void; + const held = new Promise((r) => (lockHeld = r)); + const released = new Promise((r) => (release = r)); + // 전송 경로 재현: 대화 잠금 → 메시지 삽입 → (커밋 보류) + const sender = prisma.$transaction( + async (tx) => { + await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${conversation.id} FOR UPDATE`; + await tx.storeConversationMessage.create({ + data: { + conversation_id: conversation.id, + sender_type: 'USER', + body_format: 'TEXT', + body_text: '아직 커밋 전', + created_at: inFlightAt, + }, + }); + lockHeld(); + await released; + }, + { timeout: 10_000 }, + ); + await held; + + const marking = repo.markSellerRead(conversation.id); + // 잠금 대기 중임을 확인한 뒤 커밋 — 잠금 없이 조회했다면 이 시점에 이미 옛 메시지로 전진했다 + await new Promise((r) => setTimeout(r, 300)); + expect( + (await conversationRow(conversation.id)).seller_last_read_at, + ).toBeNull(); + release(); + await sender; + + const result = await marking; + expect(result?.conversation.seller_last_read_at?.getTime()).toBe( + inFlightAt.getTime(), + ); + }); + }); + + describe('listBuyerMessagesAndMarkRead', () => { + it('구매자 읽음은 updated_at을 바꾸지 않는다(판매자 목록 정렬 보존)', async () => { + const updatedAt = new Date('2026-09-01T00:00:00.000Z'); + const { conversation } = await setupConversation({ updatedAt }); + await addMessage({ + conversationId: conversation.id, + senderType: 'STORE', + createdAt: hoursAgo(1), + }); + + await repo.listBuyerMessagesAndMarkRead({ + conversationId: conversation.id, + limit: 10, + }); + + const row = await conversationRow(conversation.id); + expect(row.last_read_at).not.toBeNull(); + expect(row.updated_at.getTime()).toBe(updatedAt.getTime()); + }); + }); + + describe('getStoreConversationPageWithExtras', () => { + it('미리보기(HTML 태그 제거)와 판매자 기준 미읽음(USER만)을 함께 돌려준다', async () => { + const store = await createStore(prisma); + const { conversation: unread } = await setupConversation({ + storeId: store.id, + sellerLastReadAt: hoursAgo(5), + }); + // 마커 이전 USER 1건(안 셈) + 이후 USER 2건·STORE 1건·SYSTEM 1건(USER만 셈) + await addMessage({ conversationId: unread.id, createdAt: hoursAgo(6) }); + await addMessage({ conversationId: unread.id, createdAt: hoursAgo(4) }); + await addMessage({ conversationId: unread.id, createdAt: hoursAgo(3) }); + await addMessage({ + conversationId: unread.id, + senderType: 'STORE', + createdAt: hoursAgo(2), + }); + await addMessage({ + conversationId: unread.id, + senderType: 'SYSTEM', + bodyFormat: 'HTML', + bodyText: null, + bodyHtml: '

시스템 안내

', + createdAt: hoursAgo(1), + }); + const { conversation: never } = await setupConversation({ + storeId: store.id, + }); + await addMessage({ conversationId: never.id, createdAt: hoursAgo(1) }); + await addMessage({ conversationId: never.id, createdAt: hoursAgo(1) }); + + const { rows, totalCount, extras } = + await repo.getStoreConversationPageWithExtras({ + storeId: store.id, + limit: 10, + }); + + expect(totalCount).toBe(2); + expect(rows).toHaveLength(2); + const byId = new Map(extras.map((e) => [e.conversationId, e])); + expect(byId.get(unread.id)).toMatchObject({ + unreadCount: 2, + lastMessage: { body_html: '

시스템 안내

' }, + }); + // 마커 null이면 USER 전부 + expect(byId.get(never.id)?.unreadCount).toBe(2); + }); + + it('hasMore 판정용 초과분(limit+1)의 부가 정보는 조회하지 않는다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 3; i++) { + const { conversation } = await setupConversation({ storeId: store.id }); + await addMessage({ conversationId: conversation.id }); + } + + const { rows, extras } = await repo.getStoreConversationPageWithExtras({ + storeId: store.id, + limit: 2, + }); + + expect(rows).toHaveLength(3); + expect(extras.map((e) => e.conversationId)).toEqual( + rows.slice(0, 2).map((r) => r.id), + ); + }); + }); + + describe('countUnansweredConversationsByStore', () => { + type Case = { + name: string; + expected: number; + arrange: (storeId: bigint) => Promise; + }; + const cases: Case[] = [ + { + name: '마커 null + USER 메시지 1건', + expected: 1, + arrange: async (storeId) => { + const { conversation } = await setupConversation({ + storeId, + lastMessageAt: hoursAgo(1), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + }, + }, + { + name: '마커가 last_message_at 이상', + expected: 0, + arrange: async (storeId) => { + const { conversation } = await setupConversation({ + storeId, + lastMessageAt: hoursAgo(1), + sellerLastReadAt: hoursAgo(1), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + }, + }, + { + name: 'USER 뒤 판매자 답장만(답장이 마커를 전진)', + expected: 0, + arrange: async (storeId) => { + const { conversation } = await setupConversation({ storeId }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + const seller = await createAccount(prisma, { + account_type: 'SELLER', + }); + await repo.createSellerConversationMessage( + { + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '답장', + bodyHtml: null, + }, + () => AUDIT_ENTRY, + ); + }, + }, + { + // 마지막 발신자 기준이면 STORE가 마지막이라 빠진다 — 미읽음 기준이라 센다 + name: 'USER 질문 + STORE 자동응답이 같은 시각, 마커 이전', + expected: 1, + arrange: async (storeId) => { + const at = hoursAgo(1); + const { conversation } = await setupConversation({ + storeId, + lastMessageAt: at, + sellerLastReadAt: hoursAgo(5), + }); + await addMessage({ conversationId: conversation.id, createdAt: at }); + await addMessage({ + conversationId: conversation.id, + senderType: 'STORE', + createdAt: at, + }); + }, + }, + { + name: 'soft-delete된 대화', + expected: 0, + arrange: async (storeId) => { + const { conversation } = await setupConversation({ + storeId, + lastMessageAt: hoursAgo(1), + deletedAt: new Date(), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + }, + }, + { + name: 'soft-delete된 USER 메시지만', + expected: 0, + arrange: async (storeId) => { + const { conversation } = await setupConversation({ + storeId, + lastMessageAt: hoursAgo(1), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + deletedAt: new Date(), + }); + }, + }, + { + name: '다른 매장의 미읽음 대화', + expected: 0, + arrange: async () => { + const { conversation } = await setupConversation({ + lastMessageAt: hoursAgo(1), + }); + await addMessage({ + conversationId: conversation.id, + createdAt: hoursAgo(1), + }); + }, + }, + ]; + + it.each(cases)('$name → $expected', async ({ arrange, expected }) => { + const store = await createStore(prisma); + await arrange(store.id); + expect(await repo.countUnansweredConversationsByStore(store.id)).toBe( + expected, + ); + }); + }); + + describe('createBuyerMessages 닉네임 스냅샷', () => { + const entry = { + senderType: 'USER' as const, + senderAccountId: null, + bodyFormat: 'TEXT' as const, + bodyText: '문의', + bodyHtml: null, + }; + + it('새 대화에는 호출 시점 닉네임을 저장한다', async () => { + const customer = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const { conversationId } = await repo.createBuyerMessages({ + accountId: customer.id, + storeId: store.id, + buyerNickname: '현진', + greetingBodyText: '안녕하세요', + entries: [entry], + }); + + expect( + (await conversationRow(conversationId)).buyer_nickname_snapshot, + ).toBe('현진'); + }); + + it('기존 대화(soft-delete 재사용 포함)의 스냅샷은 바꾸지 않는다', async () => { + const customer = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + const existing = await prisma.storeConversation.create({ + data: { + account_id: customer.id, + store_id: store.id, + buyer_nickname_snapshot: '옛닉네임', + deleted_at: new Date(), + }, + }); + + const { conversationId } = await repo.createBuyerMessages({ + accountId: customer.id, + storeId: store.id, + buyerNickname: '새닉네임', + greetingBodyText: '안녕하세요', + entries: [entry], + }); + + expect(conversationId).toBe(existing.id); + expect((await conversationRow(existing.id)).buyer_nickname_snapshot).toBe( + '옛닉네임', + ); + }); + }); + + describe('createBuyerMessages — 구매자 메시지 이벤트', () => { + const USER_ENTRY = { + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: ' 픽업 시간 변경 가능한가요?', + bodyHtml: null, + } as const; + + it('USER 메시지를 기준으로 같은 tx에 이벤트 1건을 남긴다(인사말 제외)', async () => { + const customer = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const { conversationId, messages } = await repo.createBuyerMessages({ + accountId: customer.id, + storeId: store.id, + buyerNickname: '현진', + greetingBodyText: '안녕하세요', + entries: [{ ...USER_ENTRY, senderAccountId: customer.id }], + }); + + expect(messages.map((m) => m.sender_type)).toEqual(['STORE', 'USER']); + const events = await prisma.outbox.findMany(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + aggregate_type: 'conversation', + aggregate_id: conversationId.toString(), + event_type: CONVERSATION_BUYER_MESSAGE_SENT, + occurred_at: messages[1].created_at, + actor_account_id: customer.id, + }); + expect( + parseConversationBuyerMessageSentPayload(events[0].payload_json), + ).toEqual({ + conversationId: conversationId.toString(), + storeId: store.id.toString(), + buyerAccountId: customer.id.toString(), + messageId: messages[1].id.toString(), + preview: USER_ENTRY.bodyText, + messageCreatedAt: messages[1].created_at.toISOString(), + }); + }); + + it('FAQ 쌍(USER 질문 + STORE 자동응답)도 USER 메시지 기준 1건이다', async () => { + const { customer, store, conversation } = await setupConversation(); + + const { messages } = await repo.createBuyerMessages({ + accountId: customer.id, + storeId: store.id, + buyerNickname: '현진', + greetingBodyText: '안녕하세요', + entries: [ + { + ...USER_ENTRY, + bodyText: '케이크 보관 방법', + senderAccountId: customer.id, + }, + { + senderType: 'STORE', + senderAccountId: null, + bodyFormat: 'HTML', + bodyText: null, + bodyHtml: '

냉장보관시 최대 3일

', + }, + ], + }); + + const events = await prisma.outbox.findMany(); + expect(events).toHaveLength(1); + expect(events[0].aggregate_id).toBe(conversation.id.toString()); + expect( + parseConversationBuyerMessageSentPayload(events[0].payload_json), + ).toMatchObject({ + messageId: messages[1].id.toString(), + preview: '케이크 보관 방법', + }); + }); + + it('반증: 발행이 실패하면 대화·메시지도 함께 롤백된다(같은 tx)', async () => { + const customer = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + jest + .spyOn(outbox, 'publish') + .mockRejectedValueOnce(new Error('outbox down')); + + await expect( + repo.createBuyerMessages({ + accountId: customer.id, + storeId: store.id, + buyerNickname: '현진', + greetingBodyText: '안녕하세요', + entries: [{ ...USER_ENTRY, senderAccountId: customer.id }], + }), + ).rejects.toThrow('outbox down'); + + expect(await prisma.storeConversation.count()).toBe(0); + expect(await prisma.storeConversationMessage.count()).toBe(0); + expect(await prisma.outbox.count()).toBe(0); + }); + + it('반증: 판매자 답장은 이벤트를 남기지 않는다', async () => { + const { conversation } = await setupConversation(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + + await repo.createSellerConversationMessage( + { + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '판매자 응답', + bodyHtml: null, + }, + () => AUDIT_ENTRY, + ); + + expect(await prisma.outbox.count()).toBe(0); + }); + }); }); diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 1d26678f..0b7e0df6 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -5,12 +5,14 @@ import { type AuditEntry, type IAuditLogRepository, } from '@/features/audit-log'; +import { conversationBuyerMessageSentEvent } from '@/features/conversation/events/conversation-buyer-message-sent.event'; +import { OutboxPublisher } from '@/features/outbox'; import { ConversationBodyFormat, ConversationSenderType, Prisma, } from '@/generated/prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService } from '@/prisma'; export interface ConversationMessageEntry { senderType: ConversationSenderType; @@ -24,6 +26,7 @@ export interface ConversationMessageEntry { export class ConversationRepository { constructor( private readonly prisma: PrismaService, + private readonly outbox: OutboxPublisher, @Inject(AUDIT_LOG_REPOSITORY) private readonly auditLogs: IAuditLogRepository, ) {} @@ -32,15 +35,18 @@ export class ConversationRepository { * (updated_at, id) desc 키셋. 커서가 id 단독이면 정렬 순서와 무관한 행을 잘라내 목록에서 영영 빠지는 * 대화가 생긴다 — 정렬 키를 그대로 커서에 담는다. schema.prisma의 [store_id, updated_at] 인덱스가 이 정렬을 받친다. */ - async listConversationsByStore(args: { - storeId: bigint; - limit: number; - cursor?: { updatedAt: Date; id: bigint }; - }) { + async listConversationsByStore( + args: { + storeId: bigint; + limit: number; + cursor?: { updatedAt: Date; id: bigint }; + }, + db: Prisma.TransactionClient = this.prisma, + ) { const scope: Prisma.StoreConversationWhereInput = { store_id: args.storeId, }; - return this.prisma.storeConversation.findMany({ + return db.storeConversation.findMany({ where: args.cursor ? { AND: [ @@ -62,12 +68,101 @@ export class ConversationRepository { }); } - async countConversationsByStore(storeId: bigint): Promise { - return this.prisma.storeConversation.count({ + async countConversationsByStore( + storeId: bigint, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + return db.storeConversation.count({ where: { store_id: storeId }, }); } + /** 판매자 목록도 구매자 목록(getConversationPageWithExtras)과 같은 이유로 단일 스냅샷에서 부가 정보까지 읽는다. */ + async getStoreConversationPageWithExtras(args: { + storeId: bigint; + limit: number; + cursor?: { updatedAt: Date; id: bigint }; + }) { + return this.prisma.$transaction( + async (tx) => { + const [rows, totalCount] = await Promise.all([ + this.listConversationsByStore(args, tx), + this.countConversationsByStore(args.storeId, tx), + ]); + const extras = await this.getConversationListExtras( + tx, + rows.slice(0, args.limit).map((row) => ({ + id: row.id, + readAt: row.seller_last_read_at, + })), + ConversationSenderType.USER, + ); + return { rows, totalCount, extras }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + } + + /** + * 구매자 읽음(listBuyerMessagesAndMarkRead)과 같은 잠금 규칙 — 잠금 아래에서 보이는 최신 메시지까지만 단조 전진한다. + * updated_at은 고정한다(읽음이 목록 정렬 키를 흔들면 안 된다). 소유 확인 뒤 삭제된 대화는 건드리지 않는다. + */ + async markSellerRead(conversationId: bigint) { + return this.prisma.$transaction(async (tx) => { + const [locked] = await tx.$queryRaw<{ updated_at: Date }[]>` + SELECT updated_at FROM store_conversation WHERE id = ${conversationId} FOR UPDATE`; + const newest = await tx.storeConversationMessage.findFirst({ + where: { conversation_id: conversationId }, + orderBy: { id: 'desc' }, + select: { created_at: true }, + }); + if (newest) { + await tx.storeConversation.updateMany({ + where: { + id: conversationId, + ...activeWhere, + OR: [ + { seller_last_read_at: null }, + { seller_last_read_at: { lt: newest.created_at } }, + ], + }, + data: { + seller_last_read_at: newest.created_at, + updated_at: locked?.updated_at, + }, + }); + } + + const conversation = await tx.storeConversation.findFirst({ + where: { id: conversationId }, + }); + if (!conversation) return null; + const [extra] = await this.getConversationListExtras( + tx, + [{ id: conversation.id, readAt: conversation.seller_last_read_at }], + ConversationSenderType.USER, + ); + return { conversation, extra }; + }); + } + + /** + * 답변 필요 = 판매자 기준 미읽음 USER 메시지가 있는 활성 대화. 마지막 발신자 기준이 아니다 — FAQ 칩은 USER 질문과 + * STORE 자동응답이 같은 시각으로 저장돼 "마지막 메시지"가 STORE가 된다. raw라 soft-delete를 수동 명시. + */ + async countUnansweredConversationsByStore(storeId: bigint): Promise { + const rows = await this.prisma.$queryRaw<{ c: bigint }[]>` + SELECT COUNT(*) AS c + FROM store_conversation c + WHERE c.store_id = ${storeId} AND c.deleted_at IS NULL + AND (c.seller_last_read_at IS NULL OR c.last_message_at > c.seller_last_read_at) + AND EXISTS ( + SELECT 1 FROM store_conversation_message m + WHERE m.conversation_id = c.id AND m.deleted_at IS NULL AND m.sender_type = 'USER' + AND (c.seller_last_read_at IS NULL OR m.created_at > c.seller_last_read_at))`; + return Number(rows[0]?.c ?? 0n); + } + async findConversationByIdAndStore(args: { conversationId: bigint; storeId: bigint; @@ -131,8 +226,9 @@ export class ConversationRepository { tx, rows.slice(0, args.limit).map((row) => ({ id: row.id, - last_read_at: row.last_read_at, + readAt: row.last_read_at, })), + { not: ConversationSenderType.USER }, ); return { rows, totalCount, extras }; }, @@ -190,12 +286,13 @@ export class ConversationRepository { } /** - * 안읽음 = last_read_at 이후 도착한, 내가 보낸 것이 아닌 메시지. per-row 쿼리는 페이지 50건 기준 - * 100쿼리로 풀을 압박한다 — 최신 메시지 id 집계 → 본문 일괄 조회 → 안읽음 OR-분기 groupBy의 고정 3쿼리. + * 안읽음 = readAt 이후 도착한 unreadSender 메시지(구매자: 내가 보낸 것이 아닌 것, 판매자: USER). per-row 쿼리는 + * 페이지 50건 기준 100쿼리로 풀을 압박한다 — 최신 메시지 id 집계 → 본문 일괄 조회 → 안읽음 OR-분기 groupBy의 고정 3쿼리. */ private async getConversationListExtras( tx: Prisma.TransactionClient, - rows: { id: bigint; last_read_at: Date | null }[], + rows: { id: bigint; readAt: Date | null }[], + unreadSender: Prisma.StoreConversationMessageWhereInput['sender_type'], ) { if (rows.length === 0) return []; const ids = rows.map((row) => row.id); @@ -224,13 +321,11 @@ export class ConversationRepository { tx.storeConversationMessage.groupBy({ by: ['conversation_id'], where: { - sender_type: { not: ConversationSenderType.USER }, - // 대화별 last_read_at이 달라 조건을 OR 분기로 배치한다(페이지 ≤50) + sender_type: unreadSender, + // 대화별 readAt이 달라 조건을 OR 분기로 배치한다(페이지 ≤50) OR: rows.map((row) => ({ conversation_id: row.id, - ...(row.last_read_at - ? { created_at: { gt: row.last_read_at } } - : {}), + ...(row.readAt ? { created_at: { gt: row.readAt } } : {}), })), }, _count: { _all: true }, @@ -264,6 +359,7 @@ export class ConversationRepository { * 조회 + 읽음 마커 전진을 한 트랜잭션으로, 전송 경로와 같은 대화 row 잠금을 잡는다 — 미커밋 전송이 있으면 * 커밋을 기다린 뒤 조회하므로 "아직 안 보이는 메시지"를 건너뛰고 마커가 전진하는 레이스가 없다. 마커는 * 실제 내려준 최신 메시지의 created_at까지만, 과거 페이지 조회로 후퇴하지 않게 단조 증가 조건으로 갱신한다. + * updated_at은 고정한다 — 판매자 목록 정렬 키라 읽음만으로 떠오르면 안 된다. */ async listBuyerMessagesAndMarkRead(args: { conversationId: bigint; @@ -271,7 +367,8 @@ export class ConversationRepository { cursor?: bigint; }) { return this.prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; + const [locked] = await tx.$queryRaw<{ updated_at: Date }[]>` + SELECT updated_at FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; const [rows, totalCount] = await Promise.all([ tx.storeConversationMessage.findMany({ @@ -297,7 +394,10 @@ export class ConversationRepository { { last_read_at: { lt: newest.created_at } }, ], }, - data: { last_read_at: newest.created_at }, + data: { + last_read_at: newest.created_at, + updated_at: locked?.updated_at, + }, }); } @@ -319,12 +419,14 @@ export class ConversationRepository { store_id: true, last_message_at: true, last_read_at: true, + seller_last_read_at: true, + buyer_nickname_snapshot: true, store: { select: { store_name: true } }, }, }); if (!conversation) return null; - const [lastMessage, unreadCount] = await Promise.all([ + const [lastMessage, unreadCount, sellerUnreadCount] = await Promise.all([ tx.storeConversationMessage.findFirst({ where: { conversation_id: conversationId }, orderBy: { id: 'desc' }, @@ -339,9 +441,18 @@ export class ConversationRepository { : {}), }, }), + tx.storeConversationMessage.count({ + where: { + conversation_id: conversationId, + sender_type: ConversationSenderType.USER, + ...(conversation.seller_last_read_at + ? { created_at: { gt: conversation.seller_last_read_at } } + : {}), + }, + }), ]); - return { conversation, lastMessage, unreadCount }; + return { conversation, lastMessage, unreadCount, sellerUnreadCount }; }); } @@ -360,6 +471,7 @@ export class ConversationRepository { async createBuyerMessages(args: { accountId: bigint; storeId: bigint; + buyerNickname: string; greetingBodyText: string; entries: ConversationMessageEntry[]; }) { @@ -448,12 +560,28 @@ export class ConversationRepository { }, }); + // 구매자 메시지 이벤트(outbox, 같은 tx) — 판매자 푸시 원천. 호출 1회당 1건, 인사말·FAQ 자동응답(STORE)은 제외. + const buyerMessage = messages.findLast( + (m) => m.sender_type === ConversationSenderType.USER, + ); + if (buyerMessage) { + await this.outbox.publish( + tx, + conversationBuyerMessageSentEvent({ + conversationId, + storeId: args.storeId, + buyerAccountId: args.accountId, + message: buyerMessage, + }), + ); + } + return { conversationId, messages }; }); } /** - * 인스턴스 간 단일 시계(DB NOW(3))를 쓰되 해당 대화의 기존 last_message_at/last_read_at보다 1ms 이상 + * 인스턴스 간 단일 시계(DB NOW(3))를 쓰되 해당 대화의 기존 last_message_at·읽음 마커보다 1ms 이상 * 뒤로 보정한다 — 앱 시계로 찍힌 과거 row(시계가 DB보다 앞섰던 노드)가 남아 있어도 새 메시지가 마커보다 * 과거/동률 시각을 받아 안읽음 판정(created_at > last_read_at)에서 누락되지 않는다. 잠금 획득 후 호출 전제. */ @@ -467,7 +595,8 @@ export class ConversationRepository { SELECT GREATEST( NOW(3), COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_message_at), NOW(3)), - COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)) + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)), + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, seller_last_read_at), NOW(3)) ) AS now FROM store_conversation WHERE id = ${conversationId} @@ -491,7 +620,7 @@ export class ConversationRepository { */ private async lockOrCreateConversation( tx: Prisma.TransactionClient, - args: { accountId: bigint; storeId: bigint }, + args: { accountId: bigint; storeId: bigint; buyerNickname: string }, ): Promise<{ id: bigint; lastReadAt: Date | null }> { // 잠금 조회가 돌려준 last_read_at을 그대로 쓴다 — 잠금 대기 중 커밋된 // 변경까지 반영된 최신 값이다(일반 조회의 스냅샷과 달리). @@ -526,10 +655,12 @@ export class ConversationRepository { } try { + // 닉네임 스냅샷은 생성 시점에만 — 기존(soft-delete 재사용 포함) 대화는 갱신하지 않는다 const created = await tx.storeConversation.create({ data: { account_id: args.accountId, store_id: args.storeId, + buyer_nickname_snapshot: args.buyerNickname, }, select: { id: true }, }); @@ -579,6 +710,8 @@ export class ConversationRepository { data: { last_message_at: now, updated_at: now, + // 답장은 읽음을 함의한다 — 앱이 따로 읽음 처리하지 않아도 미읽음이 남지 않는다 + seller_last_read_at: now, }, }); diff --git a/src/features/conversation/resolvers/conversation-center.resolver.spec.ts b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts index 2b939805..4d10e039 100644 --- a/src/features/conversation/resolvers/conversation-center.resolver.spec.ts +++ b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts @@ -22,6 +22,7 @@ import { createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('Conversation Center Resolvers (real DB)', () => { let centerResolver: ConversationCenterQueryResolver; @@ -36,6 +37,8 @@ describe('Conversation Center Resolvers (real DB)', () => { ConversationCenterService, ConversationInquiryService, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), ConversationEventsService, AccountUserRepository, { provide: CATALOG_QUERY, useClass: StoreCatalogQueryRepository }, diff --git a/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts index 939b7a1b..99037037 100644 --- a/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts +++ b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts @@ -21,6 +21,7 @@ import { createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('Conversation Inquiry Resolvers (real DB)', () => { let queryResolver: ConversationInquiryQueryResolver; @@ -34,6 +35,8 @@ describe('Conversation Inquiry Resolvers (real DB)', () => { ConversationInquiryMutationResolver, ConversationInquiryService, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), ConversationEventsService, AccountUserRepository, { provide: CATALOG_QUERY, useClass: StoreCatalogQueryRepository }, diff --git a/src/features/conversation/resolvers/conversation-seller-mutation.resolver.ts b/src/features/conversation/resolvers/conversation-seller-mutation.resolver.ts index 9128c8bb..852726e5 100644 --- a/src/features/conversation/resolvers/conversation-seller-mutation.resolver.ts +++ b/src/features/conversation/resolvers/conversation-seller-mutation.resolver.ts @@ -3,7 +3,10 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { SellerSendConversationMessageInput } from '@/features/conversation/dto/inputs/seller-send-conversation-message.input'; import { SellerConversationService } from '@/features/conversation/services/conversation-seller.service'; -import type { SellerConversationMessageOutput } from '@/features/conversation/types/conversation-seller-output.type'; +import type { + SellerConversationMessageOutput, + SellerConversationOutput, +} from '@/features/conversation/types/conversation-seller-output.type'; import { CurrentUser, JwtAuthGuard, @@ -32,4 +35,16 @@ export class SellerConversationMutationResolver { input, ); } + + @Mutation('sellerMarkConversationRead') + sellerMarkConversationRead( + @CurrentUser() user: JwtUser, + @Args('conversationId') conversationId: string, + ): Promise { + const accountId = parseAccountId(user); + return this.conversationService.sellerMarkConversationRead( + accountId, + conversationId, + ); + } } diff --git a/src/features/conversation/resolvers/conversation-seller.resolver.spec.ts b/src/features/conversation/resolvers/conversation-seller.resolver.spec.ts index c4aa78ab..901b396b 100644 --- a/src/features/conversation/resolvers/conversation-seller.resolver.spec.ts +++ b/src/features/conversation/resolvers/conversation-seller.resolver.spec.ts @@ -14,6 +14,7 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, setupSellerWithStore } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('Seller Conversation Resolvers (real DB)', () => { let queryResolver: SellerConversationQueryResolver; @@ -28,6 +29,8 @@ describe('Seller Conversation Resolvers (real DB)', () => { SellerConversationService, StoreSellerRepository, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), ConversationEventsService, { provide: PUB_SUB, useValue: new PubSub() }, { @@ -85,4 +88,28 @@ describe('Seller Conversation Resolvers (real DB)', () => { ), ).rejects.toThrowDomain(404); }); + + it('Mutation.sellerMarkConversationRead: 내 매장 대화의 판매자 마커를 전진시킨 상태를 반환', async () => { + const me = await setupSellerWithStore(prisma); + const conv = await createConv(me.store.id); + const message = await prisma.storeConversationMessage.create({ + data: { + conversation_id: conv.id, + sender_type: 'USER', + sender_account_id: conv.account_id, + body_format: 'TEXT', + body_text: '문의', + }, + }); + + const result = await mutationResolver.sellerMarkConversationRead( + { accountId: me.account.id.toString() }, + conv.id.toString(), + ); + + expect(result.sellerLastReadAt?.getTime()).toBe( + message.created_at.getTime(), + ); + expect(result.unreadCount).toBe(0); + }); }); diff --git a/src/features/conversation/services/conversation-center.service.spec.ts b/src/features/conversation/services/conversation-center.service.spec.ts index 41759cbb..56828f27 100644 --- a/src/features/conversation/services/conversation-center.service.spec.ts +++ b/src/features/conversation/services/conversation-center.service.spec.ts @@ -12,6 +12,7 @@ import { createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('ConversationCenterService (real DB)', () => { let service: ConversationCenterService; @@ -22,6 +23,8 @@ describe('ConversationCenterService (real DB)', () => { providers: [ ConversationCenterService, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), AccountUserRepository, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, ], @@ -176,6 +179,27 @@ describe('ConversationCenterService (real DB)', () => { expect(result.items[0].unreadCount).toBe(1); }); + it('구매자 안읽음은 USER가 아닌 발신(STORE·SYSTEM) 전부를 센다(판매자 조건 USER만과 구분)', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + lastReadAt: null, + }); + await addMessage({ + conversationId: conv.id, + senderType: 'USER', + senderAccountId: buyer.id, + }); + await addMessage({ conversationId: conv.id, senderType: 'STORE' }); + await addMessage({ conversationId: conv.id, senderType: 'SYSTEM' }); + + const result = await service.myConversations(buyer.id); + + expect(result.items[0].unreadCount).toBe(2); + }); + it('커서로 다음 페이지를 이어가고, 메시지 없는 대화는 제외한다', async () => { const buyer = await setupBuyer(); const convIds: string[] = []; diff --git a/src/features/conversation/services/conversation-events.service.spec.ts b/src/features/conversation/services/conversation-events.service.spec.ts index 410a80df..c095e84d 100644 --- a/src/features/conversation/services/conversation-events.service.spec.ts +++ b/src/features/conversation/services/conversation-events.service.spec.ts @@ -126,8 +126,11 @@ describe('ConversationEventsService (real Redis)', () => { await service.publishSellerListUpdate('3', { conversationId: '10', accountId: '7', + buyerNickname: '현진', lastMessagePreview: '픽업 문의', lastMessageAt: '2026-08-01T12:00:00.000Z', + sellerLastReadAt: null, + unreadCount: 1, }); await expect(pendingBuyer).resolves.toMatchObject({ diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index 46fa77e7..2a07d9f8 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -3,6 +3,10 @@ import { PubSub } from 'graphql-subscriptions'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { AccountUserRepository } from '@/features/auth'; +import { + CONVERSATION_BUYER_MESSAGE_SENT, + parseConversationBuyerMessageSentPayload, +} from '@/features/conversation/events/conversation-buyer-message-sent.event'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; @@ -18,9 +22,11 @@ import { createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('ConversationInquiryService (real DB)', () => { let service: ConversationInquiryService; + let events: ConversationEventsService; let prisma: PrismaClient; beforeAll(async () => { @@ -28,6 +34,8 @@ describe('ConversationInquiryService (real DB)', () => { providers: [ ConversationInquiryService, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), ConversationEventsService, AccountUserRepository, { provide: CATALOG_QUERY, useClass: StoreCatalogQueryRepository }, @@ -37,6 +45,7 @@ describe('ConversationInquiryService (real DB)', () => { ], }); service = module.get(ConversationInquiryService); + events = module.get(ConversationEventsService); prisma = p; }); @@ -210,6 +219,52 @@ describe('ConversationInquiryService (real DB)', () => { expect(await messagesOf(conversation.id)).toHaveLength(2); }); + it('첫 전송 시점 닉네임을 스냅샷으로 저장하고, 이후 닉네임이 바뀌어도 유지한다', async () => { + const buyer = await setupBuyer('첫닉네임'); + const store = await createStore(prisma); + + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '첫 문의', + }); + await prisma.userProfile.update({ + where: { account_id: buyer.id }, + data: { nickname: '바뀐닉네임' }, + }); + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '두 번째 문의', + }); + + const conversation = await prisma.storeConversation.findFirstOrThrow({ + where: { account_id: buyer.id, store_id: store.id }, + }); + expect(conversation.buyer_nickname_snapshot).toBe('첫닉네임'); + }); + + it('판매자 이벤트에 닉네임 스냅샷과 판매자 기준 미읽음 수(USER만)를 싣는다', async () => { + const buyer = await setupBuyer('현진'); + const store = await createStore(prisma); + const publishSeller = jest.spyOn(events, 'publishSellerListUpdate'); + + const result = await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '픽업 문의', + }); + + // 인사말(STORE)은 판매자 미읽음에 안 센다 + expect(publishSeller).toHaveBeenCalledWith(store.id.toString(), { + conversationId: result.conversationId, + accountId: buyer.id.toString(), + buyerNickname: '현진', + lastMessagePreview: '픽업 문의', + lastMessageAt: result.messages[1].createdAt.toISOString(), + sellerLastReadAt: null, + unreadCount: 1, + }); + publishSeller.mockRestore(); + }); + it('대화가 이미 있으면 인사말 없이 유저 메시지 1건만 저장한다', async () => { const buyer = await setupBuyer(); const store = await createStore(prisma); @@ -403,4 +458,54 @@ describe('ConversationInquiryService (real DB)', () => { ).rejects.toThrowDomain(404); }); }); + + describe('구매자 메시지 이벤트(conversation.buyer_message_sent)', () => { + it('텍스트 전송은 USER 메시지 기준 1건을 남긴다(인사말 제외, 본문은 정리본)', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + + const result = await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: ' 픽업 시간 변경 가능한가요? ', + }); + + const events = await prisma.outbox.findMany(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + event_type: CONVERSATION_BUYER_MESSAGE_SENT, + aggregate_id: result.conversationId, + actor_account_id: buyer.id, + }); + expect( + parseConversationBuyerMessageSentPayload(events[0].payload_json), + ).toEqual({ + conversationId: result.conversationId, + storeId: store.id.toString(), + buyerAccountId: buyer.id.toString(), + messageId: result.messages[1].id, + preview: '픽업 시간 변경 가능한가요?', + messageCreatedAt: result.messages[1].createdAt.toISOString(), + }); + }); + + it('FAQ 칩은 자동응답을 빼고 USER 질문 기준 1건을 남긴다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const faq = await createFaq(store.id, { title: '케이크 보관 방법' }); + + const result = await service.sendConversationFaqMessage(buyer.id, { + storeId: store.id.toString(), + faqTopicId: faq.id.toString(), + }); + + const events = await prisma.outbox.findMany(); + expect(events).toHaveLength(1); + expect( + parseConversationBuyerMessageSentPayload(events[0].payload_json), + ).toMatchObject({ + messageId: result.messages[1].id, + preview: '케이크 보관 방법', + }); + }); + }); }); diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index 0e696f67..7e7e762a 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -157,7 +157,8 @@ export class ConversationInquiryService extends ConversationBaseService { const result = await this.repo.createBuyerMessages({ accountId: args.accountId, storeId: args.storeId, - // 첫 전송으로 대화가 생성될 때만 repository가 사용한다(치환 완료본 저장) + // 닉네임 스냅샷·인사말(치환 완료본)은 첫 전송으로 대화가 생성될 때만 repository가 사용한다 + buyerNickname: args.nickname, greetingBodyText: renderGreeting(args.greetingTemplate, { nickname: args.nickname, storeName: args.storeName, @@ -242,8 +243,12 @@ export class ConversationInquiryService extends ConversationBaseService { await this.events.publishSellerListUpdate(args.storeId.toString(), { conversationId: args.conversationId.toString(), accountId: args.accountId.toString(), + buyerNickname: snapshot?.conversation.buyer_nickname_snapshot ?? null, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, + sellerLastReadAt: + snapshot?.conversation.seller_last_read_at?.toISOString() ?? null, + unreadCount: snapshot?.sellerUnreadCount ?? 0, }); } diff --git a/src/features/conversation/services/conversation-seller.service.spec.ts b/src/features/conversation/services/conversation-seller.service.spec.ts index 31ef7537..b222fc81 100644 --- a/src/features/conversation/services/conversation-seller.service.spec.ts +++ b/src/features/conversation/services/conversation-seller.service.spec.ts @@ -12,9 +12,11 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, setupSellerWithStore } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('SellerConversationService (real DB)', () => { let service: SellerConversationService; + let events: ConversationEventsService; let prisma: PrismaClient; beforeAll(async () => { @@ -23,6 +25,8 @@ describe('SellerConversationService (real DB)', () => { SellerConversationService, StoreSellerRepository, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), ConversationEventsService, { provide: PUB_SUB, useValue: new PubSub() }, { @@ -32,6 +36,7 @@ describe('SellerConversationService (real DB)', () => { ], }); service = module.get(SellerConversationService); + events = module.get(ConversationEventsService); prisma = p; }); @@ -44,12 +49,37 @@ describe('SellerConversationService (real DB)', () => { await truncateAll(); }); - async function setupConversation(storeId: bigint) { + async function setupConversation( + storeId: bigint, + overrides: { buyerNickname?: string | null; sellerLastReadAt?: Date } = {}, + ) { const customer = await createAccount(prisma, { account_type: 'USER' }); return prisma.storeConversation.create({ data: { account_id: customer.id, store_id: storeId, + buyer_nickname_snapshot: overrides.buyerNickname ?? null, + seller_last_read_at: overrides.sellerLastReadAt ?? null, + }, + }); + } + + function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); + } + + async function addUserMessage( + conv: { id: bigint; account_id: bigint }, + args: { bodyText?: string; createdAt?: Date } = {}, + ) { + return prisma.storeConversationMessage.create({ + data: { + conversation_id: conv.id, + sender_type: 'USER', + sender_account_id: conv.account_id, + body_format: 'TEXT', + body_text: args.bodyText ?? '문의', + created_at: args.createdAt ?? new Date(), }, }); } @@ -76,6 +106,105 @@ describe('SellerConversationService (real DB)', () => { expect(result.items).toHaveLength(2); expect(result.nextCursor).not.toBeNull(); }); + + it('닉네임 스냅샷·미리보기·판매자 마커·미읽음 수를 매핑하고, 스냅샷 없는 대화는 null', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const marker = hoursAgo(3); + const named = await setupConversation(store.id, { + buyerNickname: '현진', + sellerLastReadAt: marker, + }); + await addUserMessage(named, { + createdAt: hoursAgo(4), + bodyText: '읽은 문의', + }); + await addUserMessage(named, { + createdAt: hoursAgo(2), + bodyText: '새 문의', + }); + await prisma.storeConversationMessage.create({ + data: { + conversation_id: named.id, + sender_type: 'STORE', + sender_account_id: account.id, + body_format: 'HTML', + body_html: '

답변 드립니다

', + created_at: hoursAgo(1), + }, + }); + const anonymous = await setupConversation(store.id); + + const result = await service.sellerConversations(account.id); + + const byId = new Map(result.items.map((i) => [i.id, i])); + expect(byId.get(named.id.toString())).toMatchObject({ + buyerNickname: '현진', + lastMessagePreview: '답변 드립니다', + unreadCount: 1, + }); + expect(byId.get(named.id.toString())?.sellerLastReadAt?.getTime()).toBe( + marker.getTime(), + ); + expect(byId.get(anonymous.id.toString())).toMatchObject({ + buyerNickname: null, + lastMessagePreview: null, + sellerLastReadAt: null, + unreadCount: 0, + }); + }); + }); + + describe('sellerMarkConversationRead', () => { + it('내 매장 대화의 마커를 최신 메시지까지 전진시키고 unreadCount 0을 돌려준다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const conv = await setupConversation(store.id, { buyerNickname: '현진' }); + await addUserMessage(conv, { createdAt: hoursAgo(2) }); + const newest = await addUserMessage(conv, { createdAt: hoursAgo(1) }); + expect( + (await service.sellerConversations(account.id)).items[0].unreadCount, + ).toBe(2); + + const result = await service.sellerMarkConversationRead( + account.id, + conv.id.toString(), + ); + + expect(result.sellerLastReadAt?.getTime()).toBe( + newest.created_at.getTime(), + ); + expect(result).toMatchObject({ buyerNickname: '현진', unreadCount: 0 }); + expect( + (await service.sellerConversations(account.id)).items[0].unreadCount, + ).toBe(0); + }); + + it('남의 매장 대화·없는 대화("0")는 CONVERSATION_NOT_FOUND', async () => { + const me = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + const othersConv = await setupConversation(other.store.id); + + await expect( + service.sellerMarkConversationRead( + me.account.id, + othersConv.id.toString(), + ), + ).rejects.toThrowDomain('CONVERSATION_NOT_FOUND'); + await expect( + service.sellerMarkConversationRead(me.account.id, '0'), + ).rejects.toThrowDomain('CONVERSATION_NOT_FOUND'); + }); + + it('형식이 잘못된 id는 INVALID_ID, USER 계정은 SELLER_ONLY', async () => { + const { account } = await setupSellerWithStore(prisma); + const user = await createAccount(prisma, { account_type: 'USER' }); + + await expect( + service.sellerMarkConversationRead(account.id, ''), + ).rejects.toThrowDomain('INVALID_ID'); + await expect( + service.sellerMarkConversationRead(user.id, '1'), + ).rejects.toThrowDomain('SELLER_ONLY'); + }); }); describe('sellerConversationMessages', () => { @@ -192,6 +321,36 @@ describe('SellerConversationService (real DB)', () => { }); expect(auditLogs).toHaveLength(1); }); + + it('답장 뒤 목록 미읽음은 0이고, 판매자 이벤트에 닉네임·마커·미읽음 0이 실린다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const conv = await setupConversation(store.id, { buyerNickname: '현진' }); + await addUserMessage(conv, { createdAt: hoursAgo(1) }); + const publishSeller = jest.spyOn(events, 'publishSellerListUpdate'); + + const reply = await service.sellerSendConversationMessage(account.id, { + conversationId: conv.id.toString(), + bodyFormat: 'TEXT', + bodyText: '답장', + }); + + expect( + (await service.sellerConversations(account.id)).items[0], + ).toMatchObject({ + unreadCount: 0, + lastMessagePreview: '답장', + }); + expect(publishSeller).toHaveBeenCalledWith(store.id.toString(), { + conversationId: conv.id.toString(), + accountId: conv.account_id.toString(), + buyerNickname: '현진', + lastMessagePreview: '답장', + lastMessageAt: reply.createdAt.toISOString(), + sellerLastReadAt: reply.createdAt.toISOString(), + unreadCount: 0, + }); + publishSeller.mockRestore(); + }); }); describe('sellerConversations / sellerConversationMessages cursor 분기', () => { diff --git a/src/features/conversation/services/conversation-seller.service.ts b/src/features/conversation/services/conversation-seller.service.ts index e2491617..d0e54e9a 100644 --- a/src/features/conversation/services/conversation-seller.service.ts +++ b/src/features/conversation/services/conversation-seller.service.ts @@ -64,25 +64,46 @@ export class SellerConversationService extends SellerBaseService { ? parseTimestampIdCursor(input.cursor) : undefined; - const [rows, totalCount] = await Promise.all([ - this.conversationRepository.listConversationsByStore({ + const { rows, totalCount, extras } = + await this.conversationRepository.getStoreConversationPageWithExtras({ storeId: ctx.storeId, limit, ...(cursor ? { cursor: { updatedAt: cursor.timestamp, id: cursor.id } } : {}), - }), - this.conversationRepository.countConversationsByStore(ctx.storeId), - ]); + }); const page = sliceCursorPage(rows, limit, (last) => buildTimestampIdCursor(last.updated_at, last.id), ); + const extraById = new Map( + extras.map((e) => [e.conversationId.toString(), e]), + ); return toCursorConnection(page, totalCount, (row) => - this.toConversationOutput(row), + this.toConversationOutput(row, extraById.get(row.id.toString())), ); } + /** 읽음은 앱이 명시 호출한다(조회 부수효과 없음). 이벤트는 내지 않는다. */ + async sellerMarkConversationRead( + accountId: bigint, + conversationIdRaw: string, + ): Promise { + const ctx = await this.requireSellerContext(accountId); + const conversationId = parseId(conversationIdRaw); + const conversation = + await this.conversationRepository.findConversationByIdAndStore({ + conversationId, + storeId: ctx.storeId, + }); + if (!conversation) throw new DomainException('CONVERSATION_NOT_FOUND'); + + const marked = + await this.conversationRepository.markSellerRead(conversationId); + if (!marked) throw new DomainException('CONVERSATION_NOT_FOUND'); + return this.toConversationOutput(marked.conversation, marked.extra); + } + async sellerConversationMessages( accountId: bigint, conversationId: bigint, @@ -256,8 +277,12 @@ export class SellerConversationService extends SellerBaseService { { conversationId: args.conversation.id.toString(), accountId: args.conversation.account_id.toString(), + buyerNickname: snapshot?.conversation.buyer_nickname_snapshot ?? null, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, + sellerLastReadAt: + snapshot?.conversation.seller_last_read_at?.toISOString() ?? null, + unreadCount: snapshot?.sellerUnreadCount ?? 0, }, ); } @@ -268,20 +293,32 @@ export class SellerConversationService extends SellerBaseService { throw new DomainException('INVALID_BODY_FORMAT'); } - private toConversationOutput(row: { - id: bigint; - account_id: bigint; - store_id: bigint; - last_message_at: Date | null; - last_read_at: Date | null; - updated_at: Date; - }): SellerConversationOutput { + private toConversationOutput( + row: { + id: bigint; + account_id: bigint; + store_id: bigint; + buyer_nickname_snapshot: string | null; + last_message_at: Date | null; + last_read_at: Date | null; + seller_last_read_at: Date | null; + updated_at: Date; + }, + extra?: { + lastMessage: Parameters[0]; + unreadCount: number; + }, + ): SellerConversationOutput { return { id: row.id.toString(), accountId: row.account_id.toString(), storeId: row.store_id.toString(), + buyerNickname: row.buyer_nickname_snapshot, + lastMessagePreview: toLastMessagePreview(extra?.lastMessage ?? null), lastMessageAt: row.last_message_at, lastReadAt: row.last_read_at, + sellerLastReadAt: row.seller_last_read_at, + unreadCount: extra?.unreadCount ?? 0, updatedAt: row.updated_at, }; } diff --git a/src/features/conversation/services/conversation-subscription.service.spec.ts b/src/features/conversation/services/conversation-subscription.service.spec.ts index 89f6d086..b79a0aa8 100644 --- a/src/features/conversation/services/conversation-subscription.service.spec.ts +++ b/src/features/conversation/services/conversation-subscription.service.spec.ts @@ -19,6 +19,7 @@ import { createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; describe('ConversationSubscriptionService (real DB)', () => { let service: ConversationSubscriptionService; @@ -32,6 +33,8 @@ describe('ConversationSubscriptionService (real DB)', () => { ConversationInquiryService, ConversationEventsService, ConversationRepository, + // 발행 repository가 OutboxPublisher를 주입받는다 + ...outboxPublisherProviders(), AccountUserRepository, StoreSellerRepository, { provide: CATALOG_QUERY, useClass: StoreCatalogQueryRepository }, diff --git a/src/features/conversation/types/conversation-output.type.ts b/src/features/conversation/types/conversation-output.type.ts index af7f8339..67d20982 100644 --- a/src/features/conversation/types/conversation-output.type.ts +++ b/src/features/conversation/types/conversation-output.type.ts @@ -81,6 +81,9 @@ export interface ConversationListUpdateEvent { export interface SellerConversationListUpdateEvent { conversationId: string; accountId: string; + buyerNickname: string | null; lastMessagePreview: string | null; lastMessageAt: string; + sellerLastReadAt: string | null; + unreadCount: number; } diff --git a/src/features/conversation/types/conversation-seller-output.type.ts b/src/features/conversation/types/conversation-seller-output.type.ts index e5b80923..61a93e1b 100644 --- a/src/features/conversation/types/conversation-seller-output.type.ts +++ b/src/features/conversation/types/conversation-seller-output.type.ts @@ -2,8 +2,12 @@ export interface SellerConversationOutput { id: string; accountId: string; storeId: string; + buyerNickname: string | null; + lastMessagePreview: string | null; lastMessageAt: Date | null; lastReadAt: Date | null; + sellerLastReadAt: Date | null; + unreadCount: number; updatedAt: Date; } diff --git a/src/features/dashboard/dashboard-seller.graphql b/src/features/dashboard/dashboard-seller.graphql new file mode 100644 index 00000000..dc49363d --- /dev/null +++ b/src/features/dashboard/dashboard-seller.graphql @@ -0,0 +1,79 @@ +extend type Query { + """ + 판매자 홈 지표를 요청 시점에 집계한다(스냅샷 테이블 없음). 날짜는 KST 달력일이며 생략하면 서버의 오늘(KST). + 형식이 YYYY-MM-DD가 아니거나 존재하지 않는 날짜면 BAD_USER_INPUT. 판매자 로그인 필수. + SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + """ + sellerDashboard(input: SellerDashboardInput): SellerDashboard! +} + +""" +판매자 홈 집계 조건. +""" +input SellerDashboardInput { + """ + 집계 기준 날짜(KST, YYYY-MM-DD). 생략하면 오늘. + """ + date: String +} + +""" +판매자 홈 지표. 금액은 원 단위 정수다. +""" +type SellerDashboard { + """ + 집계 기준 날짜(KST, YYYY-MM-DD). + """ + date: String! + """ + 집계 시점(서버 시각). 화면의 'N시 기준' 표기용이며 date가 오늘이 아니어도 현재 시각이다. + """ + asOf: DateTime! + """ + 접수(SUBMITTED) 상태로 남아 있는 주문 수. 날짜와 무관하게 전체를 센다. + """ + newOrderCount: Int! + """ + 기준일에 픽업 예정인 주문의 건수·금액. 취소 주문은 제외. 시안의 '오늘 매출 / 정산예정'에 해당한다. + """ + pickupDay: SellerDashboardOrderMetrics! + """ + 기준일에 생성된 주문의 건수·금액. 취소 주문은 제외. 접수 추세 표기('+N')용. + """ + createdDay: SellerDashboardOrderMetrics! + """ + 기준일의 제작 수량 설정값. 설정이 없으면 null(무제한). + """ + capacity: Int + """ + 기준일의 남은 제작 수량 = 설정값 − 기준일 픽업 주문의 수량 합(취소 제외). 음수가 되면 0으로 자른다. + 설정이 없으면 null. + """ + remainingCapacity: Int + """ + 기준일 픽업 주문의 제작 수량 합(취소 제외). capacity 유무와 무관하게 센다. + """ + bookedQuantity: Int! + """ + 노출 중(is_active)인 내 상품 수. 매장 노출 여부와 무관하다. + """ + activeProductCount: Int! + """ + 답변이 필요한 대화 수 = 판매자 기준 읽지 않은 구매자 메시지가 있는 대화 수. + """ + unansweredConversationCount: Int! +} + +""" +기간 내 주문 건수와 결제 금액 합. +""" +type SellerDashboardOrderMetrics { + """ + 주문 건수. + """ + orderCount: Int! + """ + 최종 결제 금액 합(원). Int(32비트)를 넘을 수 있어 Float로 내리지만 값은 항상 정수다. + """ + salesAmount: Float! +} diff --git a/src/features/dashboard/dashboard.module.ts b/src/features/dashboard/dashboard.module.ts index eceed83a..edd0cc58 100644 --- a/src/features/dashboard/dashboard.module.ts +++ b/src/features/dashboard/dashboard.module.ts @@ -2,10 +2,13 @@ import { Module } from '@nestjs/common'; import { AuditLogModule } from '@/features/audit-log'; import { AuthModule } from '@/features/auth'; +import { ConversationModule } from '@/features/conversation'; import { AdminAuditQueryResolver } from '@/features/dashboard/resolvers/dashboard-admin-audit-query.resolver'; import { AdminDashboardQueryResolver } from '@/features/dashboard/resolvers/dashboard-admin-query.resolver'; +import { SellerDashboardQueryResolver } from '@/features/dashboard/resolvers/dashboard-seller-query.resolver'; import { AdminAuditService } from '@/features/dashboard/services/dashboard-admin-audit.service'; import { AdminDashboardService } from '@/features/dashboard/services/dashboard-admin.service'; +import { SellerDashboardService } from '@/features/dashboard/services/dashboard-seller.service'; import { OrderModule } from '@/features/order'; import { ProductModule } from '@/features/product'; import { ReviewModule } from '@/features/review'; @@ -13,7 +16,7 @@ import { SearchModule } from '@/features/search'; import { StoreModule } from '@/features/store'; /** - * 관리자 운영 화면의 다도메인 집계(대시보드 요약·검색어 스냅샷·전역 감사 로그). 소유 모델이 없고 각 도메인 배럴을 읽기만 한다. + * 관리자 운영 화면(대시보드 요약·검색어 스냅샷·전역 감사 로그)과 판매자 홈의 다도메인 집계. 소유 모델이 없고 각 도메인 배럴을 읽기만 한다. * 전역 감사 로그 화면이 audit-log가 아니라 여기 있는 이유: 관리자 컨텍스트(auth)를 audit-log가 import하면 auth ↔ audit-log 순환. */ @Module({ @@ -25,12 +28,16 @@ import { StoreModule } from '@/features/store'; ProductModule, ReviewModule, SearchModule, + ConversationModule, ], providers: [ AdminDashboardService, AdminAuditService, AdminDashboardQueryResolver, AdminAuditQueryResolver, + // 판매자 홈 집계 + SellerDashboardService, + SellerDashboardQueryResolver, ], }) export class DashboardModule {} diff --git a/src/features/dashboard/dto/inputs/seller-dashboard.input.spec.ts b/src/features/dashboard/dto/inputs/seller-dashboard.input.spec.ts new file mode 100644 index 00000000..eb43221a --- /dev/null +++ b/src/features/dashboard/dto/inputs/seller-dashboard.input.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerDashboardInput } from '@/features/dashboard/dto/inputs/seller-dashboard.input'; + +function build(plain: object): SellerDashboardInput { + return plainToInstance(SellerDashboardInput, plain); +} + +describe('SellerDashboardInput', () => { + it.each([ + ['생략', {}], + ['null', { date: null }], + ['문자열', { date: '2026-10-05' }], + // 형식·존재 여부는 service가 INVALID_DATE로 판정한다 + ['빈 문자열', { date: '' }], + ['형식 밖 문자열', { date: '2026-1-5' }], + ])('date %s 통과', async (_label, plain) => { + expect(await validate(build(plain))).toHaveLength(0); + }); + + it('date 문자열 아님 거절', async () => { + const errors = await validate(build({ date: 20261005 })); + expect(errors.map((e) => e.property)).toEqual(['date']); + }); +}); diff --git a/src/features/dashboard/dto/inputs/seller-dashboard.input.ts b/src/features/dashboard/dto/inputs/seller-dashboard.input.ts new file mode 100644 index 00000000..f69f25a6 --- /dev/null +++ b/src/features/dashboard/dto/inputs/seller-dashboard.input.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString } from 'class-validator'; + +/** 날짜 형식·존재 여부는 service가 parseKstDate로 판정한다(pickupTimeSlots와 동일). */ +export class SellerDashboardInput { + @IsOptional() + @IsString() + date?: string | null; +} diff --git a/src/features/dashboard/resolvers/dashboard-seller-query.resolver.ts b/src/features/dashboard/resolvers/dashboard-seller-query.resolver.ts new file mode 100644 index 00000000..efe60217 --- /dev/null +++ b/src/features/dashboard/resolvers/dashboard-seller-query.resolver.ts @@ -0,0 +1,29 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { SellerDashboardInput } from '@/features/dashboard/dto/inputs/seller-dashboard.input'; +import { SellerDashboardService } from '@/features/dashboard/services/dashboard-seller.service'; +import type { SellerDashboardOutput } from '@/features/dashboard/types/dashboard-seller-output.type'; +import { + CurrentUser, + JwtAuthGuard, + Roles, + RolesGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('SELLER') +export class SellerDashboardQueryResolver { + constructor(private readonly dashboardService: SellerDashboardService) {} + + @Query('sellerDashboard') + sellerDashboard( + @CurrentUser() user: JwtUser, + @Args('input', { nullable: true }) input?: SellerDashboardInput, + ): Promise { + return this.dashboardService.sellerDashboard(parseAccountId(user), input); + } +} diff --git a/src/features/dashboard/resolvers/dashboard-seller.resolver.spec.ts b/src/features/dashboard/resolvers/dashboard-seller.resolver.spec.ts new file mode 100644 index 00000000..f683d072 --- /dev/null +++ b/src/features/dashboard/resolvers/dashboard-seller.resolver.spec.ts @@ -0,0 +1,64 @@ +import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { SellerDashboardQueryResolver } from '@/features/dashboard/resolvers/dashboard-seller-query.resolver'; +import { SellerDashboardService } from '@/features/dashboard/services/dashboard-seller.service'; +import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { bookedQuantityProviders } from '@/test/booked-quantity'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createProduct, setupSellerWithStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; + +// 분기·집계 세부 검증은 dashboard-seller.service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로만 본다. +describe('Seller Dashboard Resolver (real DB)', () => { + let resolver: SellerDashboardQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerDashboardQueryResolver, + SellerDashboardService, + StoreSellerRepository, + OrderRepository, + ProductRepository, + ConversationRepository, + ClockService, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ...bookedQuantityProviders(), + // 발행 repository가 OutboxPublisher를 주입받는다(08b) + ...outboxPublisherProviders(), + ], + }); + resolver = module.get(SellerDashboardQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('Query.sellerDashboard 경로(input 생략)', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await createProduct(prisma, { store_id: store.id }); + + const result = await resolver.sellerDashboard({ + accountId: account.id.toString(), + accountType: 'SELLER', + }); + expect(result.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(result.activeProductCount).toBe(1); + expect(result.capacity).toBeNull(); + }); +}); diff --git a/src/features/dashboard/services/dashboard-seller.service.spec.ts b/src/features/dashboard/services/dashboard-seller.service.spec.ts new file mode 100644 index 00000000..12ed003d --- /dev/null +++ b/src/features/dashboard/services/dashboard-seller.service.spec.ts @@ -0,0 +1,371 @@ +import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { SellerDashboardService } from '@/features/dashboard/services/dashboard-seller.service'; +import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import type { OrderStatus, PrismaClient } from '@/generated/prisma/client'; +import { bookedQuantityProviders } from '@/test/booked-quantity'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder, + createOrderItem, + createProduct, + createStoreDailyCapacity, + setupSellerWithStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { outboxPublisherProviders } from '@/test/outbox'; + +// KST 2026-10-05 12:00 — 기준일 범위는 [10-04T15:00Z, 10-05T15:00Z) +const NOW = new Date('2026-10-05T03:00:00Z'); +const TODAY = '2026-10-05'; +const TODAY_DATE_ONLY = new Date('2026-10-05'); +const DAY_START = new Date('2026-10-04T15:00:00Z'); +const BEFORE_DAY_START = new Date('2026-10-04T14:59:59.999Z'); +const IN_DAY = new Date('2026-10-05T05:00:00Z'); +const YESTERDAY = new Date('2026-10-04T05:00:00Z'); + +describe('SellerDashboardService (real DB)', () => { + let service: SellerDashboardService; + let conversations: ConversationRepository; + let clock: ClockService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerDashboardService, + StoreSellerRepository, + OrderRepository, + ProductRepository, + ConversationRepository, + ClockService, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ...bookedQuantityProviders(), + // 발행 repository가 OutboxPublisher를 주입받는다(08b) + ...outboxPublisherProviders(), + ], + }); + service = module.get(SellerDashboardService); + conversations = module.get(ConversationRepository); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.spyOn(clock, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function storeOrder( + storeId: bigint, + overrides: { + status?: OrderStatus; + pickupAt?: Date; + createdAt?: Date; + totalPrice?: number; + quantity?: number; + deletedAt?: Date; + itemDeletedAt?: Date; + } = {}, + ) { + const order = await createOrder(prisma, { + status: overrides.status, + pickup_at: overrides.pickupAt ?? IN_DAY, + created_at: overrides.createdAt ?? IN_DAY, + total_price: overrides.totalPrice, + deleted_at: overrides.deletedAt, + }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: storeId, + quantity: overrides.quantity, + deleted_at: overrides.itemDeletedAt, + }); + return order; + } + + async function dashboard(accountId: bigint, date?: string | null) { + return service.sellerDashboard( + accountId, + date === undefined ? undefined : { date }, + ); + } + + describe('기준 날짜', () => { + it('생략하면 clock.now()의 KST 오늘이고 asOf는 현재 시각이다', async () => { + const { account } = await setupSellerWithStore(prisma); + const result = await dashboard(account.id); + expect(result.date).toBe(TODAY); + expect(result.asOf).toEqual(NOW); + expect(result).toMatchObject({ + newOrderCount: 0, + pickupDay: { orderCount: 0, salesAmount: 0 }, + createdDay: { orderCount: 0, salesAmount: 0 }, + capacity: null, + remainingCapacity: null, + bookedQuantity: 0, + activeProductCount: 0, + unansweredConversationCount: 0, + }); + }); + + it('null이면 오늘로 본다', async () => { + const { account } = await setupSellerWithStore(prisma); + expect((await dashboard(account.id, null)).date).toBe(TODAY); + }); + + it('다른 날짜를 주면 그 날짜 기준이고 asOf는 그대로 현재 시각이다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await storeOrder(store.id, { pickupAt: YESTERDAY, totalPrice: 500 }); + const result = await dashboard(account.id, '2026-10-04'); + expect(result.date).toBe('2026-10-04'); + expect(result.asOf).toEqual(NOW); + expect(result.pickupDay).toEqual({ orderCount: 1, salesAmount: 500 }); + }); + + it.each([ + ['존재하지 않는 날짜', '2026-02-30'], + ['자릿수 부족', '2026-1-5'], + ['빈 문자열(오늘로 폴백하지 않는다)', ''], + ])('%s → INVALID_DATE', async (_label, date) => { + const { account } = await setupSellerWithStore(prisma); + await expect(dashboard(account.id, date)).rejects.toThrowDomain( + 'INVALID_DATE', + ); + }); + + it('반증: KST 자정(14:59:59.999Z vs 15:00:00Z)으로 날짜를 가른다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await storeOrder(store.id, { + pickupAt: DAY_START, + createdAt: DAY_START, + totalPrice: 1, + quantity: 1, + }); + await storeOrder(store.id, { + pickupAt: BEFORE_DAY_START, + createdAt: BEFORE_DAY_START, + totalPrice: 10, + quantity: 10, + }); + + const today = await dashboard(account.id); + expect(today.pickupDay).toEqual({ orderCount: 1, salesAmount: 1 }); + expect(today.createdDay).toEqual({ orderCount: 1, salesAmount: 1 }); + expect(today.bookedQuantity).toBe(1); + + const yesterday = await dashboard(account.id, '2026-10-04'); + expect(yesterday.pickupDay).toEqual({ orderCount: 1, salesAmount: 10 }); + expect(yesterday.createdDay).toEqual({ orderCount: 1, salesAmount: 10 }); + expect(yesterday.bookedQuantity).toBe(10); + }); + }); + + describe('newOrderCount', () => { + it('SUBMITTED만 날짜와 무관하게 세고 다른 매장·soft-delete 주문은 뺀다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + await storeOrder(store.id, { status: 'SUBMITTED' }); + await storeOrder(store.id, { + status: 'SUBMITTED', + pickupAt: YESTERDAY, + createdAt: YESTERDAY, + }); + await storeOrder(store.id, { status: 'CONFIRMED' }); + await storeOrder(store.id, { status: 'CANCELED' }); + await storeOrder(store.id, { status: 'SUBMITTED', deletedAt: NOW }); + await storeOrder(store.id, { status: 'SUBMITTED', itemDeletedAt: NOW }); + await storeOrder(other.store.id, { status: 'SUBMITTED' }); + + expect((await dashboard(account.id)).newOrderCount).toBe(2); + }); + }); + + describe('pickupDay · createdDay', () => { + it('픽업 기준과 생성 기준을 각자의 컬럼으로 센다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + // 픽업은 오늘, 생성은 어제 + await storeOrder(store.id, { + pickupAt: IN_DAY, + createdAt: YESTERDAY, + totalPrice: 1_000, + }); + // 픽업은 내일, 생성은 오늘 + await storeOrder(store.id, { + pickupAt: new Date('2026-10-06T05:00:00Z'), + createdAt: IN_DAY, + totalPrice: 2_000, + }); + await storeOrder(store.id, { + pickupAt: IN_DAY, + createdAt: IN_DAY, + totalPrice: 4_000, + }); + + const result = await dashboard(account.id); + expect(result.pickupDay).toEqual({ orderCount: 2, salesAmount: 5_000 }); + expect(result.createdDay).toEqual({ orderCount: 2, salesAmount: 6_000 }); + }); + + it('CANCELED·soft-delete 주문·품목이 soft-delete된 주문·다른 매장은 뺀다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + await storeOrder(store.id, { status: 'PICKED_UP', totalPrice: 1_000 }); + await storeOrder(store.id, { status: 'CANCELED', totalPrice: 2_000 }); + await storeOrder(store.id, { totalPrice: 4_000, deletedAt: NOW }); + await storeOrder(store.id, { totalPrice: 8_000, itemDeletedAt: NOW }); + await storeOrder(other.store.id, { totalPrice: 16_000 }); + + const result = await dashboard(account.id); + expect(result.pickupDay).toEqual({ orderCount: 1, salesAmount: 1_000 }); + expect(result.createdDay).toEqual({ orderCount: 1, salesAmount: 1_000 }); + }); + }); + + describe('capacity · remainingCapacity · bookedQuantity', () => { + it('설정이 없으면 capacity·remainingCapacity는 null이고 예약 수량은 그대로 센다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await storeOrder(store.id, { quantity: 3 }); + expect(await dashboard(account.id)).toMatchObject({ + capacity: null, + remainingCapacity: null, + bookedQuantity: 3, + }); + }); + + it('설정 10 − 예약 3(2+1) = 7, CANCELED·품목 soft-delete·다른 날짜 픽업은 예약에서 뺀다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: TODAY_DATE_ONLY, + capacity: 10, + }); + await storeOrder(store.id, { quantity: 2 }); + await storeOrder(store.id, { quantity: 1, createdAt: YESTERDAY }); + await storeOrder(store.id, { quantity: 5, status: 'CANCELED' }); + await storeOrder(store.id, { quantity: 5, itemDeletedAt: NOW }); + await storeOrder(store.id, { quantity: 5, pickupAt: YESTERDAY }); + + expect(await dashboard(account.id)).toMatchObject({ + capacity: 10, + remainingCapacity: 7, + bookedQuantity: 3, + }); + }); + + it('예약이 설정을 넘으면 remainingCapacity는 0이다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: TODAY_DATE_ONLY, + capacity: 2, + }); + await storeOrder(store.id, { quantity: 5 }); + expect(await dashboard(account.id)).toMatchObject({ + capacity: 2, + remainingCapacity: 0, + bookedQuantity: 5, + }); + }); + + it('설정 row가 soft-delete됐거나 다른 날짜면 설정 없음으로 본다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: TODAY_DATE_ONLY, + capacity: 10, + deleted_at: NOW, + }); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: new Date('2026-10-06'), + capacity: 20, + }); + expect(await dashboard(account.id)).toMatchObject({ + capacity: null, + remainingCapacity: null, + }); + }); + }); + + describe('activeProductCount', () => { + it('is_active 상품만 세고 soft-delete·다른 매장은 뺀다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + await createProduct(prisma, { store_id: store.id }); + await createProduct(prisma, { store_id: store.id }); + await createProduct(prisma, { store_id: store.id, is_active: false }); + await createProduct(prisma, { store_id: store.id, deleted_at: NOW }); + await createProduct(prisma, { store_id: other.store.id }); + + expect((await dashboard(account.id)).activeProductCount).toBe(2); + }); + + it('반증: 매장이 비활성이어도 내 상품은 센다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await createProduct(prisma, { store_id: store.id }); + await prisma.store.update({ + where: { id: store.id }, + data: { is_active: false }, + }); + expect((await dashboard(account.id)).activeProductCount).toBe(1); + }); + }); + + describe('unansweredConversationCount', () => { + it('읽지 않은 구매자 메시지가 있는 대화 1 → 읽음 처리 뒤 0', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const customer = await createAccount(prisma, { account_type: 'USER' }); + const conversation = await prisma.storeConversation.create({ + data: { + account_id: customer.id, + store_id: store.id, + last_message_at: YESTERDAY, + }, + }); + await prisma.storeConversationMessage.create({ + data: { + conversation_id: conversation.id, + sender_type: 'USER', + sender_account_id: customer.id, + body_format: 'TEXT', + body_text: '문의', + created_at: YESTERDAY, + }, + }); + + expect((await dashboard(account.id)).unansweredConversationCount).toBe(1); + await conversations.markSellerRead(conversation.id); + expect((await dashboard(account.id)).unansweredConversationCount).toBe(0); + }); + }); + + describe('권한', () => { + it('USER 계정 → SELLER_ONLY', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await expect(dashboard(user.id)).rejects.toThrowDomain('SELLER_ONLY'); + }); + + it('매장 없는 SELLER → STORE_NOT_FOUND', async () => { + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + await expect(dashboard(seller.id)).rejects.toThrowDomain( + 'STORE_NOT_FOUND', + ); + }); + }); +}); diff --git a/src/features/dashboard/services/dashboard-seller.service.ts b/src/features/dashboard/services/dashboard-seller.service.ts new file mode 100644 index 00000000..cad710e8 --- /dev/null +++ b/src/features/dashboard/services/dashboard-seller.service.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { + BOOKED_QUANTITY_QUERY, + type IBookedQuantityQuery, +} from '@/common/ports/booked-quantity.port'; +import { ClockService } from '@/common/providers/clock.service'; +import { + formatKstDate, + kstDayBoundaries, + parseKstDate, +} from '@/common/utils/kst-time'; +import { + AUDIT_LOG_REPOSITORY, + type IAuditLogRepository, +} from '@/features/audit-log'; +import { ConversationRepository } from '@/features/conversation'; +import type { SellerDashboardInput } from '@/features/dashboard/dto/inputs/seller-dashboard.input'; +import type { SellerDashboardOutput } from '@/features/dashboard/types/dashboard-seller-output.type'; +import { OrderRepository } from '@/features/order'; +import { ProductRepository } from '@/features/product'; +import { SellerBaseService, StoreSellerRepository } from '@/features/store'; + +/** + * 요청 시 계산하며 스냅샷 테이블은 두지 않는다(관리자 대시보드와 동일). 읽기 7개를 트랜잭션으로 묶지 않는다 — + * 카드 간 ms 단위 불일치는 허용. capacity는 catalog 정본을, 예약 수량은 픽업 판정과 같은 포트를 읽는다. + */ +@Injectable() +export class SellerDashboardService extends SellerBaseService { + constructor( + repo: StoreSellerRepository, + @Inject(AUDIT_LOG_REPOSITORY) + auditLogs: IAuditLogRepository, + private readonly orders: OrderRepository, + @Inject(BOOKED_QUANTITY_QUERY) + private readonly booked: IBookedQuantityQuery, + private readonly products: ProductRepository, + private readonly conversations: ConversationRepository, + private readonly clock: ClockService, + ) { + super(repo, auditLogs); + } + + async sellerDashboard( + accountId: bigint, + input?: SellerDashboardInput | null, + ): Promise { + const { storeId } = await this.requireSellerContext(accountId); + const asOf = this.clock.now(); + // 빈 문자열은 오늘로 폴백하지 않고 INVALID_DATE + const day = parseKstDate(input?.date ?? formatKstDate(asOf)); + if (!day) throw new DomainException('INVALID_DATE'); + const { dateOnlyUtc, dayStartUtc, dayEndUtc } = kstDayBoundaries(day); + + const [ + newOrderCount, + pickupDay, + createdDay, + capacityRow, + bookedByStore, + activeProductCount, + unansweredConversationCount, + ] = await Promise.all([ + this.orders.countOrdersByStore({ storeId, status: 'SUBMITTED' }), + this.orders.aggregateStoreOrdersInRange({ + storeId, + from: dayStartUtc, + to: dayEndUtc, + basis: 'pickup', + }), + this.orders.aggregateStoreOrdersInRange({ + storeId, + from: dayStartUtc, + to: dayEndUtc, + basis: 'created', + }), + this.repo.findStoreDailyCapacityByDate(storeId, dateOnlyUtc), + this.booked.sumByStore([storeId], dayStartUtc, dayEndUtc), + this.products.countProductsByStore({ storeId, isActive: true }), + this.conversations.countUnansweredConversationsByStore(storeId), + ]); + + const bookedQuantity = bookedByStore.get(storeId) ?? 0; + const capacity = capacityRow?.capacity ?? null; + return { + date: formatKstDate(day), + asOf, + newOrderCount, + pickupDay, + createdDay, + capacity, + // 복제 지연 구간에는 capacity를 넘겨 접수될 수 있어 음수를 0으로 자른다 + remainingCapacity: + capacity === null ? null : Math.max(capacity - bookedQuantity, 0), + bookedQuantity, + activeProductCount, + unansweredConversationCount, + }; + } +} diff --git a/src/features/dashboard/types/dashboard-seller-output.type.ts b/src/features/dashboard/types/dashboard-seller-output.type.ts new file mode 100644 index 00000000..c3a3aa73 --- /dev/null +++ b/src/features/dashboard/types/dashboard-seller-output.type.ts @@ -0,0 +1,17 @@ +export interface SellerDashboardOrderMetrics { + orderCount: number; + salesAmount: number; +} + +export interface SellerDashboardOutput { + date: string; + asOf: Date; + newOrderCount: number; + pickupDay: SellerDashboardOrderMetrics; + createdDay: SellerDashboardOrderMetrics; + capacity: number | null; + remainingCapacity: number | null; + bookedQuantity: number; + activeProductCount: number; + unansweredConversationCount: number; +} diff --git a/src/features/notification/constants/seller-push.constants.ts b/src/features/notification/constants/seller-push.constants.ts new file mode 100644 index 00000000..5a5995bb --- /dev/null +++ b/src/features/notification/constants/seller-push.constants.ts @@ -0,0 +1,23 @@ +// ── 판매자 푸시 디바이스 ── + +/** Expo 푸시 토큰 형식. 구형 `ExponentPushToken[...]`과 신형 `ExpoPushToken[...]` 둘 다 받는다. */ +export const EXPO_PUSH_TOKEN_PATTERN = + /^Expo(nent)?PushToken\[[A-Za-z0-9_-]+\]$/; +export const MAX_PUSH_TOKEN_LENGTH = 200; +export const MAX_PUSH_DEVICE_ID_LENGTH = 128; + +export const PUSH_PLATFORMS = ['IOS', 'ANDROID'] as const; +export type PushPlatformValue = (typeof PUSH_PLATFORMS)[number]; + +/** `SellerPushDevice.disabled_reason` 값. */ +export const PUSH_DEVICE_DISABLED_REASON = { + UNREGISTERED: 'UNREGISTERED', + DEVICE_NOT_REGISTERED: 'DEVICE_NOT_REGISTERED', +} as const; +export type PushDeviceDisabledReason = + (typeof PUSH_DEVICE_DISABLED_REASON)[keyof typeof PUSH_DEVICE_DISABLED_REASON]; + +/** Expo ticket·receipt 오류 코드. 토큰이 죽은 디바이스는 즉시 비활성한다. */ +export const EXPO_ERROR_DEVICE_NOT_REGISTERED = 'DeviceNotRegistered'; +/** Expo가 오류 코드를 안 준 오류 */ +export const EXPO_ERROR_UNKNOWN = 'UNKNOWN'; diff --git a/src/features/notification/dto/inputs/seller-register-push-token.input.spec.ts b/src/features/notification/dto/inputs/seller-register-push-token.input.spec.ts new file mode 100644 index 00000000..0464c450 --- /dev/null +++ b/src/features/notification/dto/inputs/seller-register-push-token.input.spec.ts @@ -0,0 +1,72 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerRegisterPushTokenInput } from '@/features/notification/dto/inputs/seller-register-push-token.input'; +import { SellerUnregisterPushTokenInput } from '@/features/notification/dto/inputs/seller-unregister-push-token.input'; + +const VALID = { token: 'ExponentPushToken[abc-123]', platform: 'IOS' }; + +async function propertiesOf(plain: object): Promise { + const errors = await validate( + plainToInstance(SellerRegisterPushTokenInput, plain), + ); + return errors.map((e) => e.property); +} + +describe('SellerRegisterPushTokenInput', () => { + // Expo 형식은 DTO가 검사하지 않는다(서비스가 INVALID_PUSH_TOKEN) — 여기서는 문자열·길이만 + it.each([ + ['ExponentPushToken[abc-123]', true], + ['not-a-token', true], + ['', true], + [`ExpoPushToken[${'a'.repeat(185)}]`, true], + [`ExpoPushToken[${'a'.repeat(186)}]`, false], + [undefined, false], + [123, false], + ])('token %p → 통과 %s', async (token, ok) => { + expect(await propertiesOf({ ...VALID, token })).toEqual( + ok ? [] : ['token'], + ); + }); + + it.each([ + ['IOS', true], + ['ANDROID', true], + ['WEB', false], + ['ios', false], + [undefined, false], + ])('platform %p → 통과 %s', async (platform, ok) => { + expect(await propertiesOf({ ...VALID, platform })).toEqual( + ok ? [] : ['platform'], + ); + }); + + it.each([ + [undefined, true], + [null, true], + ['device-1', true], + ['a'.repeat(128), true], + ['a'.repeat(129), false], + [123, false], + ])('deviceId %p → 통과 %s', async (deviceId, ok) => { + expect(await propertiesOf({ ...VALID, deviceId })).toEqual( + ok ? [] : ['deviceId'], + ); + }); +}); + +describe('SellerUnregisterPushTokenInput', () => { + it.each([ + ['ExponentPushToken[abc]', true], + ['anything', true], + ['a'.repeat(201), false], + [undefined, false], + ])('token %p → 통과 %s', async (token, ok) => { + const errors = await validate( + plainToInstance(SellerUnregisterPushTokenInput, { token }), + ); + expect(errors.map((e) => e.property)).toEqual(ok ? [] : ['token']); + }); +}); diff --git a/src/features/notification/dto/inputs/seller-register-push-token.input.ts b/src/features/notification/dto/inputs/seller-register-push-token.input.ts new file mode 100644 index 00000000..071a6c5a --- /dev/null +++ b/src/features/notification/dto/inputs/seller-register-push-token.input.ts @@ -0,0 +1,23 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; + +import { + MAX_PUSH_DEVICE_ID_LENGTH, + MAX_PUSH_TOKEN_LENGTH, + PUSH_PLATFORMS, + type PushPlatformValue, +} from '@/features/notification/constants/seller-push.constants'; + +export class SellerRegisterPushTokenInput { + // Expo 형식은 서비스가 INVALID_PUSH_TOKEN으로 검사한다 — 여기서 걸면 VALIDATION_FAILED가 돼 SDL 계약과 어긋난다. + @IsString() + @MaxLength(MAX_PUSH_TOKEN_LENGTH) + token!: string; + + @IsIn(PUSH_PLATFORMS) + platform!: PushPlatformValue; + + @IsOptional() + @IsString() + @MaxLength(MAX_PUSH_DEVICE_ID_LENGTH) + deviceId?: string | null; +} diff --git a/src/features/notification/dto/inputs/seller-unregister-push-token.input.ts b/src/features/notification/dto/inputs/seller-unregister-push-token.input.ts new file mode 100644 index 00000000..00e1ccfb --- /dev/null +++ b/src/features/notification/dto/inputs/seller-unregister-push-token.input.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength } from 'class-validator'; + +import { MAX_PUSH_TOKEN_LENGTH } from '@/features/notification/constants/seller-push.constants'; + +/** 형식 검사는 하지 않는다 — 어떤 문자열이든 본인 행이 없으면 true로 끝난다. */ +export class SellerUnregisterPushTokenInput { + @IsString() + @MaxLength(MAX_PUSH_TOKEN_LENGTH) + token!: string; +} diff --git a/src/features/notification/notification-seller-push.graphql b/src/features/notification/notification-seller-push.graphql new file mode 100644 index 00000000..4fae13de --- /dev/null +++ b/src/features/notification/notification-seller-push.graphql @@ -0,0 +1,57 @@ +extend type Mutation { + """ + 판매자 앱 디바이스의 Expo 푸시 토큰을 등록한다. 판매자 로그인 필수(SELLER 아니면 FORBIDDEN, 매장 없으면 NOT_FOUND). + 같은 토큰을 다시 보내면 마지막 확인 시각만 갱신되고, 다른 판매자가 쓰던 토큰이면 현재 로그인 판매자가 가져간다(앱 재설치·계정 전환). + 해제됐던 토큰은 다시 활성화된다. 토큰이 Expo 형식이 아니면 BAD_REQUEST(INVALID_PUSH_TOKEN). + 앱 시작·로그인마다 호출해도 된다(멱등). + """ + sellerRegisterPushToken(input: SellerRegisterPushTokenInput!): Boolean! + """ + 푸시 토큰을 해제한다(로그아웃). 판매자 로그인 필수(SELLER 아니면 FORBIDDEN). + 매장 컨텍스트를 보지 않으므로 매장이 없는 판매자도 해제할 수 있다. + 본인이 등록한 토큰만 해제되며, 없거나 남의 토큰이면 아무 일도 하지 않고 true다. + """ + sellerUnregisterPushToken(input: SellerUnregisterPushTokenInput!): Boolean! +} + +""" +푸시 토큰 등록 입력 +""" +input SellerRegisterPushTokenInput { + """ + Expo 푸시 토큰. ExponentPushToken[...] 또는 ExpoPushToken[...] 형식, 최대 200자 + """ + token: String! + """ + 디바이스 플랫폼 + """ + platform: PushPlatform! + """ + 앱이 부여한 디바이스 식별자(선택, 최대 128자). 같은 기기의 토큰 교체 추적용이며 서버 로직은 토큰만 본다 + """ + deviceId: String +} + +""" +푸시 토큰 해제 입력 +""" +input SellerUnregisterPushTokenInput { + """ + 해제할 Expo 푸시 토큰 + """ + token: String! +} + +""" +푸시 디바이스 플랫폼 +""" +enum PushPlatform { + """ + iOS(APNs 경유) + """ + IOS + """ + Android(FCM 경유) + """ + ANDROID +} diff --git a/src/features/notification/notification.module.ts b/src/features/notification/notification.module.ts index 6cc10603..f4964fe9 100644 --- a/src/features/notification/notification.module.ts +++ b/src/features/notification/notification.module.ts @@ -4,18 +4,27 @@ import { AuditLogModule } from '@/features/audit-log'; import { AuthModule } from '@/features/auth'; import { NotificationAdminRepository } from '@/features/notification/repositories/notification-admin.repository'; import { NotificationRepository } from '@/features/notification/repositories/notification.repository'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; import { AdminNotificationMutationResolver } from '@/features/notification/resolvers/notification-admin-mutation.resolver'; import { AdminNotificationQueryResolver } from '@/features/notification/resolvers/notification-admin-query.resolver'; import { UserNotificationMutationResolver } from '@/features/notification/resolvers/notification-my-mutation.resolver'; import { UserNotificationQueryResolver } from '@/features/notification/resolvers/notification-my-query.resolver'; +import { SellerPushDeviceMutationResolver } from '@/features/notification/resolvers/notification-seller-push-mutation.resolver'; import { AdminNotificationService } from '@/features/notification/services/notification-admin.service'; import { UserNotificationService } from '@/features/notification/services/notification-my.service'; import { NotificationOutboxConsumer } from '@/features/notification/services/notification-outbox.consumer'; +import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service'; +import { SellerPushOutboxConsumer } from '@/features/notification/services/seller-push-outbox.consumer'; +import { SellerPushReceiptScheduler } from '@/features/notification/services/seller-push-receipt.scheduler'; import { OutboxModule } from '@/features/outbox'; +import { StoreModule } from '@/features/store'; +import { EXPO_PUSH_TRANSPORT, expoPushTransport } from '@/global/expo-push'; -/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점). */ +/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점)·판매자 푸시 디바이스와 전송. */ @Module({ - imports: [AuthModule, AuditLogModule, OutboxModule], + // StoreModule은 판매자 컨텍스트(SellerBaseService → StoreSellerRepository)용 + imports: [AuthModule, AuditLogModule, OutboxModule, StoreModule], providers: [ NotificationAdminRepository, AdminNotificationService, @@ -26,6 +35,13 @@ import { OutboxModule } from '@/features/outbox'; UserNotificationService, UserNotificationQueryResolver, UserNotificationMutationResolver, + SellerPushDeviceRepository, + SellerPushDeviceService, + SellerPushDeviceMutationResolver, + SellerPushDeliveryRepository, + SellerPushOutboxConsumer, + SellerPushReceiptScheduler, + { provide: EXPO_PUSH_TRANSPORT, useValue: expoPushTransport }, ], // 미읽 수는 뷰어 카운트(user → 05c mypage)가 배럴로 읽는다 exports: [NotificationRepository], diff --git a/src/features/notification/repositories/seller-push-delivery.repository.spec.ts b/src/features/notification/repositories/seller-push-delivery.repository.spec.ts new file mode 100644 index 00000000..33117ff9 --- /dev/null +++ b/src/features/notification/repositories/seller-push-delivery.repository.spec.ts @@ -0,0 +1,225 @@ +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createSellerPushDelivery, + createSellerPushDevice, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const EVENT_ID = '11111111-1111-4111-8111-111111111111'; +const AT = new Date('2026-10-05T12:00:00.000Z'); + +describe('SellerPushDeliveryRepository (real DB)', () => { + let repo: SellerPushDeliveryRepository; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SellerPushDeliveryRepository], + }); + repo = module.get(SellerPushDeliveryRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('claim', () => { + it('디바이스마다 PENDING 행을 만들고 그 행들을 디바이스 id 순으로 돌려준다', async () => { + const a = await createSellerPushDevice(prisma); + const b = await createSellerPushDevice(prisma); + + const rows = await repo.claim(EVENT_ID, [b.id, a.id]); + + expect(rows.map((r) => r.push_device_id)).toEqual([a.id, b.id]); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + expect( + await prisma.sellerPushDelivery.findMany({ select: { status: true } }), + ).toEqual([{ status: 'PENDING' }, { status: 'PENDING' }]); + }); + + it('재전달: ticket이 기록된 디바이스는 빼고 PENDING인 디바이스만 다시 돌려주며 행을 더 만들지 않는다', async () => { + const done = await createSellerPushDevice(prisma); + const pending = await createSellerPushDevice(prisma); + await createSellerPushDelivery(prisma, { + source_event_id: EVENT_ID, + push_device_id: done.id, + status: 'TICKET_OK', + ticket_id: 't-1', + sent_at: AT, + }); + await createSellerPushDelivery(prisma, { + source_event_id: EVENT_ID, + push_device_id: pending.id, + }); + + const rows = await repo.claim(EVENT_ID, [done.id, pending.id]); + + expect(rows.map((r) => r.push_device_id)).toEqual([pending.id]); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + }); + + it('다른 이벤트의 행은 선점에 영향을 주지 않는다', async () => { + const device = await createSellerPushDevice(prisma); + await createSellerPushDelivery(prisma, { + source_event_id: '22222222-2222-4222-8222-222222222222', + push_device_id: device.id, + status: 'TICKET_OK', + }); + + const rows = await repo.claim(EVENT_ID, [device.id]); + + expect(rows).toHaveLength(1); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + }); + + it('반증: 없는 디바이스 id는 FK 오류로 던진다(skipDuplicates였다면 조용히 0건)', async () => { + await expect(repo.claim(EVENT_ID, [424242n])).rejects.toThrow(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + + it('디바이스가 없으면 빈 배열', async () => { + expect(await repo.claim(EVENT_ID, [])).toEqual([]); + }); + }); + + describe('markTickets', () => { + it('ticket 결과를 행마다 기록한다', async () => { + const ok = await createSellerPushDelivery(prisma); + const failed = await createSellerPushDelivery(prisma); + + await repo.markTickets([ + { id: ok.id, status: 'TICKET_OK', ticketId: 't-ok', sentAt: AT }, + { + id: failed.id, + status: 'TICKET_ERROR', + errorCode: 'DeviceNotRegistered', + sentAt: AT, + }, + ]); + + expect( + await prisma.sellerPushDelivery.findUnique({ where: { id: ok.id } }), + ).toMatchObject({ + status: 'TICKET_OK', + ticket_id: 't-ok', + error_code: null, + sent_at: AT, + }); + expect( + await prisma.sellerPushDelivery.findUnique({ + where: { id: failed.id }, + }), + ).toMatchObject({ + status: 'TICKET_ERROR', + ticket_id: null, + error_code: 'DeviceNotRegistered', + sent_at: AT, + }); + }); + }); + + describe('listForReceipt', () => { + it('기준 시각 이전에 보낸 미확인 TICKET_OK 행만 오래된 순으로, limit까지', async () => { + const old = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-old', + sent_at: new Date('2026-10-05T11:00:00.000Z'), + }); + const older = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-older', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-recent', + sent_at: AT, + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-checked', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + receipt_checked_at: AT, + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_ERROR', + error_code: 'MessageTooBig', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + }); + + const rows = await repo.listForReceipt({ sentBefore: AT, limit: 10 }); + expect(rows.map((r) => r.id)).toEqual([older.id, old.id]); + expect(rows[0]).toEqual({ + id: older.id, + push_device_id: older.push_device_id, + ticket_id: 't-older', + sent_at: older.sent_at, + }); + + const limited = await repo.listForReceipt({ sentBefore: AT, limit: 1 }); + expect(limited.map((r) => r.id)).toEqual([older.id]); + }); + }); + + describe('markReceipts', () => { + it('영수증 결과와 확인 시각을 기록하고, 오류가 아니면 error_code를 건드리지 않는다', async () => { + const ok = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-1', + }); + const failed = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-2', + }); + const unknown = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-3', + }); + + await repo.markReceipts( + [ + { id: ok.id, status: 'RECEIPT_OK' }, + { + id: failed.id, + status: 'RECEIPT_ERROR', + errorCode: 'MessageTooBig', + }, + { id: unknown.id, status: 'RECEIPT_UNKNOWN' }, + ], + AT, + ); + + const byId = async (id: bigint) => + prisma.sellerPushDelivery.findUnique({ where: { id } }); + expect(await byId(ok.id)).toMatchObject({ + status: 'RECEIPT_OK', + error_code: null, + receipt_checked_at: AT, + }); + expect(await byId(failed.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'MessageTooBig', + receipt_checked_at: AT, + }); + expect(await byId(unknown.id)).toMatchObject({ + status: 'RECEIPT_UNKNOWN', + error_code: null, + receipt_checked_at: AT, + }); + }); + + it('빈 결과는 아무것도 하지 않는다', async () => { + await expect(repo.markReceipts([], AT)).resolves.toBeUndefined(); + await expect(repo.markTickets([])).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/features/notification/repositories/seller-push-delivery.repository.ts b/src/features/notification/repositories/seller-push-delivery.repository.ts new file mode 100644 index 00000000..23d8c479 --- /dev/null +++ b/src/features/notification/repositories/seller-push-delivery.repository.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; + +import type { Prisma } from '@/generated/prisma/client'; +import { PrismaService } from '@/prisma'; + +const pendingSelect = { + id: true, + push_device_id: true, +} satisfies Prisma.SellerPushDeliverySelect; +export type PendingDeliveryRow = Prisma.SellerPushDeliveryGetPayload<{ + select: typeof pendingSelect; +}>; + +const receiptSelect = { + id: true, + push_device_id: true, + ticket_id: true, + sent_at: true, +} satisfies Prisma.SellerPushDeliverySelect; +export type ReceiptCandidateRow = Prisma.SellerPushDeliveryGetPayload<{ + select: typeof receiptSelect; +}> & { ticket_id: string; sent_at: Date }; + +export type TicketResult = + | { id: bigint; status: 'TICKET_OK'; ticketId: string; sentAt: Date } + | { id: bigint; status: 'TICKET_ERROR'; errorCode: string; sentAt: Date }; + +export type ReceiptResult = + | { id: bigint; status: 'RECEIPT_OK' | 'RECEIPT_UNKNOWN' } + | { id: bigint; status: 'RECEIPT_ERROR'; errorCode: string }; + +/** outbox 이벤트 1건 × 디바이스 1개 = 1행. at-least-once 재전달의 dedupe 키이자 ticket → receipt 추적 행. */ +@Injectable() +export class SellerPushDeliveryRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 재전달 흡수: 같은 이벤트로 이미 들어간 디바이스는 빼고 넣는다(unique (source_event_id, push_device_id)가 최종 방어). + * skipDuplicates(INSERT IGNORE)는 FK 위반 같은 다른 오류까지 삼켜 조용히 0건이 되므로 쓰지 않는다. + * 반환은 이 디바이스들 중 아직 전송 전(PENDING)인 행 — 전송은 했는데 ticket 기록 전에 죽은 행은 다시 보내진다(허용 범위). + */ + async claim( + sourceEventId: string, + deviceIds: bigint[], + ): Promise { + if (deviceIds.length === 0) return []; + const claimed = new Set( + ( + await this.prisma.sellerPushDelivery.findMany({ + where: { + source_event_id: sourceEventId, + push_device_id: { in: deviceIds }, + }, + select: { push_device_id: true }, + }) + ).map((row) => row.push_device_id.toString()), + ); + const fresh = deviceIds.filter((id) => !claimed.has(id.toString())); + if (fresh.length > 0) { + await this.prisma.sellerPushDelivery.createMany({ + data: fresh.map((push_device_id) => ({ + source_event_id: sourceEventId, + push_device_id, + })), + }); + } + return this.prisma.sellerPushDelivery.findMany({ + where: { + source_event_id: sourceEventId, + push_device_id: { in: deviceIds }, + status: 'PENDING', + }, + select: pendingSelect, + orderBy: { push_device_id: 'asc' }, + }); + } + + async markTickets(results: TicketResult[]): Promise { + if (results.length === 0) return; + await this.prisma.$transaction( + results.map((result) => + this.prisma.sellerPushDelivery.update({ + where: { id: result.id }, + data: + result.status === 'TICKET_OK' + ? { + status: 'TICKET_OK', + ticket_id: result.ticketId, + sent_at: result.sentAt, + } + : { + status: 'TICKET_ERROR', + error_code: result.errorCode, + sent_at: result.sentAt, + }, + }), + ), + ); + } + + /** 영수증을 아직 안 본 TICKET_OK 행 — 오래된 것부터. */ + async listForReceipt(args: { + sentBefore: Date; + limit: number; + }): Promise { + const rows = await this.prisma.sellerPushDelivery.findMany({ + where: { + status: 'TICKET_OK', + sent_at: { lt: args.sentBefore }, + receipt_checked_at: null, + ticket_id: { not: null }, + }, + select: receiptSelect, + orderBy: { sent_at: 'asc' }, + take: args.limit, + }); + return rows as ReceiptCandidateRow[]; + } + + async markReceipts(results: ReceiptResult[], checkedAt: Date): Promise { + if (results.length === 0) return; + await this.prisma.$transaction( + results.map((result) => + this.prisma.sellerPushDelivery.update({ + where: { id: result.id }, + data: { + status: result.status, + error_code: + result.status === 'RECEIPT_ERROR' ? result.errorCode : undefined, + receipt_checked_at: checkedAt, + }, + }), + ), + ); + } +} diff --git a/src/features/notification/repositories/seller-push-device.repository.spec.ts b/src/features/notification/repositories/seller-push-device.repository.spec.ts new file mode 100644 index 00000000..6594d9e3 --- /dev/null +++ b/src/features/notification/repositories/seller-push-device.repository.spec.ts @@ -0,0 +1,122 @@ +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createSellerPushDelivery, + createSellerPushDevice, + setupSellerWithStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const AT = new Date('2026-10-05T12:00:00.000Z'); + +describe('SellerPushDeviceRepository (real DB)', () => { + let repo: SellerPushDeviceRepository; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SellerPushDeviceRepository], + }); + repo = module.get(SellerPushDeviceRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('listActiveByStore', () => { + it('해당 매장의 활성 디바이스만 id 순으로 돌려준다', async () => { + const seller = await setupSellerWithStore(prisma); + const base = { account_id: seller.account.id, store_id: seller.store.id }; + const first = await createSellerPushDevice(prisma, base); + await createSellerPushDevice(prisma, { ...base, disabled_at: AT }); + const second = await createSellerPushDevice(prisma, base); + await createSellerPushDevice(prisma); + + const rows = await repo.listActiveByStore(seller.store.id); + + expect(rows.map((r) => r.id)).toEqual([first.id, second.id]); + expect(rows[0]).toEqual({ + id: first.id, + expo_push_token: first.expo_push_token, + platform: 'IOS', + }); + }); + + it('디바이스가 없는 매장은 빈 배열', async () => { + expect(await repo.listActiveByStore(424242n)).toEqual([]); + }); + }); + + describe('disableByIds', () => { + it('지정한 활성 행만 사유와 함께 비활성하고 건수를 돌려준다', async () => { + const target = await createSellerPushDevice(prisma); + const untouched = await createSellerPushDevice(prisma); + + const count = await repo.disableByIds( + [target.id], + 'DEVICE_NOT_REGISTERED', + AT, + ); + + expect(count).toBe(1); + const rows = await prisma.sellerPushDevice.findMany({ + orderBy: { id: 'asc' }, + }); + expect(rows[0]).toMatchObject({ + id: target.id, + disabled_at: AT, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(rows[1]).toMatchObject({ id: untouched.id, disabled_at: null }); + }); + + it('이미 해제된 행은 사유·시각을 덮지 않는다', async () => { + const earlier = new Date('2026-10-01T00:00:00.000Z'); + const device = await createSellerPushDevice(prisma, { + disabled_at: earlier, + disabled_reason: 'UNREGISTERED', + }); + + expect( + await repo.disableByIds([device.id], 'DEVICE_NOT_REGISTERED', AT), + ).toBe(0); + + const row = await prisma.sellerPushDevice.findUniqueOrThrow({ + where: { id: device.id }, + }); + expect(row.disabled_at).toEqual(earlier); + expect(row.disabled_reason).toBe('UNREGISTERED'); + }); + + it('빈 배열은 쿼리 없이 0', async () => { + expect(await repo.disableByIds([], 'DEVICE_NOT_REGISTERED', AT)).toBe(0); + }); + }); + + describe('SellerPushDelivery 제약', () => { + it('같은 이벤트·디바이스 조합은 한 행만 허용한다', async () => { + const delivery = await createSellerPushDelivery(prisma); + await expect( + createSellerPushDelivery(prisma, { + source_event_id: delivery.source_event_id, + push_device_id: delivery.push_device_id, + }), + ).rejects.toMatchObject({ code: 'P2002' }); + }); + + it('없는 디바이스를 가리키는 전달 행은 FK로 거부된다', async () => { + await expect( + createSellerPushDelivery(prisma, { push_device_id: 999999n }), + ).rejects.toMatchObject({ code: 'P2003' }); + }); + }); +}); diff --git a/src/features/notification/repositories/seller-push-device.repository.ts b/src/features/notification/repositories/seller-push-device.repository.ts new file mode 100644 index 00000000..5047e338 --- /dev/null +++ b/src/features/notification/repositories/seller-push-device.repository.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; + +import type { PushDeviceDisabledReason } from '@/features/notification/constants/seller-push.constants'; +import type { Prisma, PushPlatform } from '@/generated/prisma/client'; +import { PrismaService } from '@/prisma'; + +export interface UpsertPushDeviceArgs { + token: string; + accountId: bigint; + storeId: bigint; + platform: PushPlatform; + clientDeviceId: string | null; + seenAt: Date; +} + +const activeDeviceSelect = { + id: true, + expo_push_token: true, + platform: true, +} satisfies Prisma.SellerPushDeviceSelect; + +export type ActivePushDeviceRow = Prisma.SellerPushDeviceGetPayload<{ + select: typeof activeDeviceSelect; +}>; + +/** 판매자 푸시 디바이스. `deleted_at`이 없는 모델이라 활성 판정은 `disabled_at: null`을 명시한다. */ +@Injectable() +export class SellerPushDeviceRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 토큰 unique 기준 upsert. 기존 행이면 소유 계정·매장을 현재 판매자로 바꾸고 해제 상태를 푼다. */ + async upsertByToken(args: UpsertPushDeviceArgs): Promise { + const common = { + account_id: args.accountId, + store_id: args.storeId, + platform: args.platform, + client_device_id: args.clientDeviceId, + last_seen_at: args.seenAt, + }; + await this.prisma.sellerPushDevice.upsert({ + where: { expo_push_token: args.token }, + create: { ...common, expo_push_token: args.token }, + update: { ...common, disabled_at: null, disabled_reason: null }, + }); + } + + /** 본인 소유 활성 행만 해제한다. 반환은 해제된 건수(0이면 없거나 남의 토큰). */ + async disableByTokenForAccount(args: { + token: string; + accountId: bigint; + reason: PushDeviceDisabledReason; + at: Date; + }): Promise { + const result = await this.prisma.sellerPushDevice.updateMany({ + where: { + expo_push_token: args.token, + account_id: args.accountId, + disabled_at: null, + }, + data: { disabled_at: args.at, disabled_reason: args.reason }, + }); + return result.count; + } + + async listActiveByStore(storeId: bigint): Promise { + return this.prisma.sellerPushDevice.findMany({ + where: { store_id: storeId, disabled_at: null }, + select: activeDeviceSelect, + orderBy: { id: 'asc' }, + }); + } + + /** 전송 결과(DeviceNotRegistered 등)로 비활성. 이미 해제된 행은 사유를 덮지 않는다. */ + async disableByIds( + ids: bigint[], + reason: PushDeviceDisabledReason, + at: Date, + ): Promise { + if (ids.length === 0) return 0; + const result = await this.prisma.sellerPushDevice.updateMany({ + where: { id: { in: ids }, disabled_at: null }, + data: { disabled_at: at, disabled_reason: reason }, + }); + return result.count; + } +} diff --git a/src/features/notification/resolvers/notification-seller-push-mutation.resolver.ts b/src/features/notification/resolvers/notification-seller-push-mutation.resolver.ts new file mode 100644 index 00000000..520b4d2b --- /dev/null +++ b/src/features/notification/resolvers/notification-seller-push-mutation.resolver.ts @@ -0,0 +1,37 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { SellerRegisterPushTokenInput } from '@/features/notification/dto/inputs/seller-register-push-token.input'; +import { SellerUnregisterPushTokenInput } from '@/features/notification/dto/inputs/seller-unregister-push-token.input'; +import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service'; +import { + CurrentUser, + JwtAuthGuard, + Roles, + RolesGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Mutation') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('SELLER') +export class SellerPushDeviceMutationResolver { + constructor(private readonly service: SellerPushDeviceService) {} + + @Mutation('sellerRegisterPushToken') + sellerRegisterPushToken( + @CurrentUser() user: JwtUser, + @Args('input') input: SellerRegisterPushTokenInput, + ): Promise { + return this.service.register(parseAccountId(user), input); + } + + @Mutation('sellerUnregisterPushToken') + sellerUnregisterPushToken( + @CurrentUser() user: JwtUser, + @Args('input') input: SellerUnregisterPushTokenInput, + ): Promise { + return this.service.unregister(parseAccountId(user), input); + } +} diff --git a/src/features/notification/resolvers/notification-seller-push.resolver.spec.ts b/src/features/notification/resolvers/notification-seller-push.resolver.spec.ts new file mode 100644 index 00000000..dc55f057 --- /dev/null +++ b/src/features/notification/resolvers/notification-seller-push.resolver.spec.ts @@ -0,0 +1,90 @@ +import { ValidationPipe } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { SellerRegisterPushTokenInput } from '@/features/notification/dto/inputs/seller-register-push-token.input'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushDeviceMutationResolver } from '@/features/notification/resolvers/notification-seller-push-mutation.resolver'; +import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { setupSellerWithStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SellerPushDeviceMutationResolver (real DB)', () => { + let resolver: SellerPushDeviceMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushDeviceMutationResolver, + SellerPushDeviceService, + SellerPushDeviceRepository, + StoreSellerRepository, + ClockService, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ], + }); + resolver = module.get(SellerPushDeviceMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('등록 → 해제가 세션 계정 기준으로 한 행에 반영된다', async () => { + const seller = await setupSellerWithStore(prisma); + const user = { accountId: seller.account.id.toString() }; + const token = 'ExpoPushToken[resolver-1]'; + + await expect( + resolver.sellerRegisterPushToken(user, { token, platform: 'ANDROID' }), + ).resolves.toBe(true); + const registered = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(registered).toMatchObject({ + account_id: seller.account.id, + store_id: seller.store.id, + platform: 'ANDROID', + disabled_at: null, + }); + + await expect( + resolver.sellerUnregisterPushToken(user, { token }), + ).resolves.toBe(true); + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.disabled_reason).toBe('UNREGISTERED'); + expect(row.disabled_at).not.toBeNull(); + }); + + it('형식 밖 토큰은 ValidationPipe를 통과해 서비스의 INVALID_PUSH_TOKEN으로 끝난다', async () => { + const seller = await setupSellerWithStore(prisma); + // main.ts와 같은 옵션 — DTO가 먼저 걸면 VALIDATION_FAILED가 돼 SDL 계약과 어긋난다 + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + const input = (await pipe.transform( + { token: 'not-a-token', platform: 'IOS' }, + { type: 'body', metatype: SellerRegisterPushTokenInput }, + )) as SellerRegisterPushTokenInput; + + await expect( + resolver.sellerRegisterPushToken( + { accountId: seller.account.id.toString() }, + input, + ), + ).rejects.toThrowDomain('INVALID_PUSH_TOKEN'); + expect(await prisma.sellerPushDevice.count()).toBe(0); + }); +}); diff --git a/src/features/notification/services/seller-push-device.service.spec.ts b/src/features/notification/services/seller-push-device.service.spec.ts new file mode 100644 index 00000000..5a41b41e --- /dev/null +++ b/src/features/notification/services/seller-push-device.service.spec.ts @@ -0,0 +1,291 @@ +import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createSellerPushDevice, + setupSellerWithStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const TOKEN = 'ExponentPushToken[abc-123]'; +const T0 = new Date('2026-10-05T09:00:00.000Z'); +const T1 = new Date('2026-10-05T10:00:00.000Z'); + +describe('SellerPushDeviceService (real DB)', () => { + let service: SellerPushDeviceService; + let prisma: PrismaClient; + let now: Date; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushDeviceService, + SellerPushDeviceRepository, + StoreSellerRepository, + { provide: ClockService, useValue: { now: () => now } }, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + ], + }); + service = module.get(SellerPushDeviceService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + now = T0; + await truncateAll(); + }); + + const register = (accountId: bigint, token = TOKEN, deviceId = 'dev-1') => + service.register(accountId, { token, platform: 'IOS', deviceId }); + + const unregister = (accountId: bigint, token = TOKEN) => + service.unregister(accountId, { token }); + + describe('register', () => { + it('판매자 매장을 store_id로 하는 활성 행 1개를 만든다', async () => { + const seller = await setupSellerWithStore(prisma); + + await expect(register(seller.account.id)).resolves.toBe(true); + + const rows = await prisma.sellerPushDevice.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + account_id: seller.account.id, + store_id: seller.store.id, + expo_push_token: TOKEN, + platform: 'IOS', + client_device_id: 'dev-1', + last_seen_at: T0, + disabled_at: null, + disabled_reason: null, + }); + }); + + it('deviceId를 생략하면 client_device_id는 null이다', async () => { + const seller = await setupSellerWithStore(prisma); + await service.register(seller.account.id, { + token: TOKEN, + platform: 'ANDROID', + }); + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.client_device_id).toBeNull(); + expect(row.platform).toBe('ANDROID'); + }); + + it('같은 토큰 재등록은 행을 늘리지 않고 last_seen_at만 갱신한다', async () => { + const seller = await setupSellerWithStore(prisma); + await register(seller.account.id); + const before = await prisma.sellerPushDevice.findFirstOrThrow(); + + now = T1; + await register(seller.account.id); + + const rows = await prisma.sellerPushDevice.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].last_seen_at).toEqual(T1); + // updated_at은 Prisma가 올리므로 제외하고 나머지는 전부 그대로 + expect({ ...rows[0], last_seen_at: T0, updated_at: null }).toEqual({ + ...before, + updated_at: null, + }); + }); + + it('해제됐던 토큰을 다시 등록하면 활성으로 돌아온다', async () => { + const seller = await setupSellerWithStore(prisma); + await createSellerPushDevice(prisma, { + account_id: seller.account.id, + store_id: seller.store.id, + expo_push_token: TOKEN, + disabled_at: new Date('2026-10-01T00:00:00.000Z'), + disabled_reason: 'UNREGISTERED', + }); + + await register(seller.account.id); + + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.disabled_at).toBeNull(); + expect(row.disabled_reason).toBeNull(); + expect(row.last_seen_at).toEqual(T0); + }); + + it('다른 판매자가 쓰던 토큰은 현재 판매자가 가져간다(계정·매장 교체, 행 1개)', async () => { + const previous = await setupSellerWithStore(prisma); + const me = await setupSellerWithStore(prisma); + await createSellerPushDevice(prisma, { + account_id: previous.account.id, + store_id: previous.store.id, + expo_push_token: TOKEN, + platform: 'ANDROID', + }); + + await register(me.account.id, TOKEN, 'dev-me'); + + const rows = await prisma.sellerPushDevice.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + account_id: me.account.id, + store_id: me.store.id, + platform: 'IOS', + client_device_id: 'dev-me', + }); + }); + + it('토큰이 다르면 같은 판매자라도 행이 따로 생긴다', async () => { + const seller = await setupSellerWithStore(prisma); + await register(seller.account.id, 'ExponentPushToken[one]'); + await register(seller.account.id, 'ExponentPushToken[two]'); + expect(await prisma.sellerPushDevice.count()).toBe(2); + }); + + it('대소문자만 다른 토큰은 다른 디바이스다 — 행 2개, 각자 소유', async () => { + const upper = await setupSellerWithStore(prisma); + const lower = await setupSellerWithStore(prisma); + await register(upper.account.id, 'ExponentPushToken[AbC]'); + await register(lower.account.id, 'ExponentPushToken[abc]'); + + const rows = await prisma.sellerPushDevice.findMany({ + orderBy: { id: 'asc' }, + }); + expect(rows.map((r) => [r.expo_push_token, r.account_id])).toEqual([ + ['ExponentPushToken[AbC]', upper.account.id], + ['ExponentPushToken[abc]', lower.account.id], + ]); + }); + + it('USER 계정이면 SELLER_ONLY', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await expect(register(user.id)).rejects.toThrowDomain('SELLER_ONLY'); + expect(await prisma.sellerPushDevice.count()).toBe(0); + }); + + it('없는 계정이면 SESSION_ACCOUNT_MISSING', async () => { + await expect(register(999999n)).rejects.toThrowDomain( + 'SESSION_ACCOUNT_MISSING', + ); + }); + + it('매장 없는 SELLER는 STORE_NOT_FOUND', async () => { + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + await expect(register(seller.id)).rejects.toThrowDomain( + 'STORE_NOT_FOUND', + ); + expect(await prisma.sellerPushDevice.count()).toBe(0); + }); + + // 형식 검사는 DTO가 아니라 여기 — DTO에서 걸면 VALIDATION_FAILED가 돼 SDL 계약과 어긋난다 + it.each(['ExpoPushToken[x]', 'ExpoPushToken[a_B-9]'])( + 'Expo 형식 토큰 %p은 등록된다', + async (token) => { + const seller = await setupSellerWithStore(prisma); + await expect(register(seller.account.id, token)).resolves.toBe(true); + expect(await prisma.sellerPushDevice.count()).toBe(1); + }, + ); + + it.each([ + 'not-a-token', + '', + 'ExponentPushToken[]', + 'ExponentPushToken[abc 123]', + ' ExponentPushToken[abc]', + 'ExponentPushToken[abc]\n', + 'expopushtoken[abc]', + ])( + 'Expo 형식이 아닌 토큰 %p은 INVALID_PUSH_TOKEN이고 행을 만들지 않는다', + async (token) => { + const seller = await setupSellerWithStore(prisma); + await expect(register(seller.account.id, token)).rejects.toThrowDomain( + 'INVALID_PUSH_TOKEN', + ); + expect(await prisma.sellerPushDevice.count()).toBe(0); + }, + ); + }); + + describe('unregister', () => { + it('본인 토큰을 UNREGISTERED 사유로 해제한다', async () => { + const seller = await setupSellerWithStore(prisma); + await register(seller.account.id); + + now = T1; + await expect(unregister(seller.account.id)).resolves.toBe(true); + + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.disabled_at).toEqual(T1); + expect(row.disabled_reason).toBe('UNREGISTERED'); + }); + + it('이미 해제된 토큰을 다시 해제해도 true이고 첫 해제 시각이 유지된다', async () => { + const seller = await setupSellerWithStore(prisma); + await register(seller.account.id); + await unregister(seller.account.id); + + now = T1; + await expect(unregister(seller.account.id)).resolves.toBe(true); + + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.disabled_at).toEqual(T0); + }); + + it('등록된 적 없는 토큰 해제는 true이고 아무 행도 만들지 않는다', async () => { + const seller = await setupSellerWithStore(prisma); + await expect(unregister(seller.account.id)).resolves.toBe(true); + expect(await prisma.sellerPushDevice.count()).toBe(0); + }); + + it('남의 토큰 해제는 true를 돌려주되 남의 행은 건드리지 않는다', async () => { + const owner = await setupSellerWithStore(prisma); + const me = await setupSellerWithStore(prisma); + await register(owner.account.id); + const before = await prisma.sellerPushDevice.findFirstOrThrow(); + + now = T1; + await expect(unregister(me.account.id)).resolves.toBe(true); + + const after = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(after).toEqual(before); + expect(after.disabled_at).toBeNull(); + }); + + it('매장 없는 SELLER도 해제할 수 있다(소유권 교체 뒤 남은 본인 행 해제)', async () => { + const sellerWithoutStore = await createAccount(prisma, { + account_type: 'SELLER', + }); + const other = await setupSellerWithStore(prisma); + await createSellerPushDevice(prisma, { + account_id: sellerWithoutStore.id, + store_id: other.store.id, + expo_push_token: TOKEN, + }); + + await expect(unregister(sellerWithoutStore.id)).resolves.toBe(true); + + const row = await prisma.sellerPushDevice.findFirstOrThrow(); + expect(row.disabled_at).toEqual(T0); + expect(row.disabled_reason).toBe('UNREGISTERED'); + }); + + it('USER 계정이면 SELLER_ONLY', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await expect(unregister(user.id)).rejects.toThrowDomain('SELLER_ONLY'); + }); + + it('없는 계정이면 SESSION_ACCOUNT_MISSING', async () => { + await expect(unregister(999999n)).rejects.toThrowDomain( + 'SESSION_ACCOUNT_MISSING', + ); + }); + }); +}); diff --git a/src/features/notification/services/seller-push-device.service.ts b/src/features/notification/services/seller-push-device.service.ts new file mode 100644 index 00000000..02e719b2 --- /dev/null +++ b/src/features/notification/services/seller-push-device.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { ClockService } from '@/common/providers/clock.service'; +import { + AUDIT_LOG_REPOSITORY, + type IAuditLogRepository, +} from '@/features/audit-log'; +import { + EXPO_PUSH_TOKEN_PATTERN, + PUSH_DEVICE_DISABLED_REASON, +} from '@/features/notification/constants/seller-push.constants'; +import type { SellerRegisterPushTokenInput } from '@/features/notification/dto/inputs/seller-register-push-token.input'; +import type { SellerUnregisterPushTokenInput } from '@/features/notification/dto/inputs/seller-unregister-push-token.input'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerBaseService, StoreSellerRepository } from '@/features/store'; +import { AccountType } from '@/generated/prisma/client'; + +/** 판매자 앱 Expo 푸시 토큰 등록·해제. 둘 다 멱등 — 앱이 시작·로그인·로그아웃마다 불러도 된다. */ +@Injectable() +export class SellerPushDeviceService extends SellerBaseService { + constructor( + repo: StoreSellerRepository, + @Inject(AUDIT_LOG_REPOSITORY) + auditLogs: IAuditLogRepository, + private readonly devices: SellerPushDeviceRepository, + private readonly clock: ClockService, + ) { + super(repo, auditLogs); + } + + /** 토큰이 식별자라 다른 판매자가 쓰던 토큰도 현재 판매자가 가져간다(앱 재설치·계정 전환). */ + async register( + accountId: bigint, + input: SellerRegisterPushTokenInput, + ): Promise { + const ctx = await this.requireSellerContext(accountId); + if (!EXPO_PUSH_TOKEN_PATTERN.test(input.token)) { + throw new DomainException('INVALID_PUSH_TOKEN'); + } + await this.devices.upsertByToken({ + token: input.token, + accountId: ctx.accountId, + storeId: ctx.storeId, + platform: input.platform, + clientDeviceId: input.deviceId ?? null, + seenAt: this.clock.now(), + }); + return true; + } + + /** 로그아웃 경로라 매장 컨텍스트를 요구하지 않는다 — 매장이 없는 판매자도 해제할 수 있어야 한다. */ + async unregister( + accountId: bigint, + input: SellerUnregisterPushTokenInput, + ): Promise { + const account = await this.repo.findSellerAccountContext(accountId); + if (!account) throw new DomainException('SESSION_ACCOUNT_MISSING'); + if (account.account_type !== AccountType.SELLER) { + throw new DomainException('SELLER_ONLY'); + } + await this.devices.disableByTokenForAccount({ + token: input.token, + accountId: account.id, + reason: PUSH_DEVICE_DISABLED_REASON.UNREGISTERED, + at: this.clock.now(), + }); + return true; + } +} diff --git a/src/features/notification/services/seller-push-messages.helper.spec.ts b/src/features/notification/services/seller-push-messages.helper.spec.ts new file mode 100644 index 00000000..33370ddf --- /dev/null +++ b/src/features/notification/services/seller-push-messages.helper.spec.ts @@ -0,0 +1,55 @@ +import { + buildBuyerMessagePush, + buildOrderSubmittedPush, + formatKstPickupAt, +} from '@/features/notification/services/seller-push-messages.helper'; + +describe('seller-push-messages.helper', () => { + // UTC → KST(+9), 월·일은 0 채움 없이, 시·분은 두 자리 + it.each([ + ['2026-10-05T03:05:00.000Z', '10/5 12:05'], + ['2026-10-05T15:30:00.000Z', '10/6 00:30'], + ['2026-12-31T16:00:00.000Z', '1/1 01:00'], + ['2026-03-09T00:00:00.000Z', '3/9 09:00'], + ])('formatKstPickupAt(%s) → %s', (iso, expected) => { + expect(formatKstPickupAt(new Date(iso))).toBe(expected); + }); + + it('주문 접수: 상품명·수량·픽업 시각(KST) 본문, 딥링크 data는 kind·orderId', () => { + expect( + buildOrderSubmittedPush({ + orderId: '42', + orderNumber: 'ORD-1', + buyerAccountId: '7', + storeId: '3', + storeName: '케이크샵', + productId: '9', + productName: '레터링 케이크', + quantity: 2, + pickupAt: '2026-10-05T03:05:00.000Z', + totalPrice: 50000, + }), + ).toEqual({ + title: '새 주문', + body: '레터링 케이크 2개 · 픽업 10/5 12:05', + data: { kind: 'ORDER_SUBMITTED', orderId: '42' }, + }); + }); + + it('구매자 문의: 본문은 preview 그대로, data는 kind·conversationId', () => { + expect( + buildBuyerMessagePush({ + conversationId: '11', + storeId: '3', + buyerAccountId: '7', + messageId: '99', + preview: '픽업 시간 바꿀 수 있나요?', + messageCreatedAt: '2026-10-05T03:05:00.000Z', + }), + ).toEqual({ + title: '새 문의', + body: '픽업 시간 바꿀 수 있나요?', + data: { kind: 'BUYER_MESSAGE', conversationId: '11' }, + }); + }); +}); diff --git a/src/features/notification/services/seller-push-messages.helper.ts b/src/features/notification/services/seller-push-messages.helper.ts new file mode 100644 index 00000000..89e8099c --- /dev/null +++ b/src/features/notification/services/seller-push-messages.helper.ts @@ -0,0 +1,51 @@ +import { + formatMinutesOfDay, + kstMinutesOfDay, + toKstYmd, +} from '@/common/utils/kst-time'; +import type { ConversationBuyerMessageSentPayload } from '@/features/conversation'; +import type { OrderSubmittedPayload } from '@/features/order'; + +/** 앱 딥링크 키. */ +const SELLER_PUSH_KIND = { + ORDER_SUBMITTED: 'ORDER_SUBMITTED', + BUYER_MESSAGE: 'BUYER_MESSAGE', +} as const; + +/** Android 알림 채널 — 앱이 같은 id로 만든다. */ +export const SELLER_PUSH_CHANNEL_ID = 'default'; + +export interface SellerPushContent { + title: string; + body: string; + data: Record; +} + +/** `M/d HH:mm`(KST). */ +export function formatKstPickupAt(date: Date): string { + const { month, day } = toKstYmd(date); + return `${month}/${day} ${formatMinutesOfDay(kstMinutesOfDay(date))}`; +} + +export function buildOrderSubmittedPush( + p: OrderSubmittedPayload, +): SellerPushContent { + return { + title: '새 주문', + body: `${p.productName} ${p.quantity}개 · 픽업 ${formatKstPickupAt(new Date(p.pickupAt))}`, + data: { kind: SELLER_PUSH_KIND.ORDER_SUBMITTED, orderId: p.orderId }, + }; +} + +export function buildBuyerMessagePush( + p: ConversationBuyerMessageSentPayload, +): SellerPushContent { + return { + title: '새 문의', + body: p.preview, + data: { + kind: SELLER_PUSH_KIND.BUYER_MESSAGE, + conversationId: p.conversationId, + }, + }; +} diff --git a/src/features/notification/services/seller-push-outbox.consumer.spec.ts b/src/features/notification/services/seller-push-outbox.consumer.spec.ts new file mode 100644 index 00000000..1a335a0c --- /dev/null +++ b/src/features/notification/services/seller-push-outbox.consumer.spec.ts @@ -0,0 +1,455 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushOutboxConsumer } from '@/features/notification/services/seller-push-outbox.consumer'; +import type { OutboxEvent } from '@/features/outbox'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + type ExpoPushTicket, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { MetricsService } from '@/global/metrics'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createSellerPushDevice, setupSellerWithStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const NOW = new Date('2026-10-05T12:00:00.000Z'); +const EVENT_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_EVENT_ID = '22222222-2222-4222-8222-222222222222'; + +function event( + eventType: string, + payload: OutboxEvent['payload'], + eventId = EVENT_ID, +): OutboxEvent { + return { + id: 1n, + eventId, + aggregateType: 'test', + aggregateId: '1', + eventType, + payload, + occurredAt: NOW, + actorAccountId: null, + clientIp: null, + userAgent: null, + attempts: 0, + }; +} + +function orderSubmitted(storeId: bigint, eventId = EVENT_ID): OutboxEvent { + return event( + 'order.submitted', + { + orderId: '42', + orderNumber: 'ORD-1', + buyerAccountId: '7', + storeId: storeId.toString(), + storeName: '케이크샵', + productId: '9', + productName: '레터링 케이크', + quantity: 2, + pickupAt: '2026-10-05T03:05:00.000Z', + totalPrice: 50000, + }, + eventId, + ); +} + +function okTickets(messages: ExpoPushMessage[]): ExpoPushTicket[] { + return messages.map((m) => ({ status: 'ok', id: `ticket:${m.to}` })); +} + +// 판매자 푸시 전송 — 매장 디바이스 fan-out, 재전달 멱등(ticket 기록 행 제외), 배치 100, ticket 오류·인증 실패 처리. +describe('SellerPushOutboxConsumer (real DB)', () => { + let consumer: SellerPushOutboxConsumer; + let deliveryRepository: SellerPushDeliveryRepository; + let metrics: MetricsService; + let prisma: PrismaClient; + let cfg: ExpoPushConfig; + const send = jest.fn< + Promise, + Parameters + >(); + const transport: ExpoPushTransport = { + send, + getReceipts: jest.fn(), + }; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushOutboxConsumer, + SellerPushDeviceRepository, + SellerPushDeliveryRepository, + MetricsService, + { provide: ClockService, useValue: { now: () => NOW } }, + { provide: ConfigService, useValue: { getOrThrow: () => cfg } }, + { provide: AlertService, useValue: alerts }, + { provide: EXPO_PUSH_TRANSPORT, useValue: transport }, + ], + }); + consumer = module.get(SellerPushOutboxConsumer); + deliveryRepository = module.get(SellerPushDeliveryRepository); + metrics = module.get(MetricsService); + prisma = p; + }); + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + cfg = { enabled: true, accessToken: 'tok', requestTimeoutMs: 1_000 }; + send.mockReset(); + send.mockImplementation((messages) => Promise.resolve(okTickets(messages))); + alerts.notify.mockClear(); + metrics.expoPushSends.reset(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + async function storeWithDevices(count: number) { + const seller = await setupSellerWithStore(prisma); + const devices = []; + for (let i = 0; i < count; i++) { + devices.push( + await createSellerPushDevice(prisma, { + account_id: seller.account.id, + store_id: seller.store.id, + }), + ); + } + return { storeId: seller.store.id, devices }; + } + + async function sendsCounter(): Promise> { + const { values } = await metrics.expoPushSends.get(); + return Object.fromEntries( + values.map((v) => [v.labels.result as string, v.value]), + ); + } + + const deliveries = () => + prisma.sellerPushDelivery.findMany({ orderBy: { id: 'asc' } }); + + describe('order.submitted', () => { + it('매장의 활성 디바이스마다 메시지를 한 요청으로 보내고 ticket을 행에 기록한다', async () => { + const { storeId, devices } = await storeWithDevices(2); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(1); + const [messages, options] = send.mock.calls[0]; + expect(options).toEqual({ accessToken: 'tok', timeoutMs: 1_000 }); + expect(messages).toEqual( + devices.map((d) => ({ + to: d.expo_push_token, + title: '새 주문', + body: '레터링 케이크 2개 · 픽업 10/5 12:05', + data: { kind: 'ORDER_SUBMITTED', orderId: '42' }, + channelId: 'default', + })), + ); + const rows = await deliveries(); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.push_device_id)).toEqual( + devices.map((d) => d.id), + ); + expect(rows[0]).toMatchObject({ + source_event_id: EVENT_ID, + status: 'TICKET_OK', + ticket_id: `ticket:${devices[0].expo_push_token}`, + error_code: null, + sent_at: NOW, + receipt_checked_at: null, + }); + expect(await sendsCounter()).toEqual({ TICKET_OK: 2 }); + }); + + it('해제된 디바이스와 다른 매장의 디바이스는 대상이 아니다', async () => { + const { storeId, devices } = await storeWithDevices(1); + await createSellerPushDevice(prisma, { + store_id: storeId, + account_id: devices[0].account_id, + disabled_at: NOW, + disabled_reason: 'UNREGISTERED', + }); + await createSellerPushDevice(prisma); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send.mock.calls[0][0].map((m) => m.to)).toEqual([ + devices[0].expo_push_token, + ]); + expect(await prisma.sellerPushDelivery.count()).toBe(1); + }); + + it('디바이스가 없으면 보내지 않고 행도 남기지 않는다', async () => { + const { storeId } = await storeWithDevices(0); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + + it('같은 이벤트 재전달은 다시 보내지 않는다 — 반증: 다른 이벤트는 보낸다', async () => { + const { storeId } = await storeWithDevices(2); + await consumer.handle(orderSubmitted(storeId)); + expect(send).toHaveBeenCalledTimes(1); + + await consumer.handle(orderSubmitted(storeId)); + expect(send).toHaveBeenCalledTimes(1); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + + await consumer.handle(orderSubmitted(storeId, OTHER_EVENT_ID)); + expect(send).toHaveBeenCalledTimes(2); + expect(await prisma.sellerPushDelivery.count()).toBe(4); + }); + + it('디바이스 150개는 100 + 50 두 요청으로 나눈다', async () => { + const { storeId } = await storeWithDevices(150); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send.mock.calls.map(([m]) => m.length)).toEqual([100, 50]); + expect( + await prisma.sellerPushDelivery.count({ + where: { status: 'TICKET_OK' }, + }), + ).toBe(150); + expect(await sendsCounter()).toEqual({ TICKET_OK: 150 }); + }); + + it('ticket DeviceNotRegistered는 행에 오류 코드를 남기고 그 디바이스만 비활성한다', async () => { + const { storeId, devices } = await storeWithDevices(2); + send.mockResolvedValue([ + { + status: 'error', + message: 'not registered', + details: { error: 'DeviceNotRegistered' }, + }, + { status: 'ok', id: 't-2' }, + ]); + + await consumer.handle(orderSubmitted(storeId)); + + const rows = await deliveries(); + expect(rows[0]).toMatchObject({ + status: 'TICKET_ERROR', + error_code: 'DeviceNotRegistered', + ticket_id: null, + sent_at: NOW, + }); + expect(rows[1]).toMatchObject({ status: 'TICKET_OK', ticket_id: 't-2' }); + const [gone, alive] = await prisma.sellerPushDevice.findMany({ + where: { id: { in: devices.map((d) => d.id) } }, + orderBy: { id: 'asc' }, + }); + expect(gone).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(alive.disabled_at).toBeNull(); + expect(await sendsCounter()).toEqual({ TICKET_ERROR: 1, TICKET_OK: 1 }); + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + it('ticket 기록(markTickets)이 던져도 DeviceNotRegistered 디바이스는 이미 비활성이다', async () => { + const { storeId, devices } = await storeWithDevices(1); + send.mockResolvedValue([ + { + status: 'error', + message: 'not registered', + details: { error: 'DeviceNotRegistered' }, + }, + ]); + jest + .spyOn(deliveryRepository, 'markTickets') + .mockRejectedValueOnce(new Error('db down')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'db down', + ); + + expect( + await prisma.sellerPushDevice.findUniqueOrThrow({ + where: { id: devices[0].id }, + }), + ).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect((await deliveries())[0].status).toBe('PENDING'); + }); + + it('오류 코드가 없는 ticket 오류는 UNKNOWN으로 남기고 디바이스는 살려 둔다', async () => { + const { storeId, devices } = await storeWithDevices(1); + send.mockResolvedValue([{ status: 'error', message: 'too big' }]); + + await consumer.handle(orderSubmitted(storeId)); + + expect((await deliveries())[0]).toMatchObject({ + status: 'TICKET_ERROR', + error_code: 'UNKNOWN', + }); + expect( + ( + await prisma.sellerPushDevice.findUniqueOrThrow({ + where: { id: devices[0].id }, + }) + ).disabled_at, + ).toBeNull(); + }); + + it('전송이 던지면 그대로 던지고 행은 PENDING으로 남아 재전달 때 다시 보낸다', async () => { + const { storeId, devices } = await storeWithDevices(2); + send.mockRejectedValueOnce(new ExpoPushHttpError(503, 'Expo 푸시 전송')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'HTTP 503', + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'PENDING', + 'PENDING', + ]); + expect(alerts.notify).not.toHaveBeenCalled(); + expect(await sendsCounter()).toEqual({}); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[1][0].map((m) => m.to)).toEqual( + devices.map((d) => d.expo_push_token), + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'TICKET_OK', + 'TICKET_OK', + ]); + }); + + it('두 번째 배치만 실패하면 첫 배치는 기록되고 재전달은 남은 50개만 보낸다', async () => { + const { storeId } = await storeWithDevices(150); + send + .mockImplementationOnce((messages) => + Promise.resolve(okTickets(messages)), + ) + .mockRejectedValueOnce(new Error('fetch failed')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'fetch failed', + ); + expect( + await prisma.sellerPushDelivery.groupBy({ + by: ['status'], + _count: true, + orderBy: { status: 'asc' }, + }), + ).toEqual([ + { status: 'PENDING', _count: 50 }, + { status: 'TICKET_OK', _count: 100 }, + ]); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(3); + expect(send.mock.calls[2][0]).toHaveLength(50); + expect( + await prisma.sellerPushDelivery.count({ where: { status: 'PENDING' } }), + ).toBe(0); + }); + + it('401·403은 인증 실패 경보를 낸 뒤 던진다', async () => { + const { storeId } = await storeWithDevices(2); + send.mockRejectedValue(new ExpoPushAuthError(401, 'Expo 푸시 전송')); + + await expect( + consumer.handle(orderSubmitted(storeId)), + ).rejects.toBeInstanceOf(ExpoPushAuthError); + + expect(alerts.notify).toHaveBeenCalledTimes(1); + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: expect.stringContaining('HTTP 401'), + }), + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'PENDING', + 'PENDING', + ]); + expect(await sendsCounter()).toEqual({ AUTH_ERROR: 2 }); + }); + + it('EXPO_PUSH_ENABLED=false면 디바이스가 있어도 보내지 않고 이력도 남기지 않는다', async () => { + cfg = { ...cfg, enabled: false }; + const { storeId } = await storeWithDevices(1); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + expect(Logger.prototype.debug).toHaveBeenCalled(); + }); + + it('반증: payload 형식이 어긋나면 던진다(전송·행 없음)', async () => { + const { storeId } = await storeWithDevices(1); + const broken = orderSubmitted(storeId); + broken.payload = { ...(broken.payload as object), quantity: '2' }; + + await expect(consumer.handle(broken)).rejects.toThrow( + 'order.submitted payload 형식 오류', + ); + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + }); + + it('conversation.buyer_message_sent는 preview를 본문으로, 딥링크는 대화 id', async () => { + const { storeId, devices } = await storeWithDevices(1); + + await consumer.handle( + event('conversation.buyer_message_sent', { + conversationId: '11', + storeId: storeId.toString(), + buyerAccountId: '7', + messageId: '99', + preview: '픽업 시간 바꿀 수 있나요?', + messageCreatedAt: '2026-10-05T03:05:00.000Z', + }), + ); + + expect(send.mock.calls[0][0]).toEqual([ + { + to: devices[0].expo_push_token, + title: '새 문의', + body: '픽업 시간 바꿀 수 있나요?', + data: { kind: 'BUYER_MESSAGE', conversationId: '11' }, + channelId: 'default', + }, + ]); + expect((await deliveries())[0].status).toBe('TICKET_OK'); + }); + + it('반증: 구독하지 않은 event_type은 던진다', async () => { + await expect( + consumer.handle(event('order.status_changed', {})), + ).rejects.toThrow('구독하지 않은 outbox 이벤트: order.status_changed'); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/notification/services/seller-push-outbox.consumer.ts b/src/features/notification/services/seller-push-outbox.consumer.ts new file mode 100644 index 00000000..2c9b9e89 --- /dev/null +++ b/src/features/notification/services/seller-push-outbox.consumer.ts @@ -0,0 +1,175 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import { parseId } from '@/common/utils/id-parser'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { + CONVERSATION_BUYER_MESSAGE_SENT, + parseConversationBuyerMessageSentPayload, +} from '@/features/conversation'; +import { + EXPO_ERROR_DEVICE_NOT_REGISTERED, + EXPO_ERROR_UNKNOWN, + PUSH_DEVICE_DISABLED_REASON, +} from '@/features/notification/constants/seller-push.constants'; +import { + SellerPushDeliveryRepository, + type TicketResult, +} from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { + buildBuyerMessagePush, + buildOrderSubmittedPush, + SELLER_PUSH_CHANNEL_ID, + type SellerPushContent, +} from '@/features/notification/services/seller-push-messages.helper'; +import { ORDER_SUBMITTED, parseOrderSubmittedPayload } from '@/features/order'; +import { + type OutboxConsumer, + type OutboxEvent, + SubscribeOutbox, +} from '@/features/outbox'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_SEND_LIMIT, + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushTicket, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { MetricsService } from '@/global/metrics'; + +/** + * 판매자 앱 푸시 전송(outbox 소비자, worker). 이벤트 payload 스냅샷으로 메시지를 만들고 매장의 활성 디바이스에 보낸다. + * 재전달은 (source_event_id, push_device_id) 행으로 흡수한다 — ticket이 기록된 디바이스에는 다시 보내지 않는다. + * 전송 실패(네트워크·5xx·429)는 던져 호스트의 retry/DLQ에 맡기고, 인증 실패는 경보를 낸 뒤 던진다. + */ +@Injectable() +@SubscribeOutbox(ORDER_SUBMITTED, CONVERSATION_BUYER_MESSAGE_SENT) +export class SellerPushOutboxConsumer implements OutboxConsumer { + private readonly logger = new Logger(SellerPushOutboxConsumer.name); + + constructor( + private readonly config: ConfigService, + private readonly devices: SellerPushDeviceRepository, + private readonly deliveries: SellerPushDeliveryRepository, + private readonly clock: ClockService, + private readonly alerts: AlertService, + private readonly metrics: MetricsService, + @Inject(EXPO_PUSH_TRANSPORT) private readonly transport: ExpoPushTransport, + ) {} + + async handle(event: OutboxEvent): Promise { + const cfg = this.config.getOrThrow('expoPush'); + if (!cfg.enabled) { + // 꺼진 동안의 이벤트는 복구하지 않는다 — retry 큐에 쌓이면 상한 뒤 DLQ 경보만 는다 + this.logger.debug( + `EXPO_PUSH_ENABLED=false — ${event.eventType}#${event.eventId} 전송 생략`, + ); + return; + } + const { storeId, content } = this.contentOf(event); + const devices = await this.devices.listActiveByStore(storeId); + if (devices.length === 0) return; + + const pending = await this.deliveries.claim( + event.eventId, + devices.map((device) => device.id), + ); + const tokenByDevice = new Map( + devices.map((device) => [device.id.toString(), device.expo_push_token]), + ); + const options = { + accessToken: cfg.accessToken, + timeoutMs: cfg.requestTimeoutMs, + }; + for (let i = 0; i < pending.length; i += EXPO_PUSH_SEND_LIMIT) { + const chunk = pending.slice(i, i + EXPO_PUSH_SEND_LIMIT); + const messages = chunk.map((row) => ({ + to: tokenByDevice.get(row.push_device_id.toString()) as string, + ...content, + channelId: SELLER_PUSH_CHANNEL_ID, + })); + let tickets: ExpoPushTicket[]; + try { + tickets = await this.transport.send(messages, options); + } catch (error) { + if (error instanceof ExpoPushAuthError) { + this.metrics.expoPushSends.inc( + { result: 'AUTH_ERROR' }, + messages.length, + ); + await this.alerts.notify({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: `${error.message} — EXPO_PUSH_ACCESS_TOKEN 확인. 이벤트 ${event.eventId}`, + }); + } + throw error; + } + const sentAt = this.clock.now(); + const results: TicketResult[] = []; + const notRegistered: bigint[] = []; + const codes: string[] = []; + chunk.forEach((row, index) => { + const ticket = tickets[index]; + if (ticket.status === 'ok') { + results.push({ + id: row.id, + status: 'TICKET_OK', + ticketId: ticket.id, + sentAt, + }); + return; + } + const errorCode = ticket.details?.error ?? EXPO_ERROR_UNKNOWN; + results.push({ id: row.id, status: 'TICKET_ERROR', errorCode, sentAt }); + codes.push(errorCode); + if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) + notRegistered.push(row.push_device_id); + }); + // 비활성(멱등)이 먼저 — 행을 종료 상태로 바꾼 뒤 죽으면 재시도가 PENDING을 못 찾아 디바이스가 영영 남는다 + await this.devices.disableByIds( + notRegistered, + PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, + sentAt, + ); + await this.deliveries.markTickets(results); + for (const result of results) { + this.metrics.expoPushSends.inc({ result: result.status }); + } + if (codes.length > 0) { + this.logger.warn(`Expo ticket 오류 ${codes.length}건`, { + eventId: event.eventId, + codes, + }); + } + } + } + + private contentOf(event: OutboxEvent): { + storeId: bigint; + content: SellerPushContent; + } { + switch (event.eventType) { + case ORDER_SUBMITTED: { + const p = parseOrderSubmittedPayload(event.payload); + return { + storeId: parseId(p.storeId), + content: buildOrderSubmittedPush(p), + }; + } + case CONVERSATION_BUYER_MESSAGE_SENT: { + const p = parseConversationBuyerMessageSentPayload(event.payload); + return { + storeId: parseId(p.storeId), + content: buildBuyerMessagePush(p), + }; + } + default: + throw new Error(`구독하지 않은 outbox 이벤트: ${event.eventType}`); + } + } +} diff --git a/src/features/notification/services/seller-push-receipt.scheduler.spec.ts b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts new file mode 100644 index 00000000..597d4976 --- /dev/null +++ b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts @@ -0,0 +1,293 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushReceiptScheduler } from '@/features/notification/services/seller-push-receipt.scheduler'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushReceipt, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createSellerPushDelivery, + createSellerPushDevice, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const NOW = new Date('2026-10-05T12:00:00.000Z'); +const MINUTE = 60 * 1000; +const minutesAgo = (minutes: number) => + new Date(NOW.getTime() - minutes * MINUTE); + +// 영수증 조회 — 15분 지난 TICKET_OK만, 오류면 디바이스 비활성, 24h 넘게 영수증이 없으면 UNKNOWN으로 닫는다. 실패는 경보만. +describe('SellerPushReceiptScheduler (real DB)', () => { + let scheduler: SellerPushReceiptScheduler; + let deliveryRepository: SellerPushDeliveryRepository; + let prisma: PrismaClient; + let cfg: ExpoPushConfig; + const getReceipts = jest.fn< + Promise>, + Parameters + >(); + const transport: ExpoPushTransport = { send: jest.fn(), getReceipts }; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + const savedRole = process.env.APP_ROLE; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushReceiptScheduler, + SellerPushDeliveryRepository, + SellerPushDeviceRepository, + { provide: ClockService, useValue: { now: () => NOW } }, + { provide: ConfigService, useValue: { getOrThrow: () => cfg } }, + { provide: AlertService, useValue: alerts }, + { provide: EXPO_PUSH_TRANSPORT, useValue: transport }, + ], + }); + scheduler = module.get(SellerPushReceiptScheduler); + deliveryRepository = module.get(SellerPushDeliveryRepository); + prisma = p; + }); + afterAll(async () => { + if (savedRole === undefined) delete process.env.APP_ROLE; + else process.env.APP_ROLE = savedRole; + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + process.env.APP_ROLE = 'worker'; + cfg = { enabled: true, accessToken: 'tok', requestTimeoutMs: 1_000 }; + getReceipts.mockReset(); + getReceipts.mockResolvedValue({}); + alerts.notify.mockClear(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + async function ticketOk(ticketId: string, sentMinutesAgo: number) { + const device = await createSellerPushDevice(prisma); + const delivery = await createSellerPushDelivery(prisma, { + push_device_id: device.id, + status: 'TICKET_OK', + ticket_id: ticketId, + sent_at: minutesAgo(sentMinutesAgo), + }); + return { device, delivery }; + } + const deliveryOf = (id: bigint) => + prisma.sellerPushDelivery.findUniqueOrThrow({ where: { id } }); + const deviceOf = (id: bigint) => + prisma.sellerPushDevice.findUniqueOrThrow({ where: { id } }); + + it('15분이 지난 TICKET_OK만 조회하고 ok 영수증은 RECEIPT_OK로 닫는다 — 반증: 15분 미만은 조회하지 않는다', async () => { + const { delivery: due } = await ticketOk('t-due', 16); + const { delivery: fresh } = await ticketOk('t-fresh', 14); + getReceipts.mockResolvedValue({ 't-due': { status: 'ok' } }); + + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledTimes(1); + expect(getReceipts).toHaveBeenCalledWith(['t-due'], { + accessToken: 'tok', + timeoutMs: 1_000, + }); + expect(await deliveryOf(due.id)).toMatchObject({ + status: 'RECEIPT_OK', + receipt_checked_at: NOW, + }); + expect(await deliveryOf(fresh.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + }); + + it('대상이 없으면 Expo를 부르지 않는다', async () => { + await ticketOk('t-fresh', 1); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }); + + it('DeviceNotRegistered 영수증은 RECEIPT_ERROR로 남기고 디바이스를 비활성한다 — 다른 오류는 디바이스를 살려 둔다', async () => { + const gone = await ticketOk('t-gone', 20); + const big = await ticketOk('t-big', 20); + getReceipts.mockResolvedValue({ + 't-gone': { + status: 'error', + message: 'x', + details: { error: 'DeviceNotRegistered' }, + }, + 't-big': { + status: 'error', + message: 'y', + details: { error: 'MessageTooBig' }, + }, + }); + + await scheduler.checkReceipts(); + + expect(await deliveryOf(gone.delivery.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'DeviceNotRegistered', + receipt_checked_at: NOW, + }); + expect(await deviceOf(gone.device.id)).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(await deliveryOf(big.delivery.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'MessageTooBig', + }); + expect((await deviceOf(big.device.id)).disabled_at).toBeNull(); + }); + + it('영수증 기록(markReceipts)이 던져도 DeviceNotRegistered 디바이스는 이미 비활성이고 행은 다음 틱에 다시 본다', async () => { + const gone = await ticketOk('t-gone', 20); + getReceipts.mockResolvedValue({ + 't-gone': { + status: 'error', + message: 'x', + details: { error: 'DeviceNotRegistered' }, + }, + }); + jest + .spyOn(deliveryRepository, 'markReceipts') + .mockRejectedValueOnce(new Error('db down')); + + await scheduler.checkReceipts(); + + expect(await deviceOf(gone.device.id)).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(await deliveryOf(gone.delivery.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ key: 'expo-push:receipts', detail: 'db down' }), + ); + }); + + it('영수증이 없는 ticket은 24시간이 지나면 RECEIPT_UNKNOWN으로 닫고, 그 전에는 다음 틱에 다시 본다', async () => { + const { delivery: expired } = await ticketOk('t-expired', 24 * 60); + const { delivery: waiting } = await ticketOk('t-waiting', 24 * 60 - 1); + + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledWith( + ['t-expired', 't-waiting'], + expect.anything(), + ); + expect(await deliveryOf(expired.id)).toMatchObject({ + status: 'RECEIPT_UNKNOWN', + error_code: null, + receipt_checked_at: NOW, + }); + expect(await deliveryOf(waiting.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + + await scheduler.checkReceipts(); + expect(getReceipts).toHaveBeenLastCalledWith( + ['t-waiting'], + expect.anything(), + ); + }); + + it('조회가 던지면 삼키고 warn 경보를 내며 행은 그대로다', async () => { + const { delivery } = await ticketOk('t-1', 20); + getReceipts.mockRejectedValue(new Error('fetch failed')); + + await expect(scheduler.checkReceipts()).resolves.toBeUndefined(); + + expect(alerts.notify).toHaveBeenCalledWith({ + level: 'warn', + title: 'Expo 푸시 영수증 조회 실패', + key: 'expo-push:receipts', + detail: 'fetch failed', + }); + expect(Logger.prototype.warn).toHaveBeenCalled(); + expect(await deliveryOf(delivery.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + }); + + it('401은 인증 실패 경보(error)로 낸다', async () => { + await ticketOk('t-1', 20); + getReceipts.mockRejectedValue( + new ExpoPushAuthError(401, 'Expo 푸시 영수증 조회'), + ); + + await scheduler.checkReceipts(); + + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + }), + ); + }); + + it.each(['api', 'ws'] as const)( + '반증: %s 역할에서는 돌지 않는다', + async (role) => { + process.env.APP_ROLE = role; + await ticketOk('t-1', 20); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }, + ); + + it('EXPO_PUSH_ENABLED=false면 돌지 않는다', async () => { + cfg = { ...cfg, enabled: false }; + await ticketOk('t-1', 20); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }); + + it('반증: 앞선 틱이 끝나기 전의 틱은 건너뛰고, 끝난 뒤에는 다시 돈다', async () => { + await ticketOk('t-1', 20); + let finish!: (value: Record) => void; + getReceipts.mockReturnValueOnce( + new Promise>((resolve) => { + finish = resolve; + }), + ); + + const first = scheduler.checkReceipts(); + // 첫 틱이 DB 조회를 지나 Expo 호출에 머무를 때까지 + while (getReceipts.mock.calls.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await scheduler.checkReceipts(); + expect(getReceipts).toHaveBeenCalledTimes(1); + + finish({ 't-1': { status: 'ok' } }); + await first; + await ticketOk('t-2', 20); + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledTimes(2); + expect(getReceipts).toHaveBeenLastCalledWith(['t-2'], expect.anything()); + }); +}); diff --git a/src/features/notification/services/seller-push-receipt.scheduler.ts b/src/features/notification/services/seller-push-receipt.scheduler.ts new file mode 100644 index 00000000..5db8fa81 --- /dev/null +++ b/src/features/notification/services/seller-push-receipt.scheduler.ts @@ -0,0 +1,119 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { ClockService } from '@/common/providers/clock.service'; +import { HOUR_MS } from '@/common/utils/kst-time'; +import { resolveAppRole, runsBackgroundJobs } from '@/config/app.config'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { + EXPO_ERROR_DEVICE_NOT_REGISTERED, + EXPO_ERROR_UNKNOWN, + PUSH_DEVICE_DISABLED_REASON, +} from '@/features/notification/constants/seller-push.constants'; +import { + type ReceiptResult, + SellerPushDeliveryRepository, +} from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushTransport, +} from '@/global/expo-push'; + +/** Expo가 영수증을 준비하는 데 두는 여유. */ +export const RECEIPT_DELAY_MS = 15 * 60 * 1000; +/** 이 뒤에도 영수증이 없으면 RECEIPT_UNKNOWN으로 닫는다(Expo 보관 기한 24h). */ +export const RECEIPT_GIVE_UP_MS = 24 * HOUR_MS; +export const RECEIPT_BATCH_LIMIT = 300; + +/** + * Expo 영수증 조회(5분, worker). ticket만으로는 APNs/FCM 거절(DeviceNotRegistered 등)을 모르므로 뒤늦게 확인해 디바이스를 비활성한다. + * 실패는 던지지 않고 경보만 — 다음 틱이 같은 행을 다시 본다. + */ +@Injectable() +export class SellerPushReceiptScheduler { + private readonly logger = new Logger(SellerPushReceiptScheduler.name); + private running = false; + + constructor( + private readonly config: ConfigService, + private readonly deliveries: SellerPushDeliveryRepository, + private readonly devices: SellerPushDeviceRepository, + private readonly clock: ClockService, + private readonly alerts: AlertService, + @Inject(EXPO_PUSH_TRANSPORT) private readonly transport: ExpoPushTransport, + ) {} + + /** 크론(ScheduleModule)은 worker에만 실리지만 역할도 함께 본다 — api·ws에서 호출돼도 Expo를 부르지 않게. */ + @Cron(CronExpression.EVERY_5_MINUTES) + async checkReceipts(): Promise { + if (!runsBackgroundJobs(resolveAppRole())) return; + const cfg = this.config.getOrThrow('expoPush'); + if (!cfg.enabled || this.running) return; + this.running = true; + try { + await this.run(cfg); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.logger.warn(`Expo 푸시 영수증 조회 실패: ${detail}`); + await this.alerts.notify( + error instanceof ExpoPushAuthError + ? { + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: `${detail} — EXPO_PUSH_ACCESS_TOKEN 확인`, + } + : { + level: 'warn', + title: 'Expo 푸시 영수증 조회 실패', + key: 'expo-push:receipts', + detail, + }, + ); + } finally { + this.running = false; + } + } + + private async run(cfg: ExpoPushConfig): Promise { + const now = this.clock.now(); + const rows = await this.deliveries.listForReceipt({ + sentBefore: new Date(now.getTime() - RECEIPT_DELAY_MS), + limit: RECEIPT_BATCH_LIMIT, + }); + if (rows.length === 0) return; + const receipts = await this.transport.getReceipts( + rows.map((row) => row.ticket_id), + { accessToken: cfg.accessToken, timeoutMs: cfg.requestTimeoutMs }, + ); + const results: ReceiptResult[] = []; + const notRegistered: bigint[] = []; + for (const row of rows) { + const receipt = receipts[row.ticket_id]; + if (!receipt) { + if (now.getTime() - row.sent_at.getTime() >= RECEIPT_GIVE_UP_MS) + results.push({ id: row.id, status: 'RECEIPT_UNKNOWN' }); + continue; + } + if (receipt.status === 'ok') { + results.push({ id: row.id, status: 'RECEIPT_OK' }); + continue; + } + const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN; + results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode }); + if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) + notRegistered.push(row.push_device_id); + } + // 비활성(멱등)이 먼저 — 행을 닫은 뒤 죽으면 다음 틱이 그 행을 다시 보지 않아 디바이스가 영영 남는다 + await this.devices.disableByIds( + notRegistered, + PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, + now, + ); + await this.deliveries.markReceipts(results, now); + } +} diff --git a/src/features/order/events/order-submitted.event.spec.ts b/src/features/order/events/order-submitted.event.spec.ts new file mode 100644 index 00000000..7ef28909 --- /dev/null +++ b/src/features/order/events/order-submitted.event.spec.ts @@ -0,0 +1,75 @@ +import { + ORDER_SUBMITTED, + orderSubmittedEvent, + parseOrderSubmittedPayload, +} from '@/features/order/events/order-submitted.event'; +import type { Prisma } from '@/generated/prisma/client'; + +const VALID = { + orderId: '7', + orderNumber: 'ORD-20260916-ABC234', + buyerAccountId: '3', + storeId: '5', + storeName: '해즈 케이크', + productId: '9', + productName: '딸기 케이크', + quantity: 2, + pickupAt: '2026-09-18T05:00:00.000Z', + totalPrice: 56000, +}; + +describe('order.submitted 이벤트', () => { + it('bigint·Date를 문자열로 싣고 order aggregate(상태 전이와 같은 파티션)에 묶는다', () => { + const occurredAt = new Date('2026-09-16T07:00:00.000Z'); + const event = orderSubmittedEvent({ + orderId: 7n, + orderNumber: VALID.orderNumber, + buyerAccountId: 3n, + storeId: 5n, + storeName: VALID.storeName, + productId: 9n, + productName: VALID.productName, + quantity: 2, + pickupAt: new Date(VALID.pickupAt), + totalPrice: 56000, + occurredAt, + }); + + expect(event).toEqual({ + aggregateType: 'order', + aggregateId: '7', + eventType: ORDER_SUBMITTED, + payload: VALID, + occurredAt, + actorAccountId: 3n, + }); + expect( + parseOrderSubmittedPayload(event.payload as Prisma.JsonValue), + ).toEqual(VALID); + }); + + it.each([ + ['null', null], + ['배열', [VALID]], + ['문자열', 'x'], + ['orderId 누락', { ...VALID, orderId: undefined }], + ['orderId 숫자', { ...VALID, orderId: 7 }], + ['orderNumber 누락', { ...VALID, orderNumber: undefined }], + ['buyerAccountId 숫자', { ...VALID, buyerAccountId: 3 }], + ['storeId null', { ...VALID, storeId: null }], + ['storeName 누락', { ...VALID, storeName: undefined }], + ['productId 누락', { ...VALID, productId: undefined }], + ['productName null', { ...VALID, productName: null }], + ['quantity 문자열', { ...VALID, quantity: '2' }], + ['quantity 소수', { ...VALID, quantity: 1.5 }], + ['pickupAt 숫자', { ...VALID, pickupAt: 1_700_000_000 }], + ['pickupAt 비ISO', { ...VALID, pickupAt: 'not-a-date' }], + ['totalPrice 문자열', { ...VALID, totalPrice: '56000' }], + ])('반증: %s payload는 던진다', (_label, payload) => { + expect(() => + parseOrderSubmittedPayload( + payload as Parameters[0], + ), + ).toThrow('payload 형식 오류'); + }); +}); diff --git a/src/features/order/events/order-submitted.event.ts b/src/features/order/events/order-submitted.event.ts new file mode 100644 index 00000000..a0170a31 --- /dev/null +++ b/src/features/order/events/order-submitted.event.ts @@ -0,0 +1,94 @@ +import type { OutboxEventInput } from '@/features/outbox'; +import type { Prisma } from '@/generated/prisma/client'; + +/** + * 주문 접수 이벤트(판매자 푸시 원천). order.status_changed와 같은 aggregate라 릴레이가 "접수 → 상태 전이" 순서를 지킨다. + * payload는 생산 시점 스냅샷 — 소비자가 order·store·product를 다시 읽지 않는다. + */ +export const ORDER_SUBMITTED = 'order.submitted'; + +export interface OrderSubmittedPayload { + orderId: string; + orderNumber: string; + buyerAccountId: string; + storeId: string; + storeName: string; + productId: string; + productName: string; + quantity: number; + /** ISO 8601 */ + pickupAt: string; + totalPrice: number; +} + +export function orderSubmittedEvent(args: { + orderId: bigint; + orderNumber: string; + buyerAccountId: bigint; + storeId: bigint; + storeName: string; + productId: bigint; + productName: string; + quantity: number; + pickupAt: Date; + totalPrice: number; + occurredAt: Date; +}): OutboxEventInput { + const payload: OrderSubmittedPayload = { + orderId: args.orderId.toString(), + orderNumber: args.orderNumber, + buyerAccountId: args.buyerAccountId.toString(), + storeId: args.storeId.toString(), + storeName: args.storeName, + productId: args.productId.toString(), + productName: args.productName, + quantity: args.quantity, + pickupAt: args.pickupAt.toISOString(), + totalPrice: args.totalPrice, + }; + return { + aggregateType: 'order', + aggregateId: payload.orderId, + eventType: ORDER_SUBMITTED, + payload: { ...payload }, + occurredAt: args.occurredAt, + actorAccountId: args.buyerAccountId, + }; +} + +/** 소비자용 방어적 파싱 — 형태가 어긋난 payload는 던져서 재시도·FAILED로 드러낸다. */ +export function parseOrderSubmittedPayload( + value: Prisma.JsonValue, +): OrderSubmittedPayload { + const p = value as Partial>; + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + typeof p.orderId !== 'string' || + typeof p.orderNumber !== 'string' || + typeof p.buyerAccountId !== 'string' || + typeof p.storeId !== 'string' || + typeof p.storeName !== 'string' || + typeof p.productId !== 'string' || + typeof p.productName !== 'string' || + !Number.isSafeInteger(p.quantity) || + typeof p.pickupAt !== 'string' || + Number.isNaN(Date.parse(p.pickupAt)) || + !Number.isSafeInteger(p.totalPrice) + ) { + throw new Error(`${ORDER_SUBMITTED} payload 형식 오류`); + } + return { + orderId: p.orderId, + orderNumber: p.orderNumber, + buyerAccountId: p.buyerAccountId, + storeId: p.storeId, + storeName: p.storeName, + productId: p.productId, + productName: p.productName, + quantity: p.quantity as number, + pickupAt: p.pickupAt, + totalPrice: p.totalPrice as number, + }; +} diff --git a/src/features/order/index.ts b/src/features/order/index.ts index a910029d..38228e43 100644 --- a/src/features/order/index.ts +++ b/src/features/order/index.ts @@ -7,3 +7,9 @@ export { ORDER_STATUS_CHANGED, parseOrderStatusChangedPayload, } from '@/features/order/events/order-status-changed.event'; +// 주문 접수 이벤트 계약(outbox). 판매자 푸시 소비자(notification)가 읽는다. +export { + ORDER_SUBMITTED, + type OrderSubmittedPayload, + parseOrderSubmittedPayload, +} from '@/features/order/events/order-submitted.event'; diff --git a/src/features/order/order-admin.graphql b/src/features/order/order-admin.graphql index 463f22c5..e2e0cbe5 100644 --- a/src/features/order/order-admin.graphql +++ b/src/features/order/order-admin.graphql @@ -63,6 +63,14 @@ type AdminOrderSummary { 주문 생성 시각. """ createdAt: DateTime! + """ + 첫 품목의 상품명(주문 시점 스냅샷). 이후 상품명이 바뀌어도 유지된다. 품목이 없으면 null. + """ + firstItemName: String + """ + 첫 품목의 상품 대표 이미지 URL(주문 시점 스냅샷). 주문 당시 이미지가 없었거나 품목이 없으면 null. + """ + firstItemImageUrl: String } """ diff --git a/src/features/order/order-seller.graphql b/src/features/order/order-seller.graphql index eb56b467..9d257c32 100644 --- a/src/features/order/order-seller.graphql +++ b/src/features/order/order-seller.graphql @@ -51,6 +51,14 @@ type SellerOrderSummary { 주문 생성 시각. 목록 정렬 기준이다. """ createdAt: DateTime! + """ + 내 매장 첫 품목의 상품명(주문 시점 스냅샷). 이후 상품명이 바뀌어도 유지된다. 활성 품목이 없으면 null. + """ + firstItemName: String + """ + 내 매장 첫 품목의 상품 대표 이미지 URL(주문 시점 스냅샷). 주문 당시 이미지가 없었으면 null. + """ + firstItemImageUrl: String } """ diff --git a/src/features/order/order-subscription.graphql b/src/features/order/order-subscription.graphql new file mode 100644 index 00000000..781c9753 --- /dev/null +++ b/src/features/order/order-subscription.graphql @@ -0,0 +1,32 @@ +extend type Subscription { + """ + 내 매장 주문 생성·상태 변경 이벤트. 판매자 로그인 필수(SELLER 아니면 FORBIDDEN). + 삭제되지 않은 매장을 보유한 판매자만 구독할 수 있다 — 매장이 없거나 삭제됐으면 NOT_FOUND. + 비활성(is_active=false) 매장도 구독은 유지된다. 인증은 graphql-ws connectionParams.authorization. + 구매자 주문 생성(SUBMITTED)·판매자 상태 변경·관리자 강제 취소가 모두 여기로 온다. + """ + sellerOrderUpdated: SellerOrderUpdate! +} + +""" +판매자 주문 갱신 이벤트. 발행 시점 주문 행의 스냅샷이다. +이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 같은 orderId의 더 오래된 updatedAt 이벤트는 폐기한다. +전달 누락 가능(Redis 장애 시 발행 생략)하므로 폴백은 sellerOrderList 재조회다. +""" +type SellerOrderUpdate { + orderId: ID! + """주문번호.""" + orderNumber: String! + """이벤트 시점의 주문 상태.""" + status: OrderStatusType! + """픽업 예정 일시.""" + pickupAt: DateTime! + """주문자 이름(주문 시점 값).""" + buyerName: String! + """할인 반영 후 최종 결제 금액(원).""" + totalPrice: Int! + """첫 품목의 상품명(주문 시점 스냅샷). 주문은 단일 상품 구조다.""" + productName: String! + """주문 행의 updated_at. 폐기 규칙의 비교 키다.""" + updatedAt: DateTime! +} diff --git a/src/features/order/order.module.ts b/src/features/order/order.module.ts index 350930e0..dfe5cfc4 100644 --- a/src/features/order/order.module.ts +++ b/src/features/order/order.module.ts @@ -13,11 +13,14 @@ import { OrderCheckoutMutationResolver } from '@/features/order/resolvers/order- import { UserOrderQueryResolver } from '@/features/order/resolvers/order-my-query.resolver'; import { SellerOrderMutationResolver } from '@/features/order/resolvers/order-seller-mutation.resolver'; import { SellerOrderQueryResolver } from '@/features/order/resolvers/order-seller-query.resolver'; +import { OrderSubscriptionResolver } from '@/features/order/resolvers/order-subscription.resolver'; import { AdminOrderService } from '@/features/order/services/order-admin.service'; import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { UserOrderService } from '@/features/order/services/order-my.service'; import { SellerOrderService } from '@/features/order/services/order-seller.service'; import { OrderStoreDailyLimitConsumer } from '@/features/order/services/order-store-daily-limit.consumer'; +import { OrderSubscriptionService } from '@/features/order/services/order-subscription.service'; import { OutboxModule } from '@/features/outbox'; import { ProductModule } from '@/features/product'; import { StoreModule } from '@/features/store'; @@ -40,6 +43,10 @@ import { StoreModule } from '@/features/store'; OrderStoreDailyLimitRepository, OrderStoreDailyLimitConsumer, OrderStatusTransitionPolicy, + // 판매자 주문 구독(Redis PubSub) — 발행은 체크아웃·판매자 상태 변경·관리자 취소 서비스가 커밋 뒤 직접 + OrderEventsService, + OrderSubscriptionService, + OrderSubscriptionResolver, OrderCheckoutService, OrderCheckoutMutationResolver, // 판매자 주문 관리(목록·상세·상태 변경) — 주문 도메인이 소유한다 diff --git a/src/features/order/repositories/order.repository.spec.ts b/src/features/order/repositories/order.repository.spec.ts index 17d37175..4d3537cd 100644 --- a/src/features/order/repositories/order.repository.spec.ts +++ b/src/features/order/repositories/order.repository.spec.ts @@ -3,7 +3,15 @@ import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log. import { NotificationAdminRepository } from '@/features/notification/repositories/notification-admin.repository'; import { NotificationRepository } from '@/features/notification/repositories/notification.repository'; import { NotificationOutboxConsumer } from '@/features/notification/services/notification-outbox.consumer'; -import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { + ORDER_SUBMITTED, + parseOrderSubmittedPayload, +} from '@/features/order/events/order-submitted.event'; +import { + type CreateSubmittedOrderArgs, + type DailyCapacityGuard, + OrderRepository, +} from '@/features/order/repositories/order.repository'; import { OutboxDispatcherService } from '@/features/outbox'; import type { PrismaClient } from '@/generated/prisma/client'; import { OrderStatus } from '@/generated/prisma/client'; @@ -16,6 +24,7 @@ import { createOrderItem, createProduct, createStore, + createStoreDailyCapacity, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; import { @@ -476,6 +485,99 @@ describe('OrderRepository (real DB)', () => { }); }); + describe('aggregateStoreOrdersInRange', () => { + const from = new Date('2026-10-04T15:00:00Z'); + const to = new Date('2026-10-05T15:00:00Z'); + const inside = new Date('2026-10-05T03:00:00Z'); + const before = new Date('2026-10-04T14:59:59.999Z'); + + async function storeOrder( + storeId: bigint, + overrides: Parameters[1] & { itemDeletedAt?: Date }, + ) { + const { itemDeletedAt, ...orderOverrides } = overrides; + const order = await createOrder(prisma, orderOverrides); + await createOrderItem(prisma, { + order_id: order.id, + store_id: storeId, + ...(itemDeletedAt ? { deleted_at: itemDeletedAt } : {}), + }); + return order; + } + + it('basis가 기준 컬럼을 고르고 범위는 [from, to)다', async () => { + const store = await createStore(prisma); + // 픽업은 범위 안, 생성은 범위 밖 + await storeOrder(store.id, { + pickup_at: inside, + created_at: before, + total_price: 1_000, + }); + // 경계: from은 포함, to는 제외 + await storeOrder(store.id, { + pickup_at: from, + created_at: to, + total_price: 10, + }); + await storeOrder(store.id, { + pickup_at: to, + created_at: from, + total_price: 100, + }); + + const base = { storeId: store.id, from, to }; + expect( + await repo.aggregateStoreOrdersInRange({ ...base, basis: 'pickup' }), + ).toEqual({ orderCount: 2, salesAmount: 1_010 }); + expect( + await repo.aggregateStoreOrdersInRange({ ...base, basis: 'created' }), + ).toEqual({ orderCount: 1, salesAmount: 100 }); + }); + + it('CANCELED·soft-delete 주문·품목이 soft-delete된 주문·다른 매장은 제외한다', async () => { + const store = await createStore(prisma); + const other = await createStore(prisma); + await storeOrder(store.id, { pickup_at: inside, total_price: 1_000 }); + await storeOrder(store.id, { + pickup_at: inside, + status: 'CANCELED', + total_price: 2_000, + }); + await storeOrder(store.id, { + pickup_at: inside, + deleted_at: inside, + total_price: 4_000, + }); + await storeOrder(store.id, { + pickup_at: inside, + itemDeletedAt: inside, + total_price: 8_000, + }); + await storeOrder(other.id, { pickup_at: inside, total_price: 16_000 }); + + expect( + await repo.aggregateStoreOrdersInRange({ + storeId: store.id, + from, + to, + basis: 'pickup', + }), + ).toEqual({ orderCount: 1, salesAmount: 1_000 }); + }); + + it('해당 주문이 없으면 0·0이다', async () => { + const store = await createStore(prisma); + expect( + await repo.aggregateStoreOrdersInRange({ + storeId: store.id, + from, + to, + basis: 'created', + }), + ).toEqual({ orderCount: 0, salesAmount: 0 }); + }); + }); + describe('findOrderDetailByStore', () => { it('해당 store item만 items로 포함 (다른 store item 제외)', async () => { const storeA = await createStore(prisma); @@ -758,4 +860,131 @@ describe('OrderRepository (real DB)', () => { ]); }); }); + + describe('createSubmittedOrder', () => { + const PICKUP_AT = new Date('2026-09-18T05:00:00.000Z'); + const SUBMITTED_AT = new Date('2026-09-16T07:00:00.000Z'); + + function submitArgs(args: { + buyerId: bigint; + store: { id: bigint; store_name: string }; + product: { id: bigint; name: string }; + orderNumber?: string; + quantity?: number; + capacityGuard?: DailyCapacityGuard; + }): CreateSubmittedOrderArgs { + const quantity = args.quantity ?? 2; + return { + accountId: args.buyerId, + orderNumber: args.orderNumber ?? 'ORD-20260916-ABC234', + idempotencyKey: `idem-${args.orderNumber ?? 'base'}`, + pickupAt: PICKUP_AT, + buyerName: '차차', + buyerPhone: '010-0000-1111', + subtotalPrice: 30000 * quantity, + discountPrice: 5000 * quantity, + totalPrice: 25000 * quantity, + submittedAt: SUBMITTED_AT, + capacityGuard: args.capacityGuard ?? null, + item: { + storeId: args.store.id, + productId: args.product.id, + productNameSnapshot: args.product.name, + storeNameSnapshot: args.store.store_name, + productThumbnailUrlSnapshot: null, + regularPriceSnapshot: 30000, + salePriceSnapshot: 25000, + quantity, + itemSubtotalPrice: 25000 * quantity, + options: [], + }, + }; + } + + it('접수 이벤트(order.submitted)를 같은 tx에 1건 남기고 payload는 생성 시점 스냅샷이다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + const product = await createProduct(prisma, { + store_id: store.id, + name: '딸기 케이크', + }); + + const created = await repo.createSubmittedOrder( + submitArgs({ buyerId: buyer.id, store, product }), + ); + + const events = await prisma.outbox.findMany(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + aggregate_type: 'order', + aggregate_id: created!.id.toString(), + event_type: ORDER_SUBMITTED, + occurred_at: SUBMITTED_AT, + actor_account_id: buyer.id, + status: 'PENDING', + }); + expect(parseOrderSubmittedPayload(events[0].payload_json)).toEqual({ + orderId: created!.id.toString(), + orderNumber: 'ORD-20260916-ABC234', + buyerAccountId: buyer.id.toString(), + storeId: store.id.toString(), + storeName: '해즈 케이크', + productId: product.id.toString(), + productName: '딸기 케이크', + quantity: 2, + pickupAt: PICKUP_AT.toISOString(), + totalPrice: 50000, + }); + }); + + it('반증: capacity 재검사에서 거절되면 주문도 이벤트도 남지 않는다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 1, + }); + + const created = await repo.createSubmittedOrder( + submitArgs({ + buyerId: buyer.id, + store, + product, + quantity: 2, + capacityGuard: { + storeId: store.id, + dateOnlyUtc: new Date(Date.UTC(2026, 8, 18)), + dayStartUtc: new Date('2026-09-17T15:00:00.000Z'), + dayEndUtc: new Date('2026-09-18T15:00:00.000Z'), + }, + }), + ); + + expect(created).toBeNull(); + expect(await prisma.order.count()).toBe(0); + expect(await prisma.outbox.count()).toBe(0); + }); + + it('반증: order_number 충돌로 tx가 롤백되면 이벤트도 남지 않는다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + await createOrder(prisma, { order_number: 'ORD-20260916-DUP000' }); + + await expect( + repo.createSubmittedOrder( + submitArgs({ + buyerId: buyer.id, + store, + product, + orderNumber: 'ORD-20260916-DUP000', + }), + ), + ).rejects.toMatchObject({ code: 'P2002' }); + + expect(await prisma.outbox.count()).toBe(0); + }); + }); }); diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index e0db324c..303f3e0c 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -5,6 +5,7 @@ import { type IAuditLogRepository, } from '@/features/audit-log'; import { orderStatusChangedEvent } from '@/features/order/events/order-status-changed.event'; +import { orderSubmittedEvent } from '@/features/order/events/order-submitted.event'; import { OutboxPublisher } from '@/features/outbox'; import { AuditActionType, @@ -21,16 +22,39 @@ export type AdminOrderRow = Prisma.OrderGetPayload<{ export type AdminOrderDetailRow = Prisma.OrderGetPayload<{ include: typeof adminOrderDetailInclude; }>; +export type SellerOrderRow = Prisma.OrderGetPayload<{ + include: ReturnType; +}>; const adminOrderInclude = { items: { where: activeWhere, - select: { store_id: true, store_name_snapshot: true }, + select: { + store_id: true, + store_name_snapshot: true, + product_name_snapshot: true, + product_thumbnail_url_snapshot: true, + }, orderBy: { id: 'asc' }, take: 1, }, } satisfies Prisma.OrderInclude; +/** 목록 카드용 첫 품목 스냅샷 — 매장 필터가 들어가 상수로 둘 수 없다. */ +function sellerOrderSummaryInclude(storeId: bigint) { + return { + items: { + where: { store_id: storeId, ...activeWhere }, + select: { + product_name_snapshot: true, + product_thumbnail_url_snapshot: true, + }, + orderBy: { id: 'asc' }, + take: 1, + }, + } satisfies Prisma.OrderInclude; +} + const adminOrderDetailInclude = { account: { select: { @@ -132,7 +156,9 @@ export interface CreatedOrderRow { order_number: string; status: OrderStatus; pickup_at: Date; + buyer_name: string; total_price: number; + updated_at: Date; } export interface ReviewableOrderItemRow { @@ -258,7 +284,7 @@ export class OrderRepository { tx: Prisma.TransactionClient, args: CreateSubmittedOrderArgs, ): Promise { - return tx.order.create({ + const created = await tx.order.create({ data: { account_id: args.accountId, order_number: args.orderNumber, @@ -307,9 +333,29 @@ export class OrderRepository { order_number: true, status: true, pickup_at: true, + buyer_name: true, total_price: true, + updated_at: true, }, }); + // 접수 이벤트(outbox, 같은 tx) — 판매자 푸시 원천. 구매자 알림은 없다(notification 소비자는 구독하지 않는다). + await this.outbox.publish( + tx, + orderSubmittedEvent({ + orderId: created.id, + orderNumber: created.order_number, + buyerAccountId: args.accountId, + storeId: args.item.storeId, + storeName: args.item.storeNameSnapshot, + productId: args.item.productId, + productName: args.item.productNameSnapshot, + quantity: args.item.quantity, + pickupAt: args.pickupAt, + totalPrice: args.totalPrice, + occurredAt: args.submittedAt, + }), + ); + return created; } /** 상태가 이후 변경됐어도 현재 row를 그대로 반환한다(replay 응답 재구성용). */ @@ -324,7 +370,9 @@ export class OrderRepository { order_number: true, status: true, pickup_at: true, + buyer_name: true, total_price: true, + updated_at: true, }, }); } @@ -617,6 +665,31 @@ export class OrderRepository { }); } + /** [from, to) 픽업 또는 생성 기준 건수·금액 합(CANCELED 제외). 주문 1건 = 매장 1곳이라 total_price 합이 매장 매출이다. */ + async aggregateStoreOrdersInRange(args: { + storeId: bigint; + from: Date; + to: Date; + basis: 'pickup' | 'created'; + }): Promise<{ orderCount: number; salesAmount: number }> { + const result = await this.prisma.order.aggregate({ + where: { + status: { not: 'CANCELED' }, + [args.basis === 'pickup' ? 'pickup_at' : 'created_at']: { + gte: args.from, + lt: args.to, + }, + items: { some: { store_id: args.storeId, ...activeWhere } }, + }, + _count: { _all: true }, + _sum: { total_price: true }, + }); + return { + orderCount: result._count._all, + salesAmount: result._sum.total_price ?? 0, + }; + } + async listOrdersByStore(args: { storeId: bigint; limit: number; @@ -627,12 +700,13 @@ export class OrderRepository { fromPickupAt?: Date; toPickupAt?: Date; search?: string; - }) { + }): Promise { return this.prisma.order.findMany({ where: { ...(args.cursor ? { id: { lt: args.cursor } } : {}), ...this.storeOrderScopeWhere(args), }, + include: sellerOrderSummaryInclude(args.storeId), orderBy: { id: 'desc' }, take: args.limit + 1, }); @@ -736,6 +810,7 @@ export class OrderRepository { ? { canceled_at: args.now } : {}), }, + include: sellerOrderSummaryInclude(args.storeId), }); await tx.orderStatusHistory.create({ diff --git a/src/features/order/resolvers/order-admin.resolver.spec.ts b/src/features/order/resolvers/order-admin.resolver.spec.ts index 583cfa36..626e8ad8 100644 --- a/src/features/order/resolvers/order-admin.resolver.spec.ts +++ b/src/features/order/resolvers/order-admin.resolver.spec.ts @@ -1,5 +1,7 @@ // 분기/검증 세부는 admin-order.service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로만 본다. +import { PubSub } from 'graphql-subscriptions'; + import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; @@ -8,7 +10,9 @@ import { OrderRepository } from '@/features/order/repositories/order.repository' import { AdminOrderMutationResolver } from '@/features/order/resolvers/order-admin-mutation.resolver'; import { AdminOrderQueryResolver } from '@/features/order/resolvers/order-admin-query.resolver'; import { AdminOrderService } from '@/features/order/services/order-admin.service'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import type { PrismaClient } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -33,6 +37,8 @@ describe('Admin Order Resolvers (real DB)', () => { AccountAdminRepository, OrderRepository, OrderStatusTransitionPolicy, + OrderEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, // 발행 repository가 OutboxPublisher를 주입받는다(08b) ...outboxPublisherProviders(), @@ -66,6 +72,10 @@ describe('Admin Order Resolvers (real DB)', () => { const list = await queryResolver.adminOrders(user); expect(list.totalCount).toBe(1); + expect(list.items[0]).toMatchObject({ + firstItemName: 'Product snapshot', + firstItemImageUrl: null, + }); const detail = await queryResolver.adminOrder(user, order.id.toString()); expect(detail.items).toHaveLength(1); diff --git a/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts b/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts index 8f394476..8dc0094d 100644 --- a/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts +++ b/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts @@ -1,3 +1,5 @@ +import { PubSub } from 'graphql-subscriptions'; + import { ClockService } from '@/common/providers/clock.service'; import { RandomService } from '@/common/providers/random.service'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; @@ -5,11 +7,13 @@ import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log. import { OrderRepository } from '@/features/order/repositories/order.repository'; import { OrderCheckoutMutationResolver } from '@/features/order/resolvers/order-checkout-mutation.resolver'; import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { ProductRepository } from '@/features/product'; import { StorePickupScheduleService } from '@/features/store'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import type { PrismaClient } from '@/generated/prisma/client'; import type { JwtUser } from '@/global/auth'; +import { PUB_SUB } from '@/global/pubsub'; import { bookedQuantityProviders } from '@/test/booked-quantity'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; @@ -43,6 +47,8 @@ describe('OrderCheckout Mutation Resolver (real DB)', () => { StoreRepository, ClockService, RandomService, + OrderEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, // 발행 repository가 OutboxPublisher를 주입받는다(08b) ...outboxPublisherProviders({ clock: true }), ...bookedQuantityProviders(), diff --git a/src/features/order/resolvers/order-seller.resolver.spec.ts b/src/features/order/resolvers/order-seller.resolver.spec.ts index ef1833e9..e7f9d97a 100644 --- a/src/features/order/resolvers/order-seller.resolver.spec.ts +++ b/src/features/order/resolvers/order-seller.resolver.spec.ts @@ -1,12 +1,16 @@ +import { PubSub } from 'graphql-subscriptions'; + import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy'; import { OrderRepository } from '@/features/order/repositories/order.repository'; import { SellerOrderMutationResolver } from '@/features/order/resolvers/order-seller-mutation.resolver'; import { SellerOrderQueryResolver } from '@/features/order/resolvers/order-seller-query.resolver'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { SellerOrderService } from '@/features/order/services/order-seller.service'; import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; import type { PrismaClient } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -32,6 +36,8 @@ describe('Seller Order Resolvers (real DB)', () => { StoreSellerRepository, OrderRepository, OrderStatusTransitionPolicy, + OrderEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository, @@ -74,6 +80,10 @@ describe('Seller Order Resolvers (real DB)', () => { accountId: me.account.id.toString(), }); expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + firstItemName: 'Product snapshot', + firstItemImageUrl: null, + }); }); it('Mutation.sellerUpdateOrderStatus: 타 store 주문 접근은 404 전파', async () => { diff --git a/src/features/order/resolvers/order-subscription.resolver.ts b/src/features/order/resolvers/order-subscription.resolver.ts new file mode 100644 index 00000000..ab6591d8 --- /dev/null +++ b/src/features/order/resolvers/order-subscription.resolver.ts @@ -0,0 +1,30 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver, Subscription } from '@nestjs/graphql'; + +import { OrderSubscriptionService } from '@/features/order/services/order-subscription.service'; +import { + CurrentUser, + JwtAuthGuard, + Roles, + RolesGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** 이벤트 payload는 발행 시 이미 GraphQL 출력 형태라 resolve는 그대로 통과시킨다. */ +const passthrough = { resolve: (payload: unknown): unknown => payload }; + +@Resolver('Subscription') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('SELLER') +export class OrderSubscriptionResolver { + constructor(private readonly subscriptionService: OrderSubscriptionService) {} + + @Subscription('sellerOrderUpdated', passthrough) + sellerOrderUpdated( + @CurrentUser() user: JwtUser, + ): Promise> { + const accountId = parseAccountId(user); + return this.subscriptionService.subscribeSellerOrderUpdates(accountId); + } +} diff --git a/src/features/order/services/order-admin-mappers.helper.ts b/src/features/order/services/order-admin-mappers.helper.ts index 22f620f7..b1f937b7 100644 --- a/src/features/order/services/order-admin-mappers.helper.ts +++ b/src/features/order/services/order-admin-mappers.helper.ts @@ -28,6 +28,8 @@ export function toAdminOrderSummaryOutput( buyerPhone: row.buyer_phone, totalPrice: row.total_price, createdAt: row.created_at, + firstItemName: row.items[0]?.product_name_snapshot ?? null, + firstItemImageUrl: row.items[0]?.product_thumbnail_url_snapshot ?? null, }; } diff --git a/src/features/order/services/order-admin.service.spec.ts b/src/features/order/services/order-admin.service.spec.ts index 7ec7d0a0..37d3ef03 100644 --- a/src/features/order/services/order-admin.service.spec.ts +++ b/src/features/order/services/order-admin.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { PubSub } from 'graphql-subscriptions'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; @@ -9,8 +10,10 @@ import { NotificationOutboxConsumer } from '@/features/notification/services/not import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy'; import { OrderRepository } from '@/features/order/repositories/order.repository'; import { AdminOrderService } from '@/features/order/services/order-admin.service'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { OutboxDispatcherService } from '@/features/outbox'; import type { PrismaClient } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -26,12 +29,14 @@ import { OUTBOX_TEST_IMPORTS, outboxTestProviders, } from '@/test/outbox'; +import { collectTopic } from '@/test/pubsub'; describe('AdminOrderService (real DB)', () => { let service: AdminOrderService; let orderRepo: OrderRepository; let prisma: PrismaClient; let dispatcher: OutboxDispatcherService; + let pubSub: PubSub; beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ @@ -42,6 +47,9 @@ describe('AdminOrderService (real DB)', () => { OrderRepository, OrderStatusTransitionPolicy, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + OrderEventsService, + // 발행-구독 왕복은 실 Redis spec(events service) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, ...outboxTestProviders(), NotificationOutboxConsumer, NotificationRepository, @@ -51,6 +59,7 @@ describe('AdminOrderService (real DB)', () => { service = module.get(AdminOrderService); orderRepo = module.get(OrderRepository); dispatcher = module.get(OutboxDispatcherService); + pubSub = module.get(PUB_SUB); prisma = p; }); @@ -173,16 +182,30 @@ describe('AdminOrderService (real DB)', () => { expect(page2.items.map((o) => o.id)).toEqual([ids[0].toString()]); }); - it('매장명은 첫 활성 품목의 주문 시점 스냅샷이고 품목이 없으면 null', async () => { + it('매장명·첫 품목명·이미지는 첫 활성 품목의 주문 시점 스냅샷이고 품목이 없으면 null', async () => { const { order, item } = await orderWithItem(); await prisma.orderItem.update({ where: { id: item.id }, - data: { store_name_snapshot: '주문 시점 매장' }, + data: { + store_name_snapshot: '주문 시점 매장', + product_name_snapshot: '주문 시점 상품', + product_thumbnail_url_snapshot: 'https://img/old.jpg', + }, }); await prisma.store.update({ where: { id: item.store_id }, data: { store_name: '바뀐 매장' }, }); + await prisma.product.update({ + where: { id: item.product_id }, + data: { name: '바뀐 상품' }, + }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: item.store_id, + product_name_snapshot: '삭제된 품목', + deleted_at: new Date(), + }); const empty = await createOrder(prisma); const result = await service.adminOrders(await admin()); @@ -191,10 +214,14 @@ describe('AdminOrderService (real DB)', () => { expect(byId.get(order.id.toString())).toMatchObject({ storeId: item.store_id.toString(), storeName: '주문 시점 매장', + firstItemName: '주문 시점 상품', + firstItemImageUrl: 'https://img/old.jpg', }); expect(byId.get(empty.id.toString())).toMatchObject({ storeId: null, storeName: null, + firstItemName: null, + firstItemImageUrl: null, }); }); }); @@ -242,7 +269,11 @@ describe('AdminOrderService (real DB)', () => { note: ' 가게 사정 ', }); - expect(result.status).toBe('CANCELED'); + expect(result).toMatchObject({ + status: 'CANCELED', + firstItemName: 'Product snapshot', + firstItemImageUrl: null, + }); const row = await prisma.order.findUniqueOrThrow({ where: { id: order.id }, }); @@ -365,8 +396,9 @@ describe('AdminOrderService (real DB)', () => { ); }); - it('두 관리자가 동시에 취소해도 이력·알림·감사는 1건씩', async () => { - const { order } = await orderWithItem(); + it('두 관리자가 동시에 취소해도 이력·알림·감사·구독 이벤트는 1건씩', async () => { + const { order, storeId } = await orderWithItem(); + const topic = await collectTopic(pubSub, `order.seller.${storeId}`); const [a, b] = [await admin(), await admin()]; const results = await Promise.allSettled([ service.adminCancelOrder(a, { @@ -396,6 +428,70 @@ describe('AdminOrderService (real DB)', () => { where: { target_type: 'ORDER', target_id: order.id }, }), ).toBe(1); + expect(topic.received).toHaveLength(1); + topic.stop(); + }); + }); + + describe('sellerOrderUpdated 발행', () => { + it('관리자 취소는 첫 품목 매장 토픽에 1건 — 취소된 주문 행의 스냅샷을 싣는다', async () => { + const { order, storeId } = await orderWithItem({ status: 'CONFIRMED' }); + const topic = await collectTopic(pubSub, `order.seller.${storeId}`); + + await service.adminCancelOrder(await admin(), { + orderId: order.id.toString(), + note: '가게 사정', + }); + + const row = await prisma.order.findUniqueOrThrow({ + where: { id: order.id }, + }); + expect(topic.received).toEqual([ + { + orderId: order.id.toString(), + orderNumber: order.order_number, + status: 'CANCELED', + pickupAt: order.pickup_at.toISOString(), + buyerName: order.buyer_name, + totalPrice: order.total_price, + productName: 'Product snapshot', + updatedAt: row.updated_at.toISOString(), + }, + ]); + topic.stop(); + }); + + it('반증: 취소 불가 주문(ORDER_NOT_CANCELLABLE)은 발행하지 않는다', async () => { + const { order, storeId } = await orderWithItem({ status: 'PICKED_UP' }); + const topic = await collectTopic(pubSub, `order.seller.${storeId}`); + + await expect( + service.adminCancelOrder(await admin(), { + orderId: order.id.toString(), + note: 'x', + }), + ).rejects.toThrowDomain('ORDER_NOT_CANCELLABLE'); + + expect(topic.received).toHaveLength(0); + topic.stop(); + }); + + it('활성 품목이 없으면 매장을 알 수 없어 발행을 생략한다', async () => { + const { order, item } = await orderWithItem(); + await prisma.orderItem.update({ + where: { id: item.id }, + data: { deleted_at: new Date() }, + }); + const publish = jest.spyOn(pubSub, 'publish'); + + const result = await service.adminCancelOrder(await admin(), { + orderId: order.id.toString(), + note: 'x', + }); + + expect(result).toMatchObject({ status: 'CANCELED', storeId: null }); + expect(publish).not.toHaveBeenCalled(); + publish.mockRestore(); }); }); }); diff --git a/src/features/order/services/order-admin.service.ts b/src/features/order/services/order-admin.service.ts index 7f29d7dd..e2e9434d 100644 --- a/src/features/order/services/order-admin.service.ts +++ b/src/features/order/services/order-admin.service.ts @@ -26,6 +26,8 @@ import { toAdminOrderDetailOutput, toAdminOrderSummaryOutput, } from '@/features/order/services/order-admin-mappers.helper'; +import { toSellerOrderUpdateEvent } from '@/features/order/services/order-events-mappers.helper'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import type { AdminOrderDetailOutput, AdminOrderSummaryOutput, @@ -41,6 +43,7 @@ export class AdminOrderService extends AdminBaseService { auditLogs: IAuditLogRepository, private readonly orderRepository: OrderRepository, private readonly statusPolicy: OrderStatusTransitionPolicy, + private readonly events: OrderEventsService, ) { super(accounts, auditLogs); } @@ -132,6 +135,15 @@ export class AdminOrderService extends AdminBaseService { ); throw new DomainException('ORDER_NOT_CANCELLABLE'); } + + // 활성 품목이 없으면 매장을 알 수 없어 발행을 생략한다 + const [firstItem] = updated.items; + if (firstItem) { + await this.events.publishSellerOrderUpdate( + firstItem.store_id, + toSellerOrderUpdateEvent(updated, firstItem.product_name_snapshot), + ); + } return toAdminOrderSummaryOutput(updated); } } diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index ae44f322..fec7a419 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -1,10 +1,17 @@ +import { PubSub } from 'graphql-subscriptions'; + import { ClockService } from '@/common/providers/clock.service'; import { RandomService } from '@/common/providers/random.service'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; +import { + ORDER_SUBMITTED, + parseOrderSubmittedPayload, +} from '@/features/order/events/order-submitted.event'; import { OrderRepository } from '@/features/order/repositories/order.repository'; import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { ProductRepository } from '@/features/product'; import { StorePickupScheduleService } from '@/features/store'; import { StoreRepository } from '@/features/store/repositories/store.repository'; @@ -14,6 +21,7 @@ import type { Product, Store, } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; import { bookedQuantityProviders } from '@/test/booked-quantity'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; @@ -28,6 +36,7 @@ import { } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; import { outboxPublisherProviders } from '@/test/outbox'; +import { collectTopic } from '@/test/pubsub'; // 2026-09-16(수) 16:00 KST 고정 const NOW = new Date('2026-09-16T07:00:00.000Z'); @@ -39,6 +48,7 @@ describe('OrderCheckoutService (real DB)', () => { let orderRepo: OrderRepository; let clock: ClockService; let random: RandomService; + let pubSub: PubSub; let prisma: PrismaClient; beforeAll(async () => { @@ -52,6 +62,9 @@ describe('OrderCheckoutService (real DB)', () => { StoreRepository, ClockService, RandomService, + OrderEventsService, + // 발행-구독 왕복은 실 Redis spec(events service) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, // 발행 repository가 OutboxPublisher를 주입받는다(08b) ...outboxPublisherProviders({ clock: true }), ...bookedQuantityProviders(), @@ -61,6 +74,7 @@ describe('OrderCheckoutService (real DB)', () => { orderRepo = module.get(OrderRepository); clock = module.get(ClockService); random = module.get(RandomService); + pubSub = module.get(PUB_SUB); prisma = p; }); @@ -955,4 +969,207 @@ describe('OrderCheckoutService (real DB)', () => { expect(await prisma.order.count()).toBe(1); }); }); + + describe('createOrder — 접수 이벤트(order.submitted)', () => { + async function submittedEvents() { + return prisma.outbox.findMany({ + where: { event_type: ORDER_SUBMITTED }, + orderBy: { id: 'asc' }, + }); + } + + it('생성과 같은 tx에 1건 남기고 payload는 생성 시점 스냅샷이다', async () => { + const store = await makeOpenStore(); + const { product, sizeSmallId } = await makeProductWithOptions(store.id); + const buyer = await makeBuyer(); + + const result = await service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [sizeSmallId.toString()], + quantity: 2, + }), + ); + + const events = await submittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + aggregate_type: 'order', + aggregate_id: result.orderId, + occurred_at: NOW, + actor_account_id: buyer.id, + }); + expect(parseOrderSubmittedPayload(events[0].payload_json)).toEqual({ + orderId: result.orderId, + orderNumber: result.orderNumber, + buyerAccountId: buyer.id.toString(), + storeId: store.id.toString(), + storeName: store.store_name, + productId: product.id.toString(), + productName: product.name, + quantity: 2, + pickupAt: VALID_PICKUP_AT.toISOString(), + // (25000 + 2000) × 2 + totalPrice: 54000, + }); + }); + + it('반증: capacity 거절은 이벤트를 남기지 않는다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 1, + }); + + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), quantity: 2 }), + ), + ).rejects.toThrowDomain(400); + + expect(await submittedEvents()).toHaveLength(0); + }); + + it('반증: 같은 키 replay(재요청·capacity race)는 이벤트를 추가하지 않는다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 1, + }); + const input = baseInput({ + idempotencyKey: 'replay-idem-key', + productId: product.id.toString(), + }); + + const first = await service.createOrder(buyer.id, input); + expect(await service.createOrder(buyer.id, input)).toEqual(first); + // 사전 조회 miss → tx 안 capacity 거절 → replay 경로 + jest + .spyOn(orderRepo, 'findOrderByIdempotencyKey') + .mockResolvedValueOnce(null); + expect(await service.createOrder(buyer.id, input)).toEqual(first); + + const events = await submittedEvents(); + expect(events).toHaveLength(1); + expect(events[0].aggregate_id).toBe(first.orderId); + }); + }); + + describe('sellerOrderUpdated 발행', () => { + it('주문 생성 시 매장 토픽에 1건 — 커밋된 주문 행(updated_at)의 스냅샷을 싣는다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + + const result = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), buyerName: '차차' }), + ); + + const saved = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(result.orderId) }, + }); + expect(topic.received).toEqual([ + { + orderId: result.orderId, + orderNumber: result.orderNumber, + status: 'SUBMITTED', + pickupAt: VALID_PICKUP_AT.toISOString(), + buyerName: '차차', + totalPrice: result.totalPrice, + productName: product.name, + updatedAt: saved.updated_at.toISOString(), + }, + ]); + topic.stop(); + }); + + it('반증: 멱등 replay(사전 조회·P2002 경합)는 발행하지 않는다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + const input = baseInput({ + idempotencyKey: 'replay-no-event', + productId: product.id.toString(), + }); + + await service.createOrder(buyer.id, input); + await service.createOrder(buyer.id, input); + // 사전 조회를 놓친 동시 제출 — 멱등 키 unique 충돌 뒤 기존 주문을 replay한다 + jest + .spyOn(orderRepo, 'findOrderByIdempotencyKey') + .mockResolvedValueOnce(null); + await service.createOrder(buyer.id, input); + + expect(await prisma.order.count()).toBe(1); + expect(topic.received).toHaveLength(1); + topic.stop(); + }); + + it('반증: capacity 거절·tx 안 capacity replay는 발행하지 않는다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 1, + }); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + const input = baseInput({ + idempotencyKey: 'capacity-replay-no-event', + productId: product.id.toString(), + }); + + await service.createOrder(buyer.id, input); + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrowDomain('PICKUP_NOT_AVAILABLE'); + // 사전 조회·사전 capacity 검사를 모두 놓친 동시 재시도 — 잠금 재검사가 거절하고 내 주문을 replay한다 + jest + .spyOn(orderRepo, 'findOrderByIdempotencyKey') + .mockResolvedValueOnce(null); + jest + .spyOn(orderRepo, 'isDailyCapacityExceeded') + .mockResolvedValueOnce(false); + await service.createOrder(buyer.id, input); + + expect(await prisma.order.count()).toBe(1); + expect(topic.received).toHaveLength(1); + topic.stop(); + }); + + it('PubSub 발행이 실패해도 주문은 성공하고 이벤트만 빠진다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + jest + .spyOn(pubSub, 'publish') + .mockRejectedValueOnce(new Error('redis down')); + + const result = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + + expect(result.status).toBe('SUBMITTED'); + expect(await prisma.order.count()).toBe(1); + expect(topic.received).toHaveLength(0); + topic.stop(); + }); + }); }); diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index dfbe6f57..cff5ca2d 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -9,6 +9,8 @@ import { uniqueConstraintName } from '@/common/utils/prisma-error'; import { evaluateActiveUserAccount } from '@/features/auth'; import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { toSellerOrderUpdateEvent } from '@/features/order/services/order-events-mappers.helper'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import type { CreateOrderOutput } from '@/features/order/types/create-order-output.type'; import { ProductRepository, type ProductDetailRow } from '@/features/product'; import { StorePickupScheduleService } from '@/features/store'; @@ -40,6 +42,7 @@ export class OrderCheckoutService { private readonly pickupSchedule: StorePickupScheduleService, private readonly clock: ClockService, private readonly random: RandomService, + private readonly events: OrderEventsService, ) {} /** 옵션 그룹 규칙·픽업 일시를 서버가 재검증하고 가격을 스냅샷한다. 커스텀 입력은 스펙 확정 후 확장. */ @@ -284,6 +287,11 @@ export class OrderCheckoutService { args.idempotencyKey, ); } + // 커밋된 새 주문만 발행한다 — replay(멱등 키·capacity·P2002)는 이미 발행된 주문이다 + await this.events.publishSellerOrderUpdate( + args.item.storeId, + toSellerOrderUpdateEvent(created, args.item.productNameSnapshot), + ); return created; } catch (error) { const isUniqueViolation = diff --git a/src/features/order/services/order-events-mappers.helper.ts b/src/features/order/services/order-events-mappers.helper.ts new file mode 100644 index 00000000..67154191 --- /dev/null +++ b/src/features/order/services/order-events-mappers.helper.ts @@ -0,0 +1,29 @@ +import type { SellerOrderUpdateEvent } from '@/features/order/types/order-seller-output.type'; +import type { Order } from '@/generated/prisma/client'; + +export type SellerOrderUpdateRow = Pick< + Order, + | 'id' + | 'order_number' + | 'status' + | 'pickup_at' + | 'buyer_name' + | 'total_price' + | 'updated_at' +>; + +export function toSellerOrderUpdateEvent( + row: SellerOrderUpdateRow, + productName: string, +): SellerOrderUpdateEvent { + return { + orderId: row.id.toString(), + orderNumber: row.order_number, + status: row.status, + pickupAt: row.pickup_at.toISOString(), + buyerName: row.buyer_name, + totalPrice: row.total_price, + productName, + updatedAt: row.updated_at.toISOString(), + }; +} diff --git a/src/features/order/services/order-events.service.spec.ts b/src/features/order/services/order-events.service.spec.ts new file mode 100644 index 00000000..7353a168 --- /dev/null +++ b/src/features/order/services/order-events.service.spec.ts @@ -0,0 +1,104 @@ +/** 실 Redis(testcontainers) 발행/구독 왕복 — JSON 직렬화를 거친 payload가 구독자에게 그대로 도착하는지 확인한다(DB 불필요). */ +import { RedisPubSub } from 'graphql-redis-subscriptions'; +import type { PubSubEngine } from 'graphql-subscriptions'; +import Redis from 'ioredis'; +import type { StartedTestContainer } from 'testcontainers'; + +import { OrderEventsService } from '@/features/order/services/order-events.service'; +import type { SellerOrderUpdateEvent } from '@/features/order/types/order-seller-output.type'; +import { redisTestContainer } from '@/test/containers'; + +jest.setTimeout(180_000); + +const EVENT: SellerOrderUpdateEvent = { + orderId: '1', + orderNumber: 'ORD-20260916-ABC234', + status: 'SUBMITTED', + pickupAt: '2026-09-18T05:00:00.000Z', + buyerName: '차차', + totalPrice: 56000, + productName: '딸기 케이크', + updatedAt: '2026-09-16T07:00:00.000Z', +}; + +describe('OrderEventsService (real Redis)', () => { + let container: StartedTestContainer; + let publisher: Redis; + let pubSub: RedisPubSub; + let service: OrderEventsService; + + beforeAll(async () => { + container = await redisTestContainer().start(); + const url = `redis://${container.getHost()}:${container.getMappedPort(6379)}`; + publisher = new Redis(url); + const subscriber = new Redis(url); + // 준비 확인(INFO)이 끝나기 전에 SUBSCRIBE가 나가면 구독 모드에서 INFO가 거절된다 — 둘 다 ready를 기다린다 + await Promise.all( + [publisher, subscriber].map( + (client) => new Promise((resolve) => client.once('ready', resolve)), + ), + ); + pubSub = new RedisPubSub({ publisher, subscriber }); + service = new OrderEventsService(pubSub); + }); + + afterAll(async () => { + await pubSub.close(); + await container.stop(); + }); + + /** + * asyncIterableIterator는 첫 next() 호출 시점에 Redis SUBSCRIBE를 보낸다 — + * 발행 전에 next()를 먼저 걸고 서버에 구독이 잡힐 때까지(PUBSUB NUMSUB) 기다려야 이벤트를 + * 놓치지 않는다(구독 등록 전 발행분은 유실되는 게 Pub/Sub 의미론). + */ + async function startListening( + iterator: AsyncIterator, + channel: string, + ) { + const pending = iterator.next().then((r) => r.value); + for (;;) { + const [, count] = (await publisher.pubsub('NUMSUB', channel)) as [ + string, + number, + ]; + if (count > 0) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return { pending }; + } + + it('판매자 토픽 발행이 해당 매장 구독자에게만 도착하고 ISO 날짜가 보존된다', async () => { + const target = service.sellerOrderIterator(BigInt(3)); + const other = service.sellerOrderIterator(BigInt(99)); + const { pending: pendingTarget } = await startListening( + target, + 'order.seller.3', + ); + const otherReceived = jest.fn(); + const { pending: pendingOther } = await startListening( + other, + 'order.seller.99', + ); + void pendingOther.then(otherReceived); + + await service.publishSellerOrderUpdate(BigInt(3), EVENT); + + await expect(pendingTarget).resolves.toEqual(EVENT); + expect(otherReceived).not.toHaveBeenCalled(); + await other.return?.(); + }); + + it('발행 실패는 삼킨다 — 커밋된 주문을 Redis 장애가 실패로 만들지 않는다', async () => { + const failing = { + publish: jest.fn().mockRejectedValue(new Error('redis down')), + } as unknown as PubSubEngine; + + await expect( + new OrderEventsService(failing).publishSellerOrderUpdate( + BigInt(1), + EVENT, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/features/order/services/order-events.service.ts b/src/features/order/services/order-events.service.ts new file mode 100644 index 00000000..2da20a07 --- /dev/null +++ b/src/features/order/services/order-events.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { PubSubEngine } from 'graphql-subscriptions'; + +import type { SellerOrderUpdateEvent } from '@/features/order/types/order-seller-output.type'; +import { PUB_SUB } from '@/global/pubsub'; + +/** 토픽 문자열은 여기서만 조립한다 — 발행자(체크아웃·판매자 상태 변경·관리자 취소)와 구독 리졸버가 같은 토픽을 보는 단일 소스. */ +@Injectable() +export class OrderEventsService { + private readonly logger = new Logger(OrderEventsService.name); + + constructor(@Inject(PUB_SUB) private readonly pubSub: PubSubEngine) {} + + /** + * 발행은 DB 커밋 이후의 부수효과 — Redis 장애가 이미 성공한 주문을 실패로 둔갑시키면 클라이언트 재시도로 + * 중복 주문이 난다. 실패는 경고 로그만 남기고 삼킨다(구독자는 sellerOrderList 재조회 폴백). + */ + private async safePublish(topic: string, payload: unknown): Promise { + try { + await this.pubSub.publish(topic, payload); + } catch (e) { + this.logger.warn( + `subscription publish 실패 (topic=${topic}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + + private sellerTopic(storeId: bigint): string { + return `order.seller.${storeId}`; + } + + async publishSellerOrderUpdate( + storeId: bigint, + event: SellerOrderUpdateEvent, + ): Promise { + await this.safePublish(this.sellerTopic(storeId), event); + } + + sellerOrderIterator(storeId: bigint): AsyncIterator { + return this.pubSub.asyncIterableIterator(this.sellerTopic(storeId)); + } +} diff --git a/src/features/order/services/order-seller.service.spec.ts b/src/features/order/services/order-seller.service.spec.ts index de18bf2c..bfc5d191 100644 --- a/src/features/order/services/order-seller.service.spec.ts +++ b/src/features/order/services/order-seller.service.spec.ts @@ -1,24 +1,32 @@ +import { PubSub } from 'graphql-subscriptions'; + import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy'; import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { SellerOrderService } from '@/features/order/services/order-seller.service'; import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; import { OrderStatus, type PrismaClient } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, createOrder, createOrderItem, + createProduct, setupSellerWithStore, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; import { outboxPublisherProviders } from '@/test/outbox'; +import { collectTopic } from '@/test/pubsub'; describe('SellerOrderService (real DB)', () => { let service: SellerOrderService; let prisma: PrismaClient; + let repo: OrderRepository; + let pubSub: PubSub; beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ @@ -31,11 +39,16 @@ describe('SellerOrderService (real DB)', () => { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository, }, + OrderEventsService, + // 발행-구독 왕복은 실 Redis spec(events service) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, // 발행 repository가 OutboxPublisher를 주입받는다(08b) ...outboxPublisherProviders(), ], }); service = module.get(SellerOrderService); + repo = module.get(OrderRepository); + pubSub = module.get(PUB_SUB); prisma = p; }); @@ -84,6 +97,85 @@ describe('SellerOrderService (real DB)', () => { expect(result.items).toHaveLength(1); }); + it('첫 품목 상품명·이미지는 주문 시점 스냅샷이라 상품을 바꿔도 유지된다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + name: '주문 시점 상품', + }); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + product_name_snapshot: '주문 시점 상품', + product_thumbnail_url_snapshot: 'https://img/old.jpg', + }); + await prisma.product.update({ + where: { id: product.id }, + data: { name: '바뀐 상품' }, + }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'https://img/new.jpg' }, + }); + + const result = await service.sellerOrderList(account.id); + expect(result.items[0]).toMatchObject({ + firstItemName: '주문 시점 상품', + firstItemImageUrl: 'https://img/old.jpg', + }); + }); + + it('다른 매장 품목의 id가 더 작아도 내 매장 품목이 첫 품목이다', async () => { + const me = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + store_id: other.store.id, + product_name_snapshot: '남의 매장 품목', + }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: me.store.id, + product_name_snapshot: '내 매장 품목', + }); + + const result = await service.sellerOrderList(me.account.id); + expect(result.items[0].firstItemName).toBe('내 매장 품목'); + }); + + it('soft-delete된 품목은 첫 품목에서 제외한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + product_name_snapshot: '삭제된 품목', + deleted_at: new Date(), + }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + product_name_snapshot: '활성 품목', + }); + + const result = await service.sellerOrderList(account.id); + expect(result.items[0].firstItemName).toBe('활성 품목'); + }); + + it('썸네일 스냅샷이 없으면 firstItemImageUrl은 null', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + product_thumbnail_url_snapshot: null, + }); + + const result = await service.sellerOrderList(account.id); + expect(result.items[0].firstItemImageUrl).toBeNull(); + }); + it('status 필터링이 동작한다', async () => { const { account, store } = await setupSellerWithStore(prisma); await createStoreOrder(store.id, { status: 'SUBMITTED' }); @@ -270,7 +362,13 @@ describe('SellerOrderService (real DB)', () => { it('정상 상태 전이: SUBMITTED → CONFIRMED, status_history row 생성 확인', async () => { const { account, store } = await setupSellerWithStore(prisma); - const order = await createStoreOrder(store.id); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + product_name_snapshot: '확정 품목', + product_thumbnail_url_snapshot: 'https://img/confirm.jpg', + }); const result = await service.sellerUpdateOrderStatus(account.id, { orderId: order.id.toString(), @@ -278,7 +376,11 @@ describe('SellerOrderService (real DB)', () => { note: null, }); - expect(result.status).toBe('CONFIRMED'); + expect(result).toMatchObject({ + status: 'CONFIRMED', + firstItemName: '확정 품목', + firstItemImageUrl: 'https://img/confirm.jpg', + }); const dbOrder = await prisma.order.findUniqueOrThrow({ where: { id: order.id }, @@ -294,6 +396,35 @@ describe('SellerOrderService (real DB)', () => { expect(histories[0].to_status).toBe('CONFIRMED'); }); + it('사전 검사 뒤 품목이 삭제돼도 상태는 바뀌고 첫 품목 필드는 null이다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createStoreOrder(store.id); + // 사전 검사(findOrderDetailByStore)와 잠금 사이에 품목이 soft-delete되는 경합 — 잠금 SQL은 품목 deleted_at을 안 본다 + const original = repo.findOrderDetailByStore.bind(repo); + jest + .spyOn(repo, 'findOrderDetailByStore') + .mockImplementationOnce(async (args) => { + const row = await original(args); + await prisma.orderItem.updateMany({ + where: { order_id: order.id }, + data: { deleted_at: new Date() }, + }); + return row; + }); + + const result = await service.sellerUpdateOrderStatus(account.id, { + orderId: order.id.toString(), + toStatus: 'CONFIRMED', + note: null, + }); + + expect(result).toMatchObject({ + status: 'CONFIRMED', + firstItemName: null, + firstItemImageUrl: null, + }); + }); + it('CANCELED 전환 + note 제공 시 정상 처리', async () => { const { account, store } = await setupSellerWithStore(prisma); const order = await createStoreOrder(store.id); @@ -311,4 +442,60 @@ describe('SellerOrderService (real DB)', () => { expect(histories[0].note).toBe('재고 부족'); }); }); + + describe('sellerOrderUpdated 발행', () => { + it('상태 변경 시 매장 토픽에 1건 — 첫 품목 상품명과 갱신된 updated_at을 싣는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createOrder(prisma); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + product_name_snapshot: '확정 품목', + }); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + + await service.sellerUpdateOrderStatus(account.id, { + orderId: order.id.toString(), + toStatus: 'CONFIRMED', + note: null, + }); + + const row = await prisma.order.findUniqueOrThrow({ + where: { id: order.id }, + }); + expect(topic.received).toEqual([ + { + orderId: order.id.toString(), + orderNumber: order.order_number, + status: 'CONFIRMED', + pickupAt: order.pickup_at.toISOString(), + buyerName: order.buyer_name, + totalPrice: order.total_price, + productName: '확정 품목', + updatedAt: row.updated_at.toISOString(), + }, + ]); + expect(row.updated_at.getTime()).toBeGreaterThan( + order.updated_at.getTime(), + ); + topic.stop(); + }); + + it('반증: 전이 거절(INVALID_ORDER_STATUS_TRANSITION)은 발행하지 않는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createStoreOrder(store.id); + const topic = await collectTopic(pubSub, `order.seller.${store.id}`); + + await expect( + service.sellerUpdateOrderStatus(account.id, { + orderId: order.id.toString(), + toStatus: 'MADE', + note: null, + }), + ).rejects.toThrowDomain('INVALID_ORDER_STATUS_TRANSITION'); + + expect(topic.received).toHaveLength(0); + topic.stop(); + }); + }); }); diff --git a/src/features/order/services/order-seller.service.ts b/src/features/order/services/order-seller.service.ts index ab2cbb94..988d04db 100644 --- a/src/features/order/services/order-seller.service.ts +++ b/src/features/order/services/order-seller.service.ts @@ -17,7 +17,12 @@ import { import type { SellerOrderListInput } from '@/features/order/dto/inputs/seller-order-list.input'; import type { SellerUpdateOrderStatusInput } from '@/features/order/dto/inputs/seller-update-order-status.input'; import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy'; -import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { + OrderRepository, + type SellerOrderRow, +} from '@/features/order/repositories/order.repository'; +import { toSellerOrderUpdateEvent } from '@/features/order/services/order-events-mappers.helper'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; import { toOrderItemDetail, toOrderStatusHistory, @@ -39,6 +44,7 @@ export class SellerOrderService extends SellerBaseService { auditLogs: IAuditLogRepository, private readonly orderRepository: OrderRepository, private readonly statusPolicy: OrderStatusTransitionPolicy, + private readonly events: OrderEventsService, ) { super(repo, auditLogs); } @@ -130,19 +136,17 @@ export class SellerOrderService extends SellerBaseService { if (!updated) throw new DomainException('ORDER_NOT_FOUND'); + await this.events.publishSellerOrderUpdate( + ctx.storeId, + toSellerOrderUpdateEvent( + updated, + updated.items[0]?.product_name_snapshot ?? '', + ), + ); return this.toOrderSummaryOutput(updated); } - private toOrderSummaryOutput(row: { - id: bigint; - order_number: string; - status: OrderStatus; - pickup_at: Date; - buyer_name: string; - buyer_phone: string; - total_price: number; - created_at: Date; - }): SellerOrderSummaryOutput { + private toOrderSummaryOutput(row: SellerOrderRow): SellerOrderSummaryOutput { return { id: row.id.toString(), orderNumber: row.order_number, @@ -152,6 +156,9 @@ export class SellerOrderService extends SellerBaseService { buyerPhone: row.buyer_phone, totalPrice: row.total_price, createdAt: row.created_at, + // 내 매장 첫 품목의 주문 시점 스냅샷 — 상품이 바뀌어도 카드 값은 유지된다 + firstItemName: row.items[0]?.product_name_snapshot ?? null, + firstItemImageUrl: row.items[0]?.product_thumbnail_url_snapshot ?? null, }; } diff --git a/src/features/order/services/order-subscription.service.spec.ts b/src/features/order/services/order-subscription.service.spec.ts new file mode 100644 index 00000000..0b0a1852 --- /dev/null +++ b/src/features/order/services/order-subscription.service.spec.ts @@ -0,0 +1,102 @@ +import { PubSub } from 'graphql-subscriptions'; + +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; +import { OrderSubscriptionService } from '@/features/order/services/order-subscription.service'; +import type { SellerOrderUpdateEvent } from '@/features/order/types/order-seller-output.type'; +import { StoreSellerRepository } from '@/features/store'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { PUB_SUB } from '@/global/pubsub'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('OrderSubscriptionService (real DB)', () => { + let service: OrderSubscriptionService; + let events: OrderEventsService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + OrderSubscriptionService, + OrderEventsService, + StoreSellerRepository, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + // 발행-구독 왕복은 실 Redis spec(events service) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, + ], + }); + service = module.get(OrderSubscriptionService); + events = module.get(OrderEventsService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function seller(storeOverrides: Parameters[1]) { + const account = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma, { + seller_account_id: account.id, + ...storeOverrides, + }); + return { account, store }; + } + + const event: SellerOrderUpdateEvent = { + orderId: '1', + orderNumber: 'ORD-1', + status: 'SUBMITTED', + pickupAt: '2026-09-18T05:00:00.000Z', + buyerName: '차차', + totalPrice: 10000, + productName: '케이크', + updatedAt: '2026-09-16T07:00:00.000Z', + }; + + it('매장 보유 판매자는 구독하고 자기 매장 토픽의 이벤트만 받는다', async () => { + const { account, store } = await seller({}); + const other = await seller({}); + + const iterator = await service.subscribeSellerOrderUpdates(account.id); + const pending = iterator.next(); + + await events.publishSellerOrderUpdate(other.store.id, { + ...event, + orderId: 'other', + }); + await events.publishSellerOrderUpdate(store.id, event); + + const { value } = await pending; + expect(value).toEqual(event); + await iterator.return?.(); + }); + + it('매장이 없거나 삭제됐으면 STORE_NOT_FOUND', async () => { + const noStore = await createAccount(prisma, { account_type: 'SELLER' }); + const deleted = await seller({ deleted_at: new Date() }); + + await expect( + service.subscribeSellerOrderUpdates(noStore.id), + ).rejects.toThrowDomain('STORE_NOT_FOUND'); + await expect( + service.subscribeSellerOrderUpdates(deleted.account.id), + ).rejects.toThrowDomain('STORE_NOT_FOUND'); + }); + + it('비활성(is_active=false) 매장도 구독은 유지된다', async () => { + const { account } = await seller({ is_active: false }); + const iterator = await service.subscribeSellerOrderUpdates(account.id); + expect(iterator).toBeDefined(); + await iterator.return?.(); + }); +}); diff --git a/src/features/order/services/order-subscription.service.ts b/src/features/order/services/order-subscription.service.ts new file mode 100644 index 00000000..73cba040 --- /dev/null +++ b/src/features/order/services/order-subscription.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { OrderEventsService } from '@/features/order/services/order-events.service'; +import { StoreSellerRepository } from '@/features/store'; + +/** 이벤트 발행은 각 쓰기 서비스(체크아웃·판매자 상태 변경·관리자 취소)가 담당한다. */ +@Injectable() +export class OrderSubscriptionService { + constructor( + private readonly stores: StoreSellerRepository, + private readonly events: OrderEventsService, + ) {} + + async subscribeSellerOrderUpdates( + accountId: bigint, + ): Promise> { + const store = await this.stores.findStoreBySellerAccountId(accountId); + if (!store) { + throw new DomainException('STORE_NOT_FOUND'); + } + return this.events.sellerOrderIterator(store.id); + } +} diff --git a/src/features/order/types/order-admin-output.type.ts b/src/features/order/types/order-admin-output.type.ts index d1aeaa35..55246c2d 100644 --- a/src/features/order/types/order-admin-output.type.ts +++ b/src/features/order/types/order-admin-output.type.ts @@ -16,6 +16,8 @@ export interface AdminOrderSummaryOutput { buyerPhone: string; totalPrice: number; createdAt: Date; + firstItemName: string | null; + firstItemImageUrl: string | null; } export interface AdminOrderDetailOutput { diff --git a/src/features/order/types/order-seller-output.type.ts b/src/features/order/types/order-seller-output.type.ts index 2a0e94ca..09f06fe9 100644 --- a/src/features/order/types/order-seller-output.type.ts +++ b/src/features/order/types/order-seller-output.type.ts @@ -12,6 +12,8 @@ export interface SellerOrderSummaryOutput { buyerPhone: string; totalPrice: number; createdAt: Date; + firstItemName: string | null; + firstItemImageUrl: string | null; } export interface SellerOrderDetailOutput { @@ -35,3 +37,15 @@ export interface SellerOrderDetailOutput { items: OrderItemDetailOutput[]; statusHistories: OrderStatusHistoryOutput[]; } + +/** 구독 이벤트 payload — Redis JSON 직렬화를 거치므로 날짜는 ISO 문자열. */ +export interface SellerOrderUpdateEvent { + orderId: string; + orderNumber: string; + status: 'SUBMITTED' | 'CONFIRMED' | 'MADE' | 'PICKED_UP' | 'CANCELED'; + pickupAt: string; + buyerName: string; + totalPrice: number; + productName: string; + updatedAt: string; +} diff --git a/src/features/outbox/rabbitmq/topology.spec.ts b/src/features/outbox/rabbitmq/topology.spec.ts index a485654f..58763a81 100644 --- a/src/features/outbox/rabbitmq/topology.spec.ts +++ b/src/features/outbox/rabbitmq/topology.spec.ts @@ -1,4 +1,8 @@ +import type { ConfirmChannel } from 'amqplib'; + import { + assertTopology, + EVENTS_EXCHANGE, parseMessage, queuesFor, toEvent, @@ -80,4 +84,30 @@ describe('rabbitmq topology', () => { ])('반증: %s → 던진다', (_label, body) => { expect(() => parseMessage(Buffer.from(body))).toThrow(); }); + + it('반증: 두 소비자가 같은 event_type을 구독하면 큐 2개에 각각 바인딩된다(fan-out은 바인딩으로)', async () => { + const bindQueue = jest.fn().mockResolvedValue(undefined); + const channel = { + assertExchange: jest.fn().mockResolvedValue(undefined), + assertQueue: jest.fn().mockResolvedValue(undefined), + bindQueue, + } as unknown as ConfirmChannel; + + await assertTopology(channel, [ + { queues: queuesFor('A'), eventTypes: ['order.submitted'] }, + { + queues: queuesFor('B'), + eventTypes: ['order.submitted', 'order.status_changed'], + }, + ]); + + const eventBindings = bindQueue.mock.calls.filter( + ([, exchange]) => exchange === EVENTS_EXCHANGE, + ); + expect(eventBindings).toEqual([ + ['q.A', EVENTS_EXCHANGE, 'order.submitted'], + ['q.B', EVENTS_EXCHANGE, 'order.submitted'], + ['q.B', EVENTS_EXCHANGE, 'order.status_changed'], + ]); + }); }); diff --git a/src/features/product/constants/product-seller.constants.ts b/src/features/product/constants/product-seller.constants.ts index e137d444..52163c4f 100644 --- a/src/features/product/constants/product-seller.constants.ts +++ b/src/features/product/constants/product-seller.constants.ts @@ -26,3 +26,12 @@ export const MAX_OPTION_ITEM_DESCRIPTION_LENGTH = 500; export const MAX_TOKEN_KEY_LENGTH = 60; export const MAX_TOKEN_DEFAULT_TEXT_LENGTH = 200; export const DEFAULT_TOKEN_MAX_LENGTH = 30; + +// ── 태그 검색 ── + +export const DEFAULT_TAG_SUGGESTIONS = 10; +export const MAX_TAG_SUGGESTIONS = 20; + +// ── 태그 연결 ── + +export const MAX_TAGS_PER_PRODUCT = 20; diff --git a/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.spec.ts b/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.spec.ts new file mode 100644 index 00000000..8cdfb22b --- /dev/null +++ b/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.spec.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerSetProductTagsByNameInput } from '@/features/product/dto/inputs/seller-set-product-tags-by-name.input'; + +function build(plain: object): SellerSetProductTagsByNameInput { + return plainToInstance(SellerSetProductTagsByNameInput, plain); +} + +describe('SellerSetProductTagsByNameInput', () => { + it('정상 입력 통과', async () => { + const dto = build({ productId: '1', names: ['생일', '#레터링'] }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('names 빈 배열 통과 (전체 태그 해제)', async () => { + const dto = build({ productId: '1', names: [] }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('names 누락 거절', async () => { + const dto = build({ productId: '1' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('names'); + }); + + it('문자열이 아닌 요소 거절', async () => { + const dto = build({ productId: '1', names: ['생일', 1] }); + const errors = await validate(dto); + expect(errors[0].property).toBe('names'); + }); + + it('길이·개수는 DTO가 막지 않는다(서비스 판정)', async () => { + const dto = build({ + productId: '1', + names: Array.from({ length: 41 }, () => 'a'.repeat(81)), + }); + expect(await validate(dto)).toHaveLength(0); + }); +}); diff --git a/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.ts b/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.ts new file mode 100644 index 00000000..027740a9 --- /dev/null +++ b/src/features/product/dto/inputs/seller-set-product-tags-by-name.input.ts @@ -0,0 +1,10 @@ +import { IsArray, IsString } from 'class-validator'; + +export class SellerSetProductTagsByNameInput { + @IsString() + productId!: string; + + @IsArray() + @IsString({ each: true }) + names!: string[]; +} diff --git a/src/features/product/dto/inputs/seller-tag-search.input.spec.ts b/src/features/product/dto/inputs/seller-tag-search.input.spec.ts new file mode 100644 index 00000000..1c6979e8 --- /dev/null +++ b/src/features/product/dto/inputs/seller-tag-search.input.spec.ts @@ -0,0 +1,38 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SellerTagSearchInput } from '@/features/product/dto/inputs/seller-tag-search.input'; + +function build(plain: object): SellerTagSearchInput { + return plainToInstance(SellerTagSearchInput, plain); +} + +describe('SellerTagSearchInput', () => { + it('keyword만 주면 통과(limit 기본값은 SDL이 채운다)', async () => { + expect(await validate(build({ keyword: '생일' }))).toHaveLength(0); + expect( + await validate(build({ keyword: '생일', limit: null })), + ).toHaveLength(0); + }); + + it('keyword가 문자열이 아니면 거절', async () => { + const errors = await validate(build({ keyword: 1 })); + expect(errors[0].property).toBe('keyword'); + }); + + it('빈 문자열·공백 keyword는 DTO를 통과한다(서비스가 빈 배열로 응답)', async () => { + expect(await validate(build({ keyword: '' }))).toHaveLength(0); + expect(await validate(build({ keyword: ' ' }))).toHaveLength(0); + }); + + it.each([0, 21, 1.5])('limit %p 거절', async (limit) => { + const errors = await validate(build({ keyword: 'a', limit })); + expect(errors[0].property).toBe('limit'); + }); + + it.each([1, 20])('limit %p 통과', async (limit) => { + expect(await validate(build({ keyword: 'a', limit }))).toHaveLength(0); + }); +}); diff --git a/src/features/product/dto/inputs/seller-tag-search.input.ts b/src/features/product/dto/inputs/seller-tag-search.input.ts new file mode 100644 index 00000000..d122a0a2 --- /dev/null +++ b/src/features/product/dto/inputs/seller-tag-search.input.ts @@ -0,0 +1,14 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +import { MAX_TAG_SUGGESTIONS } from '@/features/product/constants/product-seller.constants'; + +export class SellerTagSearchInput { + @IsString() + keyword!: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_TAG_SUGGESTIONS) + limit?: number | null; +} diff --git a/src/features/product/product-admin-taxonomy.graphql b/src/features/product/product-admin-taxonomy.graphql index c4670699..8dcbd34b 100644 --- a/src/features/product/product-admin-taxonomy.graphql +++ b/src/features/product/product-admin-taxonomy.graphql @@ -26,6 +26,7 @@ extend type Mutation { adminDeleteCategory(categoryId: ID!): Boolean! """ 태그를 만든다. 같은 이름이 있으면 BAD_USER_INPUT. 삭제된 같은 이름이 있으면 복구한다(상품 연결은 복구하지 않는다). + 판매자가 sellerSetProductTagsByName으로 만든 태그도 같은 마스터에 있으므로 충돌 대상이다. 관리자 로그인 필수. ADMIN 계정이 아니면 FORBIDDEN. """ adminCreateTag(input: AdminCreateTagInput!): AdminTag! @@ -73,7 +74,7 @@ type AdminCategory { } """ -태그 마스터. 판매자는 여기 있는 것 중에서만 상품에 연결할 수 있다. +태그 마스터. 관리자가 만든 태그와 판매자가 sellerSetProductTagsByName으로 만든 태그(소문자 정규화)를 모두 포함한다. """ type AdminTag { id: ID! diff --git a/src/features/product/product-seller.graphql b/src/features/product/product-seller.graphql index 6f84df08..742d3535 100644 --- a/src/features/product/product-seller.graphql +++ b/src/features/product/product-seller.graphql @@ -7,6 +7,12 @@ extend type Query { 내 매장 상품 상세를 조회한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. """ sellerProduct(productId: ID!): SellerProduct! + """ + 내 상품에 붙일 태그를 이름으로 검색한다(타이프어헤드). keyword를 정규화(앞뒤 공백·선행 '#' 제거, 연속 공백 1칸, 소문자)한 뒤 + 부분일치로 찾아 이름순(정확 일치가 맨 앞)으로 limit건까지 준다. 정확 일치 태그는 이름순 limit 밖이어도 항상 포함한다. + 정규화 후 비면 빈 배열, 80자를 넘으면 BAD_USER_INPUT. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + """ + sellerSearchTags(input: SellerTagSearchInput!): [SellerTagSuggestion!]! } extend type Mutation { @@ -52,6 +58,17 @@ extend type Mutation { 상품 태그 연결을 설정한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. """ sellerSetProductTags(input: SellerSetProductTagsInput!): SellerProduct! + """ + 상품 태그 연결을 이름 목록으로 통째로 교체한다. 각 이름은 앞뒤 공백·선행 '#' 제거, 연속 공백 1칸, 소문자로 정규화하고 + 정규화 후 같은 이름은 하나로 합친다(대소문자·악센트가 같은 이름도 DB 기준 하나로 저장되지만, 개수 검사는 정규화 문자열 + 기준이라 악센트만 다른 café·cafe는 각각 센다). 없는 이름은 새 태그로 만들고 + (삭제된 같은 이름은 복구), 있으면 저장된 형태 그대로 재사용한다. 빈 목록이면 전체 해제. + 정규화 후 빈 이름이 있거나 80자(코드 포인트, 정규화 전후 모두)를 넘는 이름이 있거나 합친 뒤 20개를 넘으면 BAD_USER_INPUT. + 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않거나 내 상품이 아니면 NOT_FOUND. + """ + sellerSetProductTagsByName( + input: SellerSetProductTagsByNameInput! + ): SellerProduct! """ 상품 옵션 그룹을 생성한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. @@ -147,6 +164,25 @@ type SellerTag { name: String! } +""" +태그 제안 1건. 정확 일치 여부로 FE가 '새 태그 만들기' 행을 띄울지 판단한다. +""" +type SellerTagSuggestion { + id: ID! + """ + 태그명(저장된 형태). + """ + name: String! + """ + 정규화한 keyword와 이름이 같으면 true(대소문자·악센트 무시). 목록에 true가 없으면 FE는 '새로 만들기'를 보여준다. + """ + isExactMatch: Boolean! + """ + 이 태그가 붙은 활성 상품 수(전 매장). 표시용 근사치이며 정렬에는 쓰지 않는다. + """ + productCount: Int! +} + """ 상품 이미지 1장. """ @@ -572,6 +608,32 @@ input SellerSetProductTagsInput { tagIds: [ID!]! } +""" +이름 기반 태그 연결 설정 입력. 전달한 목록으로 통째로 교체한다. +""" +input SellerSetProductTagsByNameInput { + productId: ID! + """ + 연결할 태그 이름 전체. 정규화·중복 제거 뒤 최대 20개(개수는 정규화 문자열 기준 — 악센트만 다른 이름은 저장 시 하나로 + 합쳐져도 각각 센다). 각 이름은 정규화 전후 모두 80자(코드 포인트) 이내. 빈 배열이면 전체 해제. + """ + names: [String!]! +} + +""" +태그 검색 입력. +""" +input SellerTagSearchInput { + """ + 검색어. 정규화 후 80자를 넘으면 BAD_USER_INPUT. + """ + keyword: String! + """ + 최대 건수. 기본 10, 1~20만 허용하며 벗어나면 BAD_USER_INPUT. + """ + limit: Int = 10 +} + """ 옵션 그룹 생성 입력. """ diff --git a/src/features/product/repositories/product.repository.spec.ts b/src/features/product/repositories/product.repository.spec.ts index 32d00e8f..f93ab44c 100644 --- a/src/features/product/repositories/product.repository.spec.ts +++ b/src/features/product/repositories/product.repository.spec.ts @@ -521,6 +521,51 @@ describe('ProductRepository (real DB)', () => { }); }); + describe('replaceProductTagsByName', () => { + it('이름으로 만들거나 재사용해 연결을 교체하고 연결한 tagIds를 돌려준다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const existing = await createTag('a'); + + const tagIds = await repo.replaceProductTagsByName( + { productId: product.id, names: ['a', 'b'] }, + () => AUDIT_ENTRY, + ); + expect(tagIds).toContain(existing.id); + expect(tagIds).toHaveLength(2); + const rows = await prisma.productTag.findMany({ + where: { product_id: product.id, deleted_at: null }, + }); + expect(rows.map((r) => r.tag_id).sort()).toEqual([...tagIds].sort()); + }); + + it('감사 콜백이 던지면 새 태그 행도 연결도 남지 않는다(한 트랜잭션)', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const deleted = await prisma.tag.create({ + data: { name: 'c', deleted_at: new Date() }, + }); + + await expect( + repo.replaceProductTagsByName( + { productId: product.id, names: ['a', 'c'] }, + () => { + throw new Error('audit failed'); + }, + ), + ).rejects.toThrow('audit failed'); + + expect(await prisma.tag.count()).toBe(0); + const row = await prisma.tag.findUniqueOrThrow({ + where: { id: deleted.id }, + }); + expect(row.deleted_at).not.toBeNull(); + expect( + await prisma.productTag.count({ where: { product_id: product.id } }), + ).toBe(0); + }); + }); + describe('Option group/item', () => { it('createOptionGroup + findOptionGroupById(product.store_id 포함)', async () => { const store = await createStore(prisma); diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index e3c7f105..34c3340d 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -17,6 +17,17 @@ import { } from '@/generated/prisma/client'; import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; +const tagSuggestionInclude = { + _count: { + select: { + product_tags: { where: { ...activeWhere, product: visibleWhere } }, + }, + }, +} as const; +export type TagSuggestionRow = Prisma.TagGetPayload<{ + include: typeof tagSuggestionInclude; +}>; + export interface StoreProductRow { id: bigint; store_id: bigint; @@ -482,6 +493,29 @@ export class ProductRepository { }); } + /** + * 정확 일치는 이름순 limit 밖으로 밀리면 안 되므로 별도로 찾는다 — contains 결과에 이미 있으면 서비스가 합친다. + * 정확 일치 판정은 DB collation(ci)에 맡긴다. 연결 수는 삭제 연결·비활성/삭제 상품을 뺀다. + */ + async searchTagsByName(args: { + keyword: string; + limit: number; + }): Promise<{ exact: TagSuggestionRow | null; rows: TagSuggestionRow[] }> { + const [exact, rows] = await Promise.all([ + this.prisma.tag.findFirst({ + where: { name: args.keyword }, + include: tagSuggestionInclude, + }), + this.prisma.tag.findMany({ + where: { name: { contains: args.keyword } }, + include: tagSuggestionInclude, + orderBy: { name: 'asc' }, + take: args.limit, + }), + ]); + return { exact, rows }; + } + /** * 연결 교체는 soft-delete로 통일한다(관리자 카테고리 삭제 경로와 같은 방식) — 빠진 연결은 deleted_at을 찍고, * 같은 (product, category)의 삭제 행이 있으면 복원한다(unique 인덱스가 삭제 행도 세므로 새로 만들 수 없다). @@ -541,46 +575,102 @@ export class ProductRepository { }, audit: () => AuditEntry, ): Promise { - const now = new Date(); await this.writeWithAudit(async (tx) => { - await tx.productTag.updateMany({ - where: { - product_id: args.productId, - tag_id: { notIn: args.tagIds }, - ...activeWhere, - }, - data: { deleted_at: now }, - }); - if (args.tagIds.length === 0) return; - await tx.productTag.updateMany({ - where: { - product_id: args.productId, - tag_id: { in: args.tagIds }, - deleted_at: { not: null }, - }, - data: { deleted_at: null }, - }); - const existing = await tx.productTag.findMany({ - where: { - product_id: args.productId, - tag_id: { in: args.tagIds }, - ...activeWhere, - }, - select: { tag_id: true }, - }); - const present = new Set(existing.map((row) => row.tag_id)); - const missing = args.tagIds.filter((id) => !present.has(id)); - if (missing.length > 0) { - await tx.productTag.createMany({ - data: missing.map((tagId) => ({ - product_id: args.productId, - tag_id: tagId, - })), - }); + await this.lockProductRow(tx, args.productId); + await this.replaceProductTagsInTx(tx, args.productId, args.tagIds); + }, audit); + } + + /** + * 같은 상품의 태그 교체를 직렬화한다 — 잠금 없이는 updateMany→findMany→createMany가 교차해 합집합이 남거나 P2002가 난다. + * 태그 잠금(upsert·FK 검사)보다 먼저 잡아 두 교체 경로가 교차해도 교착하지 않는다. + */ + private async lockProductRow( + tx: Prisma.TransactionClient, + productId: bigint, + ): Promise { + await tx.$queryRaw`SELECT id FROM product WHERE id = ${productId} FOR UPDATE`; + } + + /** + * 이름으로 태그를 만들거나(삭제행은 복구) 재사용해 연결을 교체한다 — 전부 한 트랜잭션. + * upsert는 raw INSERT … ON DUPLICATE KEY UPDATE 한 문장으로, 동시에 같은 새 이름을 보내도 unique가 하나로 수렴한다. + * 할당은 왼쪽부터 평가되므로 updated_at 판정이 deleted_at 복구보다 앞에 와야 한다. + * collation(ci)이 동일성을 정하므로 findMany 결과를 그대로 쓴다('Cake'가 있으면 'cake'는 그 행). + */ + async replaceProductTagsByName( + args: { productId: bigint; names: string[] }, + audit: (tagIds: bigint[]) => AuditEntry, + ): Promise { + return this.writeWithAudit(async (tx) => { + await this.lockProductRow(tx, args.productId); + if (args.names.length === 0) { + await this.replaceProductTagsInTx(tx, args.productId, []); + return []; } + // 같은 기존 태그를 반대 순서로 잠그는 동시 호출이 교착하지 않게 unique(name) 잠금 순서 고정 + const names = [...args.names].sort(); + await tx.$executeRaw(Prisma.sql` + INSERT INTO tag (name, created_at, updated_at) + VALUES ${Prisma.join( + names.map((name) => Prisma.sql`(${name}, NOW(3), NOW(3))`), + )} + ON DUPLICATE KEY UPDATE + updated_at = IF(deleted_at IS NULL, updated_at, NOW(3)), + deleted_at = NULL + `); + const tags = await tx.tag.findMany({ + where: { name: { in: args.names }, ...activeWhere }, + select: { id: true }, + }); + const tagIds = tags.map((row) => row.id); + await this.replaceProductTagsInTx(tx, args.productId, tagIds); + return tagIds; }, audit); } + private async replaceProductTagsInTx( + tx: Prisma.TransactionClient, + productId: bigint, + tagIds: bigint[], + ): Promise { + await tx.productTag.updateMany({ + where: { + product_id: productId, + tag_id: { notIn: tagIds }, + ...activeWhere, + }, + data: { deleted_at: new Date() }, + }); + if (tagIds.length === 0) return; + await tx.productTag.updateMany({ + where: { + product_id: productId, + tag_id: { in: tagIds }, + deleted_at: { not: null }, + }, + data: { deleted_at: null }, + }); + const existing = await tx.productTag.findMany({ + where: { + product_id: productId, + tag_id: { in: tagIds }, + ...activeWhere, + }, + select: { tag_id: true }, + }); + const present = new Set(existing.map((row) => row.tag_id)); + const missing = tagIds.filter((id) => !present.has(id)); + if (missing.length > 0) { + await tx.productTag.createMany({ + data: missing.map((tagId) => ({ + product_id: productId, + tag_id: tagId, + })), + }); + } + } + async createOptionGroup( args: { productId: bigint; diff --git a/src/features/product/resolvers/product-seller-mutation.resolver.ts b/src/features/product/resolvers/product-seller-mutation.resolver.ts index 519fc4bc..faba6f41 100644 --- a/src/features/product/resolvers/product-seller-mutation.resolver.ts +++ b/src/features/product/resolvers/product-seller-mutation.resolver.ts @@ -13,6 +13,7 @@ import { SellerReorderProductImagesInput } from '@/features/product/dto/inputs/s import { SellerSetProductActiveInput } from '@/features/product/dto/inputs/seller-set-product-active.input'; import { SellerSetProductCategoriesInput } from '@/features/product/dto/inputs/seller-set-product-categories.input'; import { SellerSetProductCustomTemplateActiveInput } from '@/features/product/dto/inputs/seller-set-product-custom-template-active.input'; +import { SellerSetProductTagsByNameInput } from '@/features/product/dto/inputs/seller-set-product-tags-by-name.input'; import { SellerSetProductTagsInput } from '@/features/product/dto/inputs/seller-set-product-tags.input'; import { SellerUpdateOptionGroupInput } from '@/features/product/dto/inputs/seller-update-option-group.input'; import { SellerUpdateOptionItemInput } from '@/features/product/dto/inputs/seller-update-option-item.input'; @@ -140,6 +141,15 @@ export class SellerProductMutationResolver { return this.productTaxonomy.sellerSetProductTags(accountId, input); } + @Mutation('sellerSetProductTagsByName') + sellerSetProductTagsByName( + @CurrentUser() user: JwtUser, + @Args('input') input: SellerSetProductTagsByNameInput, + ): Promise { + const accountId = parseAccountId(user); + return this.productTaxonomy.sellerSetProductTagsByName(accountId, input); + } + @Mutation('sellerCreateOptionGroup') sellerCreateOptionGroup( @CurrentUser() user: JwtUser, diff --git a/src/features/product/resolvers/product-seller-query.resolver.ts b/src/features/product/resolvers/product-seller-query.resolver.ts index f77b4d60..fc7c5adf 100644 --- a/src/features/product/resolvers/product-seller-query.resolver.ts +++ b/src/features/product/resolvers/product-seller-query.resolver.ts @@ -4,8 +4,13 @@ import { Args, Query, Resolver } from '@nestjs/graphql'; import type { CursorConnection } from '@/common/types/cursor-connection.type'; import { parseId } from '@/common/utils/id-parser'; import { SellerProductListInput } from '@/features/product/dto/inputs/seller-product-list.input'; +import { SellerTagSearchInput } from '@/features/product/dto/inputs/seller-tag-search.input'; import { SellerProductQueryService } from '@/features/product/services/product-seller-query.service'; -import type { SellerProductOutput } from '@/features/product/types/product-seller-output.type'; +import { SellerProductTaxonomyService } from '@/features/product/services/product-seller-taxonomy.service'; +import type { + SellerProductOutput, + SellerTagSuggestionOutput, +} from '@/features/product/types/product-seller-output.type'; import { CurrentUser, JwtAuthGuard, @@ -19,7 +24,10 @@ import { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('SELLER') export class SellerProductQueryResolver { - constructor(private readonly productQuery: SellerProductQueryService) {} + constructor( + private readonly productQuery: SellerProductQueryService, + private readonly taxonomy: SellerProductTaxonomyService, + ) {} @Query('sellerProducts') sellerProducts( @@ -38,4 +46,13 @@ export class SellerProductQueryResolver { const accountId = parseAccountId(user); return this.productQuery.sellerProduct(accountId, parseId(productId)); } + + @Query('sellerSearchTags') + sellerSearchTags( + @CurrentUser() user: JwtUser, + @Args('input') input: SellerTagSearchInput, + ): Promise { + const accountId = parseAccountId(user); + return this.taxonomy.sellerSearchTags(accountId, input); + } } diff --git a/src/features/product/resolvers/product-seller.resolver.spec.ts b/src/features/product/resolvers/product-seller.resolver.spec.ts index 52c15c6b..6e419b09 100644 --- a/src/features/product/resolvers/product-seller.resolver.spec.ts +++ b/src/features/product/resolvers/product-seller.resolver.spec.ts @@ -30,7 +30,11 @@ import { StoreSellerRepository } from '@/features/store/repositories/store-selle import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; -import { createProduct, setupSellerWithStore } from '@/test/factories'; +import { + createProduct, + createTag, + setupSellerWithStore, +} from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; import { ownedUploadUrl, s3TestProviders } from '@/test/storage/s3-test.helper'; @@ -93,6 +97,19 @@ describe('Seller Product Resolvers (real DB)', () => { expect(list.items).toHaveLength(1); }); + it('Query.sellerSearchTags: 태그 제안 배선', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTag(prisma, { name: '레터링' }); + + const result = await queryResolver.sellerSearchTags( + { accountId: account.id.toString() }, + { keyword: '#레터링' }, + ); + expect(result).toEqual([ + expect.objectContaining({ name: '레터링', isExactMatch: true }), + ]); + }); + it('Mutation.sellerDeleteProduct: 타 store 상품 접근은 404 전파', async () => { const me = await setupSellerWithStore(prisma); const other = await setupSellerWithStore(prisma); @@ -231,6 +248,15 @@ describe('Seller Product Resolvers (real DB)', () => { ); expect(withTag.tags.map((t) => t.name)).toContain('레터링'); + const withTagByName = await mutationResolver.sellerSetProductTagsByName( + auth, + { productId, names: ['#생일', '레터링'] }, + ); + expect(withTagByName.tags.map((t) => t.name).sort()).toEqual([ + '레터링', + '생일', + ]); + // 본인 product delete expect(await mutationResolver.sellerDeleteProduct(auth, productId)).toBe( true, diff --git a/src/features/product/services/product-seller-taxonomy.service.spec.ts b/src/features/product/services/product-seller-taxonomy.service.spec.ts index 96bab3de..b2587d2b 100644 --- a/src/features/product/services/product-seller-taxonomy.service.spec.ts +++ b/src/features/product/services/product-seller-taxonomy.service.spec.ts @@ -6,11 +6,18 @@ import { StoreSellerRepository } from '@/features/store/repositories/store-selle import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; -import { createProduct, setupSellerWithStore } from '@/test/factories'; +import { + createAccount, + createProduct, + createTag, + linkProductTag, + setupSellerWithStore, +} from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; describe('SellerProductTaxonomyService (real DB)', () => { let service: SellerProductTaxonomyService; + let repository: ProductRepository; let prisma: PrismaClient; beforeAll(async () => { @@ -26,6 +33,7 @@ describe('SellerProductTaxonomyService (real DB)', () => { ], }); service = module.get(SellerProductTaxonomyService); + repository = module.get(ProductRepository); prisma = p; }); @@ -128,4 +136,455 @@ describe('SellerProductTaxonomyService (real DB)', () => { expect(result.tags[0].name).toBe('레터링'); }); }); + + describe('sellerSetProductTagsByName', () => { + async function setTags( + accountId: bigint, + productId: bigint, + names: string[], + ) { + return service.sellerSetProductTagsByName(accountId, { + productId: productId.toString(), + names, + }); + } + async function activeLinks(productId: bigint) { + return prisma.productTag.findMany({ where: { product_id: productId } }); + } + + it('존재하지 않는 productId면 404', async () => { + const { account } = await setupSellerWithStore(prisma); + await expect( + service.sellerSetProductTagsByName(account.id, { + productId: '999999', + names: ['생일'], + }), + ).rejects.toThrowDomain(404); + }); + + it('남의 매장 상품이면 404', async () => { + const { account } = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + const product = await createSellerProduct(other.store.id); + await expect( + setTags(account.id, product.id, ['생일']), + ).rejects.toThrowDomain(404); + }); + + it('새 이름은 만들고 기존 이름은 재사용한다 — tag 행은 새 이름 수만큼만 는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const existing = await createTag(prisma, { name: '레터링' }); + const before = await prisma.tag.count(); + + const result = await setTags(account.id, product.id, [ + '생일', + '레터링', + '기념일', + ]); + expect(result.tags.map((t) => t.name).sort()).toEqual([ + '기념일', + '레터링', + '생일', + ]); + expect(result.tags.find((t) => t.name === '레터링')?.id).toBe( + existing.id.toString(), + ); + expect(await prisma.tag.count()).toBe(before + 2); + }); + + it('정규화·중복 제거: 공백·#·대소문자가 다른 같은 이름은 하나로, 소문자로 저장', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + + const result = await setTags(account.id, product.id, [ + ' #생일 ', + '생일', + 'BIRTHDAY', + 'birthday', + ]); + expect(result.tags.map((t) => t.name).sort()).toEqual([ + 'birthday', + '생일', + ]); + expect( + (await prisma.tag.findMany({ orderBy: { name: 'asc' } })).map( + (t) => t.name, + ), + ).toEqual(['birthday', '생일']); + }); + + it("관리자가 만든 'Cake'가 있을 때 ['cake']는 새 행 없이 그 행에 연결되고 updated_at도 그대로", async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const admin = await createTag(prisma, { name: 'Cake' }); + + const result = await setTags(account.id, product.id, ['cake']); + expect(result.tags).toEqual([{ id: admin.id.toString(), name: 'Cake' }]); + expect(await prisma.tag.count()).toBe(1); + const row = await prisma.tag.findUniqueOrThrow({ + where: { id: admin.id }, + }); + expect(row.updated_at).toEqual(admin.updated_at); + }); + + it("대소문자·악센트만 다른 ['café', 'cafe']는 DB 기준 하나로 합쳐 1개만 연결", async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + + const result = await setTags(account.id, product.id, ['café', 'cafe']); + expect(result.tags).toHaveLength(1); + expect(await prisma.tag.count()).toBe(1); + expect(await activeLinks(product.id)).toHaveLength(1); + }); + + it("soft-delete된 '레터링'은 같은 id가 복구되고 updated_at이 갱신된다", async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const deleted = await createTag(prisma, { + name: '레터링', + deleted_at: new Date('2026-01-01T00:00:00Z'), + }); + await prisma.tag.update({ + where: { id: deleted.id }, + data: { updated_at: new Date('2026-01-01T00:00:00Z') }, + }); + + const result = await setTags(account.id, product.id, ['레터링']); + expect(result.tags).toEqual([ + { id: deleted.id.toString(), name: '레터링' }, + ]); + const row = await prisma.tag.findUniqueOrThrow({ + where: { id: deleted.id }, + }); + expect(row.deleted_at).toBeNull(); + expect(row.updated_at.getTime()).toBeGreaterThan( + new Date('2026-01-01T00:00:00Z').getTime(), + ); + expect(await prisma.tag.count()).toBe(1); + }); + + it('빈 배열이면 연결만 전부 해제하고 태그 행은 남는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + await setTags(account.id, product.id, ['생일', '레터링']); + + const result = await setTags(account.id, product.id, []); + expect(result.tags).toEqual([]); + expect(await activeLinks(product.id)).toHaveLength(0); + expect( + await prisma.productTag.count({ + where: { product_id: product.id, deleted_at: { not: null } }, + }), + ).toBe(2); + expect(await prisma.tag.count()).toBe(2); + }); + + it('정규화 후 21개면 400 PRODUCT_TAG_LIMIT_EXCEEDED', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const names = Array.from({ length: 21 }, (_, i) => `t${i}`); + await expect( + setTags(account.id, product.id, names), + ).rejects.toThrowDomain('PRODUCT_TAG_LIMIT_EXCEEDED'); + expect(await prisma.tag.count()).toBe(0); + }); + + it('중복 포함 25개가 정규화 후 20개면 통과', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const names = [ + ...Array.from({ length: 20 }, (_, i) => `t${i}`), + ...Array.from({ length: 5 }, (_, i) => `#T${i} `), + ]; + const result = await setTags(account.id, product.id, names); + expect(result.tags).toHaveLength(20); + }); + + it.each([ + ["['#']", ['#']], + ["[' ']", [' ']], + ])('정규화 후 비는 이름 %s 은 400 TEXT_REQUIRED', async (_label, names) => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + await expect( + setTags(account.id, product.id, names), + ).rejects.toThrowDomain('TEXT_REQUIRED'); + }); + + it('정규화 전 81자면 400 TEXT_TOO_LONG', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + await expect( + setTags(account.id, product.id, ['a'.repeat(81)]), + ).rejects.toThrowDomain('TEXT_TOO_LONG'); + }); + + it("정규화 전 80자라도 소문자화로 늘어 80자를 넘으면('İ'×80 → 160) 400 TEXT_TOO_LONG", async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + await expect( + setTags(account.id, product.id, ['İ'.repeat(80)]), + ).rejects.toThrowDomain('TEXT_TOO_LONG'); + expect(await prisma.tag.count()).toBe(0); + }); + + it('같은 입력 2회는 멱등 — 결과·tag 행·활성 연결 수가 그대로', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const names = ['생일', '레터링']; + + const first = await setTags(account.id, product.id, names); + const second = await setTags(account.id, product.id, names); + expect(second.tags).toEqual(first.tags); + expect(await prisma.tag.count()).toBe(2); + expect( + await prisma.productTag.count({ where: { deleted_at: undefined } }), + ).toBe(2); + }); + + it('두 판매자가 같은 새 이름을 동시에 보내도 둘 다 성공하고 tag 행은 1개', async () => { + const a = await setupSellerWithStore(prisma); + const b = await setupSellerWithStore(prisma); + const productA = await createSellerProduct(a.store.id); + const productB = await createSellerProduct(b.store.id); + + const results = await Promise.allSettled([ + setTags(a.account.id, productA.id, ['동시']), + setTags(b.account.id, productB.id, ['동시']), + ]); + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + expect(await prisma.tag.count({ where: { name: '동시' } })).toBe(1); + expect(await activeLinks(productA.id)).toHaveLength(1); + expect(await activeLinks(productB.id)).toHaveLength(1); + }); + + it('같은 기존 태그 2개를 반대 순서로 두 판매자가 동시에 보내면 둘 다 성공하고 tag 행이 늘지 않는다', async () => { + const a = await setupSellerWithStore(prisma); + const b = await setupSellerWithStore(prisma); + const productA = await createSellerProduct(a.store.id); + const productB = await createSellerProduct(b.store.id); + await createTag(prisma, { name: 'birthday' }); + await createTag(prisma, { name: 'cake' }); + + const results = await Promise.allSettled([ + setTags(a.account.id, productA.id, ['birthday', 'cake']), + setTags(b.account.id, productB.id, ['cake', 'birthday']), + ]); + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + expect(await prisma.tag.count()).toBe(2); + expect(await activeLinks(productA.id)).toHaveLength(2); + expect(await activeLinks(productB.id)).toHaveLength(2); + }); + + it('같은 상품에 서로 다른 이름 집합을 동시에 보내면 둘 다 성공하고 최종 연결은 두 집합 중 하나와 정확히 같다(합집합 아님)', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + + await Promise.all([ + setTags(account.id, product.id, ['생일', '레터링']), + setTags(account.id, product.id, ['기념일', '꽃']), + ]); + const links = await activeLinks(product.id); + const tags = await prisma.tag.findMany({ + where: { id: { in: links.map((link) => link.tag_id) } }, + }); + expect([ + ['레터링', '생일'], + ['기념일', '꽃'], + ]).toContainEqual(tags.map((tag) => tag.name).sort()); + }); + + it('tagIds 경로와 이름 경로를 같은 상품에 동시에 보내도 교착 없이 둘 다 성공한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + const tag = await createTag(prisma, { name: '동시' }); + + await Promise.all([ + service.sellerSetProductTags(account.id, { + productId: product.id.toString(), + tagIds: [tag.id.toString()], + }), + setTags(account.id, product.id, ['동시']), + ]); + expect(await activeLinks(product.id)).toHaveLength(1); + }); + + it('같은 판매자가 같은 상품에 동시 2회 보내면 tag 행 1개·활성 연결 1건이고 둘 다 성공한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + + const results = await Promise.allSettled([ + setTags(account.id, product.id, ['동시']), + setTags(account.id, product.id, ['동시']), + ]); + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + expect(await prisma.tag.count({ where: { name: '동시' } })).toBe(1); + expect( + await prisma.productTag.count({ where: { deleted_at: undefined } }), + ).toBe(1); + }); + + it('감사 로그는 PRODUCT UPDATE 1건이고 afterJson에 tagNames·tagIds가 남는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const product = await createSellerProduct(store.id); + + const result = await setTags(account.id, product.id, ['#생일', '레터링']); + const auditLogs = await prisma.auditLog.findMany(); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0]).toMatchObject({ + target_type: 'PRODUCT', + target_id: product.id, + action: 'UPDATE', + actor_account_id: account.id, + }); + expect(auditLogs[0].after_json).toEqual({ + tagNames: ['생일', '레터링'], + tagIds: expect.arrayContaining(result.tags.map((t) => t.id)), + }); + }); + + it('판매자 계정이 아니면 403', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.sellerSetProductTagsByName(user.id, { + productId: '1', + names: ['생일'], + }), + ).rejects.toThrowDomain(403); + }); + }); + + describe('sellerSearchTags', () => { + async function createTags(names: string[]) { + for (const name of names) await createTag(prisma, { name }); + } + + it('정규화한 keyword로 부분일치하고 이름순이되 정확 일치가 맨 앞', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTags(['cake pop', 'birthday cake', 'cake', 'cookie']); + + const result = await service.sellerSearchTags(account.id, { + keyword: ' #CAKE ', + }); + expect(result.map((r) => [r.name, r.isExactMatch])).toEqual([ + ['cake', true], + ['birthday cake', false], + ['cake pop', false], + ]); + }); + + it('관리자가 대문자로 만든 Cake도 cake 검색에 정확 일치로 잡힌다', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTags(['Cake']); + + const result = await service.sellerSearchTags(account.id, { + keyword: 'cake', + }); + expect(result).toEqual([ + expect.objectContaining({ name: 'Cake', isExactMatch: true }), + ]); + }); + + it('soft-delete 태그는 부분일치·정확 일치 어느 쪽에도 안 나온다', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTag(prisma, { name: 'cake', deleted_at: new Date() }); + await createTag(prisma, { name: 'cake pop', deleted_at: new Date() }); + await createTags(['cupcake']); + + const result = await service.sellerSearchTags(account.id, { + keyword: 'cake', + }); + expect(result.map((r) => r.name)).toEqual(['cupcake']); + expect(result[0].isExactMatch).toBe(false); + }); + + it('productCount는 삭제 연결·비활성 상품·삭제 상품을 세지 않는다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const tag = await createTag(prisma, { name: 'cake' }); + const active = await createProduct(prisma, { store_id: store.id }); + const inactive = await createProduct(prisma, { + store_id: store.id, + is_active: false, + }); + const deleted = await createProduct(prisma, { + store_id: store.id, + deleted_at: new Date(), + }); + const unlinked = await createProduct(prisma, { store_id: store.id }); + for (const product of [active, inactive, deleted]) { + await linkProductTag(prisma, { productId: product.id, tagId: tag.id }); + } + await linkProductTag(prisma, { + productId: unlinked.id, + tagId: tag.id, + deleted_at: new Date(), + }); + + const result = await service.sellerSearchTags(account.id, { + keyword: 'cake', + }); + expect(result).toEqual([ + expect.objectContaining({ name: 'cake', productCount: 1 }), + ]); + }); + + it('limit건까지만 주되 정확 일치는 이름순 limit 밖이어도 맨 앞에 포함한다', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTags(['a cake', 'b cake', 'c cake', 'cake']); + + const limited = await service.sellerSearchTags(account.id, { + keyword: 'cake', + limit: 2, + }); + expect(limited.map((r) => r.name)).toEqual(['cake', 'a cake']); + + const noExact = await service.sellerSearchTags(account.id, { + keyword: 'cak', + limit: 2, + }); + expect(noExact.map((r) => r.name)).toEqual(['a cake', 'b cake']); + }); + + it('limit을 생략하면 10건', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTags( + Array.from( + { length: 11 }, + (_, i) => `cake${String(i).padStart(2, '0')}`, + ), + ); + + const result = await service.sellerSearchTags(account.id, { + keyword: 'cake', + limit: null, + }); + expect(result).toHaveLength(10); + }); + + it('정규화 후 빈 keyword면 DB를 부르지 않고 빈 배열', async () => { + const { account } = await setupSellerWithStore(prisma); + await createTags(['cake']); + const search = jest.spyOn(repository, 'searchTagsByName'); + + await expect( + service.sellerSearchTags(account.id, { keyword: '#' }), + ).resolves.toEqual([]); + expect(search).not.toHaveBeenCalled(); + }); + + it('정규화 후 80자를 넘으면 400', async () => { + const { account } = await setupSellerWithStore(prisma); + await expect( + service.sellerSearchTags(account.id, { keyword: 'a'.repeat(81) }), + ).rejects.toThrowDomain('TEXT_TOO_LONG'); + }); + + it('판매자 계정이 아니면 403', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.sellerSearchTags(user.id, { keyword: 'cake' }), + ).rejects.toThrowDomain(403); + }); + }); }); diff --git a/src/features/product/services/product-seller-taxonomy.service.ts b/src/features/product/services/product-seller-taxonomy.service.ts index 999cc1c9..5477db70 100644 --- a/src/features/product/services/product-seller-taxonomy.service.ts +++ b/src/features/product/services/product-seller-taxonomy.service.ts @@ -2,15 +2,27 @@ import { Inject, Injectable } from '@nestjs/common'; import { DomainException } from '@/common/errors/error-catalog'; import { parseId } from '@/common/utils/id-parser'; +import { cleanRequiredText } from '@/common/utils/text-cleaner'; import { AUDIT_LOG_REPOSITORY, type IAuditLogRepository, } from '@/features/audit-log'; +import { MAX_TAG_NAME_LENGTH } from '@/features/product/constants/product-admin.constants'; +import { + DEFAULT_TAG_SUGGESTIONS, + MAX_TAGS_PER_PRODUCT, +} from '@/features/product/constants/product-seller.constants'; import type { SellerSetProductCategoriesInput } from '@/features/product/dto/inputs/seller-set-product-categories.input'; +import type { SellerSetProductTagsByNameInput } from '@/features/product/dto/inputs/seller-set-product-tags-by-name.input'; import type { SellerSetProductTagsInput } from '@/features/product/dto/inputs/seller-set-product-tags.input'; +import type { SellerTagSearchInput } from '@/features/product/dto/inputs/seller-tag-search.input'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { toProductOutput } from '@/features/product/services/product-seller-mappers.helper'; -import type { SellerProductOutput } from '@/features/product/types/product-seller-output.type'; +import { normalizeTagName } from '@/features/product/services/tag-name.helper'; +import type { + SellerProductOutput, + SellerTagSuggestionOutput, +} from '@/features/product/types/product-seller-output.type'; import { SellerBaseService, StoreSellerRepository } from '@/features/store'; import { AuditActionType, AuditTargetType } from '@/generated/prisma/client'; @@ -74,6 +86,31 @@ export class SellerProductTaxonomyService extends SellerBaseService { return toProductOutput(detail); } + async sellerSearchTags( + accountId: bigint, + input: SellerTagSearchInput, + ): Promise { + await this.requireSellerContext(accountId); + const keyword = normalizeTagName(input.keyword); + if (keyword === null) return []; + cleanRequiredText(keyword, MAX_TAG_NAME_LENGTH); + + const limit = input.limit ?? DEFAULT_TAG_SUGGESTIONS; + const { exact, rows } = await this.productRepository.searchTagsByName({ + keyword, + limit, + }); + const ordered = exact + ? [exact, ...rows.filter((row) => row.id !== exact.id)] + : rows; + return ordered.slice(0, limit).map((row) => ({ + id: row.id.toString(), + name: row.name, + isExactMatch: row.id === exact?.id, + productCount: row._count.product_tags, + })); + } + async sellerSetProductTags( accountId: bigint, input: SellerSetProductTagsInput, @@ -120,4 +157,61 @@ export class SellerProductTaxonomyService extends SellerBaseService { return toProductOutput(detail); } + + async sellerSetProductTagsByName( + accountId: bigint, + input: SellerSetProductTagsByNameInput, + ): Promise { + const ctx = await this.requireSellerContext(accountId); + const productId = parseId(input.productId); + + const product = + await this.productRepository.findProductByIdIncludingInactive({ + productId, + storeId: ctx.storeId, + }); + if (!product) throw new DomainException('PRODUCT_NOT_FOUND'); + + const names = [ + ...new Set( + input.names.map((raw) => { + const name = normalizeTagName( + cleanRequiredText(raw, MAX_TAG_NAME_LENGTH), + ); + if (name === null) throw new DomainException('TEXT_REQUIRED'); + // 소문자화로 코드 포인트가 늘 수 있어('İ' → 'i̇') VARCHAR(80)에 맞게 다시 본다 + return cleanRequiredText(name, MAX_TAG_NAME_LENGTH); + }), + ), + ]; + if (names.length > MAX_TAGS_PER_PRODUCT) { + throw new DomainException('PRODUCT_TAG_LIMIT_EXCEEDED', { + max: MAX_TAGS_PER_PRODUCT, + }); + } + + await this.productRepository.replaceProductTagsByName( + { productId, names }, + (tagIds) => ({ + actorAccountId: ctx.accountId, + storeId: ctx.storeId, + targetType: AuditTargetType.PRODUCT, + targetId: productId, + action: AuditActionType.UPDATE, + afterJson: { + tagNames: names, + tagIds: tagIds.map((id) => id.toString()), + }, + }), + ); + + const detail = + await this.productRepository.findProductByIdIncludingInactive({ + productId, + storeId: ctx.storeId, + }); + if (!detail) throw new DomainException('PRODUCT_NOT_FOUND'); + + return toProductOutput(detail); + } } diff --git a/src/features/product/services/tag-name.helper.spec.ts b/src/features/product/services/tag-name.helper.spec.ts new file mode 100644 index 00000000..58256f4d --- /dev/null +++ b/src/features/product/services/tag-name.helper.spec.ts @@ -0,0 +1,26 @@ +import { normalizeTagName } from '@/features/product/services/tag-name.helper'; + +describe('normalizeTagName', () => { + it.each([ + ['앞뒤 공백 제거', ' 생일 케이크 ', '생일 케이크'], + ['선행 # 1개 제거', '#생일', '생일'], + ['선행 # 전부 제거', '##x', 'x'], + ['# 뒤 공백도 제거', '# 생일', '생일'], + ['연속 공백·탭 1칸', 'a\t\t b', 'a b'], + ['소문자', 'CAKE', 'cake'], + ['NFC 합성', 'café', 'café'], + ['내부 #은 유지', '생일#케이크', '생일#케이크'], + ['전부 적용', ' ## Birthday CAKE ', 'birthday cake'], + ])('%s: %j → %j', (_label, raw, expected) => { + expect(normalizeTagName(raw)).toBe(expected); + }); + + it.each([ + ['빈 문자열', ''], + ['공백만', ' '], + ['#만', '#'], + ['#과 공백만', '## \t'], + ])('%s(%j)이면 null', (_label, raw) => { + expect(normalizeTagName(raw)).toBeNull(); + }); +}); diff --git a/src/features/product/services/tag-name.helper.ts b/src/features/product/services/tag-name.helper.ts new file mode 100644 index 00000000..6584ea7a --- /dev/null +++ b/src/features/product/services/tag-name.helper.ts @@ -0,0 +1,14 @@ +/** + * 판매자 태그 이름 규칙 — 검색어와 저장 이름이 같은 형태여야 매칭이 맞는다: + * trim → 선행 '#' 전부 제거 → 연속 공백 1칸 → 소문자 → NFC. 길이는 호출자가 cleanRequiredText로 본다. + */ +export function normalizeTagName(raw: string): string | null { + const name = raw + .trim() + .replace(/^#+/, '') + .trim() + .replace(/\s+/g, ' ') + .toLowerCase() + .normalize('NFC'); + return name.length === 0 ? null : name; +} diff --git a/src/features/product/types/product-seller-output.type.ts b/src/features/product/types/product-seller-output.type.ts index 150e8ace..7c0e4dad 100644 --- a/src/features/product/types/product-seller-output.type.ts +++ b/src/features/product/types/product-seller-output.type.ts @@ -8,6 +8,13 @@ export interface SellerTagOutput { name: string; } +export interface SellerTagSuggestionOutput { + id: string; + name: string; + isExactMatch: boolean; + productCount: number; +} + export interface SellerProductImageOutput { id: string; imageUrl: string; diff --git a/src/features/region/resolvers/region-location-query.resolver.spec.ts b/src/features/region/resolvers/region-location-query.resolver.spec.ts index d53c59c1..57fa7b6e 100644 --- a/src/features/region/resolvers/region-location-query.resolver.spec.ts +++ b/src/features/region/resolvers/region-location-query.resolver.spec.ts @@ -94,10 +94,8 @@ describe('RegionLocationQueryResolver (real DB)', () => { RateLimitGuard, OptionalJwtAuthGuard, ]); - expect(Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, handler)).toEqual({ - name: 'region-by-location', - limit: 30, - windowSeconds: 60, - }); + expect(Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, handler)).toEqual([ + { name: 'region-by-location', limit: 30, windowSeconds: 60 }, + ]); }); }); diff --git a/src/features/store/repositories/store-seller.repository.spec.ts b/src/features/store/repositories/store-seller.repository.spec.ts index 97a76453..32af9a31 100644 --- a/src/features/store/repositories/store-seller.repository.spec.ts +++ b/src/features/store/repositories/store-seller.repository.spec.ts @@ -347,6 +347,51 @@ describe('StoreSellerRepository (real DB)', () => { }); }); + describe('findStoreDailyCapacityByDate', () => { + const date = new Date('2026-10-05'); + + it('본인 매장의 해당 날짜 설정만 돌려준다', async () => { + const me = await setupSellerWithStore(prisma); + const other = await setupSellerWithStore(prisma); + await createStoreDailyCapacity(prisma, { + store_id: me.store.id, + capacity_date: new Date('2026-10-04'), + capacity: 3, + }); + await createStoreDailyCapacity(prisma, { + store_id: other.store.id, + capacity_date: date, + capacity: 5, + }); + const mine = await createStoreDailyCapacity(prisma, { + store_id: me.store.id, + capacity_date: date, + capacity: 10, + }); + + const found = await repo.findStoreDailyCapacityByDate(me.store.id, date); + expect(found?.id).toBe(mine.id); + expect(found?.capacity).toBe(10); + }); + + it('soft-delete된 설정과 설정 없음은 null이다', async () => { + const { store } = await setupSellerWithStore(prisma); + expect( + await repo.findStoreDailyCapacityByDate(store.id, date), + ).toBeNull(); + + await createStoreDailyCapacity(prisma, { + store_id: store.id, + capacity_date: date, + capacity: 10, + deleted_at: new Date(), + }); + expect( + await repo.findStoreDailyCapacityByDate(store.id, date), + ).toBeNull(); + }); + }); + describe('faqTopic (list/create/findById/update/softDelete)', () => { it('list: sort_order, id 오름차순', async () => { const { store } = await setupSellerWithStore(prisma); diff --git a/src/features/store/repositories/store-seller.repository.ts b/src/features/store/repositories/store-seller.repository.ts index a12b841d..557f25dd 100644 --- a/src/features/store/repositories/store-seller.repository.ts +++ b/src/features/store/repositories/store-seller.repository.ts @@ -220,6 +220,13 @@ export class StoreSellerRepository { }); } + /** dateOnlyUtc는 kstDayBoundaries의 `@db.Date` 비교값. findFirst라 soft-delete 필터가 붙는다. */ + async findStoreDailyCapacityByDate(storeId: bigint, dateOnlyUtc: Date) { + return this.prisma.storeDailyCapacity.findFirst({ + where: { store_id: storeId, capacity_date: dateOnlyUtc }, + }); + } + /** 목록과 카운트가 공유한다. */ private dailyCapacityScopeWhere(args: { storeId: bigint; diff --git a/src/global/expo-push/expo-push.transport.spec.ts b/src/global/expo-push/expo-push.transport.spec.ts new file mode 100644 index 00000000..924e09a4 --- /dev/null +++ b/src/global/expo-push/expo-push.transport.spec.ts @@ -0,0 +1,245 @@ +import { TimeoutError } from '@/common/utils/with-timeout'; +import { + createExpoPushTransport, + EXPO_PUSH_RECEIPTS_URL, + EXPO_PUSH_SEND_URL, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + expoPushTransport, +} from '@/global/expo-push/expo-push.transport'; + +const OPTIONS = { accessToken: 'tok', timeoutMs: 1_000 }; + +function message(seq: number): ExpoPushMessage { + return { + to: `ExponentPushToken[dev-${seq}]`, + title: '새 주문', + body: `본문 ${seq}`, + data: { kind: 'ORDER_SUBMITTED', orderId: String(seq) }, + channelId: 'default', + }; +} + +function reply(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +describe('expoPushTransport', () => { + const fetchFn = jest.fn, [string, RequestInit]>(); + const transport = createExpoPushTransport(fetchFn); + + beforeEach(() => fetchFn.mockReset()); + + describe('send', () => { + it('send 엔드포인트에 메시지 배열을 JSON으로 POST하고 ticket 배열을 돌려준다', async () => { + const messages = [message(1), message(2)]; + const tickets = [ + { status: 'ok', id: 't-1' }, + { + status: 'error', + message: 'gone', + details: { error: 'DeviceNotRegistered' }, + }, + ]; + fetchFn.mockResolvedValue(reply({ data: tickets })); + + await expect(transport.send(messages, OPTIONS)).resolves.toEqual(tickets); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(EXPO_PUSH_SEND_URL); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + authorization: 'Bearer tok', + }); + expect(JSON.parse(init.body as string)).toEqual(messages); + }); + + it('액세스 토큰이 없으면 Authorization 헤더를 붙이지 않는다', async () => { + fetchFn.mockResolvedValue(reply({ data: [{ status: 'ok', id: 't' }] })); + + await transport.send([message(1)], { ...OPTIONS, accessToken: null }); + + expect(fetchFn.mock.calls[0][1].headers).not.toHaveProperty( + 'authorization', + ); + }); + + it('반증: 101개는 요청 없이 던진다(100개는 보낸다)', async () => { + const hundred = Array.from({ length: 100 }, (_, i) => message(i)); + fetchFn.mockResolvedValue( + reply({ data: hundred.map((_, i) => ({ status: 'ok', id: `t${i}` })) }), + ); + await expect(transport.send(hundred, OPTIONS)).resolves.toHaveLength(100); + + await expect( + transport.send([...hundred, message(100)], OPTIONS), + ).rejects.toThrow('100개까지: 101'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it.each([400, 429, 500, 503])( + '비 2xx(%i)는 상태 코드를 담은 ExpoPushHttpError', + async (status) => { + fetchFn.mockResolvedValue(reply({ errors: [] }, status)); + + const error = await transport + .send([message(1)], OPTIONS) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ExpoPushHttpError); + expect(error).not.toBeInstanceOf(ExpoPushAuthError); + expect((error as ExpoPushHttpError).status).toBe(status); + expect((error as Error).message).toBe(`Expo 푸시 전송 HTTP ${status}`); + }, + ); + + it.each([401, 403])('%i은 ExpoPushAuthError', async (status) => { + fetchFn.mockResolvedValue(reply({}, status)); + + const error = await transport + .send([message(1)], OPTIONS) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ExpoPushAuthError); + expect((error as ExpoPushHttpError).status).toBe(status); + }); + + it('기한 안에 응답이 없으면 TimeoutError', async () => { + fetchFn.mockReturnValue(new Promise(() => undefined)); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('기한을 넘기면 fetch에 넘긴 signal을 abort한다 — fetch가 AbortError로 끝나도 TimeoutError를 던진다', async () => { + fetchFn.mockImplementation( + (_url, init) => + new Promise((_, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(true); + }); + + it('헤더는 바로 와도 바디(JSON) 읽기가 기한을 넘기면 TimeoutError — 기한은 바디까지 덮는다', async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: () => new Promise(() => undefined), + } as unknown as Response); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(true); + }); + + it('제때 끝난 요청의 signal은 abort하지 않는다', async () => { + fetchFn.mockResolvedValue(reply({ data: [{ status: 'ok', id: 't' }] })); + + await transport.send([message(1)], OPTIONS); + + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(false); + }); + + it('200이어도 errors가 있으면 요청 전체 거절로 던진다', async () => { + fetchFn.mockResolvedValue( + reply({ errors: [{ code: 'PUSH_TOO_MANY_EXPERIENCE_IDS' }] }), + ); + + await expect(transport.send([message(1)], OPTIONS)).rejects.toThrow( + 'PUSH_TOO_MANY_EXPERIENCE_IDS', + ); + }); + + it.each([ + ['data 없음', {}], + ['배열 아님', { data: { status: 'ok' } }], + ['수 불일치', { data: [{ status: 'ok', id: 't' }] }], + ])('반증: %s 응답은 형식 오류로 던진다', async (_label, body) => { + fetchFn.mockResolvedValue(reply(body)); + + await expect( + transport.send([message(1), message(2)], OPTIONS), + ).rejects.toThrow('응답 형식 오류'); + }); + }); + + describe('getReceipts', () => { + it('getReceipts 엔드포인트에 ids를 POST하고 ticket id → 영수증 맵을 돌려준다', async () => { + const receipts = { + 't-1': { status: 'ok' }, + 't-2': { + status: 'error', + message: 'x', + details: { error: 'MessageTooBig' }, + }, + }; + fetchFn.mockResolvedValue(reply({ data: receipts })); + + await expect( + transport.getReceipts(['t-1', 't-2'], OPTIONS), + ).resolves.toEqual(receipts); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(EXPO_PUSH_RECEIPTS_URL); + expect(JSON.parse(init.body as string)).toEqual({ ids: ['t-1', 't-2'] }); + expect(init.headers).toMatchObject({ authorization: 'Bearer tok' }); + }); + + it('반증: 1001개는 요청 없이 던진다', async () => { + const ids = Array.from({ length: 1_001 }, (_, i) => `t${i}`); + + await expect(transport.getReceipts(ids, OPTIONS)).rejects.toThrow( + '1000개까지: 1001', + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('401은 ExpoPushAuthError', async () => { + fetchFn.mockResolvedValue(reply({}, 401)); + + await expect( + transport.getReceipts(['t'], OPTIONS), + ).rejects.toBeInstanceOf(ExpoPushAuthError); + }); + + it.each([ + ['data 없음', {}], + ['배열', { data: [] }], + ])('반증: %s 응답은 형식 오류로 던진다', async (_label, body) => { + fetchFn.mockResolvedValue(reply(body)); + + await expect(transport.getReceipts(['t'], OPTIONS)).rejects.toThrow( + '응답 형식 오류', + ); + }); + }); + + it('기본 구현은 전역 fetch를 쓴다', async () => { + const spy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(reply({ data: { t: { status: 'ok' } } })); + try { + await expect( + expoPushTransport.getReceipts(['t'], OPTIONS), + ).resolves.toEqual({ t: { status: 'ok' } }); + expect(spy).toHaveBeenCalledWith( + EXPO_PUSH_RECEIPTS_URL, + expect.objectContaining({ method: 'POST' }), + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/global/expo-push/expo-push.transport.ts b/src/global/expo-push/expo-push.transport.ts new file mode 100644 index 00000000..a87aa761 --- /dev/null +++ b/src/global/expo-push/expo-push.transport.ts @@ -0,0 +1,158 @@ +import { TimeoutError, withTimeout } from '@/common/utils/with-timeout'; + +export const EXPO_PUSH_SEND_URL = 'https://exp.host/--/api/v2/push/send'; +export const EXPO_PUSH_RECEIPTS_URL = + 'https://exp.host/--/api/v2/push/getReceipts'; +/** Expo Push Service 요청당 상한 — 배치 분할은 호출자 책임이고 어댑터는 넘기면 던진다. */ +export const EXPO_PUSH_SEND_LIMIT = 100; +export const EXPO_PUSH_RECEIPT_LIMIT = 1_000; + +export interface ExpoPushMessage { + to: string; + title: string; + body: string; + data: Record; + channelId: string; +} + +export interface ExpoPushFailure { + status: 'error'; + message: string; + /** error가 Expo 오류 코드(DeviceNotRegistered 등) */ + details?: { error?: string }; +} +export type ExpoPushTicket = { status: 'ok'; id: string } | ExpoPushFailure; +export type ExpoPushReceipt = { status: 'ok' } | ExpoPushFailure; + +export interface ExpoPushRequestOptions { + accessToken: string | null; + timeoutMs: number; +} + +/** 전송 계약 — 소비자·스케줄러는 이 형태만 알고, 테스트는 가짜를 넣는다. */ +export interface ExpoPushTransport { + send( + messages: ExpoPushMessage[], + options: ExpoPushRequestOptions, + ): Promise; + getReceipts( + ticketIds: string[], + options: ExpoPushRequestOptions, + ): Promise>; +} + +export const EXPO_PUSH_TRANSPORT = Symbol('EXPO_PUSH_TRANSPORT'); + +export class ExpoPushHttpError extends Error { + constructor( + readonly status: number, + label: string, + ) { + super(`${label} HTTP ${status}`); + this.name = 'ExpoPushHttpError'; + } +} + +/** 401·403 — 액세스 토큰 폐기·오설정. 재시도로 풀리지 않으므로 호출자가 경보를 낸다. */ +export class ExpoPushAuthError extends ExpoPushHttpError { + constructor(status: number, label: string) { + super(status, label); + this.name = 'ExpoPushAuthError'; + } +} + +export type ExpoPushFetch = ( + url: string, + init: RequestInit, +) => Promise; + +export function createExpoPushTransport( + fetchFn: ExpoPushFetch = (url, init) => fetch(url, init), +): ExpoPushTransport { + async function post( + url: string, + body: unknown, + options: ExpoPushRequestOptions, + label: string, + ): Promise { + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/json', + }; + if (options.accessToken) + headers.authorization = `Bearer ${options.accessToken}`; + // 기한은 헤더가 아니라 바디(JSON)까지 덮고, 넘기면 진행 중인 POST를 끊는다 — 재시도와 겹쳐 중복 발송되지 않게 + const controller = new AbortController(); + try { + return await withTimeout( + read(url, headers, body, controller.signal, label), + options.timeoutMs, + label, + ); + } catch (error) { + if (error instanceof TimeoutError) controller.abort(); + throw error; + } + } + + async function read( + url: string, + headers: Record, + body: unknown, + signal: AbortSignal, + label: string, + ): Promise { + const response = await fetchFn(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }); + if (response.status === 401 || response.status === 403) + throw new ExpoPushAuthError(response.status, label); + if (!response.ok) throw new ExpoPushHttpError(response.status, label); + const json = (await response.json()) as { + data?: unknown; + errors?: unknown; + }; + // 요청 전체 거절은 200에 errors로 온다(PUSH_TOO_MANY_EXPERIENCE_IDS 등) + if (json.errors !== undefined) + throw new Error(`${label} 거절: ${JSON.stringify(json.errors)}`); + return json.data; + } + + return { + async send(messages, options) { + if (messages.length > EXPO_PUSH_SEND_LIMIT) { + throw new Error( + `Expo 푸시 전송은 한 번에 ${EXPO_PUSH_SEND_LIMIT}개까지: ${messages.length}`, + ); + } + const label = 'Expo 푸시 전송'; + const data = await post(EXPO_PUSH_SEND_URL, messages, options, label); + // ticket은 요청 순서대로 온다 — 수가 다르면 디바이스 매핑을 믿을 수 없다 + if (!Array.isArray(data) || data.length !== messages.length) + throw new Error(`${label} 응답 형식 오류`); + return data as ExpoPushTicket[]; + }, + async getReceipts(ticketIds, options) { + if (ticketIds.length > EXPO_PUSH_RECEIPT_LIMIT) { + throw new Error( + `Expo 푸시 영수증 조회는 한 번에 ${EXPO_PUSH_RECEIPT_LIMIT}개까지: ${ticketIds.length}`, + ); + } + const label = 'Expo 푸시 영수증 조회'; + const data = await post( + EXPO_PUSH_RECEIPTS_URL, + { ids: ticketIds }, + options, + label, + ); + if (typeof data !== 'object' || data === null || Array.isArray(data)) + throw new Error(`${label} 응답 형식 오류`); + return data as Record; + }, + }; +} + +export const expoPushTransport = createExpoPushTransport(); diff --git a/src/global/expo-push/index.ts b/src/global/expo-push/index.ts new file mode 100644 index 00000000..b51607f2 --- /dev/null +++ b/src/global/expo-push/index.ts @@ -0,0 +1,11 @@ +export { + EXPO_PUSH_SEND_LIMIT, + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + type ExpoPushReceipt, + type ExpoPushTicket, + type ExpoPushTransport, + expoPushTransport, +} from '@/global/expo-push/expo-push.transport'; diff --git a/src/global/metrics/metrics.service.spec.ts b/src/global/metrics/metrics.service.spec.ts index 17b1b661..67e315bb 100644 --- a/src/global/metrics/metrics.service.spec.ts +++ b/src/global/metrics/metrics.service.spec.ts @@ -19,6 +19,7 @@ describe('MetricsService', () => { ['type', 'field', 'outcome'], ], ['caquick_outbox_consume_duration_seconds', ['consumer', 'result']], + ['caquick_expo_push_sends_total', ['result']], ['caquick_metrics_collect_errors_total', ['gauge']], ['caquick_process_cpu_seconds_total', []], ['caquick_nodejs_heap_size_used_bytes', []], diff --git a/src/global/metrics/metrics.service.ts b/src/global/metrics/metrics.service.ts index 5045cefe..92d7f7ee 100644 --- a/src/global/metrics/metrics.service.ts +++ b/src/global/metrics/metrics.service.ts @@ -54,6 +54,13 @@ export class MetricsService { registers: [this.registry], }); + readonly expoPushSends = new Counter({ + name: 'caquick_expo_push_sends_total', + help: '판매자 푸시 전송 메시지 수. result = TICKET_OK | TICKET_ERROR | AUTH_ERROR(인증 실패로 전송되지 않은 메시지).', + labelNames: ['result'] as const, + registers: [this.registry], + }); + /** collect 실패는 /metrics를 죽이지 않고 여기에 쌓인다 — 장애 중에도 나머지 지표는 나가야 한다. */ readonly collectErrors = new Counter({ name: 'caquick_metrics_collect_errors_total', diff --git a/src/global/rate-limit/rate-limit.decorator.ts b/src/global/rate-limit/rate-limit.decorator.ts index 6778c7f7..84e4a7a7 100644 --- a/src/global/rate-limit/rate-limit.decorator.ts +++ b/src/global/rate-limit/rate-limit.decorator.ts @@ -7,11 +7,11 @@ import { } from '@/global/rate-limit/rate-limit.guard'; /** - * 정책과 가드를 함께 건다 — 한쪽만 걸려 제한이 조용히 빠지는 일이 없게. + * 정책과 가드를 함께 건다 — 한쪽만 걸려 제한이 조용히 빠지는 일이 없게. 정책은 하나여도 배열로 저장한다. * 가드는 핸들러의 정책만 읽으므로 메서드 전용이다(클래스에 걸면 컴파일 오류). */ -export const RateLimit = (policy: RateLimitPolicy): MethodDecorator => +export const RateLimit = (...policies: RateLimitPolicy[]): MethodDecorator => applyDecorators( - SetMetadata(RATE_LIMIT_METADATA_KEY, policy), + SetMetadata(RATE_LIMIT_METADATA_KEY, policies), UseGuards(RateLimitGuard), ); diff --git a/src/global/rate-limit/rate-limit.guard.spec.ts b/src/global/rate-limit/rate-limit.guard.spec.ts index c439440b..a2502f6b 100644 --- a/src/global/rate-limit/rate-limit.guard.spec.ts +++ b/src/global/rate-limit/rate-limit.guard.spec.ts @@ -1,9 +1,10 @@ -import { type ExecutionContext } from '@nestjs/common'; +import { type ExecutionContext, SetMetadata } from '@nestjs/common'; import { GUARDS_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; import type Redis from 'ioredis'; import { ClockService } from '@/common/providers/clock.service'; +import { sha256Hex } from '@/common/utils/crypto'; import { RateLimit } from '@/global/rate-limit/rate-limit.decorator'; import { RATE_LIMIT_METADATA_KEY, @@ -13,7 +14,9 @@ import { connectTestRedis } from '@/test/db/redis-test-client'; const LIMIT = 3; const WINDOW_SECONDS = 60; -const T0 = new Date('2026-10-03T12:00:00.000Z'); // 60초 창의 시작 +const LOGIN_WINDOW_SECONDS = 900; +const T0 = new Date('2026-10-03T12:00:00.000Z'); // 60초·900초 창의 시작 +const LOGIN_WINDOW = Math.floor(T0.getTime() / 1000 / LOGIN_WINDOW_SECONDS); class Target { @RateLimit({ name: 'loc', limit: LIMIT, windowSeconds: WINDOW_SECONDS }) @@ -22,11 +25,47 @@ class Target { @RateLimit({ name: 'other', limit: LIMIT, windowSeconds: WINDOW_SECONDS }) otherLimited(): void {} + @RateLimit({ + name: 'login', + subject: 'ip+username', + limit: LIMIT, + windowSeconds: LOGIN_WINDOW_SECONDS, + code: 'LOGIN_RATE_LIMITED', + }) + byUser(): void {} + + @RateLimit({ + name: 'login-default', + subject: 'ip+username', + limit: LIMIT, + windowSeconds: WINDOW_SECONDS, + }) + byUserDefaultCode(): void {} + + @RateLimit( + { name: 'dual-user', subject: 'ip+username', limit: 2, windowSeconds: 60 }, + { name: 'dual-ip', limit: 3, windowSeconds: 60 }, + ) + dual(): void {} + + @SetMetadata(RATE_LIMIT_METADATA_KEY, { + name: 'single', + limit: LIMIT, + windowSeconds: WINDOW_SECONDS, + }) + singleObject(): void {} + open(): void {} } -function gqlContext(handler: object, ip: string): ExecutionContext { - const gqlArgs = [undefined, {}, { req: { ip, headers: {} } }, {}]; +type Body = Record; + +function gqlContext( + handler: object, + ip: string, + body?: Body, +): ExecutionContext { + const gqlArgs = [undefined, {}, { req: { ip, headers: {}, body } }, {}]; return { getType: () => 'graphql', getArgs: () => gqlArgs, @@ -36,10 +75,14 @@ function gqlContext(handler: object, ip: string): ExecutionContext { } as unknown as ExecutionContext; } -function httpContext(handler: object, ip: string): ExecutionContext { +function httpContext( + handler: object, + ip: string, + body?: Body, +): ExecutionContext { return { getType: () => 'http', - switchToHttp: () => ({ getRequest: () => ({ ip, headers: {} }) }), + switchToHttp: () => ({ getRequest: () => ({ ip, headers: {}, body }) }), getHandler: () => handler, getClass: () => Target, } as unknown as ExecutionContext; @@ -79,15 +122,22 @@ describe('RateLimitGuard (real Redis)', () => { for (let i = 0; i < times; i++) await guard.canActivate(ctx); } - it('@RateLimit은 정책과 가드를 함께 건다', () => { + it('@RateLimit은 정책(배열)과 가드를 함께 건다', () => { expect(Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, proto.limited)).toEqual( - { name: 'loc', limit: LIMIT, windowSeconds: WINDOW_SECONDS }, + [{ name: 'loc', limit: LIMIT, windowSeconds: WINDOW_SECONDS }], ); expect(Reflect.getMetadata(GUARDS_METADATA, proto.limited)).toEqual([ RateLimitGuard, ]); }); + it('정책이 배열 아닌 객체 하나로 저장돼 있어도 센다', async () => { + const ctx = httpContext(proto.singleObject, '203.0.113.1'); + await callTimes(ctx, LIMIT); + + await expect(guard.canActivate(ctx)).rejects.toThrowDomain('RATE_LIMITED'); + }); + it('클래스에는 걸 수 없다(가드가 핸들러 정책만 읽어 조용히 빠지지 않게)', () => { // @ts-expect-error RateLimit은 메서드 전용 데코레이터다 @RateLimit({ name: 'cls', limit: 1, windowSeconds: 1 }) @@ -157,6 +207,127 @@ describe('RateLimitGuard (real Redis)', () => { expect(await redis.keys('rl:*')).toEqual([]); }); + describe('subject ip+username', () => { + const IP = '203.0.113.1'; + const login = (ip: string, username?: unknown) => + httpContext(proto.byUser, ip, { username, password: 'x' }); + + it('같은 IP라도 username이 다르면 따로 센다', async () => { + await callTimes(login(IP, 'alice'), LIMIT); + + await expect(guard.canActivate(login(IP, 'bob'))).resolves.toBe(true); + await expect(guard.canActivate(login(IP, 'alice'))).rejects.toThrowDomain( + 'LOGIN_RATE_LIMITED', + ); + }); + + it('같은 username이라도 IP가 다르면 따로 센다', async () => { + await callTimes(login(IP, 'alice'), LIMIT); + + await expect( + guard.canActivate(login('203.0.113.2', 'alice')), + ).resolves.toBe(true); + }); + + it.each([ + ['대소문자', 'Alice'], + ['앞뒤 공백', ' alice '], + ])('username의 %s 차이는 같은 키다', async (_label, variant) => { + await callTimes(login(IP, variant), LIMIT); + + await expect(guard.canActivate(login(IP, 'alice'))).rejects.toThrowDomain( + 'LOGIN_RATE_LIMITED', + ); + }); + + it.each([ + ['없음', undefined], + ['문자열 아님', 42], + ['공백뿐', ' '], + ])('username이 %s이면 IP 키로 센다', async (_label, username) => { + await callTimes(login(IP, username), LIMIT); + + await expect( + guard.canActivate(httpContext(proto.byUser, IP)), + ).rejects.toThrowDomain('LOGIN_RATE_LIMITED'); + expect(await redis.keys('rl:login:*')).toEqual([ + `rl:login:${IP}:${LOGIN_WINDOW}`, + ]); + }); + + it('키에는 username 원문 대신 해시 16자가 들어간다', async () => { + await guard.canActivate(login(IP, 'Alice@Example.com')); + + const keys = await redis.keys('rl:login:*'); + expect(keys).toHaveLength(1); + expect(keys[0]).toBe( + `rl:login:${IP}:${sha256Hex('alice@example.com').slice(0, 16)}:${LOGIN_WINDOW}`, + ); + expect(keys[0].toLowerCase()).not.toContain('alice'); + }); + + it('GraphQL 컨텍스트에서도 바디 username을 읽는다', async () => { + const ctx = gqlContext(proto.byUser, IP, { username: 'alice' }); + await callTimes(ctx, LIMIT); + + await expect( + guard.canActivate(gqlContext(proto.byUser, IP, { username: 'bob' })), + ).resolves.toBe(true); + await expect(guard.canActivate(ctx)).rejects.toThrowDomain( + 'LOGIN_RATE_LIMITED', + ); + }); + + it('code 지정 시 그 코드와 창 길이(분)를 넣은 메시지로 던진다', async () => { + await callTimes(login(IP, 'alice'), LIMIT); + + await expect(guard.canActivate(login(IP, 'alice'))).rejects.toMatchObject( + { + code: 'LOGIN_RATE_LIMITED', + response: { message: expect.stringContaining('15분') as string }, + }, + ); + }); + + it('code 미지정이면 RATE_LIMITED다', async () => { + const ctx = httpContext(proto.byUserDefaultCode, IP, { + username: 'alice', + }); + await callTimes(ctx, LIMIT); + + await expect(guard.canActivate(ctx)).rejects.toThrowDomain( + 'RATE_LIMITED', + ); + }); + }); + + describe('정책 2개', () => { + const IP = '203.0.113.1'; + const dual = (username: string) => + httpContext(proto.dual, IP, { username }); + + it('username 한도(2)만 넘어도 거절하고 IP 키도 함께 증가한다', async () => { + await callTimes(dual('alice'), 2); + + await expect(guard.canActivate(dual('alice'))).rejects.toThrowDomain( + 'RATE_LIMITED', + ); + expect(await redis.get(`rl:dual-ip:${IP}:${T0.getTime() / 60000}`)).toBe( + '3', + ); + }); + + it('username마다 한도 안이어도 IP 합계(3)가 넘으면 거절한다', async () => { + await guard.canActivate(dual('a')); + await guard.canActivate(dual('b')); + await guard.canActivate(dual('c')); + + await expect(guard.canActivate(dual('d'))).rejects.toThrowDomain( + 'RATE_LIMITED', + ); + }); + }); + it('Redis 장애면 통과시킨다', async () => { const broken = new Proxy(redis, { get(target, prop, receiver) { diff --git a/src/global/rate-limit/rate-limit.guard.ts b/src/global/rate-limit/rate-limit.guard.ts index 4df9b91a..18e56b05 100644 --- a/src/global/rate-limit/rate-limit.guard.ts +++ b/src/global/rate-limit/rate-limit.guard.ts @@ -6,10 +6,12 @@ import { Logger, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; import type Redis from 'ioredis'; -import { DomainException } from '@/common/errors/error-catalog'; +import { DomainException, type ErrorCode } from '@/common/errors/error-catalog'; import { ClockService } from '@/common/providers/clock.service'; +import { sha256Hex } from '@/common/utils/crypto'; import { clientIpOf } from '@/common/utils/http-meta'; import { requestOfContext } from '@/global/auth/guards/request-of-context.helper'; import { REDIS_CLIENT } from '@/global/redis'; @@ -20,6 +22,10 @@ export interface RateLimitPolicy { /** 창 하나에서 허용하는 호출 수. */ limit: number; windowSeconds: number; + /** 키 주체. 기본 ip. ip+username은 바디 username(trim·lowercase 해시)을 IP에 덧붙이고, username이 없으면 IP만 쓴다. */ + subject?: 'ip' | 'ip+username'; + /** 초과 시 던질 코드. 기본 RATE_LIMITED. 메시지 파라미터로 { minutes }를 넘긴다. */ + code?: ErrorCode; } export const RATE_LIMIT_METADATA_KEY = 'rate-limit:policy'; @@ -30,8 +36,18 @@ local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return n`; +/** 원문 대신 해시 앞 16자 — 키에 계정명이 남지 않고 길이·구분자 문제도 없다. */ +function usernameSegment(req: Request): string | undefined { + const raw: unknown = (req.body as Record | undefined) + ?.username; + if (typeof raw !== 'string') return undefined; + const normalized = raw.trim().toLowerCase(); + return normalized ? sha256Hex(normalized).slice(0, 16) : undefined; +} + /** * 클라이언트 IP별 고정 창 호출 제한. IP는 trust proxy를 거친 req.ip다. + * 정책이 여럿이면 전부 센 뒤 판정한다 — 하나가 넘어도 나머지 창이 정확해야 한다. * Redis 장애면 통과시킨다 — 남용 방지 장치가 정상 사용자를 막으면 안 된다. */ @Injectable() @@ -45,33 +61,46 @@ export class RateLimitGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - const policy = this.reflector.get( - RATE_LIMIT_METADATA_KEY, - context.getHandler(), - ); - if (!policy) return true; + const raw = this.reflector.get< + RateLimitPolicy | RateLimitPolicy[] | undefined + >(RATE_LIMIT_METADATA_KEY, context.getHandler()); + const policies = Array.isArray(raw) ? raw : raw ? [raw] : []; + if (policies.length === 0) return true; - const ip = clientIpOf(requestOfContext(context)); - const window = Math.floor( - this.clock.now().getTime() / 1000 / policy.windowSeconds, - ); - let count: number; - try { - count = Number( - await this.redis.eval( - INCR_WITH_TTL, - 1, - `rl:${policy.name}:${ip}:${window}`, - String(policy.windowSeconds), - ), - ); - } catch (error) { - this.logger.warn( - `레이트리밋 확인 실패(${policy.name}) — 통과시킴: ${error instanceof Error ? error.message : String(error)}`, - ); - return true; + const req = requestOfContext(context); + const ip = clientIpOf(req); + const nowSeconds = this.clock.now().getTime() / 1000; + let exceeded: RateLimitPolicy | undefined; + for (const policy of policies) { + const window = Math.floor(nowSeconds / policy.windowSeconds); + const subject = + policy.subject === 'ip+username' ? usernameSegment(req) : undefined; + const key = [`rl:${policy.name}`, ip, subject, window] + .filter((part) => part !== undefined) + .join(':'); + let count: number; + try { + count = Number( + await this.redis.eval( + INCR_WITH_TTL, + 1, + key, + String(policy.windowSeconds), + ), + ); + } catch (error) { + this.logger.warn( + `레이트리밋 확인 실패(${policy.name}) — 통과시킴: ${error instanceof Error ? error.message : String(error)}`, + ); + return true; + } + if (count > policy.limit) exceeded ??= policy; + } + if (exceeded) { + throw new DomainException(exceeded.code ?? 'RATE_LIMITED', { + minutes: Math.ceil(exceeded.windowSeconds / 60), + }); } - if (count > policy.limit) throw new DomainException('RATE_LIMITED'); return true; } } diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index ebfc42b7..08141a0f 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -14,6 +14,7 @@ export * from './search-event.factory'; export * from './search-history.factory'; export * from './search-keyword-chip.factory'; export * from './search-keyword-rank-snapshot.factory'; +export * from './seller-push-device.factory'; export * from './seller.factory'; export * from './sequence'; export * from './store-daily-capacity.factory'; diff --git a/src/test/factories/seller-push-device.factory.ts b/src/test/factories/seller-push-device.factory.ts new file mode 100644 index 00000000..659ff8a0 --- /dev/null +++ b/src/test/factories/seller-push-device.factory.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto'; + +import type { + PrismaClient, + PushPlatform, + SellerPushDelivery, + SellerPushDeliveryStatus, + SellerPushDevice, +} from '@/generated/prisma/client'; +import { setupSellerWithStore } from '@/test/factories/seller.factory'; +import { nextSeq } from '@/test/factories/sequence'; + +export interface SellerPushDeviceOverrides { + account_id?: bigint; + store_id?: bigint; + expo_push_token?: string; + platform?: PushPlatform; + client_device_id?: string | null; + last_seen_at?: Date; + disabled_at?: Date | null; + disabled_reason?: string | null; +} + +export function expoPushToken(seq: number = nextSeq()): string { + return `ExponentPushToken[dev-${seq}]`; +} + +/** account_id·store_id를 둘 다 안 주면 매장 있는 판매자를 새로 만든다 — 하나만 주면 나머지는 그 값과 무관한 새 판매자다. */ +export async function createSellerPushDevice( + prisma: PrismaClient, + overrides: SellerPushDeviceOverrides = {}, +): Promise { + const seq = nextSeq(); + let accountId = overrides.account_id; + let storeId = overrides.store_id; + if (accountId === undefined || storeId === undefined) { + const seller = await setupSellerWithStore(prisma); + accountId ??= seller.account.id; + storeId ??= seller.store.id; + } + + return prisma.sellerPushDevice.create({ + data: { + account_id: accountId, + store_id: storeId, + expo_push_token: overrides.expo_push_token ?? expoPushToken(seq), + platform: overrides.platform ?? 'IOS', + client_device_id: + overrides.client_device_id === undefined + ? `device-${seq}` + : overrides.client_device_id, + last_seen_at: overrides.last_seen_at ?? new Date(), + disabled_at: overrides.disabled_at ?? null, + disabled_reason: overrides.disabled_reason ?? null, + }, + }); +} + +export interface SellerPushDeliveryOverrides { + source_event_id?: string; + push_device_id?: bigint; + status?: SellerPushDeliveryStatus; + ticket_id?: string | null; + error_code?: string | null; + sent_at?: Date | null; + receipt_checked_at?: Date | null; +} + +export async function createSellerPushDelivery( + prisma: PrismaClient, + overrides: SellerPushDeliveryOverrides = {}, +): Promise { + const deviceId = + overrides.push_device_id ?? (await createSellerPushDevice(prisma)).id; + + return prisma.sellerPushDelivery.create({ + data: { + source_event_id: overrides.source_event_id ?? randomUUID(), + push_device_id: deviceId, + status: overrides.status ?? 'PENDING', + ticket_id: overrides.ticket_id ?? null, + error_code: overrides.error_code ?? null, + sent_at: overrides.sent_at ?? null, + receipt_checked_at: overrides.receipt_checked_at ?? null, + }, + }); +} diff --git a/src/test/model-ownership.ts b/src/test/model-ownership.ts index 1ee60020..f214744e 100644 --- a/src/test/model-ownership.ts +++ b/src/test/model-ownership.ts @@ -108,6 +108,8 @@ export const MODEL_OWNERSHIP: Readonly> = { Notification: { service: 'notification', writers: ['notification'] }, NotificationBroadcast: { service: 'notification', writers: ['notification'] }, + SellerPushDevice: { service: 'notification', writers: ['notification'] }, + SellerPushDelivery: { service: 'notification', writers: ['notification'] }, AuditLog: { service: 'audit', writers: ['audit-log'] }, // 발행 feature는 OutboxPublisher(같은 tx 적재)로만 쓴다 — 직접 write는 게이트가 막는다 diff --git a/src/test/pubsub.ts b/src/test/pubsub.ts new file mode 100644 index 00000000..efea0d89 --- /dev/null +++ b/src/test/pubsub.ts @@ -0,0 +1,10 @@ +import type { PubSub } from 'graphql-subscriptions'; + +/** in-memory PubSub 토픽 수신분을 모은다 — 발행 건수(0건 포함)를 단언하는 service spec용. 끝나면 stop(). */ +export async function collectTopic(pubSub: PubSub, topic: string) { + const received: unknown[] = []; + const subId = await pubSub.subscribe(topic, (payload: unknown) => { + received.push(payload); + }); + return { received, stop: () => pubSub.unsubscribe(subId) }; +} diff --git a/src/test/roles-coverage.spec.ts b/src/test/roles-coverage.spec.ts index 9c2a7a14..383e3d8f 100644 --- a/src/test/roles-coverage.spec.ts +++ b/src/test/roles-coverage.spec.ts @@ -13,7 +13,7 @@ describe('접두 루트 필드 인가 커버리지', () => { const handlerAuth = collectHandlerAuth(collectFeatureResolverClasses()); describe.each([ - ['seller', 'SELLER', 48], + ['seller', 'SELLER', 56], ['admin', 'ADMIN', 59], ] as const)('%s 접두 → @Roles(%s)', (prefix, role, count) => { const fields = collectRootFieldsWithPrefix(prefix);