# springtime-server

Server repository for SPRINGTIME.

## Server packages

Current server package status verified on this host:

- PHP: 8.3.6
- MySQL client: 8.0.46-0ubuntu0.24.04.3
- Redis server: 7.0.15-1ubuntu0.24.04.4
- redis-cli: 7.0.15
- PHP Redis extension: enabled
- PHP Redis extension version: 5.3.7

## Redis installation

Redis and the PHP Redis extension were installed with apt:

```bash
sudo apt-get update
sudo apt-get install -y redis-server php-redis
sudo systemctl enable --now redis-server
```

Verification used on the server:

```bash
redis-server --version
redis-cli --version
php -m | grep -i '^redis$'
php --ri redis
systemctl is-active redis-server
```

Current Redis service state:

- redis-server service: active

## Translation script

- DeepL 번역 스크립트 위치: `script/deepl.py`
- 번역 입력, glossary, 출력 디렉토리 위치: `translate/`
- 자세한 사용법: `translate/README.md`

## Install scripts

### `install/apply_store_procedue.sh`

`/home/ubuntu/springtime-db/store_procedure` 아래의 SQL 파일을 MySQL에 적용합니다. 인자 없이 실행하면 해당 디렉터리의 모든 `*.sql` 파일을 적용합니다.

```bash
cd /home/ubuntu/springtime-server/install
./apply_store_procedue.sh
```

Springtime DB 저장소를 최신 상태로 업데이트한 후 모든 저장 프로시저 적용:

```bash
./apply_store_procedue.sh --update
```

`--update`를 지정하면 `/home/ubuntu/springtime-db`에서 `git pull --ff-only`를 실행한 후 SQL을 적용합니다. Git 업데이트에 실패하면 SQL을 적용하지 않고 종료합니다.

단일 SQL 파일 적용:

```bash
./apply_store_procedue.sh --input Login.sql
```

`--input`에는 직접 접근 가능한 파일 경로 또는 `/home/ubuntu/springtime-db/store_procedure` 아래의 파일명을 지정할 수 있습니다. `--update`와 함께 사용할 수도 있습니다.

```bash
./apply_store_procedue.sh --update --input Login.sql
```

MySQL 비밀번호 프롬프트가 터미널에 표시되면 root 비밀번호를 직접 입력합니다. 다른 Springtime DB 저장소를 사용하려면 `SPRINGTIME_DB_ROOT` 환경 변수를 지정합니다.

```bash
SPRINGTIME_DB_ROOT=/path/to/springtime-db ./apply_store_procedue.sh --update
```

### `install/generated_infosql.sh`

`/home/ubuntu/springtime-infodata` 아래의 모든 XLSX 파일을 읽어 MySQL에서 실행할 수 있는 하나의 SQL 파일을 생성합니다. Python 표준 라이브러리만 사용하므로 별도의 XLSX 패키지는 필요하지 않습니다.

XLSX 작성 규칙:

- 워크시트 이름은 대상 테이블명이어야 합니다.
- 하나의 XLSX에 여러 워크시트가 있으면 모든 워크시트의 SQL을 생성합니다.
- 첫 번째 행은 A열부터 빈 셀이 나오기 전까지 컬럼명으로 사용하며, 첫 열이 체크박스이면 B열부터 컬럼명으로 사용합니다.
- 두 번째 행부터 데이터 행으로 사용합니다.
- 첫 열에 Excel 체크박스가 없으면 모든 데이터 행의 `INSERT`를 생성합니다.
- 첫 열에 Excel 체크박스가 있으면 체크된 행만 `INSERT`를 생성하며, 체크박스 열은 테이블 컬럼에 포함하지 않습니다.
- 체크박스가 모두 해제된 워크시트도 `DELETE FROM`은 생성하고 오류 없이 처리합니다.
- 완전히 비어 있는 데이터 행은 무시합니다.
- 문자열은 작은따옴표를 이스케이프하고, 빈 셀은 `NULL`로 생성합니다.
- 엑셀 파일 안에 `DELETE` 또는 `INSERT` 쿼리와 수식을 작성할 필요가 없습니다.

목록형 필드 치환 규칙:

