generated from ttipo/project-base
Compare commits
20 Commits
cab7e840fe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d8b07d43 | |||
| 10a931aba2 | |||
| 696449bc42 | |||
| 97780eb4b6 | |||
| 7b5d16bfea | |||
| fbd040ba6d | |||
| 4ddfe2bbc3 | |||
| f29d48fcfa | |||
| bf891d2177 | |||
| 5badf94dc7 | |||
| 1f71ce048f | |||
| 8d4adfd7f1 | |||
| 9e6cae7b95 | |||
| 9a413174ed | |||
| ab7ef371dc | |||
| 1052cc3348 | |||
| a6e1b2b501 | |||
| bd849b1090 | |||
| 6316666bea | |||
| b5ed8850c7 |
Generated
+10
@@ -20,6 +20,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
@@ -5494,6 +5495,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-version": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { ActivitiesModule } from '@/modules/activities/activities.module';
|
||||
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
|
||||
import { ApplicationsModule } from '@/modules/applications/applications.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
@@ -70,6 +71,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
MembersModule,
|
||||
ApplicationsModule,
|
||||
PartnersModule,
|
||||
ActivitiesModule,
|
||||
],
|
||||
providers: [
|
||||
ShutdownLogger,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
|
||||
import type { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
|
||||
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
|
||||
|
||||
/**
|
||||
* AdminAuthGuard 가 검증한 관리자를 핸들러 인자로 받는다.
|
||||
@@ -15,3 +16,15 @@ export const CurrentAdmin = createParamDecorator(
|
||||
return request.admin;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 가드가 검증한 계정(역할·업체 포함)을 핸들러 인자로 받는다.
|
||||
* 업체 화면은 반드시 이 값의 partnerId 로 데이터를 거른다 — 요청값의 업체 ID 를 믿지 않는다.
|
||||
*/
|
||||
export const CurrentAccount = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): VerifiedAccount => {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
if (!request.account) throw new BusinessException('AUTH_001');
|
||||
return request.account;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -127,6 +127,19 @@ const DOMAIN_ERROR_CODES = {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '같은 이름의 업체가 이미 있습니다.',
|
||||
},
|
||||
// 업체 계정 — 최고관리자가 업체마다 만들어 주는 로그인 계정
|
||||
ACCOUNT_001: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '이미 사용 중인 아이디입니다. 다른 아이디를 입력해 주세요.',
|
||||
},
|
||||
ACCOUNT_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '현재 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
ACCOUNT_003: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '처음 받은 임시 비밀번호를 변경한 뒤 이용해 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AdminAuthService,
|
||||
VerifiedAccount,
|
||||
} from '@/modules/admin-auth/admin-auth.service';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
AdminRequest,
|
||||
PartnerAuthGuard,
|
||||
} from './admin-auth.guard';
|
||||
|
||||
const SUPER: VerifiedAccount = {
|
||||
admin: {
|
||||
username: 'admin',
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
sessionId: 's-super',
|
||||
role: 'super',
|
||||
accountId: null,
|
||||
partnerId: null,
|
||||
};
|
||||
|
||||
function partner(mustChangePassword = false): VerifiedAccount {
|
||||
return {
|
||||
admin: {
|
||||
username: 'moneytube',
|
||||
role: 'partner',
|
||||
partner: { id: 'p-1', name: '머니튜브' },
|
||||
mustChangePassword,
|
||||
},
|
||||
sessionId: 's-partner',
|
||||
role: 'partner',
|
||||
accountId: 'a-1',
|
||||
partnerId: 'p-1',
|
||||
};
|
||||
}
|
||||
|
||||
function run(
|
||||
Guard:
|
||||
| typeof AdminAuthGuard
|
||||
| typeof PartnerAuthGuard
|
||||
| typeof AccountAuthGuard,
|
||||
verified: VerifiedAccount,
|
||||
) {
|
||||
const request = {
|
||||
headers: { authorization: 'Bearer token' },
|
||||
} as AdminRequest;
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
const auth = {
|
||||
verifyAccessToken: jest.fn(() => Promise.resolve(verified)),
|
||||
} as unknown as AdminAuthService;
|
||||
return { result: new Guard(auth).canActivate(context), request };
|
||||
}
|
||||
|
||||
describe('계정 가드 (역할별 접근)', () => {
|
||||
it('기존 관리 기능(AdminAuthGuard)은 최고관리자만 — 업체 계정은 AUTH_003', async () => {
|
||||
await expect(run(AdminAuthGuard, SUPER).result).resolves.toBe(true);
|
||||
await expect(run(AdminAuthGuard, partner()).result).rejects.toMatchObject({
|
||||
code: 'AUTH_003',
|
||||
});
|
||||
});
|
||||
|
||||
it('업체 화면(PartnerAuthGuard)은 업체 계정만 — 최고관리자는 AUTH_003', async () => {
|
||||
const { result, request } = run(PartnerAuthGuard, partner());
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(request.account?.partnerId).toBe('p-1');
|
||||
await expect(run(PartnerAuthGuard, SUPER).result).rejects.toMatchObject({
|
||||
code: 'AUTH_003',
|
||||
});
|
||||
});
|
||||
|
||||
it('임시 비밀번호를 바꾸기 전에는 업체 화면을 ACCOUNT_003 으로 막는다', async () => {
|
||||
await expect(
|
||||
run(PartnerAuthGuard, partner(true)).result,
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_003' });
|
||||
});
|
||||
|
||||
it('계정 공통(AccountAuthGuard)은 두 역할 모두 통과한다 (임시 비밀번호여도 — 비밀번호 변경용)', async () => {
|
||||
await expect(run(AccountAuthGuard, SUPER).result).resolves.toBe(true);
|
||||
await expect(run(AccountAuthGuard, partner(true)).result).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,88 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AdminAuthService } from '@/modules/admin-auth/admin-auth.service';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AccountRole } from '@/modules/admin-auth/admin-auth.constants';
|
||||
import {
|
||||
AdminAuthService,
|
||||
VerifiedAccount,
|
||||
} from '@/modules/admin-auth/admin-auth.service';
|
||||
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
|
||||
import { readBearerToken } from '@/shared/utils/bearer';
|
||||
|
||||
/** 가드가 검증한 관리자를 요청 객체에 실어 둔다 */
|
||||
/** 가드가 검증한 계정을 요청 객체에 실어 둔다 */
|
||||
export interface AdminRequest extends Request {
|
||||
admin?: AdminResponseDto;
|
||||
/** 이 요청의 로그인 세션 — 로그아웃할 때 이 세션만 끊는다 */
|
||||
adminSessionId?: string;
|
||||
/** 역할·업체까지 포함한 검증 결과 — 업체 화면은 여기 partnerId 로만 데이터를 거른다 */
|
||||
account?: VerifiedAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 전용 라우트 보호 — `Authorization: Bearer` 의 액세스 토큰을 검증한다.
|
||||
* 실패하면 AUTH_001(401). 화면은 401 을 받으면 리프레시 토큰으로 갱신을 한 번 시도한다.
|
||||
* 토큰을 검증하고 허용된 역할인지 확인한다.
|
||||
* - 토큰이 없거나 무효: AUTH_001(401) — 화면은 리프레시 토큰으로 갱신을 한 번 시도한다
|
||||
* - 역할이 다르면: AUTH_003(403)
|
||||
*/
|
||||
async function authenticate(
|
||||
context: ExecutionContext,
|
||||
auth: AdminAuthService,
|
||||
roles: readonly AccountRole[],
|
||||
): Promise<VerifiedAccount> {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const token = readBearerToken(request.headers.authorization);
|
||||
const verified = await auth.verifyAccessToken(token);
|
||||
if (!roles.includes(verified.role)) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'role_not_allowed', role: verified.role },
|
||||
});
|
||||
}
|
||||
request.admin = verified.admin;
|
||||
request.adminSessionId = verified.sessionId;
|
||||
request.account = verified;
|
||||
return verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최고관리자 전용 — 기존 관리 기능(회원·신청·구매·업체 링크·연동)은 모두 이 가드를 쓴다.
|
||||
* 업체 계정 토큰은 AUTH_003 으로 막는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const token = readBearerToken(request.headers.authorization);
|
||||
const verified = await this.auth.verifyAccessToken(token);
|
||||
request.admin = verified.admin;
|
||||
request.adminSessionId = verified.sessionId;
|
||||
await authenticate(context, this.auth, ['super']);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 계정 전용 — 업체 화면(partner-portal). 임시 비밀번호를 바꾸기 전에는 ACCOUNT_003(403).
|
||||
* 최고관리자 토큰은 AUTH_003 — 업체 화면은 업체 ID 가 있어야 의미가 있다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const verified = await authenticate(context, this.auth, ['partner']);
|
||||
if (verified.admin.mustChangePassword) {
|
||||
throw new BusinessException('ACCOUNT_003', {
|
||||
context: { accountId: verified.accountId },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 역할 무관 — 로그인한 계정이면 된다 (현재 계정 조회·로그아웃·비밀번호 변경) */
|
||||
@Injectable()
|
||||
export class AccountAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
await authenticate(context, this.auth, ['super', 'partner']);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPurchases1791220773446 implements MigrationInterface {
|
||||
name = 'AddPurchases1791220773446';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "purchases" ("id" uuid NOT NULL, "order_no" character varying(30) NOT NULL, "prod_no" integer NOT NULL, "product_name" character varying(200), "member_code" character varying(40), "member_uid" character varying(255), "orderer_name" character varying(100), "orderer_email" character varying(255), "orderer_call" character varying(40), "ordered_at" TIMESTAMP WITH TIME ZONE NOT NULL, "is_valid" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d55032f37a34c6eceacbbca6b8" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_purchases_member" ON "purchases" ("member_code") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_purchases_cursor" ON "purchases" ("ordered_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_purchases_order_product" ON "purchases" ("order_no", "prod_no") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."uq_purchases_order_product"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_purchases_cursor"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_purchases_member"`);
|
||||
await queryRunner.query(`DROP TABLE "purchases"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddOrderInflows1791258525922 implements MigrationInterface {
|
||||
name = 'AddOrderInflows1791258525922';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_inflows" ("order_no" character varying(30) NOT NULL, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "partner_refs" character varying(16) array NOT NULL DEFAULT '{}', "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_5611d45b9e9708e854d3131aeda" PRIMARY KEY ("order_no"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_order_inflows_received" ON "order_inflows" ("received_at") `,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "purchases" ADD "inflow_raw" text`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" ADD "inflow_source" character varying(100)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" ADD "inflow_medium" character varying(100)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" ADD "inflow_campaign" character varying(100)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" ADD "inflow_content" character varying(100)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7fc2cb346462240f6f38ea03c1" ON "purchases" ("inflow_source") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7fc2cb346462240f6f38ea03c1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" DROP COLUMN "inflow_content"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" DROP COLUMN "inflow_campaign"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" DROP COLUMN "inflow_medium"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "purchases" DROP COLUMN "inflow_source"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "purchases" DROP COLUMN "inflow_raw"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_order_inflows_received"`);
|
||||
await queryRunner.query(`DROP TABLE "order_inflows"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPartnerAccounts1791269588289 implements MigrationInterface {
|
||||
name = 'AddPartnerAccounts1791269588289';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "admin_accounts" ("id" uuid NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "partner_id" uuid NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "must_change_password" boolean NOT NULL DEFAULT true, "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_79fb3604ed15538685183d8df5f" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_admin_accounts_username" ON "admin_accounts" ("username") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_admin_accounts_partner" ON "admin_accounts" ("partner_id", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" ADD "account_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a0e3bf56d35eae8a062e2757cc" ON "admin_sessions" ("account_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_accounts" ADD CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3" FOREIGN KEY ("partner_id") REFERENCES "partners"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" ADD CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3" FOREIGN KEY ("account_id") REFERENCES "admin_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" DROP CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_accounts" DROP CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a0e3bf56d35eae8a062e2757cc"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" DROP COLUMN "account_id"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_admin_accounts_partner"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."uq_admin_accounts_username"`);
|
||||
await queryRunner.query(`DROP TABLE "admin_accounts"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Controller, Get, 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 { ActivitiesService } from './activities.service';
|
||||
import {
|
||||
ActivityGroupsQueryDto,
|
||||
ActivityInflowStatsQueryDto,
|
||||
ActivityListQueryDto,
|
||||
} from './dto/activity-query.dto';
|
||||
import {
|
||||
ActivityGroupsResponseDto,
|
||||
ActivityInflowStatsResponseDto,
|
||||
ActivityResponseDto,
|
||||
} from './dto/activity-response.dto';
|
||||
|
||||
/** 관리자 신청·구매 내역 — 무료 신청과 유료 구매를 한 화면에서 본다 */
|
||||
@ApiTags('Admin Activities')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/activities')
|
||||
export class AdminActivitiesController {
|
||||
constructor(private readonly activities: ActivitiesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '신청·구매 내역 (최신순, 커서 페이지네이션) — 종류로 거를 수 있다',
|
||||
})
|
||||
@ApiStandardResponse(ActivityResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
|
||||
findMany(
|
||||
@Query() query: ActivityListQueryDto,
|
||||
): Promise<PaginatedResult<ActivityResponseDto>> {
|
||||
return this.activities.findMany(query);
|
||||
}
|
||||
|
||||
@Get('groups')
|
||||
@ApiOperation({
|
||||
summary: '상품별·업체별 신청·구매 수 — 요약 표 (기간만 거른다)',
|
||||
})
|
||||
@ApiStandardResponse(ActivityGroupsResponseDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
|
||||
groups(
|
||||
@Query() query: ActivityGroupsQueryDto,
|
||||
): Promise<ActivityGroupsResponseDto> {
|
||||
return this.activities.groups(query);
|
||||
}
|
||||
|
||||
@Get('inflow-stats')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
|
||||
})
|
||||
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
|
||||
description: '집계 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
|
||||
inflowStats(
|
||||
@Query() query: ActivityInflowStatsQueryDto,
|
||||
): Promise<ActivityInflowStatsResponseDto> {
|
||||
return this.activities.inflowStats(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 { MembersModule } from '@/modules/members/members.module';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
|
||||
/**
|
||||
* 관리자 신청·구매 내역 — 읽기 전용.
|
||||
* 무료 신청(ApplicationsModule)과 유료 구매(PurchasesModule)가 쌓은 기록을 합쳐 보여준다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ImwebMember, Partner]),
|
||||
AdminAuthModule,
|
||||
MembersModule,
|
||||
],
|
||||
controllers: [AdminActivitiesController],
|
||||
providers: [ActivitiesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
@@ -0,0 +1,643 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import {
|
||||
DataSource,
|
||||
In,
|
||||
ObjectLiteral,
|
||||
Repository,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { MemberApplication } from '@/modules/applications/application.entity';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
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 { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { Purchase } from '@/modules/purchases/purchase.entity';
|
||||
import { InflowStatus } from '@/shared/utils/inflow';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import {
|
||||
ActivityFilterDto,
|
||||
ActivityGroupsQueryDto,
|
||||
ActivityInflowStatsQueryDto,
|
||||
ActivityListQueryDto,
|
||||
ActivityType,
|
||||
PARTNER_FILTER_NONE,
|
||||
} from './dto/activity-query.dto';
|
||||
import {
|
||||
ActivityCountsDto,
|
||||
ActivityGroupsResponseDto,
|
||||
ActivityInflowStatsResponseDto,
|
||||
ActivityResponseDto,
|
||||
} from './dto/activity-response.dto';
|
||||
|
||||
const APP = 'a';
|
||||
const APP_MEMBER = 'am';
|
||||
const APP_CONVERSION = 'apc';
|
||||
const PUR = 'p';
|
||||
const PUR_MEMBER = 'pm';
|
||||
const PUR_CONVERSION = 'ppc';
|
||||
const UNION = 'u';
|
||||
const PRODUCT = 'ip';
|
||||
const PARTNER = 'pt';
|
||||
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
|
||||
const STATS_MAX_ITEMS = 100;
|
||||
/** 커서의 고유값은 신청·구매 기록 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
type ListFilter = ActivityFilterDto &
|
||||
Partial<
|
||||
Pick<
|
||||
ActivityListQueryDto,
|
||||
'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
|
||||
>
|
||||
>;
|
||||
|
||||
/** [SQL 식, 별칭] — 신청·구매 두 쪽이 같은 순서로 같은 열을 내야 UNION 할 수 있다 */
|
||||
type Columns = [expression: string, alias: string][];
|
||||
|
||||
/** 목록 한 줄 (UNION 결과 원본) */
|
||||
interface ActivityRow {
|
||||
type: ActivityType;
|
||||
id: string;
|
||||
occurred_at: Date;
|
||||
product_idx: string;
|
||||
product_name: string | null;
|
||||
member_code: string | null;
|
||||
member_uid: string | null;
|
||||
partner_id: string | null;
|
||||
kakao_clicked_at: Date | null;
|
||||
inflow_source: string | null;
|
||||
inflow_medium: string | null;
|
||||
inflow_campaign: string | null;
|
||||
inflow_content: string | null;
|
||||
order_no: string | null;
|
||||
is_valid: boolean | null;
|
||||
orderer_name: string | null;
|
||||
orderer_email: string | null;
|
||||
orderer_call: string | null;
|
||||
}
|
||||
|
||||
interface CountsRow {
|
||||
applicationCount: string;
|
||||
kakaoCount: string;
|
||||
purchaseCount: string;
|
||||
cancelledCount: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입 경로 식 — 가입 때 기록된 값만 쓴다 (가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다).
|
||||
* 항목은 enum 으로 검증된 값이라 문자열로 넣어도 안전하다.
|
||||
*/
|
||||
function joinInflow(memberAlias: string, field: InflowGroupBy): string {
|
||||
return `CASE WHEN ${memberAlias}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${memberAlias}.inflow_${field} END`;
|
||||
}
|
||||
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function selectColumns<T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
columns: Columns,
|
||||
): SelectQueryBuilder<T> {
|
||||
const [[firstExpression, firstAlias], ...rest] = columns;
|
||||
qb.select(firstExpression, firstAlias);
|
||||
for (const [expression, alias] of rest) qb.addSelect(expression, alias);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** 신청·구매 수 — UNION 결과(u)에서 센다 */
|
||||
const COUNT_COLUMNS: Columns = [
|
||||
[`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`, 'applicationCount'],
|
||||
[`COUNT(${UNION}.kakao_clicked_at)`, 'kakaoCount'],
|
||||
[
|
||||
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND ${UNION}.is_valid)`,
|
||||
'purchaseCount',
|
||||
],
|
||||
[
|
||||
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND NOT ${UNION}.is_valid)`,
|
||||
'cancelledCount',
|
||||
],
|
||||
];
|
||||
|
||||
function toCounts(row: CountsRow | undefined): ActivityCountsDto {
|
||||
return {
|
||||
applicationCount: Number(row?.applicationCount ?? 0),
|
||||
kakaoCount: Number(row?.kakaoCount ?? 0),
|
||||
purchaseCount: Number(row?.purchaseCount ?? 0),
|
||||
cancelledCount: Number(row?.cancelledCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 신청·구매 내역 — 무료 신청(member_applications)과 유료 구매(purchases)를 한 화면에서 본다.
|
||||
*
|
||||
* 두 기록은 따로 쌓이고(신청은 신청 완료 화면, 구매는 아임웹 주문 동기화) 여기서 UNION ALL 로 합친다.
|
||||
* 업체는 업체 실적(partner_conversions)과 이어서 보여준다 — 신청은 신청 ID, 구매는 주문번호·상품으로 잇는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivitiesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@InjectRepository(ImwebMember)
|
||||
private readonly members: Repository<ImwebMember>,
|
||||
@InjectRepository(Partner)
|
||||
private readonly partners: Repository<Partner>,
|
||||
private readonly membersService: MembersService,
|
||||
) {}
|
||||
|
||||
async findMany(
|
||||
query: ActivityListQueryDto,
|
||||
): Promise<PaginatedResult<ActivityResponseDto>> {
|
||||
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
|
||||
const app = this.applicationPart(query);
|
||||
if (app) {
|
||||
parts.push(
|
||||
selectColumns(app, [
|
||||
[`'application'`, 'type'],
|
||||
[`${APP}.id`, 'id'],
|
||||
[`${APP}.applied_at`, 'occurred_at'],
|
||||
[`${APP}.product_idx`, 'product_idx'],
|
||||
[`${APP}.product_name`, 'product_name'],
|
||||
[`${APP}.member_code`, 'member_code'],
|
||||
[`${APP}.member_uid`, 'member_uid'],
|
||||
[`${APP_CONVERSION}.partner_id`, 'partner_id'],
|
||||
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
|
||||
[`${APP}.inflow_source`, 'inflow_source'],
|
||||
[`${APP}.inflow_medium`, 'inflow_medium'],
|
||||
[`${APP}.inflow_campaign`, 'inflow_campaign'],
|
||||
[`${APP}.inflow_content`, 'inflow_content'],
|
||||
['CAST(NULL AS varchar)', 'order_no'],
|
||||
['CAST(NULL AS boolean)', 'is_valid'],
|
||||
['CAST(NULL AS varchar)', 'orderer_name'],
|
||||
['CAST(NULL AS varchar)', 'orderer_email'],
|
||||
['CAST(NULL AS varchar)', 'orderer_call'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
const pur = this.purchasePart(query);
|
||||
if (pur) {
|
||||
parts.push(
|
||||
selectColumns(pur, [
|
||||
[`'purchase'`, 'type'],
|
||||
[`${PUR}.id`, 'id'],
|
||||
[`${PUR}.ordered_at`, 'occurred_at'],
|
||||
[`CAST(${PUR}.prod_no AS varchar)`, 'product_idx'],
|
||||
[`${PUR}.product_name`, 'product_name'],
|
||||
[`${PUR}.member_code`, 'member_code'],
|
||||
[`${PUR}.member_uid`, 'member_uid'],
|
||||
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
|
||||
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
|
||||
[`${PUR}.inflow_source`, 'inflow_source'],
|
||||
[`${PUR}.inflow_medium`, 'inflow_medium'],
|
||||
[`${PUR}.inflow_campaign`, 'inflow_campaign'],
|
||||
[`${PUR}.inflow_content`, 'inflow_content'],
|
||||
[`${PUR}.order_no`, 'order_no'],
|
||||
[`${PUR}.is_valid`, 'is_valid'],
|
||||
[`${PUR}.orderer_name`, 'orderer_name'],
|
||||
[`${PUR}.orderer_email`, 'orderer_email'],
|
||||
[`${PUR}.orderer_call`, 'orderer_call'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return toPaginatedResult([], {
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const totalRow = await this.fromUnion(parts)
|
||||
.select('COUNT(*)', 'total')
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
const page = this.fromUnion(parts)
|
||||
.select(`${UNION}.*`)
|
||||
.orderBy(`${UNION}.occurred_at`, 'DESC')
|
||||
.addOrderBy(`${UNION}.id`, 'DESC')
|
||||
.limit(query.limit + 1);
|
||||
if (query.cursor) {
|
||||
const cursor = decodeTimeCursor(query.cursor);
|
||||
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(
|
||||
`(${UNION}.occurred_at, ${UNION}.id) < (:cursorTime, :cursorId)`,
|
||||
{ cursorTime: cursor.time, cursorId: cursor.key },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await page.getRawMany<ActivityRow>();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = rows.slice(0, query.limit);
|
||||
const last = items.at(-1);
|
||||
|
||||
const [membersByCode, groupTitles, partnerNames] = await Promise.all([
|
||||
this.membersByCode(items.map((row) => row.member_code)),
|
||||
this.membersService.groupTitleMap(),
|
||||
this.partnerNames(items.map((row) => row.partner_id)),
|
||||
]);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row): ActivityResponseDto => {
|
||||
const member = row.member_code
|
||||
? membersByCode.get(row.member_code)
|
||||
: undefined;
|
||||
const isApplication = row.type === 'application';
|
||||
return {
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
occurredAt: row.occurred_at.toISOString(),
|
||||
productIdx: row.product_idx,
|
||||
productName: row.product_name,
|
||||
memberUid: row.member_uid,
|
||||
member: member ? MemberResponseDto.from(member, groupTitles) : null,
|
||||
partner: row.partner_id
|
||||
? {
|
||||
id: row.partner_id,
|
||||
name: partnerNames.get(row.partner_id) ?? '',
|
||||
}
|
||||
: null,
|
||||
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
|
||||
inflow: {
|
||||
source: row.inflow_source,
|
||||
medium: row.inflow_medium,
|
||||
campaign: row.inflow_campaign,
|
||||
content: row.inflow_content,
|
||||
},
|
||||
orderNo: row.order_no,
|
||||
isCancelled: isApplication ? null : row.is_valid === false,
|
||||
orderer: isApplication
|
||||
? null
|
||||
: {
|
||||
name: row.orderer_name,
|
||||
email: row.orderer_email,
|
||||
phone: row.orderer_call,
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.occurred_at.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품별·업체별 신청·구매 수 — 화면 위쪽 요약 표.
|
||||
* 업체별은 업체 링크 실적으로 잡힌 것을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
|
||||
*/
|
||||
async groups(
|
||||
query: ActivityGroupsQueryDto,
|
||||
): Promise<ActivityGroupsResponseDto> {
|
||||
const filter: ListFilter = { from: query.from, to: query.to };
|
||||
const byPartner = query.by === 'partner';
|
||||
const parts = [
|
||||
selectColumns(this.applicationQuery(filter), [
|
||||
[`'application'`, 'type'],
|
||||
[
|
||||
byPartner ? `${APP_CONVERSION}.partner_id` : `${APP}.product_idx`,
|
||||
'key',
|
||||
],
|
||||
[byPartner ? 'CAST(NULL AS varchar)' : `${APP}.product_name`, 'label'],
|
||||
[`${APP}.applied_at`, 'occurred_at'],
|
||||
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
|
||||
['CAST(NULL AS boolean)', 'is_valid'],
|
||||
]),
|
||||
selectColumns(this.purchaseQuery(filter), [
|
||||
[`'purchase'`, 'type'],
|
||||
[
|
||||
byPartner
|
||||
? `${PUR_CONVERSION}.partner_id`
|
||||
: `CAST(${PUR}.prod_no AS varchar)`,
|
||||
'key',
|
||||
],
|
||||
[byPartner ? 'CAST(NULL AS varchar)' : `${PUR}.product_name`, 'label'],
|
||||
[`${PUR}.ordered_at`, 'occurred_at'],
|
||||
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
|
||||
[`${PUR}.is_valid`, 'is_valid'],
|
||||
]),
|
||||
];
|
||||
|
||||
const totals = await selectColumns(
|
||||
this.fromUnion(parts),
|
||||
COUNT_COLUMNS,
|
||||
).getRawOne<CountsRow>();
|
||||
|
||||
const grouped = this.fromUnion(parts);
|
||||
if (byPartner) {
|
||||
grouped
|
||||
.leftJoin(Partner, PARTNER, `${PARTNER}.id = ${UNION}.key`)
|
||||
.select(`${UNION}.key`, 'key')
|
||||
.addSelect(`MAX(${PARTNER}.name)`, 'label');
|
||||
} else {
|
||||
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 표시값
|
||||
grouped
|
||||
.leftJoin(
|
||||
ImwebProduct,
|
||||
PRODUCT,
|
||||
`CAST(${PRODUCT}.prod_no AS varchar) = ${UNION}.key`,
|
||||
)
|
||||
.select(`${UNION}.key`, 'key')
|
||||
.addSelect(
|
||||
`COALESCE(MAX(${PRODUCT}.name), (ARRAY_AGG(${UNION}.label ORDER BY ${UNION}.occurred_at DESC) FILTER (WHERE ${UNION}.label IS NOT NULL))[1])`,
|
||||
'label',
|
||||
);
|
||||
}
|
||||
for (const [expression, alias] of COUNT_COLUMNS) {
|
||||
grouped.addSelect(expression, alias);
|
||||
}
|
||||
const rows = await grouped
|
||||
.groupBy(`${UNION}.key`)
|
||||
.orderBy('COUNT(*)', 'DESC')
|
||||
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
|
||||
.limit(STATS_MAX_ITEMS)
|
||||
.getRawMany<CountsRow & { key: string | null; label: string | null }>();
|
||||
|
||||
return {
|
||||
by: query.by,
|
||||
totals: toCounts(totals),
|
||||
items: rows.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
...toCounts(row),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다.
|
||||
* - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값.
|
||||
* 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음
|
||||
* - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음
|
||||
*/
|
||||
async inflowStats(
|
||||
query: ActivityInflowStatsQueryDto,
|
||||
): Promise<ActivityInflowStatsResponseDto> {
|
||||
const isJoin = query.basis === 'join';
|
||||
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
|
||||
|
||||
const app = this.applicationPart(query);
|
||||
if (app) {
|
||||
parts.push(
|
||||
selectColumns(app, [
|
||||
[`'application'`, 'type'],
|
||||
[
|
||||
isJoin
|
||||
? joinInflow(APP_MEMBER, query.groupBy)
|
||||
: `${APP}.inflow_${query.groupBy}`,
|
||||
'key',
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
const pur = this.purchasePart(query);
|
||||
if (pur) {
|
||||
pur.andWhere(`${PUR}.is_valid = true`);
|
||||
parts.push(
|
||||
selectColumns(pur, [
|
||||
[`'purchase'`, 'type'],
|
||||
[
|
||||
isJoin
|
||||
? joinInflow(PUR_MEMBER, query.groupBy)
|
||||
: `${PUR}.inflow_${query.groupBy}`,
|
||||
'key',
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const result: ActivityInflowStatsResponseDto = {
|
||||
basis: query.basis,
|
||||
groupBy: query.groupBy,
|
||||
applicationTotal: 0,
|
||||
purchaseTotal: 0,
|
||||
items: [],
|
||||
};
|
||||
if (parts.length === 0) return result;
|
||||
|
||||
const totals = await this.fromUnion(parts)
|
||||
.select(
|
||||
`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`,
|
||||
'applicationTotal',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase')`,
|
||||
'purchaseTotal',
|
||||
)
|
||||
.getRawOne<{ applicationTotal: string; purchaseTotal: string }>();
|
||||
const rows = await this.fromUnion(parts)
|
||||
.select(`${UNION}.key`, 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy(`${UNION}.key`)
|
||||
.orderBy('count', 'DESC')
|
||||
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
|
||||
.limit(STATS_MAX_ITEMS)
|
||||
.getRawMany<{ key: string | null; count: string }>();
|
||||
|
||||
return {
|
||||
...result,
|
||||
applicationTotal: Number(totals?.applicationTotal ?? 0),
|
||||
purchaseTotal: Number(totals?.purchaseTotal ?? 0),
|
||||
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 무료 신청 쪽 — 종류 필터로 빠지면 null */
|
||||
private applicationPart(
|
||||
filter: ListFilter,
|
||||
): SelectQueryBuilder<ObjectLiteral> | null {
|
||||
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
|
||||
}
|
||||
|
||||
/** 유료 구매 쪽 — 종류 필터로 빠지면 null */
|
||||
private purchasePart(
|
||||
filter: ListFilter,
|
||||
): SelectQueryBuilder<ObjectLiteral> | null {
|
||||
return filter.type === 'application' ? null : this.purchaseQuery(filter);
|
||||
}
|
||||
|
||||
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
|
||||
private applicationQuery(
|
||||
filter: ListFilter,
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = this.dataSource
|
||||
.createQueryBuilder()
|
||||
.from(MemberApplication, APP)
|
||||
// 탈퇴 회원은 회원 정보 없이 남는다
|
||||
.leftJoin(
|
||||
ImwebMember,
|
||||
APP_MEMBER,
|
||||
`${APP_MEMBER}.member_code = ${APP}.member_code`,
|
||||
)
|
||||
// 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
|
||||
.leftJoin(
|
||||
PartnerConversion,
|
||||
APP_CONVERSION,
|
||||
`${APP_CONVERSION}.application_id = ${APP}.id`,
|
||||
);
|
||||
|
||||
this.applyCommonFilters(qb, filter, {
|
||||
occurredAt: `${APP}.applied_at`,
|
||||
productIdx: `${APP}.product_idx`,
|
||||
conversion: APP_CONVERSION,
|
||||
searchColumns: [
|
||||
`${APP_MEMBER}.name`,
|
||||
`${APP}.member_uid`,
|
||||
`${APP_MEMBER}.email`,
|
||||
`${APP_MEMBER}.callnum`,
|
||||
],
|
||||
});
|
||||
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
|
||||
const expression =
|
||||
filter.inflowBasis === 'join'
|
||||
? joinInflow(APP_MEMBER, filter.inflowField)
|
||||
: `${APP}.inflow_${filter.inflowField}`;
|
||||
qb.andWhere(`${expression} = :inflowValue`, {
|
||||
inflowValue: filter.inflowValue,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */
|
||||
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = this.dataSource
|
||||
.createQueryBuilder()
|
||||
.from(Purchase, PUR)
|
||||
.leftJoin(
|
||||
ImwebMember,
|
||||
PUR_MEMBER,
|
||||
`${PUR_MEMBER}.member_code = ${PUR}.member_code`,
|
||||
)
|
||||
// 구매 실적은 주문·상품당 하나다
|
||||
.leftJoin(
|
||||
PartnerConversion,
|
||||
PUR_CONVERSION,
|
||||
`${PUR_CONVERSION}.order_no = ${PUR}.order_no AND ${PUR_CONVERSION}.prod_no = ${PUR}.prod_no`,
|
||||
);
|
||||
|
||||
this.applyCommonFilters(qb, filter, {
|
||||
occurredAt: `${PUR}.ordered_at`,
|
||||
productIdx: `CAST(${PUR}.prod_no AS varchar)`,
|
||||
conversion: PUR_CONVERSION,
|
||||
searchColumns: [
|
||||
`${PUR_MEMBER}.name`,
|
||||
`${PUR}.member_uid`,
|
||||
`${PUR_MEMBER}.email`,
|
||||
`${PUR_MEMBER}.callnum`,
|
||||
`${PUR}.orderer_name`,
|
||||
`${PUR}.orderer_email`,
|
||||
`${PUR}.orderer_call`,
|
||||
],
|
||||
});
|
||||
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
|
||||
const expression =
|
||||
filter.inflowBasis === 'join'
|
||||
? joinInflow(PUR_MEMBER, filter.inflowField)
|
||||
: `${PUR}.inflow_${filter.inflowField}`;
|
||||
qb.andWhere(`${expression} = :inflowValue`, {
|
||||
inflowValue: filter.inflowValue,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 쪽이 같은 이름의 파라미터를 같은 값으로 쓴다 — UNION 으로 합칠 때 겹쳐도 문제없다.
|
||||
* (쪽마다 다른 값이 필요한 파라미터는 여기에 두지 않는다)
|
||||
*/
|
||||
private applyCommonFilters(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
filter: ListFilter,
|
||||
columns: {
|
||||
occurredAt: string;
|
||||
productIdx: string;
|
||||
conversion: string;
|
||||
searchColumns: string[];
|
||||
},
|
||||
): void {
|
||||
if (filter.productIdx) {
|
||||
qb.andWhere(`${columns.productIdx} = :productIdx`, {
|
||||
productIdx: filter.productIdx,
|
||||
});
|
||||
}
|
||||
if (filter.from) {
|
||||
qb.andWhere(`${columns.occurredAt} >= :from`, { from: filter.from });
|
||||
}
|
||||
if (filter.to) {
|
||||
qb.andWhere(`${columns.occurredAt} < :to`, { to: filter.to });
|
||||
}
|
||||
if (filter.partnerId === PARTNER_FILTER_NONE) {
|
||||
qb.andWhere(`${columns.conversion}.id IS NULL`);
|
||||
} else if (filter.partnerId) {
|
||||
qb.andWhere(`${columns.conversion}.partner_id = :partnerId`, {
|
||||
partnerId: filter.partnerId,
|
||||
});
|
||||
}
|
||||
if (filter.q?.trim()) {
|
||||
const conditions = columns.searchColumns
|
||||
.map((column) => `${column} ILIKE :q`)
|
||||
.join(' OR ');
|
||||
qb.andWhere(`(${conditions})`, {
|
||||
q: `%${escapeLike(filter.q.trim())}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 두 쪽을 UNION ALL 로 합친 결과(u)에서 시작하는 쿼리 */
|
||||
private fromUnion(
|
||||
parts: SelectQueryBuilder<ObjectLiteral>[],
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const sql = parts.map((part) => `(${part.getQuery()})`).join(' UNION ALL ');
|
||||
const parameters = Object.assign(
|
||||
{},
|
||||
...parts.map((part) => part.getParameters()),
|
||||
) as ObjectLiteral;
|
||||
return this.dataSource
|
||||
.createQueryBuilder()
|
||||
.from(`(${sql})`, UNION)
|
||||
.setParameters(parameters);
|
||||
}
|
||||
|
||||
private async membersByCode(
|
||||
codes: (string | null)[],
|
||||
): Promise<Map<string, ImwebMember>> {
|
||||
const unique = [...new Set(codes.filter((code): code is string => !!code))];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = await this.members.find({ where: { memberCode: In(unique) } });
|
||||
return new Map(rows.map((row) => [row.memberCode, row]));
|
||||
}
|
||||
|
||||
private async partnerNames(
|
||||
ids: (string | null)[],
|
||||
): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(ids.filter((id): id is string => !!id))];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = await this.partners.find({
|
||||
select: { id: true, name: true },
|
||||
where: { id: In(unique) },
|
||||
});
|
||||
return new Map(rows.map((row) => [row.id, row.name]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
|
||||
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
const SEARCH_MAX_LENGTH = 100;
|
||||
|
||||
/** 내역 종류 — 무료 신청 / 유료 구매 */
|
||||
export const ACTIVITY_TYPES = ['application', 'purchase'] as const;
|
||||
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
/** 업체 필터 — 업체 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;
|
||||
|
||||
/**
|
||||
* 유입을 어느 기록으로 볼 것인가.
|
||||
* - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일).
|
||||
* 주문완료 화면이 보내지 못한 구매는 값 없음
|
||||
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
|
||||
*/
|
||||
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
|
||||
export type ActivityInflowBasis = (typeof ACTIVITY_INFLOW_BASIS)[number];
|
||||
|
||||
/** 요약 묶음 기준 — 상품별 / 업체별 */
|
||||
export const ACTIVITY_GROUP_BY = ['product', 'partner'] as const;
|
||||
export type ActivityGroupBy = (typeof ACTIVITY_GROUP_BY)[number];
|
||||
|
||||
/** 기간 — 신청 시각 또는 주문 시각 */
|
||||
export class ActivityPeriodDto {
|
||||
@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 ActivityFilterDto extends ActivityPeriodDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'종류 — application(무료 신청) · purchase(유료 구매). 없으면 둘 다',
|
||||
enum: ACTIVITY_TYPES,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES, { message: '종류 값이 올바르지 않습니다.' })
|
||||
type?: ActivityType;
|
||||
|
||||
@ApiPropertyOptional({ description: '상품번호', example: '52' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
productIdx?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'업체 — 그 업체 링크 실적으로 잡힌 신청·구매만. none 이면 업체 링크를 거치지 않은 것',
|
||||
example: 'none',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
/** 상품별·업체별 신청·구매 수 — 기간만 걸러서 센다 */
|
||||
export class ActivityGroupsQueryDto extends ActivityPeriodDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
|
||||
enum: ACTIVITY_GROUP_BY,
|
||||
default: 'product',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
|
||||
by: ActivityGroupBy = 'product';
|
||||
}
|
||||
|
||||
/** 관리자 신청·구매 내역 조회 */
|
||||
export class ActivityListQueryDto extends IntersectionType(
|
||||
CursorPaginationQueryDto,
|
||||
ActivityFilterDto,
|
||||
) {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
|
||||
enum: ACTIVITY_INFLOW_BASIS,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
|
||||
inflowBasis?: ActivityInflowBasis;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 항목 — source · medium · campaign · content',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
|
||||
inflowField?: InflowGroupBy;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
inflowValue?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'검색어 — 이름·아이디·이메일·연락처 부분 일치 (비회원은 주문자 정보)',
|
||||
example: '홍길동',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/** 유입 집계 — 신청 때 유입 또는 가입 경로 기준 */
|
||||
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)',
|
||||
enum: ACTIVITY_INFLOW_BASIS,
|
||||
default: 'apply',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
|
||||
basis: ActivityInflowBasis = 'apply';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '집계 항목',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
default: 'source',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
|
||||
groupBy: InflowGroupBy = 'source';
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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 {
|
||||
ACTIVITY_GROUP_BY,
|
||||
ACTIVITY_INFLOW_BASIS,
|
||||
ACTIVITY_TYPES,
|
||||
} from './activity-query.dto';
|
||||
import type {
|
||||
ActivityGroupBy,
|
||||
ActivityInflowBasis,
|
||||
ActivityType,
|
||||
} from './activity-query.dto';
|
||||
|
||||
/** 신청 순간의 유입정보 */
|
||||
export class ActivityInflowDto {
|
||||
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
|
||||
source!: string | null;
|
||||
|
||||
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
|
||||
medium!: string | null;
|
||||
|
||||
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
|
||||
campaign!: string | null;
|
||||
|
||||
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
|
||||
content!: string | null;
|
||||
}
|
||||
|
||||
/** 신청·구매를 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
|
||||
export class ActivityPartnerDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** 주문서의 주문자 — 비회원 구매는 이 값으로만 누군지 안다 */
|
||||
export class ActivityOrdererDto {
|
||||
@ApiProperty({ description: '주문자 이름', nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ description: '주문자 이메일', nullable: true })
|
||||
email!: string | null;
|
||||
|
||||
@ApiProperty({ description: '주문자 연락처', nullable: true })
|
||||
phone!: string | null;
|
||||
}
|
||||
|
||||
/** 관리자 신청·구매 내역 한 건 */
|
||||
export class ActivityResponseDto {
|
||||
@ApiProperty({
|
||||
description: '종류 — application(무료 신청) · purchase(유료 구매)',
|
||||
enum: ACTIVITY_TYPES,
|
||||
})
|
||||
type!: ActivityType;
|
||||
|
||||
@ApiProperty({ description: '신청 기록 ID 또는 구매 기록 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 시각 또는 주문 시각',
|
||||
example: '2026-10-05T03:00:00.000Z',
|
||||
})
|
||||
occurredAt!: string;
|
||||
|
||||
@ApiProperty({ description: '상품번호', example: '52' })
|
||||
productIdx!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상품명 (신청 화면 표시값 또는 주문 당시 상품명)',
|
||||
nullable: true,
|
||||
})
|
||||
productName!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청·주문 당시 아이디 — 비회원 구매면 null',
|
||||
nullable: true,
|
||||
})
|
||||
memberUid!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'회원 정보 (동기화된 회원 기준) — 비회원·탈퇴 등으로 없으면 null',
|
||||
type: MemberResponseDto,
|
||||
nullable: true,
|
||||
})
|
||||
member!: MemberResponseDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업체 링크 실적으로 잡힌 경우 그 업체 — 아니면 null',
|
||||
type: ActivityPartnerDto,
|
||||
nullable: true,
|
||||
})
|
||||
partner!: ActivityPartnerDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'[신청] 오픈카톡 버튼을 처음 누른 시각 — 안 눌렀거나 구매면 null',
|
||||
nullable: true,
|
||||
})
|
||||
kakaoClickedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null',
|
||||
type: ActivityInflowDto,
|
||||
})
|
||||
inflow!: ActivityInflowDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[구매] 아임웹 주문번호 — 신청이면 null',
|
||||
nullable: true,
|
||||
})
|
||||
orderNo!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[구매] 환불·취소·반품됐는가 — 신청이면 null',
|
||||
nullable: true,
|
||||
})
|
||||
isCancelled!: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[구매] 주문자 — 신청이면 null',
|
||||
type: ActivityOrdererDto,
|
||||
nullable: true,
|
||||
})
|
||||
orderer!: ActivityOrdererDto | null;
|
||||
}
|
||||
|
||||
/** 신청·구매 수 */
|
||||
export class ActivityCountsDto {
|
||||
@ApiProperty({ description: '무료 신청 수', example: 120 })
|
||||
applicationCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그중 오픈카톡 버튼을 누른 수 (실제 입장 여부는 알 수 없다)',
|
||||
example: 87,
|
||||
})
|
||||
kakaoCount!: number;
|
||||
|
||||
@ApiProperty({ description: '유료 구매 수 (환불·취소 제외)', example: 12 })
|
||||
purchaseCount!: number;
|
||||
|
||||
@ApiProperty({ description: '환불·취소된 구매 수', example: 1 })
|
||||
cancelledCount!: number;
|
||||
}
|
||||
|
||||
/** 상품별·업체별 한 줄 */
|
||||
export class ActivityGroupItemDto extends ActivityCountsDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 것은 null',
|
||||
nullable: true,
|
||||
example: '52',
|
||||
})
|
||||
key!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'표시 이름 — 상품명(아임웹 기준, 없으면 가장 최근 표시값) 또는 업체 이름',
|
||||
nullable: true,
|
||||
})
|
||||
label!: string | null;
|
||||
}
|
||||
|
||||
/** 상품별·업체별 신청·구매 수 */
|
||||
export class ActivityGroupsResponseDto {
|
||||
@ApiProperty({
|
||||
description: '묶음 기준',
|
||||
enum: ACTIVITY_GROUP_BY,
|
||||
example: 'product',
|
||||
})
|
||||
by!: ActivityGroupBy;
|
||||
|
||||
@ApiProperty({ description: '기간 내 전체', type: ActivityCountsDto })
|
||||
totals!: ActivityCountsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청·구매가 많은 순 (최대 100개)',
|
||||
type: [ActivityGroupItemDto],
|
||||
})
|
||||
items!: ActivityGroupItemDto[];
|
||||
}
|
||||
|
||||
/** 유입 집계 한 줄 */
|
||||
export class ActivityInflowStatItemDto {
|
||||
@ApiProperty({
|
||||
description: '집계 값 (값이 없으면 null)',
|
||||
nullable: true,
|
||||
example: 'facebook',
|
||||
})
|
||||
key!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청·구매 수', example: 42 })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 유입 집계 결과 */
|
||||
export class ActivityInflowStatsResponseDto {
|
||||
@ApiProperty({ description: '집계 기준', enum: ACTIVITY_INFLOW_BASIS })
|
||||
basis!: ActivityInflowBasis;
|
||||
|
||||
@ApiProperty({ description: '집계 항목', enum: INFLOW_GROUP_BY })
|
||||
groupBy!: InflowGroupBy;
|
||||
|
||||
@ApiProperty({ description: '집계한 무료 신청 수', example: 300 })
|
||||
applicationTotal!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '집계한 유료 구매 수 (환불·취소 제외)',
|
||||
example: 10,
|
||||
})
|
||||
purchaseTotal!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '많은 순 (최대 100개, 값 없음은 key=null)',
|
||||
type: [ActivityInflowStatItemDto],
|
||||
})
|
||||
items!: ActivityInflowStatItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
|
||||
/**
|
||||
* 업체 계정 — 최고관리자가 업체마다 만들어 주는 로그인 계정.
|
||||
*
|
||||
* 최고관리자는 DB 계정이 아니라 env(ADMIN_USERNAME / ADMIN_PASSWORD) 계정이다.
|
||||
* 업체 계정은 자기 업체의 링크·실적만 볼 수 있다 (PartnerAuthGuard).
|
||||
* 지우지 않고 isActive=false 로 중지한다 — 업체를 지우면 그 업체의 계정도 함께 지워진다.
|
||||
*/
|
||||
@Entity({ name: 'admin_accounts' })
|
||||
@Index('idx_admin_accounts_partner', ['partnerId', 'createdAt'])
|
||||
export class AdminAccount {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 로그인 아이디 — 소문자로 저장한다 (대소문자 구분 없이 로그인) */
|
||||
@Index('uq_admin_accounts_username', { unique: true })
|
||||
@Column({ name: 'username', type: 'varchar', length: 50 })
|
||||
username!: string;
|
||||
|
||||
/** bcrypt 해시 — 평문은 어디에도 남기지 않는다 */
|
||||
@Column({ name: 'password_hash', type: 'varchar', length: 100 })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ name: 'partner_id', type: 'uuid' })
|
||||
partnerId!: string;
|
||||
|
||||
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'partner_id' })
|
||||
partner?: Partner;
|
||||
|
||||
/** 사용 여부 — 중지하면 로그인할 수 없고 열려 있던 세션도 끊긴다 */
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** 서버가 만든 임시 비밀번호인가 — 바꾸기 전에는 실적을 볼 수 없다 (ACCOUNT_003) */
|
||||
@Column({ name: 'must_change_password', type: 'boolean', default: true })
|
||||
mustChangePassword!: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -28,16 +28,35 @@ export const ADMIN_PASSWORD_MIN_LENGTH = 12;
|
||||
/** 로그인 시도 제한 — 1분에 5회 (브루트포스 방지, 전역 한도보다 엄격하게) */
|
||||
export const LOGIN_THROTTLE = { default: { ttl: 60_000, limit: 5 } };
|
||||
|
||||
/**
|
||||
* 계정 역할.
|
||||
* - super : 최고관리자 — env 계정. 모든 관리 기능 + 업체 계정 관리
|
||||
* - partner : 업체 계정 — DB(admin_accounts). 자기 업체의 링크·실적만
|
||||
*/
|
||||
export const ACCOUNT_ROLES = ['super', 'partner'] as const;
|
||||
export type AccountRole = (typeof ACCOUNT_ROLES)[number];
|
||||
|
||||
/** 업체 계정 비밀번호 — 최소 길이 (bcrypt 가 72바이트까지만 쓰므로 최대는 72) */
|
||||
export const ACCOUNT_PASSWORD_MIN_LENGTH = 10;
|
||||
export const ACCOUNT_PASSWORD_MAX_LENGTH = 72;
|
||||
/**
|
||||
* bcrypt 비용 — OWASP 최소 권장값. 순수 JS 구현(bcryptjs)이라 12 면 한 번에 1초 가까이 걸려
|
||||
* 로그인 실패마다 서버 CPU 를 오래 쓴다. 무차별 대입은 로그인 요청 제한(LOGIN_THROTTLE)이 함께 막는다
|
||||
*/
|
||||
export const BCRYPT_ROUNDS = 10;
|
||||
|
||||
/** Swagger 의 Bearer 인증 이름 (main.ts 의 addBearerAuth 기본값) */
|
||||
export const ADMIN_BEARER_AUTH = 'bearer';
|
||||
|
||||
export type AdminTokenType = 'access' | 'refresh';
|
||||
|
||||
export interface AdminTokenPayload {
|
||||
/** 관리자 아이디 (env ADMIN_USERNAME) */
|
||||
/** 최고관리자는 env 아이디, 업체 계정은 계정 ID(admin_accounts.id) */
|
||||
sub: string;
|
||||
typ: AdminTokenType;
|
||||
/** 계정 지문 — env 아이디·비밀번호가 바뀌면 달라져 모든 토큰이 무효가 된다 */
|
||||
/** 역할 — 예전 토큰(역할 없음)은 최고관리자 토큰이다 */
|
||||
role?: AccountRole;
|
||||
/** 계정 지문 — 아이디·비밀번호가 바뀌면 달라져 그 계정의 모든 토큰이 무효가 된다 */
|
||||
cred: string;
|
||||
/** AdminSession.id — 로그아웃은 이 세션만 끊는다 (공유 계정의 다른 기기는 유지) */
|
||||
sid: string;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import { AccountAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
|
||||
@@ -29,7 +29,7 @@ import { AdminSessionDto, RenewSessionDto } from './dto/admin-session.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 관리자 세션 — 로그인·갱신·로그아웃.
|
||||
* 로그인 세션 — 최고관리자(env 계정)·업체 계정 공통. 로그인·갱신·로그아웃.
|
||||
* 관리자 화면과 API 가 다른 사이트라 쿠키를 쓸 수 없어 토큰을 본문으로 내려준다
|
||||
* (이유와 보완책은 admin-auth.constants.ts 참조).
|
||||
*/
|
||||
@@ -40,7 +40,10 @@ export class AdminAuthController {
|
||||
|
||||
@Post()
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@ApiOperation({ summary: '관리자 로그인 — 액세스·리프레시 토큰 발급' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'로그인 — 최고관리자(env 계정) 또는 업체 계정. 액세스·리프레시 토큰 발급',
|
||||
})
|
||||
@ApiStandardResponse(AdminSessionDto, {
|
||||
status: 201,
|
||||
description: '로그인 성공',
|
||||
@@ -66,9 +69,12 @@ export class AdminAuthController {
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@ApiOperation({ summary: '현재 로그인한 관리자' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'현재 로그인한 계정 — 역할(최고관리자·업체)과 업체를 함께 돌려준다',
|
||||
})
|
||||
@ApiStandardResponse(AdminResponseDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
current(@CurrentAdmin() admin: AdminResponseDto): AdminResponseDto {
|
||||
@@ -76,7 +82,7 @@ export class AdminAuthController {
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
|
||||
@@ -3,14 +3,28 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
PartnerAuthGuard,
|
||||
} from '@/common/guards/admin-auth.guard';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminSession } from './admin-session.entity';
|
||||
import {
|
||||
AccountPasswordController,
|
||||
PartnerAccountsController,
|
||||
} from './partner-accounts.controller';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
const GUARDS = [AdminAuthGuard, PartnerAuthGuard, AccountAuthGuard];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AdminSession]),
|
||||
// 업체 계정이 업체에 속하는지 확인한다 (PartnersModule 을 가져오면 순환 의존이 되어 엔티티만 쓴다)
|
||||
TypeOrmModule.forFeature([AdminSession, AdminAccount, Partner]),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
// 운영에서는 env 검증이 JWT_SECRET 을 필수로 요구한다.
|
||||
@@ -20,9 +34,13 @@ import { AdminSession } from './admin-session.entity';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AdminAuthController],
|
||||
providers: [AdminAuthService, AdminAuthGuard],
|
||||
// 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다
|
||||
exports: [AdminAuthService, AdminAuthGuard],
|
||||
controllers: [
|
||||
AdminAuthController,
|
||||
PartnerAccountsController,
|
||||
AccountPasswordController,
|
||||
],
|
||||
providers: [AdminAuthService, PartnerAccountsService, ...GUARDS],
|
||||
// 다른 모듈의 컨트롤러가 @UseGuards(...) 를 쓰려면 서비스와 가드가 필요하다
|
||||
exports: [AdminAuthService, ...GUARDS],
|
||||
})
|
||||
export class AdminAuthModule {}
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { hashSync } from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { FindOperator } from 'typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminSession } from './admin-session.entity';
|
||||
|
||||
const USERNAME = 'moneyguider';
|
||||
const PASSWORD = 'correct-horse-battery';
|
||||
const SECRET = 'test-secret';
|
||||
/** 테스트 픽스처는 bcrypt 비용을 낮춰 빠르게 만든다 (비교는 비용과 무관하게 동작한다) */
|
||||
const FIXTURE_ROUNDS = 4;
|
||||
const PARTNER = { id: 'partner-1', name: '머니튜브' } as Partner;
|
||||
const PARTNER_USERNAME = 'moneytube';
|
||||
const PARTNER_PASSWORD = 'temporary-pass-1';
|
||||
|
||||
function setup(env: Record<string, string> = {}) {
|
||||
function partnerAccount(overrides: Partial<AdminAccount> = {}): AdminAccount {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
username: PARTNER_USERNAME,
|
||||
passwordHash: hashSync(PARTNER_PASSWORD, FIXTURE_ROUNDS),
|
||||
partnerId: PARTNER.id,
|
||||
partner: PARTNER,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** update 조건 흉내 — id(값 또는 Not), accountId 로 고른다 */
|
||||
function matches(row: AdminSession, where: Record<string, unknown>): boolean {
|
||||
const { id, accountId } = where;
|
||||
if (typeof id === 'string' && row.id !== id) return false;
|
||||
if (id instanceof FindOperator && id.type === 'not' && row.id === id.value) {
|
||||
return false;
|
||||
}
|
||||
if (typeof accountId === 'string' && row.accountId !== accountId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup(
|
||||
env: Record<string, string> = {},
|
||||
accountRows: AdminAccount[] = [],
|
||||
) {
|
||||
// env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오)
|
||||
const values: Record<string, string> = {
|
||||
ADMIN_USERNAME: USERNAME,
|
||||
@@ -32,20 +72,46 @@ function setup(env: Record<string, string> = {}) {
|
||||
findOne: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(store.get(where.id) ?? null),
|
||||
),
|
||||
update: jest.fn(({ id }: { id: string }, patch: Partial<AdminSession>) => {
|
||||
const row = store.get(id);
|
||||
update: jest.fn(
|
||||
(where: Record<string, unknown>, patch: Partial<AdminSession>) => {
|
||||
for (const row of store.values()) {
|
||||
if (matches(row, where)) Object.assign(row, patch);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
};
|
||||
|
||||
// 업체 계정 저장소 — 메모리
|
||||
const accountStore = new Map(accountRows.map((row) => [row.id, row]));
|
||||
const accounts = {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: { id?: string; username?: string } }) =>
|
||||
Promise.resolve(
|
||||
[...accountStore.values()].find((row) =>
|
||||
where.id ? row.id === where.id : row.username === where.username,
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((id: string, patch: Partial<AdminAccount>) => {
|
||||
const row = accountStore.get(id);
|
||||
if (row) Object.assign(row, patch);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
save: jest.fn((row: AdminAccount) => {
|
||||
accountStore.set(row.id, row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new AdminAuthService(
|
||||
sessions as unknown as Repository<AdminSession>,
|
||||
accounts as unknown as Repository<AdminAccount>,
|
||||
new JwtService({ secret: SECRET }),
|
||||
config as unknown as ConfigService,
|
||||
);
|
||||
return { service, store, values };
|
||||
return { service, store, values, accountStore };
|
||||
}
|
||||
|
||||
describe('AdminAuthService (env 단일 계정)', () => {
|
||||
@@ -54,7 +120,12 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
|
||||
const session = await service.login(USERNAME, PASSWORD, 'Mozilla/5.0');
|
||||
|
||||
expect(session.admin).toEqual({ username: USERNAME });
|
||||
expect(session.admin).toEqual({
|
||||
username: USERNAME,
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
});
|
||||
expect(Date.parse(session.refreshTokenExpiresAt)).toBeGreaterThan(
|
||||
Date.parse(session.accessTokenExpiresAt),
|
||||
);
|
||||
@@ -63,8 +134,11 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
|
||||
const verified = await service.verifyAccessToken(session.accessToken);
|
||||
expect(verified).toEqual({
|
||||
admin: { username: USERNAME },
|
||||
admin: session.admin,
|
||||
sessionId: [...store.keys()][0],
|
||||
role: 'super',
|
||||
accountId: null,
|
||||
partnerId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,3 +280,121 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminAuthService (업체 계정)', () => {
|
||||
it('업체 계정으로 로그인하면 업체 역할·업체·임시 비밀번호 여부를 돌려준다 (아이디 대소문자 무시)', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service, store } = setup({}, [account]);
|
||||
|
||||
const session = await service.login('MoneyTube', PARTNER_PASSWORD);
|
||||
|
||||
expect(session.admin).toEqual({
|
||||
username: PARTNER_USERNAME,
|
||||
role: 'partner',
|
||||
partner: { id: PARTNER.id, name: PARTNER.name },
|
||||
mustChangePassword: true,
|
||||
});
|
||||
expect([...store.values()][0].accountId).toBe(account.id);
|
||||
expect(account.lastLoginAt).toBeInstanceOf(Date);
|
||||
await expect(
|
||||
service.verifyAccessToken(session.accessToken),
|
||||
).resolves.toMatchObject({
|
||||
role: 'partner',
|
||||
accountId: account.id,
|
||||
partnerId: PARTNER.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리거나 중지된 계정·없는 아이디는 같은 AUTH_001, 세션을 만들지 않는다', async () => {
|
||||
const inactive = partnerAccount({ username: 'stopped', isActive: false });
|
||||
const { service, store } = setup({}, [partnerAccount(), inactive]);
|
||||
|
||||
for (const [username, password] of [
|
||||
[PARTNER_USERNAME, 'wrong-password'],
|
||||
['stopped', PARTNER_PASSWORD],
|
||||
['nobody', PARTNER_PASSWORD],
|
||||
]) {
|
||||
await expect(service.login(username, password)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
}
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
it('계정을 중지하면 이미 발급한 토큰도 바로 거부한다', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service } = setup({}, [account]);
|
||||
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
|
||||
account.isActive = false;
|
||||
|
||||
await expect(
|
||||
service.verifyAccessToken(session.accessToken),
|
||||
).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
await expect(service.renew(session.refreshToken)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾸면 이 기기는 새 토큰으로 이어 쓰고 다른 기기는 끊긴다', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service } = setup({}, [account]);
|
||||
const deviceA = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const deviceB = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const verifiedA = await service.verifyAccessToken(deviceA.accessToken);
|
||||
|
||||
const renewed = await service.changePassword(
|
||||
verifiedA,
|
||||
PARTNER_PASSWORD,
|
||||
'my-own-new-password',
|
||||
);
|
||||
|
||||
expect(renewed.admin.mustChangePassword).toBe(false);
|
||||
await expect(
|
||||
service.verifyAccessToken(renewed.accessToken),
|
||||
).resolves.toMatchObject({ sessionId: verifiedA.sessionId });
|
||||
await expect(
|
||||
service.verifyAccessToken(deviceB.accessToken),
|
||||
).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
await expect(
|
||||
service.login(PARTNER_USERNAME, 'my-own-new-password'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('현재 비밀번호가 틀리면 ACCOUNT_002, 최고관리자는 AUTH_003 (env 에서 바꾼다)', async () => {
|
||||
const { service } = setup({}, [partnerAccount()]);
|
||||
const partner = await service.verifyAccessToken(
|
||||
(await service.login(PARTNER_USERNAME, PARTNER_PASSWORD)).accessToken,
|
||||
);
|
||||
const admin = await service.verifyAccessToken(
|
||||
(await service.login(USERNAME, PASSWORD)).accessToken,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.changePassword(partner, 'wrong-current', 'my-own-new-password'),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_002' });
|
||||
await expect(
|
||||
service.changePassword(admin, PASSWORD, 'my-own-new-password'),
|
||||
).rejects.toMatchObject({ code: 'AUTH_003' });
|
||||
});
|
||||
|
||||
it('업체 토큰의 역할을 최고관리자로 바꿔 쓸 수 없다', async () => {
|
||||
const { service } = setup({}, [partnerAccount()]);
|
||||
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(session.accessToken.split('.')[1], 'base64url').toString(),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// 서명키를 아는 상황을 가정해도, 업체 지문으로는 최고관리자 확인을 통과하지 못한다
|
||||
const forged = new JwtService({ secret: SECRET }).sign({
|
||||
sub: USERNAME,
|
||||
typ: 'access',
|
||||
role: 'super',
|
||||
cred: payload.cred,
|
||||
sid: payload.sid,
|
||||
});
|
||||
await expect(service.verifyAccessToken(forged)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { IsNull, LessThan, Not, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { credentialFingerprint, safeEqual } from '@/shared/utils/credential';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import {
|
||||
ADMIN_PASSWORD_KEY,
|
||||
ADMIN_USERNAME_KEY,
|
||||
AccountRole,
|
||||
AdminTokenPayload,
|
||||
AdminTokenType,
|
||||
BCRYPT_ROUNDS,
|
||||
accessTokenTtlMs,
|
||||
refreshTokenTtlMs,
|
||||
} from './admin-auth.constants';
|
||||
@@ -25,54 +29,61 @@ const USER_AGENT_MAX_LENGTH = 200;
|
||||
const ENDED_SESSION_RETENTION_MS = 7 * 86_400_000;
|
||||
|
||||
/** 가드가 확인한 요청의 주인 */
|
||||
export interface VerifiedAdmin {
|
||||
export interface VerifiedAccount {
|
||||
admin: AdminResponseDto;
|
||||
sessionId: string;
|
||||
role: AccountRole;
|
||||
/** [업체 계정] 계정 ID — 최고관리자면 null */
|
||||
accountId: string | null;
|
||||
/** [업체 계정] 업체 ID — 최고관리자면 null. 업체 화면은 이 값으로만 데이터를 거른다 */
|
||||
partnerId: string | null;
|
||||
}
|
||||
|
||||
interface AdminAccount {
|
||||
username: string;
|
||||
password: string;
|
||||
/** 현재 env 계정의 지문 — 토큰의 cred 와 같아야 유효하다 */
|
||||
fingerprint: string;
|
||||
/** 토큰의 주인 — 최고관리자(env) 또는 업체 계정(DB) */
|
||||
type Principal =
|
||||
| { role: 'super'; username: string; fingerprint: string }
|
||||
| { role: 'partner'; account: AdminAccount; fingerprint: string };
|
||||
|
||||
/** 아이디는 대소문자를 가리지 않는다 — 저장·조회 모두 소문자로 */
|
||||
export function normalizeUsername(username: string): string {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 인증 — env(ADMIN_USERNAME / ADMIN_PASSWORD)에 둔 단일 계정.
|
||||
* 계정 인증 — 최고관리자(env ADMIN_USERNAME / ADMIN_PASSWORD)와 업체 계정(admin_accounts).
|
||||
*
|
||||
* 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
|
||||
* 최고관리자 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
|
||||
* - 로그아웃: 그 기기의 세션만 끊는다
|
||||
* - env 비밀번호를 바꾸고 재시작: 계정 지문이 달라져 모든 기기가 끊긴다
|
||||
* - 비밀번호 변경(env 는 재시작): 계정 지문이 달라져 그 계정의 모든 기기가 끊긴다
|
||||
* - 업체 계정 중지: 그 계정의 세션을 모두 끊는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
private readonly logger = new Logger(AdminAuthService.name);
|
||||
/** 없는 아이디도 bcrypt 비교를 거치게 하는 해시 — 응답 시간으로 아이디 존재를 알 수 없게 */
|
||||
private dummyHash: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminSession)
|
||||
private readonly sessions: Repository<AdminSession>,
|
||||
@InjectRepository(AdminAccount)
|
||||
private readonly accounts: Repository<AdminAccount>,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */
|
||||
/**
|
||||
* 아이디·비밀번호로 새 세션을 연다 — env 최고관리자 계정을 먼저 보고, 아니면 업체 계정.
|
||||
* 실패 원인(아이디 없음·비밀번호 틀림·중지됨)은 구분하지 않는다.
|
||||
*/
|
||||
async login(
|
||||
username: string,
|
||||
password: string,
|
||||
userAgent?: string,
|
||||
): Promise<AdminSessionDto> {
|
||||
const account = this.account();
|
||||
// 둘 다 항상 비교해 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 한다
|
||||
const usernameMatched = safeEqual(username, account?.username ?? '');
|
||||
const passwordMatched = safeEqual(password, account?.password ?? '');
|
||||
|
||||
if (!account || !usernameMatched || !passwordMatched) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: account ? 'invalid_credentials' : 'admin_not_configured',
|
||||
},
|
||||
});
|
||||
}
|
||||
const principal =
|
||||
this.matchSuperAdmin(username, password) ??
|
||||
(await this.matchPartnerAccount(username, password));
|
||||
|
||||
await this.removeEndedSessions();
|
||||
|
||||
@@ -80,52 +91,172 @@ export class AdminAuthService {
|
||||
const session = await this.sessions.save(
|
||||
this.sessions.create({
|
||||
id: randomUUID(),
|
||||
accountId: principal.role === 'partner' ? principal.account.id : null,
|
||||
userAgent: userAgent?.slice(0, USER_AGENT_MAX_LENGTH) || null,
|
||||
lastUsedAt: new Date(now),
|
||||
expiresAt: new Date(now + refreshTokenTtlMs()),
|
||||
revokedAt: null,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`관리자 로그인: session=${session.id}`);
|
||||
if (principal.role === 'partner') {
|
||||
await this.accounts.update(principal.account.id, {
|
||||
lastLoginAt: new Date(now),
|
||||
});
|
||||
this.logger.log(
|
||||
`업체 계정 로그인: account=${principal.account.id} session=${session.id}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`관리자 로그인: session=${session.id}`);
|
||||
}
|
||||
|
||||
return this.issue(account, session.id, now);
|
||||
return this.issue(principal, session.id, now);
|
||||
}
|
||||
|
||||
/** 리프레시 토큰으로 같은 세션의 새 토큰 쌍을 발급하고 세션 만료를 늦춘다. */
|
||||
async renew(refreshToken: string): Promise<AdminSessionDto> {
|
||||
const payload = await this.verify(refreshToken, 'refresh');
|
||||
const { account, session } = await this.findActive(payload);
|
||||
const { principal, session } = await this.findActive(payload);
|
||||
|
||||
const now = Date.now();
|
||||
session.lastUsedAt = new Date(now);
|
||||
session.expiresAt = new Date(now + refreshTokenTtlMs());
|
||||
await this.sessions.save(session);
|
||||
|
||||
return this.issue(account, session.id, now);
|
||||
return this.issue(principal, session.id, now);
|
||||
}
|
||||
|
||||
/** 로그아웃 — 이 세션만 끊는다. 같은 계정으로 로그인한 다른 기기는 그대로다. */
|
||||
async logout(sessionId: string): Promise<void> {
|
||||
await this.sessions.update({ id: sessionId }, { revokedAt: new Date() });
|
||||
this.logger.log(`관리자 로그아웃: session=${sessionId}`);
|
||||
this.logger.log(`로그아웃: session=${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰을 검증한다 — 가드에서 쓴다.
|
||||
* 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다.
|
||||
* 서명·만료뿐 아니라 세션과 계정 지문·계정 상태도 확인해, 로그아웃·비밀번호 변경·중지가 바로 반영된다.
|
||||
* (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다)
|
||||
*/
|
||||
async verifyAccessToken(token: string | null): Promise<VerifiedAdmin> {
|
||||
async verifyAccessToken(token: string | null): Promise<VerifiedAccount> {
|
||||
const payload = await this.verify(token, 'access');
|
||||
const { account, session } = await this.findActive(payload);
|
||||
return { admin: { username: account.username }, sessionId: session.id };
|
||||
const { principal, session } = await this.findActive(payload);
|
||||
return {
|
||||
admin: this.toResponse(principal),
|
||||
sessionId: session.id,
|
||||
role: principal.role,
|
||||
accountId: principal.role === 'partner' ? principal.account.id : null,
|
||||
partnerId:
|
||||
principal.role === 'partner' ? principal.account.partnerId : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** env 계정. 아이디·비밀번호가 비어 있으면 null — 아무도 로그인할 수 없다. */
|
||||
private account(): AdminAccount | null {
|
||||
/**
|
||||
* 업체 계정 비밀번호 변경. 바꾸면 계정 지문이 달라져 다른 기기의 세션은 끊기고,
|
||||
* 지금 세션에는 새 토큰을 발급해 그대로 이어서 쓰게 한다.
|
||||
*/
|
||||
async changePassword(
|
||||
verified: VerifiedAccount,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<AdminSessionDto> {
|
||||
if (verified.role !== 'partner' || !verified.accountId) {
|
||||
// 최고관리자 비밀번호는 env 에서 바꾼다
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'super_admin_password_in_env' },
|
||||
});
|
||||
}
|
||||
const account = await this.accounts.findOne({
|
||||
where: { id: verified.accountId },
|
||||
relations: { partner: true },
|
||||
});
|
||||
if (!account) throw this.rejected('account_not_found');
|
||||
if (!(await compare(currentPassword, account.passwordHash))) {
|
||||
throw new BusinessException('ACCOUNT_002', {
|
||||
context: { accountId: account.id },
|
||||
});
|
||||
}
|
||||
|
||||
account.passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
|
||||
account.mustChangePassword = false;
|
||||
await this.accounts.save(account);
|
||||
// 다른 기기의 세션은 지문이 달라져 어차피 무효다 — 기록도 끊긴 것으로 남긴다
|
||||
await this.sessions.update(
|
||||
{
|
||||
accountId: account.id,
|
||||
id: Not(verified.sessionId),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
this.logger.log(`업체 계정 비밀번호 변경: account=${account.id}`);
|
||||
|
||||
return this.issue(
|
||||
{ role: 'partner', account, fingerprint: this.fingerprintOf(account) },
|
||||
verified.sessionId,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 업체 계정의 열린 세션을 모두 끊는다 — 계정 중지·비밀번호 초기화 때 */
|
||||
async revokeAccountSessions(accountId: string): Promise<void> {
|
||||
await this.sessions.update(
|
||||
{ accountId, revokedAt: IsNull() },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
/** env 최고관리자 계정과 맞으면 그 주인. 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 둘 다 비교한다 */
|
||||
private matchSuperAdmin(
|
||||
username: string,
|
||||
password: string,
|
||||
): Principal | null {
|
||||
const account = this.superAccount();
|
||||
const usernameMatched = safeEqual(username, account?.username ?? '');
|
||||
const passwordMatched = safeEqual(password, account?.password ?? '');
|
||||
if (!account || !usernameMatched || !passwordMatched) return null;
|
||||
return {
|
||||
role: 'super',
|
||||
username: account.username,
|
||||
fingerprint: account.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
/** 업체 계정과 맞으면 그 주인, 아니면 AUTH_001. 없는 아이디도 bcrypt 비교를 거친다 */
|
||||
private async matchPartnerAccount(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Principal> {
|
||||
const account = await this.accounts.findOne({
|
||||
where: { username: normalizeUsername(username) },
|
||||
relations: { partner: true },
|
||||
});
|
||||
const matched = await compare(
|
||||
password,
|
||||
account?.passwordHash ?? (await this.getDummyHash()),
|
||||
);
|
||||
if (!account || !matched || !account.isActive || !account.partner) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason:
|
||||
account && matched ? 'account_inactive' : 'invalid_credentials',
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: 'partner',
|
||||
account,
|
||||
fingerprint: this.fingerprintOf(account),
|
||||
};
|
||||
}
|
||||
|
||||
/** env 최고관리자 계정. 아이디·비밀번호·서명키 중 하나라도 비어 있으면 null — 최고관리자 로그인 불가 */
|
||||
private superAccount(): {
|
||||
username: string;
|
||||
password: string;
|
||||
fingerprint: string;
|
||||
} | null {
|
||||
const username = (this.config.get<string>(ADMIN_USERNAME_KEY) ?? '').trim();
|
||||
const password = this.config.get<string>(ADMIN_PASSWORD_KEY) ?? '';
|
||||
const secret = this.config.get<string>('JWT_SECRET') ?? '';
|
||||
const secret = this.secret();
|
||||
if (!username || !password || !secret) {
|
||||
this.logger.warn(
|
||||
`관리자 계정이 설정되지 않았습니다 — backend/.env 의 ${ADMIN_USERNAME_KEY}·${ADMIN_PASSWORD_KEY}·JWT_SECRET 을 확인하세요`,
|
||||
@@ -139,29 +270,84 @@ export class AdminAuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 업체 계정 지문 — 비밀번호(해시)가 바뀌면 달라진다. 비밀키 HMAC 이라 토큰으로 해시를 알 수 없다 */
|
||||
private fingerprintOf(account: AdminAccount): string {
|
||||
return credentialFingerprint(
|
||||
account.id,
|
||||
account.passwordHash,
|
||||
this.secret(),
|
||||
);
|
||||
}
|
||||
|
||||
private secret(): string {
|
||||
return this.config.get<string>('JWT_SECRET') ?? '';
|
||||
}
|
||||
|
||||
private getDummyHash(): Promise<string> {
|
||||
this.dummyHash ??= hash(randomUUID(), BCRYPT_ROUNDS);
|
||||
return this.dummyHash;
|
||||
}
|
||||
|
||||
/** 토큰이 가리키는 세션과 계정이 아직 유효한지 확인한다. */
|
||||
private async findActive(
|
||||
payload: AdminTokenPayload,
|
||||
): Promise<{ account: AdminAccount; session: AdminSession }> {
|
||||
const account = this.account();
|
||||
// env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다
|
||||
): Promise<{ principal: Principal; session: AdminSession }> {
|
||||
if (typeof payload.cred !== 'string') {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
const principal =
|
||||
(payload.role ?? 'super') === 'super'
|
||||
? this.activeSuperAdmin(payload)
|
||||
: await this.activePartnerAccount(payload);
|
||||
|
||||
if (!payload.sid) throw this.rejected('no_session');
|
||||
const session = await this.sessions.findOne({ where: { id: payload.sid } });
|
||||
if (!session) throw this.rejected('no_session');
|
||||
// 다른 계정의 세션을 빌려 쓰지 못하게 한다
|
||||
const expectedAccountId =
|
||||
principal.role === 'partner' ? principal.account.id : null;
|
||||
if (session.accountId !== expectedAccountId) {
|
||||
throw this.rejected('session_account_mismatch');
|
||||
}
|
||||
if (session.revokedAt) throw this.rejected('logged_out');
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
throw this.rejected('session_expired');
|
||||
}
|
||||
return { principal, session };
|
||||
}
|
||||
|
||||
/** env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다 */
|
||||
private activeSuperAdmin(payload: AdminTokenPayload): Principal {
|
||||
const account = this.superAccount();
|
||||
if (
|
||||
!account ||
|
||||
typeof payload.cred !== 'string' ||
|
||||
payload.sub !== account.username ||
|
||||
!safeEqual(payload.cred, account.fingerprint)
|
||||
) {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
if (!payload.sid) throw this.rejected('no_session');
|
||||
return {
|
||||
role: 'super',
|
||||
username: account.username,
|
||||
fingerprint: account.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await this.sessions.findOne({ where: { id: payload.sid } });
|
||||
if (!session) throw this.rejected('no_session');
|
||||
if (session.revokedAt) throw this.rejected('logged_out');
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
throw this.rejected('session_expired');
|
||||
/** 계정이 중지·삭제됐거나 비밀번호가 바뀌었으면 무효다 */
|
||||
private async activePartnerAccount(
|
||||
payload: AdminTokenPayload,
|
||||
): Promise<Principal> {
|
||||
const account = await this.accounts.findOne({
|
||||
where: { id: payload.sub },
|
||||
relations: { partner: true },
|
||||
});
|
||||
if (!account || !account.partner) throw this.rejected('account_not_found');
|
||||
if (!account.isActive) throw this.rejected('account_inactive');
|
||||
const fingerprint = this.fingerprintOf(account);
|
||||
if (!safeEqual(payload.cred, fingerprint)) {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
return { account, session };
|
||||
return { role: 'partner', account, fingerprint };
|
||||
}
|
||||
|
||||
private rejected(reason: string): BusinessException {
|
||||
@@ -175,14 +361,36 @@ export class AdminAuthService {
|
||||
await this.sessions.delete({ revokedAt: LessThan(cutoff) });
|
||||
}
|
||||
|
||||
private toResponse(principal: Principal): AdminResponseDto {
|
||||
if (principal.role === 'super') {
|
||||
return {
|
||||
username: principal.username,
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
}
|
||||
const { account } = principal;
|
||||
return {
|
||||
username: account.username,
|
||||
role: 'partner',
|
||||
partner: account.partner
|
||||
? { id: account.partner.id, name: account.partner.name }
|
||||
: null,
|
||||
mustChangePassword: account.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
private async issue(
|
||||
account: AdminAccount,
|
||||
principal: Principal,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): Promise<AdminSessionDto> {
|
||||
const base = {
|
||||
sub: account.username,
|
||||
cred: account.fingerprint,
|
||||
sub:
|
||||
principal.role === 'super' ? principal.username : principal.account.id,
|
||||
role: principal.role,
|
||||
cred: principal.fingerprint,
|
||||
sid: sessionId,
|
||||
};
|
||||
const accessTtlMs = accessTokenTtlMs();
|
||||
@@ -199,7 +407,7 @@ export class AdminAuthService {
|
||||
),
|
||||
]);
|
||||
return {
|
||||
admin: { username: account.username },
|
||||
admin: this.toResponse(principal),
|
||||
accessToken,
|
||||
accessTokenExpiresAt: new Date(now + accessTtlMs).toISOString(),
|
||||
refreshToken,
|
||||
|
||||
@@ -3,15 +3,20 @@ import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
|
||||
/**
|
||||
* 관리자 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
|
||||
* 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
|
||||
*
|
||||
* 관리자 계정(env 단일 계정) 하나를 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
|
||||
* 최고관리자(env 단일 계정)는 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
|
||||
* (계정 단위로 끊으면 한 사람이 로그아웃할 때 모두가 함께 로그아웃된다)
|
||||
* 모든 세션을 한 번에 끊는 것은 env 비밀번호 변경(토큰의 계정 지문)이 맡는다.
|
||||
* 모든 세션을 한 번에 끊는 것은 비밀번호 변경(토큰의 계정 지문)이 맡는다.
|
||||
* 업체 계정 세션은 accountId 로 계정과 잇는다 — 계정을 중지하면 그 계정의 세션을 모두 끊는다.
|
||||
*/
|
||||
@Entity({ name: 'admin_sessions' })
|
||||
export class AdminSession {
|
||||
@@ -19,6 +24,15 @@ export class AdminSession {
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 업체 계정 — 최고관리자(env 계정) 세션이면 null */
|
||||
@Index()
|
||||
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||
accountId!: string | null;
|
||||
|
||||
@ManyToOne(() => AdminAccount, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'account_id' })
|
||||
account?: AdminAccount;
|
||||
|
||||
/** 어느 브라우저에서 로그인했는지 — 확인·감사용 (개인정보 아님, 길이 제한) */
|
||||
@Column({ name: 'user_agent', type: 'varchar', length: 200, nullable: true })
|
||||
userAgent!: string | null;
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** 로그인한 관리자 */
|
||||
import { ACCOUNT_ROLES } from '../admin-auth.constants';
|
||||
import type { AccountRole } from '../admin-auth.constants';
|
||||
|
||||
/** 업체 계정이 속한 업체 */
|
||||
export class AdminPartnerDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** 로그인한 계정 */
|
||||
export class AdminResponseDto {
|
||||
@ApiProperty({
|
||||
description: '관리자 아이디 (env ADMIN_USERNAME)',
|
||||
description: '아이디 (최고관리자는 env ADMIN_USERNAME)',
|
||||
example: 'admin',
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '역할 — super(최고관리자) · partner(업체 계정)',
|
||||
enum: ACCOUNT_ROLES,
|
||||
example: 'super',
|
||||
})
|
||||
role!: AccountRole;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[업체 계정] 속한 업체 — 최고관리자면 null',
|
||||
type: AdminPartnerDto,
|
||||
nullable: true,
|
||||
})
|
||||
partner!: AdminPartnerDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'[업체 계정] 임시 비밀번호를 바꿔야 하는가 — 바꾸기 전에는 실적을 볼 수 없다',
|
||||
example: false,
|
||||
})
|
||||
mustChangePassword!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
ACCOUNT_PASSWORD_MAX_LENGTH,
|
||||
ACCOUNT_PASSWORD_MIN_LENGTH,
|
||||
} from '../admin-auth.constants';
|
||||
|
||||
/** 영문·숫자로 시작하고 영문·숫자·. _ - 만, 4~30자 (저장은 소문자) */
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{3,29}$/;
|
||||
|
||||
/** 업체 계정 목록 조회 */
|
||||
export class PartnerAccountListQueryDto extends CursorPaginationQueryDto {}
|
||||
|
||||
/** 업체 계정 만들기 — 비밀번호는 서버가 만들어 한 번만 돌려준다 */
|
||||
export class CreatePartnerAccountDto {
|
||||
@ApiProperty({
|
||||
description: '로그인 아이디 — 영문·숫자·. _ - 4~30자 (대소문자 구분 없음)',
|
||||
example: 'moneytube',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(USERNAME_PATTERN, {
|
||||
message:
|
||||
'아이디는 영문·숫자로 시작하는 4~30자(영문·숫자·. _ -)로 입력해 주세요.',
|
||||
})
|
||||
username!: string;
|
||||
}
|
||||
|
||||
/** 업체 계정 수정 — 지금은 사용/중지만 */
|
||||
export class UpdatePartnerAccountDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'사용 여부 — false 로 바꾸면 그 계정의 모든 기기가 로그아웃된다',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** 업체 계정 한 개 — 비밀번호(해시 포함)는 내려주지 않는다 */
|
||||
export class PartnerAccountResponseDto {
|
||||
@ApiProperty({ description: '계정 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '로그인 아이디 (소문자)', example: 'moneytube' })
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
partnerId!: string;
|
||||
|
||||
@ApiProperty({ description: '사용 여부', example: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '임시 비밀번호를 아직 바꾸지 않았는가',
|
||||
example: true,
|
||||
})
|
||||
mustChangePassword!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 로그인 시각 — 한 번도 안 했으면 null',
|
||||
nullable: true,
|
||||
example: '2026-10-06T05:00:00.000Z',
|
||||
})
|
||||
lastLoginAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만든 시각',
|
||||
example: '2026-10-06T05:00:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
/** 만들기·초기화 결과 — 임시 비밀번호는 이 응답에서 한 번만 볼 수 있다 */
|
||||
export class PartnerAccountCredentialDto {
|
||||
@ApiProperty({ description: '업체 계정', type: PartnerAccountResponseDto })
|
||||
account!: PartnerAccountResponseDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'임시 비밀번호 — 다시 볼 수 없다. 업체가 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다',
|
||||
example: 'Kp7mXz3qRt9wHc',
|
||||
})
|
||||
temporaryPassword!: string;
|
||||
}
|
||||
|
||||
/** 비밀번호 변경 (업체 계정 본인) */
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: '현재 비밀번호 (임시 비밀번호 포함)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '현재 비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
|
||||
message: '비밀번호가 너무 깁니다.',
|
||||
})
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `새 비밀번호 — ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상`,
|
||||
example: 'a-new-long-password',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(ACCOUNT_PASSWORD_MIN_LENGTH, {
|
||||
message: `새 비밀번호는 ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
|
||||
})
|
||||
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
|
||||
message: '비밀번호가 너무 깁니다.',
|
||||
})
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
|
||||
import { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
} from '@/common/guards/admin-auth.guard';
|
||||
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import type { VerifiedAccount } from './admin-auth.service';
|
||||
import { AdminSessionDto } from './dto/admin-session.dto';
|
||||
import {
|
||||
ChangePasswordDto,
|
||||
CreatePartnerAccountDto,
|
||||
PartnerAccountCredentialDto,
|
||||
PartnerAccountListQueryDto,
|
||||
PartnerAccountResponseDto,
|
||||
UpdatePartnerAccountDto,
|
||||
} from './dto/partner-account.dto';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
/** 업체 계정 관리 — 최고관리자 전용 */
|
||||
@ApiTags('Admin Partner Accounts')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin')
|
||||
export class PartnerAccountsController {
|
||||
constructor(private readonly accounts: PartnerAccountsService) {}
|
||||
|
||||
@Get('partners/:partnerId/accounts')
|
||||
@ApiOperation({ summary: '업체 계정 목록 (최근 만든 순)' })
|
||||
@ApiStandardResponse(PartnerAccountResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
findMany(
|
||||
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
|
||||
@Query() query: PartnerAccountListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
|
||||
return this.accounts.findMany(partnerId, query);
|
||||
}
|
||||
|
||||
@Post('partners/:partnerId/accounts')
|
||||
@ApiOperation({
|
||||
summary: '업체 계정 만들기 — 임시 비밀번호는 이 응답에서만 볼 수 있다',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountCredentialDto, {
|
||||
status: 201,
|
||||
description: '생성 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
@ApiErrorResponse('ACCOUNT_001', '이미 쓰는 아이디')
|
||||
@ApiErrorResponse('VAL_001', '아이디 형식 오류')
|
||||
create(
|
||||
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
|
||||
@Body() dto: CreatePartnerAccountDto,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
return this.accounts.create(partnerId, dto);
|
||||
}
|
||||
|
||||
@Patch('partner-accounts/:id')
|
||||
@ApiOperation({
|
||||
summary: '업체 계정 사용/중지 — 중지하면 모든 기기 로그아웃',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountResponseDto, { description: '수정 성공' })
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 계정')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdatePartnerAccountDto,
|
||||
): Promise<PartnerAccountResponseDto> {
|
||||
return this.accounts.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('partner-accounts/:id/password-resets')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'업체 계정 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 모든 기기 로그아웃',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountCredentialDto, {
|
||||
description: '초기화 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 계정')
|
||||
resetPassword(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
return this.accounts.resetPassword(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인한 계정 본인 — 비밀번호 변경 (업체 계정만. 최고관리자는 env 에서 바꾼다) */
|
||||
@ApiTags('Admin Session')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@Controller('admin/accounts/current')
|
||||
export class AccountPasswordController {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
@Put('password')
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'비밀번호 변경 — 다른 기기는 로그아웃되고, 이 기기에는 새 토큰을 돌려준다',
|
||||
})
|
||||
@ApiStandardResponse(AdminSessionDto, { description: '변경 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자 (env 에서 바꾼다)')
|
||||
@ApiErrorResponse('ACCOUNT_002', '현재 비밀번호 불일치')
|
||||
@ApiErrorResponse('VAL_001', '새 비밀번호 형식 오류')
|
||||
@ApiErrorResponse('REQ_001', '시도 횟수 초과 (1분 5회)')
|
||||
changePassword(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
): Promise<AdminSessionDto> {
|
||||
return this.auth.changePassword(
|
||||
account,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import { compareSync } from 'bcryptjs';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import type { AdminAuthService } from './admin-auth.service';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
const PARTNER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function setup(options: { partnerExists?: boolean } = {}) {
|
||||
const inserted: AdminAccount[] = [];
|
||||
const store = new Map<string, AdminAccount>();
|
||||
const accounts = {
|
||||
create: jest.fn((row: Partial<AdminAccount>) => row as AdminAccount),
|
||||
insert: jest.fn((row: AdminAccount) => {
|
||||
inserted.push(row);
|
||||
store.set(row.id, { ...row, createdAt: new Date() });
|
||||
return Promise.resolve();
|
||||
}),
|
||||
findOne: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(store.get(where.id) ?? null),
|
||||
),
|
||||
save: jest.fn((row: AdminAccount) => {
|
||||
store.set(row.id, row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
};
|
||||
const partners = {
|
||||
exists: jest.fn(() => Promise.resolve(options.partnerExists ?? true)),
|
||||
};
|
||||
const auth = { revokeAccountSessions: jest.fn(() => Promise.resolve()) };
|
||||
const config = {
|
||||
get: (key: string) => (key === 'ADMIN_USERNAME' ? 'Admin' : ''),
|
||||
};
|
||||
|
||||
const service = new PartnerAccountsService(
|
||||
accounts as unknown as Repository<AdminAccount>,
|
||||
partners as unknown as Repository<Partner>,
|
||||
auth as unknown as AdminAuthService,
|
||||
config as unknown as ConfigService,
|
||||
);
|
||||
return { service, accounts, inserted, store, auth };
|
||||
}
|
||||
|
||||
describe('PartnerAccountsService', () => {
|
||||
it('계정을 만들면 임시 비밀번호를 한 번 돌려주고 해시만 저장한다 (아이디는 소문자)', async () => {
|
||||
const { service, inserted } = setup();
|
||||
|
||||
const result = await service.create(PARTNER_ID, { username: 'MoneyTube' });
|
||||
|
||||
expect(result.account).toMatchObject({
|
||||
username: 'moneytube',
|
||||
partnerId: PARTNER_ID,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
expect(result.temporaryPassword).toHaveLength(14);
|
||||
expect(inserted[0].passwordHash).not.toContain(result.temporaryPassword);
|
||||
expect(
|
||||
compareSync(result.temporaryPassword, inserted[0].passwordHash),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(result.account)).not.toContain('passwordHash');
|
||||
});
|
||||
|
||||
it('최고관리자 아이디·이미 있는 아이디는 ACCOUNT_001, 없는 업체는 RES_001', async () => {
|
||||
const { service, accounts } = setup();
|
||||
await expect(
|
||||
service.create(PARTNER_ID, { username: 'admin' }),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
|
||||
|
||||
accounts.insert.mockRejectedValueOnce(
|
||||
new QueryFailedError('INSERT', [], { code: '23505' } as never),
|
||||
);
|
||||
await expect(
|
||||
service.create(PARTNER_ID, { username: 'taken' }),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
|
||||
|
||||
const missing = setup({ partnerExists: false });
|
||||
await expect(
|
||||
missing.service.create(PARTNER_ID, { username: 'someone' }),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
});
|
||||
|
||||
it('계정을 중지하면 그 계정의 세션을 모두 끊는다', async () => {
|
||||
const { service, auth } = setup();
|
||||
const { account } = await service.create(PARTNER_ID, {
|
||||
username: 'moneytube',
|
||||
});
|
||||
|
||||
const updated = await service.update(account.id, { isActive: false });
|
||||
|
||||
expect(updated.isActive).toBe(false);
|
||||
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(account.id);
|
||||
});
|
||||
|
||||
it('비밀번호를 초기화하면 새 임시 비밀번호로 바뀌고 다시 변경을 요구하며 세션을 끊는다', async () => {
|
||||
const { service, auth, store } = setup();
|
||||
const created = await service.create(PARTNER_ID, { username: 'moneytube' });
|
||||
store.get(created.account.id)!.mustChangePassword = false;
|
||||
|
||||
const reset = await service.resetPassword(created.account.id);
|
||||
|
||||
const row = store.get(created.account.id)!;
|
||||
expect(reset.temporaryPassword).not.toBe(created.temporaryPassword);
|
||||
expect(compareSync(reset.temporaryPassword, row.passwordHash)).toBe(true);
|
||||
expect(compareSync(created.temporaryPassword, row.passwordHash)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(row.mustChangePassword).toBe(true);
|
||||
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(created.account.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { randomInt, randomUUID } from 'crypto';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { generateTemporaryPassword } from '@/shared/utils/temporary-password';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { ADMIN_USERNAME_KEY, BCRYPT_ROUNDS } from './admin-auth.constants';
|
||||
import { AdminAuthService, normalizeUsername } from './admin-auth.service';
|
||||
import {
|
||||
CreatePartnerAccountDto,
|
||||
PartnerAccountCredentialDto,
|
||||
PartnerAccountListQueryDto,
|
||||
PartnerAccountResponseDto,
|
||||
UpdatePartnerAccountDto,
|
||||
} from './dto/partner-account.dto';
|
||||
|
||||
const ALIAS = 'account';
|
||||
/** Postgres 유일 제약 위반 — 같은 아이디가 동시에 만들어진 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof QueryFailedError &&
|
||||
(error.driverError as { code?: string } | undefined)?.code ===
|
||||
PG_UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
function toResponse(row: AdminAccount): PartnerAccountResponseDto {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
partnerId: row.partnerId,
|
||||
isActive: row.isActive,
|
||||
mustChangePassword: row.mustChangePassword,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 계정 관리 — 최고관리자만 (AdminAuthGuard).
|
||||
*
|
||||
* 비밀번호는 서버가 만들어 응답으로 한 번만 돌려주고 해시만 남긴다.
|
||||
* 업체는 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다 (mustChangePassword → ACCOUNT_003).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerAccountsService {
|
||||
private readonly logger = new Logger(PartnerAccountsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminAccount)
|
||||
private readonly accounts: Repository<AdminAccount>,
|
||||
@InjectRepository(Partner)
|
||||
private readonly partners: Repository<Partner>,
|
||||
private readonly auth: AdminAuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 업체의 계정 목록 — 최근 만든 순 */
|
||||
async findMany(
|
||||
partnerId: string,
|
||||
query: PartnerAccountListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
|
||||
await this.requirePartner(partnerId);
|
||||
const base = this.accounts
|
||||
.createQueryBuilder(ALIAS)
|
||||
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
|
||||
|
||||
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(items.map(toResponse), {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.createdAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 업체 계정 만들기 — 임시 비밀번호를 한 번만 돌려준다 */
|
||||
async create(
|
||||
partnerId: string,
|
||||
dto: CreatePartnerAccountDto,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
await this.requirePartner(partnerId);
|
||||
const username = normalizeUsername(dto.username);
|
||||
// 최고관리자(env) 아이디와 같으면 로그인 때 최고관리자로 먼저 맞춰 보므로 쓸 수 없다
|
||||
const superUsername = normalizeUsername(
|
||||
this.config.get<string>(ADMIN_USERNAME_KEY) ?? '',
|
||||
);
|
||||
if (username === superUsername) {
|
||||
throw new BusinessException('ACCOUNT_001', {
|
||||
context: { reason: 'super_admin_username' },
|
||||
});
|
||||
}
|
||||
|
||||
const temporaryPassword = generateTemporaryPassword(randomInt);
|
||||
const row = this.accounts.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: await hash(temporaryPassword, BCRYPT_ROUNDS),
|
||||
partnerId,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
try {
|
||||
await this.accounts.insert(row);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new BusinessException('ACCOUNT_001', { context: { username } });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.logger.log(`업체 계정 생성: account=${row.id} partner=${partnerId}`);
|
||||
return {
|
||||
account: toResponse(await this.require(row.id)),
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/** 사용/중지 — 중지하면 그 계정의 모든 기기가 로그아웃된다 */
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePartnerAccountDto,
|
||||
): Promise<PartnerAccountResponseDto> {
|
||||
const row = await this.require(id);
|
||||
if (dto.isActive !== undefined && dto.isActive !== row.isActive) {
|
||||
row.isActive = dto.isActive;
|
||||
await this.accounts.save(row);
|
||||
if (!row.isActive) await this.auth.revokeAccountSessions(row.id);
|
||||
this.logger.log(
|
||||
`업체 계정 ${row.isActive ? '사용' : '중지'}: account=${row.id}`,
|
||||
);
|
||||
}
|
||||
return toResponse(row);
|
||||
}
|
||||
|
||||
/** 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 열린 세션을 모두 끊는다 */
|
||||
async resetPassword(id: string): Promise<PartnerAccountCredentialDto> {
|
||||
const row = await this.require(id);
|
||||
const temporaryPassword = generateTemporaryPassword(randomInt);
|
||||
row.passwordHash = await hash(temporaryPassword, BCRYPT_ROUNDS);
|
||||
row.mustChangePassword = true;
|
||||
await this.accounts.save(row);
|
||||
await this.auth.revokeAccountSessions(row.id);
|
||||
this.logger.log(`업체 계정 비밀번호 초기화: account=${row.id}`);
|
||||
return { account: toResponse(row), temporaryPassword };
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<AdminAccount> {
|
||||
const row = await this.accounts.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new BusinessException('RES_001', { context: { accountId: id } });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async requirePartner(partnerId: string): Promise<void> {
|
||||
const exists = await this.partners.exists({ where: { id: partnerId } });
|
||||
if (!exists) {
|
||||
throw new BusinessException('RES_001', { context: { partnerId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,21 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
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 {
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
import {
|
||||
CreateApplicationDto,
|
||||
CreateApplicationResultDto,
|
||||
@@ -75,53 +60,3 @@ export class ApplicationsController {
|
||||
await this.applications.markKakaoClicked(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 조회 */
|
||||
@ApiTags('Admin Applications')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/applications')
|
||||
export class AdminApplicationsController {
|
||||
constructor(private readonly applications: ApplicationsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '신청 내역 (최신순, 커서 페이지네이션)' })
|
||||
@ApiStandardResponse(ApplicationResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
|
||||
findMany(
|
||||
@Query() query: ApplicationListQueryDto,
|
||||
): Promise<PaginatedResult<ApplicationResponseDto>> {
|
||||
return this.applications.findMany(query);
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
@ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' })
|
||||
@ApiStandardResponse(ApplicationProductDto, {
|
||||
description: '조회 성공',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
products(): Promise<ApplicationProductDto[]> {
|
||||
return this.applications.products();
|
||||
}
|
||||
|
||||
@Get('inflow-stats')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다',
|
||||
})
|
||||
@ApiStandardResponse(ApplicationInflowStatsResponseDto, {
|
||||
description: '집계 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
|
||||
inflowStats(
|
||||
@Query() query: ApplicationInflowStatsQueryDto,
|
||||
): Promise<ApplicationInflowStatsResponseDto> {
|
||||
return this.applications.inflowStats(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
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 { 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,
|
||||
ApplicationsController,
|
||||
} from './applications.controller';
|
||||
import { ApplicationsController } from './applications.controller';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
|
||||
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
|
||||
/**
|
||||
* 강의 신청 기록 — 신청 완료 화면이 보낸다.
|
||||
* 관리자 화면의 조회는 ActivitiesModule(신청·구매 내역)이 맡는다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
|
||||
AdminAuthModule,
|
||||
TypeOrmModule.forFeature([MemberApplication]),
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
// 신청을 업체 링크 실적으로 귀속한다
|
||||
PartnersModule,
|
||||
],
|
||||
controllers: [ApplicationsController, AdminApplicationsController],
|
||||
controllers: [ApplicationsController],
|
||||
providers: [ApplicationsService],
|
||||
})
|
||||
export class ApplicationsModule {}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { QueryFailedError, type Repository } from 'typeorm';
|
||||
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';
|
||||
@@ -46,9 +45,7 @@ function setup(member: ImwebMember | null = MEMBER) {
|
||||
};
|
||||
const service = new ApplicationsService(
|
||||
applications as unknown as Repository<MemberApplication>,
|
||||
{} as Repository<ImwebMember>,
|
||||
lookup as unknown as ImwebMemberLookupService,
|
||||
{} as MembersService,
|
||||
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
|
||||
attribution as unknown as PartnerAttributionService,
|
||||
);
|
||||
|
||||
@@ -1,68 +1,21 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
IsNull,
|
||||
QueryFailedError,
|
||||
Repository,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
import { IsNull, QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.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 { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
|
||||
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { parseInflow } from '@/shared/utils/inflow';
|
||||
import { MemberApplication } from './application.entity';
|
||||
import {
|
||||
ApplicationFilterDto,
|
||||
ApplicationInflowBasis,
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
import {
|
||||
CreateApplicationDto,
|
||||
CreateApplicationResultDto,
|
||||
} from './dto/create-application.dto';
|
||||
|
||||
const ALIAS = 'a';
|
||||
const MEMBER_ALIAS = 'm';
|
||||
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
|
||||
const STATS_MAX_ITEMS = 100;
|
||||
|
||||
/**
|
||||
* 유입 기준·항목 → SQL 식 (항목 이름이 곧 컬럼 접미사다: inflow_source 등).
|
||||
* 가입 경로는 가입 때 기록된 값만 쓴다 — 가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다.
|
||||
* 값은 모두 enum 으로 검증된 것이라 문자열로 넣어도 안전하다.
|
||||
*/
|
||||
function inflowExpression(
|
||||
basis: ApplicationInflowBasis,
|
||||
field: InflowGroupBy,
|
||||
): string {
|
||||
if (basis === 'apply_first') return `${ALIAS}.inflow_${field}`;
|
||||
if (basis === 'apply_last') return `${ALIAS}.inflow_last_${field}`;
|
||||
return `CASE WHEN ${MEMBER_ALIAS}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${MEMBER_ALIAS}.inflow_${field} END`;
|
||||
}
|
||||
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
@@ -78,6 +31,7 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
* 신청 완료 화면은 공개 페이지라 보낸 사람이 정말 그 회원인지는 확인할 수 없다.
|
||||
* 그래서 (1) 아임웹에 실제로 있는 회원인지 확인하고 (2) 회원·상품당 한 건만 남기며
|
||||
* (3) 화면에 보이는 회원 정보는 브라우저가 보낸 값이 아니라 아임웹에서 받은 값만 쓴다.
|
||||
* 관리자 조회는 유료 구매와 함께 ActivitiesModule(신청·구매 내역)이 맡는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
@@ -86,10 +40,7 @@ export class ApplicationsService {
|
||||
constructor(
|
||||
@InjectRepository(MemberApplication)
|
||||
private readonly applications: Repository<MemberApplication>,
|
||||
@InjectRepository(ImwebMember)
|
||||
private readonly members: Repository<ImwebMember>,
|
||||
private readonly lookup: ImwebMemberLookupService,
|
||||
private readonly membersService: MembersService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly attribution: PartnerAttributionService,
|
||||
) {}
|
||||
@@ -197,204 +148,10 @@ export class ApplicationsService {
|
||||
);
|
||||
}
|
||||
|
||||
async findMany(
|
||||
query: ApplicationListQueryDto,
|
||||
): Promise<PaginatedResult<ApplicationResponseDto>> {
|
||||
const base = this.filtered(query);
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.appliedAt`, '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}.appliedAt, ${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 [membersByCode, groupTitles] = await Promise.all([
|
||||
this.membersByCode(items.map((row) => row.memberCode)),
|
||||
this.membersService.groupTitleMap(),
|
||||
]);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) => {
|
||||
const member = membersByCode.get(row.memberCode);
|
||||
return {
|
||||
id: row.id,
|
||||
appliedAt: row.appliedAt.toISOString(),
|
||||
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
|
||||
productIdx: row.productIdx,
|
||||
productName: row.productName,
|
||||
memberUid: row.memberUid,
|
||||
member: member ? MemberResponseDto.from(member, groupTitles) : null,
|
||||
inflow: {
|
||||
source: row.inflowSource,
|
||||
medium: row.inflowMedium,
|
||||
campaign: row.inflowCampaign,
|
||||
content: row.inflowContent,
|
||||
},
|
||||
lastInflow: {
|
||||
source: row.inflowLastSource,
|
||||
medium: row.inflowLastMedium,
|
||||
campaign: row.inflowLastCampaign,
|
||||
content: row.inflowLastContent,
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.appliedAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 유입 집계 — 같은 신청을 '신청 때 최종 / 최초 유입' 또는 '가입 경로'로 나눠 센다.
|
||||
* 기준을 바꿔 보면 "가입은 네이버로 했지만 이번 신청은 페이스북 광고로 왔다" 같은 차이가 보인다.
|
||||
*/
|
||||
async inflowStats(
|
||||
query: ApplicationInflowStatsQueryDto,
|
||||
): Promise<ApplicationInflowStatsResponseDto> {
|
||||
const base = this.filtered(query);
|
||||
const total = await base.clone().getCount();
|
||||
const rows = await base
|
||||
.clone()
|
||||
.select(inflowExpression(query.basis, query.groupBy), 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('key')
|
||||
.orderBy('count', 'DESC')
|
||||
.addOrderBy('key', 'ASC', 'NULLS LAST')
|
||||
.limit(STATS_MAX_ITEMS)
|
||||
.getRawMany<{ key: string | null; count: string }>();
|
||||
|
||||
return {
|
||||
basis: query.basis,
|
||||
groupBy: query.groupBy,
|
||||
total,
|
||||
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록·집계 공통 필터 — 회원 검색·가입 경로를 위해 동기화된 회원과 잇는다 */
|
||||
private filtered(
|
||||
filter: ApplicationFilterDto &
|
||||
Partial<
|
||||
Pick<
|
||||
ApplicationListQueryDto,
|
||||
'source' | 'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
|
||||
>
|
||||
>,
|
||||
): SelectQueryBuilder<MemberApplication> {
|
||||
const qb = this.applications
|
||||
.createQueryBuilder(ALIAS)
|
||||
// 탈퇴 회원은 회원 정보 없이 남는다
|
||||
.leftJoin(
|
||||
ImwebMember,
|
||||
MEMBER_ALIAS,
|
||||
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
|
||||
);
|
||||
|
||||
if (filter.productIdx) {
|
||||
qb.andWhere(`${ALIAS}.productIdx = :productIdx`, {
|
||||
productIdx: filter.productIdx,
|
||||
});
|
||||
}
|
||||
if (filter.appliedFrom) {
|
||||
qb.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
|
||||
appliedFrom: filter.appliedFrom,
|
||||
});
|
||||
}
|
||||
if (filter.appliedTo) {
|
||||
qb.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
|
||||
appliedTo: filter.appliedTo,
|
||||
});
|
||||
}
|
||||
if (filter.source) {
|
||||
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
|
||||
}
|
||||
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
|
||||
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
|
||||
qb.andWhere(
|
||||
`${inflowExpression(filter.inflowBasis, filter.inflowField)} = :inflowValue`,
|
||||
{ inflowValue: filter.inflowValue },
|
||||
);
|
||||
}
|
||||
if (filter.q) {
|
||||
qb.andWhere(
|
||||
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
|
||||
{ q: `%${escapeLike(filter.q.trim())}%` },
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
|
||||
async products(): Promise<ApplicationProductDto[]> {
|
||||
const rows = await this.applications
|
||||
.createQueryBuilder(ALIAS)
|
||||
.select(`${ALIAS}.productIdx`, 'productIdx')
|
||||
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
|
||||
.addSelect(
|
||||
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
|
||||
'productName',
|
||||
)
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy(`${ALIAS}.productIdx`)
|
||||
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
|
||||
.getRawMany<{
|
||||
productIdx: string;
|
||||
productName: string | null;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
productIdx: row.productIdx,
|
||||
productName: row.productName,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
private findExisting(
|
||||
memberCode: string,
|
||||
productIdx: string,
|
||||
): Promise<MemberApplication | null> {
|
||||
return this.applications.findOne({ where: { memberCode, productIdx } });
|
||||
}
|
||||
|
||||
private async membersByCode(
|
||||
codes: string[],
|
||||
): Promise<Map<string, ImwebMember>> {
|
||||
if (codes.length === 0) return new Map();
|
||||
const rows = await this.members
|
||||
.createQueryBuilder(MEMBER_ALIAS)
|
||||
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
|
||||
.getMany();
|
||||
return new Map(rows.map((row) => [row.memberCode, row]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
|
||||
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
const SEARCH_MAX_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* 신청 유입을 어느 기록으로 볼 것인가.
|
||||
* - apply_last : 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입)
|
||||
* - apply_first : 신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)
|
||||
* - join : 신청한 회원의 가입 경로 — 가입 때 기록된 값만 (가입 후 채워진 값은 제외)
|
||||
*/
|
||||
export const APPLICATION_INFLOW_BASIS = [
|
||||
'apply_last',
|
||||
'apply_first',
|
||||
'join',
|
||||
] as const;
|
||||
export type ApplicationInflowBasis = (typeof APPLICATION_INFLOW_BASIS)[number];
|
||||
|
||||
/** 신청 목록·집계가 함께 쓰는 필터 */
|
||||
export class ApplicationFilterDto {
|
||||
@ApiPropertyOptional({ description: '상품번호', example: '52' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
productIdx?: string;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 조회 */
|
||||
export class ApplicationListQueryDto extends IntersectionType(
|
||||
CursorPaginationQueryDto,
|
||||
ApplicationFilterDto,
|
||||
) {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'신청 때 최초 유입원 (inflowBasis 필터가 생기기 전부터 있던 값)',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
source?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'유입 필터 기준 — apply_last(신청 때 최종) · apply_first(신청 때 최초) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(APPLICATION_INFLOW_BASIS, {
|
||||
message: '유입 기준 값이 올바르지 않습니다.',
|
||||
})
|
||||
inflowBasis?: ApplicationInflowBasis;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 항목 — source · medium · campaign · content',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
|
||||
inflowField?: InflowGroupBy;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
inflowValue?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
|
||||
example: '홍길동',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 볼 수 있다 */
|
||||
export class ApplicationInflowStatsQueryDto extends ApplicationFilterDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'집계 기준 — apply_last(신청 때 최종, 기본) · apply_first(신청 때 최초) · join(가입 경로)',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
default: 'apply_last',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(APPLICATION_INFLOW_BASIS, {
|
||||
message: '유입 기준 값이 올바르지 않습니다.',
|
||||
})
|
||||
basis: ApplicationInflowBasis = 'apply_last';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '집계 항목',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
default: 'source',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
|
||||
groupBy: InflowGroupBy = 'source';
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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';
|
||||
|
||||
/** 신청 순간의 유입정보 */
|
||||
export class ApplicationInflowDto {
|
||||
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
|
||||
source!: string | null;
|
||||
|
||||
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
|
||||
medium!: string | null;
|
||||
|
||||
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
|
||||
campaign!: string | null;
|
||||
|
||||
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
|
||||
content!: string | null;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 한 건 */
|
||||
export class ApplicationResponseDto {
|
||||
@ApiProperty({ description: '신청 기록 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 시각',
|
||||
example: '2026-10-05T03:00:00.000Z',
|
||||
})
|
||||
appliedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
|
||||
nullable: true,
|
||||
})
|
||||
kakaoClickedAt!: string | null;
|
||||
|
||||
@ApiProperty({ description: '상품번호', example: '52' })
|
||||
productIdx!: string;
|
||||
|
||||
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
|
||||
productName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
|
||||
memberUid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
|
||||
type: MemberResponseDto,
|
||||
nullable: true,
|
||||
})
|
||||
member!: MemberResponseDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)',
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
inflow!: ApplicationInflowDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로. 유입이 한 번뿐이면 최초와 같다',
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
lastInflow!: ApplicationInflowDto;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 필터 칩에 쓴다 */
|
||||
export class ApplicationProductDto {
|
||||
@ApiProperty({ description: '상품번호', example: '52' })
|
||||
productIdx!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '가장 최근 신청에 표시된 상품명',
|
||||
nullable: true,
|
||||
})
|
||||
productName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 120 })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 한 줄 */
|
||||
export class ApplicationInflowStatItemDto {
|
||||
@ApiProperty({
|
||||
description: '집계 값 (값이 없으면 null)',
|
||||
nullable: true,
|
||||
example: 'facebook',
|
||||
})
|
||||
key!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 42 })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 결과 */
|
||||
export class ApplicationInflowStatsResponseDto {
|
||||
@ApiProperty({
|
||||
description: '집계 기준',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
example: 'apply_last',
|
||||
})
|
||||
basis!: ApplicationInflowBasis;
|
||||
|
||||
@ApiProperty({
|
||||
description: '집계 항목',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
example: 'source',
|
||||
})
|
||||
groupBy!: InflowGroupBy;
|
||||
|
||||
@ApiProperty({ description: '필터에 맞는 전체 신청 수', example: 310 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 수 많은 순 (최대 100개, 값 없음은 key=null)',
|
||||
type: [ApplicationInflowStatItemDto],
|
||||
})
|
||||
items!: ApplicationInflowStatItemDto[];
|
||||
}
|
||||
@@ -103,6 +103,10 @@ export interface ImwebOrderData {
|
||||
isMember?: string | null;
|
||||
memberCode?: string | null;
|
||||
memberUid?: string | null;
|
||||
/** 주문자 — 비회원 주문은 이 값으로만 누군지 알 수 있다 */
|
||||
ordererName?: string | null;
|
||||
ordererEmail?: string | null;
|
||||
ordererCall?: string | null;
|
||||
/** 주문 생성 시각 (UTC) */
|
||||
wtime: string;
|
||||
payments?: {
|
||||
|
||||
@@ -53,15 +53,25 @@ export class ConversionResponseDto {
|
||||
})
|
||||
isValid!: boolean;
|
||||
|
||||
@ApiProperty({ description: '아임웹 회원 코드' })
|
||||
memberCode!: string;
|
||||
@ApiProperty({
|
||||
description: '아임웹 회원 코드 — 비회원 구매면 null',
|
||||
nullable: true,
|
||||
})
|
||||
memberCode!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
|
||||
description: '회원 정보 (동기화된 회원 기준) — 탈퇴·비회원이면 null',
|
||||
type: MemberResponseDto,
|
||||
nullable: true,
|
||||
})
|
||||
member!: MemberResponseDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[비회원 구매] 주문서의 주문자 이름 — 회원이거나 모르면 null',
|
||||
nullable: true,
|
||||
example: '홍길동',
|
||||
})
|
||||
ordererName!: string | null;
|
||||
}
|
||||
|
||||
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { ConversionType } from '../entities/partner-conversion.entity';
|
||||
import { PartnerLinkProductDto } from './partner-link.dto';
|
||||
import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto';
|
||||
|
||||
/** 업체 화면 요약 기간 — 없으면 전체 */
|
||||
export class PortalPeriodQueryDto {
|
||||
@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 PortalConversionQueryDto extends PartnerPeriodQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '실적 종류 — application(신청) · purchase(구매)',
|
||||
enum: ConversionType,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' })
|
||||
type?: ConversionType;
|
||||
}
|
||||
|
||||
/** 업체 화면 요약 — 로그인한 업체 계정의 업체 */
|
||||
export class PortalSummaryDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
partnerId!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
partnerName!: string;
|
||||
|
||||
@ApiProperty({ description: '발급된 링크 수', example: 3 })
|
||||
linkCount!: number;
|
||||
|
||||
@ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto })
|
||||
stats!: PartnerStatsDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 화면 실적 한 건 — 누가 왔는지는 가린 이름만.
|
||||
* 연락처·이메일·아이디·회원 코드·주문번호는 내려주지 않는다 (개인정보 제3자 제공 방지).
|
||||
*/
|
||||
export class PortalConversionDto {
|
||||
@ApiProperty({ description: '실적 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '실적 종류', enum: ConversionType })
|
||||
type!: ConversionType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청·주문 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
convertedAt!: string;
|
||||
|
||||
@ApiProperty({ description: '상품', type: PartnerLinkProductDto })
|
||||
product!: PartnerLinkProductDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '가린 이름 (예: 홍*동) — 모르면 null',
|
||||
nullable: true,
|
||||
example: '홍*동',
|
||||
})
|
||||
maskedName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '회원인가 (비회원 구매면 false)', example: true })
|
||||
isMember!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '실적에 들어가는가 — 구매가 환불·취소되면 false',
|
||||
example: true,
|
||||
})
|
||||
isValid!: boolean;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export enum ConversionType {
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건.
|
||||
* 업체 실적 — 업체 링크로 들어온 손님의 신청·구매 한 건 (구매는 비회원 포함).
|
||||
*
|
||||
* 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다.
|
||||
* 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다.
|
||||
@@ -56,8 +56,9 @@ export class PartnerConversion {
|
||||
@Column({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
@Column({ name: 'member_code', type: 'varchar', length: 40 })
|
||||
memberCode!: string;
|
||||
/** 아임웹 회원 코드 — 비회원 구매(주문완료 화면이 보낸 클릭 ID 로 이은 것)면 null */
|
||||
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
|
||||
memberCode!: string | null;
|
||||
|
||||
/** 신청 기록 ID (구매면 null) */
|
||||
@Column({ name: 'application_id', type: 'uuid', nullable: true })
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('PartnerAttributionService.attributeOrder', () => {
|
||||
expect(conversionStore[0].isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
|
||||
it('결제 전 주문·클릭 ID 없는 비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
touch(LINK_A, new Date(Date.now() - DAY));
|
||||
|
||||
@@ -294,6 +294,90 @@ describe('PartnerAttributionService.attributeOrder', () => {
|
||||
expect(conversionStore).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('비회원 주문도 주문완료 화면이 보낸 클릭 ID 로 그 상품 링크에 귀속한다', async () => {
|
||||
const wtime = new Date();
|
||||
const clickedAt = new Date(wtime.getTime() - DAY);
|
||||
const { service, conversionStore } = setup({
|
||||
clicks: [
|
||||
{
|
||||
ref: 'AAAAAAAAAAAAAAAA',
|
||||
linkId: LINK_A.id,
|
||||
clickedAt,
|
||||
link: { ...LINK_A, isActive: true } as PartnerLink,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.attributeOrder(
|
||||
order({ isMember: 'N', memberCode: null, wtime: wtime.toISOString() }),
|
||||
linked,
|
||||
['AAAAAAAAAAAAAAAA'],
|
||||
);
|
||||
|
||||
expect(result).toEqual({ created: 1, updated: 0 });
|
||||
expect(conversionStore[0]).toMatchObject({
|
||||
type: ConversionType.PURCHASE,
|
||||
linkId: LINK_A.id,
|
||||
memberCode: null,
|
||||
orderNo: '202610050001',
|
||||
touchedAt: clickedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('다른 상품 링크·중지된 링크의 클릭 ID 는 귀속하지 않는다', async () => {
|
||||
const clickedAt = new Date(Date.now() - DAY);
|
||||
const { service, conversionStore } = setup({
|
||||
clicks: [
|
||||
{
|
||||
ref: 'AAAAAAAAAAAAAAAA',
|
||||
linkId: 'link-other',
|
||||
clickedAt,
|
||||
link: { id: 'link-other', prodNo: 60, isActive: true } as PartnerLink,
|
||||
},
|
||||
{
|
||||
ref: 'BBBBBBBBBBBBBBBB',
|
||||
linkId: LINK_B.id,
|
||||
clickedAt,
|
||||
link: { ...LINK_B, isActive: false } as PartnerLink,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.attributeOrder(
|
||||
order({ isMember: 'N', memberCode: null }),
|
||||
linked,
|
||||
['AAAAAAAAAAAAAAAA', 'BBBBBBBBBBBBBBBB'],
|
||||
);
|
||||
|
||||
expect(conversionStore).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('회원 주문은 회원 클릭과 주문완료 클릭 ID 중 마지막 클릭에 귀속한다', async () => {
|
||||
const wtime = new Date();
|
||||
const { service, conversionStore, touch } = setup({
|
||||
clicks: [
|
||||
{
|
||||
ref: 'BBBBBBBBBBBBBBBB',
|
||||
linkId: LINK_B.id,
|
||||
clickedAt: new Date(wtime.getTime() - DAY),
|
||||
link: { ...LINK_B, isActive: true } as PartnerLink,
|
||||
},
|
||||
],
|
||||
});
|
||||
touch(LINK_A, new Date(wtime.getTime() - 3 * DAY));
|
||||
|
||||
await service.attributeOrder(
|
||||
order({ wtime: wtime.toISOString() }),
|
||||
linked,
|
||||
['BBBBBBBBBBBBBBBB'],
|
||||
);
|
||||
|
||||
expect(conversionStore[0]).toMatchObject({
|
||||
linkId: LINK_B.id,
|
||||
memberCode: MEMBER_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
const wtime = new Date(Date.now() - 3 * DAY);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ATTRIBUTION_WINDOW_MS,
|
||||
evaluateOrder,
|
||||
pickLastTouch,
|
||||
TouchCandidate,
|
||||
} from '@/shared/utils/partner-attribution';
|
||||
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
|
||||
import {
|
||||
@@ -159,18 +160,24 @@ export class PartnerAttributionService {
|
||||
* 아임웹 주문 한 건을 반영한다.
|
||||
* - 이미 잡힌 구매는 환불·취소 여부만 갱신한다.
|
||||
* - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다.
|
||||
* 클릭 후보는 회원의 클릭 연결(partner_touches)과 주문완료 화면이 보낸 클릭 ID 를 합친 것이다.
|
||||
* 주문완료 화면의 클릭 ID 로는 비회원 구매도 잡힌다.
|
||||
*
|
||||
* @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다
|
||||
* @param partnerRefs 주문완료 화면이 보낸 이 주문의 업체 링크 클릭 ID (없으면 빈 배열)
|
||||
*/
|
||||
async attributeOrder(
|
||||
order: ImwebOrderData,
|
||||
linkedProdNos: ReadonlySet<number>,
|
||||
partnerRefs: readonly string[] = [],
|
||||
): Promise<OrderAttributionResult> {
|
||||
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 memberCode =
|
||||
order.isMember === 'Y' && order.memberCode ? order.memberCode : null;
|
||||
const { products } = evaluateOrder(order);
|
||||
for (const [prodNo, counts] of products) {
|
||||
if (!linkedProdNos.has(prodNo)) continue;
|
||||
@@ -186,12 +193,17 @@ export class PartnerAttributionService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다.
|
||||
if (!counts || order.isMember !== 'Y' || !order.memberCode) continue;
|
||||
// 결제 전이면 다음 동기화에서 다시 본다.
|
||||
// 회원도 아니고 주문완료 화면이 클릭 ID 를 보내지도 않았으면 이을 방법이 없다.
|
||||
if (!counts || (!memberCode && partnerRefs.length === 0)) continue;
|
||||
|
||||
const touch = await this.findLastTouch(
|
||||
order.memberCode,
|
||||
prodNo,
|
||||
const touch = pickLastTouch(
|
||||
[
|
||||
...(memberCode
|
||||
? await this.memberTouches(memberCode, prodNo, orderedAt)
|
||||
: []),
|
||||
...(await this.refTouches(partnerRefs, prodNo, orderedAt)),
|
||||
],
|
||||
orderedAt,
|
||||
);
|
||||
if (!touch) continue;
|
||||
@@ -199,7 +211,7 @@ export class PartnerAttributionService {
|
||||
await this.insertConversion({
|
||||
type: ConversionType.PURCHASE,
|
||||
touch,
|
||||
memberCode: order.memberCode,
|
||||
memberCode,
|
||||
prodNo,
|
||||
applicationId: null,
|
||||
orderNo,
|
||||
@@ -207,7 +219,7 @@ export class PartnerAttributionService {
|
||||
});
|
||||
result.created += 1;
|
||||
this.logger.log(
|
||||
`업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`,
|
||||
`업체 실적(구매): link=${touch.linkId} member=${memberCode ?? 'guest'} order=${orderNo} product=${prodNo}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
@@ -227,7 +239,19 @@ export class PartnerAttributionService {
|
||||
prodNo: number,
|
||||
convertedAt: Date,
|
||||
): Promise<PartnerTouch | null> {
|
||||
const candidates = await this.touches.find({
|
||||
return pickLastTouch(
|
||||
await this.memberTouches(memberCode, prodNo, convertedAt),
|
||||
convertedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/** 회원의 그 상품 링크 클릭 연결 — 전환 전 30일 안 (전환 뒤 클릭은 pickLastTouch 가 거른다) */
|
||||
private memberTouches(
|
||||
memberCode: string,
|
||||
prodNo: number,
|
||||
convertedAt: Date,
|
||||
): Promise<PartnerTouch[]> {
|
||||
return this.touches.find({
|
||||
where: {
|
||||
memberCode,
|
||||
prodNo,
|
||||
@@ -236,13 +260,40 @@ export class PartnerAttributionService {
|
||||
),
|
||||
},
|
||||
});
|
||||
return pickLastTouch(candidates, convertedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문완료 화면이 보낸 클릭 ID 중 그 상품의 링크 클릭 — 전환 전 30일 안.
|
||||
* 회원 연결(recordTouches)과 같이 실제로 있는 클릭만, 중지된 링크의 클릭은 뺀다.
|
||||
*/
|
||||
private async refTouches(
|
||||
refs: readonly string[],
|
||||
prodNo: number,
|
||||
convertedAt: Date,
|
||||
): Promise<TouchCandidate[]> {
|
||||
const valid = [...new Set(refs)]
|
||||
.filter((ref) => CLICK_REF_PATTERN.test(ref))
|
||||
.slice(0, PARTNER_REFS_MAX);
|
||||
if (valid.length === 0) return [];
|
||||
|
||||
const found = await this.clicks.find({
|
||||
where: {
|
||||
ref: In(valid),
|
||||
clickedAt: MoreThanOrEqual(
|
||||
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
|
||||
),
|
||||
},
|
||||
relations: { link: true },
|
||||
});
|
||||
return found
|
||||
.filter((click) => click.link?.isActive && click.link.prodNo === prodNo)
|
||||
.map((click) => ({ linkId: click.linkId, clickedAt: click.clickedAt }));
|
||||
}
|
||||
|
||||
private async insertConversion(input: {
|
||||
type: ConversionType;
|
||||
touch: PartnerTouch;
|
||||
memberCode: string;
|
||||
touch: TouchCandidate;
|
||||
memberCode: string | null;
|
||||
prodNo: number;
|
||||
applicationId: string | null;
|
||||
orderNo: string | null;
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import {
|
||||
CLICK_REF_LENGTH,
|
||||
LINK_CODE_LENGTH,
|
||||
@@ -82,6 +83,7 @@ export class PartnerLinksService {
|
||||
private readonly membersService: MembersService,
|
||||
private readonly imwebConfig: ImwebConfig,
|
||||
private readonly config: ConfigService,
|
||||
private readonly purchases: PurchasesService,
|
||||
) {}
|
||||
|
||||
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
|
||||
@@ -240,18 +242,31 @@ export class PartnerLinksService {
|
||||
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([
|
||||
const codes = [
|
||||
...new Set(
|
||||
items
|
||||
.map((row) => row.memberCode)
|
||||
.filter((code): code is string => !!code),
|
||||
),
|
||||
];
|
||||
// 비회원 구매는 회원 정보가 없어 주문자 이름으로 보여준다
|
||||
const guestOrderNos = items
|
||||
.filter((row) => !row.memberCode && row.orderNo)
|
||||
.map((row) => row.orderNo as string);
|
||||
const [memberRows, groupTitles, ordererNames] = await Promise.all([
|
||||
codes.length > 0
|
||||
? this.members.find({ where: { memberCode: In(codes) } })
|
||||
: Promise.resolve([]),
|
||||
this.membersService.groupTitleMap(),
|
||||
this.purchases.ordererNames(guestOrderNos),
|
||||
]);
|
||||
const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m]));
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) => {
|
||||
const member = membersByCode.get(row.memberCode);
|
||||
const member = row.memberCode
|
||||
? membersByCode.get(row.memberCode)
|
||||
: undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
@@ -261,6 +276,10 @@ export class PartnerLinksService {
|
||||
isValid: row.isValid,
|
||||
memberCode: row.memberCode,
|
||||
member: member ? MemberResponseDto.from(member, groupTitles) : null,
|
||||
ordererName:
|
||||
!row.memberCode && row.orderNo
|
||||
? (ordererNames.get(row.orderNo) ?? null)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import type { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
|
||||
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
|
||||
import type { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
|
||||
import type { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import type { PartnerAttributionService } from './partner-attribution.service';
|
||||
import { PartnerOrderSyncService } from './partner-order-sync.service';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const NOW = new Date('2026-10-06T00:00:00.000Z');
|
||||
const ORDER = { orderNo: 1, wtime: '2026-10-05T00:00:00Z' } as ImwebOrderData;
|
||||
|
||||
function setup(
|
||||
linked: number[] = [],
|
||||
refsByOrder = new Map<string, string[]>(),
|
||||
) {
|
||||
const get = jest.fn(() =>
|
||||
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
|
||||
);
|
||||
const auth = {
|
||||
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u1' })),
|
||||
authorizedGet: get,
|
||||
};
|
||||
const attribution = {
|
||||
linkedProdNos: jest.fn(() => Promise.resolve(new Set(linked))),
|
||||
attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })),
|
||||
};
|
||||
const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) };
|
||||
const orderInflows = {
|
||||
partnerRefsByOrder: jest.fn(() => Promise.resolve(refsByOrder)),
|
||||
prune: jest.fn(() => Promise.resolve(0)),
|
||||
};
|
||||
const service = new PartnerOrderSyncService(
|
||||
attribution as unknown as PartnerAttributionService,
|
||||
purchases as unknown as PurchasesService,
|
||||
orderInflows as unknown as OrderInflowsService,
|
||||
auth as unknown as ImwebAuthService,
|
||||
{} as ImwebConfig,
|
||||
{} as SchedulerRegistry,
|
||||
);
|
||||
/** n번째 아임웹 호출의 조회 시작 시각 */
|
||||
const sinceOf = (call: number) => {
|
||||
const params = (get.mock.calls[call] as unknown[])[1] as {
|
||||
startWtime: string;
|
||||
};
|
||||
return new Date(params.startWtime).getTime();
|
||||
};
|
||||
return { service, get, attribution, purchases, orderInflows, sinceOf };
|
||||
}
|
||||
|
||||
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
|
||||
service.start();
|
||||
await service.waitForIdle();
|
||||
}
|
||||
|
||||
describe('PartnerOrderSyncService', () => {
|
||||
beforeEach(() => jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout'] }));
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('업체 링크가 없어도 주문을 훑어 구매 내역을 남긴다 (회원 여부로 거르지 않는다)', async () => {
|
||||
const { service, get, purchases, attribution } = setup([]);
|
||||
await runOnce(service);
|
||||
expect(purchases.recordOrders).toHaveBeenCalledWith([ORDER]);
|
||||
expect(attribution.attributeOrder).not.toHaveBeenCalled();
|
||||
expect(get.mock.calls[0]).not.toHaveProperty('1.isMember');
|
||||
});
|
||||
|
||||
it('업체 링크가 있으면 같은 주문으로 업체 실적도 반영한다', async () => {
|
||||
const { service, attribution } = setup([52]);
|
||||
await runOnce(service);
|
||||
expect(attribution.attributeOrder).toHaveBeenCalledWith(
|
||||
ORDER,
|
||||
new Set([52]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('주문완료 화면이 보낸 클릭 ID 를 그 주문의 업체 실적 판정에 넘긴다 (비회원 구매 귀속)', async () => {
|
||||
const refs = ['AAAAAAAAAAAAAAAA'];
|
||||
const { service, attribution, orderInflows } = setup(
|
||||
[52],
|
||||
new Map([['1', refs]]),
|
||||
);
|
||||
await runOnce(service);
|
||||
expect(orderInflows.partnerRefsByOrder).toHaveBeenCalledWith(['1']);
|
||||
expect(attribution.attributeOrder).toHaveBeenCalledWith(
|
||||
ORDER,
|
||||
new Set([52]),
|
||||
refs,
|
||||
);
|
||||
});
|
||||
|
||||
it('끝까지 성공했을 때만 오래된 구매 때 유입을 정리한다', async () => {
|
||||
const { service, get, orderInflows } = setup();
|
||||
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
|
||||
await runOnce(service);
|
||||
expect(orderInflows.prune).not.toHaveBeenCalled();
|
||||
await runOnce(service);
|
||||
expect(orderInflows.prune).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('첫 실행은 1년치, 성공한 뒤부터는 60일치만 본다', async () => {
|
||||
const { service, sinceOf } = setup();
|
||||
await runOnce(service);
|
||||
await runOnce(service);
|
||||
expect(NOW.getTime() - sinceOf(0)).toBe(365 * DAY_MS);
|
||||
expect(NOW.getTime() - sinceOf(1)).toBe(60 * DAY_MS);
|
||||
});
|
||||
|
||||
it('1년치 조회가 실패하면 다음 실행에서 다시 1년치를 본다', async () => {
|
||||
const { service, get, sinceOf } = setup();
|
||||
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
|
||||
await runOnce(service);
|
||||
await runOnce(service);
|
||||
expect(service.getStatus().lastErrorCode).toBeNull();
|
||||
expect(NOW.getTime() - sinceOf(1)).toBe(365 * DAY_MS);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ 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 { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
|
||||
import { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import { OrderSyncStatusDto } from './dto/partner-conversion.dto';
|
||||
import { PartnerAttributionService } from './partner-attribution.service';
|
||||
|
||||
@@ -19,6 +21,11 @@ const SCHEDULE_NAME = 'partner-order-sync';
|
||||
* 그보다 오래된 구매의 환불은 반영되지 않는다.
|
||||
*/
|
||||
const ORDER_LOOKBACK_DAYS = 60;
|
||||
/**
|
||||
* 서버가 뜬 뒤 첫 실행은 1년치를 본다 — 구매 내역을 처음 채우고, 서버가 꺼져 있던 동안의 변경도 메운다.
|
||||
* 끝까지 성공한 뒤부터는 ORDER_LOOKBACK_DAYS 만 본다.
|
||||
*/
|
||||
const PURCHASE_BACKFILL_DAYS = 365;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ORDER_PAGE_SIZE = 100;
|
||||
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
|
||||
@@ -37,10 +44,11 @@ function toImwebTime(date: Date): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다.
|
||||
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
|
||||
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다)
|
||||
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
|
||||
*
|
||||
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
|
||||
* 업체 링크가 하나도 없으면 아임웹을 부르지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerOrderSyncService
|
||||
@@ -49,6 +57,8 @@ export class PartnerOrderSyncService
|
||||
private readonly logger = new Logger(PartnerOrderSyncService.name);
|
||||
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
|
||||
private running: Promise<void> | null = null;
|
||||
/** 1년치를 한 번 끝까지 봤는가 — 중간에 실패하면 다음 실행에서 다시 1년치를 본다 */
|
||||
private isBackfilled = false;
|
||||
private status: OrderSyncStatusDto = {
|
||||
isRunning: false,
|
||||
lastStartedAt: null,
|
||||
@@ -60,6 +70,8 @@ export class PartnerOrderSyncService
|
||||
|
||||
constructor(
|
||||
private readonly attribution: PartnerAttributionService,
|
||||
private readonly purchases: PurchasesService,
|
||||
private readonly orderInflows: OrderInflowsService,
|
||||
private readonly auth: ImwebAuthService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
@@ -122,37 +134,51 @@ export class PartnerOrderSyncService
|
||||
let checked = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let purchases = 0;
|
||||
const lookbackDays = this.isBackfilled
|
||||
? ORDER_LOOKBACK_DAYS
|
||||
: PURCHASE_BACKFILL_DAYS;
|
||||
|
||||
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);
|
||||
const { unitCode } = await this.auth.requireConnection();
|
||||
const since = new Date(Date.now() - lookbackDays * 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<ImwebOrderPageData>(
|
||||
IMWEB_ORDERS_PATH,
|
||||
{
|
||||
page,
|
||||
limit: ORDER_PAGE_SIZE,
|
||||
unitCode,
|
||||
isMember: 'Y',
|
||||
startWtime: toImwebTime(since),
|
||||
},
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
if (page > 1) await sleep(PAGE_DELAY_MS);
|
||||
// 비회원 주문도 구매 내역에 남기므로 회원 여부로 거르지 않는다
|
||||
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
|
||||
IMWEB_ORDERS_PATH,
|
||||
{
|
||||
page,
|
||||
limit: ORDER_PAGE_SIZE,
|
||||
unitCode,
|
||||
startWtime: toImwebTime(since),
|
||||
},
|
||||
);
|
||||
purchases += await this.purchases.recordOrders(data.list);
|
||||
// 업체 실적은 업체 링크가 있는 상품만.
|
||||
// 비회원 주문은 주문완료 화면이 보낸 클릭 ID 가 있을 때만 잡힌다
|
||||
if (linkedProdNos.size > 0) {
|
||||
const refsByOrder = await this.orderInflows.partnerRefsByOrder(
|
||||
data.list.map((order) => String(order.orderNo)),
|
||||
);
|
||||
for (const order of data.list) {
|
||||
const result = await this.attribution.attributeOrder(
|
||||
order,
|
||||
linkedProdNos,
|
||||
refsByOrder.get(String(order.orderNo)) ?? [],
|
||||
);
|
||||
created += result.created;
|
||||
updated += result.updated;
|
||||
}
|
||||
checked += data.list.length;
|
||||
if (data.list.length === 0 || page >= data.totalPage) break;
|
||||
}
|
||||
checked += data.list.length;
|
||||
if (data.list.length === 0 || page >= data.totalPage) break;
|
||||
}
|
||||
this.isBackfilled = true;
|
||||
// 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만)
|
||||
await this.orderInflows.prune();
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
@@ -161,7 +187,7 @@ export class PartnerOrderSyncService
|
||||
createdCount: created,
|
||||
};
|
||||
this.logger.log(
|
||||
`업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`,
|
||||
`주문 동기화 완료: days=${lookbackDays} orders=${checked} purchases=${purchases} partnerCreated=${created} partnerUpdated=${updated} ${Date.now() - startedAt}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -174,10 +200,10 @@ export class PartnerOrderSyncService
|
||||
};
|
||||
// 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다
|
||||
if (error instanceof BusinessException) {
|
||||
this.logger.warn(`[${code}] 업체 구매 집계 실패`);
|
||||
this.logger.warn(`[${code}] 주문 동기화 실패`);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[${code}] 업체 구매 집계 실패`,
|
||||
`[${code}] 주문 동기화 실패`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
|
||||
import { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { PartnerAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
|
||||
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
|
||||
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
|
||||
import {
|
||||
PortalConversionDto,
|
||||
PortalConversionQueryDto,
|
||||
PortalPeriodQueryDto,
|
||||
PortalSummaryDto,
|
||||
} from './dto/partner-portal.dto';
|
||||
import { PartnerPeriodQueryDto } from './dto/partner.dto';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
|
||||
/** 가드가 업체 계정만 통과시키지만, 업체 ID 가 없으면 아무것도 보여주지 않는다 */
|
||||
function partnerIdOf(account: VerifiedAccount): string {
|
||||
if (!account.partnerId) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'no_partner' },
|
||||
});
|
||||
}
|
||||
return account.partnerId;
|
||||
}
|
||||
|
||||
/** 업체 화면 — 업체 계정 전용. 업체 ID 는 로그인한 계정에서만 가져온다 */
|
||||
@ApiTags('Partner Portal')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(PartnerAuthGuard)
|
||||
@Controller('partner-portal')
|
||||
export class PartnerPortalController {
|
||||
constructor(private readonly portal: PartnerPortalService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: '내 업체 요약 — 링크 수·기간 실적' })
|
||||
@ApiStandardResponse(PortalSummaryDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
summary(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PortalPeriodQueryDto,
|
||||
): Promise<PortalSummaryDto> {
|
||||
return this.portal.summary(partnerIdOf(account), query);
|
||||
}
|
||||
|
||||
@Get('links')
|
||||
@ApiOperation({ summary: '내 업체 링크 — 업체에 줄 링크와 기간 실적' })
|
||||
@ApiStandardResponse(PartnerLinkResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
findLinks(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
return this.portal.findLinks(partnerIdOf(account), query);
|
||||
}
|
||||
|
||||
@Get('conversions')
|
||||
@ApiOperation({
|
||||
summary: '내 업체 신청·구매 — 최근순, 이름은 가려서 (연락처 없음)',
|
||||
})
|
||||
@ApiStandardResponse(PortalConversionDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
|
||||
findConversions(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PortalConversionQueryDto,
|
||||
): Promise<PaginatedResult<PortalConversionDto>> {
|
||||
return this.portal.findConversions(partnerIdOf(account), query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import type { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import type { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from './entities/partner-conversion.entity';
|
||||
import type { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
import type { PartnerStatsService } from './partner-stats.service';
|
||||
import type { PartnersService } from './partners.service';
|
||||
|
||||
const PARTNER_ID = 'partner-1';
|
||||
|
||||
function conversion(overrides: Partial<PartnerConversion>): PartnerConversion {
|
||||
return {
|
||||
id: 'c-1',
|
||||
type: ConversionType.APPLICATION,
|
||||
linkId: 'link-1',
|
||||
partnerId: PARTNER_ID,
|
||||
prodNo: 52,
|
||||
memberCode: 'm-1',
|
||||
applicationId: 'app-1',
|
||||
orderNo: null,
|
||||
isValid: true,
|
||||
touchedAt: new Date('2026-10-01T00:00:00Z'),
|
||||
convertedAt: new Date('2026-10-02T00:00:00Z'),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 조회 쿼리 빌더 흉내 — where 조건을 기록하고 정해 둔 행을 돌려준다 */
|
||||
interface QueryMock {
|
||||
where: jest.Mock<QueryMock, unknown[]>;
|
||||
andWhere: jest.Mock<QueryMock, unknown[]>;
|
||||
orderBy: jest.Mock<QueryMock, unknown[]>;
|
||||
addOrderBy: jest.Mock<QueryMock, unknown[]>;
|
||||
take: jest.Mock<QueryMock, unknown[]>;
|
||||
clone: jest.Mock<QueryMock, []>;
|
||||
getCount: jest.Mock<Promise<number>, []>;
|
||||
getMany: jest.Mock<Promise<PartnerConversion[]>, []>;
|
||||
}
|
||||
|
||||
function setup(rows: PartnerConversion[]) {
|
||||
const query: QueryMock = {
|
||||
where: jest.fn(() => query),
|
||||
andWhere: jest.fn(() => query),
|
||||
orderBy: jest.fn(() => query),
|
||||
addOrderBy: jest.fn(() => query),
|
||||
take: jest.fn(() => query),
|
||||
clone: jest.fn(() => query),
|
||||
getCount: jest.fn(() => Promise.resolve(rows.length)),
|
||||
getMany: jest.fn(() => Promise.resolve(rows)),
|
||||
};
|
||||
const conversions = { createQueryBuilder: jest.fn(() => query) };
|
||||
const members = {
|
||||
find: jest.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
memberCode: 'm-1',
|
||||
name: '홍길동',
|
||||
callnum: '010-1234-5678',
|
||||
email: 'hong@test.dev',
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
const products = {
|
||||
find: jest.fn(() => Promise.resolve([{ prodNo: 52, name: 'AI 강의' }])),
|
||||
};
|
||||
const purchases = {
|
||||
ordererNames: jest.fn(() =>
|
||||
Promise.resolve(new Map([['202610060001', '김비회']])),
|
||||
),
|
||||
};
|
||||
const service = new PartnerPortalService(
|
||||
conversions as unknown as Repository<PartnerConversion>,
|
||||
members as unknown as Repository<ImwebMember>,
|
||||
products as unknown as Repository<ImwebProduct>,
|
||||
{} as PartnersService,
|
||||
{} as PartnerLinksService,
|
||||
{} as PartnerStatsService,
|
||||
purchases as unknown as PurchasesService,
|
||||
);
|
||||
return { service, query };
|
||||
}
|
||||
|
||||
describe('PartnerPortalService.findConversions', () => {
|
||||
it('로그인한 업체의 실적만 조회한다', async () => {
|
||||
const { service, query } = setup([]);
|
||||
await service.findConversions(PARTNER_ID, { limit: 20 });
|
||||
expect(query.where).toHaveBeenCalledWith(
|
||||
expect.stringContaining('partnerId'),
|
||||
{ partnerId: PARTNER_ID },
|
||||
);
|
||||
});
|
||||
|
||||
it('신청·구매자는 가린 이름만 — 연락처·이메일·회원 코드·주문번호는 내려주지 않는다', async () => {
|
||||
const { service } = setup([
|
||||
conversion({}),
|
||||
conversion({
|
||||
id: 'c-2',
|
||||
type: ConversionType.PURCHASE,
|
||||
memberCode: null,
|
||||
applicationId: null,
|
||||
orderNo: '202610060001',
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await service.findConversions(PARTNER_ID, { limit: 20 });
|
||||
|
||||
expect(page.items).toEqual([
|
||||
expect.objectContaining({
|
||||
maskedName: '홍*동',
|
||||
isMember: true,
|
||||
product: { prodNo: 52, name: 'AI 강의' },
|
||||
}),
|
||||
expect.objectContaining({ maskedName: '김*회', isMember: false }),
|
||||
]);
|
||||
const body = JSON.stringify(page);
|
||||
for (const secret of [
|
||||
'010-1234-5678',
|
||||
'hong@test.dev',
|
||||
'm-1',
|
||||
'202610060001',
|
||||
'홍길동',
|
||||
]) {
|
||||
expect(body).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
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 { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import { maskName } from '@/shared/utils/mask-name';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
|
||||
import {
|
||||
PortalConversionDto,
|
||||
PortalConversionQueryDto,
|
||||
PortalPeriodQueryDto,
|
||||
PortalSummaryDto,
|
||||
} from './dto/partner-portal.dto';
|
||||
import { PartnerPeriodQueryDto } from './dto/partner.dto';
|
||||
import { PartnerConversion } from './entities/partner-conversion.entity';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerStatsService } from './partner-stats.service';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
const ALIAS = 'v';
|
||||
/** 커서의 고유값은 실적 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 업체 화면 — 업체 계정이 자기 업체의 링크·실적을 본다 (PartnerAuthGuard).
|
||||
*
|
||||
* 업체 ID 는 항상 로그인한 계정에서 받는다 — 요청값으로 다른 업체를 볼 수 없다.
|
||||
* 신청·구매자는 가린 이름만 보여주고 연락처·이메일·아이디·주문번호는 내려주지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerPortalService {
|
||||
constructor(
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(ImwebMember)
|
||||
private readonly members: Repository<ImwebMember>,
|
||||
@InjectRepository(ImwebProduct)
|
||||
private readonly products: Repository<ImwebProduct>,
|
||||
private readonly partners: PartnersService,
|
||||
private readonly links: PartnerLinksService,
|
||||
private readonly stats: PartnerStatsService,
|
||||
private readonly purchases: PurchasesService,
|
||||
) {}
|
||||
|
||||
async summary(
|
||||
partnerId: string,
|
||||
query: PortalPeriodQueryDto,
|
||||
): Promise<PortalSummaryDto> {
|
||||
const partner = await this.partners.require(partnerId);
|
||||
const [statsById, linkCounts] = await Promise.all([
|
||||
this.stats.byPartners([partnerId], query),
|
||||
this.stats.linkCounts([partnerId]),
|
||||
]);
|
||||
return {
|
||||
partnerId,
|
||||
partnerName: partner.name,
|
||||
linkCount: linkCounts.get(partnerId) ?? 0,
|
||||
stats: statsById.get(partnerId) ?? {
|
||||
clickCount: 0,
|
||||
applicationCount: 0,
|
||||
purchaseCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 자기 업체의 링크 — 관리자 화면과 같은 응답 (업체에 줄 링크·기간 실적) */
|
||||
findLinks(
|
||||
partnerId: string,
|
||||
query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
return this.links.findMany({
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 자기 업체 실적 목록 — 최근순, 가린 이름만 */
|
||||
async findConversions(
|
||||
partnerId: string,
|
||||
query: PortalConversionQueryDto,
|
||||
): Promise<PaginatedResult<PortalConversionDto>> {
|
||||
const base = this.conversions
|
||||
.createQueryBuilder(ALIAS)
|
||||
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
|
||||
if (query.type) {
|
||||
base.andWhere(`${ALIAS}.type = :type`, { type: query.type });
|
||||
}
|
||||
if (query.from) {
|
||||
base.andWhere(`${ALIAS}.convertedAt >= :from`, { from: query.from });
|
||||
}
|
||||
if (query.to) {
|
||||
base.andWhere(`${ALIAS}.convertedAt < :to`, { to: query.to });
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.convertedAt`, 'DESC')
|
||||
.addOrderBy(`${ALIAS}.id`, 'DESC')
|
||||
.take(query.limit + 1);
|
||||
if (query.cursor) {
|
||||
const cursor = decodeTimeCursor(query.cursor);
|
||||
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(
|
||||
`(${ALIAS}.convertedAt, ${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 names = await this.namesOf(items);
|
||||
const productNames = await this.productNamesOf(items);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map(
|
||||
(row): PortalConversionDto => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
convertedAt: row.convertedAt.toISOString(),
|
||||
product: {
|
||||
prodNo: row.prodNo,
|
||||
name: productNames.get(row.prodNo) ?? null,
|
||||
},
|
||||
maskedName: maskName(names.get(row.id)),
|
||||
isMember: !!row.memberCode,
|
||||
isValid: row.isValid,
|
||||
}),
|
||||
),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.convertedAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 실적 ID → 이름 (가리기 전) — 회원은 회원 이름, 비회원 구매는 주문자 이름 */
|
||||
private async namesOf(
|
||||
rows: PartnerConversion[],
|
||||
): Promise<Map<string, string>> {
|
||||
const memberCodes = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.memberCode)
|
||||
.filter((code): code is string => !!code),
|
||||
),
|
||||
];
|
||||
const guestOrderNos = rows
|
||||
.filter((row) => !row.memberCode && row.orderNo)
|
||||
.map((row) => row.orderNo as string);
|
||||
|
||||
const [memberRows, ordererNames] = await Promise.all([
|
||||
memberCodes.length > 0
|
||||
? this.members.find({
|
||||
select: { memberCode: true, name: true },
|
||||
where: { memberCode: In(memberCodes) },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
this.purchases.ordererNames(guestOrderNos),
|
||||
]);
|
||||
const memberNames = new Map(memberRows.map((m) => [m.memberCode, m.name]));
|
||||
|
||||
const names = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const name = row.memberCode
|
||||
? memberNames.get(row.memberCode)
|
||||
: row.orderNo
|
||||
? ordererNames.get(row.orderNo)
|
||||
: undefined;
|
||||
if (name) names.set(row.id, name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private async productNamesOf(
|
||||
rows: PartnerConversion[],
|
||||
): Promise<Map<number, string>> {
|
||||
const prodNos = [...new Set(rows.map((row) => row.prodNo))];
|
||||
if (prodNos.length === 0) return new Map();
|
||||
const products = await this.products.find({
|
||||
select: { prodNo: true, name: true },
|
||||
where: { prodNo: In(prodNos) },
|
||||
});
|
||||
return new Map(products.map((p) => [p.prodNo, p.name]));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { PurchasesModule } from '@/modules/purchases/purchases.module';
|
||||
import { PartnerConversion } from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} from './partner-links.controller';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerOrderSyncService } from './partner-order-sync.service';
|
||||
import { PartnerPortalController } from './partner-portal.controller';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
import {
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
@@ -28,8 +31,10 @@ import { PartnersService } from './partners.service';
|
||||
|
||||
/**
|
||||
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
|
||||
* 업체 계정은 업체 화면(PartnerPortalController)에서 자기 업체의 링크·실적만 본다.
|
||||
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
|
||||
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다.
|
||||
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
|
||||
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +50,7 @@ import { PartnersService } from './partners.service';
|
||||
AdminAuthModule,
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
PurchasesModule,
|
||||
],
|
||||
controllers: [
|
||||
PartnersController,
|
||||
@@ -52,8 +58,10 @@ import { PartnersService } from './partners.service';
|
||||
PartnerOrderSyncController,
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
PartnerPortalController,
|
||||
],
|
||||
providers: [
|
||||
PartnerPortalService,
|
||||
PartnersService,
|
||||
PartnerLinksService,
|
||||
PartnerStatsService,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto';
|
||||
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
|
||||
|
||||
/** 아임웹 주문번호 — 숫자만 (예: 202610064563182). 저장 칸(30자)을 넘지 않게 */
|
||||
export const ORDER_NO_PATTERN = /^\d{1,30}$/;
|
||||
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
|
||||
const INFLOW_RAW_MAX_LENGTH = 1000;
|
||||
|
||||
/** 주문완료 화면(imweb-header-top.html)이 보내는 구매 때 유입 */
|
||||
export class CreateOrderInflowDto {
|
||||
@ApiProperty({
|
||||
description: '아임웹 주문번호 (주문완료 주소의 order_no)',
|
||||
example: '202610064563182',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(ORDER_NO_PATTERN, { message: '주문번호 형식이 올바르지 않습니다.' })
|
||||
orderNo!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'주문 순간의 유입정보 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식)',
|
||||
example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_RAW_MAX_LENGTH)
|
||||
inflow?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb-header-top.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[];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
/**
|
||||
* 주문완료 화면이 보낸 구매 때 유입 — 주문번호당 한 줄 (먼저 온 값만 남긴다).
|
||||
*
|
||||
* 아임웹 주문 API 에는 손님이 어디서 왔는지가 없어서, 주문완료 화면(imweb-header-top.html)이
|
||||
* 그 순간 브라우저에 남아 있던 유입과 업체 링크 클릭 ID 를 보낸다.
|
||||
* 공개 페이지에서 오는 값이라 받을 때는 확인하지 않고, 주문 동기화가 아임웹에 실제로 있는
|
||||
* 주문번호와 이을 때만 쓴다 (없는 주문번호는 쓰이지 않고 보관 기간이 지나면 지워진다).
|
||||
*/
|
||||
@Entity({ name: 'order_inflows' })
|
||||
@Index('idx_order_inflows_received', ['receivedAt'])
|
||||
export class OrderInflow {
|
||||
/** 아임웹 주문번호 (주문완료 주소의 order_no) */
|
||||
@PrimaryColumn({ name: 'order_no', type: 'varchar', length: 30 })
|
||||
orderNo!: string;
|
||||
|
||||
/** 유입정보 원본 (파이프 형식) — 형식이 아니면 null */
|
||||
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
|
||||
inflowRaw!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_source',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowSource!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_medium',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowMedium!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_campaign',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowCampaign!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_content',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowContent!: string | null;
|
||||
|
||||
/** 브라우저가 보관한 업체 링크 클릭 ID (MG_PARTNER.refs()) — 비회원 구매도 업체 실적으로 잇는다 */
|
||||
@Column({
|
||||
name: 'partner_refs',
|
||||
type: 'varchar',
|
||||
length: 16,
|
||||
array: true,
|
||||
default: () => "'{}'",
|
||||
})
|
||||
partnerRefs!: string[];
|
||||
|
||||
@Column({ name: 'received_at', type: 'timestamptz' })
|
||||
receivedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
|
||||
import { OrderInflowsService } from './order-inflows.service';
|
||||
|
||||
/**
|
||||
* 구매 때 유입 — 아임웹 주문완료 화면(공개 페이지)의 Header Code 가 부른다.
|
||||
* 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다 (신청 기록과 같다).
|
||||
*/
|
||||
@ApiTags('Order Inflows')
|
||||
@Controller('order-inflows')
|
||||
export class OrderInflowsController {
|
||||
constructor(private readonly orderInflows: OrderInflowsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@Throttle({ default: { ttl: 60_000, limit: 10 } })
|
||||
@ApiOperation({
|
||||
summary: '구매 때 유입 기록 — 항상 204 (같은 주문번호는 처음 값만 남는다)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '입력값 오류')
|
||||
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)')
|
||||
async create(@Body() dto: CreateOrderInflowDto): Promise<void> {
|
||||
await this.orderInflows.record(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import type { OrderInflow } from './order-inflow.entity';
|
||||
import {
|
||||
ORDER_INFLOW_RETENTION_DAYS,
|
||||
OrderInflowsService,
|
||||
} from './order-inflows.service';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
|
||||
interface InsertQueryMock {
|
||||
insert: jest.Mock<InsertQueryMock, []>;
|
||||
values: jest.Mock<InsertQueryMock, [OrderInflow]>;
|
||||
orIgnore: jest.Mock<InsertQueryMock, []>;
|
||||
execute: jest.Mock<Promise<void>, []>;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const inserted: OrderInflow[] = [];
|
||||
const builder: InsertQueryMock = {
|
||||
insert: jest.fn(() => builder),
|
||||
values: jest.fn((row: OrderInflow) => {
|
||||
inserted.push(row);
|
||||
return builder;
|
||||
}),
|
||||
orIgnore: jest.fn(() => builder),
|
||||
execute: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
const repo = {
|
||||
create: jest.fn((row: OrderInflow) => row),
|
||||
createQueryBuilder: jest.fn(() => builder),
|
||||
find: jest.fn(() => Promise.resolve([] as Partial<OrderInflow>[])),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
};
|
||||
const service = new OrderInflowsService(
|
||||
repo as unknown as Repository<OrderInflow>,
|
||||
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
|
||||
);
|
||||
return { service, repo, builder, inserted };
|
||||
}
|
||||
|
||||
describe('OrderInflowsService.record', () => {
|
||||
it('유입정보를 풀어 주문번호와 함께 남긴다 (같은 주문번호는 처음 값만)', async () => {
|
||||
const { service, builder, inserted } = setup();
|
||||
await service.record({
|
||||
orderNo: '202610064563182',
|
||||
inflow: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
|
||||
partnerRefs: ['AAAAAAAAAAAAAAAA', 'AAAAAAAAAAAAAAAA'],
|
||||
});
|
||||
expect(builder.orIgnore).toHaveBeenCalled();
|
||||
expect(inserted[0]).toMatchObject({
|
||||
orderNo: '202610064563182',
|
||||
inflowRaw: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
|
||||
inflowSource: '업체A',
|
||||
inflowMedium: 'partner',
|
||||
inflowCampaign: 'CODE1234',
|
||||
partnerRefs: ['AAAAAAAAAAAAAAAA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('유입정보가 없거나 형식이 아니면 유입 칸은 비우고 클릭 ID 만 남긴다', async () => {
|
||||
const { service, inserted } = setup();
|
||||
await service.record({
|
||||
orderNo: '202610064563182',
|
||||
inflow: 'hello',
|
||||
partnerRefs: ['AAAAAAAAAAAAAAAA'],
|
||||
});
|
||||
expect(inserted[0]).toMatchObject({
|
||||
inflowRaw: null,
|
||||
inflowSource: null,
|
||||
partnerRefs: ['AAAAAAAAAAAAAAAA'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrderInflowsService.partnerRefsByOrder', () => {
|
||||
it('클릭 ID 가 있는 주문만 돌려준다', async () => {
|
||||
const { service, repo } = setup();
|
||||
repo.find.mockResolvedValueOnce([
|
||||
{ orderNo: '1', partnerRefs: ['AAAAAAAAAAAAAAAA'] },
|
||||
{ orderNo: '2', partnerRefs: [] },
|
||||
]);
|
||||
const refs = await service.partnerRefsByOrder(['1', '2', '1']);
|
||||
expect(refs).toEqual(new Map([['1', ['AAAAAAAAAAAAAAAA']]]));
|
||||
});
|
||||
|
||||
it('주문번호가 없으면 DB 를 보지 않는다', async () => {
|
||||
const { service, repo } = setup();
|
||||
await expect(service.partnerRefsByOrder([])).resolves.toEqual(new Map());
|
||||
expect(repo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrderInflowsService.prune', () => {
|
||||
it('보관 기간이 지난 값만 지운다', async () => {
|
||||
const { service, repo } = setup();
|
||||
const now = new Date('2026-10-06T00:00:00.000Z');
|
||||
await service.prune(now);
|
||||
const [where] = repo.delete.mock.calls[0] as unknown as [
|
||||
{ receivedAt: { value: Date } },
|
||||
];
|
||||
expect(now.getTime() - where.receivedAt.value.getTime()).toBe(
|
||||
ORDER_INFLOW_RETENTION_DAYS * DAY_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import { parseInflow } from '@/shared/utils/inflow';
|
||||
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
|
||||
import { OrderInflow } from './order-inflow.entity';
|
||||
|
||||
/**
|
||||
* 이만큼 지나도록 아임웹 주문과 이어지지 않으면 지운다 — 없는 주문번호이거나 끝내 결제되지 않은 주문이다.
|
||||
* 무통장입금은 입금 확인 후에야 구매로 잡히므로 입금 기한보다 넉넉히 둔다.
|
||||
*/
|
||||
export const ORDER_INFLOW_RETENTION_DAYS = 90;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* 구매 때 유입 수집 — 주문완료 화면이 보낸 값을 주문번호당 한 줄로 받아 둔다.
|
||||
*
|
||||
* 공개 페이지에서 오는 값이라 정말 그 주문인지는 확인할 수 없다. 그래서
|
||||
* (1) 먼저 온 값만 남기고 (2) 주문 동기화가 아임웹에 실제로 있는 주문과 이을 때만 쓰며
|
||||
* (3) 이어지지 않은 값은 보관 기간이 지나면 지운다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrderInflowsService {
|
||||
private readonly logger = new Logger(OrderInflowsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(OrderInflow)
|
||||
private readonly inflows: Repository<OrderInflow>,
|
||||
private readonly config: ImwebConfig,
|
||||
) {}
|
||||
|
||||
/** 같은 주문번호가 다시 오면 처음 값을 그대로 둔다 (새로고침·중복 전송에도 안전) */
|
||||
async record(dto: CreateOrderInflowDto): Promise<void> {
|
||||
const receivedAt = new Date();
|
||||
const inflow = parseInflow(
|
||||
dto.inflow,
|
||||
receivedAt.toISOString(),
|
||||
this.config.trackingHost,
|
||||
);
|
||||
await this.inflows
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(
|
||||
this.inflows.create({
|
||||
orderNo: dto.orderNo,
|
||||
inflowRaw: inflow.source ? (dto.inflow?.trim() ?? null) : null,
|
||||
inflowSource: inflow.source,
|
||||
inflowMedium: inflow.medium,
|
||||
inflowCampaign: inflow.campaign,
|
||||
inflowContent: inflow.content,
|
||||
partnerRefs: [...new Set(dto.partnerRefs ?? [])],
|
||||
receivedAt,
|
||||
}),
|
||||
)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 주문번호 → 주문완료 화면이 보낸 업체 링크 클릭 ID (보낸 적 없거나 비었으면 빠진다) */
|
||||
async partnerRefsByOrder(
|
||||
orderNos: readonly string[],
|
||||
): Promise<Map<string, string[]>> {
|
||||
const unique = [...new Set(orderNos)];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = await this.inflows.find({
|
||||
select: { orderNo: true, partnerRefs: true },
|
||||
where: { orderNo: In(unique) },
|
||||
});
|
||||
return new Map(
|
||||
rows
|
||||
.filter((row) => row.partnerRefs.length > 0)
|
||||
.map((row) => [row.orderNo, row.partnerRefs]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 보관 기간이 지난 값을 지운다 — 주문 동기화가 끝날 때 부른다 */
|
||||
async prune(now: Date = new Date()): Promise<number> {
|
||||
const result = await this.inflows.delete({
|
||||
receivedAt: LessThan(
|
||||
new Date(now.getTime() - ORDER_INFLOW_RETENTION_DAYS * DAY_MS),
|
||||
),
|
||||
});
|
||||
const deleted = result.affected ?? 0;
|
||||
if (deleted > 0) this.logger.log(`구매 때 유입 정리: ${deleted}건`);
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
/**
|
||||
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
|
||||
*
|
||||
* 회원·비회원 주문을 모두 남긴다. 비회원은 회원 코드가 없어 주문자 정보로만 누군지 안다.
|
||||
* 나중에 환불·취소되면 지우지 않고 isValid=false 로 바꾼다.
|
||||
* 금액은 관리하지 않는다.
|
||||
*/
|
||||
@Entity({ name: 'purchases' })
|
||||
@Index('uq_purchases_order_product', ['orderNo', 'prodNo'], { unique: true })
|
||||
@Index('idx_purchases_cursor', ['orderedAt', 'id'])
|
||||
@Index('idx_purchases_member', ['memberCode'])
|
||||
export class Purchase {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 아임웹 주문번호 */
|
||||
@Column({ name: 'order_no', type: 'varchar', length: 30 })
|
||||
orderNo!: string;
|
||||
|
||||
@Column({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
/** 주문 당시 상품명 */
|
||||
@Column({
|
||||
name: 'product_name',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
productName!: string | null;
|
||||
|
||||
/** 아임웹 회원 코드 — 비회원 주문이면 null */
|
||||
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
|
||||
memberCode!: string | null;
|
||||
|
||||
/** 주문 당시 아이디 — 비회원 주문이면 null */
|
||||
@Column({ name: 'member_uid', type: 'varchar', length: 255, nullable: true })
|
||||
memberUid!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'orderer_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
ordererName!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'orderer_email',
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
ordererEmail!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'orderer_call',
|
||||
type: 'varchar',
|
||||
length: 40,
|
||||
nullable: true,
|
||||
})
|
||||
ordererCall!: string | null;
|
||||
|
||||
/** 주문 시각 (아임웹 wtime) */
|
||||
@Column({ name: 'ordered_at', type: 'timestamptz' })
|
||||
orderedAt!: Date;
|
||||
|
||||
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
|
||||
@Column({ name: 'is_valid', type: 'boolean', default: true })
|
||||
isValid!: boolean;
|
||||
|
||||
/*
|
||||
* 구매 때 유입 — 주문완료 화면이 보낸 유입(order_inflows)을 주문 동기화가 주문번호로 이어 채운다.
|
||||
* 주문완료 화면이 보내지 못한 주문(다른 기기·스크립트 차단·도입 전 주문)은 null 이다.
|
||||
*/
|
||||
|
||||
/** 유입정보 원본 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식) */
|
||||
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
|
||||
inflowRaw!: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'inflow_source',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowSource!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_medium',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowMedium!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_campaign',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowCampaign!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'inflow_content',
|
||||
type: 'varchar',
|
||||
length: INFLOW_VALUE_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
inflowContent!: string | null;
|
||||
|
||||
/** 처음 잡힌 시각 */
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ImwebModule } from '@/modules/imweb/imweb.module';
|
||||
import { OrderInflow } from './order-inflow.entity';
|
||||
import { OrderInflowsController } from './order-inflows.controller';
|
||||
import { OrderInflowsService } from './order-inflows.service';
|
||||
import { Purchase } from './purchase.entity';
|
||||
import { PurchasesService } from './purchases.service';
|
||||
|
||||
/**
|
||||
* 유료 구매 기록 — 아임웹 주문 동기화(PartnersModule 의 PartnerOrderSyncService)가 채우고,
|
||||
* 관리자 신청·구매 내역(ActivitiesModule)이 읽는다.
|
||||
* 구매 때 유입은 주문완료 화면이 보낸 값(order_inflows)을 동기화가 주문번호로 이어 채운다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Purchase, OrderInflow]), ImwebModule],
|
||||
controllers: [OrderInflowsController],
|
||||
providers: [PurchasesService, OrderInflowsService],
|
||||
exports: [PurchasesService, OrderInflowsService],
|
||||
})
|
||||
export class PurchasesModule {}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
|
||||
import type { Purchase } from './purchase.entity';
|
||||
import { PurchasesService } from './purchases.service';
|
||||
|
||||
const PAID = [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }];
|
||||
|
||||
function order(overrides: Partial<ImwebOrderData>): ImwebOrderData {
|
||||
return {
|
||||
orderNo: 202610050001,
|
||||
isMember: 'Y',
|
||||
memberCode: 'm20261005abcdef',
|
||||
memberUid: 'user@test.dev',
|
||||
ordererName: '홍길동',
|
||||
ordererEmail: 'user@test.dev',
|
||||
ordererCall: '010-1234-5678',
|
||||
wtime: '2026-10-05T03:00:00.000Z',
|
||||
payments: PAID,
|
||||
sections: [
|
||||
{ sectionItems: [{ productInfo: { prodNo: 52, prodName: 'AI 강의' } }] },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
|
||||
interface InsertQueryMock {
|
||||
insert: jest.Mock<InsertQueryMock, []>;
|
||||
values: jest.Mock<InsertQueryMock, [Purchase[]]>;
|
||||
orUpdate: jest.Mock<InsertQueryMock, unknown[]>;
|
||||
execute: jest.Mock<Promise<void>, []>;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const inserted: Purchase[][] = [];
|
||||
const query: InsertQueryMock = {
|
||||
insert: jest.fn(() => query),
|
||||
values: jest.fn((rows: Purchase[]) => {
|
||||
inserted.push(rows);
|
||||
return query;
|
||||
}),
|
||||
orUpdate: jest.fn(() => query),
|
||||
execute: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
const repo = {
|
||||
create: jest.fn((row: Purchase) => row),
|
||||
createQueryBuilder: jest.fn(() => query),
|
||||
// 구매 때 유입 채우기 (UPDATE ... FROM order_inflows)
|
||||
query: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
const service = new PurchasesService(repo as unknown as Repository<Purchase>);
|
||||
return { service, inserted, query, repo };
|
||||
}
|
||||
|
||||
describe('PurchasesService.recordOrders', () => {
|
||||
it('결제된 회원 주문을 주문·상품 단위로 남긴다', async () => {
|
||||
const { service, inserted } = setup();
|
||||
await expect(service.recordOrders([order({})])).resolves.toBe(1);
|
||||
expect(inserted[0][0]).toMatchObject({
|
||||
orderNo: '202610050001',
|
||||
prodNo: 52,
|
||||
productName: 'AI 강의',
|
||||
memberCode: 'm20261005abcdef',
|
||||
memberUid: 'user@test.dev',
|
||||
ordererName: '홍길동',
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('비회원 주문은 회원 정보 없이 주문자 정보로 남긴다', async () => {
|
||||
const { service, inserted } = setup();
|
||||
await service.recordOrders([
|
||||
order({ isMember: 'N', memberCode: '', memberUid: '' }),
|
||||
]);
|
||||
expect(inserted[0][0]).toMatchObject({
|
||||
memberCode: null,
|
||||
memberUid: null,
|
||||
ordererName: '홍길동',
|
||||
ordererCall: '010-1234-5678',
|
||||
});
|
||||
});
|
||||
|
||||
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
|
||||
const { service, query, repo } = setup();
|
||||
await expect(
|
||||
service.recordOrders([
|
||||
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
|
||||
]),
|
||||
).resolves.toBe(0);
|
||||
expect(query.execute).not.toHaveBeenCalled();
|
||||
expect(repo.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('구매를 남긴 뒤 같은 주문번호의 구매 때 유입을 아직 비어 있는 구매에만 채운다', async () => {
|
||||
const { service, repo } = setup();
|
||||
await service.recordOrders([order({}), order({ orderNo: 202610050002 })]);
|
||||
expect(repo.query).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = repo.query.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown[],
|
||||
];
|
||||
expect(sql).toContain('"order_inflows"');
|
||||
expect(sql).toContain('p."inflow_source" IS NULL');
|
||||
expect(params).toEqual([['202610050001', '202610050002']]);
|
||||
});
|
||||
|
||||
it('구매 때 유입은 다시 볼 때 덮어쓰지 않는다 (갱신 목록에 없다)', async () => {
|
||||
const { service, query } = setup();
|
||||
await service.recordOrders([order({})]);
|
||||
const [columns] = query.orUpdate.mock.calls[0] as [string[]];
|
||||
expect(columns.some((column) => column.startsWith('inflow_'))).toBe(false);
|
||||
});
|
||||
|
||||
it('같은 주문이 두 번 와도 한 줄로 모은다 (ON CONFLICT 이중 갱신 방지)', async () => {
|
||||
const { service, inserted } = setup();
|
||||
await expect(service.recordOrders([order({}), order({})])).resolves.toBe(1);
|
||||
expect(inserted[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('이미 있는 주문·상품은 환불 여부와 주문자 정보만 갱신한다', async () => {
|
||||
const { service, query } = setup();
|
||||
await service.recordOrders([order({})]);
|
||||
expect(query.orUpdate).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['is_valid', 'orderer_name']),
|
||||
['order_no', 'prod_no'],
|
||||
{ skipUpdateIfNoValuesChanged: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('주문 시각을 읽을 수 없으면 건너뛴다', async () => {
|
||||
const { service } = setup();
|
||||
await expect(
|
||||
service.recordOrders([order({ wtime: 'not-a-date' })]),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
|
||||
import { purchaseLines } from '@/shared/utils/order-purchase';
|
||||
import { Purchase } from './purchase.entity';
|
||||
|
||||
/** 컬럼 길이 — 아임웹이 더 긴 값을 보내도 저장이 실패하지 않게 자른다 */
|
||||
const PRODUCT_NAME_MAX = 200;
|
||||
const NAME_MAX = 100;
|
||||
const EMAIL_MAX = 255;
|
||||
const CALL_MAX = 40;
|
||||
|
||||
function clip(value: string | null | undefined, max: number): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.slice(0, max) : null;
|
||||
}
|
||||
|
||||
/** 다시 볼 때마다 갱신하는 값 — 환불·취소 여부와 주문자 정보 */
|
||||
const OVERWRITE_COLUMNS = [
|
||||
'is_valid',
|
||||
'product_name',
|
||||
'member_code',
|
||||
'member_uid',
|
||||
'orderer_name',
|
||||
'orderer_email',
|
||||
'orderer_call',
|
||||
];
|
||||
const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
|
||||
|
||||
/**
|
||||
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
|
||||
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
|
||||
* 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PurchasesService {
|
||||
constructor(
|
||||
@InjectRepository(Purchase)
|
||||
private readonly purchases: Repository<Purchase>,
|
||||
) {}
|
||||
|
||||
/** @returns 남기거나 갱신한 대상 줄 수 (결제된 주문의 상품 수) */
|
||||
async recordOrders(orders: readonly ImwebOrderData[]): Promise<number> {
|
||||
// 같은 주문·상품이 한 문장에 두 번 들어가면 ON CONFLICT 가 실패하므로 하나로 모은다
|
||||
const rows = new Map<string, Purchase>();
|
||||
for (const order of orders) {
|
||||
const orderedAt = new Date(order.wtime);
|
||||
if (Number.isNaN(orderedAt.getTime())) continue;
|
||||
|
||||
const orderNo = String(order.orderNo);
|
||||
const isMember = order.isMember === 'Y' && !!order.memberCode;
|
||||
for (const line of purchaseLines(order)) {
|
||||
rows.set(
|
||||
`${orderNo}:${line.prodNo}`,
|
||||
this.purchases.create({
|
||||
id: randomUUID(),
|
||||
orderNo,
|
||||
prodNo: line.prodNo,
|
||||
productName: clip(line.productName, PRODUCT_NAME_MAX),
|
||||
memberCode: isMember ? (order.memberCode ?? null) : null,
|
||||
memberUid: isMember ? clip(order.memberUid, EMAIL_MAX) : null,
|
||||
ordererName: clip(order.ordererName, NAME_MAX),
|
||||
ordererEmail: clip(order.ordererEmail, EMAIL_MAX),
|
||||
ordererCall: clip(order.ordererCall, CALL_MAX),
|
||||
orderedAt,
|
||||
isValid: line.isValid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rows.size === 0) return 0;
|
||||
|
||||
await this.purchases
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values([...rows.values()])
|
||||
// 바뀐 게 없으면 다시 쓰지 않는다
|
||||
.orUpdate(OVERWRITE_COLUMNS, CONFLICT_COLUMNS, {
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
})
|
||||
.execute();
|
||||
await this.fillInflows([
|
||||
...new Set([...rows.values()].map((row) => row.orderNo)),
|
||||
]);
|
||||
return rows.size;
|
||||
}
|
||||
|
||||
/** 주문번호 → 주문자 이름 — 업체 실적의 비회원 구매를 누군지 보여줄 때 쓴다 */
|
||||
async ordererNames(
|
||||
orderNos: readonly string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(orderNos)];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = await this.purchases.find({
|
||||
select: { orderNo: true, ordererName: true },
|
||||
where: { orderNo: In(unique) },
|
||||
});
|
||||
const names = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (row.ordererName) names.set(row.orderNo, row.ordererName);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문완료 화면이 보낸 구매 때 유입(order_inflows)을 같은 주문번호의 구매에 채운다.
|
||||
* 아직 유입이 없는 구매에만 채운다 — 동기화가 같은 주문을 다시 봐도 값이 바뀌지 않는다.
|
||||
* 유입이 구매보다 늦게 들어와도 다음 동기화(최근 60일)에서 채워진다.
|
||||
*/
|
||||
private async fillInflows(orderNos: string[]): Promise<void> {
|
||||
if (orderNos.length === 0) return;
|
||||
await this.purchases.query(
|
||||
`UPDATE "purchases" AS p
|
||||
SET "inflow_raw" = o."inflow_raw",
|
||||
"inflow_source" = o."inflow_source",
|
||||
"inflow_medium" = o."inflow_medium",
|
||||
"inflow_campaign" = o."inflow_campaign",
|
||||
"inflow_content" = o."inflow_content"
|
||||
FROM "order_inflows" AS o
|
||||
WHERE p."order_no" = o."order_no"
|
||||
AND p."order_no" = ANY($1)
|
||||
AND p."inflow_source" IS NULL
|
||||
AND o."inflow_source" IS NOT NULL`,
|
||||
[orderNos],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { maskName } from './mask-name';
|
||||
|
||||
describe('maskName', () => {
|
||||
it('세 글자 이상이면 첫 글자와 마지막 글자만 남긴다', () => {
|
||||
expect(maskName('홍길동')).toBe('홍*동');
|
||||
expect(maskName('남궁민수')).toBe('남**수');
|
||||
});
|
||||
|
||||
it('두 글자면 첫 글자만, 한 글자면 모두 가린다', () => {
|
||||
expect(maskName('김철')).toBe('김*');
|
||||
expect(maskName('이')).toBe('*');
|
||||
});
|
||||
|
||||
it('값이 없으면 null 이다', () => {
|
||||
expect(maskName(null)).toBeNull();
|
||||
expect(maskName(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('공백·영문도 글자 수대로 가린다 (길이로 이름을 추측하기 어렵게 앞뒤만 남긴다)', () => {
|
||||
expect(maskName(' John Smith ')).toBe('J********h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 이름 가리기 (순수 함수) — 업체 계정 화면에 신청·구매자를 보여줄 때 쓴다.
|
||||
*
|
||||
* 업체는 자기 링크로 누가 왔는지 대략 확인할 수 있어야 하지만, 개인을 특정할 수 있으면 안 된다.
|
||||
* 연락처·이메일은 아예 내려주지 않고, 이름은 첫 글자와 마지막 글자만 남긴다.
|
||||
* 홍길동 → 홍*동 · 김철 → 김* · 남궁민수 → 남**수 · 이 → *
|
||||
*/
|
||||
const MASK = '*';
|
||||
|
||||
export function maskName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim();
|
||||
if (!trimmed) return null;
|
||||
// 한글·이모지도 한 글자로 센다 (UTF-16 길이가 아니라 코드 포인트 기준)
|
||||
const chars = [...trimmed.replace(/\s+/g, ' ')];
|
||||
if (chars.length === 1) return MASK;
|
||||
if (chars.length === 2) return `${chars[0]}${MASK}`;
|
||||
return `${chars[0]}${MASK.repeat(chars.length - 2)}${chars.at(-1)}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { OrderForPurchase, purchaseLines } from './order-purchase';
|
||||
|
||||
function order(
|
||||
paymentStatus: string,
|
||||
sections: NonNullable<OrderForPurchase['sections']>,
|
||||
): OrderForPurchase {
|
||||
return { payments: [{ paymentStatus, isCancel: 'N' }], sections };
|
||||
}
|
||||
|
||||
const item = (prodNo: number, prodName?: string, isPromotion = 'N') => ({
|
||||
isPromotion,
|
||||
productInfo: { prodNo, prodName },
|
||||
});
|
||||
|
||||
describe('purchaseLines', () => {
|
||||
it('결제완료 주문의 상품을 상품명과 함께 구매로 남긴다', () => {
|
||||
const lines = purchaseLines(
|
||||
order('PAYMENT_COMPLETE', [
|
||||
{
|
||||
orderSectionStatus: 'PURCHASE_CONFIRMATION',
|
||||
sectionItems: [item(52, ' AI 강의 ')],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
{ prodNo: 52, productName: 'AI 강의', isValid: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('결제 전·실패한 주문은 구매가 아니다', () => {
|
||||
for (const status of [
|
||||
'PAYMENT_PREPARATION',
|
||||
'PAYMENT_OVERDUE',
|
||||
'PAYMENT_FAILED',
|
||||
'CANCELLED_BEFORE_DEPOSIT',
|
||||
]) {
|
||||
expect(
|
||||
purchaseLines(order(status, [{ sectionItems: [item(52)] }])),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('전액 환불된 주문도 남기되 구매로 세지 않는다 (환불·취소 표시)', () => {
|
||||
expect(
|
||||
purchaseLines(order('REFUND_COMPLETE', [{ sectionItems: [item(52)] }])),
|
||||
).toEqual([{ prodNo: 52, productName: null, isValid: false }]);
|
||||
});
|
||||
|
||||
it('취소된 섹션의 상품은 세지 않고, 남은 상품은 그대로 센다', () => {
|
||||
const lines = purchaseLines(
|
||||
order('PARTIAL_REFUND_COMPLETE', [
|
||||
{ orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] },
|
||||
{ orderSectionStatus: 'DELIVERY_COMPLETE', sectionItems: [item(53)] },
|
||||
]),
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
{ prodNo: 52, productName: null, isValid: false },
|
||||
{ prodNo: 53, productName: null, isValid: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('사은품 품목은 구매 내역에 넣지 않는다', () => {
|
||||
expect(
|
||||
purchaseLines(
|
||||
order('PAYMENT_COMPLETE', [
|
||||
{ sectionItems: [item(52), item(99, '사은품', 'Y')] },
|
||||
]),
|
||||
).map((line) => line.prodNo),
|
||||
).toEqual([52]);
|
||||
});
|
||||
|
||||
it('취소 결제 행만 있는 주문은 결제된 것으로 보지 않는다', () => {
|
||||
expect(
|
||||
purchaseLines({
|
||||
payments: [{ paymentStatus: 'REFUND_COMPLETE', isCancel: 'Y' }],
|
||||
sections: [{ sectionItems: [item(52)] }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 구매 내역 판정 (순수 함수) — 아임웹 주문 한 건에서 구매 내역으로 남길 상품을 고른다.
|
||||
*
|
||||
* - 한 번이라도 결제된 주문의 상품만 남긴다. 결제 대기·실패·입금 전 취소 주문은 구매가 아니다.
|
||||
* - 나중에 환불·취소된 상품도 지우지 않고 isValid=false 로 남긴다 (화면에 '환불·취소'로 보인다).
|
||||
* - '지금 구매로 세는가'는 업체 실적과 같은 규칙(evaluateOrder)을 쓴다 — 두 화면의 구매 수가 어긋나지 않게.
|
||||
*/
|
||||
import { evaluateOrder, OrderForAttribution } from './partner-attribution';
|
||||
|
||||
/** 결제가 한 번은 끝났던 결제 상태 — 지금은 환불됐어도 구매 기록은 남긴다 */
|
||||
const EVER_PAID_PAYMENT_STATUSES = new Set([
|
||||
'PAYMENT_COMPLETE',
|
||||
'PARTIAL_REFUND_COMPLETE',
|
||||
'REFUND_PROCESSING',
|
||||
'REFUND_COMPLETE',
|
||||
'REFUND_FAILED',
|
||||
]);
|
||||
|
||||
/** 판정에 필요한 주문 필드 — 업체 실적 판정 필드 + 상품명 */
|
||||
export interface OrderForPurchase extends OrderForAttribution {
|
||||
sections?: {
|
||||
orderSectionStatus?: string | null;
|
||||
sectionItems?: {
|
||||
isPromotion?: string | null;
|
||||
productInfo?: {
|
||||
prodNo?: number | null;
|
||||
prodName?: string | null;
|
||||
} | null;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PurchaseLine {
|
||||
prodNo: number;
|
||||
/** 주문 당시 상품명 (없으면 null) */
|
||||
productName: string | null;
|
||||
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/** 주문 한 건 → 상품별 구매 내역 (상품당 한 줄, 사은품 제외) */
|
||||
export function purchaseLines(order: OrderForPurchase): PurchaseLine[] {
|
||||
const wasPaid = (order.payments ?? []).some(
|
||||
(payment) =>
|
||||
payment.isCancel !== 'Y' &&
|
||||
EVER_PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''),
|
||||
);
|
||||
if (!wasPaid) return [];
|
||||
|
||||
const names = new Map<number, string>();
|
||||
for (const section of order.sections ?? []) {
|
||||
for (const item of section.sectionItems ?? []) {
|
||||
const prodNo = item.productInfo?.prodNo;
|
||||
const name = item.productInfo?.prodName?.trim();
|
||||
if (typeof prodNo === 'number' && name && !names.has(prodNo)) {
|
||||
names.set(prodNo, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...evaluateOrder(order).products].map(([prodNo, isValid]) => ({
|
||||
prodNo,
|
||||
productName: names.get(prodNo) ?? null,
|
||||
isValid,
|
||||
}));
|
||||
}
|
||||
@@ -86,7 +86,10 @@ export function buildEntryUrl(siteUrl: string, code: string): string {
|
||||
|
||||
/** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */
|
||||
const UTM_PARAM_PREFIX = 'utm_';
|
||||
/** 업체 링크로 온 방문의 매체 값 */
|
||||
/**
|
||||
* 업체 링크로 온 방문의 매체 값.
|
||||
* imweb-header-top.html 의 PARTNER_MEDIUM 과 같아야 한다 — 이 값이면 UTM 스크립트가 최초유입을 업체로 덮어쓴다
|
||||
*/
|
||||
export const PARTNER_UTM_MEDIUM = 'partner';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
import {
|
||||
TEMPORARY_PASSWORD_LENGTH,
|
||||
generateTemporaryPassword,
|
||||
} from './temporary-password';
|
||||
|
||||
describe('generateTemporaryPassword', () => {
|
||||
it('정한 길이만큼 헷갈리는 글자 없이 만든다', () => {
|
||||
const password = generateTemporaryPassword(randomInt);
|
||||
expect(password).toHaveLength(TEMPORARY_PASSWORD_LENGTH);
|
||||
expect(password).toMatch(/^[A-HJ-NP-Za-km-z2-9]+$/);
|
||||
});
|
||||
|
||||
it('난수 함수가 고른 글자를 쓴다 (결정적)', () => {
|
||||
expect(generateTemporaryPassword(() => 0, 4)).toBe('AAAA');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 임시 비밀번호 생성 (순수 함수 — 난수 함수를 받는다).
|
||||
*
|
||||
* 최고관리자가 업체 계정을 만들거나 비밀번호를 초기화하면 서버가 만들어 화면에 한 번만 보여준다.
|
||||
* 전화·메신저로 불러 주기 쉽게 헷갈리는 글자(0·O·1·l·I)를 뺐다.
|
||||
*/
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
|
||||
export const TEMPORARY_PASSWORD_LENGTH = 14;
|
||||
|
||||
/** @param randomInt 0 이상 max 미만의 정수를 돌려주는 함수 (운영: crypto.randomInt) */
|
||||
export function generateTemporaryPassword(
|
||||
randomInt: (max: number) => number,
|
||||
length: number = TEMPORARY_PASSWORD_LENGTH,
|
||||
): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
result += ALPHABET[randomInt(ALPHABET.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# 유료 구매 추적 — 현황과 배포
|
||||
|
||||
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
|
||||
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절.
|
||||
|
||||
## 0. 한눈에
|
||||
|
||||
| 항목 | 상태 |
|
||||
| --- | --- |
|
||||
| 유료 구매 목록 (회원·비회원) | 구현 완료 · `main` 머지 · **운영 미배포** |
|
||||
| 신청·구매 통합 화면 | 구현 완료 · `main` 머지 · **운영 미배포** |
|
||||
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
|
||||
| 업체 링크 우선 (신청·구매 때 유입) | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4.5절) |
|
||||
| 구매 때 유입경로 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
|
||||
| 비회원 구매의 업체 실적 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
|
||||
|
||||
`feat/purchase-history` 는 `main` 에 머지·푸시됐다 (`9e6cae7`). 구매 때 유입 작업은 그 위의
|
||||
`feat/purchase-inflow` 브랜치에 있다 (2026-10-06 기준 푸시·머지 전).
|
||||
|
||||
---
|
||||
|
||||
## 1. 지금 구매에 대해 알 수 있는 것
|
||||
|
||||
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
|
||||
그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절).
|
||||
|
||||
| 항목 | 알 수 있나 | 근거 · 조건 |
|
||||
| --- | --- | --- |
|
||||
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
|
||||
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
|
||||
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
|
||||
| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 |
|
||||
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
|
||||
| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현된 것 (`feat/purchase-history`)
|
||||
|
||||
### 2.1 데이터 흐름
|
||||
|
||||
```
|
||||
아임웹 GET /orders ── 1시간마다 (IMWEB_SYNC_INTERVAL_MINUTES, 기본 60)
|
||||
│ PartnerOrderSyncService (modules/partners/partner-order-sync.service.ts)
|
||||
│ · 서버 기동 후 첫 실행: 최근 365일 / 성공 후: 최근 60일
|
||||
│ · 회원 여부로 거르지 않는다 (isMember 파라미터 없음)
|
||||
├─▶ PurchasesService.recordOrders() → purchases (모든 결제 주문)
|
||||
└─▶ PartnerAttributionService.attributeOrder() → partner_conversions
|
||||
(업체 링크 상품 + 회원 주문만)
|
||||
|
||||
관리자 화면 ── GET /api/admin/activities* (ActivitiesService)
|
||||
member_applications ∪ purchases (UNION ALL) + partner_conversions 로 업체 표시
|
||||
```
|
||||
|
||||
- 수동 실행: 관리자 화면 **업체 링크 → 구매 집계 새로고침** (`POST /api/admin/partner-order-sync-runs`)
|
||||
- "1년치 완료" 여부는 **메모리 플래그**(`isBackfilled`)다. 서버를 재시작할 때마다 첫 실행은 다시 1년치를 본다
|
||||
(재시작 동안 놓친 환불도 메운다). 1년치 중간에 실패하면 다음 실행에서 다시 1년치를 본다.
|
||||
|
||||
### 2.2 `purchases` 테이블 (마이그레이션 `1791220773446-AddPurchases`)
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
| --- | --- |
|
||||
| `id` uuid PK | 앱에서 생성 |
|
||||
| `order_no` varchar(30) + `prod_no` int | **유일** (`uq_purchases_order_product`) — 주문·상품당 한 줄 |
|
||||
| `product_name` | 주문 당시 상품명 (`productInfo.prodName`) |
|
||||
| `member_code` / `member_uid` | 비회원 주문이면 null |
|
||||
| `orderer_name` / `orderer_email` / `orderer_call` | 주문서 주문자 (회원 주문도 저장) |
|
||||
| `ordered_at` | 아임웹 wtime |
|
||||
| `is_valid` | 지금 구매로 세는가 — 환불·취소·반품이면 false |
|
||||
| `created_at` | 처음 잡힌 시각 |
|
||||
|
||||
- 저장은 `INSERT ... ON CONFLICT (order_no, prod_no) DO UPDATE` (`skipUpdateIfNoValuesChanged`) — 여러 번 돌려도 안전.
|
||||
- 금액은 저장하지 않는다 (요청사항).
|
||||
|
||||
### 2.3 판정 규칙 — `shared/utils/order-purchase.ts`
|
||||
|
||||
- **남기는 주문**: 결제가 한 번이라도 끝난 주문
|
||||
(`PAYMENT_COMPLETE` · `PARTIAL_REFUND_COMPLETE` · `REFUND_PROCESSING` · `REFUND_COMPLETE` · `REFUND_FAILED`, 취소 결제 행 제외).
|
||||
결제 대기·실패·입금 전 취소는 남기지 않는다.
|
||||
- **구매로 세는가(`is_valid`)**: 업체 실적과 같은 `evaluateOrder()` — 결제완료(부분 환불 포함)이고 그 상품 섹션이
|
||||
`CANCEL_COMPLETE`/`RETURN_COMPLETE` 가 아닐 때. → 업체 실적 구매 수와 화면 구매 수가 어긋나지 않는다.
|
||||
- 사은품 품목(`isPromotion=Y`)은 넣지 않는다.
|
||||
- **0원 주문도 결제완료면 구매로 남는다** (미결정 — 5절).
|
||||
|
||||
### 2.4 관리자 API (`modules/activities/`)
|
||||
|
||||
| 경로 | 용도 | 주요 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/activities` | 신청·구매 목록 (커서) | `type`(application·purchase), `from`/`to`, `productIdx`, `partnerId`(uuid·none), `q`, `inflowBasis`(apply·join)+`inflowField`+`inflowValue` |
|
||||
| `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` |
|
||||
| `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 |
|
||||
|
||||
- `basis=apply` 는 처음엔 무료 신청만 셌으나, 4절 이후 **신청·구매 때 유입**(구매 포함)이다. `join` 은 가입 경로 — 비회원은 값 없음.
|
||||
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
|
||||
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
|
||||
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
|
||||
|
||||
### 2.5 관리자 화면 (`imweb/imweb-members.html`)
|
||||
|
||||
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
|
||||
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
|
||||
- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다.
|
||||
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
|
||||
|
||||
### 2.6 파일
|
||||
|
||||
```
|
||||
backend/src/shared/utils/order-purchase.ts (+spec) 판정 규칙 (순수 함수)
|
||||
backend/src/modules/purchases/ 엔티티 · recordOrders (+spec)
|
||||
backend/src/modules/activities/ 관리자 통합 조회 (UNION ALL)
|
||||
backend/src/modules/partners/partner-order-sync.service.ts (+spec) 주문 동기화 (1년→60일)
|
||||
backend/src/modules/imweb/imweb.types.ts ordererName/Email/Call 추가
|
||||
backend/src/modules/applications/ 관리자 조회 제거 (수집만 남음)
|
||||
backend/src/migrations/1791220773446-AddPurchases.ts
|
||||
imweb/imweb-members.html · imweb/README.md
|
||||
```
|
||||
|
||||
### 2.7 검증한 것
|
||||
|
||||
- 백엔드 테스트 189개 통과, lint·build 통과.
|
||||
- 로컬 DB 에 회원 신청·회원 구매·비회원 구매·환불 건을 넣고 API(필터·커서·묶음·유입 집계·잘못된 커서 VAL_001)와
|
||||
실제 화면(종류 전환·업체별·상세 패널)을 확인. 넣은 데이터는 지웠다.
|
||||
- upsert 를 실제 Postgres 에서 확인 (결제 전 무시 → 결제 저장 → 재저장 1행 유지 → 환불 반영).
|
||||
- **실제 아임웹 주문으로는 아직 돌려보지 않았다.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 배포 순서 (아직 안 함)
|
||||
|
||||
`feat/purchase-history`(main 머지 완료)와 4절 작업을 **한 번에** 배포한다고 보고 적었다.
|
||||
|
||||
1. 4절 브랜치 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
|
||||
(마이그레이션 `AddPurchases` · `AddOrderInflows` 자동 실행, 에러 코드 변경 없음)
|
||||
2. **바로 이어서** 아임웹에 두 파일을 다시 붙여 넣는다
|
||||
- `imweb/imweb-members.html` → 관리자 페이지 코드 (예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
|
||||
- `imweb/imweb-header-top.html` → Header Code (업체 링크 우선 v1.2 · 결제창 리퍼러 제외 v1.3 · 구매 때 유입 전송)
|
||||
3. 업체 링크 메뉴의 **구매 집계 새로고침** → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인
|
||||
4. 비회원 구매 한 건을 열어 **주문자 이름·연락처가 실제로 채워지는지** 확인 (5절 첫 항목)
|
||||
5. 테스트 주문 한 건 → 주문완료 화면 → 다음 동기화(또는 새로고침) 후 그 구매의 상세 패널에 **구매 때 유입**이 보이는지 확인
|
||||
(브라우저 개발자 도구 네트워크 탭에서 `order-inflows` 가 204 인지도 볼 수 있다)
|
||||
|
||||
---
|
||||
|
||||
## 4. 구매 때 유입·비회원 업체 실적 (`feat/purchase-inflow`, 푸시·머지 전)
|
||||
|
||||
### 4.1 흐름
|
||||
|
||||
```
|
||||
주문완료 화면 (/shop_payment_complete?order_no=…)
|
||||
│ Header Code '구매 때 유입 전송' → { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() }
|
||||
│ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
|
||||
▼
|
||||
order_inflows order_no PK, inflow raw/파싱값, partner_refs varchar(16)[], received_at
|
||||
│ 주문 동기화(PartnerOrderSyncService)가 페이지마다
|
||||
├─▶ PurchasesService.recordOrders → UPDATE purchases … FROM order_inflows (inflow_source 가 빈 구매에만)
|
||||
└─▶ PartnerAttributionService.attributeOrder(order, linked, refs)
|
||||
후보 = 회원 클릭 연결(partner_touches) ∪ refs 의 실제 클릭(사용 중인 그 상품 링크) → 30일 안 마지막 클릭
|
||||
→ partner_conversions (비회원이면 member_code null)
|
||||
동기화가 끝까지 성공하면 90일 지난 order_inflows 를 지운다
|
||||
```
|
||||
|
||||
- 주문완료 주소의 `order_no` 는 아임웹 API `orderNo` 와 같다 (2026-10-06 관리자 화면에서 확인).
|
||||
- 유입 기준은 무료 신청과 같다 — 첫 경로(2일 보관), 업체 링크로 오면 그 업체가 우선 (Header Code v1.2).
|
||||
- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 — `AUTH_PATH` 에 `shop_payment` 추가 (v1.3).
|
||||
이전에는 `pay.kakao.com` 이 '카카오 소셜', `pay.naver.com` 이 '네이버 검색' 유입으로 잡힐 수 있었다.
|
||||
- 관리자 화면: 유입 기준 "신청 때 유입" → **"신청·구매 때 유입"**, 구매 행·상세 패널에 구매 때 유입 표시,
|
||||
업체 링크 실적 상세에 비회원 구매는 주문자 이름 + "비회원 구매".
|
||||
- 0원 주문은 지금처럼 둔다 (2026-10-06 결정).
|
||||
|
||||
### 4.2 파일
|
||||
|
||||
```
|
||||
backend/src/modules/purchases/order-inflow.entity.ts order_inflows
|
||||
backend/src/modules/purchases/order-inflows.{controller,service}.ts (+spec) POST /api/order-inflows · 보관·정리
|
||||
backend/src/modules/purchases/purchases.service.ts (+spec) 구매 때 유입 채우기 · 주문자 이름 조회
|
||||
backend/src/modules/purchases/purchase.entity.ts inflow_* 칸
|
||||
backend/src/modules/partners/partner-attribution.service.ts (+spec) 비회원 귀속 (refs)
|
||||
backend/src/modules/partners/partner-order-sync.service.ts (+spec) refs 전달 · 정리
|
||||
backend/src/modules/partners/entities/partner-conversion.entity.ts member_code nullable
|
||||
backend/src/modules/partners/partner-links.service.ts · dto 비회원 실적에 주문자 이름
|
||||
backend/src/modules/activities/ 구매도 신청·구매 때 유입으로 조회·집계
|
||||
backend/src/migrations/1791258525922-AddOrderInflows.ts
|
||||
imweb/imweb-header-top.html · imweb/imweb-members.html · imweb/README.md
|
||||
```
|
||||
|
||||
### 4.3 검증한 것
|
||||
|
||||
- 백엔드 테스트 201개 통과 (새 테스트 12개), lint·build 통과.
|
||||
- 로컬 Postgres: 공개 API(첫 값만 저장·형식 오류 VAL_001) → 구매 저장 시 유입 채움 → 비회원 주문이 클릭 ID 로
|
||||
업체에 귀속 → 목록·유입 필터·집계·업체 실적 상세(주문자 이름)까지 확인. 넣은 데이터는 지웠다.
|
||||
- Header Code 를 Node 로 흉내 내 확인: 결제창 리퍼러 무시, 업체 링크 클릭 ID 전송, 새로고침 시 재전송 없음,
|
||||
주문완료가 아닌 페이지·잘못된 주문번호면 보내지 않음.
|
||||
- **실제 아임웹 주문완료 화면에서는 아직 돌려보지 않았다** (3절 5번).
|
||||
|
||||
### 4.4 남은 확인
|
||||
|
||||
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2`. 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정 (무료 신청에도 같이 영향)
|
||||
- [ ] 비회원 구매 비중 — 배포 후 화면에서 확인
|
||||
- [ ] 예전 신청 기록 보정 — v1.2 이전 신청은 업체 링크로 왔어도 첫 경로가 광고일 수 있다
|
||||
(`inflow_last_medium='partner'` 인데 첫 유입이 다른 행). 보정할지 결정
|
||||
|
||||
### 4.5 업체 링크 우선 (같은 브랜치)
|
||||
|
||||
업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를 그 업체로 바꾼다 (Header Code v1.2).
|
||||
광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다.
|
||||
여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 열린 결정 · 알려진 한계
|
||||
|
||||
- **주문자 필드 실제 응답 미확인** — `ordererName/ordererEmail/ordererCall` 은 아임웹 문서 필드명으로 받는다.
|
||||
운영에서 비어 있으면 응답 구조를 다시 확인해야 한다.
|
||||
- **0원 주문** — 결제완료면 유료 구매로 잡힌다. 무료 상품을 아임웹에서 0원 주문으로 받는 경우가 있으면
|
||||
결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다).
|
||||
- 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
|
||||
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
|
||||
- 구매 때 유입은 공개 API 로 받으므로, 주문번호를 아는 사람이 먼저 가짜 값을 보내면 그 값이 남는다
|
||||
(실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다).
|
||||
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.
|
||||
@@ -22,6 +22,9 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'PARTNER_002': '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
|
||||
'PARTNER_003': '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
|
||||
'PARTNER_004': '같은 이름의 업체가 이미 있습니다.',
|
||||
'ACCOUNT_001': '이미 사용 중인 아이디입니다. 다른 아이디를 입력해 주세요.',
|
||||
'ACCOUNT_002': '현재 비밀번호가 올바르지 않습니다.',
|
||||
'ACCOUNT_003': '처음 받은 임시 비밀번호를 변경한 뒤 이용해 주세요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -22,6 +22,9 @@ export const ERROR_CODE_MESSAGES = {
|
||||
PARTNER_002: '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
|
||||
PARTNER_003: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
|
||||
PARTNER_004: '같은 이름의 업체가 이미 있습니다.',
|
||||
ACCOUNT_001: '이미 사용 중인 아이디입니다. 다른 아이디를 입력해 주세요.',
|
||||
ACCOUNT_002: '현재 비밀번호가 올바르지 않습니다.',
|
||||
ACCOUNT_003: '처음 받은 임시 비밀번호를 변경한 뒤 이용해 주세요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
+93
-11
@@ -72,23 +72,37 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 2026-10-05 `회원2026_10_05_1.xls` (2026-08-20 ~ 09-07 가입자 2,489명) 가져옴.
|
||||
유입경로 항목이 생긴 2026-08-20 이전 가입자(약 12,500명)는 복구할 수 없다.
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
|
||||
## 신청·구매 내역 (관리자 화면)
|
||||
|
||||
관리자 화면 **신청·구매** 메뉴는 무료 신청과 유료 구매를 한 화면에서 본다
|
||||
(`GET /api/admin/activities` · `/groups` · `/inflow-stats`).
|
||||
|
||||
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
|
||||
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
|
||||
- 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과
|
||||
'가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다.
|
||||
주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
|
||||
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청·구매** 메뉴에서 본다.
|
||||
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
|
||||
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
|
||||
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
|
||||
(최종 유입은 저장만 하고 화면에는 보여주지 않는다)
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 '유입' 집계에서 기준을 바꿔 비교한다
|
||||
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| --- | --- | --- |
|
||||
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
|
||||
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
|
||||
| 신청 때 유입 | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | facebook |
|
||||
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
|
||||
|
||||
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
|
||||
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
|
||||
- **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를
|
||||
그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체.
|
||||
여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다)
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
@@ -97,6 +111,40 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
|
||||
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
|
||||
|
||||
## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역)
|
||||
|
||||
아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어
|
||||
결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다.
|
||||
|
||||
- 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다
|
||||
- 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다
|
||||
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
|
||||
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
|
||||
- 금액은 관리하지 않는다
|
||||
- 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
|
||||
|
||||
## 구매 때 유입 (주문완료 화면 → 구매 기록)
|
||||
|
||||
아임웹 주문에는 손님이 어디서 왔는지가 없어서, Header Code(`imweb-header-top.html` 의 '구매 때 유입 전송')가
|
||||
주문완료 화면(`/shop_payment_complete?order_no=주문번호`)에서 `POST /api/order-inflows` 로 보낸다.
|
||||
|
||||
```
|
||||
주문완료 화면 ── { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() }
|
||||
│ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
|
||||
▼
|
||||
order_inflows ── 주문 동기화(1시간마다)가 같은 주문번호의 구매에 붙인다
|
||||
├─▶ purchases.inflow_* 구매 때 유입 (비어 있는 구매에만 — 한 번 붙으면 바뀌지 않는다)
|
||||
└─▶ partner_conversions 업체 실적 — 회원 클릭 연결 + 주문완료 클릭 ID 중 마지막 클릭 (비회원도)
|
||||
```
|
||||
|
||||
- 주소의 `order_no` 가 아임웹 API 의 `orderNo` 와 같은 값이다 (관리자 화면에서 확인함)
|
||||
- 유입 기준은 무료 신청과 같다 — 처음 들어온 경로(2일 보관), 단 업체 링크로 오면 그 업체가 우선
|
||||
- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 (`/shop_payment*` 경로 제외, v1.3)
|
||||
- 무통장입금은 주문완료 때 받아 두었다가 입금이 확인돼 구매로 잡힐 때 붙는다
|
||||
- 90일 동안 주문과 이어지지 않은 값(없는 주문번호·끝내 결제되지 않은 주문)은 동기화가 지운다
|
||||
- 한계: 다른 기기에서 결제하거나 스크립트가 막힌 브라우저면 남지 않는다. 공개 API 라 주문번호를 아는 사람은
|
||||
먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다)
|
||||
|
||||
## 업체 링크 (업체별 신청·구매 추적)
|
||||
|
||||
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
|
||||
@@ -116,19 +164,23 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
|
||||
▼
|
||||
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
|
||||
구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
|
||||
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 결제완료 상품을 귀속 (회원은 클릭 연결, 비회원은 주문완료 화면의 클릭 ID), 환불·취소는 집계에서 뺀다
|
||||
```
|
||||
|
||||
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
|
||||
- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
|
||||
- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
|
||||
링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
|
||||
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다
|
||||
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다.
|
||||
먼저 광고 등으로 들어와 첫 경로가 남아 있어도 업체로 덮어쓴다 (Header Code v1.2)
|
||||
- 단, 관리자가 이동 주소에 직접 UTM 을 넣은 링크는 `utm_medium=partner` 가 아니므로 덮어쓰지 않는다
|
||||
- 업체 실적은 **그 상품의** 링크만 세지만 유입은 상품을 가리지 않는다 —
|
||||
A 상품 업체 링크로 들어와 B 상품을 신청하면 신청 때 유입은 업체, 업체 칸은 비어 있다
|
||||
- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
|
||||
사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
|
||||
- 한계
|
||||
- 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다
|
||||
- 비회원 주문은 회원과 이을 수 없어 세지 않는다
|
||||
- 비회원 주문은 주문완료 화면이 클릭 ID 를 보냈을 때만 센다 (위 '구매 때 유입')
|
||||
- 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인)
|
||||
- 60일보다 오래된 구매의 환불은 반영되지 않는다
|
||||
|
||||
@@ -145,14 +197,44 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
(업체에 주는 `www.moneyguider.co.kr/go?c=코드` 의 입구. 이 페이지가 없으면 링크가 404 가 된다)
|
||||
7. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
|
||||
|
||||
## 업체 계정 (업체가 자기 실적을 직접 본다)
|
||||
|
||||
같은 관리자 페이지에 업체도 로그인한다. 역할에 따라 보이는 메뉴와 쓸 수 있는 API 가 다르다.
|
||||
|
||||
| 역할 | 계정 | 볼 수 있는 것 |
|
||||
| --- | --- | --- |
|
||||
| 최고관리자 | env 의 `ADMIN_USERNAME`/`ADMIN_PASSWORD` (지금과 같다) | 모든 메뉴 + 업체 계정 관리 |
|
||||
| 업체 계정 | 최고관리자가 업체마다 만든다 (DB `admin_accounts`) | **내 업체 실적**만 — 자기 링크(나눠 줄 주소)·클릭·신청·구매 수, 신청·구매 목록 |
|
||||
|
||||
- 만들기: **업체 링크** → 업체를 고르고 **계정 관리** → **+ 계정 만들기** → 아이디 입력
|
||||
→ 서버가 만든 **임시 비밀번호를 한 번만** 보여준다 (닫으면 다시 볼 수 없다). 로그인 주소·아이디와 함께 업체에 전달한다
|
||||
- 업체는 첫 로그인 때 비밀번호를 직접 바꿔야 실적을 볼 수 있다 (바꾸기 전 업체 화면 API 는 `ACCOUNT_003`)
|
||||
- 비밀번호를 잊으면 최고관리자가 **비밀번호 초기화** → 새 임시 비밀번호. 열려 있던 화면은 바로 로그아웃된다
|
||||
- 거래가 끝나면 **사용 스위치를 끈다** (중지) — 로그인할 수 없고 열려 있던 화면도 바로 끊긴다. 업체를 지우면 계정도 함께 지워진다
|
||||
- 개인정보: 업체 화면의 신청·구매자는 **이름 일부만**(홍\*동) 보이고 연락처·이메일·아이디·주문번호는 내려주지 않는다
|
||||
- 권한은 서버가 막는다 — 기존 관리 API 는 모두 최고관리자 전용(`AdminAuthGuard`, 업체 토큰은 `AUTH_003`),
|
||||
업체 화면 API(`/api/partner-portal/*`)는 **로그인 정보의 업체 ID 로만** 거른다 (요청값으로 다른 업체를 볼 수 없다)
|
||||
- 아이디는 대소문자를 가리지 않는다. 최고관리자 아이디와 같은 아이디는 만들 수 없다 (`ACCOUNT_001`)
|
||||
- 아임웹 회원 계정으로 최고관리자를 판단하지 않는다 — 브라우저의 아임웹 회원 정보(`MEMBER_UID`)는
|
||||
누구나 다른 값으로 보낼 수 있어 권한 판단에 쓸 수 없다. 페이지를 아임웹 회원 그룹으로 제한하는 것은 화면 단계의 추가 잠금으로만 쓴다
|
||||
|
||||
| API | 누가 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `GET/POST /api/admin/partners/{업체 ID}/accounts` | 최고관리자 | 업체 계정 목록·만들기 |
|
||||
| `PATCH /api/admin/partner-accounts/{ID}` | 최고관리자 | 사용/중지 |
|
||||
| `POST /api/admin/partner-accounts/{ID}/password-resets` | 최고관리자 | 비밀번호 초기화 |
|
||||
| `PUT /api/admin/accounts/current/password` | 업체 계정 본인 | 비밀번호 변경 (1분 5회) |
|
||||
| `GET /api/partner-portal/summary` · `links` · `conversions` | 업체 계정 | 내 업체 요약·링크·신청·구매 |
|
||||
|
||||
## 인증 방식 — 왜 쿠키가 아닌가
|
||||
|
||||
백엔드(`moneyguide.wandergo.net`)와 관리자 화면(`www.moneyguider.co.kr`)이 **다른 사이트**라
|
||||
인증 쿠키는 제3자 쿠키가 되어 Safari·Chrome 시크릿 모드 등에서 차단된다. 그래서
|
||||
|
||||
- 로그인 응답으로 토큰을 받아 **sessionStorage** 에 두고 `Authorization: Bearer` 로 보낸다 (탭을 닫으면 사라짐)
|
||||
- 관리자 계정은 env 의 단일 계정이다. 여러 사람이 같은 계정으로 각자 로그인하고,
|
||||
- 최고관리자 계정은 env 의 단일 계정이다. 여러 사람이 같은 계정으로 각자 로그인하고,
|
||||
로그아웃은 그 기기만 끊는다. env 비밀번호를 바꾸고 재시작하면 모든 기기가 끊긴다
|
||||
- 업체 계정(위 절)도 같은 방식이다. 비밀번호를 바꾸거나 초기화·중지하면 그 계정의 다른 기기가 끊긴다
|
||||
- 액세스 1시간 / 리프레시 12시간 (`JWT_ACCESS_EXPIRES_IN` / `JWT_REFRESH_EXPIRES_IN`)
|
||||
- 서버가 요청마다 토큰 세대를 확인해 **로그아웃하면 발급된 토큰이 즉시 모두 무효**가 된다
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.3
|
||||
v1.2: 업체 링크(utm_medium=partner) 방문은 보관 중인 최초유입을 덮어쓴다
|
||||
v1.3: 결제 페이지(/shop_payment*)에서는 결제창 리퍼러를 유입으로 기록하지 않는다
|
||||
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
|
||||
> 고급 > 공통 코드 삽입 > "Header Code"
|
||||
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
|
||||
@@ -51,6 +53,7 @@
|
||||
HP_ENABLED: true, // 홈페이지 항목에도 유입정보 기록 (Open API homePage 로 조회용)
|
||||
HP_LABEL: '홈페이지', // 홈페이지 입력칸을 name/id로 못 찾을 때 라벨로 재탐색
|
||||
OWN_HOST: 'moneyguider.co.kr',
|
||||
PARTNER_MEDIUM: 'partner', // 업체 링크 UTM 의 utm_medium (백엔드 PARTNER_UTM_MEDIUM 과 같아야 한다)
|
||||
DEBUG: false // true로 바꾸면 콘솔에 로그 출력
|
||||
};
|
||||
|
||||
@@ -158,11 +161,14 @@
|
||||
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
|
||||
];
|
||||
|
||||
/* 인증·가입 관련 페이지.
|
||||
/* 인증·가입·결제 관련 페이지.
|
||||
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
|
||||
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
|
||||
결제도 같다 — 결제창(카카오페이 pay.kakao.com, 네이버페이 pay.naver.com, 카드사 등)에서
|
||||
주문완료(/shop_payment_complete)로 돌아올 때의 리퍼러가 '카카오 소셜'·'네이버 검색'
|
||||
유입으로 잡혀 구매 때 유입을 가린다.
|
||||
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/i;
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join|shop_payment)/i;
|
||||
|
||||
/* 소셜 로그인 인증 도메인.
|
||||
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
|
||||
@@ -253,6 +259,8 @@
|
||||
if (t.ref && AUTH_REF.test(t.ref) && !explicitUtm) return;
|
||||
|
||||
var isDirect = (t.src === 'direct');
|
||||
// 업체 링크로 온 방문 — 업체 단축 링크가 붙인 UTM 이 주소에 실제로 있을 때만
|
||||
var isPartner = explicitUtm && t.med === CFG.PARTNER_MEDIUM;
|
||||
|
||||
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
|
||||
if (!isDirect) saveTouch(K_LAST, t);
|
||||
@@ -260,6 +268,10 @@
|
||||
var prev = readTouch(K_FIRST);
|
||||
if (!prev) {
|
||||
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
|
||||
} else if (isPartner) {
|
||||
// 업체 링크는 이전 유입(광고 등)보다 우선한다 — 신청 때 유입·가입 경로가
|
||||
// 업체 실적(가장 최근에 누른 업체 링크로 귀속)과 같은 업체를 가리키게 한다
|
||||
saveTouch(K_FIRST, t);
|
||||
} else if (!isDirect && prev.src === 'direct') {
|
||||
saveTouch(K_FIRST, t); // 이전 기록이 direct였다면 실제 유입원으로 승격
|
||||
}
|
||||
@@ -648,4 +660,59 @@
|
||||
window.setTimeout(poll, POLL_MS);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] 구매 때 유입 전송 v1.0
|
||||
위 두 스크립트(MG_UTM · MG_PARTNER) 뒤에 있어야 한다.
|
||||
|
||||
주문완료 화면(/shop_payment_complete?order_no=주문번호)에서 주문번호와
|
||||
그 순간의 유입정보(MG_UTM.peek().value), 업체 링크 클릭 ID(MG_PARTNER.refs())를
|
||||
서버에 한 번 보낸다.
|
||||
→ 서버가 아임웹 주문 동기화(1시간마다) 때 같은 주문번호의 구매에 붙인다.
|
||||
구매 때 유입이 남고, 비회원 구매도 업체 링크 실적으로 잡힌다.
|
||||
무통장입금은 입금이 확인돼 구매로 잡힐 때 붙는다.
|
||||
같은 주문번호는 서버가 처음 값만 남기므로 새로고침해도 안전하다.
|
||||
============================================================ -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 위 업체 링크 추적과 같은 값
|
||||
var COMPLETE_PATH = /^\/shop_payment_complete\/?$/; // 아임웹 주문완료 페이지
|
||||
var ORDER_PARAM = 'order_no'; // 아임웹 API 의 orderNo 와 같은 값
|
||||
var ORDER_NO_PATTERN = /^\d{1,30}$/; // 백엔드 ORDER_NO_PATTERN 과 같아야 한다
|
||||
var INFLOW_MAX = 1000;
|
||||
var K_SENT = 'mg_order_sent'; // 이 탭에서 이미 보낸 주문번호 (새로고침 때 다시 보내지 않게)
|
||||
|
||||
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
|
||||
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
|
||||
|
||||
try {
|
||||
if (!COMPLETE_PATH.test(location.pathname)) return;
|
||||
var orderNo = new URLSearchParams(location.search).get(ORDER_PARAM);
|
||||
if (!orderNo || !ORDER_NO_PATTERN.test(orderNo)) return;
|
||||
if (typeof window.fetch !== 'function' || ssGet(K_SENT) === orderNo) return;
|
||||
|
||||
var body = { orderNo: orderNo };
|
||||
try {
|
||||
var peeked = window.MG_UTM && window.MG_UTM.peek();
|
||||
var value = peeked && peeked.value;
|
||||
if (typeof value === 'string' && value) body.inflow = value.slice(0, INFLOW_MAX);
|
||||
} catch (e) { }
|
||||
try {
|
||||
var refs = window.MG_PARTNER && window.MG_PARTNER.refs();
|
||||
if (refs && refs.length) body.partnerRefs = refs;
|
||||
} catch (e) { }
|
||||
|
||||
window.fetch(API_BASE + '/order-inflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
if (res.ok) ssSet(K_SENT, orderNo); // 실패하면 새로고침 때 다시 보낸다
|
||||
}).catch(function () { });
|
||||
} catch (e) { }
|
||||
})();
|
||||
</script>
|
||||
+2158
-1168
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user