Browse Source

+ 룰 추가

송용우 4 months ago
parent
commit
ce046371ce

+ 288 - 0
.cursor/rules/api-rule.mdc

@@ -1,3 +1,7 @@
+---
+alwaysApply: true
+---
+
 # 최우선 규칙: 한글 응답 필수
 
 **모든 응답은 한글로만 작성해야 함. 이 규칙은 다른 모든 규칙보다 우선한다.**
@@ -9,6 +13,290 @@
 - companyId 관련 변수, 필드, 파라미터 모두 제거
 - API 요청/응답에서 companyId 사용 금지
 
+# 벤더-인플루언서 처리자 SEQ 인증 규칙
+
+**백엔드에서 processedBy, approvedBy, terminatedBy 등 처리자 SEQ를 받을 때는 반드시 다음 로직을 적용해야 함:**
+
+## 처리자 SEQ 변환 표준 로직
+```php
+// 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
+$processingUser = null;
+
+// 1. 먼저 USER_LIST에서 확인
+$processingUser = $this->userModel
+    ->where('SEQ', $processedBy)
+    ->where('IS_ACT', 'Y')
+    ->first();
+
+if ($processingUser) {
+    // 사용자 SEQ인 경우 바로 사용
+    $approvedByUserSeq = $processedBy;
+} else {
+    // 2. processedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+    $vendorInfo = $this->vendorModel
+        ->where('SEQ', $processedBy)
+        ->where('IS_ACT', 'Y')
+        ->first();
+    
+    if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
+        // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
+        $vendorUser = $this->userModel
+            ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
+            ->where('IS_ACT', 'Y')
+            ->first();
+        
+        if ($vendorUser) {
+            $approvedByUserSeq = $vendorUser['SEQ'];
+            $processingUser = $vendorUser;
+        } else {
+            return $this->response->setStatusCode(400)->setJSON([
+                'success' => false,
+                'message' => "벤더사 SEQ {$processedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다."
+            ]);
+        }
+    } else {
+        return $this->response->setStatusCode(400)->setJSON([
+            'success' => false,
+            'message' => "처리자 SEQ {$processedBy}는 USER_LIST나 VENDOR_LIST에서 찾을 수 없습니다."
+        ]);
+    }
+}
+
+// 최종적으로 $approvedByUserSeq를 데이터베이스에 저장
+```
+
+## 규칙 적용 필수 상황
+- **승인/거부 처리**: approveRequest() 함수
+- **해지 처리**: terminate() 함수  
+- **취소 처리**: cancelRequest() 함수
+- **기타 모든 사용자 인증이 필요한 벤더-인플루언서 관련 API**
+
+## 이유
+- 프론트엔드에서 벤더사 SEQ를 전달할 수 있음
+- 데이터베이스 외래키는 USER_LIST.SEQ를 참조함
+- 벤더사 SEQ → 해당 벤더사 담당자 USER SEQ 변환 필요
+
+# API & Store Rules
+
+## Pinia Store Rules
+
+### 1. Setup Syntax Store Reset 구현
+- Setup 문법(`defineStore(() => {...})`)으로 작성된 store는 자동 `$reset()`이 제공되지 않음
+- 반드시 수동으로 `reset()` 함수를 구현해야 함
+```typescript
+// Good
+export const useMyStore = defineStore('myStore', () => {
+  const data = ref([])
+  const loading = ref(false)
+  
+  // 수동으로 reset 함수 구현
+  function reset() {
+    data.value = []
+    loading.value = false
+  }
+  
+  return {
+    data,
+    loading,
+    reset  // reset 함수 반환 필수
+  }
+})
+
+// Bad - reset 함수 없음
+export const useMyStore = defineStore('myStore', () => {
+  const data = ref([])
+  return { data }
+})
+```
+
+### 2. Reset 함수 구현 가이드
+- 모든 state를 초기값으로 되돌리는 로직 포함
+- 중첩된 객체의 경우 깊은 복사 고려
+- persist 옵션이 있는 경우 저장소 데이터도 정리
+```typescript
+function reset() {
+  // 단순 값 초기화
+  simpleValue.value = null
+  
+  // 객체 초기화
+  objectValue.value = {
+    prop1: '',
+    prop2: 0
+  }
+  
+  // 배열 초기화
+  arrayValue.value = []
+  
+  // 중첩 객체 초기화
+  complexValue.value = JSON.parse(JSON.stringify(DEFAULT_STATE))
+}
+```
+
+### 3. Store 초기화 호출 방식
+- Setup 문법 store: `store.reset()`
+- Options 문법 store: `store.$reset()`
+```typescript
+// Setup store
+const setupStore = useSetupStore()
+setupStore.reset()  // O
+setupStore.$reset() // X - 에러 발생
+
+// Options store
+const optionsStore = useOptionsStore()
+optionsStore.$reset() // O
+```
+
+### 4. Store 초기화 시점
+- 로그아웃
+- 사용자 전환
+- 주요 상태 변경
+- 에러 복구
+```typescript
+async function logout() {
+  // 모든 store 초기화
+  authStore.setLogout()
+  setupStore.reset()     // Setup syntax
+  optionsStore.$reset()  // Options syntax
+  
+  // 로컬 스토리지 정리
+  localStorage.clear()
+}
+```
+
+## API Rules
+
+- api 서버는 코드이그나이터4 베이스의 벡엔드 기술로 구현되어있으며
+  기존 문서에사용되는 양식을 지키며 구현
+- 프론트에서 api신규 생성시 백엔드 코드이그나4 기반의 기술로 구현하는 예제를 함께 제공
+- 항상 페이지 구성이 완료되고 나면 제작에 필요한 쿼리를 DDL형태로 구성해서 ddl폴더에 만들어줘
+- api구성후 백엔드 예제를 backend-examples에 코드이그나이터4 형태로 구성해줘
+- MD파일을 생성해서 백엔드 구성과 DB생성을 하는 과정을 순서대로 작성해줘
+- 프론트화면 및 UI / API 구성시에는 항상 composition api 형태로 작성 css는 항상 scss형태로 분리해서 구성
+
+## 프론트엔드 API 호출 규칙
+- **Nuxt.js server/api 사용 금지**: 프론트엔드에서 직접 백엔드 API 호출
+- **useAxios() 패턴 강제**: 기존 코드베이스와 일관성 유지
+- 반드시 다음 형태로 구성:
+```javascript
+const loadData = async () => {
+  try {
+    const params = {
+      // 파라미터들...
+    }
+
+    useAxios()
+      .post('/api/endpoint', params)
+      .then((res) => {
+        if (res.data.success) {
+          // 성공 처리
+          data.value = res.data.data
+        } else {
+          // 실패 처리
+          error.value = res.data.message
+        }
+      })
+      .catch((err) => {
+        // 에러 처리
+        error.value = err.message
+      })
+      .finally(() => {
+        // 완료 처리
+        loading.value = false
+      })
+
+  } catch (err) {
+    error.value = err.message
+  }
+}
+```
+
+## API 구조 금지사항
+- **server/api 디렉토리 생성 금지**: Nuxt.js 서버 API 사용하지 않음
+- **mysql2, 데이터베이스 라이브러리 사용 금지**: 프론트엔드에서 직접 DB 연결 금지
+- **$fetch 사용 금지**: useAxios() 패턴만 사용
+- **async/await 패턴 지양**: .then().catch().finally() 체인 사용
+
+## 백엔드 연동 방식
+- 프론트엔드 → CodeIgniter4 백엔드 직접 호출
+- useAxios()를 통한 HTTP 통신만 사용
+- 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
+- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
+- useAxios()를 통한 HTTP 통신만 사용
+- 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
+- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어# 최우선 규칙: 한글 응답 필수
+
+**모든 응답은 한글로만 작성해야 함. 이 규칙은 다른 모든 규칙보다 우선한다.**
+
+# companyId 사용 금지 규칙
+
+**companyId는 사용하지 않는 값이므로 모든 코드에서 제거해야 함. 프론트엔드, 백엔드 모두 해당.**
+- 대신 COMPANY_NUMBER를 직접 사용
+- companyId 관련 변수, 필드, 파라미터 모두 제거
+- API 요청/응답에서 companyId 사용 금지
+
+# 벤더-인플루언서 처리자 SEQ 인증 규칙
+
+**백엔드에서 processedBy, approvedBy, terminatedBy 등 처리자 SEQ를 받을 때는 반드시 다음 로직을 적용해야 함:**
+
+## 처리자 SEQ 변환 표준 로직
+```php
+// 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
+$processingUser = null;
+
+// 1. 먼저 USER_LIST에서 확인
+$processingUser = $this->userModel
+    ->where('SEQ', $processedBy)
+    ->where('IS_ACT', 'Y')
+    ->first();
+
+if ($processingUser) {
+    // 사용자 SEQ인 경우 바로 사용
+    $approvedByUserSeq = $processedBy;
+} else {
+    // 2. processedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+    $vendorInfo = $this->vendorModel
+        ->where('SEQ', $processedBy)
+        ->where('IS_ACT', 'Y')
+        ->first();
+    
+    if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
+        // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
+        $vendorUser = $this->userModel
+            ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
+            ->where('IS_ACT', 'Y')
+            ->first();
+        
+        if ($vendorUser) {
+            $approvedByUserSeq = $vendorUser['SEQ'];
+            $processingUser = $vendorUser;
+        } else {
+            return $this->response->setStatusCode(400)->setJSON([
+                'success' => false,
+                'message' => "벤더사 SEQ {$processedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다."
+            ]);
+        }
+    } else {
+        return $this->response->setStatusCode(400)->setJSON([
+            'success' => false,
+            'message' => "처리자 SEQ {$processedBy}는 USER_LIST나 VENDOR_LIST에서 찾을 수 없습니다."
+        ]);
+    }
+}
+
+// 최종적으로 $approvedByUserSeq를 데이터베이스에 저장
+```
+
+## 규칙 적용 필수 상황
+- **승인/거부 처리**: approveRequest() 함수
+- **해지 처리**: terminate() 함수  
+- **취소 처리**: cancelRequest() 함수
+- **기타 모든 사용자 인증이 필요한 벤더-인플루언서 관련 API**
+
+## 이유
+- 프론트엔드에서 벤더사 SEQ를 전달할 수 있음
+- 데이터베이스 외래키는 USER_LIST.SEQ를 참조함
+- 벤더사 SEQ → 해당 벤더사 담당자 USER SEQ 변환 필요
+
 # API & Store Rules
 
 ## Pinia Store Rules