- Excel의 목록 데이터 유효성 검사가 적용된 필드는 같은 XLSX의 `info_<필드명>` 워크시트를 참조해야 합니다.
- 참조 워크시트에는 대상 필드명과 같은 키 컬럼 및 표시값을 담은 `title` 컬럼이 있어야 합니다.
- 목록 데이터 유효성 검사 수식은 참조 워크시트의 `title` 범위를 가리켜야 합니다.
- 생성기는 입력된 표시값을 `title`에서 찾아 같은 행의 키 값으로 치환한 후 SQL을 생성합니다.
- 참조 워크시트가 같은 XLSX에 없거나, 필드 또는 `title` 컬럼이 없거나, 표시값이 정확히 한 행에 매칭되지 않거나, 치환할 키 값이 비어 있으면 오류로 처리합니다.

예를 들어 `info_item` 워크시트의 `itemtype` 셀 값이 `일반`이면 같은 XLSX에 `info_itemtype` 워크시트가 있어야 합니다. `info_itemtype.title`에서 `일반`인 행의 `itemtype` 값이 `1`이면 다음과 같이 숫자 키로 치환됩니다.

```sql
INSERT INTO `info_item` (`itemtype`, ...) VALUES (1, ...);
```

생성되는 SQL은 워크시트마다 `DELETE FROM`을 먼저 실행하고 각 데이터 행의 `INSERT INTO`를 이어서 실행합니다. 전체 파일은 `START TRANSACTION`과 `COMMIT`으로 감쌉니다. 연관된 info 테이블을 일괄 교체할 수 있도록 현재 세션의 `FOREIGN_KEY_CHECKS` 값을 보관하고 실행 중에만 비활성화한 뒤 원래 값으로 복원합니다.

MySQL은 `FOREIGN_KEY_CHECKS`를 다시 활성화할 때 기존 행을 재검증하지 않습니다. `info_item`처럼 일반 데이터 테이블에서 참조하는 키는 XLSX에서 삭제하거나 변경하지 않아야 하며, 반영 전에 참조 대상 키가 모두 생성되는지 확인해야 합니다.

기본 실행:

```bash
cd /home/ubuntu/springtime-server/install
./generated_infosql.sh
```

기본 출력 위치는 `install/infodata/YYYYMMDDHHMMSS.sql`입니다. 실행 결과에는 XLSX별 성공 및 실패 목록이 표시됩니다. 하나라도 실패하면 종료 코드 `1`을 반환하고 불완전한 SQL 파일은 생성하지 않습니다.

Infodata 저장소를 최신 상태로 업데이트한 후 SQL 생성:

```bash
cd /home/ubuntu/springtime-server/install
./generated_infosql.sh --update
```

`--update`를 지정하면 `/home/ubuntu/springtime-infodata`에서 `git pull --ff-only`를 실행한 후 SQL을 생성합니다. Git 업데이트에 실패하면 SQL을 생성하지 않고 종료합니다.

SQL 생성 후 즉시 MySQL에 반영:

```bash
cd /home/ubuntu/springtime-server/install
./generated_infosql.sh --applydb
```

Infodata 업데이트부터 MySQL 반영까지 한 번에 실행:

```bash
./generated_infosql.sh --update --applydb
```

DeepL 번역을 생략하고 현재 한국어 Info 데이터를 모든 언어에 반영해 빠르게 재생성:

```bash
./generated_infosql.sh --update --applydb --no-translate
```

`--no-translate`를 지정하지 않으면 기존처럼 변경된 문구를 언어별로 번역합니다.

`--applydb`를 지정하면 SQL 생성에 성공한 후 다음 명령과 같은 방식으로 생성 파일을 반영합니다.

```bash
mysql -u root -p < infodata/YYYYMMDDHHMMSS.sql
```

MySQL 비밀번호 프롬프트가 터미널에 표시되면 root 비밀번호를 직접 입력합니다. SQL 생성 또는 MySQL 반영에 실패하면 스크립트는 종료 코드 `1`을 반환하며, `--applydb`가 없으면 기존처럼 SQL 파일만 생성합니다.

생성된 SQL 반영:

```bash
mysql -u root -p < /home/ubuntu/springtime-server/install/infodata/YYYYMMDDHHMMSS.sql
```

