merge: 관리자 회원 연동(hub)과 통합
두 레인이 같은 시기에 backendFetch에 form-urlencoded 전송을 추가해 lib/http/backend-fetch.ts가 충돌했다. 원인이 같다 — 백엔드의 쓰기 API가 @RequestBody가 아니라 @ParameterObject로 받아 JSON을 보내면 전 필드가 null로 저장된다(관리자 등록·수정, 아이템 등록·수정 모두). 아이템 쪽 구현이 관리자 쪽을 포함하므로 그것을 채택했다: - method에 DELETE가 더 있다(아이템 삭제). - form 값이 string 외에 number·undefined를 받는다(undefined는 전송에서 제외). - multipart 본문을 지원한다(아이템 이미지 업로드). - 본문·Content-Type 결정을 buildRequestBody 한 곳으로 모아 세 형식을 한눈에 볼 수 있다. 관리자 레인의 호출부는 Record을 넘기므로 넓어진 타입에 그대로 들어맞는다. 주석에는 두 도메인의 예시를 함께 남겼다.
@99edd25dfa4b39994bf18494187d053e2ed2cbc6
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { BackendRequestError } from '@/lib/http/backend-fetch';
|
|
| 5 | 6 |
import {
|
| 6 | 7 |
createAdminMember, |
| 7 | 8 |
deleteAdminMember, |
... | ... | @@ -28,9 +29,8 @@ |
| 28 | 29 |
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) — |
| 29 | 30 |
* 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다. |
| 30 | 31 |
* |
| 31 |
- * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock |
|
| 32 |
- * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은 |
|
| 33 |
- * 바뀌지 않는다. |
|
| 32 |
+ * 실제 저장은 Repository에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이 |
|
| 33 |
+ * 알지 못한다(현재 삭제만 mock이다). |
|
| 34 | 34 |
* |
| 35 | 35 |
* `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라 |
| 36 | 36 |
* `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을 |
... | ... | @@ -77,6 +77,27 @@ |
| 77 | 77 |
}; |
| 78 | 78 |
} |
| 79 | 79 |
|
| 80 |
+/** |
|
| 81 |
+ * 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다. |
|
| 82 |
+ * |
|
| 83 |
+ * 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가 |
|
| 84 |
+ * 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
|
|
| 85 |
+ * 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다. |
|
| 86 |
+ */ |
|
| 87 |
+async function runWrite( |
|
| 88 |
+ write: () => Promise<void> |
|
| 89 |
+): Promise<AdminMemberFormState | null> {
|
|
| 90 |
+ try {
|
|
| 91 |
+ await write(); |
|
| 92 |
+ return null; |
|
| 93 |
+ } catch (error) {
|
|
| 94 |
+ if (error instanceof BackendRequestError) {
|
|
| 95 |
+ return { status: 'error', message: error.message };
|
|
| 96 |
+ } |
|
| 97 |
+ throw error; |
|
| 98 |
+ } |
|
| 99 |
+} |
|
| 100 |
+ |
|
| 80 | 101 |
/** 시안 ADM_ADM_102_p — 관리자 등록. */ |
| 81 | 102 |
export async function createAdminMemberAction( |
| 82 | 103 |
_prevState: AdminMemberFormState, |
... | ... | @@ -94,7 +115,7 @@ |
| 94 | 115 |
return { status: 'error', errors: validation.errors };
|
| 95 | 116 |
} |
| 96 | 117 |
|
| 97 |
- const { name, loginId, phoneNumber, email, roleCode, menuCodes } =
|
|
| 118 |
+ const { name, loginId, password, phoneNumber, email, roleCode } =
|
|
| 98 | 119 |
validation.values; |
| 99 | 120 |
|
| 100 | 121 |
// 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고 |
... | ... | @@ -103,16 +124,13 @@ |
| 103 | 124 |
return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
|
| 104 | 125 |
} |
| 105 | 126 |
|
| 106 |
- // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock |
|
| 107 |
- // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조). |
|
| 108 |
- await createAdminMember({
|
|
| 109 |
- name, |
|
| 110 |
- loginId, |
|
| 111 |
- phoneNumber, |
|
| 112 |
- email, |
|
| 113 |
- roleCode, |
|
| 114 |
- menuCodes, |
|
| 115 |
- }); |
|
| 127 |
+ // 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조). |
|
| 128 |
+ const failure = await runWrite(() => |
|
| 129 |
+ createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
|
|
| 130 |
+ ); |
|
| 131 |
+ if (failure) {
|
|
| 132 |
+ return failure; |
|
| 133 |
+ } |
|
| 116 | 134 |
|
| 117 | 135 |
revalidatePath(ADMIN_MEMBERS_PATH); |
| 118 | 136 |
return { status: 'success' };
|
... | ... | @@ -135,16 +153,21 @@ |
| 135 | 153 |
return { status: 'error', errors: validation.errors };
|
| 136 | 154 |
} |
| 137 | 155 |
|
| 138 |
- const { phoneNumber, email, roleCode, menuCodes } = validation.values;
|
|
| 156 |
+ const { password, phoneNumber, email, roleCode } = validation.values;
|
|
| 139 | 157 |
|
| 140 |
- await updateAdminMember(id, {
|
|
| 141 |
- phoneNumber, |
|
| 142 |
- email, |
|
| 143 |
- roleCode, |
|
| 144 |
- menuCodes, |
|
| 145 |
- // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. |
|
| 146 |
- isLocked: readString(formData, 'isLocked') === 'true', |
|
| 147 |
- }); |
|
| 158 |
+ const failure = await runWrite(() => |
|
| 159 |
+ updateAdminMember(id, {
|
|
| 160 |
+ password, |
|
| 161 |
+ phoneNumber, |
|
| 162 |
+ email, |
|
| 163 |
+ roleCode, |
|
| 164 |
+ // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. |
|
| 165 |
+ isLocked: readString(formData, 'isLocked') === 'true', |
|
| 166 |
+ }) |
|
| 167 |
+ ); |
|
| 168 |
+ if (failure) {
|
|
| 169 |
+ return failure; |
|
| 170 |
+ } |
|
| 148 | 171 |
|
| 149 | 172 |
revalidatePath(ADMIN_MEMBERS_PATH); |
| 150 | 173 |
return { status: 'success' };
|
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -34,9 +34,13 @@ |
| 34 | 34 |
* 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 |
| 35 | 35 |
* 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. |
| 36 | 36 |
* |
| 37 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 비워 두면 기존 비밀번호를 |
|
| 38 |
- * 유지한다는 뜻이라 Server Action도 그때는 형식 검증을 건너뛴다. 지금은 저장할 백엔드 API가 없어 |
|
| 39 |
- * 입력값이 검증 후 버려진다 — 흐름만 완성해 둔 상태다. |
|
| 37 |
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이 |
|
| 38 |
+ * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 |
|
| 39 |
+ * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에 |
|
| 40 |
+ * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다. |
|
| 41 |
+ * |
|
| 42 |
+ * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을 |
|
| 43 |
+ * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다. |
|
| 40 | 44 |
* |
| 41 | 45 |
* "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
| 42 | 46 |
* 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. |
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -27,6 +27,11 @@ |
| 27 | 27 |
* |
| 28 | 28 |
* 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다 |
| 29 | 29 |
* (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다. |
| 30 |
+ * |
|
| 31 |
+ * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이 |
|
| 32 |
+ * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와 |
|
| 33 |
+ * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고 |
|
| 34 |
+ * Server Action이 값을 넘기기만 하면 된다. |
|
| 30 | 35 |
*/ |
| 31 | 36 |
export function AdminMemberFormFields({
|
| 32 | 37 |
member, |
--- lib/data/mock/admin-member-store.ts
+++ lib/data/mock/admin-member-store.ts
... | ... | @@ -2,164 +2,44 @@ |
| 2 | 2 |
import type { AdminMember } from '@/lib/domain/admin-member';
|
| 3 | 3 |
|
| 4 | 4 |
/** |
| 5 |
- * 관리자 회원 등록/수정/삭제의 **mock 저장소** — 백엔드에 해당 API가 없어 임시로 두는 계층이다. |
|
| 5 |
+ * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다. |
|
| 6 |
+ * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다. |
|
| 6 | 7 |
* |
| 7 |
- * 백엔드(edupay-backend develop b742bb4)의 관리자 도메인에는 목록 조회 |
|
| 8 |
- * (`GET /api/v1/mngr/admin/pagination`)와 로그인 조회밖에 없다 — 등록·수정·삭제·단건 조회 |
|
| 9 |
- * 어느 것도 컨트롤러·서비스·매퍼에 존재하지 않는다(패키지 전체를 읽어 확인). 그래서 시안 |
|
| 10 |
- * (ADM_ADM_101 ③⑤ / 102_p / 103_p)의 쓰기 흐름은 이 파일이 대신 처리한다. |
|
| 8 |
+ * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와 |
|
| 9 |
+ * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이 |
|
| 10 |
+ * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐). |
|
| 11 | 11 |
* |
| 12 |
- * **동작 방식은 "덮어쓰기 오버레이"다.** 목록의 원본은 여전히 백엔드이고, 이 저장소는 그 위에 |
|
| 13 |
- * 얹을 변경분(추가된 행 / 필드 패치 / 삭제된 id)만 들고 있다. Repository가 백엔드 응답을 받은 뒤 |
|
| 14 |
- * `applyMockOverlay`로 합쳐 화면에 넘긴다. |
|
| 12 |
+ * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면 |
|
| 13 |
+ * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다. |
|
| 15 | 14 |
* |
| 16 |
- * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.** |
|
| 17 |
- * - 서버 프로세스 메모리에만 있다. 재시작하면 사라지고, 인스턴스가 여럿이면 공유되지 않는다. |
|
| 18 |
- * - 비밀번호는 **보관하지 않는다.** mock이라 검증할 곳이 없고, 평문 비밀번호를 메모리에 들고 |
|
| 19 |
- * 있는 형태를 코드에 남기고 싶지 않다. 등록/수정 시 형식 검증만 하고 값은 버린다. |
|
| 20 |
- * |
|
| 21 |
- * 백엔드 API가 생기면 이 파일을 삭제하고 Repository의 쓰기 함수 본문만 실제 호출로 교체한다 — |
|
| 22 |
- * 화면·Server Action은 그대로다. |
|
| 15 |
+ * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다. |
|
| 23 | 16 |
*/ |
| 24 |
- |
|
| 25 |
-/** 신규 등록 행의 id 접두사 — 백엔드 `admUserId`와 섞이지 않게 구분한다. */ |
|
| 26 |
-const MOCK_ID_PREFIX = 'mock-admin-'; |
|
| 27 |
- |
|
| 28 |
-type AdminMemberPatch = Partial< |
|
| 29 |
- Pick< |
|
| 30 |
- AdminMember, |
|
| 31 |
- 'phoneNumber' | 'email' | 'roleCode' | 'menuCodes' | 'isLocked' |
|
| 32 |
- > |
|
| 33 |
->; |
|
| 34 | 17 |
|
| 35 | 18 |
type MockStoreState = {
|
| 36 |
- created: AdminMember[]; |
|
| 37 |
- patches: Map<string, AdminMemberPatch>; |
|
| 38 | 19 |
deletedIds: Set<string>; |
| 39 |
- nextId: number; |
|
| 40 | 20 |
}; |
| 41 | 21 |
|
| 42 |
-/** |
|
| 43 |
- * 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 — 저장한 |
|
| 44 |
- * 내용이 코드 수정 때마다 사라지면 mock으로 흐름을 확인하는 의미가 없다. mock 전용 편법이며, |
|
| 45 |
- * 실제 데이터 계층에는 쓰지 않는다. |
|
| 46 |
- */ |
|
| 22 |
+/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */ |
|
| 47 | 23 |
const globalStore = globalThis as typeof globalThis & {
|
| 48 | 24 |
__adminMemberMockStore?: MockStoreState; |
| 49 | 25 |
}; |
| 50 | 26 |
|
| 51 | 27 |
function getState(): MockStoreState {
|
| 52 |
- globalStore.__adminMemberMockStore ??= {
|
|
| 53 |
- created: [], |
|
| 54 |
- patches: new Map(), |
|
| 55 |
- deletedIds: new Set(), |
|
| 56 |
- nextId: 1, |
|
| 57 |
- }; |
|
| 28 |
+ globalStore.__adminMemberMockStore ??= { deletedIds: new Set() };
|
|
| 58 | 29 |
return globalStore.__adminMemberMockStore; |
| 59 | 30 |
} |
| 60 | 31 |
|
| 61 |
-/** 오늘 날짜(YYYY-MM-DD) — 신규 등록 행의 생성일. */ |
|
| 62 |
-function today(): string {
|
|
| 63 |
- return new Date().toISOString().slice(0, 10); |
|
| 32 |
+/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */ |
|
| 33 |
+export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] {
|
|
| 34 |
+ const { deletedIds } = getState();
|
|
| 35 |
+ return backendItems.filter((item) => !deletedIds.has(item.id)); |
|
| 64 | 36 |
} |
| 65 | 37 |
|
| 66 |
-export type CreateAdminMemberInput = {
|
|
| 67 |
- name: string; |
|
| 68 |
- loginId: string; |
|
| 69 |
- phoneNumber: string; |
|
| 70 |
- email: string; |
|
| 71 |
- roleCode: string; |
|
| 72 |
- menuCodes: string[]; |
|
| 73 |
-}; |
|
| 74 |
- |
|
| 75 |
-export type UpdateAdminMemberInput = {
|
|
| 76 |
- phoneNumber: string; |
|
| 77 |
- email: string; |
|
| 78 |
- roleCode: string; |
|
| 79 |
- menuCodes: string[]; |
|
| 80 |
- isLocked: boolean; |
|
| 81 |
-}; |
|
| 82 |
- |
|
| 83 |
-/** |
|
| 84 |
- * 백엔드 목록에 mock 변경분을 얹는다 — 삭제된 행을 걷어내고, 수정된 행에 패치를 입히고, |
|
| 85 |
- * 신규 등록 행을 **앞에** 붙인다(기본 정렬이 생성일 최신순이라 방금 만든 행이 맨 위에 온다). |
|
| 86 |
- * |
|
| 87 |
- * 정렬·검색·페이징은 이 함수가 하지 않는다 — 합쳐진 전체 목록을 대상으로 Repository가 처리한다. |
|
| 88 |
- */ |
|
| 89 |
-export function applyMockOverlay(backendItems: AdminMember[]): AdminMember[] {
|
|
| 90 |
- const { created, patches, deletedIds } = getState();
|
|
| 91 |
- |
|
| 92 |
- const merged = [...created, ...backendItems]; |
|
| 93 |
- |
|
| 94 |
- return merged |
|
| 95 |
- .filter((item) => !deletedIds.has(item.id)) |
|
| 96 |
- .map((item) => {
|
|
| 97 |
- const patch = patches.get(item.id); |
|
| 98 |
- return patch ? { ...item, ...patch } : item;
|
|
| 99 |
- }); |
|
| 38 |
+export function isMockDeleted(id: string): boolean {
|
|
| 39 |
+ return getState().deletedIds.has(id); |
|
| 100 | 40 |
} |
| 101 | 41 |
|
| 102 |
-/** mock으로 등록된 행들의 로그인 ID — 중복 확인이 백엔드 목록과 함께 검사할 수 있게 노출한다. */ |
|
| 103 |
-export function listMockCreatedLoginIds(): string[] {
|
|
| 104 |
- return getState().created.map((item) => item.loginId); |
|
| 105 |
-} |
|
| 106 |
- |
|
| 107 |
-export function createMockAdminMember( |
|
| 108 |
- input: CreateAdminMemberInput |
|
| 109 |
-): AdminMember {
|
|
| 110 |
- const state = getState(); |
|
| 111 |
- const id = `${MOCK_ID_PREFIX}${state.nextId++}`;
|
|
| 112 |
- |
|
| 113 |
- const member: AdminMember = {
|
|
| 114 |
- id, |
|
| 115 |
- name: input.name, |
|
| 116 |
- loginId: input.loginId, |
|
| 117 |
- phoneNumber: input.phoneNumber, |
|
| 118 |
- email: input.email || null, |
|
| 119 |
- roleCode: input.roleCode, |
|
| 120 |
- menuCodes: input.menuCodes, |
|
| 121 |
- createdAt: today(), |
|
| 122 |
- isLocked: false, |
|
| 123 |
- isActive: true, |
|
| 124 |
- loginFailCount: 0, |
|
| 125 |
- }; |
|
| 126 |
- |
|
| 127 |
- state.created.unshift(member); |
|
| 128 |
- return member; |
|
| 129 |
-} |
|
| 130 |
- |
|
| 131 |
-/** |
|
| 132 |
- * 수정 — 신규 등록 행은 원본을 직접 고치고, 백엔드에서 온 행은 패치로 기록해 둔다(원본을 우리가 |
|
| 133 |
- * 소유하지 않으므로 매 조회마다 덧입힌다). |
|
| 134 |
- */ |
|
| 135 |
-export function updateMockAdminMember( |
|
| 136 |
- id: string, |
|
| 137 |
- input: UpdateAdminMemberInput |
|
| 138 |
-): void {
|
|
| 139 |
- const state = getState(); |
|
| 140 |
- |
|
| 141 |
- const patch: AdminMemberPatch = {
|
|
| 142 |
- phoneNumber: input.phoneNumber, |
|
| 143 |
- email: input.email || null, |
|
| 144 |
- roleCode: input.roleCode, |
|
| 145 |
- menuCodes: input.menuCodes, |
|
| 146 |
- isLocked: input.isLocked, |
|
| 147 |
- }; |
|
| 148 |
- |
|
| 149 |
- const createdIndex = state.created.findIndex((item) => item.id === id); |
|
| 150 |
- if (createdIndex >= 0) {
|
|
| 151 |
- state.created[createdIndex] = { ...state.created[createdIndex], ...patch };
|
|
| 152 |
- return; |
|
| 153 |
- } |
|
| 154 |
- |
|
| 155 |
- state.patches.set(id, { ...state.patches.get(id), ...patch });
|
|
| 156 |
-} |
|
| 157 |
- |
|
| 158 |
-export function deleteMockAdminMember(id: string): void {
|
|
| 159 |
- const state = getState(); |
|
| 160 |
- |
|
| 161 |
- state.created = state.created.filter((item) => item.id !== id); |
|
| 162 |
- state.patches.delete(id); |
|
| 163 |
- // 백엔드에서 오는 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. |
|
| 164 |
- state.deletedIds.add(id); |
|
| 42 |
+/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */ |
|
| 43 |
+export function markMockDeleted(id: string): void {
|
|
| 44 |
+ getState().deletedIds.add(id); |
|
| 165 | 45 |
} |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -7,30 +7,29 @@ |
| 7 | 7 |
AdminMemberSearchField, |
| 8 | 8 |
} from '@/lib/domain/admin-member-query'; |
| 9 | 9 |
import {
|
| 10 |
- applyMockOverlay, |
|
| 11 |
- createMockAdminMember, |
|
| 12 |
- deleteMockAdminMember, |
|
| 13 |
- listMockCreatedLoginIds, |
|
| 14 |
- updateMockAdminMember, |
|
| 15 |
- type CreateAdminMemberInput, |
|
| 16 |
- type UpdateAdminMemberInput, |
|
| 10 |
+ applyMockDeletions, |
|
| 11 |
+ isMockDeleted, |
|
| 12 |
+ markMockDeleted, |
|
| 17 | 13 |
} from '@/lib/data/mock/admin-member-store'; |
| 18 | 14 |
|
| 19 | 15 |
/** |
| 20 |
- * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터· |
|
| 16 |
+ * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터· |
|
| 21 | 17 |
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
| 22 | 18 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 23 | 19 |
* |
| 24 |
- * GET /api/v1/mngr/admin/pagination (ROLE_ADMIN 전용) |
|
| 25 |
- * → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd,
|
|
| 26 |
- * loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages } |
|
| 20 |
+ * GET /api/v1/mngr/admin/pagination 목록 |
|
| 21 |
+ * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 22 |
+ * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 23 |
+ * POST /api/v1/mngr/admin 등록 |
|
| 24 |
+ * PUT /api/v1/mngr/admin 수정 |
|
| 27 | 25 |
* |
| 28 |
- * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
|
| 29 |
- * — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil. |
|
| 26 |
+ * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다. |
|
| 30 | 27 |
* |
| 31 |
- * - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고 |
|
| 32 |
- * `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO |
|
| 33 |
- * 필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. |
|
| 28 |
+ * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이 |
|
| 29 |
+ * `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다. |
|
| 30 |
+ * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어 |
|
| 31 |
+ * 응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다. |
|
| 32 |
+ * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다. |
|
| 34 | 33 |
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 |
| 35 | 34 |
* `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 |
| 36 | 35 |
* 그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를 |
... | ... | @@ -44,7 +43,8 @@ |
| 44 | 43 |
* 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
| 45 | 44 |
*/ |
| 46 | 45 |
|
| 47 |
-const ADMIN_MEMBER_PAGINATION_PATH = '/api/v1/mngr/admin/pagination'; |
|
| 46 |
+const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin'; |
|
| 47 |
+const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
|
|
| 48 | 48 |
|
| 49 | 49 |
/** |
| 50 | 50 |
* 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.** |
... | ... | @@ -56,8 +56,8 @@ |
| 56 | 56 |
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다. |
| 57 | 57 |
* 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색 |
| 58 | 58 |
* 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조). |
| 59 |
- * 3. **등록/수정/삭제가 mock이다.** mock으로 만든 행과 백엔드 행이 같은 검색·정렬·페이징 규칙을 |
|
| 60 |
- * 따라야 하는데, 페이징이 백엔드에 있으면 두 출처를 일관되게 합칠 방법이 없다. |
|
| 59 |
+ * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면 |
|
| 60 |
+ * 페이지마다 구멍이 생겨 건수와 순번이 어긋난다. |
|
| 61 | 61 |
* |
| 62 | 62 |
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 |
| 63 | 63 |
* 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. |
... | ... | @@ -115,8 +115,9 @@ |
| 115 | 115 |
id: readRequiredString(raw, 'admUserId'), |
| 116 | 116 |
name: readRequiredString(raw, 'admNm'), |
| 117 | 117 |
loginId: readRequiredString(raw, 'loginId'), |
| 118 |
- phoneNumber: null, |
|
| 119 |
- email: null, |
|
| 118 |
+ // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조). |
|
| 119 |
+ phoneNumber: readOptionalString(raw, 'admTelNo'), |
|
| 120 |
+ email: readOptionalString(raw, 'admEmlAddr'), |
|
| 120 | 121 |
roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
| 121 | 122 |
menuCodes: [], |
| 122 | 123 |
createdAt: null, |
... | ... | @@ -204,7 +205,7 @@ |
| 204 | 205 |
export async function fetchAdminMembers( |
| 205 | 206 |
query: AdminMemberQuery |
| 206 | 207 |
): Promise<AdminMemberPage> {
|
| 207 |
- const all = applyMockOverlay(await fetchAllAdminMembers()); |
|
| 208 |
+ const all = applyMockDeletions(await fetchAllAdminMembers()); |
|
| 208 | 209 |
const matched = sortItems(filterByKeyword(all, query), query); |
| 209 | 210 |
|
| 210 | 211 |
const offset = (query.page - 1) * query.pageSize; |
... | ... | @@ -215,53 +216,148 @@ |
| 215 | 216 |
}; |
| 216 | 217 |
} |
| 217 | 218 |
|
| 218 |
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */ |
|
| 219 |
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */ |
|
| 219 | 220 |
export async function findAdminMemberById( |
| 220 | 221 |
id: string |
| 221 | 222 |
): Promise<AdminMember | null> {
|
| 222 |
- const all = applyMockOverlay(await fetchAllAdminMembers()); |
|
| 223 |
- return all.find((item) => item.id === id) ?? null; |
|
| 223 |
+ if (isMockDeleted(id)) {
|
|
| 224 |
+ return null; |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ const accessToken = await getSessionAccessToken(); |
|
| 228 |
+ const result = await backendFetch<unknown>( |
|
| 229 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 230 |
+ {
|
|
| 231 |
+ method: 'GET', |
|
| 232 |
+ accessToken: accessToken ?? undefined, |
|
| 233 |
+ cache: 'no-store', |
|
| 234 |
+ // 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
|
|
| 235 |
+ canHaveNullData: true, |
|
| 236 |
+ } |
|
| 237 |
+ ); |
|
| 238 |
+ |
|
| 239 |
+ if (!result.ok) {
|
|
| 240 |
+ throw new BackendRequestError(result); |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ return result.data === null ? null : toAdminMember(result.data); |
|
| 224 | 244 |
} |
| 225 | 245 |
|
| 226 | 246 |
/** |
| 227 |
- * ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로 |
|
| 228 |
- * 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에 |
|
| 229 |
- * 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드 |
|
| 230 |
- * (DB 유니크 제약)의 몫이다. |
|
| 247 |
+ * 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①). |
|
| 248 |
+ * |
|
| 249 |
+ * 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점 |
|
| 250 |
+ * 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은 |
|
| 251 |
+ * 등록 API가 저장 직전에 다시 검사한다. |
|
| 231 | 252 |
*/ |
| 232 | 253 |
export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
|
| 233 |
- const normalized = loginId.trim().toLowerCase(); |
|
| 254 |
+ const accessToken = await getSessionAccessToken(); |
|
| 234 | 255 |
|
| 235 |
- const backendLoginIds = (await fetchAllAdminMembers()).map( |
|
| 236 |
- (item) => item.loginId |
|
| 256 |
+ const result = await backendFetch<unknown>( |
|
| 257 |
+ `${ADMIN_MEMBER_BASE_PATH}/duplication/${encodeURIComponent(loginId.trim())}`,
|
|
| 258 |
+ {
|
|
| 259 |
+ method: 'GET', |
|
| 260 |
+ accessToken: accessToken ?? undefined, |
|
| 261 |
+ cache: 'no-store', |
|
| 262 |
+ canHaveNullData: true, |
|
| 263 |
+ } |
|
| 237 | 264 |
); |
| 238 | 265 |
|
| 239 |
- return [...backendLoginIds, ...listMockCreatedLoginIds()].some( |
|
| 240 |
- (existing) => existing.toLowerCase() === normalized |
|
| 241 |
- ); |
|
| 266 |
+ if (!result.ok) {
|
|
| 267 |
+ throw new BackendRequestError(result); |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ return result.data !== null; |
|
| 242 | 271 |
} |
| 243 | 272 |
|
| 244 | 273 |
/* |
| 245 | 274 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 246 |
- * 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다 |
|
| 247 |
- * (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다). |
|
| 248 |
- * 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고, |
|
| 249 |
- * Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다. |
|
| 275 |
+ * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다. |
|
| 276 |
+ * |
|
| 277 |
+ * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을 |
|
| 278 |
+ * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 279 |
+ * |
|
| 280 |
+ * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다 |
|
| 281 |
+ * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다). |
|
| 250 | 282 |
*/ |
| 283 |
+ |
|
| 284 |
+export type CreateAdminMemberInput = {
|
|
| 285 |
+ name: string; |
|
| 286 |
+ loginId: string; |
|
| 287 |
+ password: string; |
|
| 288 |
+ phoneNumber: string; |
|
| 289 |
+ email: string; |
|
| 290 |
+ roleCode: string; |
|
| 291 |
+}; |
|
| 292 |
+ |
|
| 293 |
+export type UpdateAdminMemberInput = {
|
|
| 294 |
+ /** |
|
| 295 |
+ * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다. |
|
| 296 |
+ * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다. |
|
| 297 |
+ */ |
|
| 298 |
+ password: string; |
|
| 299 |
+ phoneNumber: string; |
|
| 300 |
+ email: string; |
|
| 301 |
+ roleCode: string; |
|
| 302 |
+ isLocked: boolean; |
|
| 303 |
+}; |
|
| 304 |
+ |
|
| 305 |
+/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */ |
|
| 306 |
+function toYesNo(value: boolean): string {
|
|
| 307 |
+ return value ? 'Y' : 'N'; |
|
| 308 |
+} |
|
| 251 | 309 |
|
| 252 | 310 |
export async function createAdminMember( |
| 253 | 311 |
input: CreateAdminMemberInput |
| 254 | 312 |
): Promise<void> {
|
| 255 |
- createMockAdminMember(input); |
|
| 313 |
+ const accessToken = await getSessionAccessToken(); |
|
| 314 |
+ |
|
| 315 |
+ const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 316 |
+ method: 'POST', |
|
| 317 |
+ form: {
|
|
| 318 |
+ admNm: input.name, |
|
| 319 |
+ loginId: input.loginId, |
|
| 320 |
+ loginPw: input.password, |
|
| 321 |
+ admEmlAddr: input.email, |
|
| 322 |
+ admTelNo: input.phoneNumber, |
|
| 323 |
+ admRoleCd: input.roleCode, |
|
| 324 |
+ }, |
|
| 325 |
+ accessToken: accessToken ?? undefined, |
|
| 326 |
+ canHaveNullData: true, |
|
| 327 |
+ }); |
|
| 328 |
+ |
|
| 329 |
+ if (!result.ok) {
|
|
| 330 |
+ // 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다. |
|
| 331 |
+ throw new BackendRequestError(result); |
|
| 332 |
+ } |
|
| 256 | 333 |
} |
| 257 | 334 |
|
| 258 | 335 |
export async function updateAdminMember( |
| 259 | 336 |
id: string, |
| 260 | 337 |
input: UpdateAdminMemberInput |
| 261 | 338 |
): Promise<void> {
|
| 262 |
- updateMockAdminMember(id, input); |
|
| 339 |
+ const accessToken = await getSessionAccessToken(); |
|
| 340 |
+ |
|
| 341 |
+ const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 342 |
+ method: 'PUT', |
|
| 343 |
+ form: {
|
|
| 344 |
+ admUserId: id, |
|
| 345 |
+ loginPw: input.password, |
|
| 346 |
+ admEmlAddr: input.email, |
|
| 347 |
+ admTelNo: input.phoneNumber, |
|
| 348 |
+ admRoleCd: input.roleCode, |
|
| 349 |
+ acctLockYn: toYesNo(input.isLocked), |
|
| 350 |
+ }, |
|
| 351 |
+ accessToken: accessToken ?? undefined, |
|
| 352 |
+ canHaveNullData: true, |
|
| 353 |
+ }); |
|
| 354 |
+ |
|
| 355 |
+ if (!result.ok) {
|
|
| 356 |
+ throw new BackendRequestError(result); |
|
| 357 |
+ } |
|
| 263 | 358 |
} |
| 264 | 359 |
|
| 360 |
+/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */ |
|
| 265 | 361 |
export async function deleteAdminMember(id: string): Promise<void> {
|
| 266 |
- deleteMockAdminMember(id); |
|
| 362 |
+ markMockDeleted(id); |
|
| 267 | 363 |
} |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -15,8 +15,8 @@ |
| 15 | 15 |
* 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는 |
| 16 | 16 |
* 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다. |
| 17 | 17 |
* |
| 18 |
- * 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이 |
|
| 19 |
- * 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다). |
|
| 18 |
+ * 형식 규칙(자릿수·조합)의 근거는 여전히 시안뿐이다 — 백엔드 등록·수정 API는 값을 그대로 받아 |
|
| 19 |
+ * 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`). |
|
| 20 | 20 |
*/ |
| 21 | 21 |
|
| 22 | 22 |
import {
|
... | ... | @@ -29,6 +29,17 @@ |
| 29 | 29 |
'영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; |
| 30 | 30 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 31 | 31 |
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 32 |
+ |
|
| 33 |
+/** |
|
| 34 |
+ * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다. |
|
| 35 |
+ * |
|
| 36 |
+ * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을 |
|
| 37 |
+ * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과 |
|
| 38 |
+ * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이 |
|
| 39 |
+ * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다. |
|
| 40 |
+ */ |
|
| 41 |
+export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT = |
|
| 42 |
+ '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)'; |
|
| 32 | 43 |
|
| 33 | 44 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 34 | 45 |
const LOGIN_ID_MAX_LENGTH = 20; |
... | ... | @@ -146,25 +157,26 @@ |
| 146 | 157 |
/** |
| 147 | 158 |
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 148 | 159 |
* |
| 149 |
- * `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①), |
|
| 150 |
- * 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지). |
|
| 160 |
+ * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내, |
|
| 161 |
+ * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다 |
|
| 162 |
+ * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조). |
|
| 151 | 163 |
*/ |
| 152 | 164 |
function validateEditableValues( |
| 153 | 165 |
values: AdminMemberEditableValues, |
| 154 | 166 |
errors: AdminMemberFormErrors, |
| 155 |
- options: { isPasswordRequired: boolean }
|
|
| 167 |
+ options: { emptyPasswordMessage: string }
|
|
| 156 | 168 |
): AdminMemberEditableValues {
|
| 157 | 169 |
const password = values.password; |
| 158 | 170 |
const phoneNumber = values.phoneNumber.trim(); |
| 159 | 171 |
const email = values.email.trim(); |
| 160 | 172 |
|
| 161 |
- if (options.isPasswordRequired || password) {
|
|
| 162 |
- if ( |
|
| 163 |
- password.length < PASSWORD_MIN_LENGTH || |
|
| 164 |
- countCharacterKinds(password) < 2 |
|
| 165 |
- ) {
|
|
| 166 |
- errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 167 |
- } |
|
| 173 |
+ if (!password) {
|
|
| 174 |
+ errors.password = options.emptyPasswordMessage; |
|
| 175 |
+ } else if ( |
|
| 176 |
+ password.length < PASSWORD_MIN_LENGTH || |
|
| 177 |
+ countCharacterKinds(password) < 2 |
|
| 178 |
+ ) {
|
|
| 179 |
+ errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 168 | 180 |
} |
| 169 | 181 |
|
| 170 | 182 |
if (!phoneNumber) {
|
... | ... | @@ -209,7 +221,7 @@ |
| 209 | 221 |
} |
| 210 | 222 |
|
| 211 | 223 |
const editable = validateEditableValues(values, errors, {
|
| 212 |
- isPasswordRequired: true, |
|
| 224 |
+ emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT, |
|
| 213 | 225 |
}); |
| 214 | 226 |
|
| 215 | 227 |
if (Object.keys(errors).length > 0) {
|
... | ... | @@ -229,7 +241,7 @@ |
| 229 | 241 |
const errors: AdminMemberFormErrors = {};
|
| 230 | 242 |
|
| 231 | 243 |
const editable = validateEditableValues(values, errors, {
|
| 232 |
- isPasswordRequired: false, |
|
| 244 |
+ emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT, |
|
| 233 | 245 |
}); |
| 234 | 246 |
|
| 235 | 247 |
if (Object.keys(errors).length > 0) {
|
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -54,12 +54,13 @@ |
| 54 | 54 |
/** JSON 본문. `@RequestBody`로 받는 엔드포인트(예: 로그인)에 쓴다. */ |
| 55 | 55 |
body?: unknown; |
| 56 | 56 |
/** |
| 57 |
- * form 인코딩 본문(`application/x-www-form-urlencoded`). |
|
| 57 |
+ * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다. |
|
| 58 | 58 |
* |
| 59 | 59 |
* 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute |
| 60 |
- * 바인딩)** 로 파라미터를 받는다(예: `POST/PUT /api/v1/mngr/item`). 그런 엔드포인트에 JSON을 |
|
| 61 |
- * 보내면 바인딩이 하나도 되지 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 |
|
| 62 |
- * 조용히 빈 레코드가 생기므로, 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 60 |
+ * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, |
|
| 61 |
+ * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 |
|
| 62 |
+ * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, |
|
| 63 |
+ * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 63 | 64 |
* |
| 64 | 65 |
* `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다). |
| 65 | 66 |
*/ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?