+ 9 - 0
.cursor/rules/code-style-consistency.mdc

@@ -0,0 +1,9 @@
+---
+description: 프로젝트 전체에서 일관된 코드 스타일을 유지합니다.
+alwaysApply: true
+---
+
+- 변수명과 함수명은 snake_case를 사용합니다.
+- 클래스명은 PascalCase를 사용합니다.
+- 코드 포맷팅은 [Prettier 또는 Black 등 프로젝트에서 사용하는 포맷터 이름] 규칙을 따릅니다.
+- 특정 코드 패턴이나 라이브러리 사용법에 대한 예시를 제공하여 AI가 다른 방식을 시도하지 않도록 합니다. (예: 데이터 페칭에는 항상 `axios`를 사용) [1]

+ 8 - 0
.cursor/rules/concise-answer.mdc

@@ -0,0 +1,8 @@
+---
+description: AI가 간결하고 핵심적인 답변만 생성하도록 유도합니다.
+alwaysApply: true
+---
+
+- 답변은 항상 간결하게, 핵심만 요약해서 제공해주세요.
+- 코드에 대한 설명은 요청할 때만 추가하고, 기본적으로는 코드만 생성해주세요.
+- 불필요한 인사나 서론, 결론은 생략해주세요.

+ 12 - 0
.cursor/rules/context-limitation.mdc

