diff --git a/.env.development.example b/.env.development.example index 5c725b5..780e432 100644 --- a/.env.development.example +++ b/.env.development.example @@ -92,8 +92,12 @@ UPLOAD_MAX_SIZE_MB=5 # IMWEB_ADMIN_PAGE_URL 아임웹 연결을 마친 뒤 돌아갈 관리자 화면 주소 # IMWEB_SYNC_INTERVAL_MINUTES 회원 전체 동기화 주기 (분, 기본 60) # IMWEB_PAGE_SIZE 동기화 때 한 번에 받는 회원 수 (기본 100) +# IMWEB_SITE_URL (선택) 아임웹 사이트 주소 — 업체 링크의 이동 주소는 이 사이트 안만 허용한다. +# 비우면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다 (관리자 화면이 아임웹 사이트 안에 있으므로) # ========================================== IMWEB_SITE_CODE=S20230730923b9e1418574 IMWEB_ADMIN_PAGE_URL=http://localhost:5173/imweb-members.html IMWEB_SYNC_INTERVAL_MINUTES=60 IMWEB_PAGE_SIZE=100 +# 로컬 관리자 화면(localhost:5173) 대신 실제 사이트 상품 주소로 링크를 만들어 보려면: +# IMWEB_SITE_URL=https://www.moneyguider.co.kr diff --git a/.env.production.example b/.env.production.example index 934c107..44890e1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -96,8 +96,11 @@ UPLOAD_MAX_SIZE_MB=5 # IMWEB_ADMIN_PAGE_URL 아임웹 연결을 마친 뒤 돌아갈 관리자 화면 주소 # IMWEB_SYNC_INTERVAL_MINUTES 회원 전체 동기화 주기 (분, 기본 60) # IMWEB_PAGE_SIZE 동기화 때 한 번에 받는 회원 수 (기본 100) +# IMWEB_SITE_URL (선택) 아임웹 사이트 주소 — 업체 링크의 이동 주소는 이 사이트 안만 허용한다. +# 비우면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다 (관리자 화면이 아임웹 사이트 안에 있으므로) # ========================================== IMWEB_SITE_CODE=S20230730923b9e1418574 IMWEB_ADMIN_PAGE_URL=https://www.moneyguider.co.kr/admin-members IMWEB_SYNC_INTERVAL_MINUTES=60 IMWEB_PAGE_SIZE=100 +# IMWEB_SITE_URL=https://www.moneyguider.co.kr diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7faa9ec..542ce6f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { ApplicationsModule } from '@/modules/applications/applications.module'; import { HealthModule } from '@/modules/health/health.module'; import { ImwebModule } from '@/modules/imweb/imweb.module'; import { MembersModule } from '@/modules/members/members.module'; +import { PartnersModule } from '@/modules/partners/partners.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -68,6 +69,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 ImwebModule, MembersModule, ApplicationsModule, + PartnersModule, ], providers: [ ShutdownLogger, diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index ba60541..3e4ccef 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -98,11 +98,35 @@ const DOMAIN_ERROR_CODES = { message: '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', }, + // 연결된 토큰에 필요한 권한(상품·주문 읽기 등)이 없을 때 — 아임웹이 403 으로 거부한다 + IMWEB_006: { + status: HttpStatus.FORBIDDEN, + message: + '아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.', + }, // 강의 신청 기록 — 신청 완료 화면이 보낸다 APPLY_001: { status: HttpStatus.BAD_REQUEST, message: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', }, + // 업체 링크 — 업체별 신청·구매 추적 + PARTNER_001: { + status: HttpStatus.CONFLICT, + message: '이 업체에는 같은 상품의 링크가 이미 있습니다.', + }, + PARTNER_002: { + status: HttpStatus.CONFLICT, + message: + '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.', + }, + PARTNER_003: { + status: HttpStatus.BAD_REQUEST, + message: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.', + }, + PARTNER_004: { + status: HttpStatus.CONFLICT, + message: '같은 이름의 업체가 이미 있습니다.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/main.ts b/backend/src/main.ts index 5638ac3..d7304cc 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -28,8 +28,12 @@ async function bootstrap() { // 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치) app.setGlobalPrefix('api', { - // 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용 - exclude: [{ path: '/', method: RequestMethod.GET }], + exclude: [ + // 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용 + { path: '/', method: RequestMethod.GET }, + // 업체 단축 링크는 짧아야 하므로 /api 없이 /r/{코드} (PartnerRedirectController) + { path: 'r/:code', method: RequestMethod.GET }, + ], }); // 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가 diff --git a/backend/src/migrations/1791210484651-AddPartnerLinks.ts b/backend/src/migrations/1791210484651-AddPartnerLinks.ts new file mode 100644 index 0000000..7c38626 --- /dev/null +++ b/backend/src/migrations/1791210484651-AddPartnerLinks.ts @@ -0,0 +1,147 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPartnerLinks1791210484651 implements MigrationInterface { + name = 'AddPartnerLinks1791210484651'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "partners" ("id" uuid NOT NULL, "name" character varying(100) NOT NULL, "memo" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_998645b20820e4ab99aeae03b41" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partners_name" ON "partners" ("name") `, + ); + await queryRunner.query( + `CREATE INDEX "idx_partners_cursor" ON "partners" ("created_at", "id") `, + ); + await queryRunner.query( + `CREATE TABLE "partner_links" ("id" uuid NOT NULL, "code" character varying(16) NOT NULL, "partner_id" uuid NOT NULL, "prod_no" integer NOT NULL, "target_url" text NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_9b3ad64da16c31b2fe6377fda91" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partner_links_code" ON "partner_links" ("code") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_85479a14717acd798f0691d6e2" ON "partner_links" ("prod_no") `, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_links_cursor" ON "partner_links" ("created_at", "id") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partner_links_partner_product" ON "partner_links" ("partner_id", "prod_no") `, + ); + await queryRunner.query( + `CREATE TABLE "partner_link_clicks" ("ref" character varying(16) NOT NULL, "link_id" uuid NOT NULL, "clicked_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3bae5d966512036470f8e12d37e" PRIMARY KEY ("ref"))`, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_link_clicks_link_time" ON "partner_link_clicks" ("link_id", "clicked_at") `, + ); + await queryRunner.query( + `CREATE TABLE "partner_touches" ("id" uuid NOT NULL, "member_code" character varying(40) NOT NULL, "click_ref" character varying(16) NOT NULL, "link_id" uuid NOT NULL, "prod_no" integer NOT NULL, "clicked_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_461c5428d39ff3b7971a607259c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_touches_member_product" ON "partner_touches" ("member_code", "prod_no") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partner_touches_member_click" ON "partner_touches" ("member_code", "click_ref") `, + ); + await queryRunner.query( + `CREATE TABLE "partner_conversions" ("id" uuid NOT NULL, "type" character varying(20) NOT NULL, "link_id" uuid NOT NULL, "partner_id" uuid NOT NULL, "prod_no" integer NOT NULL, "member_code" character varying(40) NOT NULL, "application_id" uuid, "order_no" character varying(30), "is_valid" boolean NOT NULL DEFAULT true, "touched_at" TIMESTAMP WITH TIME ZONE NOT NULL, "converted_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_ef640b4a299b8d72fa00dad9986" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_conversions_cursor" ON "partner_conversions" ("converted_at", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_conversions_partner_time" ON "partner_conversions" ("partner_id", "converted_at") `, + ); + await queryRunner.query( + `CREATE INDEX "idx_partner_conversions_link_time" ON "partner_conversions" ("link_id", "converted_at") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partner_conversions_order_product" ON "partner_conversions" ("order_no", "prod_no") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "uq_partner_conversions_application" ON "partner_conversions" ("application_id") `, + ); + await queryRunner.query( + `CREATE TABLE "imweb_products" ("prod_no" integer NOT NULL, "name" character varying(200) NOT NULL, "prod_status" character varying(20), "prod_type" character varying(20), "is_display" boolean NOT NULL DEFAULT true, "image_url" text, "added_at" TIMESTAMP WITH TIME ZONE, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_70f2eacbd1c4ced5c69fd981234" PRIMARY KEY ("prod_no"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2f0656a249891065be7b1582d1" ON "imweb_products" ("prod_status") `, + ); + await queryRunner.query( + `ALTER TABLE "partner_links" ADD CONSTRAINT "FK_0724320077cfbcc1a63a0fc5e4d" FOREIGN KEY ("partner_id") REFERENCES "partners"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "partner_link_clicks" ADD CONSTRAINT "FK_ac73de0b839ed8cee3576b2d0dc" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "partner_touches" ADD CONSTRAINT "FK_b398d1fc61bd5f4f73ce768bcb1" FOREIGN KEY ("click_ref") REFERENCES "partner_link_clicks"("ref") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "partner_touches" ADD CONSTRAINT "FK_1cca550fd006db68c070e80966a" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "partner_conversions" ADD CONSTRAINT "FK_a3cf869f5ecc6078feca0149260" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "partner_conversions" DROP CONSTRAINT "FK_a3cf869f5ecc6078feca0149260"`, + ); + await queryRunner.query( + `ALTER TABLE "partner_touches" DROP CONSTRAINT "FK_1cca550fd006db68c070e80966a"`, + ); + await queryRunner.query( + `ALTER TABLE "partner_touches" DROP CONSTRAINT "FK_b398d1fc61bd5f4f73ce768bcb1"`, + ); + await queryRunner.query( + `ALTER TABLE "partner_link_clicks" DROP CONSTRAINT "FK_ac73de0b839ed8cee3576b2d0dc"`, + ); + await queryRunner.query( + `ALTER TABLE "partner_links" DROP CONSTRAINT "FK_0724320077cfbcc1a63a0fc5e4d"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_2f0656a249891065be7b1582d1"`, + ); + await queryRunner.query(`DROP TABLE "imweb_products"`); + await queryRunner.query( + `DROP INDEX "public"."uq_partner_conversions_application"`, + ); + await queryRunner.query( + `DROP INDEX "public"."uq_partner_conversions_order_product"`, + ); + await queryRunner.query( + `DROP INDEX "public"."idx_partner_conversions_link_time"`, + ); + await queryRunner.query( + `DROP INDEX "public"."idx_partner_conversions_partner_time"`, + ); + await queryRunner.query( + `DROP INDEX "public"."idx_partner_conversions_cursor"`, + ); + await queryRunner.query(`DROP TABLE "partner_conversions"`); + await queryRunner.query( + `DROP INDEX "public"."uq_partner_touches_member_click"`, + ); + await queryRunner.query( + `DROP INDEX "public"."idx_partner_touches_member_product"`, + ); + await queryRunner.query(`DROP TABLE "partner_touches"`); + await queryRunner.query( + `DROP INDEX "public"."idx_partner_link_clicks_link_time"`, + ); + await queryRunner.query(`DROP TABLE "partner_link_clicks"`); + await queryRunner.query( + `DROP INDEX "public"."uq_partner_links_partner_product"`, + ); + await queryRunner.query(`DROP INDEX "public"."idx_partner_links_cursor"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_85479a14717acd798f0691d6e2"`, + ); + await queryRunner.query(`DROP INDEX "public"."uq_partner_links_code"`); + await queryRunner.query(`DROP TABLE "partner_links"`); + await queryRunner.query(`DROP INDEX "public"."idx_partners_cursor"`); + await queryRunner.query(`DROP INDEX "public"."uq_partners_name"`); + await queryRunner.query(`DROP TABLE "partners"`); + } +} diff --git a/backend/src/modules/applications/applications.module.ts b/backend/src/modules/applications/applications.module.ts index d8266c8..4b561a2 100644 --- a/backend/src/modules/applications/applications.module.ts +++ b/backend/src/modules/applications/applications.module.ts @@ -5,6 +5,7 @@ import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module'; import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity'; import { ImwebModule } from '@/modules/imweb/imweb.module'; import { MembersModule } from '@/modules/members/members.module'; +import { PartnersModule } from '@/modules/partners/partners.module'; import { MemberApplication } from './application.entity'; import { AdminApplicationsController, @@ -19,6 +20,8 @@ import { ApplicationsService } from './applications.service'; AdminAuthModule, ImwebModule, MembersModule, + // 신청을 업체 링크 실적으로 귀속한다 + PartnersModule, ], controllers: [ApplicationsController, AdminApplicationsController], providers: [ApplicationsService], diff --git a/backend/src/modules/applications/applications.service.spec.ts b/backend/src/modules/applications/applications.service.spec.ts index 8bd07ea..0745856 100644 --- a/backend/src/modules/applications/applications.service.spec.ts +++ b/backend/src/modules/applications/applications.service.spec.ts @@ -4,6 +4,7 @@ import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity'; import type { ImwebConfig } from '@/modules/imweb/imweb.config'; import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service'; import type { MembersService } from '@/modules/members/members.service'; +import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service'; import { MemberApplication } from './application.entity'; import { ApplicationsService } from './applications.service'; @@ -32,14 +33,26 @@ function setup(member: ImwebMember | null = MEMBER) { update: jest.fn(() => Promise.resolve()), }; const lookup = { find: jest.fn(() => Promise.resolve(member)) }; + const calls: string[] = []; + const attribution = { + recordTouches: jest.fn(() => { + calls.push('recordTouches'); + return Promise.resolve(); + }), + attributeApplication: jest.fn(() => { + calls.push('attributeApplication'); + return Promise.resolve(); + }), + }; const service = new ApplicationsService( applications as unknown as Repository, {} as Repository, lookup as unknown as ImwebMemberLookupService, {} as MembersService, { trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig, + attribution as unknown as PartnerAttributionService, ); - return { service, store, applications, lookup }; + return { service, store, applications, lookup, attribution, calls }; } describe('ApplicationsService.create', () => { @@ -131,3 +144,61 @@ describe('ApplicationsService.create', () => { expect(result).toEqual({ id: 'winner-id', isNew: false }); }); }); + +describe('ApplicationsService.create — 업체 링크 실적', () => { + it('클릭 연결을 먼저 남긴 뒤 신청을 귀속한다', async () => { + const { service, attribution, calls } = setup(); + + const result = await service.create({ + memberUid: 'user@test.dev', + productIdx: '52', + partnerRefs: ['AbCd1234EfGh5678'], + }); + + expect(attribution.recordTouches).toHaveBeenCalledWith(MEMBER.memberCode, [ + 'AbCd1234EfGh5678', + ]); + expect(attribution.attributeApplication).toHaveBeenCalledWith( + expect.objectContaining({ + id: result.id, + memberCode: MEMBER.memberCode, + productIdx: '52', + }), + ); + expect(calls).toEqual(['recordTouches', 'attributeApplication']); + }); + + it('이미 있는 신청도 귀속을 다시 시도한다 (연결 기록이 늦게 온 경우)', async () => { + const { service, attribution } = setup(); + await service.create({ memberUid: 'user@test.dev', productIdx: '52' }); + + await service.create({ memberUid: 'user@test.dev', productIdx: '52' }); + + expect(attribution.attributeApplication).toHaveBeenCalledTimes(2); + }); + + it('업체 실적 처리가 실패해도 신청 기록은 성공한다', async () => { + const { service, store, attribution } = setup(); + attribution.recordTouches.mockRejectedValueOnce(new Error('db down')); + attribution.attributeApplication.mockRejectedValueOnce( + new Error('db down'), + ); + + const result = await service.create({ + memberUid: 'user@test.dev', + productIdx: '52', + partnerRefs: ['AbCd1234EfGh5678'], + }); + + expect(result.isNew).toBe(true); + expect(store).toHaveLength(1); + }); + + it('클릭 ID 가 없으면 연결 기록을 부르지 않는다', async () => { + const { service, attribution } = setup(); + + await service.create({ memberUid: 'user@test.dev', productIdx: '52' }); + + expect(attribution.recordTouches).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/applications/applications.service.ts b/backend/src/modules/applications/applications.service.ts index ff435a6..ed6be69 100644 --- a/backend/src/modules/applications/applications.service.ts +++ b/backend/src/modules/applications/applications.service.ts @@ -13,6 +13,7 @@ import { ImwebConfig } from '@/modules/imweb/imweb.config'; import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service'; import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; import { MembersService } from '@/modules/members/members.service'; +import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service'; import { parseInflow } from '@/shared/utils/inflow'; import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor'; import { MemberApplication } from './application.entity'; @@ -62,6 +63,7 @@ export class ApplicationsService { private readonly lookup: ImwebMemberLookupService, private readonly membersService: MembersService, private readonly config: ImwebConfig, + private readonly attribution: PartnerAttributionService, ) {} /** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */ @@ -74,8 +76,15 @@ export class ApplicationsService { }); } + // 업체 링크 클릭 연결을 먼저 남겨야 바로 아래 귀속에서 찾을 수 있다 + await this.recordPartnerRefs(member.memberCode, dto.partnerRefs); + const existing = await this.findExisting(member.memberCode, dto.productIdx); - if (existing) return { id: existing.id, isNew: false }; + if (existing) { + // 연결 기록이 신청보다 늦게 들어온 경우를 위해 다시 시도한다 (이미 귀속됐으면 그대로) + await this.attributeToPartner(existing); + return { id: existing.id, isNew: false }; + } const now = new Date(); const inflow = parseInflow( @@ -111,9 +120,33 @@ export class ApplicationsService { this.logger.log( `강의 신청 기록: member=${member.memberCode} product=${dto.productIdx}`, ); + await this.attributeToPartner(row); return { id: row.id, isNew: true }; } + /** 업체 링크 실적은 부가 기능이다 — 실패해도 신청 기록은 그대로 성공시킨다 */ + private async recordPartnerRefs( + memberCode: string, + refs: string[] | undefined, + ): Promise { + if (!refs || refs.length === 0) return; + try { + await this.attribution.recordTouches(memberCode, refs); + } catch (error) { + this.logger.warn(`업체 링크 연결 기록 실패: ${String(error)}`); + } + } + + private async attributeToPartner(row: MemberApplication): Promise { + try { + await this.attribution.attributeApplication(row); + } catch (error) { + this.logger.warn( + `업체 실적(신청) 귀속 실패: application=${row.id} ${String(error)}`, + ); + } + } + /** 오픈카톡 버튼 클릭 — 처음 누른 시각만 남긴다. 없는 ID 도 조용히 넘어간다 */ async markKakaoClicked(id: string): Promise { await this.applications.update( diff --git a/backend/src/modules/applications/dto/create-application.dto.ts b/backend/src/modules/applications/dto/create-application.dto.ts index ab7e869..b928cc3 100644 --- a/backend/src/modules/applications/dto/create-application.dto.ts +++ b/backend/src/modules/applications/dto/create-application.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + IsArray, IsNotEmpty, IsOptional, IsString, @@ -7,6 +9,9 @@ import { MaxLength, } from 'class-validator'; +import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto'; +import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link'; + const MEMBER_ID_MAX_LENGTH = 255; const PRODUCT_NAME_MAX_LENGTH = 200; /** header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */ @@ -46,6 +51,22 @@ export class CreateApplicationDto { @IsString() @MaxLength(INFLOW_RAW_MAX_LENGTH) inflow?: string; + + @ApiPropertyOptional({ + description: + '브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/partner-ref.html 의 MG_PARTNER.refs())', + example: ['AbCd1234EfGh5678'], + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(PARTNER_REFS_MAX) + @IsString({ each: true }) + @Matches(CLICK_REF_PATTERN, { + each: true, + message: '클릭 정보 형식이 올바르지 않습니다.', + }) + partnerRefs?: string[]; } /** 신청 기록 결과 — 회원 정보는 돌려주지 않는다 */ diff --git a/backend/src/modules/imweb/dto/product.dto.ts b/backend/src/modules/imweb/dto/product.dto.ts new file mode 100644 index 0000000..a5d5c8f --- /dev/null +++ b/backend/src/modules/imweb/dto/product.dto.ts @@ -0,0 +1,92 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { ImwebProduct } from '../entities/imweb-product.entity'; + +const SEARCH_MAX_LENGTH = 100; +/** 아임웹 상품 상태 코드 */ +export const PRODUCT_STATUSES = ['sale', 'soldout', 'nosale'] as const; + +/** 관리자 상품 목록 조회 */ +export class ProductListQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: '검색어 — 상품명 부분 일치, 숫자면 상품번호도', + example: '무료', + }) + @IsOptional() + @IsString() + @MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' }) + q?: string; + + @ApiPropertyOptional({ + description: '상품 상태 — sale(판매중) · soldout(품절) · nosale(숨김)', + enum: PRODUCT_STATUSES, + }) + @IsOptional() + @IsIn(PRODUCT_STATUSES, { message: '상품 상태 값이 올바르지 않습니다.' }) + status?: (typeof PRODUCT_STATUSES)[number]; +} + +/** 아임웹 상품 한 개 */ +export class ProductResponseDto { + @ApiProperty({ description: '상품번호 (상품 페이지 ?idx= 값)', example: 52 }) + prodNo!: number; + + @ApiProperty({ + description: '상품명', + example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기', + }) + name!: string; + + @ApiProperty({ + description: '상품 상태 — sale(판매중) · soldout(품절) · nosale(숨김)', + nullable: true, + example: 'sale', + }) + prodStatus!: string | null; + + @ApiProperty({ + description: + '상품 종류 — normal(일반) · digital(디지털) · subscribe(이용권)', + nullable: true, + example: 'subscribe', + }) + prodType!: string | null; + + @ApiProperty({ description: '사이트에 전시 중인가', example: true }) + isDisplay!: boolean; + + @ApiProperty({ description: '대표 이미지 주소', nullable: true }) + imageUrl!: string | null; + + @ApiProperty({ + description: '마지막 동기화 시각', + example: '2026-10-05T05:00:00.000Z', + }) + syncedAt!: string; + + static from(this: void, product: ImwebProduct): ProductResponseDto { + return { + prodNo: product.prodNo, + name: product.name, + prodStatus: product.prodStatus, + prodType: product.prodType, + isDisplay: product.isDisplay, + imageUrl: product.imageUrl, + syncedAt: product.syncedAt.toISOString(), + }; + } +} + +/** 상품 동기화 결과 */ +export class ProductSyncResultDto { + @ApiProperty({ description: '받아온 상품 수', example: 24 }) + fetchedCount!: number; + + @ApiProperty({ + description: '동기화 시각', + example: '2026-10-05T05:00:00.000Z', + }) + syncedAt!: string; +} diff --git a/backend/src/modules/imweb/entities/imweb-product.entity.ts b/backend/src/modules/imweb/entities/imweb-product.entity.ts new file mode 100644 index 0000000..781b09f --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-product.entity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +/** + * 아임웹 상품 사본 — 업체 링크를 만들 상품을 고르고 상품명을 보여주려고 동기화해 둔다. + * + * 원본은 아임웹이다. 동기화 때마다 덮어쓰고, 아임웹에서 지운 상품은 삭제한다. + * (업체 링크는 상품번호만 들고 있으므로 상품이 지워져도 링크·실적은 남는다) + */ +@Entity({ name: 'imweb_products' }) +export class ImwebProduct { + /** 아임웹 상품번호 — 상품 페이지 주소의 ?idx= 값 */ + @PrimaryColumn({ name: 'prod_no', type: 'int' }) + prodNo!: number; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name!: string; + + /** sale(판매중) · soldout(품절) · nosale(숨김) */ + @Index() + @Column({ name: 'prod_status', type: 'varchar', length: 20, nullable: true }) + prodStatus!: string | null; + + /** normal(일반) · digital(디지털) · subscribe(이용권) */ + @Column({ name: 'prod_type', type: 'varchar', length: 20, nullable: true }) + prodType!: string | null; + + /** 사이트에 전시 중인가 */ + @Column({ name: 'is_display', type: 'boolean', default: true }) + isDisplay!: boolean; + + /** 대표 이미지 (첫 번째) */ + @Column({ name: 'image_url', type: 'text', nullable: true }) + imageUrl!: string | null; + + /** 아임웹 상품 추가 시각 — 목록 정렬 기준 */ + @Column({ name: 'added_at', type: 'timestamptz', nullable: true }) + addedAt!: Date | null; + + @Column({ name: 'synced_at', type: 'timestamptz' }) + syncedAt!: Date; +} diff --git a/backend/src/modules/imweb/imweb-auth.service.spec.ts b/backend/src/modules/imweb/imweb-auth.service.spec.ts index 03fa418..ad52403 100644 --- a/backend/src/modules/imweb/imweb-auth.service.spec.ts +++ b/backend/src/modules/imweb/imweb-auth.service.spec.ts @@ -80,7 +80,7 @@ describe('ImwebAuthService', () => { expect(url.searchParams.get('clientId')).toBe('client-id'); expect(url.searchParams.get('siteCode')).toBe('S-site'); expect(url.searchParams.get('scope')).toBe( - 'site-info:write member-info:read', + 'site-info:write member-info:read product:read order:read', ); const state = url.searchParams.get('state'); expect(state).toBeTruthy(); diff --git a/backend/src/modules/imweb/imweb-auth.service.ts b/backend/src/modules/imweb/imweb-auth.service.ts index a51ab9a..601688f 100644 --- a/backend/src/modules/imweb/imweb-auth.service.ts +++ b/backend/src/modules/imweb/imweb-auth.service.ts @@ -297,6 +297,15 @@ export class ImwebAuthService { /** 아임웹 호출 실패를 사용자 응답용 에러 코드로 바꾼다 */ private toBusinessError(error: unknown): unknown { if (error instanceof BusinessException) return error; + // 토큰에 그 API 권한이 없다 — 권한을 추가한 뒤 다시 연결하기 전까지 같은 결과다 + if (error instanceof ImwebHttpError && error.isForbidden()) { + this.logger.warn( + `[IMWEB_006] ${error.path} 권한 부족 errorCode=${error.errorCode ?? '-'}`, + ); + return new BusinessException('IMWEB_006', { + context: { path: error.path, errorCode: error.errorCode }, + }); + } if (error instanceof ImwebHttpError) { // 전역 필터는 5xx 의 context 를 남기지 않으므로 진단 정보는 여기서 남긴다 (토큰·본문 제외) this.logger.warn( diff --git a/backend/src/modules/imweb/imweb-http.client.ts b/backend/src/modules/imweb/imweb-http.client.ts index 845b6a0..292668a 100644 --- a/backend/src/modules/imweb/imweb-http.client.ts +++ b/backend/src/modules/imweb/imweb-http.client.ts @@ -9,6 +9,7 @@ const MAX_RETRIES = 3; const RETRY_BASE_DELAY_MS = 1_000; const HTTP_BAD_REQUEST = 400; const HTTP_UNAUTHORIZED = 401; +const HTTP_FORBIDDEN = 403; const HTTP_TOO_MANY_REQUESTS = 429; const HTTP_SERVER_ERROR = 500; @@ -35,6 +36,11 @@ export class ImwebHttpError extends Error { return this.status === HTTP_UNAUTHORIZED; } + /** 토큰에 이 API 권한(scope)이 없다 */ + isForbidden(): boolean { + return this.status === HTTP_FORBIDDEN; + } + /** 요청 자체가 거부됨 — 다시 시도해도 같은 결과 */ isClientError(): boolean { return this.status >= HTTP_BAD_REQUEST && this.status < HTTP_SERVER_ERROR; diff --git a/backend/src/modules/imweb/imweb-product.service.ts b/backend/src/modules/imweb/imweb-product.service.ts new file mode 100644 index 0000000..077b138 --- /dev/null +++ b/backend/src/modules/imweb/imweb-product.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Not, Repository } from 'typeorm'; + +import { + PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + ProductListQueryDto, + ProductResponseDto, + ProductSyncResultDto, +} from './dto/product.dto'; +import { ImwebProduct } from './entities/imweb-product.entity'; +import { IMWEB_PRODUCTS_PATH } from './imweb.config'; +import { ImwebAuthService } from './imweb-auth.service'; +import { ImwebProductData, ImwebProductPageData } from './imweb.types'; + +const ALIAS = 'p'; +/** 상품 목록 한 번에 받는 개수 */ +const PRODUCT_PAGE_SIZE = 100; +/** 응답이 이상해도 무한 루프에 빠지지 않도록 */ +const MAX_PAGES = 1_000; +/** 엔티티 name 길이와 맞춘다 */ +const PRODUCT_NAME_MAX_LENGTH = 200; + +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +/** + * 상품 목록 응답 — 문서(openapi.json)는 data 를 '페이지 객체의 배열'로 적어 두었지만 + * 다른 목록 API 는 모두 페이지 객체 하나다. 어느 쪽이 와도 읽는다. + */ +function readPage( + data: ImwebProductPageData | ImwebProductPageData[], +): ImwebProductPageData | null { + const page = Array.isArray(data) ? data[0] : data; + return page && Array.isArray(page.list) ? page : null; +} + +function toDate(value: string | null | undefined): Date | null { + if (!value) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : new Date(time); +} + +function toRow(product: ImwebProductData, syncedAt: Date): ImwebProduct | null { + if (!Number.isInteger(product.prodNo)) return null; + return { + prodNo: product.prodNo, + name: + (product.name ?? '').trim().slice(0, PRODUCT_NAME_MAX_LENGTH) || + `상품 ${product.prodNo}`, + prodStatus: product.prodStatus ?? null, + prodType: product.prodType ?? null, + isDisplay: product.isDisplay !== 'N', + imageUrl: product.productImages?.find(Boolean) ?? null, + addedAt: toDate(product.addTime), + syncedAt, + }; +} + +/** 아임웹 상품 → 우리 DB 동기화와 관리자 조회 */ +@Injectable() +export class ImwebProductService { + private readonly logger = new Logger(ImwebProductService.name); + + constructor( + @InjectRepository(ImwebProduct) + private readonly products: Repository, + private readonly auth: ImwebAuthService, + ) {} + + /** + * 아임웹 상품 전체를 받아 맞춘다. 아임웹에서 지운 상품은 여기서도 지운다. + * 상품 읽기 권한이 없으면 IMWEB_006. + */ + async sync(): Promise { + const syncedAt = new Date(); + const { unitCode } = await this.auth.requireConnection(); + const rows: ImwebProduct[] = []; + + for (let page = 1; page <= MAX_PAGES; page += 1) { + const { data } = await this.auth.authorizedGet< + ImwebProductPageData | ImwebProductPageData[] + >(IMWEB_PRODUCTS_PATH, { page, limit: PRODUCT_PAGE_SIZE, unitCode }); + const current = readPage(data); + if (!current) { + throw new BusinessException('IMWEB_002', { + context: { reason: 'unexpected_products_response', page }, + }); + } + for (const product of current.list) { + const row = toRow(product, syncedAt); + if (row) rows.push(row); + } + if (current.list.length === 0 || page >= current.totalPage) break; + } + + if (rows.length > 0) { + await this.products.upsert(rows, { conflictPaths: ['prodNo'] }); + // 받은 목록이 비었으면 응답 이상으로 보고 지우지 않는다 + await this.products.delete({ + prodNo: Not(In(rows.map((row) => row.prodNo))), + }); + } + + this.logger.log(`상품 동기화 완료: ${rows.length}개`); + return { fetchedCount: rows.length, syncedAt: syncedAt.toISOString() }; + } + + /** 관리자 상품 목록 — 최근 추가된 상품부터 (상품번호 내림차순 커서) */ + async findMany( + query: ProductListQueryDto, + ): Promise> { + const base = this.products.createQueryBuilder(ALIAS); + + if (query.status) { + base.andWhere(`${ALIAS}.prodStatus = :status`, { status: query.status }); + } + if (query.q) { + const q = query.q.trim(); + // 숫자만 입력하면 상품번호로도 찾는다 + base.andWhere( + /^\d+$/.test(q) + ? `(${ALIAS}.name ILIKE :like OR ${ALIAS}.prodNo = :prodNo)` + : `${ALIAS}.name ILIKE :like`, + { like: `%${escapeLike(q)}%`, prodNo: Number(q) }, + ); + } + + const total = await base.clone().getCount(); + const page = base + .clone() + .orderBy(`${ALIAS}.prodNo`, 'DESC') + .take(query.limit + 1); + + if (query.cursor) { + const cursor = Number(query.cursor); + if (!Number.isInteger(cursor) || cursor <= 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid_cursor' }, + }); + } + page.andWhere(`${ALIAS}.prodNo < :cursor`, { cursor }); + } + + const rows = await page.getMany(); + const hasNext = rows.length > query.limit; + const items = rows.slice(0, query.limit); + + return toPaginatedResult(items.map(ProductResponseDto.from), { + hasNext, + nextCursor: hasNext ? String(items.at(-1)?.prodNo) : null, + total, + }); + } +} diff --git a/backend/src/modules/imweb/imweb-products.controller.ts b/backend/src/modules/imweb/imweb-products.controller.ts new file mode 100644 index 0000000..feaf663 --- /dev/null +++ b/backend/src/modules/imweb/imweb-products.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { PaginatedResult } from '@/common/dto/pagination.dto'; +import { AdminAuthGuard } from '@/common/guards/admin-auth.guard'; +import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants'; +import { + ProductListQueryDto, + ProductResponseDto, + ProductSyncResultDto, +} from './dto/product.dto'; +import { ImwebProductService } from './imweb-product.service'; + +/** 관리자 상품 조회 — 업체 링크를 만들 상품을 고른다 */ +@ApiTags('Admin Products') +@ApiBearerAuth(ADMIN_BEARER_AUTH) +@UseGuards(AdminAuthGuard) +@Controller('admin/products') +export class ImwebProductsController { + constructor(private readonly products: ImwebProductService) {} + + @Get() + @ApiOperation({ summary: '상품 목록 (상품번호 내림차순, 커서 페이지네이션)' }) + @ApiStandardResponse(ProductResponseDto, { + description: '조회 성공 — data 는 { items, pageInfo }', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '필터·커서 형식 오류') + findMany( + @Query() query: ProductListQueryDto, + ): Promise> { + return this.products.findMany(query); + } + + @Post('sync-runs') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + '아임웹 상품 불러오기 — 끝날 때까지 기다린다 (상품 수가 적어 빠르다)', + }) + @ApiStandardResponse(ProductSyncResultDto, { description: '동기화 완료' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('IMWEB_001', '아임웹 미연결') + @ApiErrorResponse('IMWEB_006', '상품 읽기 권한 없음 — 아임웹 재연결 필요') + sync(): Promise { + return this.products.sync(); + } +} diff --git a/backend/src/modules/imweb/imweb-sync.service.spec.ts b/backend/src/modules/imweb/imweb-sync.service.spec.ts index 191592a..e1d26ab 100644 --- a/backend/src/modules/imweb/imweb-sync.service.spec.ts +++ b/backend/src/modules/imweb/imweb-sync.service.spec.ts @@ -12,6 +12,7 @@ import { } from './entities/imweb-sync-run.entity'; import { ImwebConfig } from './imweb.config'; import { ImwebAuthService } from './imweb-auth.service'; +import { ImwebProductService } from './imweb-product.service'; import { ImwebSyncService } from './imweb-sync.service'; import { ImwebMemberData, ImwebMemberPageData } from './imweb.types'; @@ -97,6 +98,11 @@ function setup(options: { previousSucceededRunId?: number } = {}) { ), }; const config = { pageSize: 2, trackingHost: HOST }; + const products = { + sync: jest.fn(() => + Promise.resolve({ fetchedCount: 0, syncedAt: new Date().toISOString() }), + ), + }; const service = new ImwebSyncService( members as unknown as Repository, @@ -106,6 +112,7 @@ function setup(options: { previousSucceededRunId?: number } = {}) { auth as unknown as ImwebAuthService, config as unknown as ImwebConfig, {} as SchedulerRegistry, + products as unknown as ImwebProductService, ); const finalRun = () => savedRuns.at(-1); return { @@ -115,6 +122,7 @@ function setup(options: { previousSucceededRunId?: number } = {}) { imports, groups, auth, + products, memberPages, finalRun, }; @@ -143,6 +151,21 @@ describe('ImwebSyncService', () => { }); }); + it('상품 동기화가 실패해도(권한 부족 등) 회원 동기화는 성공한다', async () => { + const { service, products, memberPages, finalRun } = setup(); + products.sync.mockRejectedValueOnce(new BusinessException('IMWEB_006')); + memberPages.mockResolvedValueOnce({ data: page([member('m1')], 1, 1) }); + + await service.start(SyncTrigger.MANUAL); + await service.waitForIdle(); + + expect(products.sync).toHaveBeenCalledTimes(1); + expect(finalRun()).toMatchObject({ + status: SyncStatus.SUCCEEDED, + fetchedCount: 1, + }); + }); + it('직전 성공 실행보다 오래 못 본 회원만 지운다 (두 번 연속 미확인)', async () => { const { service, members, memberPages, finalRun } = setup({ previousSucceededRunId: 9, diff --git a/backend/src/modules/imweb/imweb-sync.service.ts b/backend/src/modules/imweb/imweb-sync.service.ts index 3f0f981..50abb7f 100644 --- a/backend/src/modules/imweb/imweb-sync.service.ts +++ b/backend/src/modules/imweb/imweb-sync.service.ts @@ -23,6 +23,7 @@ import { ImwebConfig, } from './imweb.config'; import { ImwebAuthService } from './imweb-auth.service'; +import { ImwebProductService } from './imweb-product.service'; import { ImwebGroupPageData, ImwebMemberPageData } from './imweb.types'; import { toMemberRow } from './member-row.mapper'; @@ -72,6 +73,7 @@ export class ImwebSyncService private readonly auth: ImwebAuthService, private readonly config: ImwebConfig, private readonly scheduler: SchedulerRegistry, + private readonly products: ImwebProductService, ) {} async onApplicationBootstrap(): Promise { @@ -169,6 +171,7 @@ export class ImwebSyncService const startedAt = Date.now(); try { await this.syncGroups(); + await this.syncProducts(); const fetched = await this.fetchAll(run); run.fetchedCount = fetched; run.removedCount = await this.removeDeparted(run, fetched); @@ -228,6 +231,20 @@ export class ImwebSyncService } } + /** + * 상품 목록도 같은 주기로 맞춘다. 실패해도 회원 동기화는 계속한다 — + * 상품 읽기 권한을 추가한 뒤 아직 다시 연결하지 않은 동안(IMWEB_006)에도 회원은 받아야 한다. + */ + private async syncProducts(): Promise { + try { + await this.products.sync(); + } catch (error) { + const code = + error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE; + this.logger.warn(`[${code}] 상품 동기화 건너뜀 — 회원 동기화는 계속한다`); + } + } + /** 아임웹 회원 전체를 페이지 단위로 받아 저장한다. 받은 회원 수를 돌려준다. */ private async fetchAll(run: ImwebSyncRun): Promise { const syncedAt = new Date(); diff --git a/backend/src/modules/imweb/imweb.config.ts b/backend/src/modules/imweb/imweb.config.ts index 41a47a5..6bb5c9e 100644 --- a/backend/src/modules/imweb/imweb.config.ts +++ b/backend/src/modules/imweb/imweb.config.ts @@ -18,13 +18,21 @@ export const IMWEB_INTEGRATION_COMPLETE_PATH = export const IMWEB_MEMBERS_PATH = '/member-info/members'; /** 회원 그룹 목록 — 운영진 그룹(/member-info/admin/groups)은 다루지 않는다 */ export const IMWEB_GROUPS_PATH = '/member-info/groups'; +/** 상품 목록 — 업체 링크를 만들 상품을 고르는 데 쓴다 */ +export const IMWEB_PRODUCTS_PATH = '/products'; +/** 주문 목록 — 업체 링크로 들어온 회원의 구매를 집계한다 */ +export const IMWEB_ORDERS_PATH = '/orders'; /** * 요청 권한 — 아임웹 개발자센터 앱 설정의 권한과 일치해야 한다. - * 회원 정보는 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고(errorCode 30156) + * 앱 설정에 없는 권한을 요청하면 인가가 거부된다(errorCode 30156). + * 회원·상품·주문은 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고 * 연동완료 처리에만 쓴다. (write 는 읽기를 포함한다) + * + * ⚠️ 권한을 바꾸면 관리자 화면에서 '아임웹 연결'을 다시 해야 새 권한이 담긴 토큰을 받는다. */ -export const IMWEB_SCOPES = 'site-info:write member-info:read'; +export const IMWEB_SCOPES = + 'site-info:write member-info:read product:read order:read'; /** OAuth callback 경로 (BACKEND_API_URL 뒤에 붙는다) */ export const IMWEB_CALLBACK_PATH = '/imweb/oauth/callback'; @@ -68,6 +76,21 @@ export class ImwebConfig { return this.read('IMWEB_ADMIN_PAGE_URL'); } + /** + * 아임웹 사이트 주소 (origin) — 업체 링크의 이동 주소는 이 사이트 안만 허용한다. + * 관리자 화면이 아임웹 사이트 안에 있으므로 따로 주지 않으면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다. + * 설정이 없거나 주소 형식이 아니면 빈 문자열. + */ + get siteUrl(): string { + const raw = this.read('IMWEB_SITE_URL') || this.adminPageUrl; + if (!raw) return ''; + try { + return new URL(raw).origin; + } catch { + return ''; + } + } + get trackingHost(): string { return this.read('IMWEB_TRACKING_HOST') || DEFAULT_TRACKING_HOST; } diff --git a/backend/src/modules/imweb/imweb.module.ts b/backend/src/modules/imweb/imweb.module.ts index 652522e..da400cc 100644 --- a/backend/src/modules/imweb/imweb.module.ts +++ b/backend/src/modules/imweb/imweb.module.ts @@ -6,6 +6,7 @@ import { ImwebConnection } from './entities/imweb-connection.entity'; import { ImwebGroup } from './entities/imweb-group.entity'; import { ImwebMember } from './entities/imweb-member.entity'; import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity'; +import { ImwebProduct } from './entities/imweb-product.entity'; import { ImwebSyncRun } from './entities/imweb-sync-run.entity'; import { ImwebAdminController } from './imweb-admin.controller'; import { ImwebAdminService } from './imweb-admin.service'; @@ -14,9 +15,11 @@ import { ImwebConfig } from './imweb.config'; import { ImwebHttpClient } from './imweb-http.client'; import { ImwebMemberLookupService } from './imweb-member-lookup.service'; import { ImwebOAuthController } from './imweb-oauth.controller'; +import { ImwebProductService } from './imweb-product.service'; +import { ImwebProductsController } from './imweb-products.controller'; import { ImwebSyncService } from './imweb-sync.service'; -/** 아임웹 Open API 연동 — OAuth 연결, 토큰 관리, 회원 동기화 */ +/** 아임웹 Open API 연동 — OAuth 연결, 토큰 관리, 회원·상품 동기화 */ @Module({ imports: [ TypeOrmModule.forFeature([ @@ -24,11 +27,16 @@ import { ImwebSyncService } from './imweb-sync.service'; ImwebGroup, ImwebMember, ImwebMemberInflowImport, + ImwebProduct, ImwebSyncRun, ]), AdminAuthModule, ], - controllers: [ImwebAdminController, ImwebOAuthController], + controllers: [ + ImwebAdminController, + ImwebOAuthController, + ImwebProductsController, + ], providers: [ ImwebConfig, ImwebHttpClient, @@ -36,7 +44,9 @@ import { ImwebSyncService } from './imweb-sync.service'; ImwebSyncService, ImwebAdminService, ImwebMemberLookupService, + ImwebProductService, ], - exports: [ImwebConfig, ImwebMemberLookupService], + // 업체 링크 모듈이 주문 조회(ImwebAuthService)와 회원 확인(ImwebMemberLookupService)을 쓴다 + exports: [ImwebConfig, ImwebAuthService, ImwebMemberLookupService], }) export class ImwebModule {} diff --git a/backend/src/modules/imweb/imweb.types.ts b/backend/src/modules/imweb/imweb.types.ts index 097696c..d7769b9 100644 --- a/backend/src/modules/imweb/imweb.types.ts +++ b/backend/src/modules/imweb/imweb.types.ts @@ -65,6 +65,65 @@ export interface ImwebMemberPageData { list: ImwebMemberData[]; } +/** GET /products — ReadShopProductResponseDto (쓰는 필드만) */ +export interface ImwebProductData { + prodNo: number; + name: string; + /** sale(판매중) · soldout(품절) · nosale(숨김) */ + prodStatus?: string | null; + /** normal(일반) · digital(디지털) · subscribe(이용권) */ + prodType?: string | null; + /** 유닛별 전시 여부 Y/N */ + isDisplay?: string | null; + productImages?: string[] | null; + addTime?: string | null; + editTime?: string | null; +} + +/** GET /products — ReadAllShopProductsResponseDto (페이지 방식) */ +export interface ImwebProductPageData { + totalCount: number; + totalPage: number; + currentPage: number; + pageSize: number; + list: ImwebProductData[]; +} + +/** GET /orders — OrderSectionItemDataDto (쓰는 필드만) */ +export interface ImwebOrderSectionItemData { + orderSectionItemNo?: string; + /** 사은품 여부 Y/N */ + isPromotion?: string | null; + productInfo?: { prodNo?: number | null; prodName?: string | null } | null; +} + +/** GET /orders — OrderDataDto (쓰는 필드만) */ +export interface ImwebOrderData { + orderNo: number; + isMember?: string | null; + memberCode?: string | null; + memberUid?: string | null; + /** 주문 생성 시각 (UTC) */ + wtime: string; + payments?: { + paymentStatus?: string | null; + isCancel?: string | null; + }[]; + sections?: { + orderSectionStatus?: string | null; + sectionItems?: ImwebOrderSectionItemData[]; + }[]; +} + +/** GET /orders — ReadAllOrderResponseDto (페이지 방식) */ +export interface ImwebOrderPageData { + totalCount: number; + totalPage: number; + currentPage: number; + pageSize: number; + list: ImwebOrderData[]; +} + /** * 아임웹 에러 응답 — 문서의 Error List (statusCode / errorCode / message). * 본문 구조는 문서에 정의돼 있지 않아 모든 필드를 선택으로 둔다. diff --git a/backend/src/modules/partners/dto/partner-conversion.dto.ts b/backend/src/modules/partners/dto/partner-conversion.dto.ts new file mode 100644 index 0000000..a9a8b74 --- /dev/null +++ b/backend/src/modules/partners/dto/partner-conversion.dto.ts @@ -0,0 +1,98 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; +import { ConversionType } from '../entities/partner-conversion.entity'; + +/** 링크 실적 상세 조회 */ +export class ConversionListQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: '실적 종류 — application(신청) · purchase(구매)', + enum: ConversionType, + }) + @IsOptional() + @IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' }) + type?: ConversionType; +} + +/** 실적 한 건 — 어느 회원이 언제 신청·구매했는가 */ +export class ConversionResponseDto { + @ApiProperty({ description: '실적 ID' }) + id!: string; + + @ApiProperty({ + description: '실적 종류', + enum: ConversionType, + example: ConversionType.APPLICATION, + }) + type!: ConversionType; + + @ApiProperty({ + description: '신청·주문 시각', + example: '2026-10-05T05:00:00.000Z', + }) + convertedAt!: string; + + @ApiProperty({ + description: '귀속한 링크 클릭 시각', + example: '2026-10-04T12:00:00.000Z', + }) + touchedAt!: string; + + @ApiProperty({ + description: '아임웹 주문번호 (신청이면 null)', + nullable: true, + example: '202610050001234', + }) + orderNo!: string | null; + + @ApiProperty({ + description: '집계에 들어가는가 — 구매가 환불·취소되면 false', + example: true, + }) + isValid!: boolean; + + @ApiProperty({ description: '아임웹 회원 코드' }) + memberCode!: string; + + @ApiProperty({ + description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null', + type: MemberResponseDto, + nullable: true, + }) + member!: MemberResponseDto | null; +} + +/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */ +export class OrderSyncStatusDto { + @ApiProperty({ description: '진행 중인가', example: false }) + isRunning!: boolean; + + @ApiProperty({ + description: '마지막 시작 시각', + nullable: true, + example: '2026-10-05T05:00:00.000Z', + }) + lastStartedAt!: string | null; + + @ApiProperty({ + description: '마지막 종료 시각', + nullable: true, + example: '2026-10-05T05:00:20.000Z', + }) + lastFinishedAt!: string | null; + + @ApiProperty({ + description: '마지막 실패 에러 코드 (성공이면 null)', + nullable: true, + example: null, + }) + lastErrorCode!: string | null; + + @ApiProperty({ description: '마지막 실행에서 확인한 주문 수', example: 120 }) + checkedOrderCount!: number; + + @ApiProperty({ description: '마지막 실행에서 새로 잡은 구매 수', example: 2 }) + createdCount!: number; +} diff --git a/backend/src/modules/partners/dto/partner-link.dto.ts b/backend/src/modules/partners/dto/partner-link.dto.ts new file mode 100644 index 0000000..f6a0c04 --- /dev/null +++ b/backend/src/modules/partners/dto/partner-link.dto.ts @@ -0,0 +1,130 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-validator'; + +import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto'; + +const TARGET_URL_MAX_LENGTH = 2000; + +export class CreatePartnerLinkDto { + @ApiProperty({ description: '업체 ID' }) + @IsUUID('4', { message: '업체를 선택해 주세요.' }) + partnerId!: string; + + @ApiProperty({ description: '아임웹 상품번호', example: 52 }) + @Type(() => Number) + @IsInt({ message: '상품을 선택해 주세요.' }) + @Min(1, { message: '상품을 선택해 주세요.' }) + prodNo!: number; + + @ApiProperty({ + description: '이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만', + example: 'https://www.moneyguider.co.kr/free-shop/?idx=52', + }) + @IsString() + @IsNotEmpty({ message: '이동 주소를 입력해 주세요.' }) + @MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' }) + targetUrl!: string; +} + +export class UpdatePartnerLinkDto { + @ApiPropertyOptional({ + description: '이동할 상품 페이지 주소', + example: 'https://www.moneyguider.co.kr/free-shop/?idx=52', + }) + @IsOptional() + @IsString() + @IsNotEmpty({ message: '이동 주소를 입력해 주세요.' }) + @MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' }) + targetUrl?: string; + + @ApiPropertyOptional({ + description: + '사용 여부 — 중지하면 이동은 되지만 클릭·실적을 새로 남기지 않는다', + example: false, + }) + @IsOptional() + @IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' }) + isActive?: boolean; +} + +export class PartnerLinkListQueryDto extends PartnerPeriodQueryDto { + @ApiPropertyOptional({ description: '업체 ID' }) + @IsOptional() + @IsUUID('4', { message: '업체 ID 형식이 올바르지 않습니다.' }) + partnerId?: string; + + @ApiPropertyOptional({ description: '아임웹 상품번호', example: 52 }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: '상품번호 형식이 올바르지 않습니다.' }) + @Min(1, { message: '상품번호 형식이 올바르지 않습니다.' }) + prodNo?: number; +} + +export class PartnerLinkPartnerDto { + @ApiProperty({ description: '업체 ID' }) + id!: string; + + @ApiProperty({ description: '업체 이름', example: '머니튜브' }) + name!: string; +} + +export class PartnerLinkProductDto { + @ApiProperty({ description: '아임웹 상품번호', example: 52 }) + prodNo!: number; + + @ApiProperty({ + description: '상품명 — 아임웹에서 지워진 상품이면 null', + nullable: true, + }) + name!: string | null; +} + +/** 업체 링크 한 개 */ +export class PartnerLinkResponseDto { + @ApiProperty({ description: '링크 ID' }) + id!: string; + + @ApiProperty({ description: '단축 링크 코드', example: 'Ab3kX9pQ' }) + code!: string; + + @ApiProperty({ + description: '업체에 전달할 단축 링크', + example: 'https://moneyguide.wandergo.net/r/Ab3kX9pQ', + }) + shortUrl!: string; + + @ApiProperty({ description: '업체', type: PartnerLinkPartnerDto }) + partner!: PartnerLinkPartnerDto; + + @ApiProperty({ description: '상품', type: PartnerLinkProductDto }) + product!: PartnerLinkProductDto; + + @ApiProperty({ + description: '이동할 상품 페이지 주소', + example: 'https://www.moneyguider.co.kr/free-shop/?idx=52', + }) + targetUrl!: string; + + @ApiProperty({ description: '사용 여부', example: true }) + isActive!: boolean; + + @ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto }) + stats!: PartnerStatsDto; + + @ApiProperty({ + description: '발급 시각', + example: '2026-10-05T05:00:00.000Z', + }) + createdAt!: string; +} diff --git a/backend/src/modules/partners/dto/partner-touch.dto.ts b/backend/src/modules/partners/dto/partner-touch.dto.ts new file mode 100644 index 0000000..ef73eed --- /dev/null +++ b/backend/src/modules/partners/dto/partner-touch.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link'; + +const MEMBER_ID_MAX_LENGTH = 255; +/** 한 번에 받는 클릭 ID 수 — 브라우저는 최근 30일 것만 보관한다 */ +export const PARTNER_REFS_MAX = 20; + +/** 아임웹 Header Code 스크립트가 보내는 회원 ↔ 링크 클릭 연결 */ +export class CreatePartnerTouchDto { + @ApiProperty({ + description: '아임웹 회원 아이디 또는 회원 코드 (window.MEMBER_UID)', + example: 'user@example.com', + }) + @IsString() + @IsNotEmpty({ message: '회원 정보가 없습니다.' }) + @MaxLength(MEMBER_ID_MAX_LENGTH) + memberUid!: string; + + @ApiProperty({ + description: '브라우저가 보관한 업체 링크 클릭 ID (mg_ref) 목록', + example: ['AbCd1234EfGh5678'], + type: [String], + }) + @IsArray() + @ArrayMaxSize(PARTNER_REFS_MAX) + @IsString({ each: true }) + @Matches(CLICK_REF_PATTERN, { + each: true, + message: '클릭 정보 형식이 올바르지 않습니다.', + }) + refs!: string[]; +} diff --git a/backend/src/modules/partners/dto/partner.dto.ts b/backend/src/modules/partners/dto/partner.dto.ts new file mode 100644 index 0000000..158e6be --- /dev/null +++ b/backend/src/modules/partners/dto/partner.dto.ts @@ -0,0 +1,113 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { Partner } from '../entities/partner.entity'; + +const PARTNER_NAME_MAX_LENGTH = 100; +const PARTNER_MEMO_MAX_LENGTH = 1000; + +/** 실적 집계 기간 — 목록의 클릭·신청·구매 수를 이 기간으로 센다 (없으면 전체) */ +export class PartnerPeriodQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: '집계 시작 (이상, ISO 8601)', + example: '2026-09-30T15:00:00.000Z', + }) + @IsOptional() + @IsDateString({}, { message: '집계 시작일 형식이 올바르지 않습니다.' }) + from?: string; + + @ApiPropertyOptional({ + description: '집계 끝 (미만, ISO 8601)', + example: '2026-10-31T15:00:00.000Z', + }) + @IsOptional() + @IsDateString({}, { message: '집계 종료일 형식이 올바르지 않습니다.' }) + to?: string; +} + +export class CreatePartnerDto { + @ApiProperty({ description: '업체 이름', example: '머니튜브' }) + @IsString() + @IsNotEmpty({ message: '업체 이름을 입력해 주세요.' }) + @MaxLength(PARTNER_NAME_MAX_LENGTH, { message: '업체 이름이 너무 깁니다.' }) + name!: string; + + @ApiPropertyOptional({ + description: '관리자 메모 (담당자·정산 조건 등)', + example: '담당 김OO / 신청 1건당 정산', + }) + @IsOptional() + @IsString() + @MaxLength(PARTNER_MEMO_MAX_LENGTH, { message: '메모가 너무 깁니다.' }) + memo?: string; +} + +export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {} + +/** 업체·링크의 실적 */ +export class PartnerStatsDto { + @ApiProperty({ description: '링크 클릭 수 (사람만)', example: 320 }) + clickCount!: number; + + @ApiProperty({ description: '신청 수', example: 41 }) + applicationCount!: number; + + @ApiProperty({ + description: '구매 수 — 결제완료 주문 (환불·취소 제외)', + example: 7, + }) + purchaseCount!: number; +} + +/** 업체 한 곳 */ +export class PartnerResponseDto { + @ApiProperty({ description: '업체 ID' }) + id!: string; + + @ApiProperty({ description: '업체 이름', example: '머니튜브' }) + name!: string; + + @ApiProperty({ description: '관리자 메모', nullable: true }) + memo!: string | null; + + @ApiProperty({ description: '발급된 링크 수', example: 3 }) + linkCount!: number; + + @ApiProperty({ + description: '기간 내 실적 (모든 링크 합계)', + type: PartnerStatsDto, + }) + stats!: PartnerStatsDto; + + @ApiProperty({ + description: '등록 시각', + example: '2026-10-05T05:00:00.000Z', + }) + createdAt!: string; + + static from( + partner: Partner, + linkCount = 0, + stats: PartnerStatsDto = { + clickCount: 0, + applicationCount: 0, + purchaseCount: 0, + }, + ): PartnerResponseDto { + return { + id: partner.id, + name: partner.name, + memo: partner.memo, + linkCount, + stats, + createdAt: partner.createdAt.toISOString(), + }; + } +} diff --git a/backend/src/modules/partners/entities/partner-conversion.entity.ts b/backend/src/modules/partners/entities/partner-conversion.entity.ts new file mode 100644 index 0000000..8c35e30 --- /dev/null +++ b/backend/src/modules/partners/entities/partner-conversion.entity.ts @@ -0,0 +1,84 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { PartnerLink } from './partner-link.entity'; + +export enum ConversionType { + /** 무료 강의 신청 — 신청 완료 화면이 보낸 신청 기록 */ + APPLICATION = 'application', + /** 결제완료 주문 — 아임웹 주문 동기화가 찾는다 */ + PURCHASE = 'purchase', +} + +/** + * 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건. + * + * 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다. + * 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다. + * 금액은 관리하지 않는다 (건수만 센다). + */ +@Entity({ name: 'partner_conversions' }) +@Index('uq_partner_conversions_application', ['applicationId'], { + unique: true, +}) +@Index('uq_partner_conversions_order_product', ['orderNo', 'prodNo'], { + unique: true, +}) +@Index('idx_partner_conversions_link_time', ['linkId', 'convertedAt']) +@Index('idx_partner_conversions_partner_time', ['partnerId', 'convertedAt']) +@Index('idx_partner_conversions_cursor', ['convertedAt', 'id']) +export class PartnerConversion { + /** 앱에서 crypto.randomUUID() 로 만든다 */ + @PrimaryColumn({ name: 'id', type: 'uuid' }) + id!: string; + + @Column({ name: 'type', type: 'varchar', length: 20 }) + type!: ConversionType; + + @Column({ name: 'link_id', type: 'uuid' }) + linkId!: string; + + @ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'link_id' }) + link?: PartnerLink; + + /** 링크의 업체 — 업체별 집계용으로 함께 둔다 */ + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId!: string; + + @Column({ name: 'prod_no', type: 'int' }) + prodNo!: number; + + @Column({ name: 'member_code', type: 'varchar', length: 40 }) + memberCode!: string; + + /** 신청 기록 ID (구매면 null) */ + @Column({ name: 'application_id', type: 'uuid', nullable: true }) + applicationId!: string | null; + + /** 아임웹 주문번호 (신청이면 null) */ + @Column({ name: 'order_no', type: 'varchar', length: 30, nullable: true }) + orderNo!: string | null; + + /** 집계에 넣는가 — 구매가 전액 환불·취소되면 false */ + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid!: boolean; + + /** 귀속한 클릭의 시각 */ + @Column({ name: 'touched_at', type: 'timestamptz' }) + touchedAt!: Date; + + /** 신청 시각 또는 주문 시각 */ + @Column({ name: 'converted_at', type: 'timestamptz' }) + convertedAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/partners/entities/partner-link-click.entity.ts b/backend/src/modules/partners/entities/partner-link-click.entity.ts new file mode 100644 index 0000000..0302164 --- /dev/null +++ b/backend/src/modules/partners/entities/partner-link-click.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; + +import { PartnerLink } from './partner-link.entity'; + +/** + * 업체 링크 클릭 — 사람이 누른 것만 남긴다 (링크 미리보기·검색봇 제외). + * + * ref 는 상품 페이지 주소에 mg_ref 로 붙여 보내는 클릭 ID 다. + * 회원과 클릭을 이을 때 클릭 시각을 브라우저가 아니라 이 기록에서 가져온다. + * IP·브라우저 정보는 남기지 않는다. + */ +@Entity({ name: 'partner_link_clicks' }) +@Index('idx_partner_link_clicks_link_time', ['linkId', 'clickedAt']) +export class PartnerLinkClick { + /** 클릭 ID (영숫자 16자) */ + @PrimaryColumn({ name: 'ref', type: 'varchar', length: 16 }) + ref!: string; + + @Column({ name: 'link_id', type: 'uuid' }) + linkId!: string; + + @ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'link_id' }) + link?: PartnerLink; + + @Column({ name: 'clicked_at', type: 'timestamptz' }) + clickedAt!: Date; +} diff --git a/backend/src/modules/partners/entities/partner-link.entity.ts b/backend/src/modules/partners/entities/partner-link.entity.ts new file mode 100644 index 0000000..a8d9bbd --- /dev/null +++ b/backend/src/modules/partners/entities/partner-link.entity.ts @@ -0,0 +1,60 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { Partner } from './partner.entity'; + +/** + * 업체 링크 — 업체·상품 조합당 하나. 업체에는 단축 링크(/r/{code})를 준다. + * + * 실적 기록을 지키기 위해 지우지 않고 중지(isActive=false)만 한다. + * 중지된 링크도 이동은 시켜 주지만 클릭·실적은 새로 남기지 않는다. + */ +@Entity({ name: 'partner_links' }) +@Index('uq_partner_links_partner_product', ['partnerId', 'prodNo'], { + unique: true, +}) +@Index('idx_partner_links_cursor', ['createdAt', 'id']) +export class PartnerLink { + /** 앱에서 crypto.randomUUID() 로 만든다 */ + @PrimaryColumn({ name: 'id', type: 'uuid' }) + id!: string; + + /** 단축 링크 코드 (영숫자 8자) */ + @Index('uq_partner_links_code', { unique: true }) + @Column({ name: 'code', type: 'varchar', length: 16 }) + code!: string; + + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId!: string; + + /** 링크가 있는 업체는 지울 수 없다 (DB 에서도 막는다) */ + @ManyToOne(() => Partner, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'partner_id' }) + partner?: Partner; + + /** 아임웹 상품번호 — 상품이 아임웹에서 지워져도 링크·실적은 남도록 FK 를 걸지 않는다 */ + @Index() + @Column({ name: 'prod_no', type: 'int' }) + prodNo!: number; + + /** 이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만 허용한다 */ + @Column({ name: 'target_url', type: 'text' }) + targetUrl!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/partners/entities/partner-touch.entity.ts b/backend/src/modules/partners/entities/partner-touch.entity.ts new file mode 100644 index 0000000..f77b061 --- /dev/null +++ b/backend/src/modules/partners/entities/partner-touch.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; + +import { PartnerLinkClick } from './partner-link-click.entity'; +import { PartnerLink } from './partner-link.entity'; + +/** + * 회원 ↔ 링크 클릭 연결 — "이 회원이 이 링크를 이때 눌렀다". + * + * 링크를 누른 브라우저에서 로그인한 회원이 확인되면 기록한다 (Header Code 스크립트·신청 완료 화면). + * 신청·구매가 생기면 이 기록에서 마지막 클릭을 찾아 업체 실적으로 잇는다. + */ +@Entity({ name: 'partner_touches' }) +@Index('uq_partner_touches_member_click', ['memberCode', 'clickRef'], { + unique: true, +}) +@Index('idx_partner_touches_member_product', ['memberCode', 'prodNo']) +export class PartnerTouch { + /** 앱에서 crypto.randomUUID() 로 만든다 */ + @PrimaryColumn({ name: 'id', type: 'uuid' }) + id!: string; + + @Column({ name: 'member_code', type: 'varchar', length: 40 }) + memberCode!: string; + + @Column({ name: 'click_ref', type: 'varchar', length: 16 }) + clickRef!: string; + + @ManyToOne(() => PartnerLinkClick, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'click_ref' }) + click?: PartnerLinkClick; + + @Column({ name: 'link_id', type: 'uuid' }) + linkId!: string; + + @ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'link_id' }) + link?: PartnerLink; + + /** 링크의 상품번호 — 신청·구매 상품과 맞춰 보려고 함께 둔다 */ + @Column({ name: 'prod_no', type: 'int' }) + prodNo!: number; + + /** 클릭 시각 (클릭 기록의 값) */ + @Column({ name: 'clicked_at', type: 'timestamptz' }) + clickedAt!: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/backend/src/modules/partners/entities/partner.entity.ts b/backend/src/modules/partners/entities/partner.entity.ts new file mode 100644 index 0000000..27dec80 --- /dev/null +++ b/backend/src/modules/partners/entities/partner.entity.ts @@ -0,0 +1,27 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; + +/** 업체 — 상품별 링크를 받아 신청·구매 회원을 데려오는 제휴처 */ +@Entity({ name: 'partners' }) +@Index('idx_partners_cursor', ['createdAt', 'id']) +export class Partner { + /** 앱에서 crypto.randomUUID() 로 만든다 */ + @PrimaryColumn({ name: 'id', type: 'uuid' }) + id!: string; + + @Index('uq_partners_name', { unique: true }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + /** 관리자 메모 (담당자·정산 조건 등) */ + @Column({ name: 'memo', type: 'text', nullable: true }) + memo!: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/backend/src/modules/partners/partner-attribution.service.spec.ts b/backend/src/modules/partners/partner-attribution.service.spec.ts new file mode 100644 index 0000000..4fd3e36 --- /dev/null +++ b/backend/src/modules/partners/partner-attribution.service.spec.ts @@ -0,0 +1,306 @@ +import type { Repository } from 'typeorm'; + +import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service'; +import type { ImwebOrderData } from '@/modules/imweb/imweb.types'; +import { + ConversionType, + PartnerConversion, +} from './entities/partner-conversion.entity'; +import { PartnerLinkClick } from './entities/partner-link-click.entity'; +import { PartnerLink } from './entities/partner-link.entity'; +import { PartnerTouch } from './entities/partner-touch.entity'; +import { PartnerAttributionService } from './partner-attribution.service'; + +const MEMBER_CODE = 'm20261005abcdef'; +const LINK_A = { id: 'link-a', partnerId: 'partner-a', prodNo: 52 }; +const LINK_B = { id: 'link-b', partnerId: 'partner-b', prodNo: 52 }; +const DAY = 86_400_000; + +/** insert().values().orIgnore().execute() 를 흉내 내 값을 store 에 넣는다 */ +function insertBuilder(store: T[]) { + const builder = { + insert: () => builder, + values: (rows: T | T[]) => { + store.push(...(Array.isArray(rows) ? rows : [rows])); + return builder; + }, + orIgnore: () => builder, + execute: () => Promise.resolve(), + }; + return builder; +} + +function setup(options: { clicks?: Partial[] } = {}) { + const touchStore: PartnerTouch[] = []; + const conversionStore: PartnerConversion[] = []; + const linksById = new Map( + [LINK_A, LINK_B].map((link) => [link.id, link as PartnerLink]), + ); + + const clicks = { + find: jest.fn(() => Promise.resolve(options.clicks ?? [])), + }; + const touches = { + create: jest.fn((row: PartnerTouch) => row), + createQueryBuilder: jest.fn(() => insertBuilder(touchStore)), + find: jest.fn(({ where }: { where: Partial }) => + Promise.resolve( + touchStore.filter( + (t) => t.memberCode === where.memberCode && t.prodNo === where.prodNo, + ), + ), + ), + }; + const conversions = { + create: jest.fn((row: PartnerConversion) => row), + createQueryBuilder: jest.fn(() => insertBuilder(conversionStore)), + exists: jest.fn(({ where }: { where: Partial }) => + Promise.resolve( + conversionStore.some((c) => c.applicationId === where.applicationId), + ), + ), + findOne: jest.fn(({ where }: { where: Partial }) => + Promise.resolve( + conversionStore.find( + (c) => c.orderNo === where.orderNo && c.prodNo === where.prodNo, + ) ?? null, + ), + ), + update: jest.fn((id: string, patch: Partial) => { + const row = conversionStore.find((c) => c.id === id); + if (row) Object.assign(row, patch); + return Promise.resolve(); + }), + }; + const links = { + findOne: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(linksById.get(where.id) ?? null), + ), + }; + const lookup = { + find: jest.fn(() => Promise.resolve({ memberCode: MEMBER_CODE })), + }; + + const service = new PartnerAttributionService( + clicks as unknown as Repository, + touches as unknown as Repository, + conversions as unknown as Repository, + links as unknown as Repository, + lookup as unknown as ImwebMemberLookupService, + ); + + /** 회원이 그 링크를 이 시각에 눌렀다고 기록해 둔다 */ + const touch = (link: typeof LINK_A, clickedAt: Date) => + touchStore.push({ + id: `touch-${touchStore.length}`, + memberCode: MEMBER_CODE, + clickRef: `ref${touchStore.length}`.padEnd(16, '0'), + linkId: link.id, + prodNo: link.prodNo, + clickedAt, + createdAt: clickedAt, + }); + + return { service, touchStore, conversionStore, clicks, lookup, touch }; +} + +function order(overrides: Partial = {}): ImwebOrderData { + return { + orderNo: 202610050001, + isMember: 'Y', + memberCode: MEMBER_CODE, + wtime: new Date().toISOString(), + payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }], + sections: [ + { + orderSectionStatus: 'PRODUCT_PREPARATION', + sectionItems: [{ isPromotion: 'N', productInfo: { prodNo: 52 } }], + }, + ], + ...overrides, + }; +} + +describe('PartnerAttributionService.recordTouches', () => { + it('사용 중인 링크의 클릭만 회원과 잇는다 (클릭 시각은 서버 기록)', async () => { + const clickedAt = new Date(Date.now() - DAY); + const { service, touchStore } = setup({ + clicks: [ + { + ref: 'AAAAAAAAAAAAAAAA', + linkId: LINK_A.id, + clickedAt, + link: { ...LINK_A, isActive: true } as PartnerLink, + }, + { + ref: 'BBBBBBBBBBBBBBBB', + linkId: LINK_B.id, + clickedAt, + link: { ...LINK_B, isActive: false } as PartnerLink, + }, + ], + }); + + await service.recordTouches(MEMBER_CODE, [ + 'AAAAAAAAAAAAAAAA', + 'BBBBBBBBBBBBBBBB', + ]); + + expect(touchStore).toHaveLength(1); + expect(touchStore[0]).toMatchObject({ + memberCode: MEMBER_CODE, + clickRef: 'AAAAAAAAAAAAAAAA', + linkId: LINK_A.id, + prodNo: 52, + clickedAt, + }); + }); + + it('형식이 아닌 클릭 ID 만 오면 DB 를 보지 않는다', async () => { + const { service, clicks } = setup(); + + await service.recordTouches(MEMBER_CODE, ['../../etc', 'short']); + + expect(clicks.find).not.toHaveBeenCalled(); + }); +}); + +describe('PartnerAttributionService.touchFromBrowser', () => { + it('아임웹에 없는 회원이면 조용히 버린다', async () => { + const { service, lookup, clicks } = setup(); + lookup.find.mockResolvedValueOnce(null as never); + + await service.touchFromBrowser({ + memberUid: 'nobody@test.dev', + refs: ['AAAAAAAAAAAAAAAA'], + }); + + expect(clicks.find).not.toHaveBeenCalled(); + }); + + it('회원 확인이 실패해도 예외를 밖으로 던지지 않는다 (공개 API)', async () => { + const { service, lookup } = setup(); + lookup.find.mockRejectedValueOnce(new Error('imweb down')); + + await expect( + service.touchFromBrowser({ + memberUid: 'user@test.dev', + refs: ['AAAAAAAAAAAAAAAA'], + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('PartnerAttributionService.attributeApplication', () => { + const appliedAt = new Date('2026-10-20T00:00:00.000Z'); + const application = { + id: 'app-1', + memberCode: MEMBER_CODE, + productIdx: '52', + appliedAt, + }; + + it('마지막으로 누른 링크에 신청을 귀속한다', async () => { + const { service, conversionStore, touch } = setup(); + touch(LINK_A, new Date(appliedAt.getTime() - 5 * DAY)); + touch(LINK_B, new Date(appliedAt.getTime() - 1 * DAY)); + + await service.attributeApplication(application); + + expect(conversionStore).toHaveLength(1); + expect(conversionStore[0]).toMatchObject({ + type: ConversionType.APPLICATION, + linkId: LINK_B.id, + partnerId: LINK_B.partnerId, + applicationId: 'app-1', + orderNo: null, + isValid: true, + convertedAt: appliedAt, + }); + }); + + it('클릭이 없으면 아무것도 남기지 않는다', async () => { + const { service, conversionStore } = setup(); + + await service.attributeApplication(application); + + expect(conversionStore).toHaveLength(0); + }); + + it('이미 귀속된 신청은 다시 남기지 않는다', async () => { + const { service, conversionStore, touch } = setup(); + touch(LINK_A, new Date(appliedAt.getTime() - DAY)); + + await service.attributeApplication(application); + await service.attributeApplication(application); + + expect(conversionStore).toHaveLength(1); + }); +}); + +describe('PartnerAttributionService.attributeOrder', () => { + const linked = new Set([52]); + + it('링크로 들어온 회원의 결제완료 주문을 구매로 잡는다', async () => { + const { service, conversionStore, touch } = setup(); + const wtime = new Date(); + touch(LINK_A, new Date(wtime.getTime() - 2 * DAY)); + + const result = await service.attributeOrder( + order({ wtime: wtime.toISOString() }), + linked, + ); + + expect(result).toEqual({ created: 1, updated: 0 }); + expect(conversionStore[0]).toMatchObject({ + type: ConversionType.PURCHASE, + linkId: LINK_A.id, + orderNo: '202610050001', + prodNo: 52, + applicationId: null, + isValid: true, + }); + }); + + it('이미 잡힌 구매가 환불되면 지우지 않고 집계에서 뺀다', async () => { + const { service, conversionStore, touch } = setup(); + touch(LINK_A, new Date(Date.now() - DAY)); + await service.attributeOrder(order(), linked); + + const result = await service.attributeOrder( + order({ payments: [{ paymentStatus: 'REFUND_COMPLETE' }] }), + linked, + ); + + expect(result).toEqual({ created: 0, updated: 1 }); + expect(conversionStore).toHaveLength(1); + expect(conversionStore[0].isValid).toBe(false); + }); + + it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => { + const { service, conversionStore, touch } = setup(); + touch(LINK_A, new Date(Date.now() - DAY)); + + await service.attributeOrder( + order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }), + linked, + ); + await service.attributeOrder( + order({ isMember: 'N', memberCode: null }), + linked, + ); + await service.attributeOrder(order(), new Set([60])); + + expect(conversionStore).toHaveLength(0); + }); + + it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => { + const { service, conversionStore, touch } = setup(); + const wtime = new Date(Date.now() - 3 * DAY); + touch(LINK_A, new Date(Date.now() - DAY)); + + await service.attributeOrder(order({ wtime: wtime.toISOString() }), linked); + + expect(conversionStore).toHaveLength(0); + }); +}); diff --git a/backend/src/modules/partners/partner-attribution.service.ts b/backend/src/modules/partners/partner-attribution.service.ts new file mode 100644 index 0000000..1cff86e --- /dev/null +++ b/backend/src/modules/partners/partner-attribution.service.ts @@ -0,0 +1,277 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { In, MoreThanOrEqual, Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service'; +import { ImwebOrderData } from '@/modules/imweb/imweb.types'; +import { + ATTRIBUTION_WINDOW_MS, + evaluateOrder, + pickLastTouch, +} from '@/shared/utils/partner-attribution'; +import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link'; +import { + CreatePartnerTouchDto, + PARTNER_REFS_MAX, +} from './dto/partner-touch.dto'; +import { + ConversionType, + PartnerConversion, +} from './entities/partner-conversion.entity'; +import { PartnerLinkClick } from './entities/partner-link-click.entity'; +import { PartnerLink } from './entities/partner-link.entity'; +import { PartnerTouch } from './entities/partner-touch.entity'; + +/** 신청 기록 중 귀속에 필요한 값 */ +export interface ApplicationForAttribution { + id: string; + memberCode: string; + productIdx: string; + appliedAt: Date; +} + +/** 주문 한 건을 반영한 결과 */ +export interface OrderAttributionResult { + created: number; + updated: number; +} + +/** + * 업체 실적 귀속 — 회원과 링크 클릭을 잇고, 신청·구매를 마지막 클릭 링크에 기록한다. + * + * 규칙(마지막 클릭·30일)은 shared/utils/partner-attribution.ts 의 순수 함수가 정한다. + * 회원 ↔ 클릭 연결은 공개 페이지에서 오므로 실제 아임웹 회원인지, 실제로 있는 클릭인지만 확인한다. + */ +@Injectable() +export class PartnerAttributionService { + private readonly logger = new Logger(PartnerAttributionService.name); + + constructor( + @InjectRepository(PartnerLinkClick) + private readonly clicks: Repository, + @InjectRepository(PartnerTouch) + private readonly touches: Repository, + @InjectRepository(PartnerConversion) + private readonly conversions: Repository, + @InjectRepository(PartnerLink) + private readonly links: Repository, + private readonly lookup: ImwebMemberLookupService, + ) {} + + /** + * Header Code 스크립트가 보낸 회원 ↔ 클릭 연결. + * 공개 API 라 실패 사유를 밖에 알리지 않는다 — 없는 회원·잘못된 클릭은 조용히 버린다. + */ + async touchFromBrowser(dto: CreatePartnerTouchDto): Promise { + if (dto.refs.length === 0) return; + try { + const member = await this.lookup.find(dto.memberUid); + if (!member) return; + await this.recordTouches(member.memberCode, dto.refs); + } catch (error) { + // 아임웹 미연결·일시 장애 등 — 다음 페이지 방문 때 다시 보낸다 + const code = error instanceof BusinessException ? error.code : 'SYS_001'; + this.logger.warn(`[${code}] 업체 링크 연결 기록 실패`); + } + } + + /** + * 회원과 클릭을 잇는다. 30일이 지난 클릭과 중지된 링크의 클릭은 버린다. + * 같은 회원·클릭은 한 번만 남는다 (여러 번 보내도 안전). + */ + async recordTouches( + memberCode: string, + refs: readonly string[], + ): Promise { + const valid = [...new Set(refs)] + .filter((ref) => CLICK_REF_PATTERN.test(ref)) + .slice(0, PARTNER_REFS_MAX); + if (valid.length === 0) return; + + const since = new Date(Date.now() - ATTRIBUTION_WINDOW_MS); + const found = await this.clicks.find({ + where: { ref: In(valid), clickedAt: MoreThanOrEqual(since) }, + relations: { link: true }, + }); + const rows: PartnerTouch[] = []; + for (const click of found) { + if (!click.link?.isActive) continue; + rows.push( + this.touches.create({ + id: randomUUID(), + memberCode, + clickRef: click.ref, + linkId: click.linkId, + prodNo: click.link.prodNo, + clickedAt: click.clickedAt, + }), + ); + } + if (rows.length === 0) return; + + await this.touches + .createQueryBuilder() + .insert() + .values(rows) + .orIgnore() // (회원, 클릭) 유일 — 이미 있으면 그대로 둔다 + .execute(); + } + + /** + * 신청을 마지막 클릭 링크에 귀속한다. 귀속할 클릭이 없으면 아무것도 남기지 않는다. + * 이미 귀속된 신청이면 그대로 둔다 (여러 번 불러도 안전). + */ + async attributeApplication( + application: ApplicationForAttribution, + ): Promise { + const prodNo = Number(application.productIdx); + if (!Number.isInteger(prodNo)) return; + + const exists = await this.conversions.exists({ + where: { applicationId: application.id }, + }); + if (exists) return; + + const touch = await this.findLastTouch( + application.memberCode, + prodNo, + application.appliedAt, + ); + if (!touch) return; + + await this.insertConversion({ + type: ConversionType.APPLICATION, + touch, + memberCode: application.memberCode, + prodNo, + applicationId: application.id, + orderNo: null, + convertedAt: application.appliedAt, + }); + this.logger.log( + `업체 실적(신청): link=${touch.linkId} member=${application.memberCode} product=${prodNo}`, + ); + } + + /** + * 아임웹 주문 한 건을 반영한다. + * - 이미 잡힌 구매는 환불·취소 여부만 갱신한다. + * - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다. + * + * @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다 + */ + async attributeOrder( + order: ImwebOrderData, + linkedProdNos: ReadonlySet, + ): Promise { + const result: OrderAttributionResult = { created: 0, updated: 0 }; + const orderNo = String(order.orderNo); + const orderedAt = new Date(order.wtime); + if (Number.isNaN(orderedAt.getTime())) return result; + + const { products } = evaluateOrder(order); + for (const [prodNo, counts] of products) { + if (!linkedProdNos.has(prodNo)) continue; + + const existing = await this.conversions.findOne({ + where: { orderNo, prodNo }, + }); + if (existing) { + if (existing.isValid !== counts) { + await this.conversions.update(existing.id, { isValid: counts }); + result.updated += 1; + } + continue; + } + + // 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다. + if (!counts || order.isMember !== 'Y' || !order.memberCode) continue; + + const touch = await this.findLastTouch( + order.memberCode, + prodNo, + orderedAt, + ); + if (!touch) continue; + + await this.insertConversion({ + type: ConversionType.PURCHASE, + touch, + memberCode: order.memberCode, + prodNo, + applicationId: null, + orderNo, + convertedAt: orderedAt, + }); + result.created += 1; + this.logger.log( + `업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`, + ); + } + return result; + } + + /** 업체 링크가 있는 상품번호 — 주문 동기화가 볼 상품을 좁힌다 */ + async linkedProdNos(): Promise> { + const rows = await this.links + .createQueryBuilder('l') + .select('DISTINCT l.prod_no', 'prodNo') + .getRawMany<{ prodNo: number }>(); + return new Set(rows.map((row) => Number(row.prodNo))); + } + + private async findLastTouch( + memberCode: string, + prodNo: number, + convertedAt: Date, + ): Promise { + const candidates = await this.touches.find({ + where: { + memberCode, + prodNo, + clickedAt: MoreThanOrEqual( + new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS), + ), + }, + }); + return pickLastTouch(candidates, convertedAt); + } + + private async insertConversion(input: { + type: ConversionType; + touch: PartnerTouch; + memberCode: string; + prodNo: number; + applicationId: string | null; + orderNo: string | null; + convertedAt: Date; + }): Promise { + const link = await this.links.findOne({ + where: { id: input.touch.linkId }, + }); + if (!link) return; + + await this.conversions + .createQueryBuilder() + .insert() + .values( + this.conversions.create({ + id: randomUUID(), + type: input.type, + linkId: link.id, + partnerId: link.partnerId, + prodNo: input.prodNo, + memberCode: input.memberCode, + applicationId: input.applicationId, + orderNo: input.orderNo, + isValid: true, + touchedAt: input.touch.clickedAt, + convertedAt: input.convertedAt, + }), + ) + .orIgnore() // 신청·주문당 하나 — 동시에 두 번 들어와도 한 건 + .execute(); + } +} diff --git a/backend/src/modules/partners/partner-links.controller.ts b/backend/src/modules/partners/partner-links.controller.ts new file mode 100644 index 0000000..9eb6cfd --- /dev/null +++ b/backend/src/modules/partners/partner-links.controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { PaginatedResult } from '@/common/dto/pagination.dto'; +import { AdminAuthGuard } from '@/common/guards/admin-auth.guard'; +import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants'; +import { + ConversionListQueryDto, + ConversionResponseDto, + OrderSyncStatusDto, +} from './dto/partner-conversion.dto'; +import { + CreatePartnerLinkDto, + PartnerLinkListQueryDto, + PartnerLinkResponseDto, + UpdatePartnerLinkDto, +} from './dto/partner-link.dto'; +import { PartnerLinksService } from './partner-links.service'; +import { PartnerOrderSyncService } from './partner-order-sync.service'; + +/** 업체 링크 관리 — 발급·중지와 링크별 신청·구매 실적 */ +@ApiTags('Admin Partner Links') +@ApiBearerAuth(ADMIN_BEARER_AUTH) +@UseGuards(AdminAuthGuard) +@Controller('admin/partner-links') +export class PartnerLinksController { + constructor(private readonly links: PartnerLinksService) {} + + @Get() + @ApiOperation({ summary: '업체 링크 목록 (최근 발급순) — 기간 내 실적 포함' }) + @ApiStandardResponse(PartnerLinkResponseDto, { + description: '조회 성공 — data 는 { items, pageInfo }', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '필터·기간·커서 형식 오류') + findMany( + @Query() query: PartnerLinkListQueryDto, + ): Promise> { + return this.links.findMany(query); + } + + @Post() + @ApiOperation({ summary: '업체 링크 발급 — 업체·상품 조합당 하나' }) + @ApiStandardResponse(PartnerLinkResponseDto, { + status: 201, + description: '발급 성공', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '입력값 오류') + @ApiErrorResponse('RES_001', '없는 업체, 또는 불러오지 않은 상품') + @ApiErrorResponse('PARTNER_001', '같은 업체·상품의 링크가 있음') + @ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖') + create(@Body() dto: CreatePartnerLinkDto): Promise { + return this.links.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: '업체 링크 수정 — 이동 주소 변경, 사용/중지' }) + @ApiStandardResponse(PartnerLinkResponseDto, { description: '수정 성공' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('RES_001', '없는 링크') + @ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖') + update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdatePartnerLinkDto, + ): Promise { + return this.links.update(id, dto); + } + + @Get(':id/conversions') + @ApiOperation({ summary: '링크 실적 상세 — 신청·구매한 회원 (최근순)' }) + @ApiStandardResponse(ConversionResponseDto, { + description: '조회 성공 — data 는 { items, pageInfo }', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('RES_001', '없는 링크') + findConversions( + @Param('id', new ParseUUIDPipe()) id: string, + @Query() query: ConversionListQueryDto, + ): Promise> { + return this.links.findConversions(id, query); + } +} + +/** 업체 구매 집계 새로고침 — 아임웹 주문을 다시 훑는다 (기본 1시간마다 자동) */ +@ApiTags('Admin Partner Links') +@ApiBearerAuth(ADMIN_BEARER_AUTH) +@UseGuards(AdminAuthGuard) +@Controller('admin/partner-order-sync-runs') +export class PartnerOrderSyncController { + constructor(private readonly orderSync: PartnerOrderSyncService) {} + + @Post() + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: '구매 집계 새로고침 시작 — 바로 응답하고 백그라운드에서 진행', + }) + @ApiStandardResponse(OrderSyncStatusDto, { + status: 202, + description: '시작됨', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + start(): OrderSyncStatusDto { + return this.orderSync.start(); + } + + @Get('latest') + @ApiOperation({ summary: '구매 집계 새로고침 상태 (서버 재시작 시 비워짐)' }) + @ApiStandardResponse(OrderSyncStatusDto, { description: '조회 성공' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + latest(): OrderSyncStatusDto { + return this.orderSync.getStatus(); + } +} diff --git a/backend/src/modules/partners/partner-links.service.ts b/backend/src/modules/partners/partner-links.service.ts new file mode 100644 index 0000000..4afe140 --- /dev/null +++ b/backend/src/modules/partners/partner-links.service.ts @@ -0,0 +1,391 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomBytes, randomUUID } from 'crypto'; +import { In, Repository } from 'typeorm'; + +import { + PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity'; +import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity'; +import { ImwebConfig } from '@/modules/imweb/imweb.config'; +import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; +import { MembersService } from '@/modules/members/members.service'; +import { + CLICK_REF_LENGTH, + LINK_CODE_LENGTH, + LINK_CODE_PATTERN, + isBotUserAgent, + normalizeTargetUrl, + toBase62, + withPartnerRef, +} from '@/shared/utils/partner-link'; +import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor'; +import { + ConversionListQueryDto, + ConversionResponseDto, +} from './dto/partner-conversion.dto'; +import { + CreatePartnerLinkDto, + PartnerLinkListQueryDto, + PartnerLinkResponseDto, + UpdatePartnerLinkDto, +} from './dto/partner-link.dto'; +import { PartnerConversion } from './entities/partner-conversion.entity'; +import { PartnerLinkClick } from './entities/partner-link-click.entity'; +import { PartnerLink } from './entities/partner-link.entity'; +import { Partner } from './entities/partner.entity'; +import { PartnerStatsService } from './partner-stats.service'; +import { PartnersService, isUniqueViolation } from './partners.service'; + +const ALIAS = 'l'; +const CONVERSION_ALIAS = 'v'; +/** 단축 링크 경로 — main.ts 에서 /api prefix 를 붙이지 않는다 */ +export const SHORT_LINK_PATH = 'r'; +/** 코드가 우연히 겹치면 다시 만든다 (62^8 이라 사실상 일어나지 않는다) */ +const CODE_RETRIES = 3; +const UQ_PARTNER_PRODUCT = 'uq_partner_links_partner_product'; + +function constraintOf(error: unknown): string | undefined { + return (error as QueryErrorWithConstraint).driverError?.constraint; +} + +interface QueryErrorWithConstraint { + driverError?: { constraint?: string }; +} + +/** 업체 링크 관리 — 발급·수정, 링크별 실적, 단축 링크 이동 */ +@Injectable() +export class PartnerLinksService { + private readonly logger = new Logger(PartnerLinksService.name); + + constructor( + @InjectRepository(PartnerLink) + private readonly links: Repository, + @InjectRepository(PartnerLinkClick) + private readonly clicks: Repository, + @InjectRepository(PartnerConversion) + private readonly conversions: Repository, + @InjectRepository(Partner) + private readonly partnerRows: Repository, + @InjectRepository(ImwebProduct) + private readonly products: Repository, + @InjectRepository(ImwebMember) + private readonly members: Repository, + private readonly partners: PartnersService, + private readonly stats: PartnerStatsService, + private readonly membersService: MembersService, + private readonly imwebConfig: ImwebConfig, + private readonly config: ConfigService, + ) {} + + /** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */ + async findMany( + query: PartnerLinkListQueryDto, + ): Promise> { + const base = this.links.createQueryBuilder(ALIAS); + if (query.partnerId) { + base.andWhere(`${ALIAS}.partnerId = :partnerId`, { + partnerId: query.partnerId, + }); + } + if (query.prodNo) { + base.andWhere(`${ALIAS}.prodNo = :prodNo`, { prodNo: query.prodNo }); + } + + const total = await base.clone().getCount(); + const page = base + .clone() + .orderBy(`${ALIAS}.createdAt`, 'DESC') + .addOrderBy(`${ALIAS}.id`, 'DESC') + .take(query.limit + 1); + + if (query.cursor) { + const cursor = decodeTimeCursor(query.cursor); + if (!cursor) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid_cursor' }, + }); + } + page.andWhere( + `(${ALIAS}.createdAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`, + { cursorTime: cursor.time, cursorId: cursor.key }, + ); + } + + const rows = await page.getMany(); + const hasNext = rows.length > query.limit; + const items = rows.slice(0, query.limit); + const last = items.at(-1); + + return toPaginatedResult(await this.toResponses(items, query), { + hasNext, + nextCursor: + hasNext && last + ? encodeTimeCursor({ + time: last.createdAt.toISOString(), + key: last.id, + }) + : null, + total, + }); + } + + /** 링크 발급 — 업체·상품 조합당 하나 */ + async create(dto: CreatePartnerLinkDto): Promise { + await this.partners.require(dto.partnerId); + const product = await this.products.findOne({ + where: { prodNo: dto.prodNo }, + }); + if (!product) { + // 관리자 화면에서 '상품 불러오기'를 먼저 해야 한다 + throw new BusinessException('RES_001', { + context: { reason: 'product_not_synced', prodNo: dto.prodNo }, + }); + } + const targetUrl = this.requireTargetUrl(dto.targetUrl); + const duplicated = await this.links.exists({ + where: { partnerId: dto.partnerId, prodNo: dto.prodNo }, + }); + if (duplicated) { + throw new BusinessException('PARTNER_001', { + context: { partnerId: dto.partnerId, prodNo: dto.prodNo }, + }); + } + + for (let attempt = 1; attempt <= CODE_RETRIES; attempt += 1) { + const row = this.links.create({ + id: randomUUID(), + code: toBase62(randomBytes(LINK_CODE_LENGTH)), + partnerId: dto.partnerId, + prodNo: dto.prodNo, + targetUrl, + isActive: true, + }); + try { + await this.links.insert(row); + this.logger.log( + `업체 링크 발급: link=${row.id} partner=${dto.partnerId} product=${dto.prodNo}`, + ); + return this.findOneResponse(row.id); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + if (constraintOf(error) === UQ_PARTNER_PRODUCT) { + throw new BusinessException('PARTNER_001', { + context: { partnerId: dto.partnerId, prodNo: dto.prodNo }, + }); + } + // 코드가 겹친 경우 — 새 코드로 다시 + } + } + throw new BusinessException('SYS_001', { + context: { reason: 'link_code_collision' }, + }); + } + + async update( + id: string, + dto: UpdatePartnerLinkDto, + ): Promise { + const row = await this.require(id); + if (dto.targetUrl !== undefined) { + row.targetUrl = this.requireTargetUrl(dto.targetUrl); + } + if (dto.isActive !== undefined) row.isActive = dto.isActive; + await this.links.save(row); + this.logger.log(`업체 링크 수정: link=${id} active=${row.isActive}`); + return this.findOneResponse(id); + } + + /** 링크 실적 상세 — 어느 회원이 언제 신청·구매했는지 (최근순) */ + async findConversions( + linkId: string, + query: ConversionListQueryDto, + ): Promise> { + await this.require(linkId); + const base = this.conversions + .createQueryBuilder(CONVERSION_ALIAS) + .where(`${CONVERSION_ALIAS}.linkId = :linkId`, { linkId }); + if (query.type) { + base.andWhere(`${CONVERSION_ALIAS}.type = :type`, { type: query.type }); + } + + const total = await base.clone().getCount(); + const page = base + .clone() + .orderBy(`${CONVERSION_ALIAS}.convertedAt`, 'DESC') + .addOrderBy(`${CONVERSION_ALIAS}.id`, 'DESC') + .take(query.limit + 1); + + if (query.cursor) { + const cursor = decodeTimeCursor(query.cursor); + if (!cursor) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid_cursor' }, + }); + } + page.andWhere( + `(${CONVERSION_ALIAS}.convertedAt, ${CONVERSION_ALIAS}.id) < (:cursorTime, :cursorId)`, + { cursorTime: cursor.time, cursorId: cursor.key }, + ); + } + + const rows = await page.getMany(); + const hasNext = rows.length > query.limit; + const items = rows.slice(0, query.limit); + const last = items.at(-1); + + const codes = [...new Set(items.map((row) => row.memberCode))]; + const [memberRows, groupTitles] = await Promise.all([ + codes.length > 0 + ? this.members.find({ where: { memberCode: In(codes) } }) + : Promise.resolve([]), + this.membersService.groupTitleMap(), + ]); + const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m])); + + return toPaginatedResult( + items.map((row) => { + const member = membersByCode.get(row.memberCode); + return { + id: row.id, + type: row.type, + convertedAt: row.convertedAt.toISOString(), + touchedAt: row.touchedAt.toISOString(), + orderNo: row.orderNo, + isValid: row.isValid, + memberCode: row.memberCode, + member: member ? MemberResponseDto.from(member, groupTitles) : null, + }; + }), + { + hasNext, + nextCursor: + hasNext && last + ? encodeTimeCursor({ + time: last.convertedAt.toISOString(), + key: last.id, + }) + : null, + total, + }, + ); + } + + /** + * 단축 링크가 보낼 주소. + * - 사용 중인 링크: 사람의 클릭이면 기록하고 클릭 ID 를 붙인다. 봇·미리보기는 기록 없이 이동만. + * - 중지된 링크: 이동만 시킨다 (실적을 남기지 않는다). + * - 없는 코드: 사이트 첫 화면으로 보낸다. + */ + async resolveRedirect( + code: string, + userAgent: string | undefined, + method: string, + ): Promise { + const link = LINK_CODE_PATTERN.test(code) + ? await this.links.findOne({ where: { code } }) + : null; + + if (!link) { + const home = this.imwebConfig.siteUrl; + if (!home) throw new BusinessException('RES_001', { context: { code } }); + return home; + } + if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) { + return link.targetUrl; + } + + const ref = toBase62(randomBytes(CLICK_REF_LENGTH)); + try { + await this.clicks.insert({ ref, linkId: link.id, clickedAt: new Date() }); + } catch (error) { + // 클릭 기록이 실패해도 이동은 시킨다 — 업체 링크가 막히는 것이 더 나쁘다 + this.logger.warn( + `업체 링크 클릭 기록 실패: link=${link.id} ${String(error)}`, + ); + return link.targetUrl; + } + return withPartnerRef(link.targetUrl, ref); + } + + private async require(id: string): Promise { + const row = await this.links.findOne({ where: { id } }); + if (!row) + throw new BusinessException('RES_001', { context: { linkId: id } }); + return row; + } + + private requireTargetUrl(raw: string): string { + const siteUrl = this.imwebConfig.siteUrl; + if (!siteUrl) { + throw new BusinessException('IMWEB_005', { + context: { reason: 'missing_site_url' }, + }); + } + const normalized = normalizeTargetUrl(raw, siteUrl); + if (!normalized) { + throw new BusinessException('PARTNER_003', { context: { siteUrl } }); + } + return normalized; + } + + private async findOneResponse(id: string): Promise { + const row = await this.require(id); + const [response] = await this.toResponses([row], {}); + return response; + } + + private async toResponses( + rows: PartnerLink[], + period: { from?: string; to?: string }, + ): Promise { + if (rows.length === 0) return []; + const partnerIds = [...new Set(rows.map((row) => row.partnerId))]; + const prodNos = [...new Set(rows.map((row) => row.prodNo))]; + + const [statsById, partnerRows, productRows] = await Promise.all([ + this.stats.byLinks( + rows.map((row) => row.id), + period, + ), + this.partnerRows.find({ where: { id: In(partnerIds) } }), + this.products.find({ where: { prodNo: In(prodNos) } }), + ]); + const partnerById = new Map(partnerRows.map((p) => [p.id, p])); + const productByNo = new Map(productRows.map((p) => [p.prodNo, p])); + const shortBase = this.shortLinkBase(); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + shortUrl: shortBase ? `${shortBase}/${SHORT_LINK_PATH}/${row.code}` : '', + partner: { + id: row.partnerId, + name: partnerById.get(row.partnerId)?.name ?? '', + }, + product: { + prodNo: row.prodNo, + name: productByNo.get(row.prodNo)?.name ?? null, + }, + targetUrl: row.targetUrl, + isActive: row.isActive, + stats: statsById.get(row.id) ?? PartnerStatsService.empty(), + createdAt: row.createdAt.toISOString(), + })); + } + + /** 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin */ + private shortLinkBase(): string { + const apiUrl = (this.config.get('BACKEND_API_URL') ?? '').trim(); + if (!apiUrl) return ''; + try { + return new URL(apiUrl).origin; + } catch { + return ''; + } + } +} diff --git a/backend/src/modules/partners/partner-order-sync.service.ts b/backend/src/modules/partners/partner-order-sync.service.ts new file mode 100644 index 0000000..76dd8bd --- /dev/null +++ b/backend/src/modules/partners/partner-order-sync.service.ts @@ -0,0 +1,186 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { IMWEB_ORDERS_PATH, ImwebConfig } from '@/modules/imweb/imweb.config'; +import { ImwebAuthService } from '@/modules/imweb/imweb-auth.service'; +import { ImwebOrderPageData } from '@/modules/imweb/imweb.types'; +import { OrderSyncStatusDto } from './dto/partner-conversion.dto'; +import { PartnerAttributionService } from './partner-attribution.service'; + +const SCHEDULE_NAME = 'partner-order-sync'; +/** + * 이만큼 지난 주문까지 다시 본다 — 귀속 기간(30일)에 환불·취소가 늦게 반영되는 기간을 더했다. + * 그보다 오래된 구매의 환불은 반영되지 않는다. + */ +const ORDER_LOOKBACK_DAYS = 60; +const DAY_MS = 86_400_000; +const ORDER_PAGE_SIZE = 100; +/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */ +const PAGE_DELAY_MS = 300; +/** 응답이 이상해도 무한 루프에 빠지지 않도록 */ +const MAX_PAGES = 1_000; +const FALLBACK_ERROR_CODE = 'SYS_001'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 아임웹 예시 형식(2024-09-01T00:00:00Z)에 맞춰 밀리초를 뗀다 */ +function toImwebTime(date: Date): string { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다. + * + * 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다. + * 업체 링크가 하나도 없으면 아임웹을 부르지 않는다. + */ +@Injectable() +export class PartnerOrderSyncService + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(PartnerOrderSyncService.name); + /** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */ + private running: Promise | null = null; + private status: OrderSyncStatusDto = { + isRunning: false, + lastStartedAt: null, + lastFinishedAt: null, + lastErrorCode: null, + checkedOrderCount: 0, + createdCount: 0, + }; + + constructor( + private readonly attribution: PartnerAttributionService, + private readonly auth: ImwebAuthService, + private readonly config: ImwebConfig, + private readonly scheduler: SchedulerRegistry, + ) {} + + onApplicationBootstrap(): void { + const timer = setInterval(() => { + void this.runScheduled(); + }, this.config.syncIntervalMs); + this.scheduler.addInterval(SCHEDULE_NAME, timer); + } + + async onApplicationShutdown(): Promise { + if (this.scheduler.doesExist('interval', SCHEDULE_NAME)) { + this.scheduler.deleteInterval(SCHEDULE_NAME); + } + await this.waitForIdle(); + } + + async waitForIdle(): Promise { + await this.running?.catch(() => undefined); + } + + getStatus(): OrderSyncStatusDto { + return { ...this.status }; + } + + /** + * 새로고침을 시작하고 상태를 바로 돌려준다 (끝까지 기다리지 않는다). + * 이미 진행 중이면 새로 시작하지 않고 현재 상태를 돌려준다. + */ + start(): OrderSyncStatusDto { + if (!this.running) { + this.status = { + ...this.status, + isRunning: true, + lastStartedAt: new Date().toISOString(), + }; + this.running = this.execute().finally(() => { + this.running = null; + this.status = { + ...this.status, + isRunning: false, + lastFinishedAt: new Date().toISOString(), + }; + }); + } + return this.getStatus(); + } + + private async runScheduled(): Promise { + if (this.running || !this.config.isConfigured()) return; + const connection = await this.auth.findConnection(); + if (!connection) return; // 아직 연결 전이면 조용히 건너뛴다 + this.start(); + } + + private async execute(): Promise { + const startedAt = Date.now(); + let checked = 0; + let created = 0; + let updated = 0; + + try { + const linkedProdNos = await this.attribution.linkedProdNos(); + if (linkedProdNos.size > 0) { + const { unitCode } = await this.auth.requireConnection(); + const since = new Date(Date.now() - ORDER_LOOKBACK_DAYS * DAY_MS); + + for (let page = 1; page <= MAX_PAGES; page += 1) { + if (page > 1) await sleep(PAGE_DELAY_MS); + const { data } = await this.auth.authorizedGet( + IMWEB_ORDERS_PATH, + { + page, + limit: ORDER_PAGE_SIZE, + unitCode, + isMember: 'Y', + startWtime: toImwebTime(since), + }, + ); + for (const order of data.list) { + const result = await this.attribution.attributeOrder( + order, + linkedProdNos, + ); + created += result.created; + updated += result.updated; + } + checked += data.list.length; + if (data.list.length === 0 || page >= data.totalPage) break; + } + } + + this.status = { + ...this.status, + lastErrorCode: null, + checkedOrderCount: checked, + createdCount: created, + }; + this.logger.log( + `업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`, + ); + } catch (error) { + const code = + error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE; + this.status = { + ...this.status, + lastErrorCode: code, + checkedOrderCount: checked, + createdCount: created, + }; + // 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다 + if (error instanceof BusinessException) { + this.logger.warn(`[${code}] 업체 구매 집계 실패`); + } else { + this.logger.error( + `[${code}] 업체 구매 집계 실패`, + error instanceof Error ? error.stack : String(error), + ); + } + } + } +} diff --git a/backend/src/modules/partners/partner-public.controller.ts b/backend/src/modules/partners/partner-public.controller.ts new file mode 100644 index 0000000..642bc6f --- /dev/null +++ b/backend/src/modules/partners/partner-public.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Req, + Res, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; + +import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { CreatePartnerTouchDto } from './dto/partner-touch.dto'; +import { PartnerAttributionService } from './partner-attribution.service'; +import { PartnerLinksService, SHORT_LINK_PATH } from './partner-links.service'; + +/** + * 업체 단축 링크 — moneyguide.wandergo.net/r/{코드} (main.ts 에서 /api prefix 제외). + * + * 사람이 보는 화면 이동이라 JSON 대신 상품 페이지로 리다이렉트한다. + * 같은 통신사 IP 뒤의 여러 사용자가 동시에 누를 수 있어 요청 수 제한을 걸지 않는다 + * (클릭 수가 부풀 수는 있지만 실적은 실제 회원의 신청·구매로만 잡힌다). + */ +@ApiExcludeController() +@SkipTransform() +@SkipThrottle() +@Controller(SHORT_LINK_PATH) +export class PartnerRedirectController { + constructor(private readonly links: PartnerLinksService) {} + + @Get(':code') + async redirect( + @Param('code') code: string, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const target = await this.links.resolveRedirect( + code, + req.headers['user-agent'], + req.method, + ); + // 매 클릭을 서버가 받아야 클릭 ID 가 새로 발급된다 — 브라우저·프록시가 저장하지 않게 한다 + res.setHeader('Cache-Control', 'no-store'); + res.redirect(HttpStatus.FOUND, target); + } +} + +/** 아임웹 Header Code 스크립트(imweb/partner-ref.html)가 회원 ↔ 링크 클릭 연결을 보낸다 */ +@ApiTags('Partner Touches') +@Controller('partner-touches') +export class PartnerTouchesController { + constructor(private readonly attribution: PartnerAttributionService) {} + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + @ApiOperation({ + summary: + '회원 ↔ 업체 링크 클릭 연결 — 항상 204 (없는 회원·클릭은 조용히 버린다)', + }) + @ApiErrorResponse('VAL_001', '입력값 오류') + @ApiErrorResponse('REQ_001', '요청 수 초과 (1분 30회)') + async create(@Body() dto: CreatePartnerTouchDto): Promise { + await this.attribution.touchFromBrowser(dto); + } +} diff --git a/backend/src/modules/partners/partner-stats.service.ts b/backend/src/modules/partners/partner-stats.service.ts new file mode 100644 index 0000000..cdd0d90 --- /dev/null +++ b/backend/src/modules/partners/partner-stats.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; + +import { PartnerStatsDto } from './dto/partner.dto'; +import { + ConversionType, + PartnerConversion, +} from './entities/partner-conversion.entity'; +import { PartnerLinkClick } from './entities/partner-link-click.entity'; +import { PartnerLink } from './entities/partner-link.entity'; + +/** 집계 기간 — 없으면 전체 */ +export interface StatsPeriod { + from?: string; + to?: string; +} + +const EMPTY_STATS: PartnerStatsDto = { + clickCount: 0, + applicationCount: 0, + purchaseCount: 0, +}; + +function withPeriod( + query: SelectQueryBuilder, + column: string, + period: StatsPeriod, +): SelectQueryBuilder { + if (period.from) query.andWhere(`${column} >= :from`, { from: period.from }); + if (period.to) query.andWhere(`${column} < :to`, { to: period.to }); + return query; +} + +/** 링크·업체별 클릭·신청·구매 수 집계 (환불·취소된 구매는 뺀다) */ +@Injectable() +export class PartnerStatsService { + constructor( + @InjectRepository(PartnerLinkClick) + private readonly clicks: Repository, + @InjectRepository(PartnerConversion) + private readonly conversions: Repository, + @InjectRepository(PartnerLink) + private readonly links: Repository, + ) {} + + /** 링크 ID → 실적 */ + async byLinks( + linkIds: string[], + period: StatsPeriod, + ): Promise> { + if (linkIds.length === 0) return new Map(); + + const [clickRows, conversionRows] = await Promise.all([ + withPeriod( + this.clicks + .createQueryBuilder('c') + .select('c.link_id', 'key') + .addSelect('COUNT(*)', 'count') + .where('c.link_id IN (:...ids)', { ids: linkIds }) + .groupBy('c.link_id'), + 'c.clicked_at', + period, + ).getRawMany<{ key: string; count: string }>(), + withPeriod( + this.conversions + .createQueryBuilder('v') + .select('v.link_id', 'key') + .addSelect('v.type', 'type') + .addSelect('COUNT(*)', 'count') + .where('v.link_id IN (:...ids)', { ids: linkIds }) + .andWhere('v.is_valid = true') + .groupBy('v.link_id') + .addGroupBy('v.type'), + 'v.converted_at', + period, + ).getRawMany<{ key: string; type: ConversionType; count: string }>(), + ]); + return this.merge(clickRows, conversionRows); + } + + /** 업체 ID → 실적 (모든 링크 합계) */ + async byPartners( + partnerIds: string[], + period: StatsPeriod, + ): Promise> { + if (partnerIds.length === 0) return new Map(); + + const [clickRows, conversionRows] = await Promise.all([ + withPeriod( + this.clicks + .createQueryBuilder('c') + .innerJoin(PartnerLink, 'l', 'l.id = c.link_id') + .select('l.partner_id', 'key') + .addSelect('COUNT(*)', 'count') + .where('l.partner_id IN (:...ids)', { ids: partnerIds }) + .groupBy('l.partner_id'), + 'c.clicked_at', + period, + ).getRawMany<{ key: string; count: string }>(), + withPeriod( + this.conversions + .createQueryBuilder('v') + .select('v.partner_id', 'key') + .addSelect('v.type', 'type') + .addSelect('COUNT(*)', 'count') + .where('v.partner_id IN (:...ids)', { ids: partnerIds }) + .andWhere('v.is_valid = true') + .groupBy('v.partner_id') + .addGroupBy('v.type'), + 'v.converted_at', + period, + ).getRawMany<{ key: string; type: ConversionType; count: string }>(), + ]); + return this.merge(clickRows, conversionRows); + } + + /** 업체 ID → 발급된 링크 수 */ + async linkCounts(partnerIds: string[]): Promise> { + if (partnerIds.length === 0) return new Map(); + const rows = await this.links + .createQueryBuilder('l') + .select('l.partner_id', 'key') + .addSelect('COUNT(*)', 'count') + .where('l.partner_id IN (:...ids)', { ids: partnerIds }) + .groupBy('l.partner_id') + .getRawMany<{ key: string; count: string }>(); + return new Map(rows.map((row) => [row.key, Number(row.count)])); + } + + private merge( + clickRows: { key: string; count: string }[], + conversionRows: { key: string; type: ConversionType; count: string }[], + ): Map { + const stats = new Map(); + const get = (key: string): PartnerStatsDto => { + let value = stats.get(key); + if (!value) { + value = { ...EMPTY_STATS }; + stats.set(key, value); + } + return value; + }; + + for (const row of clickRows) get(row.key).clickCount = Number(row.count); + for (const row of conversionRows) { + const value = get(row.key); + if (row.type === ConversionType.APPLICATION) { + value.applicationCount = Number(row.count); + } else if (row.type === ConversionType.PURCHASE) { + value.purchaseCount = Number(row.count); + } + } + return stats; + } + + static empty(): PartnerStatsDto { + return { ...EMPTY_STATS }; + } +} diff --git a/backend/src/modules/partners/partners.controller.ts b/backend/src/modules/partners/partners.controller.ts new file mode 100644 index 0000000..f1ee92a --- /dev/null +++ b/backend/src/modules/partners/partners.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { PaginatedResult } from '@/common/dto/pagination.dto'; +import { AdminAuthGuard } from '@/common/guards/admin-auth.guard'; +import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants'; +import { + CreatePartnerDto, + PartnerPeriodQueryDto, + PartnerResponseDto, + UpdatePartnerDto, +} from './dto/partner.dto'; +import { PartnersService } from './partners.service'; + +/** 업체 관리 — 업체별 링크를 발급해 신청·구매를 추적한다 */ +@ApiTags('Admin Partners') +@ApiBearerAuth(ADMIN_BEARER_AUTH) +@UseGuards(AdminAuthGuard) +@Controller('admin/partners') +export class PartnersController { + constructor(private readonly partners: PartnersService) {} + + @Get() + @ApiOperation({ summary: '업체 목록 (최근 등록순) — 기간 내 실적 포함' }) + @ApiStandardResponse(PartnerResponseDto, { + description: '조회 성공 — data 는 { items, pageInfo }', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '기간·커서 형식 오류') + findMany( + @Query() query: PartnerPeriodQueryDto, + ): Promise> { + return this.partners.findMany(query); + } + + @Post() + @ApiOperation({ summary: '업체 등록' }) + @ApiStandardResponse(PartnerResponseDto, { + status: 201, + description: '등록 성공', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '입력값 오류') + @ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음') + create(@Body() dto: CreatePartnerDto): Promise { + return this.partners.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: '업체 이름·메모 수정' }) + @ApiStandardResponse(PartnerResponseDto, { description: '수정 성공' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('RES_001', '없는 업체') + @ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음') + update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdatePartnerDto, + ): Promise { + return this.partners.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @ApiOperation({ summary: '업체 삭제 — 링크가 없는 업체만' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('RES_001', '없는 업체') + @ApiErrorResponse('PARTNER_002', '링크가 발급된 업체') + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + await this.partners.remove(id); + } +} diff --git a/backend/src/modules/partners/partners.module.ts b/backend/src/modules/partners/partners.module.ts new file mode 100644 index 0000000..13b8f4b --- /dev/null +++ b/backend/src/modules/partners/partners.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module'; +import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity'; +import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity'; +import { ImwebModule } from '@/modules/imweb/imweb.module'; +import { MembersModule } from '@/modules/members/members.module'; +import { PartnerConversion } from './entities/partner-conversion.entity'; +import { PartnerLinkClick } from './entities/partner-link-click.entity'; +import { PartnerLink } from './entities/partner-link.entity'; +import { PartnerTouch } from './entities/partner-touch.entity'; +import { Partner } from './entities/partner.entity'; +import { PartnerAttributionService } from './partner-attribution.service'; +import { + PartnerLinksController, + PartnerOrderSyncController, +} from './partner-links.controller'; +import { PartnerLinksService } from './partner-links.service'; +import { PartnerOrderSyncService } from './partner-order-sync.service'; +import { + PartnerRedirectController, + PartnerTouchesController, +} from './partner-public.controller'; +import { PartnerStatsService } from './partner-stats.service'; +import { PartnersController } from './partners.controller'; +import { PartnersService } from './partners.service'; + +/** + * 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다. + * 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고, + * 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Partner, + PartnerLink, + PartnerLinkClick, + PartnerTouch, + PartnerConversion, + ImwebProduct, + ImwebMember, + ]), + AdminAuthModule, + ImwebModule, + MembersModule, + ], + controllers: [ + PartnersController, + PartnerLinksController, + PartnerOrderSyncController, + PartnerRedirectController, + PartnerTouchesController, + ], + providers: [ + PartnersService, + PartnerLinksService, + PartnerStatsService, + PartnerAttributionService, + PartnerOrderSyncService, + ], + exports: [PartnerAttributionService], +}) +export class PartnersModule {} diff --git a/backend/src/modules/partners/partners.service.ts b/backend/src/modules/partners/partners.service.ts new file mode 100644 index 0000000..0996d28 --- /dev/null +++ b/backend/src/modules/partners/partners.service.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { QueryFailedError, Repository } from 'typeorm'; + +import { + PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor'; +import { + CreatePartnerDto, + PartnerPeriodQueryDto, + PartnerResponseDto, + UpdatePartnerDto, +} from './dto/partner.dto'; +import { PartnerLink } from './entities/partner-link.entity'; +import { Partner } from './entities/partner.entity'; +import { PartnerStatsService } from './partner-stats.service'; + +const ALIAS = 'p'; +/** Postgres 유일 제약 위반 */ +const PG_UNIQUE_VIOLATION = '23505'; + +export function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof QueryFailedError && + (error.driverError as { code?: string } | undefined)?.code === + PG_UNIQUE_VIOLATION + ); +} + +/** 업체 관리 — 등록·수정·삭제와 업체별 실적 */ +@Injectable() +export class PartnersService { + private readonly logger = new Logger(PartnersService.name); + + constructor( + @InjectRepository(Partner) + private readonly partners: Repository, + @InjectRepository(PartnerLink) + private readonly links: Repository, + private readonly stats: PartnerStatsService, + ) {} + + /** 업체 목록 — 최근 등록순, 기간 내 실적 포함 */ + async findMany( + query: PartnerPeriodQueryDto, + ): Promise> { + const total = await this.partners.count(); + const page = this.partners + .createQueryBuilder(ALIAS) + .orderBy(`${ALIAS}.createdAt`, 'DESC') + .addOrderBy(`${ALIAS}.id`, 'DESC') + .take(query.limit + 1); + + if (query.cursor) { + const cursor = decodeTimeCursor(query.cursor); + if (!cursor) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid_cursor' }, + }); + } + page.andWhere( + `(${ALIAS}.createdAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`, + { cursorTime: cursor.time, cursorId: cursor.key }, + ); + } + + const rows = await page.getMany(); + const hasNext = rows.length > query.limit; + const items = rows.slice(0, query.limit); + const ids = items.map((row) => row.id); + const [statsById, linkCounts] = await Promise.all([ + this.stats.byPartners(ids, query), + this.stats.linkCounts(ids), + ]); + const last = items.at(-1); + + return toPaginatedResult( + items.map((row) => + PartnerResponseDto.from( + row, + linkCounts.get(row.id) ?? 0, + statsById.get(row.id) ?? PartnerStatsService.empty(), + ), + ), + { + hasNext, + nextCursor: + hasNext && last + ? encodeTimeCursor({ + time: last.createdAt.toISOString(), + key: last.id, + }) + : null, + total, + }, + ); + } + + async create(dto: CreatePartnerDto): Promise { + const row = this.partners.create({ + id: randomUUID(), + name: dto.name.trim(), + memo: dto.memo?.trim() || null, + }); + await this.saveUnique(row); + this.logger.log(`업체 등록: id=${row.id}`); + return PartnerResponseDto.from(row); + } + + async update(id: string, dto: UpdatePartnerDto): Promise { + const row = await this.require(id); + if (dto.name !== undefined) row.name = dto.name.trim(); + if (dto.memo !== undefined) row.memo = dto.memo.trim() || null; + await this.saveUnique(row); + + const [statsById, linkCounts] = await Promise.all([ + this.stats.byPartners([id], {}), + this.stats.linkCounts([id]), + ]); + return PartnerResponseDto.from( + row, + linkCounts.get(id) ?? 0, + statsById.get(id) ?? PartnerStatsService.empty(), + ); + } + + /** 링크가 하나라도 있으면 지우지 않는다 — 실적 기록이 업체를 가리키고 있다 */ + async remove(id: string): Promise { + await this.require(id); + const hasLinks = await this.links.exists({ where: { partnerId: id } }); + if (hasLinks) { + throw new BusinessException('PARTNER_002', { + context: { partnerId: id }, + }); + } + await this.partners.delete(id); + this.logger.log(`업체 삭제: id=${id}`); + } + + /** 업체 — 없으면 RES_001 */ + async require(id: string): Promise { + const row = await this.partners.findOne({ where: { id } }); + if (!row) { + throw new BusinessException('RES_001', { context: { partnerId: id } }); + } + return row; + } + + private async saveUnique(row: Partner): Promise { + try { + await this.partners.save(row); + } catch (error) { + if (isUniqueViolation(error)) { + throw new BusinessException('PARTNER_004', { + context: { partnerId: row.id }, + }); + } + throw error; + } + } +} diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index e824ce6..3aa6e49 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -101,6 +101,16 @@ describe('validateEnv', () => { ).toThrow(/IMWEB_ADMIN_PAGE_URL 가 운영에서 쓸 수 없는 주소입니다/); }); + it('아임웹 사이트 주소는 선택이지만, 주면 공개 주소여야 한다', () => { + expect(() => validateEnv(prod())).not.toThrow(); + expect(() => + validateEnv(prod({ IMWEB_SITE_URL: 'https://www.moneyguider.co.kr' })), + ).not.toThrow(); + expect(() => + validateEnv(prod({ IMWEB_SITE_URL: 'http://localhost:5173' })), + ).toThrow(/IMWEB_SITE_URL 가 운영에서 쓸 수 없는 주소입니다/); + }); + it('아임웹 동기화 주기는 양의 정수여야 한다', () => { expect(() => validateEnv({ diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index a39141d..57dff78 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -59,6 +59,8 @@ const PUBLIC_URL_IN_PRODUCTION = [ 'BACKEND_API_URL', // 아임웹 OAuth 가 끝나면 브라우저를 이 주소로 돌려보낸다 'IMWEB_ADMIN_PAGE_URL', + // (선택) 업체 링크의 이동 주소를 허용할 사이트 — 비우면 위 주소의 origin + 'IMWEB_SITE_URL', ] as const; /** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */ diff --git a/backend/src/shared/utils/partner-attribution.spec.ts b/backend/src/shared/utils/partner-attribution.spec.ts new file mode 100644 index 0000000..ed53a41 --- /dev/null +++ b/backend/src/shared/utils/partner-attribution.spec.ts @@ -0,0 +1,129 @@ +import { evaluateOrder, pickLastTouch } from './partner-attribution'; + +const at = (iso: string) => new Date(iso); + +describe('pickLastTouch', () => { + const convertedAt = at('2026-10-31T12:00:00.000Z'); + + it('전환 직전 30일 안의 클릭 중 가장 최근 것을 고른다 (마지막 클릭)', () => { + const touches = [ + { linkId: 'A', clickedAt: at('2026-10-10T00:00:00.000Z') }, + { linkId: 'B', clickedAt: at('2026-10-20T00:00:00.000Z') }, + { linkId: 'A', clickedAt: at('2026-10-15T00:00:00.000Z') }, + ]; + expect(pickLastTouch(touches, convertedAt)?.linkId).toBe('B'); + }); + + it('전환 뒤에 누른 클릭은 원인이 아니므로 제외한다', () => { + const touches = [ + { linkId: 'A', clickedAt: at('2026-10-20T00:00:00.000Z') }, + { linkId: 'B', clickedAt: at('2026-11-01T00:00:00.000Z') }, + ]; + expect(pickLastTouch(touches, convertedAt)?.linkId).toBe('A'); + }); + + it('30일보다 오래된 클릭은 인정하지 않는다 (경계 포함 여부)', () => { + expect( + pickLastTouch( + [{ linkId: 'A', clickedAt: at('2026-10-01T12:00:00.000Z') }], + convertedAt, + )?.linkId, + ).toBe('A'); // 정확히 30일 전은 인정 + expect( + pickLastTouch( + [{ linkId: 'A', clickedAt: at('2026-10-01T11:59:59.999Z') }], + convertedAt, + ), + ).toBeNull(); + }); + + it('클릭이 없으면 null', () => { + expect(pickLastTouch([], convertedAt)).toBeNull(); + }); +}); + +describe('evaluateOrder', () => { + const item = (prodNo: number, isPromotion = 'N') => ({ + isPromotion, + productInfo: { prodNo }, + }); + + it('결제완료 주문의 상품을 구매로 센다', () => { + const result = evaluateOrder({ + payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }], + sections: [ + { + orderSectionStatus: 'PRODUCT_PREPARATION', + sectionItems: [item(52), item(60)], + }, + ], + }); + expect(result.isPaid).toBe(true); + expect(result.products).toEqual( + new Map([ + [52, true], + [60, true], + ]), + ); + }); + + it('입금 전·결제 실패·전액 환불이면 세지 않는다', () => { + for (const paymentStatus of [ + 'PAYMENT_PREPARATION', + 'PAYMENT_FAILED', + 'REFUND_COMPLETE', + ]) { + const result = evaluateOrder({ + payments: [{ paymentStatus, isCancel: 'N' }], + sections: [ + { orderSectionStatus: 'SHIPPING', sectionItems: [item(52)] }, + ], + }); + expect(result.isPaid).toBe(false); + expect(result.products.get(52)).toBe(false); + } + }); + + it('취소 결제 기록은 결제완료로 보지 않는다', () => { + const result = evaluateOrder({ + payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'Y' }], + sections: [{ sectionItems: [item(52)] }], + }); + expect(result.isPaid).toBe(false); + }); + + it('부분 환불 주문은 취소·반품된 상품만 빠진다', () => { + const result = evaluateOrder({ + payments: [{ paymentStatus: 'PARTIAL_REFUND_COMPLETE' }], + sections: [ + { orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] }, + { orderSectionStatus: 'SHIPPING_COMPLETE', sectionItems: [item(60)] }, + ], + }); + expect(result.products.get(52)).toBe(false); + expect(result.products.get(60)).toBe(true); + }); + + it('같은 상품이 여러 섹션에 있으면 하나라도 살아 있으면 구매로 본다', () => { + const result = evaluateOrder({ + payments: [{ paymentStatus: 'PAYMENT_COMPLETE' }], + sections: [ + { orderSectionStatus: 'RETURN_COMPLETE', sectionItems: [item(52)] }, + { orderSectionStatus: 'SHIPPING', sectionItems: [item(52)] }, + ], + }); + expect(result.products.get(52)).toBe(true); + }); + + it('사은품 품목과 상품번호 없는 품목은 넣지 않는다', () => { + const result = evaluateOrder({ + payments: [{ paymentStatus: 'PAYMENT_COMPLETE' }], + sections: [ + { + sectionItems: [item(99, 'Y'), { productInfo: null }, item(52)], + }, + ], + }); + expect([...result.products.keys()]).toEqual([52]); + }); +}); diff --git a/backend/src/shared/utils/partner-attribution.ts b/backend/src/shared/utils/partner-attribution.ts new file mode 100644 index 0000000..9c0f715 --- /dev/null +++ b/backend/src/shared/utils/partner-attribution.ts @@ -0,0 +1,96 @@ +/** + * 업체 링크 실적 귀속 규칙 (순수 함수). + * + * - 마지막 클릭: 신청·구매 직전 30일 안에 누른 그 상품의 업체 링크 중 가장 최근 것에 귀속한다. + * - 구매: 결제완료된 주문만 센다. 전액 환불됐거나 해당 상품 품목이 모두 취소·반품되면 빠진다. + * (금액은 관리하지 않는다 — 건수만 센다) + */ + +/** 클릭 후 이 기간 안에 신청·구매해야 업체 실적으로 인정한다 */ +export const ATTRIBUTION_WINDOW_DAYS = 30; +const DAY_MS = 86_400_000; +export const ATTRIBUTION_WINDOW_MS = ATTRIBUTION_WINDOW_DAYS * DAY_MS; + +export interface TouchCandidate { + linkId: string; + clickedAt: Date; +} + +/** + * 귀속할 클릭을 고른다 — 전환 시각 이전 30일 안의 클릭 중 가장 최근 것. 없으면 null. + * 전환 뒤에 누른 클릭은 그 전환의 원인이 아니므로 제외한다. + */ +export function pickLastTouch( + touches: readonly T[], + convertedAt: Date, +): T | null { + const end = convertedAt.getTime(); + const start = end - ATTRIBUTION_WINDOW_MS; + let picked: T | null = null; + + for (const touch of touches) { + const time = touch.clickedAt.getTime(); + if (time > end || time < start) continue; + if (!picked || time > picked.clickedAt.getTime()) picked = touch; + } + return picked; +} + +/** 결제가 끝난 것으로 보는 결제 상태 (부분 환불은 아직 결제된 주문이다) */ +const PAID_PAYMENT_STATUSES = new Set([ + 'PAYMENT_COMPLETE', + 'PARTIAL_REFUND_COMPLETE', +]); + +/** 이 섹션의 품목은 더 이상 구매로 보지 않는다 */ +const CANCELLED_SECTION_STATUSES = new Set([ + 'CANCEL_COMPLETE', + 'RETURN_COMPLETE', +]); + +/** 판정에 필요한 주문 필드만 — 아임웹 GET /orders 의 OrderDataDto 부분 */ +export interface OrderForAttribution { + payments?: { paymentStatus?: string | null; isCancel?: string | null }[]; + sections?: { + orderSectionStatus?: string | null; + sectionItems?: { + isPromotion?: string | null; + productInfo?: { prodNo?: number | null } | null; + }[]; + }[]; +} + +export interface OrderEvaluation { + /** 결제완료(부분 환불 포함)된 주문인가 */ + isPaid: boolean; + /** + * 주문에 든 상품번호 → 지금 구매로 셀 수 있는가. + * 결제가 안 됐거나, 그 상품 품목이 모두 취소·반품됐으면 false. + * 사은품 품목은 구매가 아니므로 넣지 않는다. + */ + products: Map; +} + +/** 주문 한 건을 상품별로 판정한다 */ +export function evaluateOrder(order: OrderForAttribution): OrderEvaluation { + const isPaid = (order.payments ?? []).some( + (payment) => + payment.isCancel !== 'Y' && + PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''), + ); + + const products = new Map(); + for (const section of order.sections ?? []) { + const isCancelled = CANCELLED_SECTION_STATUSES.has( + section.orderSectionStatus ?? '', + ); + for (const item of section.sectionItems ?? []) { + const prodNo = item.productInfo?.prodNo; + if (typeof prodNo !== 'number' || item.isPromotion === 'Y') continue; + // 같은 상품이 여러 섹션에 나뉘어 있으면 하나라도 살아 있으면 구매로 본다 + const counts = isPaid && !isCancelled; + products.set(prodNo, (products.get(prodNo) ?? false) || counts); + } + } + return { isPaid, products }; +} diff --git a/backend/src/shared/utils/partner-link.spec.ts b/backend/src/shared/utils/partner-link.spec.ts new file mode 100644 index 0000000..16cea76 --- /dev/null +++ b/backend/src/shared/utils/partner-link.spec.ts @@ -0,0 +1,102 @@ +import { + isBotUserAgent, + normalizeTargetUrl, + toBase62, + withPartnerRef, +} from './partner-link'; + +const SITE = 'https://www.moneyguider.co.kr'; + +describe('normalizeTargetUrl', () => { + it('사이트 안의 주소는 그대로 받는다', () => { + expect( + normalizeTargetUrl( + 'https://www.moneyguider.co.kr/free-shop/?idx=52', + SITE, + ), + ).toBe('https://www.moneyguider.co.kr/free-shop/?idx=52'); + }); + + it('www 유무만 다른 주소도 같은 사이트로 본다', () => { + expect( + normalizeTargetUrl('https://moneyguider.co.kr/shop/?idx=60', SITE), + ).toBe('https://moneyguider.co.kr/shop/?idx=60'); + }); + + it('다른 사이트·비슷한 도메인은 거부한다 (open redirect 방지)', () => { + expect(normalizeTargetUrl('https://evil.com/?idx=52', SITE)).toBeNull(); + expect( + normalizeTargetUrl('https://moneyguider.co.kr.evil.com/', SITE), + ).toBeNull(); + expect( + normalizeTargetUrl('https://www.moneyguider.co.kr@evil.com/', SITE), + ).toBeNull(); + }); + + it('http(s) 가 아니거나 주소 형식이 아니면 거부한다', () => { + expect(normalizeTargetUrl('javascript:alert(1)', SITE)).toBeNull(); + expect(normalizeTargetUrl('/free-shop/?idx=52', SITE)).toBeNull(); + expect(normalizeTargetUrl('', SITE)).toBeNull(); + }); + + it('이전 클릭 ID 와 해시는 지운다', () => { + expect( + normalizeTargetUrl( + 'https://www.moneyguider.co.kr/shop/?idx=52&mg_ref=abc#top', + SITE, + ), + ).toBe('https://www.moneyguider.co.kr/shop/?idx=52'); + }); +}); + +describe('withPartnerRef', () => { + it('기존 파라미터를 유지한 채 클릭 ID 를 붙인다', () => { + expect( + withPartnerRef( + 'https://www.moneyguider.co.kr/shop/?idx=52', + 'AbCd1234EfGh5678', + ), + ).toBe( + 'https://www.moneyguider.co.kr/shop/?idx=52&mg_ref=AbCd1234EfGh5678', + ); + }); +}); + +describe('isBotUserAgent', () => { + it('링크 미리보기·검색봇은 사람으로 세지 않는다', () => { + expect( + isBotUserAgent('kakaotalk-scrap/1.0; +https://devtalk.kakao.com/'), + ).toBe(true); + expect( + isBotUserAgent( + 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', + ), + ).toBe(true); + expect(isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe( + true, + ); + expect(isBotUserAgent(undefined)).toBe(true); + }); + + it('카카오톡 인앱 브라우저 등 실제 사용자는 센다', () => { + expect( + isBotUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 KAKAOTALK 10.4.5', + ), + ).toBe(false); + expect( + isBotUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36', + ), + ).toBe(false); + }); +}); + +describe('toBase62', () => { + it('바이트 수만큼 영숫자 글자를 만든다', () => { + const code = toBase62(new Uint8Array([0, 61, 62, 255])); + expect(code).toMatch(/^[0-9A-Za-z]{4}$/); + expect(code[0]).toBe('0'); + expect(code[1]).toBe('z'); + }); +}); diff --git a/backend/src/shared/utils/partner-link.ts b/backend/src/shared/utils/partner-link.ts new file mode 100644 index 0000000..cc52ff3 --- /dev/null +++ b/backend/src/shared/utils/partner-link.ts @@ -0,0 +1,83 @@ +/** + * 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 ID 부착, 봇 판별. + * + * 업체에 주는 단축 링크(/r/{코드})를 누르면 서버가 클릭마다 고유 ID 를 발급해 + * 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/partner-ref.html)가 + * 이 값을 브라우저에 보관했다가, 로그인한 회원이 확인되면 서버에 알려 회원과 클릭을 잇는다. + */ + +/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/partner-ref.html 과 같아야 한다 */ +export const PARTNER_REF_PARAM = 'mg_ref'; + +/** 업체 링크 코드 — /r/{코드} */ +export const LINK_CODE_LENGTH = 8; +export const LINK_CODE_PATTERN = /^[0-9A-Za-z]{8}$/; + +/** 클릭 ID — 추측할 수 없게 링크 코드보다 길게 둔다 */ +export const CLICK_REF_LENGTH = 16; +export const CLICK_REF_PATTERN = /^[0-9A-Za-z]{16}$/; + +const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** 난수 바이트를 base62 문자열로 바꾼다 (바이트 수 = 글자 수) */ +export function toBase62(bytes: Uint8Array): string { + let result = ''; + for (const byte of bytes) result += BASE62[byte % BASE62.length]; + return result; +} + +const WWW_PREFIX = 'www.'; + +/** www 유무만 다른 같은 사이트로 본다 (moneyguider.co.kr ↔ www.moneyguider.co.kr) */ +function bareHost(host: string): string { + const lower = host.toLowerCase(); + return lower.startsWith(WWW_PREFIX) ? lower.slice(WWW_PREFIX.length) : lower; +} + +/** + * 이동 주소를 검증하고 정리한다. 우리 사이트 밖이면 null. + * + * 단축 링크가 아무 주소로나 보낼 수 있으면 우리 도메인을 빌린 피싱 링크가 된다(open redirect). + * 그래서 아임웹 사이트(www 유무 무관) 안의 주소만 받는다. + * 이전에 붙은 클릭 ID 는 지운다 — 리다이렉트할 때 새로 붙인다. + */ +export function normalizeTargetUrl( + raw: string, + siteUrl: string, +): string | null { + let target: URL; + let site: URL; + try { + target = new URL(raw.trim()); + site = new URL(siteUrl); + } catch { + return null; + } + if (target.protocol !== 'https:' && target.protocol !== 'http:') return null; + if (target.username || target.password) return null; + if (bareHost(target.host) !== bareHost(site.host)) return null; + + target.searchParams.delete(PARTNER_REF_PARAM); + target.hash = ''; + return target.toString(); +} + +/** 이동 주소에 클릭 ID 를 붙인다 */ +export function withPartnerRef(targetUrl: string, clickRef: string): string { + const url = new URL(targetUrl); + url.searchParams.set(PARTNER_REF_PARAM, clickRef); + return url.toString(); +} + +/** + * 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇. + * 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다. + * 카카오톡 인앱 브라우저(KAKAOTALK)는 실제 사용자라 걸리지 않게 'kakaotalk-scrap' 만 본다. + */ +const BOT_USER_AGENT_PATTERN = + /bot|crawl|spider|slurp|facebookexternalhit|kakaotalk-scrap|whatsapp|headlesschrome|curl\/|wget\/|python-requests|go-http-client|axios\//i; + +export function isBotUserAgent(userAgent: string | undefined): boolean { + if (!userAgent || userAgent.trim() === '') return true; + return BOT_USER_AGENT_PATTERN.test(userAgent); +} diff --git a/docker-compose.yml b/docker-compose.yml index 435b0e9..a76ac64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: # 아임웹 연동 — 비밀값(IMWEB_CLIENT_ID/SECRET)은 env_file(backend/.env.*)로 들어온다 - IMWEB_SITE_CODE=${IMWEB_SITE_CODE:-} - IMWEB_ADMIN_PAGE_URL=${IMWEB_ADMIN_PAGE_URL:-} + - IMWEB_SITE_URL=${IMWEB_SITE_URL:-} - IMWEB_SYNC_INTERVAL_MINUTES=${IMWEB_SYNC_INTERVAL_MINUTES:-60} - IMWEB_PAGE_SIZE=${IMWEB_PAGE_SIZE:-100} - REDIS_HOST=redis diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 33f8f1e..e81742c 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -16,7 +16,12 @@ const Map kErrorCodeMessages = { 'IMWEB_003': '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.', 'IMWEB_004': '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.', 'IMWEB_005': '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', + 'IMWEB_006': '아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.', 'APPLY_001': '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', + 'PARTNER_001': '이 업체에는 같은 상품의 링크가 이미 있습니다.', + 'PARTNER_002': '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.', + 'PARTNER_003': '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.', + 'PARTNER_004': '같은 이름의 업체가 이미 있습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 6a16ba8..4fae529 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -16,7 +16,12 @@ export const ERROR_CODE_MESSAGES = { IMWEB_003: '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.', IMWEB_004: '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.', IMWEB_005: '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', + IMWEB_006: '아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.', APPLY_001: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', + PARTNER_001: '이 업체에는 같은 상품의 링크가 이미 있습니다.', + PARTNER_002: '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.', + PARTNER_003: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.', + PARTNER_004: '같은 이름의 업체가 이미 있습니다.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES