12 Commits

Author SHA1 Message Date
ttipo 11d8b07d43 Merge branch 'feat/partner-accounts'
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 18:22:27 +09:00
ttipo 10a931aba2 docs: 업체 계정 운영 안내와 권한 구조 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:21 +09:00
ttipo 696449bc42 feat(imweb): 업체 계정 로그인 화면과 최고관리자 계정 관리
- 업체 계정: '내 업체 실적' 메뉴만 보인다 (기간별 클릭·신청·구매, 나눠 줄 링크, 가린 이름의 신청·구매 목록).
  임시 비밀번호면 닫을 수 없는 비밀번호 변경 창을 먼저 띄운다 (대신 로그아웃 가능)
- 최고관리자: 업체 링크 → 업체 선택 → '계정 관리'에서 만들기·사용/중지·비밀번호 초기화.
  임시 비밀번호는 한 번만 보여주고 바깥 클릭·Esc 로 닫히지 않는다. 복사해도 알림에 비밀번호를 띄우지 않는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:20 +09:00
ttipo 97780eb4b6 feat(backend): 최고관리자가 만드는 업체 계정과 업체 실적 화면 API
관리자 화면에는 env 계정 하나만 있어 업체가 자기 실적을 볼 방법이 없었다.
env 계정은 최고관리자로 그대로 두고, 최고관리자가 업체마다 로그인 계정을 만든다.

- admin_accounts(업체 계정) · admin_sessions.account_id — 비밀번호는 bcrypt 해시만
- 토큰에 역할(super·partner)을 싣고, 계정 지문으로 비밀번호 변경·초기화 시 다른 기기를 끊는다.
  중지된 계정은 즉시 거부한다
- 가드 분리: AdminAuthGuard 는 최고관리자 전용(기존 관리 API 전부, 업체 토큰은 AUTH_003),
  PartnerAuthGuard 는 업체 계정 전용(임시 비밀번호면 ACCOUNT_003), AccountAuthGuard 는 공통
- 업체 계정 관리: 만들기·사용/중지·비밀번호 초기화 — 임시 비밀번호는 응답에서 한 번만
- 업체 화면 /api/partner-portal: 요약·링크·신청·구매. 업체 ID 는 로그인 정보에서만 가져오고
  신청·구매자는 가린 이름만 내려준다 (연락처·이메일·아이디·주문번호 없음)
- 없는 아이디도 bcrypt 비교를 거쳐 응답 시간으로 아이디 존재를 알 수 없게 한다.
  bcryptjs(순수 JS)라 비용은 10 — 무차별 대입은 로그인 요청 제한이 함께 막는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:20 +09:00
ttipo 7b5d16bfea feat(backend): 업체 계정용 에러 코드 등록 (ACCOUNT_001~003)
- ACCOUNT_001 (409): 이미 사용 중인 아이디
- ACCOUNT_002 (400): 현재 비밀번호 불일치
- ACCOUNT_003 (403): 임시 비밀번호를 바꾸기 전 실적 조회