@@ -0,0 +1,12 @@
+---
+description: AI가 불필요한 파일을 컨텍스트에 포함하지 않도록 합니다.
+globs:
+  - "!**/node_modules/**"
+  - "!**/*.log"
+  - "!**/dist/**"
+  - "!**/build/**"
+alwaysApply: false
+---
+
+- 현재 작업과 직접적으로 관련된 파일만 참고해주세요.
+- 특히 `node_modules`, 로그 파일, 빌드 결과물은 컨텍스트에서 제외해주세요.

+ 2 - 86
.cursor/rules/db-structure-rules.mdc

@@ -1,87 +1,3 @@
-# 데이터베이스 구조 규칙
-
-## 1. 사용자 구분 및 관리
-
-### 1.1 인플루언서 (USER_LIST)
-- 인플루언서 정보는 USER_LIST 테이블에서 관리
-- 주요 필드:
-  ```sql
-  SEQ: 고유 식별자
-  NICK_NAME: 닉네임
-  NAME: 실명
-  EMAIL: 이메일
-  PROFILE_IMAGE: 프로필 이미지
-  PRIMARY_CATEGORY: 주요 카테고리
-  REGION: 지역
-  DESCRIPTION: 소개글
-  SNS_CHANNELS: SNS 채널 정보 (JSON)
-  FOLLOWER_COUNT: 팔로워 수
-  ENGAGEMENT_RATE: 참여율
-  IS_ACT: 활성화 상태
-  ```
-
-### 1.2 벤더사 (VENDOR_LIST)
-- 벤더사 정보는 VENDOR_LIST 테이블에서 관리
-- 주요 필드:
-  ```sql
-  SEQ: 고유 식별자
-  COMPANY_NAME: 회사명
-  EMAIL: 이메일
-  LOGO: 로고 이미지
-  CATEGORY: 업종 카테고리
-  REGION: 지역
-  DESCRIPTION: 회사 소개
-  IS_ACT: 활성화 상태
-  ```
-
-## 2. 파트너십 관리 (VENDOR_INFLUENCER_MAPPING)
-
-### 2.1 매핑 구조
-```sql
-SEQ: 매핑 고유 식별자
-VENDOR_SEQ: 벤더사 SEQ (VENDOR_LIST 참조)
-INFLUENCER_SEQ: 인플루언서 SEQ (USER_LIST 참조)
-STATUS: 상태 (PENDING/APPROVED/REJECTED/CANCELLED)
-REQUEST_TYPE: 요청 타입 (INFLUENCER_REQUEST/VENDOR_REQUEST)
-REQUEST_MESSAGE: 요청 메시지
-RESPONSE_MESSAGE: 응답 메시지
-COMMISSION_RATE: 수수료율
-REQUESTED_BY: 요청자 SEQ
-APPROVED_BY: 승인자 SEQ
-REQUEST_DATE: 요청일시
-RESPONSE_DATE: 응답일시
-IS_ACT: 활성화 상태
-```
-
-### 2.2 승인 프로세스
-1. 인플루언서 → 벤더사 승인 요청
-   - REQUEST_TYPE: 'INFLUENCER_REQUEST'
-   - REQUESTED_BY: 인플루언서 SEQ (USER_LIST)
-   - APPROVED_BY: 벤더사 담당자 SEQ (VENDOR_LIST)
-
-2. 벤더사 → 인플루언서 승인 요청
-   - REQUEST_TYPE: 'VENDOR_REQUEST'
-   - REQUESTED_BY: 벤더사 담당자 SEQ (VENDOR_LIST)
-   - APPROVED_BY: 인플루언서 SEQ (USER_LIST)
-
-## 3. 주의사항
-
-### 3.1 SEQ 참조 규칙
-- USER_LIST의 SEQ는 인플루언서 식별에만 사용
-- VENDOR_LIST의 SEQ는 벤더사 식별에만 사용
-- 두 테이블의 SEQ를 혼용하지 않음
-
-### 3.2 승인 처리 시 주의사항
-- APPROVED_BY는 항상 요청을 받은 쪽의 SEQ
-- REQUESTED_BY는 항상 요청을 보낸 쪽의 SEQ
-- STATUS 변경 시 요청 타입 확인 필수
-
-### 3.3 날짜 처리 규칙
-- 날짜 컬럼은 자동으로 처리됨 (INSERT/UPDATE 시 자동 설정)
-- REQUEST_DATE, RESPONSE_DATE는 수동으로 설정
-- 날짜 입력 시 NOW() 함수 사용
-- INTERVAL을 사용한 날짜 계산 가능 (예: NOW() - INTERVAL 30 DAY)
-description:
-globs:
-alwaysApply: false
+---
+alwaysApply: true
 ---

+ 8 - 0
.cursor/rules/minimal-comments.mdc