환경변수:

- `INFODATA_ROOT`: XLSX 파일을 재귀적으로 검색할 루트. 기본값은 `/home/ubuntu/springtime-infodata`
- `OUTPUT_DIR`: SQL 출력 디렉터리. 기본값은 `install/infodata`
- `DATABASE_NAME`: SQL의 `USE` 대상 데이터베이스. 기본값은 `springtime`

다른 입력 및 출력 경로 사용:

```bash
INFODATA_ROOT=/path/to/xlsx \
OUTPUT_DIR=/path/to/sql \
DATABASE_NAME=springtime \
./install/generated_infosql.sh
```

## Data generation scripts

### `install/generated_json.sh`

JSON Schema 파일을 PHP DTO 클래스로 변환합니다. 기본 스키마 경로는 `/home/ubuntu/springtime-schema-json`이며 `msg/`, `data/`, `info/`를 재귀적으로 탐색합니다. 생성 결과는 스키마 디렉터리와 관계없이 모두 `class/json/` 바로 아래에 생성합니다.

모든 `*.schema.json` 생성:

```bash
cd /home/ubuntu/springtime-server
./install/generated_json.sh
```

일부 스키마만 생성:

```bash
./install/generated_json.sh iData.schema.json iDataMap.schema.json
./install/generated_json.sh data/iData.schema.json msg/ansLogin.schema.json
```

파일의 절대 또는 상대 경로를 직접 전달할 수도 있습니다. 다른 스키마 루트를 사용하려면 `SCHEMA_ROOT`를 지정합니다.

```bash
SCHEMA_ROOT=/path/to/schemas ./install/generated_json.sh
```

생성기는 객체 스키마의 `properties`와 `required`를 PHP DTO에 반영합니다. `additionalProperties.$ref`로 객체 스키마를 참조하는 맵 스키마도 지원하며, 로컬 참조는 `./파일.schema.json` 형식이어야 합니다.

### `install/make_info.php`

MySQL의 `info_*` 테이블을 조회해 서버에서 사용하는 언어별 PHP 정보 파일을 `data/<언어>/` 아래에 생성합니다. `info_language`를 먼저 생성하고 번역 정보를 준비한 뒤 아이템, 세포, 서버존, 메시지 코드 정보를 생성합니다. 번역이 필요한 신규 또는 변경 데이터가 있으면 DeepL API와 glossary를 사용할 수 있습니다.

PHP 및 JSON의 필드 값은 MySQL 테이블 컬럼 정의를 따릅니다. 정수 계열은 정수, 실수 및 DECIMAL 계열은 실수로 생성하며, 그 외 값은 문자열로 생성합니다. `NULL`은 빈 문자열로 바꾸지 않고 그대로 유지합니다.

이 파일은 파일 위치를 기준으로 프로젝트 루트의 `init.php`를 불러옵니다.

```bash
cd /home/ubuntu/springtime-server/install
php ./make_info.php
```

실행 전 확인 사항:

- `init.php`의 MySQL 접속 정보로 `springtime` 데이터베이스에 연결할 수 있어야 합니다.
- 대상 `info_*` 테이블과 데이터가 준비되어 있어야 합니다.
- 번역이 필요한 경우 DeepL 설정과 `translate/glossary/` 파일이 유효해야 합니다.
- 생성된 파일은 서버가 읽는 `data/<언어>/` 내용을 갱신하므로 운영 반영 전에 변경 내용을 확인해야 합니다.

### Purchase receipt validation

`reqItemCashBuyData`는 Android와 iOS에서 공통으로 `platform`, `iisn`, `count`, `product_id`, `receipt`를 전달합니다. `receipt`에는 Android의 Google Play purchase token 또는 iOS의 Base64 App Store receipt data를 넣습니다. iOS 요청에는 `transaction_id`를 추가합니다. Android package name은 서버의 `game::getAndroidPackageName()`에서 가져옵니다.

```json
{
  "platform": "android",
  "iisn": 1,
  "count": 1,
  "product_id": "item.1",
  "receipt": "google-play-purchase-token"
}
```

```json
{
  "platform": "ios",
  "iisn": 1,
  "count": 1,
  "product_id": "item.1",
  "receipt": "base64-app-store-receipt",
  "transaction_id": "123456789012345"
}
```

