From 765d7c9ca8150f1c261850fe92da01e0154189bb Mon Sep 17 00:00:00 2001 From: yhaeul Date: Mon, 10 Aug 2026 21:22:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EC=8B=9D=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EC=83=81=ED=83=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=9A=9F=EC=88=98=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/clubs/[uuid]/verifications/index.ts | 7 +++ apps/web/server/domain/model/Club.ts | 14 +++++ .../club-verification-request.entity.ts | 3 + .../service/club-verification.service.ts | 58 +++++++++++++------ apps/web/server/service/club.service.ts | 29 +++++++++- apps/web/sql/260810.sql | 23 ++++++++ .../constants/official-verification-status.ts | 1 + apps/web/src/lib/openapi/register-paths.ts | 3 + apps/web/src/lib/schemas/common.ts | 10 ++++ apps/web/src/lib/schemas/managers.ts | 3 + 10 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 apps/web/sql/260810.sql diff --git a/apps/web/pages/api/v2/managers/me/clubs/[uuid]/verifications/index.ts b/apps/web/pages/api/v2/managers/me/clubs/[uuid]/verifications/index.ts index b7a36cdf..4d7cb3bb 100644 --- a/apps/web/pages/api/v2/managers/me/clubs/[uuid]/verifications/index.ts +++ b/apps/web/pages/api/v2/managers/me/clubs/[uuid]/verifications/index.ts @@ -8,6 +8,7 @@ import { import { Provider } from 'server/provider' import { ClubVerificationService } from 'server/service/club-verification.service' import { UserService } from 'server/service/user.service' +import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status' import { ClubUuidParamsSchema } from 'src/lib/schemas/clubs' import type { CreateVerificationRequestResponse } from 'src/lib/schemas/managers' import { type ZodIssue, z } from 'zod' @@ -35,6 +36,12 @@ export default async function handler( club_uuid: request.clubId, status: 'PENDING', created_at: request.createdAt, + attempt_no: request.attemptNo, + retry_count: request.attemptNo - 1, + remaining_retry_count: Math.max( + MAX_OFFICIAL_VERIFICATION_RETRY_COUNT - (request.attemptNo - 1), + 0, + ), }, }) } diff --git a/apps/web/server/domain/model/Club.ts b/apps/web/server/domain/model/Club.ts index bccf7bca..e7d2db31 100644 --- a/apps/web/server/domain/model/Club.ts +++ b/apps/web/server/domain/model/Club.ts @@ -16,6 +16,20 @@ export type ClubManager = { export type ManagedClubDetail = Club & { managers: ClubManager[] + officialVerification: ManagedOfficialVerification +} + +export type ManagedOfficialVerificationStatus = 'VERIFIED' | 'PENDING' | 'REJECTED' | 'UNVERIFIED' + +export type ManagedOfficialVerification = { + status: ManagedOfficialVerificationStatus + requestId: number | null + attemptNo: number | null + retryCount: number + retryLimit: number + remainingRetryCount: number + rejectReason: string | null + requestedAt: string | null } export type ManagedClubManagementStatus = diff --git a/apps/web/server/infra/database/entities/club-verification-request.entity.ts b/apps/web/server/infra/database/entities/club-verification-request.entity.ts index 1c35b214..6e549d67 100644 --- a/apps/web/server/infra/database/entities/club-verification-request.entity.ts +++ b/apps/web/server/infra/database/entities/club-verification-request.entity.ts @@ -8,6 +8,9 @@ export class ClubVerificationRequestEntity { @Column({ type: 'uuid', name: 'club_id' }) clubId: string + @Column({ type: 'smallint', name: 'attempt_no' }) + attemptNo: number + @Column({ type: 'varchar', default: 'PENDING', name: 'status' }) status: string diff --git a/apps/web/server/service/club-verification.service.ts b/apps/web/server/service/club-verification.service.ts index f9162913..5d67cd0c 100644 --- a/apps/web/server/service/club-verification.service.ts +++ b/apps/web/server/service/club-verification.service.ts @@ -1,10 +1,13 @@ -import { IsNull, type Repository } from 'typeorm' +import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status' +import type { Repository } from 'typeorm' import { ConflictError, ForbiddenError, NotFoundError } from '../domain/error' import { ClubEntity } from '../infra/database/entities' import { ClubManagerEntity } from '../infra/database/entities/club-manager.entity' import { ClubVerificationRequestEntity } from '../infra/database/entities/club-verification-request.entity' import { InjectRepository, Service } from '../provider' +const MAX_OFFICIAL_VERIFICATION_ATTEMPT_COUNT = MAX_OFFICIAL_VERIFICATION_RETRY_COUNT + 1 + @Service export class ClubVerificationService { @InjectRepository(ClubEntity) @@ -20,29 +23,48 @@ export class ClubVerificationService { clubUuid: string, serviceUserId: string, ): Promise { - const club = await this.clubRepository.findOneBy({ uuid: clubUuid, deletedAt: IsNull() }) - if (!club) { - throw new NotFoundError('club not found') - } - const manager = await this.clubManagerRepository.findOneBy({ clubId: clubUuid, serviceUserId }) if (!manager) { throw new ForbiddenError('not a manager of this club') } - if (club.isOfficialVerified) { - throw new ConflictError('club is already officially verified') - } + return this.clubRepository.manager.transaction(async (transactionManager) => { + const club = await transactionManager + .getRepository(ClubEntity) + .createQueryBuilder('club') + .setLock('pessimistic_write') + .where('club.uuid = :clubUuid', { clubUuid }) + .andWhere('club.deleted_at IS NULL') + .getOne() + if (!club) { + throw new NotFoundError('club not found') + } - const pendingRequest = await this.clubVerificationRequestRepository.findOneBy({ - clubId: clubUuid, - status: 'PENDING', - }) - if (pendingRequest) { - throw new ConflictError('pending verification request already exists') - } + if (club.isOfficialVerified) { + throw new ConflictError('club is already officially verified') + } + + const verificationRequestRepository = transactionManager.getRepository( + ClubVerificationRequestEntity, + ) + const pendingRequest = await verificationRequestRepository.findOneBy({ + clubId: clubUuid, + status: 'PENDING', + }) + if (pendingRequest) { + throw new ConflictError('pending verification request already exists') + } - const request = this.clubVerificationRequestRepository.create({ clubId: clubUuid }) - return this.clubVerificationRequestRepository.save(request) + const attemptCount = await verificationRequestRepository.countBy({ clubId: clubUuid }) + if (attemptCount >= MAX_OFFICIAL_VERIFICATION_ATTEMPT_COUNT) { + throw new ConflictError('official verification retry limit exceeded') + } + + const request = verificationRequestRepository.create({ + clubId: clubUuid, + attemptNo: attemptCount + 1, + }) + return verificationRequestRepository.save(request) + }) } } diff --git a/apps/web/server/service/club.service.ts b/apps/web/server/service/club.service.ts index 08b73e8a..b6018be4 100644 --- a/apps/web/server/service/club.service.ts +++ b/apps/web/server/service/club.service.ts @@ -15,6 +15,7 @@ import { PUBLIC_CLUB_STATUS, REJECTED_CLUB_STATUS, } from 'src/common/constants/club-status' +import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status' import type { ClubData, ClubManagerRequest, @@ -143,10 +144,23 @@ export class ClubService { async getManagedClubByUuid(clubUuid: string, serviceUserId: string): Promise { await this.clubAccessService.assertManagedClub(clubUuid, serviceUserId) - const [club, managers] = await Promise.all([ + const [club, managers, latestVerificationRequest] = await Promise.all([ this.clubAccessService.getExistingClub(clubUuid), this.clubManagerRepository.findBy({ clubId: clubUuid }), + this.clubVerificationRequestRepository.findOne({ + where: { clubId: clubUuid }, + order: { createdAt: 'DESC', id: 'DESC' }, + }), ]) + const verificationStatus = club.isOfficialVerified + ? 'VERIFIED' + : latestVerificationRequest?.status === PENDING_CLUB_STATUS + ? 'PENDING' + : latestVerificationRequest?.status === REJECTED_CLUB_STATUS + ? 'REJECTED' + : 'UNVERIFIED' + const retryCount = Math.max((latestVerificationRequest?.attemptNo ?? 1) - 1, 0) + return { ...toClubDomain(club), managers: managers.map((m) => ({ @@ -155,6 +169,19 @@ export class ClubService { phone: m.phone, studentId: m.studentId, })), + officialVerification: { + status: verificationStatus, + requestId: latestVerificationRequest ? Number(latestVerificationRequest.id) : null, + attemptNo: latestVerificationRequest?.attemptNo ?? null, + retryCount, + retryLimit: MAX_OFFICIAL_VERIFICATION_RETRY_COUNT, + remainingRetryCount: Math.max(MAX_OFFICIAL_VERIFICATION_RETRY_COUNT - retryCount, 0), + rejectReason: + verificationStatus === 'REJECTED' + ? (latestVerificationRequest?.rejectReason ?? null) + : null, + requestedAt: latestVerificationRequest?.createdAt ?? null, + }, } } diff --git a/apps/web/sql/260810.sql b/apps/web/sql/260810.sql new file mode 100644 index 00000000..8fcd0bfc --- /dev/null +++ b/apps/web/sql/260810.sql @@ -0,0 +1,23 @@ +/* 공식 인증 요청별 시도 번호를 보존합니다. */ + +ALTER TABLE public.club_verification_request +ADD COLUMN attempt_no smallint; + +WITH numbered_requests AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY club_id ORDER BY created_at ASC, id ASC) AS attempt_no + FROM public.club_verification_request +) +UPDATE public.club_verification_request AS request +SET attempt_no = numbered_requests.attempt_no +FROM numbered_requests +WHERE request.id = numbered_requests.id; + +ALTER TABLE public.club_verification_request +ALTER COLUMN attempt_no SET NOT NULL, +ADD CONSTRAINT chk_club_verification_request_attempt_no_positive CHECK (attempt_no >= 1), +ADD CONSTRAINT uq_club_verification_request_attempt_no UNIQUE (club_id, attempt_no); + +CREATE INDEX IF NOT EXISTS idx_club_verification_request_latest +ON public.club_verification_request (club_id, created_at DESC, id DESC); diff --git a/apps/web/src/common/constants/official-verification-status.ts b/apps/web/src/common/constants/official-verification-status.ts index 55f83717..20cb0e53 100644 --- a/apps/web/src/common/constants/official-verification-status.ts +++ b/apps/web/src/common/constants/official-verification-status.ts @@ -1,3 +1,4 @@ export const OFFICIAL_VERIFICATION_STATUSES = ['VERIFIED', 'PENDING', 'UNVERIFIED'] as const +export const MAX_OFFICIAL_VERIFICATION_RETRY_COUNT = 3 export type OfficialVerificationStatus = (typeof OFFICIAL_VERIFICATION_STATUSES)[number] diff --git a/apps/web/src/lib/openapi/register-paths.ts b/apps/web/src/lib/openapi/register-paths.ts index ad4565ba..21245677 100644 --- a/apps/web/src/lib/openapi/register-paths.ts +++ b/apps/web/src/lib/openapi/register-paths.ts @@ -2077,6 +2077,9 @@ registry.registerPath({ club_uuid: '123e4567-e89b-12d3-a456-426614174000', status: 'PENDING', created_at: '2026-04-29T17:00:00Z', + attempt_no: 1, + retry_count: 0, + remaining_retry_count: 3, }, }, }, diff --git a/apps/web/src/lib/schemas/common.ts b/apps/web/src/lib/schemas/common.ts index 84bae87d..ff621898 100644 --- a/apps/web/src/lib/schemas/common.ts +++ b/apps/web/src/lib/schemas/common.ts @@ -164,4 +164,14 @@ export const ClubManagerSchema = z export const ManagedClubDetailSchema = ClubSchema.extend({ managers: z.array(ClubManagerSchema), + officialVerification: z.object({ + status: z.enum(['VERIFIED', 'PENDING', 'REJECTED', 'UNVERIFIED']), + requestId: z.number().int().nullable(), + attemptNo: z.number().int().nullable(), + retryCount: z.number().int().nonnegative(), + retryLimit: z.number().int().positive(), + remainingRetryCount: z.number().int().nonnegative(), + rejectReason: z.string().nullable(), + requestedAt: z.string().nullable(), + }), }).openapi('ManagedClubDetail') diff --git a/apps/web/src/lib/schemas/managers.ts b/apps/web/src/lib/schemas/managers.ts index 9b9c7957..d31d4b80 100644 --- a/apps/web/src/lib/schemas/managers.ts +++ b/apps/web/src/lib/schemas/managers.ts @@ -242,6 +242,9 @@ export const CreateVerificationRequestResponseSchema = z club_uuid: z.string().uuid(), status: z.literal('PENDING'), created_at: z.string(), + attempt_no: z.number().int(), + retry_count: z.number().int().nonnegative(), + remaining_retry_count: z.number().int().nonnegative(), }), }) .openapi('CreateVerificationRequestResponse')