@@ -0,0 +1,8 @@
+---
+description: 코드에 꼭 필요한 경우를 제외하고 주석 생성을 최소화합니다.
+alwaysApply: true
+---
+
+- 명백한 코드에는 주석을 달지 마세요. 코드는 자체적으로 설명 가능해야 합니다(self-documenting code).
+- 복잡한 로직이나 "왜(why)"에 대한 설명이 필요한 경우에만 주석을 추가해주세요. [8]
+- 함수나 클래스에 대한 설명은 docstring 형식으로 요청 시에만 작성해주세요.

+ 2 - 122
.cursor/rules/sql-rules.mdc

@@ -1,123 +1,3 @@
-# SQL 작성 규칙
-
-## 1. DDL 작성 규칙
-
-### 1.1 컬럼 추가
-- ALTER TABLE 구문은 항상 단일 컬럼 단위로 분리하여 작성
-  ```sql
-  -- 좋은 예:
-  ALTER TABLE USER_LIST
-  ADD COLUMN REGION VARCHAR(50) COMMENT '지역' AFTER EMAIL;
-
-  ALTER TABLE USER_LIST
-  ADD COLUMN DESCRIPTION TEXT COMMENT '소개글' AFTER ENGAGEMENT_RATE;
-
-  -- 나쁜 예:
-  ALTER TABLE USER_LIST
-  ADD COLUMN REGION VARCHAR(50) COMMENT '지역' AFTER EMAIL,
-  ADD COLUMN DESCRIPTION TEXT COMMENT '소개글' AFTER ENGAGEMENT_RATE;
-  ```
-
-### 1.2 단계별 실행
-- 각 DDL 구문은 독립적으로 실행 가능하도록 작성
-- 주석으로 단계 구분을 명확히 표시
-  ```sql
-  -- 1단계: REGION 컬럼 추가
-  ALTER TABLE USER_LIST ...
-
-  -- 2단계: 인덱스 추가
-  ALTER TABLE USER_LIST ...
-  ```
-
-### 1.3 안전한 데이터 업데이트
-- WHERE 절 조건은 신중하게 설정
-  - 불필요한 제약조건 피하기 (예: USER_TYPE이나 PRIMARY_CATEGORY로 제한하지 않기)
-  - NULL 값 처리 로직 포함
-  ```sql
-  -- 좋은 예:
-  DESCRIPTION = CASE 
-      WHEN PRIMARY_CATEGORY IS NOT NULL THEN
-          CASE 
-              WHEN PRIMARY_CATEGORY = 'FASHION_BEAUTY' THEN '패션 크리에이터'
-              ELSE '일반 크리에이터'
-          END
-      ELSE '프로필 소개가 없습니다.'
-  END
-  ```
-
-## 2. MySQL 특화 문법
-
-### 2.1 문자열 연결
-- MySQL에서는 `||` 연산자 대신 CONCAT() 함수 사용
-  ```sql
-  -- 좋은 예:
-  CONCAT('prefix_', column_name, '_suffix')
-
-  -- 나쁜 예:
-  'prefix_' || column_name || '_suffix'
-  ```
-
-### 2.2 JSON 데이터 처리
-- JSON 문자열은 반드시 CONCAT() 함수로 조합
-- 이스케이프 문자 처리 주의
-  ```sql
-  -- 좋은 예:
-  CONCAT('{"key":"', value, '"}')
-
-  -- 나쁜 예:
-  '{"key":"' || value || '"}'
-  ```
-
-## 3. DBeaver 최적화
-
-### 3.1 실행 계획
-- 각 SQL 구문은 독립적으로 실행 가능하도록 분리
-- 트랜잭션이 필요한 경우 명시적으로 표시
-- 실행 순서를 주석으로 명확히 표시
-
-### 3.2 에러 처리
-- 컬럼 존재 여부 확인이 필요한 경우 IF NOT EXISTS 사용
-  ```sql
-  ALTER TABLE table_name
-  ADD COLUMN IF NOT EXISTS column_name data_type;
-  ```
-
-## 4. 데이터 마이그레이션
-
-### 4.1 데이터 업데이트
-- 대량 데이터 업데이트 시 LIMIT 사용 고려
-- 인덱스 영향도 고려
-- 롤백 계획 포함
-
-### 4.2 테스트 데이터
-- 의미 있는 테스트 데이터 생성
-- SEQ나 ID 값을 활용한 동적 데이터 생성
-- NULL 값 처리 로직 포함
-
-## 5. 성능 최적화
-
-### 5.1 인덱스
-- 조회 패턴에 맞는 인덱스 설계
-- 복합 인덱스 순서 고려
-- 불필요한 인덱스 제거
-
-### 5.2 쿼리 최적화
-- 대량 데이터 처리 시 배치 처리 고려
-- 임시 테이블 사용 최소화
-- 조인 성능 고려
-
-## 6. 문서화
-
-### 6.1 주석 처리
-- 각 SQL 구문의 목적 명시
-- 주의사항이나 제약사항 표시
-- 실행 순서와 종속성 명시
-
-### 6.2 백업 계획
-- 데이터 변경 전 백업 방법 명시
-- 롤백 스크립트 준비
-- 테스트 환경에서 검증 후 운영 반영
-description:
-globs:
-alwaysApply: false
+---
+alwaysApply: true
 ---