`android::validateReceipt($product_id, $receipt)`은 `game::getAndroidPackageName()`으로 package name을 가져와 Google Play Developer API로 비구독 상품 영수증을 검증합니다. 구매 상태가 완료(`purchaseState = 0`)이면 `true`를 반환하고, 실패하면 다음 msgcode를 반환합니다. 운영 서비스에서는 라이선스 테스트 구매(`purchaseType = 0`)를 거부합니다. Google Play 서비스 계정에는 대상 앱의 Play Console API 접근 권한이 있어야 합니다.

- `MSGCODE_NOT_EXIST_ANDROID_PACKAGE_NAME`: 서버 package name 설정 누락
- `MSGCODE_ERROR_ANDROID_INVALID_PARAMETER`: 상품 ID 또는 구매 토큰 누락
- `MSGCODE_ERROR_ANDROID_SERVICE_ACCOUNT_NOT_CONFIGURED`: Google Play 서비스 계정 설정 누락
- `MSGCODE_ERROR_ANDROID_JWT_SIGN_FAILED`: JWT 서명 실패
- `MSGCODE_ERROR_ANDROID_ACCESS_TOKEN_REQUEST_FAILED`: OAuth 액세스 토큰 요청 또는 응답 실패
- `MSGCODE_ERROR_ANDROID_PURCHASE_REQUEST_FAILED`: Google Play 구매 조회 요청 또는 응답 실패
- `MSGCODE_ERROR_ANDROID_TEST_PURCHASE_NOT_ALLOWED`: 운영 서비스의 테스트 구매
- `MSGCODE_ERROR_ANDROID_PURCHASE_NOT_COMPLETED`: 구매가 완료 상태가 아님

서버 실행 환경에 Google 서비스 계정 정보를 설정합니다. 개인키 값에 실제 줄바꿈 대신 `\n`이 포함된 형식도 지원합니다.

```bash
export GOOGLE_SERVICE_ACCOUNT_EMAIL='service-account@example.iam.gserviceaccount.com'
export GOOGLE_PRIVATE_KEY_PEM='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n'
```

`ios::validateReceipt($receipt, $product_id, $transaction_id)`은 Apple 프로덕션 서버에서 영수증을 검증하고 상태 `21007`이면 샌드박스 서버로 다시 요청합니다. 단, 운영 서비스에서는 샌드박스 영수증을 거부합니다. Apple 응답 상태가 `0`이고 동일한 상품 및 transaction ID가 영수증에 존재할 때 `true`를 반환하고, 실패하면 다음 msgcode를 반환합니다. 구독 상품에서 shared secret이 필요한 경우 서버 환경에 선택적으로 설정합니다.

- `MSGCODE_ERROR_IOS_INVALID_PARAMETER`: 영수증, 상품 ID 또는 거래 ID 누락
- `MSGCODE_ERROR_IOS_PRODUCTION_REQUEST_FAILED`: 프로덕션 영수증 검증 요청 또는 응답 실패
- `MSGCODE_ERROR_IOS_SANDBOX_NOT_ALLOWED`: 운영 서비스의 샌드박스 영수증
- `MSGCODE_ERROR_IOS_SANDBOX_REQUEST_FAILED`: 샌드박스 영수증 검증 요청 또는 응답 실패
- `MSGCODE_ERROR_IOS_INVALID_STATUS`: Apple 영수증 검증 상태가 성공이 아님
- `MSGCODE_ERROR_IOS_PURCHASE_MISMATCH`: 상품 ID 또는 거래 ID 불일치

```bash
export APPLE_SHARED_SECRET='app-store-shared-secret'
```

Android와 iOS는 설정, 서명, 네트워크, 응답 또는 구매 상태 검증 실패에 해당하는 msgcode를 반환합니다. 개인키, shared secret, 액세스 토큰과 영수증은 로그에 기록하지 않습니다.

### Firebase Cloud Messaging

`push::sendPushNotification($usn, $title, $message, $data)`은 사용자의 등록 토큰으로 FCM HTTP v1 API를 호출합니다. `push::sendPushNotificationToToken($pushToken, $pushData)`을 사용하면 토큰을 직접 지정할 수 있습니다. 전송 성공 시 `true`, 실패 시 다음 msgcode를 반환합니다.

