From b5ed8850c71374d5d2d188f2f5b216fc51f19743 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 6 Oct 2026 01:37:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=84=EC=B2=B4=20=EA=B0=9C=ED=8E=B8=EA=B3=BC=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=82=B4=EC=97=AD=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EB=B3=84=C2=B7=EC=97=85=EC=B2=B4=EB=B3=84=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 화면이 기능을 덧붙이며 복잡해져(회원 표 15열, 업체 화면에 폼·표·설명이 모두 펼쳐짐, 브라우저 기본 prompt/confirm) 보기 어렵고, 신청을 업체 기준으로 볼 방법이 없었다. - 메뉴를 개요 / 운영(신청 내역·업체 링크·회원) / 설정(연동 관리)으로 묶음 - 개요: 오늘·7일 신청, 7일 업체 링크 신청, 7일 가입 지표 + 신청 유입·업체 실적 상위·최근 신청 - 신청 내역: 상품별 ↔ 업체별 전환 — 요약 표의 행을 누르면 목록과 유입 집계를 그 항목으로 좁힘, 신청자 표에 업체 열 추가, 행을 누르면 상세 패널(신청·유입·회원·가입 경로) - 업체 링크: 왼쪽 업체 목록 + 오른쪽 링크 표, 실적 지표, 링크 발급·수정은 대화상자, 사용/중지 스위치, 링크 실적은 상세 패널, 집계 기준은 접는 도움말 - 회원: 표를 7열로 줄이고 유입경로 필터는 접어 둠, 행을 누르면 상세 패널 - 토스트 알림·대화상자·상세 패널 공통 컴포넌트, 연락처 하이픈 표시, 휴대폰 화면 정리 - API: GET /api/admin/applications/groups (상품별·업체별 신청 수·오픈카톡 입장 수), 신청 목록·유입 집계에 partnerId 필터(업체 ID 또는 none), 신청 응답에 데려온 업체(partner) Co-Authored-By: Claude Opus 5.5 --- .../applications/applications.controller.ts | 17 + .../applications/applications.module.ts | 10 +- .../applications/applications.service.spec.ts | 4 + .../applications/applications.service.ts | 154 +- .../applications/dto/application-query.dto.ts | 45 + .../dto/application-response.dto.ts | 80 +- imweb/imweb-members.html | 2792 ++++++++++------- 7 files changed, 1948 insertions(+), 1154 deletions(-) diff --git a/backend/src/modules/applications/applications.controller.ts b/backend/src/modules/applications/applications.controller.ts index 41bfa21..e6c0b98 100644 --- a/backend/src/modules/applications/applications.controller.ts +++ b/backend/src/modules/applications/applications.controller.ts @@ -23,10 +23,12 @@ import { AdminAuthGuard } from '@/common/guards/admin-auth.guard'; import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants'; import { ApplicationsService } from './applications.service'; import { + ApplicationGroupsQueryDto, ApplicationInflowStatsQueryDto, ApplicationListQueryDto, } from './dto/application-query.dto'; import { + ApplicationGroupsResponseDto, ApplicationInflowStatsResponseDto, ApplicationProductDto, ApplicationResponseDto, @@ -109,6 +111,21 @@ export class AdminApplicationsController { return this.applications.products(); } + @Get('groups') + @ApiOperation({ + summary: '상품별·업체별 신청 수 — 신청 내역의 요약 표 (기간만 거른다)', + }) + @ApiStandardResponse(ApplicationGroupsResponseDto, { + description: '조회 성공', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '기준·기간 형식 오류') + groups( + @Query() query: ApplicationGroupsQueryDto, + ): Promise { + return this.applications.groups(query); + } + @Get('inflow-stats') @ApiOperation({ summary: diff --git a/backend/src/modules/applications/applications.module.ts b/backend/src/modules/applications/applications.module.ts index 4b561a2..b4285a4 100644 --- a/backend/src/modules/applications/applications.module.ts +++ b/backend/src/modules/applications/applications.module.ts @@ -5,6 +5,8 @@ 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 { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity'; +import { Partner } from '@/modules/partners/entities/partner.entity'; import { PartnersModule } from '@/modules/partners/partners.module'; import { MemberApplication } from './application.entity'; import { @@ -16,7 +18,13 @@ import { ApplicationsService } from './applications.service'; /** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */ @Module({ imports: [ - TypeOrmModule.forFeature([MemberApplication, ImwebMember]), + // 신청을 데려온 업체를 보여주려고 업체 실적·업체를 읽는다 + TypeOrmModule.forFeature([ + MemberApplication, + ImwebMember, + PartnerConversion, + Partner, + ]), AdminAuthModule, ImwebModule, MembersModule, diff --git a/backend/src/modules/applications/applications.service.spec.ts b/backend/src/modules/applications/applications.service.spec.ts index 694e6eb..8732c7c 100644 --- a/backend/src/modules/applications/applications.service.spec.ts +++ b/backend/src/modules/applications/applications.service.spec.ts @@ -4,6 +4,8 @@ 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 { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity'; +import type { Partner } from '@/modules/partners/entities/partner.entity'; import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service'; import { MemberApplication } from './application.entity'; import { ApplicationsService } from './applications.service'; @@ -51,6 +53,8 @@ function setup(member: ImwebMember | null = MEMBER) { {} as MembersService, { trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig, attribution as unknown as PartnerAttributionService, + {} as Repository, + {} as Repository, ); return { service, store, applications, lookup, attribution, calls }; } diff --git a/backend/src/modules/applications/applications.service.ts b/backend/src/modules/applications/applications.service.ts index 1f2803c..5c06af2 100644 --- a/backend/src/modules/applications/applications.service.ts +++ b/backend/src/modules/applications/applications.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { + In, IsNull, QueryFailedError, Repository, @@ -14,23 +15,33 @@ import { toPaginatedResult, } from '@/common/dto/pagination.dto'; 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 { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service'; import { InflowGroupBy } from '@/modules/members/dto/member-query.dto'; import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; import { MembersService } from '@/modules/members/members.service'; +import { + ConversionType, + PartnerConversion, +} from '@/modules/partners/entities/partner-conversion.entity'; +import { Partner } from '@/modules/partners/entities/partner.entity'; import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service'; import { InflowStatus, parseInflow } from '@/shared/utils/inflow'; import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor'; import { MemberApplication } from './application.entity'; import { ApplicationFilterDto, + ApplicationGroupsQueryDto, ApplicationInflowBasis, ApplicationInflowStatsQueryDto, ApplicationListQueryDto, + PARTNER_FILTER_NONE, } from './dto/application-query.dto'; import { + ApplicationGroupsResponseDto, ApplicationInflowStatsResponseDto, + ApplicationPartnerDto, ApplicationProductDto, ApplicationResponseDto, } from './dto/application-response.dto'; @@ -41,6 +52,9 @@ import { const ALIAS = 'a'; const MEMBER_ALIAS = 'm'; +const CONVERSION_ALIAS = 'pc'; +const PARTNER_ALIAS = 'p'; +const PRODUCT_ALIAS = 'ip'; /** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */ const PG_UNIQUE_VIOLATION = '23505'; /** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */ @@ -92,6 +106,10 @@ export class ApplicationsService { private readonly membersService: MembersService, private readonly config: ImwebConfig, private readonly attribution: PartnerAttributionService, + @InjectRepository(PartnerConversion) + private readonly conversions: Repository, + @InjectRepository(Partner) + private readonly partners: Repository, ) {} /** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */ @@ -230,10 +248,12 @@ export class ApplicationsService { const items = rows.slice(0, query.limit); const last = items.at(-1); - const [membersByCode, groupTitles] = await Promise.all([ - this.membersByCode(items.map((row) => row.memberCode)), - this.membersService.groupTitleMap(), - ]); + const [membersByCode, groupTitles, partnersByApplication] = + await Promise.all([ + this.membersByCode(items.map((row) => row.memberCode)), + this.membersService.groupTitleMap(), + this.partnersByApplication(items.map((row) => row.id)), + ]); return toPaginatedResult( items.map((row) => { @@ -258,6 +278,7 @@ export class ApplicationsService { campaign: row.inflowLastCampaign, content: row.inflowLastContent, }, + partner: partnersByApplication.get(row.id) ?? null, }; }), { @@ -338,6 +359,21 @@ export class ApplicationsService { if (filter.source) { qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source }); } + // 업체 — 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다 + if (filter.partnerId) { + qb.leftJoin( + PartnerConversion, + CONVERSION_ALIAS, + `${CONVERSION_ALIAS}.application_id = ${ALIAS}.id`, + ); + if (filter.partnerId === PARTNER_FILTER_NONE) { + qb.andWhere(`${CONVERSION_ALIAS}.id IS NULL`); + } else { + qb.andWhere(`${CONVERSION_ALIAS}.partner_id = :partnerId`, { + partnerId: filter.partnerId, + }); + } + } // 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다 if (filter.inflowBasis && filter.inflowField && filter.inflowValue) { qb.andWhere( @@ -354,6 +390,116 @@ export class ApplicationsService { return qb; } + /** + * 상품별·업체별 신청 수 — 신청 내역 화면의 요약 표. + * 업체별은 업체 링크 실적으로 잡힌 신청을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다. + */ + async groups( + query: ApplicationGroupsQueryDto, + ): Promise { + const base = this.applications.createQueryBuilder(ALIAS); + if (query.appliedFrom) { + base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, { + appliedFrom: query.appliedFrom, + }); + } + if (query.appliedTo) { + base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, { + appliedTo: query.appliedTo, + }); + } + + const totals = await base + .clone() + .select('COUNT(*)', 'total') + .addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoTotal') + .getRawOne<{ total: string; kakaoTotal: string }>(); + + const grouped = base.clone(); + if (query.by === 'partner') { + grouped + .leftJoin( + PartnerConversion, + CONVERSION_ALIAS, + `${CONVERSION_ALIAS}.application_id = ${ALIAS}.id AND ${CONVERSION_ALIAS}.type = :applicationType`, + { applicationType: ConversionType.APPLICATION }, + ) + .leftJoin( + Partner, + PARTNER_ALIAS, + `${PARTNER_ALIAS}.id = ${CONVERSION_ALIAS}.partner_id`, + ) + .select(`${CONVERSION_ALIAS}.partner_id`, 'key') + .addSelect(`MAX(${PARTNER_ALIAS}.name)`, 'label'); + } else { + // 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 신청의 표시값 + grouped + .leftJoin( + ImwebProduct, + PRODUCT_ALIAS, + `CAST(${PRODUCT_ALIAS}.prod_no AS text) = ${ALIAS}.product_idx`, + ) + .select(`${ALIAS}.product_idx`, 'key') + .addSelect( + `COALESCE(MAX(${PRODUCT_ALIAS}.name), (ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1])`, + 'label', + ); + } + const rows = await grouped + .addSelect('COUNT(*)', 'count') + .addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoCount') + .groupBy('key') + .orderBy('count', 'DESC') + .addOrderBy('key', 'ASC', 'NULLS LAST') + .limit(STATS_MAX_ITEMS) + .getRawMany<{ + key: string | null; + label: string | null; + count: string; + kakaoCount: string; + }>(); + + return { + by: query.by, + total: Number(totals?.total ?? 0), + kakaoTotal: Number(totals?.kakaoTotal ?? 0), + items: rows.map((row) => ({ + key: row.key, + label: row.label, + count: Number(row.count), + kakaoCount: Number(row.kakaoCount), + })), + }; + } + + /** 신청 ID → 그 신청을 데려온 업체 (업체 링크 실적이 없으면 빠진다) */ + private async partnersByApplication( + applicationIds: string[], + ): Promise> { + if (applicationIds.length === 0) return new Map(); + const conversions = await this.conversions.find({ + select: { applicationId: true, partnerId: true }, + where: { applicationId: In(applicationIds) }, + }); + const partnerIds = [...new Set(conversions.map((c) => c.partnerId))]; + if (partnerIds.length === 0) return new Map(); + const partners = await this.partners.find({ + select: { id: true, name: true }, + where: { id: In(partnerIds) }, + }); + const nameById = new Map(partners.map((p) => [p.id, p.name])); + + const result = new Map(); + for (const conversion of conversions) { + if (!conversion.applicationId) continue; + result.set(conversion.applicationId, { + id: conversion.partnerId, + name: nameById.get(conversion.partnerId) ?? '', + }); + } + return result; + } + /** 상품별 신청 수 — 최근 신청이 있는 상품부터 */ async products(): Promise { const rows = await this.applications diff --git a/backend/src/modules/applications/dto/application-query.dto.ts b/backend/src/modules/applications/dto/application-query.dto.ts index e81d12e..5c282b5 100644 --- a/backend/src/modules/applications/dto/application-query.dto.ts +++ b/backend/src/modules/applications/dto/application-query.dto.ts @@ -14,6 +14,10 @@ import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto'; import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow'; const SEARCH_MAX_LENGTH = 100; +/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청) */ +export const PARTNER_FILTER_NONE = 'none'; +const PARTNER_FILTER_PATTERN = + /^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; /** * 신청 유입을 어느 기록으로 볼 것인가. @@ -50,6 +54,47 @@ export class ApplicationFilterDto { @IsOptional() @IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' }) appliedTo?: string; + + @ApiPropertyOptional({ + description: + '업체 — 그 업체 링크 실적으로 잡힌 신청만. none 이면 업체 링크를 거치지 않은 신청', + example: 'none', + }) + @IsOptional() + @Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' }) + partnerId?: string; +} + +/** 신청 요약 묶음 기준 — 상품별 / 업체별 */ +export const APPLICATION_GROUP_BY = ['product', 'partner'] as const; +export type ApplicationGroupBy = (typeof APPLICATION_GROUP_BY)[number]; + +/** 상품별·업체별 신청 수 — 기간만 걸러서 센다 */ +export class ApplicationGroupsQueryDto { + @ApiPropertyOptional({ + description: '묶음 기준 — product(상품별, 기본) · partner(업체별)', + enum: APPLICATION_GROUP_BY, + default: 'product', + }) + @IsOptional() + @IsIn(APPLICATION_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' }) + by: ApplicationGroupBy = 'product'; + + @ApiPropertyOptional({ + description: '신청 시각 시작 (이상, ISO 8601)', + example: '2026-09-30T15:00:00.000Z', + }) + @IsOptional() + @IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' }) + appliedFrom?: string; + + @ApiPropertyOptional({ + description: '신청 시각 끝 (미만, ISO 8601)', + example: '2026-10-31T15:00:00.000Z', + }) + @IsOptional() + @IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' }) + appliedTo?: string; } /** 관리자 신청 내역 조회 */ diff --git a/backend/src/modules/applications/dto/application-response.dto.ts b/backend/src/modules/applications/dto/application-response.dto.ts index ae08b62..f4aa191 100644 --- a/backend/src/modules/applications/dto/application-response.dto.ts +++ b/backend/src/modules/applications/dto/application-response.dto.ts @@ -3,8 +3,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto'; import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto'; import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; -import { APPLICATION_INFLOW_BASIS } from './application-query.dto'; -import type { ApplicationInflowBasis } from './application-query.dto'; +import { + APPLICATION_GROUP_BY, + APPLICATION_INFLOW_BASIS, +} from './application-query.dto'; +import type { + ApplicationGroupBy, + ApplicationInflowBasis, +} from './application-query.dto'; /** 신청 순간의 유입정보 */ export class ApplicationInflowDto { @@ -21,6 +27,15 @@ export class ApplicationInflowDto { content!: string | null; } +/** 신청을 데려온 업체 (업체 링크 실적으로 잡힌 경우) */ +export class ApplicationPartnerDto { + @ApiProperty({ description: '업체 ID' }) + id!: string; + + @ApiProperty({ description: '업체 이름', example: '머니튜브' }) + name!: string; +} + /** 관리자 신청 내역 한 건 */ export class ApplicationResponseDto { @ApiProperty({ description: '신청 기록 ID' }) @@ -67,6 +82,14 @@ export class ApplicationResponseDto { type: ApplicationInflowDto, }) lastInflow!: ApplicationInflowDto; + + @ApiProperty({ + description: + '업체 링크 실적으로 잡힌 경우 그 업체 — 업체 링크를 거치지 않았으면 null', + type: ApplicationPartnerDto, + nullable: true, + }) + partner!: ApplicationPartnerDto | null; } /** 상품별 신청 수 — 필터 칩에 쓴다 */ @@ -122,3 +145,56 @@ export class ApplicationInflowStatsResponseDto { }) items!: ApplicationInflowStatItemDto[]; } + +/** 상품별·업체별 신청 수 한 줄 */ +export class ApplicationGroupItemDto { + @ApiProperty({ + description: + '묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 신청은 null', + nullable: true, + example: '52', + }) + key!: string | null; + + @ApiProperty({ + description: + '표시 이름 — 상품명(아임웹 기준, 없으면 신청 화면 표시값) 또는 업체 이름', + nullable: true, + example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기', + }) + label!: string | null; + + @ApiProperty({ description: '신청 수', example: 120 }) + count!: number; + + @ApiProperty({ + description: '그중 오픈카톡 입장 버튼을 누른 수', + example: 87, + }) + kakaoCount!: number; +} + +/** 상품별·업체별 신청 수 */ +export class ApplicationGroupsResponseDto { + @ApiProperty({ + description: '묶음 기준', + enum: APPLICATION_GROUP_BY, + example: 'product', + }) + by!: ApplicationGroupBy; + + @ApiProperty({ description: '기간 내 전체 신청 수', example: 310 }) + total!: number; + + @ApiProperty({ + description: '기간 내 오픈카톡 입장 버튼을 누른 신청 수', + example: 220, + }) + kakaoTotal!: number; + + @ApiProperty({ + description: '신청 수 많은 순', + type: [ApplicationGroupItemDto], + }) + items!: ApplicationGroupItemDto[]; +} diff --git a/imweb/imweb-members.html b/imweb/imweb-members.html index bf1e016..4a3a5a7 100644 --- a/imweb/imweb-members.html +++ b/imweb/imweb-members.html @@ -1,5 +1,5 @@