+ 203 - 0
backend-examples/vendor-influencer-terminate.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace App\Controllers;
+
+use App\Controllers\BaseController;
+use App\Models\VendorInfluencerMappingModel;
+use App\Models\UserModel;
+use CodeIgniter\HTTP\ResponseInterface;
+
+/**
+ * 벤더-인플루언서 파트너십 해지 API 예제
+ * 경로: POST /api/vendor-influencer/terminate
+ */
+class VendorInfluencerTerminate extends BaseController
+{
+    protected $vendorInfluencerModel;
+    protected $userModel;
+    
+    public function __construct()
+    {
+        $this->vendorInfluencerModel = new VendorInfluencerMappingModel();
+        $this->userModel = new UserModel();
+    }
+    
+    /**
+     * 승인된 파트너십 해지 처리
+     */
+    public function terminate()
+    {
+        try {
+            $request = $this->request->getJSON();
+            
+            $mappingSeq = $request->mappingSeq ?? null;
+            $terminateReason = $request->terminateReason ?? null;
+            $terminatedBy = $request->terminatedBy ?? null;
+            
+            // 필수 파라미터 검증
+            if (!$mappingSeq || !$terminateReason || !$terminatedBy) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '필수 파라미터가 누락되었습니다. (mappingSeq, terminateReason, terminatedBy 필요)'
+                ]);
+            }
+            
+            // 해지 사유 길이 검증
+            if (strlen($terminateReason) > 500) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '해지 사유는 500자를 초과할 수 없습니다.'
+                ]);
+            }
+            
+            // 기존 매핑 확인 (승인된 상태여야 함)
+            $existingMapping = $this->vendorInfluencerModel
+                ->where('SEQ', $mappingSeq)
+                ->where('STATUS', 'APPROVED')
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if (!$existingMapping) {
+                return $this->response->setStatusCode(404)->setJSON([
+                    'success' => false,
+                    'message' => '해지할 수 있는 승인된 파트너십을 찾을 수 없습니다.'
+                ]);
+            }
+            
+            // 해지 권한 확인 (벤더사 또는 관련 사용자만 해지 가능)
+            $terminatingUser = $this->userModel
+                ->where('SEQ', $terminatedBy)
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if (!$terminatingUser) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '해지 처리자 정보를 찾을 수 없습니다.'
+                ]);
+            }
+            
+            // 해지 처리 데이터 준비
+            $terminateData = [
+                'STATUS' => 'TERMINATED',
+                'RESPONSE_MESSAGE' => '파트너십 해지: ' . $terminateReason,
+                'RESPONSE_DATE' => date('Y-m-d H:i:s'),
+                'APPROVED_BY' => $terminatedBy, // 해지 처리자
+                'PARTNERSHIP_END_DATE' => date('Y-m-d H:i:s'), // 파트너십 종료일
+                'MOD_DATE' => date('Y-m-d H:i:s')
+            ];
+            
+            log_message('info', "파트너십 해지 처리 시작 - 매핑 SEQ: {$mappingSeq}, 해지자: {$terminatedBy}");
+            
+            // 해지 처리 실행
+            $result = $this->vendorInfluencerModel->update($mappingSeq, $terminateData);
+            
+            if (!$result) {
+                log_message('error', "파트너십 해지 업데이트 실패 - 매핑 SEQ: {$mappingSeq}");
+                return $this->response->setStatusCode(500)->setJSON([
+                    'success' => false,
+                    'message' => '파트너십 해지 처리 중 데이터베이스 오류가 발생했습니다.'
+                ]);
+            }
+            
+            // 해지된 매핑 정보 조회
+            $terminatedMapping = $this->vendorInfluencerModel
+                ->select('vim.SEQ, vim.VENDOR_SEQ, vim.INFLUENCER_SEQ, vim.STATUS, 
+                         vim.RESPONSE_MESSAGE, vim.RESPONSE_DATE, vim.PARTNERSHIP_END_DATE,
+                         v.COMPANY_NAME as vendorName, 
+                         inf.NICK_NAME as influencerNickname, inf.NAME as influencerName')
+                ->from('VENDOR_INFLUENCER_MAPPING vim')
+                ->join('VENDOR_LIST v', 'vim.VENDOR_SEQ = v.SEQ', 'left')
+                ->join('USER_LIST inf', 'vim.INFLUENCER_SEQ = inf.SEQ', 'left')
+                ->where('vim.SEQ', $mappingSeq)
+                ->get()
+                ->getRowArray();
+            
+            log_message('info', "파트너십 해지 완료 - 매핑 SEQ: {$mappingSeq}");
+            
+            return $this->response->setJSON([
+                'success' => true,
+                'message' => '파트너십이 성공적으로 해지되었습니다.',
+                'data' => [
+                    'terminatedMapping' => $terminatedMapping,
+                    'terminateDate' => date('Y-m-d H:i:s'),
+                    'terminatedBy' => $terminatingUser['NICK_NAME'] ?? $terminatingUser['NAME']
+                ]
+            ]);
+            
+        } catch (\Exception $e) {
+            log_message('error', "파트너십 해지 처리 중 예외 발생: " . $e->getMessage());
+            
+            return $this->response->setStatusCode(500)->setJSON([
+                'success' => false,
+                'message' => '파트너십 해지 처리 중 오류가 발생했습니다.',
+                'error' => ENVIRONMENT === 'development' ? $e->getMessage() : null
+            ]);
+        }
+    }
+}
+
+/**
+ * 라우터 설정 예제 (routes.php에 추가)
+ * 
+ * $routes->group('api/vendor-influencer', ['namespace' => 'App\Controllers'], function($routes) {
+ *     $routes->post('terminate', 'VendorInfluencerController::terminate');
+ * });
+ */
+
+/**
+ * 프론트엔드에서 호출 예제
+ * 
+ * const params = {
+ *   mappingSeq: 123,
+ *   terminateReason: "계약 조건 위반으로 인한 해지",
+ *   terminatedBy: 8 // 해지 처리자 USER SEQ
+ * };
+ * 
+ * useAxios()
+ *   .post('/api/vendor-influencer/terminate', params)
+ *   .then((res) => {
+ *     if (res.data.success) {
+ *       console.log('해지 완료:', res.data.data);
+ *       // 성공 처리
+ *     } else {
+ *       console.error('해지 실패:', res.data.message);
+ *       // 실패 처리
+ *     }
+ *   })
+ *   .catch((err) => {
+ *     console.error('해지 오류:', err);
+ *   });
+ */
+
+/**
+ * 응답 예제
+ * 
+ * 성공시:
+ * {
+ *   "success": true,
+ *   "message": "파트너십이 성공적으로 해지되었습니다.",
+ *   "data": {
+ *     "terminatedMapping": {
+ *       "SEQ": 123,
+ *       "VENDOR_SEQ": 8,
+ *       "INFLUENCER_SEQ": 23,
+ *       "STATUS": "TERMINATED",
+ *       "RESPONSE_MESSAGE": "파트너십 해지: 계약 조건 위반으로 인한 해지",
+ *       "RESPONSE_DATE": "2025-07-23 10:30:00",
+ *       "PARTNERSHIP_END_DATE": "2025-07-23 10:30:00",
+ *       "vendorName": "테스트 벤더사",
+ *       "influencerNickname": "인플루언서닉네임",
+ *       "influencerName": "인플루언서이름"
+ *     },
+ *     "terminateDate": "2025-07-23 10:30:00",
+ *     "terminatedBy": "벤더관리자"
+ *   }
+ * }
+ * 
+ * 실패시:
+ * {
+ *   "success": false,
+ *   "message": "해지할 수 있는 승인된 파트너십을 찾을 수 없습니다."
+ * }
+ */