- `MSGCODE_ERROR_PUSH_TOKEN_NOT_FOUND`: 사용자에게 등록된 push token이 없음
- `MSGCODE_ERROR_PUSH_INVALID_PARAMETER`: push token 또는 push 데이터가 올바르지 않음
- `MSGCODE_NOT_EXIST_FIREBASE_PROJECT_ID`: Firebase project ID 설정 누락
- `MSGCODE_ERROR_PUSH_SERVICE_ACCOUNT_NOT_CONFIGURED`: Firebase 서비스 계정 설정 누락
- `MSGCODE_ERROR_PUSH_JWT_SIGN_FAILED`: OAuth JWT 서명 실패
- `MSGCODE_ERROR_PUSH_ACCESS_TOKEN_REQUEST_FAILED`: OAuth 액세스 토큰 요청 또는 응답 실패
- `MSGCODE_ERROR_PUSH_PAYLOAD_ENCODE_FAILED`: FCM 메시지 JSON 생성 실패
- `MSGCODE_ERROR_PUSH_REQUEST_FAILED`: FCM HTTP v1 요청 또는 응답 실패
- `MSGCODE_ERROR_PUSH_RESPONSE_INVALID`: FCM 성공 응답에 메시지 ID가 없음

Firebase project ID는 `game::getFirebaseProjectId()`가 `info_game`의 `FIREBASE_PROJECT_ID`에서 가져옵니다. DEV, STAGE, PROD에 서로 다른 `FIREBASE_PROJECT_ID`와 서비스 계정을 배포하면 각 환경의 Firebase 프로젝트로 분리됩니다. FCM URL은 환경과 관계없이 `https://fcm.googleapis.com/v1/projects/<project-id>/messages:send`를 사용합니다.

각 서버 실행 환경에 Firebase 서비스 계정을 설정합니다. 서비스 계정에는 대상 프로젝트의 Firebase Cloud Messaging API 전송 권한이 있어야 합니다.

```bash
export FIREBASE_SERVICE_ACCOUNT_EMAIL='firebase-adminsdk@example.iam.gserviceaccount.com'
export FIREBASE_PRIVATE_KEY_PEM='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n'
```

FCM HTTP v1의 `data` 값은 문자열이어야 하므로 숫자와 boolean은 문자열로 변환하고 배열과 객체는 JSON 문자열로 변환합니다. TLS 인증서와 hostname 검증은 항상 활성화합니다.

### `cron/make_recommand_friend.php`

추천 친구 info 파일을 생성합니다. `user` 테이블의 최소 및 최대 `usn` 범위에서 `db::AUTO_INCREMENT_INCREMENT` 간격에 맞는 값을 무작위로 선택하고, 조회된 사용자의 `usn`과 `nickname`을 저장합니다. 생성 후보 수는 `friend::RECOMMEND_FRIEND_MAKECOUNT`를 사용합니다.

수동 실행:

```bash
cd /home/ubuntu/springtime-server/cron
php ./make_recommand_friend.php
```

현재 `ubuntu` 사용자의 crontab에는 5분마다 생성하도록 다음 작업이 등록되어 있습니다.

```cron
*/5 * * * * /usr/bin/flock -n /tmp/springtime-recommend-friend.lock /usr/bin/php /home/ubuntu/springtime-server/cron/make_recommand_friend.php >> /home/ubuntu/springtime-server/log/make_recommend_friend.cron.log 2>&1
```

`flock`은 이전 실행이 끝나지 않은 경우 다음 실행이 겹치지 않도록 방지합니다. 실행 결과와 오류는 `log/make_recommend_friend.cron.log`에 누적됩니다.

등록 내용 및 로그 확인:

```bash
crontab -l
tail -f /home/ubuntu/springtime-server/log/make_recommend_friend.cron.log
```

실행 전 `init.php`의 MySQL 연결 설정, `user` 테이블 데이터, `db::AUTO_INCREMENT_INCREMENT` 및 `friend::RECOMMEND_FRIEND_MAKECOUNT` 값이 올바른지 확인해야 합니다.
