2026-01-30 14:33:46 +09:00
|
|
|
# 서버 작업 이력
|
|
|
|
|
|
|
|
|
|
> 서버: mng.codebridge-x.com (114.203.209.83)
|
|
|
|
|
> 접속 계정: pro / sampass
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2026-01-30
|
|
|
|
|
|
|
|
|
|
### 1. Nginx client_max_body_size 설정 (413 오류 해결)
|
|
|
|
|
|
|
|
|
|
**문제**: 명함 이미지 업로드 시 `413 Content Too Large` 오류 발생
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
sudo nano /etc/nginx/sites-available/codebridge-x
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`mng.codebridge-x.com` 서버 블록에 추가:
|
|
|
|
|
```nginx
|
|
|
|
|
server {
|
|
|
|
|
server_name mng.codebridge-x.com;
|
|
|
|
|
client_max_body_size 20M; # 추가됨
|
|
|
|
|
...
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
sudo nginx -t
|
|
|
|
|
sudo systemctl reload nginx
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 2. shared-storage 디렉토리 생성
|
|
|
|
|
|
|
|
|
|
**문제**: `Unable to create a directory at /var/www/shared-storage/tenants` 오류 발생
|
|
|
|
|
- 서버 환경은 Docker가 아니므로 `/var/www/` 경로가 존재하지 않음
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
sudo mkdir -p /home/webservice/shared-storage/tenants
|
|
|
|
|
sudo chown -R www-data:www-data /home/webservice/shared-storage
|
|
|
|
|
sudo chmod -R 775 /home/webservice/shared-storage
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**결과 확인**:
|
|
|
|
|
```bash
|
|
|
|
|
ls -la /home/webservice/shared-storage/
|
|
|
|
|
# drwxrwsr-x 3 www-data www-data 4096 Jan 30 14:19 .
|
|
|
|
|
# drwxrwsr-x 2 www-data www-data 4096 Jan 30 14:19 tenants
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 3. MNG .env 파일에 TENANT_STORAGE_PATH 추가
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
# .env 파일에 추가
|
|
|
|
|
echo 'TENANT_STORAGE_PATH=/home/webservice/shared-storage/tenants' >> /home/webservice/mng/.env
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**설정 확인**:
|
|
|
|
|
```bash
|
|
|
|
|
grep TENANT_STORAGE /home/webservice/mng/.env
|
|
|
|
|
# TENANT_STORAGE_PATH=/home/webservice/shared-storage/tenants
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 4. Laravel 캐시 클리어 및 PHP-FPM 재시작
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan config:clear
|
|
|
|
|
php artisan cache:clear
|
|
|
|
|
php artisan route:clear
|
|
|
|
|
php artisan view:clear
|
|
|
|
|
|
|
|
|
|
sudo systemctl restart php8.4-fpm
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**설정 반영 확인**:
|
|
|
|
|
```bash
|
|
|
|
|
php artisan tinker --execute="echo config('filesystems.disks.tenant.root');"
|
|
|
|
|
# /home/webservice/shared-storage/tenants
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 5. AI 설정 메뉴 추가
|
|
|
|
|
|
|
|
|
|
**문제**: 로컬에는 있던 "AI 설정" 메뉴가 서버에 없음
|
|
|
|
|
|
|
|
|
|
**작업 내용** (tinker 사용):
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan tinker --execute='
|
|
|
|
|
use App\Models\Commons\Menu;
|
|
|
|
|
$s = Menu::where("tenant_id", 1)->where("name", "시스템")->first();
|
|
|
|
|
if ($s) {
|
|
|
|
|
$a = Menu::where("tenant_id", 1)->where("parent_id", $s->id)->where("url", "/system/ai-config")->first();
|
|
|
|
|
if ($a) {
|
|
|
|
|
echo "AI menu exists: " . $a->id;
|
|
|
|
|
} else {
|
|
|
|
|
$n = Menu::create([
|
|
|
|
|
"tenant_id" => 1,
|
|
|
|
|
"parent_id" => $s->id,
|
|
|
|
|
"name" => "AI 설정",
|
|
|
|
|
"url" => "/system/ai-config",
|
|
|
|
|
"is_active" => true,
|
|
|
|
|
"sort_order" => 3,
|
|
|
|
|
"hidden" => false,
|
|
|
|
|
"icon" => "cpu"
|
|
|
|
|
]);
|
|
|
|
|
echo "Created: " . $n->id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 6. AI Config 데이터 추가 (Gemini, GCS)
|
|
|
|
|
|
|
|
|
|
**작업 내용** (tinker 사용):
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan tinker --execute='
|
|
|
|
|
use App\Models\System\AiConfig;
|
|
|
|
|
|
|
|
|
|
$configs = [
|
|
|
|
|
[
|
|
|
|
|
"name" => "Gemini for Google Cloud API",
|
|
|
|
|
"provider" => "gemini",
|
|
|
|
|
"api_key" => "vertex_ai_service_account",
|
|
|
|
|
"model" => "gemini-2.0-flash",
|
|
|
|
|
"base_url" => "https://generativelanguage.googleapis.com/v1beta",
|
|
|
|
|
"description" => null,
|
|
|
|
|
"is_active" => true,
|
|
|
|
|
"options" => [
|
|
|
|
|
"region" => "us-central1",
|
|
|
|
|
"auth_type" => "vertex_ai",
|
|
|
|
|
"project_id" => "codebridge-chatbot",
|
|
|
|
|
"service_account_path" => "/home/webservice/sales/apikey/google_service_account.json"
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
"name" => "Production GCS",
|
|
|
|
|
"provider" => "gcs",
|
|
|
|
|
"api_key" => "gcs_service_account",
|
|
|
|
|
"model" => "-",
|
|
|
|
|
"base_url" => "https://storage.googleapis.com",
|
|
|
|
|
"description" => "음성 녹음 파일 백업용 (본사 연구)",
|
|
|
|
|
"is_active" => true,
|
|
|
|
|
"options" => [
|
|
|
|
|
"bucket_name" => "codebridge-speech-audio-files",
|
|
|
|
|
"service_account_path" => "/home/webservice/sales/apikey/google_service_account.json"
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($configs as $config) {
|
|
|
|
|
$existing = AiConfig::where("name", $config["name"])->first();
|
|
|
|
|
if ($existing) {
|
|
|
|
|
echo "Already exists: " . $config["name"] . "\n";
|
|
|
|
|
} else {
|
|
|
|
|
AiConfig::create($config);
|
|
|
|
|
echo "Created: " . $config["name"] . "\n";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 7. AI Config service_account_path 경로 수정
|
|
|
|
|
|
|
|
|
|
**문제**: Docker 환경 경로 `/var/www/sales/apikey/...`가 서버에서 작동하지 않음
|
|
|
|
|
|
|
|
|
|
**작업 내용** (tinker 사용):
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan tinker --execute='
|
|
|
|
|
use App\Models\System\AiConfig;
|
|
|
|
|
|
|
|
|
|
$configs = AiConfig::all();
|
|
|
|
|
foreach ($configs as $config) {
|
|
|
|
|
$options = $config->options ?? [];
|
|
|
|
|
if (isset($options["service_account_path"])) {
|
|
|
|
|
$oldPath = $options["service_account_path"];
|
|
|
|
|
$newPath = str_replace("/var/www/sales/", "/home/webservice/sales/", $oldPath);
|
|
|
|
|
if ($oldPath !== $newPath) {
|
|
|
|
|
$options["service_account_path"] = $newPath;
|
|
|
|
|
$config->options = $options;
|
|
|
|
|
$config->save();
|
|
|
|
|
echo "Updated " . $config->name . ": " . $newPath . "\n";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
echo "Done.\n";
|
|
|
|
|
'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 8. SalesRoleSeeder 실행
|
|
|
|
|
|
|
|
|
|
**문제**: 서버에 영업 관련 역할(권한)이 없음
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan db:seed --class=SalesRoleSeeder
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 14:36:10 +09:00
|
|
|
### 9. tenant-storage 심볼릭 링크 수정
|
|
|
|
|
|
|
|
|
|
**문제**: 명함 이미지가 저장되었지만 브라우저에서 로드되지 않음
|
|
|
|
|
- 기존 심볼릭 링크가 잘못된 경로를 가리킴: `/var/www/api/storage/app/tenants` (존재하지 않음)
|
|
|
|
|
|
|
|
|
|
**작업 내용**:
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng/public
|
|
|
|
|
rm tenant-storage
|
|
|
|
|
ln -s /home/webservice/shared-storage/tenants tenant-storage
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**결과 확인**:
|
|
|
|
|
```bash
|
|
|
|
|
ls -la tenant-storage
|
|
|
|
|
# lrwxrwxrwx 1 pro develop 39 Jan 30 14:35 tenant-storage -> /home/webservice/shared-storage/tenants
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 15:05:49 +09:00
|
|
|
### 10. sales_scenario_checklists 테이블 스키마 동기화
|
|
|
|
|
|
|
|
|
|
**문제**: 로컬과 서버의 테이블 구조 불일치 (컬럼 및 인덱스 누락)
|
|
|
|
|
|
|
|
|
|
**추가된 컬럼**:
|
|
|
|
|
```sql
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD COLUMN scenario_type ENUM('sales','manager') NOT NULL DEFAULT 'sales' COMMENT '시나리오 유형' AFTER tenant_id;
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD COLUMN checkpoint_id VARCHAR(50) DEFAULT NULL COMMENT '체크포인트 ID' AFTER step_id;
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD COLUMN checked_at TIMESTAMP NULL DEFAULT NULL COMMENT '체크 일시' AFTER is_checked;
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD COLUMN checked_by BIGINT UNSIGNED DEFAULT NULL COMMENT '체크한 사용자 ID' AFTER checked_at;
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD COLUMN memo TEXT COMMENT '메모' AFTER checked_by;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**추가된 인덱스**:
|
|
|
|
|
```sql
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD UNIQUE KEY sales_scenario_checkpoint_unique (tenant_id, scenario_type, step_id, checkpoint_id);
|
|
|
|
|
ALTER TABLE sales_scenario_checklists ADD INDEX sales_scenario_checklists_tenant_id_scenario_type_index (tenant_id, scenario_type);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**작업 방법** (tinker 사용):
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan tinker --execute="DB::statement('ALTER TABLE ...');"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 15:19:02 +09:00
|
|
|
### 11. 마이그레이션 파일 수정 (서버 호환성)
|
|
|
|
|
|
|
|
|
|
**문제**: 서버에서 git push 후 자동 마이그레이션 실행 시 여러 오류 발생
|
|
|
|
|
|
|
|
|
|
**수정된 마이그레이션 파일들**:
|
|
|
|
|
|
|
|
|
|
#### 1) `2026_01_29_090000_fix_sales_scenario_checklists_unique_key.php`
|
|
|
|
|
- **오류**: `Can't DROP 'sales_scenario_unique'; check that column/key exists`
|
|
|
|
|
- **원인**: 서버에 해당 인덱스가 없음 (이미 수동으로 다른 이름으로 생성됨)
|
|
|
|
|
- **해결**: 인덱스 삭제/생성 전 `SHOW INDEX` 쿼리로 존재 여부 확인
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// 수정 전
|
|
|
|
|
$table->dropUnique('sales_scenario_unique');
|
|
|
|
|
|
|
|
|
|
// 수정 후
|
|
|
|
|
$indexes = DB::select("SHOW INDEX FROM sales_scenario_checklists WHERE Key_name = 'sales_scenario_unique'");
|
|
|
|
|
if (count($indexes) > 0) {
|
|
|
|
|
$table->dropUnique('sales_scenario_unique');
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 2) `2026_01_29_093000_add_gcs_uri_to_sales_consultations.php`
|
|
|
|
|
- **오류**: `Table 'sam.sales_consultations' doesn't exist`
|
|
|
|
|
- **원인**: 테이블 생성 마이그레이션보다 먼저 실행됨
|
|
|
|
|
- **해결**: `Schema::hasTable()` 체크 추가
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
public function up(): void
|
|
|
|
|
{
|
|
|
|
|
if (!Schema::hasTable('sales_consultations')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// ... 기존 로직
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 3) `2026_01_29_100200_create_sales_scenario_checklists_table.php`
|
|
|
|
|
- **오류**: `Table 'sales_scenario_checklists' already exists`
|
|
|
|
|
- **원인**: 서버에 이미 테이블이 존재 (수동 생성됨)
|
|
|
|
|
- **해결**: `Schema::hasTable()` 체크 추가
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
public function up(): void
|
|
|
|
|
{
|
|
|
|
|
if (Schema::hasTable('sales_scenario_checklists')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Schema::create('sales_scenario_checklists', function (Blueprint $table) {
|
|
|
|
|
// ...
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 4) `2026_01_30_150000_add_missing_columns_to_sales_scenario_checklists_table.php`
|
|
|
|
|
- **오류**: `Method getDoctrineSchemaManager does not exist`
|
|
|
|
|
- **원인**: Laravel 12에서 Doctrine DBAL 제거됨
|
|
|
|
|
- **해결**: `DB::select("SHOW INDEX ...")` 쿼리로 대체
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// 수정 전 (Laravel 11 이하)
|
|
|
|
|
$sm = Schema::getConnection()->getDoctrineSchemaManager();
|
|
|
|
|
$indexes = $sm->listTableIndexes('sales_scenario_checklists');
|
|
|
|
|
|
|
|
|
|
// 수정 후 (Laravel 12 호환)
|
|
|
|
|
$uniqueExists = DB::select("SHOW INDEX FROM sales_scenario_checklists WHERE Key_name = 'sales_scenario_checkpoint_unique'");
|
|
|
|
|
if (empty($uniqueExists)) {
|
|
|
|
|
// 인덱스 추가
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 참고: 마이그레이션 작성 가이드 (Laravel 12)
|
|
|
|
|
|
|
|
|
|
### 안전한 마이그레이션 패턴
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
|
|
|
|
|
|
// 1. 테이블 존재 여부 확인
|
|
|
|
|
if (Schema::hasTable('table_name')) { ... }
|
|
|
|
|
|
|
|
|
|
// 2. 컬럼 존재 여부 확인
|
|
|
|
|
if (Schema::hasColumn('table_name', 'column_name')) { ... }
|
|
|
|
|
|
|
|
|
|
// 3. 인덱스 존재 여부 확인 (Laravel 12)
|
|
|
|
|
$indexes = DB::select("SHOW INDEX FROM table_name WHERE Key_name = 'index_name'");
|
|
|
|
|
if (empty($indexes)) { ... }
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 주의사항
|
|
|
|
|
- Laravel 12에서 `getDoctrineSchemaManager()` 사용 불가
|
|
|
|
|
- 테이블/컬럼 생성 전 항상 존재 여부 체크
|
|
|
|
|
- 인덱스 삭제 전 항상 존재 여부 체크
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 15:27:24 +09:00
|
|
|
### 12. 배포 스크립트 수정 (심볼릭 링크 자동 복원)
|
|
|
|
|
|
|
|
|
|
**문제**: git push 후 자동 배포 시 `git checkout .` 명령으로 인해 심볼릭 링크가 원래 값으로 덮어씌워짐
|
|
|
|
|
|
|
|
|
|
**수정 파일**: `/home/webservice/script/pull_mng.sh`
|
|
|
|
|
|
|
|
|
|
**추가된 코드**:
|
|
|
|
|
```bash
|
|
|
|
|
# 심볼릭 링크 수정 (서버 경로에 맞게)
|
|
|
|
|
echo "심볼릭 링크 수정 중..." >> $LOGFILE
|
|
|
|
|
cd /home/webservice/mng/public
|
|
|
|
|
rm -f tenant-storage
|
|
|
|
|
ln -s /home/webservice/shared-storage/tenants tenant-storage
|
|
|
|
|
echo "심볼릭 링크 수정 완료" >> $LOGFILE
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**효과**: 이제 git push 후에도 `tenant-storage` 심볼릭 링크가 자동으로 `/home/webservice/shared-storage/tenants`를 가리키게 됨
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 15:31:07 +09:00
|
|
|
### 13. 영업관리 - 대시보드 메뉴 추가
|
|
|
|
|
|
|
|
|
|
**문제**: 로컬에는 있는 "영업관리 - 대시보드" 메뉴가 서버에 없음
|
|
|
|
|
|
|
|
|
|
**작업 내용** (tinker 사용):
|
|
|
|
|
```bash
|
|
|
|
|
cd /home/webservice/mng
|
|
|
|
|
php artisan tinker --execute='
|
|
|
|
|
use App\Models\Commons\Menu;
|
|
|
|
|
$sales = Menu::where("tenant_id", 1)->where("name", "영업관리")->first();
|
|
|
|
|
if ($sales) {
|
|
|
|
|
$m = Menu::create([
|
|
|
|
|
"tenant_id" => 1,
|
|
|
|
|
"parent_id" => $sales->id,
|
|
|
|
|
"name" => "대시보드",
|
|
|
|
|
"url" => "/sales/salesmanagement/dashboard",
|
|
|
|
|
"is_active" => true,
|
|
|
|
|
"sort_order" => 0,
|
|
|
|
|
"hidden" => false,
|
|
|
|
|
"icon" => "chart-bar"
|
|
|
|
|
]);
|
|
|
|
|
echo "Created: " . $m->id;
|
|
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-01-30 14:33:46 +09:00
|
|
|
## 참고: Docker vs 서버 경로 차이
|
|
|
|
|
|
|
|
|
|
| 항목 | Docker (로컬) | 서버 |
|
|
|
|
|
|------|--------------|------|
|
|
|
|
|
| 웹 루트 | `/var/www/` | `/home/webservice/` |
|
|
|
|
|
| MNG 앱 | `/var/www/mng/` | `/home/webservice/mng/` |
|
|
|
|
|
| API 앱 | `/var/www/api/` | `/home/webservice/api/` |
|
|
|
|
|
| Sales 앱 | `/var/www/sales/` | `/home/webservice/sales/` |
|
|
|
|
|
| 공유 스토리지 | `/var/www/shared-storage/` | `/home/webservice/shared-storage/` |
|
|
|
|
|
|
|
|
|
|
서버에 새로운 설정을 추가할 때는 경로 차이를 반드시 확인해야 합니다.
|