+ 1 - 0
backend/app/Config/Routes.php

@@ -77,6 +77,7 @@ $routes->group('api', ['namespace' => 'App\Controllers'], function($routes) {
     $routes->post('requests', 'VendorInfluencerController::getList');         // 요청목록 조회 (벤더사용)
     $routes->post('approve', 'VendorInfluencerController::approveRequest');
     $routes->post('process', 'VendorInfluencerController::approveRequest');   // 승인/거부 처리 (통합)
+    $routes->post('terminate', 'VendorInfluencerController::terminate');      // 파트너십 해지
     $routes->post('list', 'VendorInfluencerController::getList');
     $routes->post('detail', 'VendorInfluencerController::getDetail');
     $routes->post('cancel', 'VendorInfluencerController::cancelRequest');

+ 136 - 0
backend/app/Controllers/VendorInfluencerController.php

@@ -639,4 +639,140 @@ class VendorInfluencerController extends BaseController
             ]);
         }
     }
+    
+    /**
+     * 승인된 파트너십 해지
+     */
+    public function terminate()
+    {
+        try {
+            $request = $this->request->getJSON();
+            
+            $mappingSeq = $request->mappingSeq ?? null;
+            $terminateReason = $request->terminateReason ?? null;
+            $terminatedBy = $request->terminatedBy ?? null;
+            
+            // 필수 파라미터 검증
+            if (!$mappingSeq || !$terminateReason || !$terminatedBy) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '필수 파라미터가 누락되었습니다.'
+                ]);
+            }
+            
+            // 해지 사유 길이 검증
+            if (strlen($terminateReason) > 500) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '해지 사유는 500자를 초과할 수 없습니다.'
+                ]);
+            }
+            
+            // 기존 매핑 확인 (승인된 상태여야 함)
+            $existingMapping = $this->vendorInfluencerModel
+                ->where('SEQ', $mappingSeq)
+                ->where('STATUS', 'APPROVED')
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if (!$existingMapping) {
+                return $this->response->setStatusCode(404)->setJSON([
+                    'success' => false,
+                    'message' => '해지할 수 있는 승인된 파트너십을 찾을 수 없습니다.'
+                ]);
+            }
+            
+            // 해지 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
+            $terminatingUser = null;
+            
+            // 1. 먼저 USER_LIST에서 확인
+            $terminatingUser = $this->userModel
+                ->where('SEQ', $terminatedBy)
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if ($terminatingUser) {
+                // 사용자 SEQ인 경우 바로 사용
+                $approvedByUserSeq = $terminatedBy;
+            } else {
+                // 2. terminatedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+                $vendorInfo = $this->vendorModel
+                    ->where('SEQ', $terminatedBy)
+                    ->where('IS_ACT', 'Y')
+                    ->first();
+                
+                if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
+                    // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
+                    $vendorUser = $this->userModel
+                        ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
+                        ->where('IS_ACT', 'Y')
+                        ->first();
+                    
+                    if ($vendorUser) {
+                        $approvedByUserSeq = $vendorUser['SEQ'];
+                        $terminatingUser = $vendorUser; // terminatingUser로 설정
+                    } else {
+                        return $this->response->setStatusCode(400)->setJSON([
+                            'success' => false,
+                            'message' => "벤더사 SEQ {$terminatedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다. COMPANY_NUMBER: {$vendorInfo['COMPANY_NUMBER']}"
+                        ]);
+                    }
+                } else {
+                    return $this->response->setStatusCode(400)->setJSON([
+                        'success' => false,
+                        'message' => "해지 처리자 SEQ {$terminatedBy}는 USER_LIST나 VENDOR_LIST에서 찾을 수 없습니다."
+                    ]);
+                }
+            }
+            
+            // 해지 처리
+            $terminateData = [
+                'STATUS' => 'TERMINATED',
+                'RESPONSE_MESSAGE' => '파트너십 해지: ' . $terminateReason,
+                'RESPONSE_DATE' => date('Y-m-d H:i:s'),
+                'APPROVED_BY' => $approvedByUserSeq,
+                'PARTNERSHIP_END_DATE' => date('Y-m-d H:i:s'),
+                'MOD_DATE' => date('Y-m-d H:i:s')
+            ];
+            
+            $result = $this->vendorInfluencerModel->update($mappingSeq, $terminateData);
+            
+            if (!$result) {
+                return $this->response->setStatusCode(500)->setJSON([
+                    'success' => false,
+                    'message' => '파트너십 해지 처리 중 오류가 발생했습니다.'
+                ]);
+            }
+            
+            // 해지된 매핑 정보 조회
+            $terminatedMapping = $this->vendorInfluencerModel
+                ->select('vim.SEQ, vim.VENDOR_SEQ, vim.INFLUENCER_SEQ, vim.STATUS, 
+                         vim.RESPONSE_MESSAGE, vim.RESPONSE_DATE, vim.PARTNERSHIP_END_DATE,
+                         v.COMPANY_NAME as vendorName, 
+                         inf.NICK_NAME as influencerNickname, inf.NAME as influencerName')
+                ->from('VENDOR_INFLUENCER_MAPPING vim')
+                ->join('VENDOR_LIST v', 'vim.VENDOR_SEQ = v.SEQ', 'left')
+                ->join('USER_LIST inf', 'vim.INFLUENCER_SEQ = inf.SEQ', 'left')
+                ->where('vim.SEQ', $mappingSeq)
+                ->get()
+                ->getRowArray();
+            
+            return $this->response->setJSON([
+                'success' => true,
+                'message' => '파트너십이 성공적으로 해지되었습니다.',
+                'data' => [
+                    'terminatedMapping' => $terminatedMapping,
+                    'terminateDate' => date('Y-m-d H:i:s'),
+                    'terminatedBy' => $terminatingUser['NICK_NAME'] ?? $terminatingUser['NAME']
+                ]
+            ]);
+            
+        } catch (\Exception $e) {
+            return $this->response->setStatusCode(500)->setJSON([
+                'success' => false,
+                'message' => '파트너십 해지 처리 중 오류가 발생했습니다.',
+                'error' => ENVIRONMENT === 'development' ? $e->getMessage() : null
+            ]);
+        }
+    }
 }