npm run sync:error-codes 로 프론트엔드·Flutter 사전을 함께 갱신했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:20 +09:00
ttipo fbd040ba6d Merge branch 'style/admin-brand-colors'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 13:37:54 +09:00
ttipo 4ddfe2bbc3 style(imweb): 관리자 화면을 자본주의가이드 색으로 바꾸고 로고 제거
파란 강조색·푸른 회색 테마를 사이트와 같은 블랙(#141414)·골드(#f5b727)로 바꾼다
(값은 신청 완료 위젯 imweb-free-apply.html 과 같다).

골드는 흰 배경 위 글자로는 대비가 부족해 채우는 색(버튼·막대·칩·배지)으로만 쓰고
그 위 글자는 #1a1200, 흰 배경 위 링크·선택 표시·포커스는 진한 골드(#8a5d00)로 나눴다.
주의 색은 골드와 헷갈리지 않게 주황 쪽으로 옮겼다.
로그인·사이드바의 ₩ 로고를 지우고 사이드바 이름을 골드로 표시한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:37:54 +09:00
ttipo f29d48fcfa Merge branch 'feat/purchase-inflow'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 13:18:02 +09:00
ttipo bf891d2177 docs: 구매 때 유입·비회원 업체 실적 구현 현황과 배포 순서 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:59 +09:00
ttipo 5badf94dc7 feat(imweb): 주문완료 화면에서 구매 때 유입 전송·관리자 화면에 표시
- Header Code '구매 때 유입 전송': /shop_payment_complete 에서 order_no·유입·업체 클릭 ID 를
  POST /api/order-inflows 로 한 번 보낸다 (같은 탭 새로고침 때는 다시 보내지 않는다)
- 결제 페이지(/shop_payment*)에서는 리퍼러를 유입으로 기록하지 않는다 (v1.3) —
  결제창에서 돌아올 때 pay.kakao.com 이 '카카오 소셜', pay.naver.com 이 '네이버 검색'
  유입으로 잡혀 구매 때 유입을 가릴 수 있었다
- 관리자 화면: 유입 기준을 '신청·구매 때 유입'으로, 구매 행·상세 패널에 구매 때 유입,
  업체 실적 상세의 비회원 구매는 주문자 이름으로 보여준다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:42 +09:00
ttipo 1f71ce048f feat(backend): 주문완료 화면의 구매 때 유입 수집과 비회원 구매 업체 귀속
아임웹 주문 API 에는 손님이 어디서 왔는지가 없어 구매는 가입 경로로만 볼 수 있었고,
비회원 구매는 회원 코드가 없어 업체 링크 실적으로 잡을 수 없었다.

- POST /api/order-inflows: 주문완료 화면이 주문번호·유입·업체 클릭 ID 를 보낸다
  (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
- 주문 동기화가 같은 주문번호의 구매에 유입을 채운다 (비어 있는 구매에만)
- 업체 귀속 후보 = 회원 클릭 연결 + 주문완료 클릭 ID → 비회원 구매도 마지막 클릭에 귀속
  (partner_conversions.member_code nullable, 실적 상세에 주문자 이름)
- 90일 동안 주문과 이어지지 않은 값은 동기화가 끝날 때 지운다
- 관리자 조회: 유입 기준 apply 를 신청·구매 때 유입으로 넓힌다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:33 +09:00
ttipo 8d4adfd7f1 feat(imweb): 업체 링크로 들어오면 신청 때 유입을 그 업체로 바꿈
광고로 가입한 회원이 업체 링크로 다시 와서 신청해도, 보관 중인 첫 경로(광고)가
남아 신청 때 유입이 광고로 잡혔다. 업체 칸(업체 실적)은 그 업체라 한 줄 안에서
유입과 업체가 어긋났다.

업체 단축 링크가 붙인 UTM(utm_medium=partner)으로 들어오면 첫 경로를 그 업체로
덮어쓴다. 여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
화면의 유입 기준은 그대로 하나만 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:22 +09:00
56 changed files with 3564 additions and 305 deletions
+10
View File
@@ -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",
+1
View File
@@ -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",
@@ -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;
},
);
+13
View File
@@ -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,
);
});
});
+66 -9
View File
@@ -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,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"`);
}
}
@@ -60,7 +60,7 @@ export class AdminActivitiesController {
@Get('inflow-stats')
@ApiOperation({
summary:
'유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준',
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
})
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
description: '집계 성공',
@@ -198,10 +198,10 @@ export class ActivitiesService {
[`${PUR}.member_uid`, 'member_uid'],
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
['CAST(NULL AS varchar)', 'inflow_source'],
['CAST(NULL AS varchar)', 'inflow_medium'],
['CAST(NULL AS varchar)', 'inflow_campaign'],
['CAST(NULL AS varchar)', 'inflow_content'],
[`${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'],
@@ -272,14 +272,12 @@ export class ActivitiesService {
}
: null,
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
inflow: isApplication
? {
source: row.inflow_source,
medium: row.inflow_medium,
campaign: row.inflow_campaign,
content: row.inflow_content,
}
: 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
@@ -388,9 +386,10 @@ export class ActivitiesService {
}
/**
* 유입 집계.
* - 신청 때 유입: 무료 신청만 센다 (구매 시점의 유입은 아임웹 주문에 남지 않는다)
* - 가입 경로: 신청·구매(환불·취소 제외) 모두 회원의 가입 경로로 센다. 비회원 구매는 값 없음
* 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다.
* - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값.
* 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음
* - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음
*/
async inflowStats(
query: ActivityInflowStatsQueryDto,
@@ -412,13 +411,18 @@ export class ActivitiesService {
]),
);
}
const pur = isJoin ? this.purchasePart(query) : null;
const pur = this.purchasePart(query);
if (pur) {
pur.andWhere(`${PUR}.is_valid = true`);
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[joinInflow(PUR_MEMBER, query.groupBy), 'key'],
[
isJoin
? joinInflow(PUR_MEMBER, query.groupBy)
: `${PUR}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
@@ -466,16 +470,11 @@ export class ActivitiesService {
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
}
/** 유료 구매 쪽 — 종류 필터로 빠지거나, 신청 때 유입으로 거르면(구매엔 없는 기록) null */
/** 유료 구매 쪽 — 종류 필터로 빠지면 null */
private purchasePart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
if (filter.type === 'application') return null;
const filtersByApplyInflow =
filter.inflowBasis === 'apply' &&
!!filter.inflowField &&
!!filter.inflowValue;
return filtersByApplyInflow ? null : this.purchaseQuery(filter);
return filter.type === 'application' ? null : this.purchaseQuery(filter);
}
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
@@ -521,7 +520,7 @@ export class ActivitiesService {
return qb;
}
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다. 유입 필터는 가입 경로일 때만 건다 */
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
@@ -552,17 +551,14 @@ export class ActivitiesService {
`${PUR}.orderer_call`,
],
});
if (
filter.inflowBasis === 'join' &&
filter.inflowField &&
filter.inflowValue
) {
qb.andWhere(
`${joinInflow(PUR_MEMBER, filter.inflowField)} = :inflowValue`,
{
inflowValue: filter.inflowValue,
},
);
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;
}
@@ -26,7 +26,8 @@ const PARTNER_FILTER_PATTERN =
/**
* 유입을 어느 기록으로 볼 것인가.
* - apply : 신청 때 유입 — 신청 시점 브라우저에 남아 있던 경로 (2일 보관). 구매에는 이 기록이 없다
* - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일).
* 주문완료 화면이 보내지 못한 구매는 값 없음
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
*/
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
@@ -100,7 +101,7 @@ export class ActivityListQueryDto extends IntersectionType(
) {
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply(신청 때 유입, 구매는 빠진다) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
'유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: ACTIVITY_INFLOW_BASIS,
})
@IsOptional()
@@ -139,7 +140,7 @@ export class ActivityListQueryDto extends IntersectionType(
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply(신청 때 유입, 무료 신청만 · 기본) · join(가입 경로, 신청·구매 모두)',
'집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)',
enum: ACTIVITY_INFLOW_BASIS,
default: 'apply',
})
@@ -106,11 +106,10 @@ export class ActivityResponseDto {
@ApiProperty({
description:
'[신청] 신청 때 유입 (신청 시점 브라우저에 남아 있던 경로) — 구매면 null',
'신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null',
type: ActivityInflowDto,
nullable: true,
})
inflow!: ActivityInflowDto | null;
inflow!: ActivityInflowDto;
@ApiProperty({
description: '[구매] 아임웹 주문번호 — 신청이면 null',
@@ -212,7 +211,7 @@ export class ActivityInflowStatsResponseDto {
applicationTotal!: number;
@ApiProperty({
description: '집계한 유료 구매 수 (가입 경로 기준일 때만, 환불·취소 제외)',
description: '집계한 유료 구매 수 (환불·취소 제외)',
example: 10,
})
purchaseTotal!: number;
@@ -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 } });
}
}
}
@@ -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,
};
}),
{
@@ -3,6 +3,7 @@ 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';
@@ -11,7 +12,10 @@ 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[] = []) {
function setup(
linked: number[] = [],
refsByOrder = new Map<string, string[]>(),
) {
const get = jest.fn(() =>
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
);
@@ -24,9 +28,14 @@ function setup(linked: number[] = []) {
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,
@@ -38,7 +47,7 @@ function setup(linked: number[] = []) {
};
return new Date(params.startWtime).getTime();
};
return { service, get, attribution, purchases, sinceOf };
return { service, get, attribution, purchases, orderInflows, sinceOf };
}
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
@@ -64,9 +73,34 @@ describe('PartnerOrderSyncService', () => {
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);
@@ -10,6 +10,7 @@ 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';
@@ -44,7 +45,7 @@ function toImwebTime(date: Date): string {
/**
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두)
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다)
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
@@ -70,6 +71,7 @@ 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,
@@ -155,12 +157,17 @@ export class PartnerOrderSyncService
},
);
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;
@@ -170,6 +177,8 @@ export class PartnerOrderSyncService
if (data.list.length === 0 || page >= data.totalPage) break;
}
this.isBackfilled = true;
// 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만)
await this.orderInflows.prune();
this.status = {
...this.status,
@@ -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]));
}
}
@@ -19,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,
@@ -29,6 +31,7 @@ import { PartnersService } from './partners.service';
/**
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
* 업체 계정은 업체 화면(PartnerPortalController)에서 자기 업체의 링크·실적만 본다.
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
@@ -55,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;
}
}
@@ -6,6 +6,8 @@ import {
PrimaryColumn,
} from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
*
@@ -78,6 +80,48 @@ export class Purchase {
@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;
@@ -1,16 +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])],
providers: [PurchasesService],
exports: [PurchasesService],
imports: [TypeOrmModule.forFeature([Purchase, OrderInflow]), ImwebModule],
controllers: [OrderInflowsController],
providers: [PurchasesService, OrderInflowsService],
exports: [PurchasesService, OrderInflowsService],
})
export class PurchasesModule {}
@@ -46,9 +46,11 @@ function setup() {
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 };
return { service, inserted, query, repo };
}
describe('PurchasesService.recordOrders', () => {
@@ -80,13 +82,34 @@ describe('PurchasesService.recordOrders', () => {
});
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
const { service, query } = setup();
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 () => {
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import { purchaseLines } from '@/shared/utils/order-purchase';
@@ -33,6 +33,7 @@ const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
/**
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
* 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService).
*/
@Injectable()
export class PurchasesService {
@@ -81,6 +82,49 @@ export class PurchasesService {
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');
});
});
+18
View File
@@ -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)}`;
}
+4 -1
View File
@@ -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;
}
+78 -54
View File
@@ -1,37 +1,37 @@
# 유료 구매 추적 — 현황과 다음 작업
# 유료 구매 추적 — 현황과 배포
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "업체 링크" 절.
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절.
## 0. 한눈에
| 항목 | 상태 |
| --- | --- |
| 유료 구매 목록 (회원·비회원) | **구현 완료 · 운영 미배포** |
| 신청·구매 통합 화면 | **구현 완료 · 운영 미배포** |
| 유료 구매 목록 (회원·비회원) | 구현 완료 · `main` 머지 · **운영 미배포** |
| 신청·구매 통합 화면 | 구현 완료 · `main` 머지 · **운영 미배포** |
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
| 구매 때 유입경로 | **미구현** — 다음 작업 (4절) |
| 비회원 구매의 업체 실적 | **미구현** — 다음 작업 (4절) |
| 업체 링크 우선 (신청·구매 때 유입) | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4.5절) |
| 구매 때 유입경로 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
| 비회원 구매의 업체 실적 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
브랜치: `feat/purchase-history` (커밋 `ab7ef37`, 푸시·머지 전).
`fix/admin-ui-tweaks` 에서 따왔으므로 그 브랜치의 미푸시 커밋 3개(카톡 버튼 문구·링크 칸 줄 맞춤·직전 유입 표시 제거)를 함께 담고 있다.
`feat/purchase-history` 는 `main` 에 머지·푸시됐다 (`9e6cae7`). 구매 때 유입 작업은 그 위의
`feat/purchase-inflow` 브랜치에 있다 (2026-10-06 기준 푸시·머지 전).
---
## 1. 지금 구매에 대해 알 수 있는 것
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
무료 신청은 신청 완료 화면이 그 순간의 유입을 보내지만, 구매에는 그런 화면 연동이 아직 없다.
그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절).
| 항목 | 알 수 있나 | 근거 · 조건 |
| --- | --- | --- |
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
| 업체 링크로 왔는지 | 일부 | **회원만.** 링크를 누른 브라우저에서 로그인 → 30일 안에 같은 상품 구매 |
| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 |
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
| 구매 때 유입경로 | **X** | 주문 데이터에 없다 |
| 비회원 구매의 업체 | **X** | 회원 코드가 없어 클릭과 이을 수 없다 |
| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 |
---
@@ -90,7 +90,7 @@
| `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` |
| `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 |
- `basis=apply`(신청 때 유입)는 **무료 신청만** 센다. 구매는 `join`(가입 경로)으로만 집계된다 — 비회원은 값 없음.
- `basis=apply` 는 처음엔 무료 신청만 셌으나, 4절 이후 **신청·구매 때 유입**(구매 포함)이다. `join` 은 가입 경로 — 비회원은 값 없음.
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
@@ -99,7 +99,7 @@
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
- 유료 구매만 볼 때 유입 집계는 가입 경로로 고정(신청 때 유입 버튼 비활성).
- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다.
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
### 2.6 파일
@@ -127,61 +127,83 @@ imweb/imweb-members.html · imweb/README.md
## 3. 배포 순서 (아직 안 함)
1. `feat/purchase-history` 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
(마이그레이션 자동 실행, 에러 코드 변경 없음)
2. **바로 이어서** `imweb/imweb-members.html` 을 아임웹 관리자 페이지 코드에 다시 붙여 넣기
(예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
`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. 다음 작업 — 주문완료 화면에서 구매 때 유입·업체 잡기
## 4. 구매 때 유입·비회원 업체 실적 (`feat/purchase-inflow`, 푸시·머지 전)
### 4.1 목표
- 구매 때 유입(UTM)을 남겨 신청 때 유입처럼 집계한다.
- **비회원 구매도 업체 실적으로 잡는다** — 회원 대신 주문번호로 잇는다.
### 4.2 방식 (추천안)
무료 신청 완료 위젯과 같은 방식이다.
### 4.1 흐름
```
주문완료 화면 ── 스크립트가 { 주문번호, MG_UTM.peek().value, MG_PARTNER.refs() } 를 보낸다
│ POST /api/order-inflows (공개 · 엄격한 rate limit · 이름은 api-design 규칙에 맞춰 확정)
주문완료 화면 (/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 유일, inflow raw/파싱값, partner refs, received_at
│ 주문 동기화가 같은 order_no 의 구매를 가져올 때 매칭
▼
purchases 에 구매 때 유입 컬럼(가칭 inflow_source 등) 채움
partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 touch 로만)
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 를 지운다
```
- 스크립트 위치: Header Code(`imweb-header-top.html`)가 주문완료 페이지를 감지하거나, 주문완료 페이지 전용 코드 위젯.
UTM·업체 클릭 ID 는 이미 Header Code 가 보관하므로 읽기만 하면 된다.
- 위조 대응: 공개 API 라 막을 수는 없다. **아임웹에 실제로 있는 주문번호에만** 붙인다(동기화 시점 매칭이라 자연히 걸러짐).
같은 주문번호에 두 번 오면 첫 값만, 오래 매칭되지 않은 행은 정리한다.
- 무통장입금: 주문완료 시점에 받아두고, 입금 확인 후 구매로 잡힐 때 붙는다.
- 귀속 규칙 변경 검토: 주문완료에서 온 refs 를 회원 touch 보다 우선할지, 30일 기준을 그대로 쓸지.
- 화면: `inflow-stats` 의 `basis=apply` 를 "신청·구매 때 유입"으로 넓히고, 목록의 "신청 때 유입" 칸에 구매도 표시.
- 주문완료 주소의 `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.3 시작 전에 확인할 것
### 4.2 파일
- [ ] **주문완료 페이지 주소와 주문번호 위치** — 주소창 파라미터인지 화면 DOM 인지. 테스트 주문 한 번으로 확인.
(아임웹 API 의 `orderNo` 와 같은 값인지도 확인)
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2` (`imweb-header-top.html` 49행). 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정.
늘리면 무료 신청의 신청 때 유입에도 같이 영향
- [ ] 비회원 구매 비중 — 많을수록 이 작업의 효과가 크다 (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.4 작업 체크리스트
### 4.3 검증한 것
- [ ] 엔티티·마이그레이션 (`order_inflows`, `purchases` 유입 컬럼) — 생성된 SQL 검토
- [ ] 공개 엔드포인트 DTO 검증 + `@Throttle` (신청 기록처럼)
- [ ] 동기화 매칭 + 비회원 업체 귀속 + 단위 테스트
- [ ] `activities` 유입 집계·목록 확장
- [ ] 아임웹 스크립트 + `imweb/README.md`
- [ ] 필요하면 에러 코드 등록 → `npm run sync:error-codes`
- 백엔드 테스트 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).
광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다.
여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
---
@@ -193,4 +215,6 @@ partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원
결제 수단 `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
+67 -7
View File
@@ -79,8 +79,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
- 유입 집계: '신청 때 유입'은 무료 신청에만 있다 (구매 시점 유입은 아임웹 주문에 남지 않는다).
구매는 '가입 경로'(회원이 가입할 때 기록된 경로)로만 볼 수 있고, 비회원 구매는 값이 없다
- 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과
'가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다.
주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
@@ -99,6 +100,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
- **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를
그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체.
여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다)
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -117,7 +121,29 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
- 금액은 관리하지 않는다
- 구매 때 유입경로는 아직 없다 — 현황·다음 작업은 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
- 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`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 라 주문번호를 아는 사람은
먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다)
## 업체 링크 (업체별 신청·구매 추적)
@@ -138,19 +164,23 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
▼
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
구매: 구매 기록과 같은 주문 동기화(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일보다 오래된 구매의 환불은 반영되지 않는다
@@ -167,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`)
- 서버가 요청마다 토큰 세대를 확인해 **로그아웃하면 발급된 토큰이 즉시 모두 무효**가 된다
+70 -3
View File
@@ -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>
+489 -80
View File
@@ -11,25 +11,31 @@
<div id="mg-members">
<style>
#mg-members {
--mgm-bg: #f3f5f8;
/* 자본주의가이드 색 — 블랙 + 골드 (사이트 신청 완료 위젯 imweb-free-apply.html 과 같은 값) */
--mgm-bg: #f5f4f1;
--mgm-surface: #ffffff;
--mgm-border: #e3e7ec;
--mgm-border-strong: #cfd5dd;
--mgm-text: #1b1f27;
--mgm-muted: #6b7380;
--mgm-faint: #9aa1ad;
--mgm-side: #141922;
--mgm-side-text: #a9b1bf;
--mgm-accent: #2f6fed;
--mgm-accent-soft: #eaf1ff;
--mgm-warn: #b45309;
--mgm-warn-soft: #fff4e5;
--mgm-hover: #faf9f6;
--mgm-border: #e7e4dd;
--mgm-border-strong: #d4d0c6;
--mgm-text: #1a1a1a;
--mgm-muted: #6b6760;
--mgm-faint: #a19d95;
--mgm-side: #141414;
--mgm-side-text: #a8a8a8;
/* 골드는 채우는 색(버튼·막대·배지)으로 쓴다 — 흰 배경 위 글자로는 대비가 부족하다 */
--mgm-accent: #f5b727;
--mgm-accent-hover: #e3a614;
--mgm-on-accent: #1a1200; /* 골드 위 글자 */
--mgm-accent-text: #8a5d00; /* 흰 배경 위 골드 계열 글자·테두리 (링크·선택 표시·포커스) */
--mgm-accent-soft: #fdf3d8;
--mgm-warn: #c2410c;
--mgm-warn-soft: #fff0e6;
--mgm-danger: #c62828;
--mgm-danger-soft: #fdecec;
--mgm-ok: #1b7f4b;
--mgm-ok-soft: #e7f6ee;
--mgm-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
--mgm-shadow-lg: 0 12px 32px rgba(16, 24, 40, .18);
--mgm-shadow: 0 1px 2px rgba(20, 20, 20, .04), 0 1px 3px rgba(20, 20, 20, .06);
--mgm-shadow-lg: 0 12px 32px rgba(20, 20, 20, .18);
font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", "Noto Sans KR", sans-serif;
font-size: 14px;
line-height: 1.5;
@@ -58,13 +64,13 @@
padding: 7px 12px; border-radius: 8px; white-space: nowrap;
transition: background-color .12s, border-color .12s, color .12s;
}
#mg-members button:hover { border-color: var(--mgm-border-strong); background: #fafbfc; }
#mg-members button:focus-visible { outline: 2px solid var(--mgm-accent); outline-offset: 2px; }
#mg-members button:hover { border-color: var(--mgm-border-strong); background: var(--mgm-hover); }
#mg-members button:focus-visible { outline: 2px solid var(--mgm-accent-text); outline-offset: 2px; }
#mg-members button:disabled { opacity: .5; cursor: default; }
#mg-members button.mgm-primary { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; font-weight: 600; }
#mg-members button.mgm-primary:hover { background: #255ed1; }
#mg-members button.mgm-primary { background: var(--mgm-accent); border-color: var(--mgm-accent); color: var(--mgm-on-accent); font-weight: 700; }
#mg-members button.mgm-primary:hover { background: var(--mgm-accent-hover); border-color: var(--mgm-accent-hover); }
#mg-members button.mgm-danger { background: var(--mgm-danger); border-color: var(--mgm-danger); color: #fff; font-weight: 600; }
#mg-members button.mgm-link { border: 0; background: none; color: var(--mgm-accent); padding: 4px 6px; }
#mg-members button.mgm-link { border: 0; background: none; color: var(--mgm-accent-text); padding: 4px 6px; font-weight: 600; }
#mg-members button.mgm-link:hover { text-decoration: underline; background: none; }
#mg-members button.mgm-link.is-danger { color: var(--mgm-danger); }
#mg-members button.mgm-sm { padding: 4px 9px; font-size: 12px; }
@@ -73,7 +79,7 @@
border-radius: 8px; background: #fff; color: var(--mgm-text); min-width: 0; width: 100%;
}
#mg-members input, #mg-members select { height: 36px; }
#mg-members input:focus, #mg-members select:focus, #mg-members textarea:focus { outline: none; border-color: var(--mgm-accent); box-shadow: 0 0 0 3px var(--mgm-accent-soft); }
#mg-members input:focus, #mg-members select:focus, #mg-members textarea:focus { outline: none; border-color: var(--mgm-accent-text); box-shadow: 0 0 0 3px var(--mgm-accent-soft); }
#mg-members select {
appearance: none; -webkit-appearance: none; padding-right: 30px; cursor: pointer;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7380' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center;
@@ -88,7 +94,7 @@
#mg-members .mgm-badge.is-ok { background: var(--mgm-ok-soft); color: var(--mgm-ok); border-color: transparent; }
#mg-members .mgm-badge.is-warn { background: var(--mgm-warn-soft); color: var(--mgm-warn); border-color: transparent; }
#mg-members .mgm-badge.is-danger { background: var(--mgm-danger-soft); color: var(--mgm-danger); border-color: transparent; }
#mg-members .mgm-badge.is-accent { background: var(--mgm-accent-soft); color: var(--mgm-accent); border-color: transparent; }
#mg-members .mgm-badge.is-accent { background: var(--mgm-accent); color: var(--mgm-on-accent); border-color: transparent; font-weight: 600; }
#mg-members .mgm-trace { font-size: 11px; opacity: .7; }
#mg-members .mgm-card {
@@ -104,8 +110,8 @@
#mg-members .mgm-seg { display: inline-flex; border: 1px solid var(--mgm-border); border-radius: 9px; overflow: hidden; background: var(--mgm-surface); flex: none; }
#mg-members .mgm-seg button { border: 0; border-radius: 0; padding: 7px 12px; background: none; color: var(--mgm-muted); }
#mg-members .mgm-seg button + button { border-left: 1px solid var(--mgm-border); }
#mg-members .mgm-seg button:hover { background: #f7f9fc; color: var(--mgm-text); }
#mg-members .mgm-seg button.is-active { background: var(--mgm-accent-soft); color: var(--mgm-accent); font-weight: 600; }
#mg-members .mgm-seg button:hover { background: var(--mgm-hover); color: var(--mgm-text); }
#mg-members .mgm-seg button.is-active { background: var(--mgm-accent-soft); color: var(--mgm-accent-text); font-weight: 600; }
/* 큰 전환 버튼 (상품별 / 업체별) */
#mg-members .mgm-seg.is-large button { padding: 8px 16px; font-size: 14px; }
@@ -121,13 +127,13 @@
#mg-members .mgm-chiprow-label { font-size: 12px; color: var(--mgm-muted); margin-right: 4px; font-weight: 600; }
#mg-members .mgm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 13px; }
#mg-members .mgm-chip em { font-style: normal; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
#mg-members .mgm-chip.is-active { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; }
#mg-members .mgm-chip.is-active em { color: rgba(255,255,255,.85); }
#mg-members .mgm-chip.is-active { background: var(--mgm-accent); border-color: var(--mgm-accent); color: var(--mgm-on-accent); font-weight: 600; }
#mg-members .mgm-chip.is-active em { color: rgba(26, 18, 0, .7); }
#mg-members .mgm-chip.is-empty { opacity: .45; }
#mg-members .mgm-active-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 4px 3px 10px; border-radius: 999px; background: var(--mgm-accent-soft); color: var(--mgm-accent); font-size: 12px; }
#mg-members .mgm-active-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 4px 3px 10px; border-radius: 999px; background: var(--mgm-accent-soft); color: var(--mgm-accent-text); font-size: 12px; }
#mg-members .mgm-active-chip b { font-weight: 600; }
#mg-members .mgm-active-chip button { border: 0; background: none; color: inherit; padding: 0 6px; font-size: 14px; line-height: 1; }
#mg-members .mgm-group-badge { display: inline-block; margin: 1px 4px 1px 0; padding: 1px 8px; border-radius: 999px; font-size: 12px; background: #eef1f6; color: #3b4352; white-space: nowrap; }
#mg-members .mgm-group-badge { display: inline-block; margin: 1px 4px 1px 0; padding: 1px 8px; border-radius: 999px; font-size: 12px; background: #f0eee9; color: #3d3a35; white-space: nowrap; }
/* ---------- 로그인 ---------- */
#mg-members .mgm-login-wrap { background: var(--mgm-bg); border-radius: 14px; padding: 64px 16px; }
@@ -155,8 +161,8 @@
height: calc(100vh - var(--mgm-sticky-top, 0px)); overflow-y: auto;
}
#mg-members .mgm-brand { display: flex; align-items: center; gap: 10px; color: #fff; padding: 0 8px; }
#mg-members .mgm-logo { width: 32px; height: 32px; border-radius: 9px; background: var(--mgm-accent); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; flex: none; }
#mg-members .mgm-brand strong { display: block; font-size: 14px; }
#mg-members .mgm-brand strong { display: block; font-size: 15px; letter-spacing: -.01em; }
#mg-members .mgm-side .mgm-brand strong { color: var(--mgm-accent); }
#mg-members .mgm-brand span { font-size: 12px; color: var(--mgm-side-text); }
#mg-members .mgm-account {
display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 4px;
@@ -166,13 +172,13 @@
#mg-members .mgm-account button { flex: none; background: none; border-color: rgba(255,255,255,.18); color: var(--mgm-side-text); padding: 3px 9px; font-size: 12px; }
#mg-members .mgm-account button:hover { color: #fff; border-color: rgba(255,255,255,.4); background: none; }
#mg-members .mgm-nav { display: flex; flex-direction: column; gap: 2px; }
#mg-members .mgm-nav-label { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #6c7584; padding: 12px 10px 4px; }
#mg-members .mgm-nav-label { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #7a7a7a; padding: 12px 10px 4px; }
#mg-members .mgm-nav button {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: 0; color: var(--mgm-side-text); padding: 9px 10px; border-radius: 8px; font-size: 14px;
}
#mg-members .mgm-nav button:hover { background: rgba(255,255,255,.06); color: #fff; }
#mg-members .mgm-nav button.is-active { background: rgba(255,255,255,.12); color: #fff; font-weight: 600; }
#mg-members .mgm-nav button.is-active { background: rgba(245, 183, 39, .14); color: var(--mgm-accent); font-weight: 600; }
#mg-members .mgm-nav svg { width: 18px; height: 18px; flex: none; }
#mg-members .mgm-main { padding: 24px 26px 32px; min-width: 0; }
#mg-members .mgm-topbar { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
@@ -216,12 +222,12 @@
#mg-members .mgm-table-wrap { overflow-x: auto; margin: 0 -18px; padding: 0 18px; }
#mg-members table { width: 100%; border-collapse: collapse; font-size: 13px; }
#mg-members th, #mg-members td { padding: 10px 10px; border-bottom: 1px solid var(--mgm-border); text-align: left; white-space: nowrap; vertical-align: top; }
#mg-members th { font-size: 12px; color: var(--mgm-muted); font-weight: 600; background: #fafbfc; position: sticky; top: 0; z-index: 1; }
#mg-members th { font-size: 12px; color: var(--mgm-muted); font-weight: 600; background: var(--mgm-hover); position: sticky; top: 0; z-index: 1; }
#mg-members tbody tr:last-child td { border-bottom: 0; }
#mg-members th.mgm-r, #mg-members td.mgm-r { text-align: right; font-variant-numeric: tabular-nums; }
#mg-members td.mgm-wrap { white-space: normal; min-width: 140px; }
#mg-members tr.is-clickable { cursor: pointer; }
#mg-members tr.is-clickable:hover td { background: #f7f9fc; }
#mg-members tr.is-clickable:hover td { background: var(--mgm-hover); }
#mg-members tr.is-selected td { background: var(--mgm-accent-soft); }
#mg-members .mgm-cell-main { display: block; font-weight: 600; }
#mg-members .mgm-cell-sub { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 1px; }
@@ -239,7 +245,13 @@
#mg-members .mgm-pitem-memo { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mg-members .mgm-pitem-stats { display: flex; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
#mg-members .mgm-pitem-stats b { color: var(--mgm-text); font-weight: 600; }
#mg-members .mgm-pitem-actions { display: flex; gap: 2px; margin: 2px 0 2px 4px; }
#mg-members .mgm-pitem-actions { display: flex; gap: 2px; margin: 2px 0 2px 4px; flex-wrap: wrap; }
/* 임시 비밀번호 — 한 번만 보여주므로 크게, 한 번에 선택되게 */
#mg-members .mgm-secret {
margin: 4px 0; padding: 14px; border-radius: 10px; background: var(--mgm-accent-soft); color: var(--mgm-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 20px; font-weight: 700;
letter-spacing: .08em; text-align: center; user-select: all; word-break: break-all;
}
/* 업체 링크 칸 */
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
@@ -302,7 +314,7 @@
#mg-members .mgm-opt span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mg-members .mgm-opt span + span { flex: none; color: var(--mgm-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
#mg-members .mgm-opt.is-active, #mg-members .mgm-opt:hover { background: var(--mgm-bg); }
#mg-members .mgm-opt.is-selected span:first-child { color: var(--mgm-accent); font-weight: 600; }
#mg-members .mgm-opt.is-selected span:first-child { color: var(--mgm-accent-text); font-weight: 600; }
#mg-members .mgm-opt-empty { padding: 8px 10px; font-size: 13px; color: var(--mgm-muted); }
#mg-members .mgm-filter-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
#mg-members .mgm-divider { border-top: 1px dashed var(--mgm-border); margin: 14px 0 0; padding-top: 14px; }
@@ -389,7 +401,6 @@
<div class="mgm-login-wrap" data-role="login" hidden>
<section class="mgm-card mgm-login">
<div class="mgm-brand" style="padding:0;color:var(--mgm-text)">
<span class="mgm-logo" style="color:#fff">₩</span>
<div><strong>자본주의가이드 관리자</strong><span class="mgm-muted">신청·업체 링크·회원 유입경로</span></div>
</div>
<div class="mgm-login-notice" data-role="login-notice" hidden></div>
@@ -405,8 +416,7 @@
<div class="mgm-shell" data-role="app" hidden>
<aside class="mgm-side">
<div class="mgm-brand">
<span class="mgm-logo">₩</span>
<div><strong>자본주의가이드</strong><span>관리자 대시보드</span></div>
<div><strong>자본주의가이드</strong><span data-role="brand-sub">관리자 대시보드</span></div>
</div>
<!-- 위젯 위에 사이트 헤더가 있어도 항상 보이도록 위쪽에 둔다 -->
<div class="mgm-account">
@@ -414,25 +424,35 @@
<button type="button" data-action="logout">로그아웃</button>
</div>
<nav class="mgm-nav" aria-label="관리 메뉴">
<button type="button" data-view="overview">
<!-- 업체 계정 — 이 메뉴만 보인다 -->
<button type="button" data-view="portal" data-role-only="partner" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 3v18h18"/><path d="M7 15l4-4 3 3 5-6"/></svg>
내 업체 실적
</button>
<button type="button" data-action="change-password" data-role-only="partner" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
비밀번호 변경
</button>
<!-- 최고관리자 -->
<button type="button" data-view="overview" data-role-only="super">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
개요
</button>
<span class="mgm-nav-label">운영</span>
<button type="button" data-view="applications">
<span class="mgm-nav-label" data-role-only="super">운영</span>
<button type="button" data-view="applications" data-role-only="super">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
신청·구매
</button>
<button type="button" data-view="partners">
<button type="button" data-view="partners" data-role-only="super">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
업체 링크
</button>
<button type="button" data-view="members">
<button type="button" data-view="members" data-role-only="super">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
회원
</button>
<span class="mgm-nav-label">설정</span>
<button type="button" data-view="integration">
<span class="mgm-nav-label" data-role-only="super">설정</span>
<button type="button" data-view="integration" data-role-only="super">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
연동 관리
</button>
@@ -446,7 +466,7 @@
<p class="mgm-muted" data-role="view-desc"></p>
</div>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-sync-pill" data-role="sync-pill" data-goto="integration" title="연동 관리로 이동">상태 확인 중</button>
<button type="button" class="mgm-sync-pill" data-role="sync-pill" data-role-only="super" data-goto="integration" title="연동 관리로 이동">상태 확인 중</button>
</header>
<!-- ========== 개요 ========== -->
@@ -476,10 +496,10 @@
<div class="mgm-grid-2">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청 유입 상위</h3>
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
<h3>신청·구매 유입 상위</h3>
<span class="mgm-muted">최근 30일 · 신청·구매 때 유입 기준</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30" data-app-kind="application">신청·구매 →</button>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청·구매 →</button>
</div>
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
@@ -576,7 +596,7 @@
</div>
<div class="mgm-row" style="margin-bottom:12px">
<div class="mgm-seg" role="group" aria-label="유입 기준">
<button type="button" data-app-basis="apply" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 무료 신청에만 있습니다">신청 때 유입</button>
<button type="button" data-app-basis="apply" class="is-active" title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</button>
<button type="button" data-app-basis="join" title="신청·구매한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외, 비회원 구매는 값 없음)">가입 경로</button>
</div>
</div>
@@ -597,7 +617,7 @@
<thead>
<tr>
<th>일시</th><th>구분</th><th>이름</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 구매에는 이 기록이 없습니다">신청 때 유입</th><th>가입 경로</th>
<th title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</th><th>가입 경로</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
@@ -609,6 +629,81 @@
</section>
<!-- ========== 업체 링크 ========== -->
<!-- ========== 내 업체 실적 (업체 계정) ========== -->
<section data-panel="portal" class="mgm-stack" hidden>
<section class="mgm-card">
<div class="mgm-toolbar">
<div class="mgm-seg" role="group" aria-label="실적 집계 기간">
<button type="button" data-portal-range="all">전체</button>
<button type="button" data-portal-range="today">오늘</button>
<button type="button" data-portal-range="7">7일</button>
<button type="button" data-portal-range="30" class="is-active">30일</button>
<button type="button" data-portal-range="month">이번 달</button>
</div>
</div>
</section>
<div class="mgm-kpis is-compact">
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">링크 클릭</span><span class="mgm-kpi-value" data-kpi="portal-clicks">-</span><span class="mgm-kpi-sub">사람이 누른 것만 (미리보기·봇 제외)</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">신청</span><span class="mgm-kpi-value" data-kpi="portal-apps">-</span><span class="mgm-kpi-sub" data-kpi="portal-apps-sub">&nbsp;</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">구매</span><span class="mgm-kpi-value" data-kpi="portal-buys">-</span><span class="mgm-kpi-sub">결제완료 (환불·취소 제외)</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">링크</span><span class="mgm-kpi-value" data-kpi="portal-links">-</span><span class="mgm-kpi-sub">받은 링크 수</span></div>
</div>
<details class="mgm-help">
<summary>실적은 어떻게 집계되나요?</summary>
<ul>
<li>링크를 누른 뒤 <b>30일 안에</b> 같은 상품을 신청·구매하면 실적으로 잡힙니다. 여러 업체 링크를 눌렀다면 <b>마지막으로 누른</b> 링크의 실적입니다.</li>
<li>구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 구매는 1시간마다 확인합니다.</li>
<li>링크를 누른 기기와 신청·구매한 기기가 다르면 잡히지 않습니다.</li>
<li>신청·구매한 분의 이름은 일부만 보이고, 연락처·이메일은 제공하지 않습니다.</li>
</ul>
</details>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>내 링크</h3>
<span class="mgm-muted" data-role="portal-link-total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr>
<th>상품</th><th>나눠 줄 링크</th>
<th class="mgm-r">클릭</th><th class="mgm-r">신청</th><th class="mgm-r">구매</th><th>상태</th>
</tr>
</thead>
<tbody data-role="portal-link-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="portal-link-empty" hidden>
<b>받은 링크가 없습니다</b>운영사에 링크 발급을 요청해 주세요.
</div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청·구매</h3>
<div class="mgm-seg" role="group" aria-label="종류">
<button type="button" data-portal-type="" class="is-active">전체</button>
<button type="button" data-portal-type="application">신청</button>
<button type="button" data-portal-type="purchase">구매</button>
</div>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="portal-conv-total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr><th>일시</th><th>구분</th><th>이름</th><th>상품</th><th>상태</th></tr>
</thead>
<tbody data-role="portal-conv-rows"></tbody>
</table>
</div>
<div class="mgm-more"><button type="button" data-action="portal-conv-more" hidden>더 보기</button></div>
</section>
</section>
<section data-panel="partners" class="mgm-stack" hidden>
<section class="mgm-card">
<div class="mgm-toolbar">
@@ -641,7 +736,8 @@
<li>링크를 누른 뒤 <b>30일 안에</b> 신청·구매하면, 그 상품의 링크 중 <b>마지막으로 누른</b> 업체 실적으로 잡습니다.</li>
<li>구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 아임웹 주문은 1시간마다 확인합니다.</li>
<li>링크로 들어온 방문에는 업체 UTM(유입원=업체 이름, 매체=partner, 캠페인=링크 코드)이 붙어 신청 유입·가입 경로에도 업체가 나옵니다.</li>
<li>링크를 누른 기기와 신청·구매한 기기가 다르거나, 비회원 주문이면 추적되지 않습니다.</li>
<li>링크를 누른 기기와 신청·구매한 기기가 다르면 추적되지 않습니다. 비회원 주문은 주문완료 화면에서 링크 클릭이 확인될 때만 잡힙니다.</li>
<li>업체를 고르고 <b>계정 관리</b>에서 업체용 로그인 계정을 만들면, 업체가 자기 링크·실적을 직접 볼 수 있습니다 (이름은 일부만, 연락처는 보이지 않음).</li>
</ul>
</details>
@@ -872,6 +968,7 @@
var KST_OFFSET_MS = 9 * 3600000;
var DAY_MS = 86400000;
var SESSION_KEY = 'mg_admin_session';
var PASSWORD_MIN_LENGTH = 10; // 백엔드 ACCOUNT_PASSWORD_MIN_LENGTH 와 같아야 한다
var VIEW_KEY = 'mg_admin_view';
var VIEWS = {
@@ -879,8 +976,15 @@
applications: { title: '신청·구매 내역', desc: '무료 신청과 유료 구매를 상품별·업체별로 보고, 누가 신청·구매했는지 확인합니다' },
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고 클릭·신청·구매 실적을 봅니다' },
members: { title: '회원', desc: '가입 기간·회원 그룹·유입경로로 회원을 찾습니다' },
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' },
portal: { title: '내 업체 실적', desc: '우리 업체 링크의 클릭·신청·구매 실적을 봅니다' }
};
// 역할마다 볼 수 있는 화면 — 업체 계정은 업체 화면만 (서버도 같은 규칙으로 막는다)
var ROLE_HOME = { super: 'overview', partner: 'portal' };
function viewAllowed(view) {
if (!VIEWS[view] || !state.admin) return false;
return (view === 'portal') === (state.admin.role === 'partner');
}
var root = document.getElementById('mg-members');
function $(sel) { return root.querySelector(sel); }
@@ -890,6 +994,9 @@
var state = {
admin: null,
// 업체 화면 — range: 기간, type: '' | application | purchase
portal: { range: '30', type: '', cursor: null, seq: 0, loading: false },
credentialText: '', // 임시 비밀번호 창의 복사용 문구 (창을 닫으면 지운다)
view: 'overview',
status: null,
pollTimer: null,
@@ -1068,8 +1175,14 @@
box.hidden = false;
});
});
// locked: 바깥 클릭·Esc 로 닫지 않는다 (임시 비밀번호 표시·비밀번호 변경 강제) — 취소 버튼은 동작한다
function modalLocked() { return !!(modal.opts && modal.opts.locked); }
each(modal.el.querySelectorAll('[data-modal-cancel]'), function (el) {
el.addEventListener('click', function () { if (!modal.busy) closeModal(null); });
el.addEventListener('click', function () {
if (modal.busy) return;
if (modalLocked() && el.classList.contains('mgm-modal-backdrop')) return;
closeModal(null);
});
});
function confirmModal(title, message, confirmText, danger) {
return openModal({ title: title, html: '<p>' + message + '</p>', confirmText: confirmText, danger: danger });
@@ -1096,7 +1209,7 @@
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (!modal.el.hidden) { if (!modal.busy) closeModal(null); }
if (!modal.el.hidden) { if (!modal.busy && !modalLocked()) closeModal(null); }
else if (!drawer.hidden) closeDrawer();
});
@@ -1190,14 +1303,42 @@
$('[data-role="app"]').hidden = true;
$('[data-role="login"]').hidden = false;
}
function renderAccountName() {
var a = state.admin, el = $('[data-role="admin-name"]');
var isPartner = a.role === 'partner' && a.partner;
el.textContent = isPartner ? a.partner.name : a.username + ' 님';
el.title = isPartner ? a.partner.name + ' · 아이디 ' + a.username : a.username;
}
// 역할에 맞는 메뉴만 보인다 (최고관리자 메뉴는 업체 계정에게 숨긴다)
function applyRole(admin) {
each($all('[data-role-only]'), function (el) { el.hidden = el.getAttribute('data-role-only') !== admin.role; });
$('[data-role="brand-sub"]').textContent = admin.role === 'partner' ? '업체 실적' : '관리자 대시보드';
}
// 비밀번호 변경 창 뒤에도 들어갈 화면의 제목이 보이게 한다
function showViewTitle(view) {
$('[data-role="view-title"]').textContent = VIEWS[view].title;
$('[data-role="view-desc"]').textContent = VIEWS[view].desc;
}
function showApp(admin) {
state.admin = admin;
$('[data-role="login"]').hidden = true;
$('[data-role="app"]').hidden = false;
$('[data-role="admin-name"]').textContent = admin.username + ' 님';
loadStatus().catch(errorNotice);
renderAccountName();
applyRole(admin);
// 연동 상태는 최고관리자 기능 — 업체 계정은 부르지 않는다
if (admin.role === 'super') loadStatus().catch(errorNotice);
var saved = storageGet(VIEW_KEY);
go(VIEWS[saved] ? saved : 'overview');
var view = viewAllowed(saved) ? saved : ROLE_HOME[admin.role];
if (admin.mustChangePassword) {
showViewTitle(view);
changePasswordDialog(true).then(function (ok) { if (ok) go(view); });
return;
}
go(view);
}
function logout() {
// 서버에서 토큰을 무효화한 뒤 화면의 토큰도 지운다 (이미 만료됐어도 화면은 로그아웃된다)
rawRequest('DELETE', '/admin/sessions').then(function () { saveSession(null); showLogin(); });
}
$('[data-role="login-form"]').addEventListener('submit', function (e) {
@@ -1221,13 +1362,13 @@
/* ===================== 화면 전환 ===================== */
function go(view) {
if (!viewAllowed(view)) view = ROLE_HOME[state.admin ? state.admin.role : 'super'];
state.view = view;
storageSet(VIEW_KEY, view);
closeDrawer();
each($all('[data-view]'), function (b) { b.classList.toggle('is-active', b.getAttribute('data-view') === view); });
each($all('[data-panel]'), function (p) { p.hidden = p.getAttribute('data-panel') !== view; });
$('[data-role="view-title"]').textContent = VIEWS[view].title;
$('[data-role="view-desc"]').textContent = VIEWS[view].desc;
showViewTitle(view);
loadView(view);
}
@@ -1237,6 +1378,7 @@
else if (view === 'applications') loadApplicationsView();
else if (view === 'partners') loadPartnersView();
else if (view === 'integration') renderIntegration();
else if (view === 'portal') loadPortal();
}
each($all('[data-view]'), function (b) {
@@ -1349,8 +1491,9 @@
var action = btn.getAttribute('data-action');
if (action === 'logout') {
// 서버에서 토큰을 무효화한 뒤 화면의 토큰도 지운다 (이미 만료됐어도 화면은 로그아웃된다)
rawRequest('DELETE', '/admin/sessions').then(function () { saveSession(null); showLogin(); });
logout();
} else if (action === 'change-password') {
changePasswordDialog(false);
} else if (action === 'connect') {
api('POST', '/admin/imweb/authorizations').then(function (r) {
location.href = r.authorizationUrl; // 아임웹 동의 화면 → 끝나면 이 페이지로 돌아온다
@@ -1524,7 +1667,7 @@
$('[data-role="ov-app-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
if (!bar || bar.disabled) return;
openApplications({ range: '30', type: 'application', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
openApplications({ range: '30', type: 'all', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
});
$('[data-role="ov-join-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
@@ -1542,7 +1685,7 @@
/* ===================== 신청·구매 내역 ===================== */
var appForm = $('[data-role="app-filter"]');
var APP_BASIS_LABEL = { apply: '신청 때 유입', join: '가입 경로' };
var APP_BASIS_LABEL = { apply: '신청·구매 때 유입', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' };
@@ -1639,15 +1782,9 @@
}
function countCell(n) { return n ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
// 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다
// 유입 집계 기준 버튼 — 신청·구매 모두 두 기준으로 볼 수 있다
function syncBasisButtons() {
var app = state.app, onlyPurchase = app.type === 'purchase';
if (onlyPurchase && app.basis === 'apply') app.basis = 'join';
each($all('[data-app-basis]'), function (b) {
var isApply = b.getAttribute('data-app-basis') === 'apply';
b.disabled = onlyPurchase && isApply;
});
setSeg('[data-app-basis]', 'data-app-basis', app.basis);
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
}
function loadAppStats() {
@@ -1662,9 +1799,8 @@
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건');
var counted = [];
if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건');
if (s.basis === 'join' && app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준' +
(s.basis === 'apply' && app.type === 'all' ? ' (구매는 가입 경로로만 볼 수 있습니다)' : '') + ' · 막대를 누르면 목록을 거릅니다';
if (app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
@@ -1695,7 +1831,7 @@
'<td>' + (isApp && a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
: '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-wrap">' + (isApp ? inflowText(a.inflow) : '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
'<td class="mgm-wrap">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</td>' +
'</tr>';
}
@@ -1773,6 +1909,12 @@
['연락처', dash(phone(o.phone))],
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
]) + '</section>' +
'<section class="mgm-dsec"><h4>구매 때 유입</h4>' + (a.inflow && a.inflow.source ? dlHtml([
['유입원', esc(a.inflow.source)],
['매체', esc(a.inflow.medium)],
['캠페인', esc(a.inflow.campaign)],
['콘텐츠', esc(a.inflow.content)]
]) : '<p class="mgm-muted">기록 없음 — 주문완료 화면 연동 전 주문이거나, 다른 기기·브라우저에서 결제한 경우입니다.</p>') + '</section>' +
(isGuest ? '' : memberSectionsHtml(a.member));
}
openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], html);
@@ -1796,9 +1938,6 @@
var type = b.getAttribute('data-app-type');
if (type === state.app.type) return;
state.app.type = type;
// 구매에는 신청 때 유입이 없으므로 그 기준의 유입 필터는 풀어 준다
var inf = state.app.inflowFilter;
if (type === 'purchase' && inf && inf.basis === 'apply') state.app.inflowFilter = null;
setSeg('[data-app-type]', 'data-app-type', type);
loadApplications(true);
});
@@ -1945,6 +2084,7 @@
(p.memo ? '<span class="mgm-pitem-memo" title="' + esc(p.memo) + '">' + esc(p.memo) + '</span>' : '') +
stats(p.stats, p.linkCount) + '</button>' +
(active ? '<div class="mgm-pitem-actions"><button type="button" class="mgm-link mgm-sm" data-ptn-edit-partner="' + esc(p.id) + '">이름·메모 수정</button>' +
'<button type="button" class="mgm-link mgm-sm" data-ptn-accounts="' + esc(p.id) + '">계정 관리</button>' +
(p.linkCount === 0 ? '<button type="button" class="mgm-link mgm-sm is-danger" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>' : '') + '</div>' : '') +
'</div>';
}).join('');
@@ -2028,7 +2168,9 @@
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') +
(c.orderNo ? '<span class="mgm-cell-sub">주문 ' + esc(c.orderNo) + '</span>' : '') + '</td>' +
'<td>' + (m ? personCell(m.name, m.uid) : personCell(c.memberCode, '회원 정보 없음')) + '</td>' +
'<td>' + (m ? personCell(m.name, m.uid)
: c.memberCode ? personCell(c.memberCode, '회원 정보 없음')
: personCell(c.ordererName, '비회원 구매')) + '</td>' +
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
'<td><span class="mgm-cell-sub" style="margin:0">' + esc(kstShort(c.touchedAt)) + '</span></td>' +
'</tr>';
@@ -2326,6 +2468,8 @@
editPartnerDialog(id);
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
deletePartnerDialog(id);
} else if ((id = t.getAttribute('data-ptn-accounts'))) {
openAccounts(id);
} else if (t.hasAttribute('data-ptn-copy')) {
copyWithToast(t.getAttribute('data-ptn-copy'));
} else if ((id = t.getAttribute('data-ptn-conv'))) {
@@ -2337,6 +2481,271 @@
}
});
/* ===================== 내 업체 실적 (업체 계정) ===================== */
// 업체 계정은 이 화면만 본다. 업체 ID 는 서버가 로그인 정보에서 정한다 (화면이 보내지 않는다)
function portalFailed(err) {
if (err && err.code === 'ACCOUNT_003') {
changePasswordDialog(true).then(function (ok) { if (ok) loadPortal(); });
return;
}
errorNotice(err);
}
function loadPortal() {
var p = state.portal;
setSeg('[data-portal-range]', 'data-portal-range', p.range);
setSeg('[data-portal-type]', 'data-portal-type', p.type);
var period = periodQuery(p.range, 'from', 'to');
api('GET', '/partner-portal/summary' + toQuery(period)).then(function (s) {
setKpi('portal-clicks', num(s.stats.clickCount));
setKpi('portal-apps', num(s.stats.applicationCount));
setKpi('portal-apps-sub', '클릭 대비 ' + pct(s.stats.applicationCount, s.stats.clickCount));
setKpi('portal-buys', num(s.stats.purchaseCount));
setKpi('portal-links', num(s.linkCount));
}).catch(portalFailed);
api('GET', '/partner-portal/links' + toQuery(Object.assign({ limit: LIST_LIMIT }, period))).then(function (page) {
$('[data-role="portal-link-rows"]').innerHTML = page.items.map(portalLinkRowHtml).join('');
$('[data-role="portal-link-empty"]').hidden = page.items.length > 0;
var total = page.pageInfo.total;
$('[data-role="portal-link-total"]').textContent = total ? num(total) + '개' : '';
}).catch(portalFailed);
loadPortalConversions(true);
}
function portalLinkRowHtml(l) {
return '<tr>' +
'<td class="mgm-wrap"><span class="mgm-cell-main">' + esc(l.product.name || '상품 #' + l.product.prodNo) + '</span>' +
(l.product.name ? '<span class="mgm-cell-sub">#' + esc(l.product.prodNo) + '</span>' : '') + '</td>' +
'<td><div class="mgm-linkcell"><div class="mgm-linkcell-top"><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
'<button type="button" class="mgm-link mgm-sm" data-portal-copy="' + esc(l.shortUrl) + '">복사</button></div></div></td>' +
'<td class="mgm-r">' + num(l.stats.clickCount) + '</td>' +
'<td class="mgm-r"><b>' + num(l.stats.applicationCount) + '</b></td>' +
'<td class="mgm-r"><b>' + num(l.stats.purchaseCount) + '</b></td>' +
'<td>' + (l.isActive ? '<span class="mgm-badge is-ok">사용 중</span>' : '<span class="mgm-badge" title="운영사가 중지한 링크 — 눌러도 실적이 쌓이지 않습니다">중지됨</span>') + '</td>' +
'</tr>';
}
function portalConvRowHtml(c) {
return '<tr>' +
'<td>' + whenCell(c.convertedAt) + '</td>' +
'<td>' + (c.type === 'purchase' ? '<span class="mgm-badge is-accent">구매</span>' : '<span class="mgm-badge is-ok">신청</span>') + '</td>' +
'<td>' + dash(c.maskedName) + (c.isMember ? '' : ' <span class="mgm-faint">비회원</span>') + '</td>' +
'<td class="mgm-wrap">' + esc(c.product.name || '상품 #' + c.product.prodNo) + '</td>' +
'<td>' + (c.isValid ? '<span class="mgm-faint">-</span>' : '<span class="mgm-badge is-danger" title="환불·취소되어 실적에서 빠졌습니다">환불·취소</span>') + '</td>' +
'</tr>';
}
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다
function loadPortalConversions(reset) {
var p = state.portal;
if (!reset && p.loading) return;
var seq = ++p.seq;
p.loading = true;
if (reset) p.cursor = null;
var q = Object.assign({ limit: PAGE_SIZE }, periodQuery(p.range, 'from', 'to'));
if (p.type) q.type = p.type;
if (p.cursor) q.cursor = p.cursor;
var moreBtn = $('[data-action="portal-conv-more"]');
moreBtn.disabled = true;
api('GET', '/partner-portal/conversions' + toQuery(q)).then(function (page) {
if (seq !== p.seq) return;
var tbody = $('[data-role="portal-conv-rows"]');
var html = page.items.map(portalConvRowHtml).join('');
if (reset) tbody.innerHTML = html || '<tr><td colspan="5" class="mgm-empty">이 기간의 신청·구매가 없습니다.</td></tr>';
else tbody.insertAdjacentHTML('beforeend', html);
p.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
var total = page.pageInfo.total;
$('[data-role="portal-conv-total"]').textContent = total === null ? '' : num(total) + '건';
}).catch(function (err) {
if (seq === p.seq) portalFailed(err);
}).then(function () {
if (seq === p.seq) { p.loading = false; moreBtn.disabled = false; }
});
}
each($all('[data-portal-range]'), function (b) {
b.addEventListener('click', function () {
state.portal.range = b.getAttribute('data-portal-range');
loadPortal();
});
});
each($all('[data-portal-type]'), function (b) {
b.addEventListener('click', function () {
state.portal.type = b.getAttribute('data-portal-type');
setSeg('[data-portal-type]', 'data-portal-type', state.portal.type);
loadPortalConversions(true);
});
});
$('[data-panel="portal"]').addEventListener('click', function (e) {
var t = closest(e.target, 'button');
if (!t) return;
if (t.getAttribute('data-action') === 'portal-conv-more') loadPortalConversions(false);
else if (t.hasAttribute('data-portal-copy')) copyWithToast(t.getAttribute('data-portal-copy'));
});
/* ----- 비밀번호 변경 (업체 계정 본인) ----- */
// 임시 비밀번호를 바꾸기 전에는(forced) 닫을 수 없고, 대신 로그아웃할 수 있다
var passwordDialog = null;
function changePasswordDialog(forced) {
if (passwordDialog) return passwordDialog;
passwordDialog = openModal({
title: forced ? '비밀번호를 바꿔 주세요' : '비밀번호 변경',
confirmText: '변경',
cancelText: forced ? '로그아웃' : '취소',
locked: forced,
html: (forced ? '<p>처음 받은 임시 비밀번호를 내 비밀번호로 바꿔야 실적을 볼 수 있습니다.</p>' : '') +
'<label class="mgm-field">현재 비밀번호' + (forced ? ' (받은 임시 비밀번호)' : '') +
'<input name="currentPassword" type="password" autocomplete="current-password" required maxlength="72"></label>' +
'<label class="mgm-field">새 비밀번호<input name="newPassword" type="password" autocomplete="new-password" required maxlength="72"></label>' +
'<label class="mgm-field">새 비밀번호 확인<input name="newPasswordConfirm" type="password" autocomplete="new-password" required maxlength="72"></label>' +
'<p class="mgm-hint">' + PASSWORD_MIN_LENGTH + '자 이상. 바꾸면 다른 기기에서는 로그아웃됩니다.</p>',
submit: function (v) {
if (v.newPassword.length < PASSWORD_MIN_LENGTH) return Promise.reject('새 비밀번호는 ' + PASSWORD_MIN_LENGTH + '자 이상이어야 합니다.');
if (v.newPassword !== v.newPasswordConfirm) return Promise.reject('새 비밀번호가 서로 다릅니다.');
return api('PUT', '/admin/accounts/current/password', {
currentPassword: v.currentPassword,
newPassword: v.newPassword
}).then(function (issued) {
state.admin = storeIssued(issued);
renderAccountName();
return true;
});
}
}).then(function (ok) {
passwordDialog = null;
if (ok) notify('비밀번호를 변경했습니다.');
else if (forced) logout();
return !!ok;
});
return passwordDialog;
}
/* ----- 업체 계정 관리 (최고관리자) ----- */
function loginUrl() { return location.origin + location.pathname; }
function openAccounts(partnerId) {
var p = findPartner(partnerId);
if (!p) return;
state.ptn.accountsPartnerId = partnerId;
openDrawer(p.name + ' · 업체 계정', '<p class="mgm-muted">불러오는 중…</p>');
loadAccounts();
}
function loadAccounts() {
var partnerId = state.ptn.accountsPartnerId;
return api('GET', '/admin/partners/' + encodeURIComponent(partnerId) + '/accounts' + toQuery({ limit: LIST_LIMIT }))
.then(function (page) {
if (state.ptn.accountsPartnerId !== partnerId || drawer.hidden) return;
$('[data-role="drawer-body"]').innerHTML = accountsHtml(page.items);
}).catch(errorNotice);
}
function accountsHtml(items) {
var rows = items.map(function (a) {
return '<tr>' +
'<td><span class="mgm-cell-main mgm-code">' + esc(a.username) + '</span>' +
(a.mustChangePassword ? '<span class="mgm-cell-sub">임시 비밀번호 — 아직 바꾸지 않음</span>' : '') + '</td>' +
'<td>' + (a.lastLoginAt ? esc(kstShort(a.lastLoginAt)) : '<span class="mgm-faint">없음</span>') + '</td>' +
'<td><button type="button" class="mgm-switch" data-acc-toggle="' + esc(a.id) + '" data-acc-active="' + a.isActive + '" aria-pressed="' + a.isActive + '" ' +
'aria-label="' + (a.isActive ? '사용 중 — 눌러서 중지' : '중지됨 — 눌러서 다시 사용') + '" title="' + (a.isActive ? '사용 중' : '중지됨') + '"></button></td>' +
'<td><button type="button" class="mgm-link mgm-sm" data-acc-reset="' + esc(a.id) + '" data-acc-name="' + esc(a.username) + '">비밀번호 초기화</button></td>' +
'</tr>';
}).join('');
return '<section class="mgm-dsec">' +
'<p class="mgm-muted">업체 계정은 이 업체의 링크·클릭·신청·구매 수와, 이름 일부를 가린 신청·구매 목록만 봅니다. 연락처·이메일과 다른 메뉴는 보이지 않습니다.</p>' +
'<div class="mgm-row" style="margin:12px 0"><button type="button" class="mgm-primary" data-acc-new>+ 계정 만들기</button></div>' +
(items.length
? '<div class="mgm-table-wrap"><table><thead><tr><th>아이디</th><th>마지막 로그인</th><th>사용</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
: '<div class="mgm-empty"><b>만든 계정이 없습니다</b>업체가 실적을 볼 로그인 계정을 만드세요.</div>') +
'<p class="mgm-hint">업체 로그인 주소: <span class="mgm-code">' + esc(loginUrl()) + '</span></p>' +
'</section>';
}
function credentialText(cred) {
return '로그인 주소: ' + loginUrl() + '\n아이디: ' + cred.account.username + '\n임시 비밀번호: ' + cred.temporaryPassword;
}
// 임시 비밀번호는 이 창에서 한 번만 보여준다 — 실수로 닫지 않게 바깥 클릭·Esc 로는 닫히지 않는다
function showCredential(cred, title) {
state.credentialText = credentialText(cred);
return openModal({
title: title,
hideCancel: true,
locked: true,
confirmText: '전달했습니다',
html: '<p>아래 아이디와 임시 비밀번호를 업체에 전달하세요. <b>이 창을 닫으면 비밀번호를 다시 볼 수 없습니다.</b></p>' +
dlHtml([
['로그인 주소', '<span class="mgm-code">' + esc(loginUrl()) + '</span>'],
['아이디', '<span class="mgm-code">' + esc(cred.account.username) + '</span>']
]) +
'<div class="mgm-secret" aria-label="임시 비밀번호">' + esc(cred.temporaryPassword) + '</div>' +
'<div class="mgm-row"><button type="button" data-copy-credential>주소·아이디·비밀번호 함께 복사</button></div>' +
'<p class="mgm-hint">업체는 첫 로그인 때 비밀번호를 직접 바꿔야 실적을 볼 수 있습니다.</p>'
}).then(function () { state.credentialText = ''; });
}
modal.el.addEventListener('click', function (e) {
if (!closest(e.target, '[data-copy-credential]') || !state.credentialText) return;
// 복사한 내용(비밀번호)은 알림에 보여주지 않는다
copyText(state.credentialText).then(function (ok) {
if (ok) notify('복사했습니다.');
else toast('복사하지 못했습니다. 직접 선택해 복사해 주세요.', 'error');
});
});
function newAccountDialog() {
var partnerId = state.ptn.accountsPartnerId, p = findPartner(partnerId);
openModal({
title: '업체 계정 만들기',
confirmText: '만들기',
html: '<p>' + esc(p ? p.name : '') + ' 업체가 실적을 볼 때 쓸 로그인 계정입니다.</p>' +
'<label class="mgm-field">아이디<input name="username" required maxlength="30" autocomplete="off" placeholder="영문·숫자 4~30자 (예: moneytube)"></label>' +
'<p class="mgm-hint">비밀번호는 서버가 만들어 한 번만 보여 드립니다.</p>',
submit: function (v) {
if (!v.username.trim()) return Promise.reject('아이디를 입력해 주세요.');
return api('POST', '/admin/partners/' + encodeURIComponent(partnerId) + '/accounts', { username: v.username.trim() });
}
}).then(function (cred) {
if (!cred) return;
loadAccounts();
showCredential(cred, '계정을 만들었습니다');
});
}
function toggleAccount(id, isActive) {
var confirmed = isActive
? confirmModal('계정 중지', '이 계정으로는 더 이상 로그인할 수 없고, 열려 있는 화면도 바로 로그아웃됩니다. 중지할까요?', '중지', true)
: Promise.resolve(true);
confirmed.then(function (ok) {
if (!ok) return null;
return api('PATCH', '/admin/partner-accounts/' + encodeURIComponent(id), { isActive: !isActive }).then(function () {
notify(isActive ? '계정을 중지했습니다.' : '계정을 다시 사용합니다.');
return loadAccounts();
});
}).catch(errorNotice);
}
function resetAccountDialog(id, username) {
confirmModal('비밀번호 초기화',
'<b>' + esc(username) + '</b> 계정의 비밀번호를 새 임시 비밀번호로 바꿉니다. 열려 있는 화면은 로그아웃되고, 업체는 다음 로그인 때 비밀번호를 다시 바꿔야 합니다.',
'초기화', true)
.then(function (ok) {
if (!ok) return null;
return api('POST', '/admin/partner-accounts/' + encodeURIComponent(id) + '/password-resets').then(function (cred) {
loadAccounts();
showCredential(cred, '비밀번호를 초기화했습니다');
});
}).catch(errorNotice);
}
$('[data-role="drawer-body"]').addEventListener('click', function (e) {
var t = closest(e.target, 'button');
if (!t) return;
var id;
if (t.hasAttribute('data-acc-new')) newAccountDialog();
else if ((id = t.getAttribute('data-acc-toggle'))) toggleAccount(id, t.getAttribute('data-acc-active') === 'true');
else if ((id = t.getAttribute('data-acc-reset'))) resetAccountDialog(id, t.getAttribute('data-acc-name'));
});
/* ===================== 회원 목록 필터 ===================== */
// 버튼 없이 바로 적용한다 — 선택형은 바꾸는 즉시, 검색어는 입력을 멈추면
var FILTER_KEYS = ['inflowStatus', 'source', 'medium', 'campaign', 'content', 'q'];