+ 228 - 1
pages/view/vendor/dashboard/influencer-requests.vue

@@ -265,6 +265,23 @@
                   </v-btn>
                 </div>
 
+                <div v-else-if="request.STATUS === 'APPROVED'" class="approved--actions">
+                  <v-btn
+                    class="custom-btn mini btn-outline"
+                    @click="viewRequestHistory(request.SEQ)"
+                  >
+                    이력보기
+                  </v-btn>
+                  <v-btn
+                    class="custom-btn mini btn-terminate"
+                    @click="handleTerminate(request)"
+                    :loading="processing"
+                  >
+                    <v-icon left size="small">mdi-link-off</v-icon>
+                    해지
+                  </v-btn>
+                </div>
+
                 <v-btn
                   v-else
                   class="custom-btn mini btn-outline"
@@ -398,6 +415,66 @@
         </v-card-actions>
       </v-card>
     </v-dialog>
+
+    <!-- 해지 확인 모달 -->
+    <v-dialog v-model="terminateModal.show" max-width="500px">
+      <v-card>
+        <v-card-title class="text-h5 text-warning">
+          <v-icon left>mdi-link-off</v-icon>
+          파트너십 해지 확인
+        </v-card-title>
+        <v-card-text>
+          <div class="terminate--content">
+            <div class="influencer--summary">
+              <div class="influencer--avatar--small">
+                <v-img
+                  v-if="terminateModal.request?.influencerAvatar"
+                  :src="terminateModal.request.influencerAvatar"
+                  width="40"
+                  height="40"
+                ></v-img>
+                <div v-else class="no-avatar--small">
+                  {{ terminateModal.request?.influencerNickname?.charAt(0) || "U" }}
+                </div>
+              </div>
+              <div>
+                <h4>{{ terminateModal.request?.influencerNickname }}</h4>
+                <p>{{ getCategoryText(terminateModal.request?.influencerCategory) }}</p>
+              </div>
+            </div>
+
+            <v-alert type="warning" class="mb-4">
+              <strong>주의:</strong> 파트너십을 해지하면 협업 관계가 종료되며, 이 작업은 되돌릴 수 없습니다.
+            </v-alert>
+
+            <p>이 인플루언서와의 파트너십을 해지하시겠습니까?</p>
+
+            <v-textarea
+              v-model="terminateModal.terminateReason"
+              label="해지 사유"
+              placeholder="해지 사유를 입력해주세요..."
+              rows="4"
+              counter="500"
+              maxlength="500"
+              class="mt-4"
+              required
+            ></v-textarea>
+          </div>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer></v-spacer>
+          <v-btn color="grey" variant="text" @click="closeTerminateModal">취소</v-btn>
+          <v-btn 
+            class="btn-terminate-confirm"
+            @click="confirmTerminate" 
+            :loading="processing"
+          >
+            <v-icon left>mdi-link-off</v-icon>
+            해지하기
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
   </div>
 </template>
 
@@ -448,6 +525,7 @@
     { title: "대기중", value: "PENDING" },
     { title: "승인완료", value: "APPROVED" },
     { title: "거부됨", value: "REJECTED" },
+    { title: "해지됨", value: "TERMINATED" },
   ]);
 
   // 카테고리 옵션
@@ -490,6 +568,13 @@
     rejectReason: "",
   });
 
+  // 해지 모달
+  const terminateModal = ref({
+    show: false,
+    request: null,
+    terminateReason: "",
+  });
+
   /************************************************************************
   |    computed
   ************************************************************************/
@@ -687,6 +772,65 @@
     router.push(`/view/vendor/request-history/${requestSeq}`);
   };
 
+  const handleTerminate = (request) => {
+    terminateModal.value = {
+      show: true,
+      request: request,
+      terminateReason: "",
+    };
+  };
+
+  const closeTerminateModal = () => {
+    terminateModal.value = {
+      show: false,
+      request: null,
+      terminateReason: "",
+    };
+  };
+
+  const confirmTerminate = async () => {
+    if (!terminateModal.value.terminateReason.trim()) {
+      $toast.error("해지 사유를 입력해주세요.");
+      return;
+    }
+
+    try {
+      processing.value = true;
+
+      const params = {
+        mappingSeq: terminateModal.value.request.SEQ,
+        terminateReason: terminateModal.value.terminateReason,
+        terminatedBy: currentUser.value.seq,
+      };
+
+      console.log('🔗 파트너십 해지 처리 시작:', params);
+
+      useAxios()
+        .post("/api/vendor-influencer/terminate", params)
+        .then((res) => {
+          console.log('📥 해지 처리 응답:', res.data);
+          if (res.data.success) {
+            $toast.success("파트너십이 해지되었습니다.");
+            closeTerminateModal();
+            console.log('🔄 해지 후 목록 새로고침');
+            loadRequests();
+          } else {
+            console.error('❌ 해지 처리 실패:', res.data);
+            $toast.error(res.data.message || "해지 처리 중 오류가 발생했습니다.");
+          }
+        })
+        .catch((err) => {
+          $toast.error(err.message || "해지 처리 중 오류가 발생했습니다.");
+        })
+        .finally(() => {
+          processing.value = false;
+        });
+    } catch (err) {
+      $toast.error(err.message || "해지 처리 중 오류가 발생했습니다.");
+      processing.value = false;
+    }
+  };
+
   // 유틸리티 함수들
   const getCategoryText = (category) => {
     const categoryMap = {
@@ -705,6 +849,7 @@
       PENDING: "대기중",
       APPROVED: "승인완료",
       REJECTED: "거절됨",
+      TERMINATED: "해지됨",
       EXPIRED: "만료됨",
     };
     return statusMap[status] || status || "알 수 없음";
@@ -715,6 +860,7 @@
       PENDING: "orange",
       APPROVED: "success",
       REJECTED: "error",
+      TERMINATED: "warning",
       EXPIRED: "grey",
     };
     return colorMap[status] || "grey";
@@ -858,6 +1004,10 @@
     border-left-color: #f44336;
   }
 
+  .request--card.request-status-terminated {
+    border-left-color: #ff9800;
+  }
+
   .request--card--header {
     display: flex;
     justify-content: space-between;
@@ -987,7 +1137,8 @@
     align-items: center;
   }
 
-  .approval--actions {
+  .approval--actions,
+  .approved--actions {
     display: flex;
     gap: 8px;
   }
@@ -1139,4 +1290,80 @@
     padding: 4px 8px;
     border-radius: 4px;
   }
+
+  /* 해지 버튼 전용 스타일 */
+  .btn-terminate {
+    background: linear-gradient(135deg, #ff4757 0%, #ff3742 100%) !important;
+    color: white !important;
+    font-weight: 600 !important;
+    border: 2px solid #ff4757 !important;
+    box-shadow: 0 2px 8px rgba(255, 71, 87, 0.3) !important;
+    transition: all 0.3s ease !important;
+    text-transform: none !important;
+    letter-spacing: 0.5px !important;
+  }
+
+  .btn-terminate:hover {
+    background: linear-gradient(135deg, #ff3742 0%, #ff2f3a 100%) !important;
+    border-color: #ff3742 !important;
+    box-shadow: 0 4px 12px rgba(255, 71, 87, 0.4) !important;
+    transform: translateY(-1px) !important;
+  }
+
+  .btn-terminate:active {
+    transform: translateY(0) !important;
+    box-shadow: 0 2px 6px rgba(255, 71, 87, 0.4) !important;
+  }
+
+  .btn-terminate .v-icon {
+    color: white !important;
+    margin-right: 4px !important;
+  }
+
+  .btn-terminate:disabled {
+    background: #ffbdc1 !important;
+    color: #999 !important;
+    border-color: #ffbdc1 !important;
+    box-shadow: none !important;
+    transform: none !important;
+  }
+
+  /* 해지 확인 모달 버튼 스타일 */
+  .btn-terminate-confirm {
+    background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
+    color: white !important;
+    font-weight: 700 !important;
+    font-size: 14px !important;
+    padding: 12px 24px !important;
+    border-radius: 8px !important;
+    border: none !important;
+    box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4) !important;
+    transition: all 0.3s ease !important;
+    text-transform: none !important;
+    letter-spacing: 0.5px !important;
+    min-width: 120px !important;
+  }
+
+  .btn-terminate-confirm:hover {
+    background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
+    box-shadow: 0 6px 16px rgba(220, 38, 38, 0.5) !important;
+    transform: translateY(-2px) !important;
+  }
+
+  .btn-terminate-confirm:active {
+    transform: translateY(0) !important;
+    box-shadow: 0 3px 8px rgba(220, 38, 38, 0.4) !important;
+  }
+
+  .btn-terminate-confirm .v-icon {
+    color: white !important;
+    margin-right: 6px !important;
+  }
+
+  .btn-terminate-confirm:disabled {
+    background: #fca5a5 !important;
+    color: #9ca3af !important;
+    box-shadow: none !important;
+    transform: none !important;
+  }
 </style>