feat(backend): 휴대폰 앱 받기 API 와 운영 도구 올리기 · 목록 명령을 더한다

앱을 스토어 없이 사내에만 나눠 주기로 해(2026-11-08), 로그인한 직원만 최신 안드로이드 APK 의
정보와 파일을 받게 한다(GET /api/app-releases/android/latest · /latest/file — 제한된 세션은 막힌다).
올리기는 API 없이 서버의 운영 도구로 한다(파일 검사 · SHA-256 · 임시 이름에 쓴 뒤 바꾸기 · 실행 기록).
최신은 가장 늦게 올린 판이라 옛 APK 를 다시 올려 되돌릴 수 있고, app-config 의 최소 판보다 낮은 판은 올리지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 23:29:03 +09:00
parent 1f8555340e
commit f2ba81513e
24 changed files with 2869 additions and 5 deletions
@@ -0,0 +1,223 @@
import { execFileSync } from 'child_process';
import { createHash, randomBytes } from 'crypto';
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
truncateSync,
writeFileSync,
} from 'fs';
import { open } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { forEachChunk, inspectApk, type ApkInspection } from './apk-file';
import {
APK_MAX_BYTES,
APK_MIN_BYTES,
APK_ZIP_SIGNATURE,
} from './app-releases.constants';
/** zip 머리 + 무작위 내용 */
function apkBytes(size: number): Buffer {
return Buffer.concat([
APK_ZIP_SIGNATURE,
randomBytes(size - APK_ZIP_SIGNATURE.length),
]);
}
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
/** 검사가 막은 까닭 (통과했으면 연 파일을 닫고 'ok') */
async function problemOf(inspection: ApkInspection): Promise<string> {
if (inspection.ok) {
await inspection.apk.close();
return 'ok';
}
return inspection.problem;
}
/** 이 시험을 root 로 돌리는가 — root 는 권한과 상관없이 읽는다 */
const runningAsRoot = process.getuid?.() === 0;
const onWindows = process.platform === 'win32';
describe('inspectApk — 운영자가 넣은 APK 검사 (휴대폰 앱 올리기)', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'apk-file-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const file = (name: string, data: Buffer) => {
const path = join(dir, name);
writeFileSync(path, data);
return path;
};
it('통과하면 크기 · SHA-256 · 파일 이름과 연 파일을 준다 — 연 파일로 다시 읽을 수 있다', async () => {
const data = apkBytes(64 * 1024);
const path = file('ARA-SYSTEM-1.0.0.apk', data);
const inspection = await inspectApk(path);
expect(inspection.ok).toBe(true);
if (!inspection.ok) return;
const { apk } = inspection;
try {
expect(apk).toMatchObject({
path,
fileName: 'ARA-SYSTEM-1.0.0.apk',
sizeBytes: data.length,
sha256: sha256(data),
});
const again = Buffer.alloc(data.length);
await apk.handle.read(again, 0, again.length, 0);
expect(again.equals(data)).toBe(true);
} finally {
await apk.close();
}
});
it('이름은 .apk 로 끝나야 한다 (대소문자는 가리지 않는다) — 이름이 틀리면 열지도 않는다', async () => {
const data = apkBytes(APK_MIN_BYTES);
await expect(
problemOf(await inspectApk(file('ARA-SYSTEM-1.0.0.APK', data))),
).resolves.toBe('ok');
for (const name of ['ARA-SYSTEM-1.0.0.zip', 'ARA-SYSTEM-1.0.0', 'apk']) {
await expect(problemOf(await inspectApk(file(name, data)))).resolves.toBe(
'not-apk-name',
);
}
await expect(
problemOf(await inspectApk(join(dir, 'none.zip'))),
).resolves.toBe('not-apk-name');
});
it('없는 경로 · 폴더 · 파일이 아닌 것은 알린다', async () => {
await expect(
problemOf(await inspectApk(join(dir, 'none.apk'))),
).resolves.toBe('missing');
await expect(
problemOf(await inspectApk(join(dir, 'no-dir', 'x.apk'))),
).resolves.toBe('missing');
mkdirSync(join(dir, 'folder.apk'));
await expect(
problemOf(await inspectApk(join(dir, 'folder.apk'))),
).resolves.toBe('not-file');
});
(onWindows ? it.skip : it)(
'바로가기(심볼릭 링크)는 따라가지 않는다',
async () => {
const target = file('real.apk', apkBytes(APK_MIN_BYTES));
symlinkSync(target, join(dir, 'link.apk'));
await expect(
problemOf(await inspectApk(join(dir, 'link.apk'))),
).resolves.toBe('symlink');
},
);
(onWindows ? it.skip : it)(
'파이프(FIFO)를 열 때 멈추지 않는다 — 일반 파일이 아니라고 알린다',
async () => {
const path = join(dir, 'pipe.apk');
try {
execFileSync('mkfifo', [path]);
} catch {
// mkfifo 가 없는 환경 — 이 시험은 건너뛴다 (개발 컨테이너 · CI 에는 있다)
return;
}
await expect(problemOf(await inspectApk(path))).resolves.toBe('not-file');
},
);
(onWindows || runningAsRoot ? it.skip : it)(
'읽을 권한이 없으면 알린다 (docker cp 는 root 소유로 넣는다)',
async () => {
const path = file('locked.apk', apkBytes(APK_MIN_BYTES));
chmodSync(path, 0o000);
await expect(problemOf(await inspectApk(path))).resolves.toBe(
'unreadable',
);
},
);
it('크기 — 1KB 아래 · 200MB 위는 받지 않는다(큰 파일은 읽지 않고 크기로만), 경계는 받는다', async () => {
await expect(
problemOf(
await inspectApk(file('small.apk', apkBytes(APK_MIN_BYTES - 1))),
),
).resolves.toBe('too-small');
await expect(
problemOf(await inspectApk(file('edge.apk', apkBytes(APK_MIN_BYTES)))),
).resolves.toBe('ok');
// 빈 곳만 있는 큰 파일(디스크를 쓰지 않는다) — 머리를 읽기 전에 크기로 막는다
const large = join(dir, 'large.apk');
writeFileSync(large, APK_ZIP_SIGNATURE);
truncateSync(large, APK_MAX_BYTES + 1);
await expect(problemOf(await inspectApk(large))).resolves.toBe('too-large');
});
it('zip 머리(PK\\x03\\x04)가 아니면 APK 가 아니다', async () => {
const notZip = Buffer.concat([
Buffer.from('<html>'),
randomBytes(APK_MIN_BYTES),
]);
await expect(
problemOf(await inspectApk(file('page.apk', notZip))),
).resolves.toBe('not-zip');
// 빈 디렉터리 머리(PK\x05\x06 — 빈 zip)도 APK 가 아니다
const emptyZip = Buffer.concat([
Buffer.from([0x50, 0x4b, 0x05, 0x06]),
Buffer.alloc(APK_MIN_BYTES),
]);
await expect(
problemOf(await inspectApk(file('empty.apk', emptyZip))),
).resolves.toBe('not-zip');
});
});
describe('forEachChunk — 연 파일을 처음부터 끝까지', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'apk-chunks-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('조각을 차례로 넘기고 읽은 크기가 맞으면 true — 다르면(그사이 바뀜) false', async () => {
const data = randomBytes(3 * 1024 * 1024 + 17);
const path = join(dir, 'data.bin');
writeFileSync(path, data);
const handle = await open(path, 'r');
try {
const parts: Buffer[] = [];
await expect(
forEachChunk(handle, data.length, (chunk) => {
parts.push(Buffer.from(chunk));
}),
).resolves.toBe(true);
expect(Buffer.concat(parts).equals(data)).toBe(true);
expect(parts.length).toBeGreaterThan(1);
// 생각한 것보다 짧다(줄었다) · 길다(늘었다) — 넘게 읽히면 거기서 멈춘다(끝까지 읽지 않는다)
await expect(
forEachChunk(handle, data.length + 1, () => undefined),
).resolves.toBe(false);
let seen = 0;
await expect(
forEachChunk(handle, 10, (chunk) => {
seen += chunk.length;
}),
).resolves.toBe(false);
expect(seen).toBeLessThanOrEqual(10);
} finally {
await handle.close();
}
});
});
@@ -0,0 +1,172 @@
import { createHash } from 'crypto';
import { constants } from 'fs';
import { open, type FileHandle } from 'fs/promises';
import { basename, extname } from 'path';
import {
APK_EXTENSION,
APK_MAX_BYTES,
APK_MIN_BYTES,
APK_ZIP_SIGNATURE,
} from './app-releases.constants';
/**
* 운영자가 컨테이너 안에 넣은 APK 읽기 (운영 도구 「휴대폰 앱 올리기」 — 계약 1절 "휴대폰 앱 받기").
* 파일 하나를 한 번 열고, 그 열린 파일(같은 inode)로 검사 · SHA-256 · 저장소에 들이기를 모두 한다 —
* 검사한 뒤 경로가 다른 파일로 바뀌어도 들이는 것은 검사한 그 파일이다(내용이 바뀌면 들일 때 다시 센 SHA-256 이 막는다)
*/
/** 읽을 수 없는 까닭 — 운영 도구가 한국어로 알린다 (modules/ops/app-release-commands.ts) */
export type ApkProblem =
/** 이름이 .apk 로 끝나지 않는다 */
| 'not-apk-name'
/** 그 경로에 없다 */
| 'missing'
/** 바로가기(심볼릭 링크)다 — 따라가지 않는다 */
| 'symlink'
/** 일반 파일이 아니다 (폴더 · 장치 · 파이프 · 소켓) */
| 'not-file'
/** 읽을 권한이 없다 (docker cp 는 root 소유로 넣는다 — 원본이 644 가 아니면 node 가 읽지 못한다) */
| 'unreadable'
| 'too-small'
| 'too-large'
/** zip 머리(PK\x03\x04)가 아니다 */
| 'not-zip'
/** 읽는 동안 크기가 바뀌었다 (아직 복사 중) */
| 'changed';
/** 검사를 마친 APK — 다 쓰면 close() (부르는 쪽이 닫는다) */
export interface InspectedApk {
/** 운영자가 넣은 경로 그대로 — 실행 기록 · 바뀔 것에 보인다(감사용) */
readonly path: string;
/** 경로의 마지막 이름 — 판을 짐작하는 데만 쓴다(디스크 · 내려받는 이름이 되지 않는다) */
readonly fileName: string;
readonly sizeBytes: number;
/** SHA-256 (소문자 16진수) */
readonly sha256: string;
/** 연 파일 — 저장소에 들일 때 처음부터 다시 읽는다 */
readonly handle: FileHandle;
close(): Promise<void>;
}
export type ApkInspection =
| { readonly ok: true; readonly apk: InspectedApk }
| { readonly ok: false; readonly problem: ApkProblem };
/** 한 번에 읽는 양 */
const READ_CHUNK_BYTES = 1024 * 1024;
/**
* 열기 — 바로가기를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
* 일반 파일인지는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0)
*/
const OPEN_FLAGS =
constants.O_RDONLY |
(constants.O_NOFOLLOW ?? 0) |
(constants.O_NONBLOCK ?? 0);
function problem(kind: ApkProblem): ApkInspection {
return { ok: false, problem: kind };
}
/** 열지 못한 까닭 → 알릴 것 (모르는 오류는 null — 그대로 던진다) */
function openProblem(error: unknown): ApkProblem | null {
switch ((error as NodeJS.ErrnoException | null)?.code) {
case 'ENOENT':
case 'ENOTDIR':
return 'missing';
case 'ELOOP':
return 'symlink';
case 'EACCES':
case 'EPERM':
return 'unreadable';
// 소켓 등 — 열 수 없는 특수 파일
case 'ENXIO':
case 'EISDIR':
return 'not-file';
default:
return null;
}
}
/**
* 연 파일을 처음부터 끝까지 차례로 읽어 조각마다 부른다 — 읽은 바이트 수가 `size` 와 다르면(그사이 파일이 바뀜) false.
* 넘게 읽히면 거기서 멈춘다. 조각 버퍼는 다음 읽기에 다시 쓴다 — 부르는 쪽은 그 안에서 다 쓴다(해시 · 쓰기를 기다린 뒤 돌아온다)
*/
export async function forEachChunk(
handle: FileHandle,
size: number,
onChunk: (chunk: Buffer) => Promise<void> | void,
): Promise<boolean> {
const buffer = Buffer.alloc(Math.max(1, Math.min(READ_CHUNK_BYTES, size)));
let position = 0;
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) return position === size;
position += bytesRead;
if (position > size) return false;
await onChunk(buffer.subarray(0, bytesRead));
}
}
/** 열린 파일의 SHA-256 — 크기가 바뀌었으면 null */
async function sha256Of(
handle: FileHandle,
size: number,
): Promise<string | null> {
const hash = createHash('sha256');
const complete = await forEachChunk(handle, size, (chunk) => {
hash.update(chunk);
});
return complete ? hash.digest('hex') : null;
}
/**
* 운영자가 넣은 경로의 APK 를 검사한다 — 이름(.apk) · 일반 파일 · 크기(1KB ~ 200MB) · zip 머리 · SHA-256.
* 큰 파일은 크기를 본 뒤에야 읽는다(200MB 를 넘는 파일은 읽지 않는다). 통과하면 연 파일째 돌려준다 — 부르는 쪽이 닫는다
*/
export async function inspectApk(path: string): Promise<ApkInspection> {
if (extname(path).toLowerCase() !== APK_EXTENSION) {
return problem('not-apk-name');
}
let handle: FileHandle;
try {
handle = await open(path, OPEN_FLAGS);
} catch (error) {
const reason = openProblem(error);
if (reason === null) throw error;
return problem(reason);
}
let handedOver = false;
try {
const stats = await handle.stat();
if (!stats.isFile()) return problem('not-file');
if (stats.size < APK_MIN_BYTES) return problem('too-small');
if (stats.size > APK_MAX_BYTES) return problem('too-large');
const head = Buffer.alloc(APK_ZIP_SIGNATURE.length);
const { bytesRead } = await handle.read(head, 0, head.length, 0);
if (bytesRead !== head.length || !head.equals(APK_ZIP_SIGNATURE)) {
return problem('not-zip');
}
const sha256 = await sha256Of(handle, stats.size);
if (sha256 === null) return problem('changed');
handedOver = true;
const opened = handle;
return {
ok: true,
apk: {
path,
fileName: basename(path),
sizeBytes: stats.size,
sha256,
handle: opened,
close: () => opened.close(),
},
};
} finally {
if (!handedOver) await handle.close();
}
}
@@ -0,0 +1,154 @@
import { createHash, randomBytes } from 'crypto';
import {
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from 'fs';
import { open, type FileHandle } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import {
AppReleaseFileChangedError,
AppReleaseStorage,
} from './app-release-storage';
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const OTHER_KEY = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
describe('AppReleaseStorage (휴대폰 앱 파일 — 업로드 볼륨의 app-releases/)', () => {
let root: string;
let dir: string;
let storage: AppReleaseStorage;
let source: FileHandle;
const data = randomBytes(2 * 1024 * 1024 + 5);
beforeEach(async () => {
root = mkdtempSync(join(tmpdir(), 'app-release-storage-'));
// 아직 없는 하위 폴더 — 처음 들일 때 만든다
dir = join(root, 'app-releases');
storage = new AppReleaseStorage(dir);
const sourcePath = join(root, 'ARA-SYSTEM-1.0.0.apk');
writeFileSync(sourcePath, data);
source = await open(sourcePath, 'r');
});
afterEach(async () => {
await source.close();
rmSync(root, { recursive: true, force: true });
});
const expected = () => ({ sizeBytes: data.length, sha256: sha256(data) });
it('들이면 <key>.apk 만 남는다(임시 파일 없음 · 소유자만 쓴다) — 열고 · 있는지 보고 · 지운다', async () => {
await storage.importFile(source, KEY, expected());
expect(readdirSync(dir)).toEqual([`${KEY}.apk`]);
expect(readFileSync(join(dir, `${KEY}.apk`)).equals(data)).toBe(true);
if (process.platform !== 'win32') {
expect(statSync(join(dir, `${KEY}.apk`)).mode & 0o777).toBe(0o640);
}
const opened = await storage.open(KEY);
expect(opened?.sizeBytes).toBe(data.length);
await opened?.handle.close();
await expect(storage.exists(KEY)).resolves.toBe(true);
await storage.remove(KEY);
await storage.remove(KEY);
await expect(storage.exists(KEY)).resolves.toBe(false);
await expect(storage.open(KEY)).resolves.toBeNull();
});
it('들이는 동안 다시 센 SHA-256 · 크기가 검사 때와 다르면 들이지 않고 아무것도 남기지 않는다', async () => {
await expect(
storage.importFile(source, KEY, {
...expected(),
sha256: sha256(Buffer.from('other')),
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
expect(readdirSync(dir)).toEqual([]);
await expect(
storage.importFile(source, KEY, {
...expected(),
sizeBytes: data.length - 1,
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
await expect(
storage.importFile(source, KEY, {
...expected(),
sizeBytes: data.length + 1,
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
expect(readdirSync(dir)).toEqual([]);
});
it('쓰다 실패해도(원본을 읽지 못함) 반쯤 쓴 임시 파일을 남기지 않는다', async () => {
const broken = {
read: jest.fn().mockRejectedValue(new Error('EIO')),
} as unknown as FileHandle;
await expect(storage.importFile(broken, KEY, expected())).rejects.toThrow(
'EIO',
);
expect(readdirSync(dir)).toEqual([]);
});
it('같은 키의 임시 파일이 있으면(다른 올리기가 쓰는 중) 덮어쓰지도 지우지도 않는다', async () => {
await storage.importFile(source, OTHER_KEY, expected());
writeFileSync(join(dir, `${KEY}.apk.part`), 'someone else');
await expect(storage.importFile(source, KEY, expected())).rejects.toThrow();
expect(readFileSync(join(dir, `${KEY}.apk.part`), 'utf8')).toBe(
'someone else',
);
});
it('서버가 만든 키(소문자 UUID)가 아니면 경로로 쓰지 않는다', async () => {
for (const key of [
'../../etc/passwd',
`../${KEY}`,
`${KEY}/x`,
KEY.toUpperCase(),
`${KEY}.apk`,
'',
]) {
await expect(storage.importFile(source, key, expected())).rejects.toThrow(
'휴대폰 앱 파일 키 형식이 올바르지 않습니다.',
);
await expect(storage.open(key)).rejects.toThrow();
await expect(storage.exists(key)).rejects.toThrow();
await expect(storage.remove(key)).rejects.toThrow();
}
});
it('멈춘 올리기의 오래된 임시 파일만 지운다 — 새 임시 파일 · 판 파일 · 다른 이름은 그대로, 폴더가 없으면 0', async () => {
await expect(storage.removeStaleParts(new Date())).resolves.toBe(0);
await storage.importFile(source, OTHER_KEY, expected());
const old = join(dir, `${KEY}.apk.part`);
const fresh = join(dir, '0f8fad5b-d9cb-469f-a165-70867728950e.apk.part');
const stranger = join(dir, 'notes.part');
for (const path of [old, fresh, stranger]) writeFileSync(path, 'x');
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(old, longAgo, longAgo);
utimesSync(stranger, longAgo, longAgo);
await expect(
storage.removeStaleParts(new Date(Date.now() - 60 * 60 * 1000)),
).resolves.toBe(1);
expect(readdirSync(dir).sort()).toEqual(
[
`${OTHER_KEY}.apk`,
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
'notes.part',
].sort(),
);
});
});
@@ -0,0 +1,197 @@
import { createHash } from 'crypto';
import {
mkdir,
open,
readdir,
rename,
stat,
unlink,
type FileHandle,
} from 'fs/promises';
import { join } from 'path';
import { forEachChunk } from './apk-file';
import { APK_EXTENSION, APP_RELEASE_DIR } from './app-releases.constants';
/** 서버가 만든 키(소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
const FILE_KEY =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** 쓰는 중인 임시 파일의 꼬리 — `<key>.apk.part` */
const PART_SUFFIX = '.part';
/** 임시 파일 이름 — 정리할 때 이 꼴만 지운다 */
const PART_FILE_NAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.apk\.part$/;
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 · 대용량 첨부 저장과 같다 */
const FILE_MODE = 0o640;
function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
}
async function unlinkIfExists(path: string): Promise<void> {
try {
await unlink(path);
} catch (error) {
if (!isMissing(error)) throw error;
}
}
/** 버퍼를 끝까지 쓴다 (한 번에 다 쓰지 못할 수 있다) */
async function writeAll(
out: FileHandle,
data: Buffer,
position: number,
): Promise<void> {
let done = 0;
while (done < data.length) {
const { bytesWritten } = await out.write(
data,
done,
data.length - done,
position + done,
);
done += bytesWritten;
}
}
/** 저장소에 들이는 동안 원본이 검사한 것과 달라졌다 (SHA-256 · 크기) — 들이지 않았다 */
export class AppReleaseFileChangedError extends Error {
override readonly name = 'AppReleaseFileChangedError';
constructor() {
super('APK 가 검사한 뒤 바뀌었습니다.');
}
}
/** 연 판 파일 — 크기는 열린 파일에서(fstat). 부르는 쪽이 닫거나 읽기 흐름에 넘긴다 */
export interface OpenedAppReleaseFile {
readonly handle: FileHandle;
readonly sizeBytes: number;
}
/** 들이는 파일이 맞아야 할 값 — 검사 때 센 것 */
export interface ExpectedAppReleaseFile {
readonly sizeBytes: number;
readonly sha256: string;
}
/**
* 휴대폰 앱 파일 저장소 — 업로드 볼륨의 하위 폴더(APP_RELEASE_DIR) 안 `<file_key>.apk`.
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 서비스가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다
*/
export class AppReleaseStorage {
constructor(private readonly dir: string = APP_RELEASE_DIR) {}
/** 판 파일을 연다 — 없으면 null */
async open(fileKey: string): Promise<OpenedAppReleaseFile | null> {
let handle: FileHandle;
try {
handle = await open(this.pathOf(fileKey), 'r');
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
try {
return { handle, sizeBytes: (await handle.stat()).size };
} catch (error) {
await handle.close();
throw error;
}
}
/** 판 파일이 있는가 (운영 도구의 목록) */
async exists(fileKey: string): Promise<boolean> {
try {
await stat(this.pathOf(fileKey));
return true;
} catch (error) {
if (isMissing(error)) return false;
throw error;
}
}
/**
* 운영자가 넣은 APK(검사하며 연 파일)를 들인다 — `<key>.apk.part` 에 쓰고(wx — 덮어쓰지 않는다) 디스크에 내려쓴 뒤 `<key>.apk` 로 이름을 바꾼다.
* 쓰면서 다시 센 SHA-256 · 크기가 검사 때와 다르면(검사한 뒤 원본이 바뀜) AppReleaseFileChangedError.
* 어떻게 실패해도 반쯤 쓴 임시 파일을 남기지 않는다 — 이름을 바꾸기 전에는 `<key>.apk` 가 생기지 않는다
*/
async importFile(
source: FileHandle,
fileKey: string,
expected: ExpectedAppReleaseFile,
): Promise<void> {
const finalPath = this.pathOf(fileKey);
const partPath = `${finalPath}${PART_SUFFIX}`;
await mkdir(this.dir, { recursive: true });
const out = await open(partPath, 'wx', FILE_MODE);
let closed = false;
let renamed = false;
try {
const hash = createHash('sha256');
let written = 0;
const complete = await forEachChunk(
source,
expected.sizeBytes,
async (chunk) => {
hash.update(chunk);
await writeAll(out, chunk, written);
written += chunk.length;
},
);
if (!complete || hash.digest('hex') !== expected.sha256) {
throw new AppReleaseFileChangedError();
}
// 이름을 바꾸기 전에 디스크에 내려쓴다 — 표에 넣은 판의 파일이 전원이 꺼져도 남게
await out.datasync();
closed = true;
await out.close();
await rename(partPath, finalPath);
renamed = true;
} finally {
if (!closed) await out.close().catch(() => undefined);
if (!renamed) await unlinkIfExists(partPath);
}
}
/** 판 파일을 지운다 — 이미 없으면 그대로 (표에 넣지 못했을 때 되돌리기) */
async remove(fileKey: string): Promise<void> {
await unlinkIfExists(this.pathOf(fileKey));
}
/**
* 멈춘 올리기(도구가 쓰는 중에 끊김)가 남긴 임시 파일 가운데 `olderThan` 보다 오래된 것을 지운다 → 지운 수.
* 폴더가 없으면 0. 그사이 지워진 것은 건너뛴다
*/
async removeStaleParts(olderThan: Date): Promise<number> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
if (isMissing(error)) return 0;
throw error;
}
let removed = 0;
for (const name of names.filter((entry) => PART_FILE_NAME.test(entry))) {
const path = join(this.dir, name);
try {
if ((await stat(path)).mtime >= olderThan) continue;
await unlink(path);
removed += 1;
} catch (error) {
if (!isMissing(error)) throw error;
}
}
return removed;
}
private pathOf(fileKey: string): string {
if (!FILE_KEY.test(fileKey)) {
// 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
throw new Error('휴대폰 앱 파일 키 형식이 올바르지 않습니다.');
}
return join(this.dir, `${fileKey}${APK_EXTENSION}`);
}
}
@@ -0,0 +1,61 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
appReleaseFileName,
compareAppVersions,
isAppVersion,
versionInFileName,
} from './app-release-version';
describe('휴대폰 앱 버전 (사내 배포)', () => {
it('버전 글 — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이), 앱 알림 등록의 appVersion 과 같은 규칙', () => {
for (const version of ['1.0.0', '0.0.1', '10.20.30', '1234.0.9999']) {
expect(isAppVersion(version)).toBe(true);
}
for (const version of [
'',
'1.0',
'1.0.0.1',
'01.0.0',
'1.00.0',
'1.0.0-beta',
'1.0.0+5',
'v1.0.0',
'12345.0.0',
' 1.0.0',
'1.0.0\n',
]) {
expect(isAppVersion(version)).toBe(false);
}
});
it('칸마다 숫자로 견준다 — 글자 차례가 아니다(1.10.0 > 1.9.0)', () => {
expect(compareAppVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
expect(compareAppVersions('1.9.9', '2.0.0')).toBeLessThan(0);
expect(compareAppVersions('1.2.3', '1.2.3')).toBe(0);
expect(compareAppVersions('0.9.0', '1.0.0')).toBeLessThan(0);
expect(compareAppVersions('1.0.10', '1.0.2')).toBeGreaterThan(0);
});
it('파일 이름에서 버전을 찾는다 — 없거나 규칙에 맞지 않으면 null', () => {
expect(versionInFileName('ARA-SYSTEM-1.2.3.apk')).toBe('1.2.3');
expect(versionInFileName('ARA-SYSTEM-v10.0.1.APK')).toBe('10.0.1');
expect(versionInFileName('app-release-1.0.0+12.apk')).toBe('1.0.0');
expect(versionInFileName('app-release.apk')).toBeNull();
// 네 칸 · 다섯 자리 · 앞자리 0 — 판으로 보지 않는다
expect(versionInFileName('ARA-SYSTEM-1.2.3.4.apk')).toBeNull();
expect(versionInFileName('ARA-SYSTEM-12345.0.0.apk')).toBeNull();
expect(versionInFileName('ARA-SYSTEM-01.0.0.apk')).toBeNull();
});
it('파일 이름에서 찾기는 긴 이름에도 길이에 비례한다 (숫자 · 점이 잔뜩 든 이름)', () => {
const name = `${'1.'.repeat(100_000)}apk`;
const cpuMs = startCpuTimer();
expect(versionInFileName(name)).toBeNull();
// 지금 수 ms — 한도는 넉넉히(부하에서 는다 · .claude/rules/testing.md)
expect(cpuMs()).toBeLessThan(1_000);
});
it('내려받는 파일 이름 — ARA-SYSTEM-<버전>.apk', () => {
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk');
});
});
@@ -0,0 +1,43 @@
import {
APP_VERSION_MAX_LENGTH,
APP_VERSION_PATTERN,
} from '@/modules/push/push.constants';
import { APK_EXTENSION } from './app-releases.constants';
/**
* 휴대폰 앱 판 (순수 함수) — 판 글은 앱 알림 등록의 `appVersion` 과 **같은 규칙**이다(push.constants 의 APP_VERSION_PATTERN —
* `숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 앱 설정의 최소 판(app-config)도 같은 꼴로 칸마다 숫자로 견준다
*/
/** 파일 이름 안의 판 후보 — 앞뒤가 숫자 · 점으로 이어지지 않는 `x.y.z` (`1.2.3.4` 의 일부는 판으로 보지 않는다). 칸마다 4자리까지라 길이에 비례한다 */
const VERSION_IN_NAME =
/(?<![0-9.])([0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4})(?![0-9]|\.[0-9])/;
/** 판 글인가 — 길이를 먼저 본다(정규식 전에) */
export function isAppVersion(text: string): boolean {
return (
text.length <= APP_VERSION_MAX_LENGTH && APP_VERSION_PATTERN.test(text)
);
}
/** 두 판을 칸마다 숫자로 견준다 — a 가 낮으면 음수, 같으면 0, 높으면 양수 (둘 다 판 글이어야 한다) */
export function compareAppVersions(a: string, b: string): number {
const left = a.split('.').map(Number);
const right = b.split('.').map(Number);
for (let index = 0; index < left.length; index += 1) {
const difference = left[index] - right[index];
if (difference !== 0) return difference;
}
return 0;
}
/** 운영자가 넣은 파일 이름에서 판을 찾는다 (`ARA-SYSTEM-1.2.3.apk` → `1.2.3`) — 없거나 판 규칙에 맞지 않으면 null */
export function versionInFileName(fileName: string): string | null {
const match = VERSION_IN_NAME.exec(fileName);
return match && isAppVersion(match[1]) ? match[1] : null;
}
/** 직원이 내려받는 파일 이름 — 서버가 검사한 판으로만 만든다 (`ARA-SYSTEM-1.0.0.apk`) */
export function appReleaseFileName(version: string): string {
return `ARA-SYSTEM-${version}${APK_EXTENSION}`;
}
@@ -0,0 +1,63 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
import {
APP_RELEASES_TABLE,
APP_RELEASE_UPLOADER_MAX_LENGTH,
type AppReleasePlatform,
} from './app-releases.constants';
/** 플랫폼 이름 길이 — 'android' (push_registrations.platform 과 같다) */
const PLATFORM_COLUMN_LENGTH = 16;
/** 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push_registrations.app_version 과 같다) */
const VERSION_COLUMN_LENGTH = 16;
/** SHA-256 — 소문자 16진수 64자 */
const SHA256_HEX_LENGTH = 64;
/**
* 나눠 주는 휴대폰 앱 한 판 (계약 1절 "휴대폰 앱 받기" — 사내 배포) — 운영 도구 「휴대폰 앱 올리기」가 한 줄씩 넣는다.
*
* - "최신" = 그 플랫폼에서 가장 늦게 올린 줄(created_at) — 판 번호가 아니다. 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길)
* - 파일은 업로드 볼륨의 `app-releases/<file_key>.apk` — 디스크 이름은 서버가 만든 UUID 뿐이다(운영자가 넣은 파일 이름 · 경로를 쓰지 않는다)
* - 지우는 명령은 없다 — 옛 판은 표 · 볼륨에 남는다(되돌리기 · 문의 대응). 직원 주소로 키링한 표가 아니라 퇴사 마무리와 상관없다
*/
@Entity({ name: APP_RELEASES_TABLE })
@Index('IDX_app_releases_platform_created_at', ['platform', 'createdAt'])
export class AppReleaseEntity {
/** 판 id — 서버가 만든 UUID (내려받기 응답의 ETag) */
@PrimaryColumn({ type: 'uuid' })
id!: string;
/** 플랫폼 — 지금은 android 만 */
@Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH })
platform!: AppReleasePlatform;
/** 판 (`1.0.0` — 앱의 versionName. 앱 알림 등록의 appVersion 과 같은 규칙) */
@Column({ type: 'varchar', length: VERSION_COLUMN_LENGTH })
version!: string;
/** 파일 크기(바이트) — 내려받기의 Content-Length · 범위 계산이 이 값을 쓴다(디스크의 크기와 다르면 내려주지 않는다) */
@Column({ name: 'size_bytes', type: 'bigint', transformer: BIGINT_NUMBER })
sizeBytes!: number;
/** 파일의 SHA-256 (소문자 16진수) — 운영자가 빌드한 파일과 견준다 */
@Column({ type: 'char', length: SHA256_HEX_LENGTH })
sha256!: string;
/** 디스크의 파일 이름(확장자 빼고) — 서버가 만든 UUID */
@Index('UQ_app_releases_file_key', { unique: true })
@Column({ name: 'file_key', type: 'uuid' })
fileKey!: string;
/** 올린 이 — 운영 도구를 시작할 때 넣은 운영자 이름 */
@Column({
name: 'uploaded_by',
type: 'varchar',
length: APP_RELEASE_UPLOADER_MAX_LENGTH,
})
uploadedBy!: string;
/** 올린 때 — 최신을 가르는 차례 · 화면의 "올린 날"(`releasedAt`) */
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,49 @@
import { join } from 'path';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 한도 · 형식 · 자리를 한 곳에 모은다.
* 앱은 스토어 없이 직접 빌드한 APK 를 포털에 로그인한 직원에게만 나눠 준다.
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts)
*/
/** 표 이름 — app-release.entity.ts 와 같다 */
export const APP_RELEASES_TABLE = 'app_releases';
/** 나눠 주는 플랫폼 — 지금은 안드로이드만 (아이폰은 나중 거리) */
export const APP_RELEASE_PLATFORMS = ['android'] as const;
export type AppReleasePlatform = (typeof APP_RELEASE_PLATFORMS)[number];
/** APK 의 형식 — 내려받기 응답의 Content-Type (휴대폰이 설치 파일로 알아본다) */
export const APK_CONTENT_TYPE = 'application/vnd.android.package-archive';
/** 확장자 — 운영자가 넣은 파일 이름에서 본다(대소문자는 가리지 않는다) · 내려받는 파일 이름에 붙인다 */
export const APK_EXTENSION = '.apk';
/** zip 의 로컬 파일 머리 `PK\x03\x04` — APK 는 zip 이다 */
export const APK_ZIP_SIGNATURE: Readonly<Buffer> = Buffer.from([
0x50, 0x4b, 0x03, 0x04,
]);
/** 크기 한도 — 1KB 아래는 APK 가 아니다, 200MB 위는 받지 않는다(지금 앱은 30MB 남짓) */
export const APK_MIN_BYTES = 1024;
export const APK_MAX_BYTES = 200 * 1024 * 1024;
/**
* 파일 자리 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR)의 하위 폴더. 이름은 `<file_key>.apk` 뿐이다.
* 운영은 named volume `<PROJECT_NAME>_uploads`(매일 백업에 든다), 개발은 바인드 마운트 (.claude/skills/docker/SKILL.md)
*/
export const APP_RELEASE_DIR = join(UPLOAD_DIR, 'app-releases');
/** 운영 도구 「휴대폰 앱 목록」이 보이는 판 수 — 최근 것부터 */
export const APP_RELEASE_LIST_LIMIT = 10;
/** 올린 이(운영자 이름) 길이 — 운영 도구의 운영자 이름 한도(ops-audit-log.entity.ts 의 OPS_OPERATOR_MAX_LENGTH)와 같다 */
export const APP_RELEASE_UPLOADER_MAX_LENGTH = 100;
/**
* 멈춘 올리기가 남긴 임시 파일(`*.apk.part`)을 지우는 나이 — 올리기를 시작할 때 이보다 오래된 것만 지운다.
* 다른 운영자가 막 쓰는 중인 임시 파일은 건드리지 않게 넉넉히 둔다(200MB 를 쓰는 데 몇 초다)
*/
export const APP_RELEASE_STALE_PART_MS = 60 * 60 * 1000;
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpStatus,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { APK_CONTENT_TYPE } from './app-releases.constants';
import { AppReleasesService } from './app-releases.service';
import { AppReleaseDto } from './dto/app-release.dto';
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 포털에 로그인한 직원만 받는다(웹 쿠키 · 앱 Bearer — JwtAuthGuard).
* 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts).
*
* "올린 판이 없음"(404)은 화면이 설정을 열 때마다 묻는 예상된 상태라 debug 로만 남긴다 — 표에는 있는데 파일이 없는 404 는 서비스가 error 로 따로 남긴다
*/
@ApiTags('App')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors()
@UseFilters(new HttpExceptionFilter({ quietStatuses: [HttpStatus.NOT_FOUND] }))
@Controller('app-releases')
export class AppReleasesController {
constructor(private readonly releases: AppReleasesService) {}
@Get('android/latest')
@NoStore()
@ApiOperation({
summary: '안드로이드 앱의 최신 판 정보',
description:
'최신 = 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다. ' +
'올린 판이 없으면 404 RES_001 — 화면은 "아직 받을 수 있는 앱이 없습니다" 칸을 보인다(알림 끔). Cache-Control: private, no-store.',
})
@ApiStandardResponse(AppReleaseDto, { description: '최신 판 정보' })
@ApiErrorResponse('RES_001', '올린 판이 없다')
latest(): Promise<AppReleaseDto> {
return this.releases.latestInfo('android');
}
@Get('android/latest/file')
@SkipTransform()
@ApiOperation({
summary:
'안드로이드 앱의 최신 판 파일 (GET · HEAD · Range — 표준 래퍼 없음)',
description:
`늘 ${APK_CONTENT_TYPE} + Content-Disposition: attachment; filename="ARA-SYSTEM-<버전>.apk" + nosniff + sandbox CSP + private, no-store. ` +
'강한 ETag("<판 id>") · Last-Modified(올린 때) · Accept-Ranges: bytes — 대용량 첨부 받기와 같은 머리글이고 형식만 APK 다. ' +
'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만. ' +
'올린 판이 없거나 그 파일이 없으면 404 RES_001. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.',
})
@ApiProduces(APK_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: 'APK 파일 (206 — 범위)',
schema: { type: 'string', format: 'binary' },
})
@ApiResponse({
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
})
@ApiErrorResponse('RES_001', '올린 판이 없다 · 그 파일이 없다')
async file(@Req() req: Request, @Res() res: Response): Promise<void> {
await this.releases.serveLatestFile(req, res, 'android');
}
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppReleaseStorage } from './app-release-storage';
import { AppReleaseEntity } from './app-release.entity';
import { AppReleasesController } from './app-releases.controller';
import { AppReleasesRepository } from './app-releases.repository';
import { AppReleasesService } from './app-releases.service';
/**
* 휴대폰 앱 받기 모듈 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 최신 판 정보 · 파일(`/api/app-releases/android/latest` · `/file`).
* 판은 표 app_releases, 파일은 업로드 볼륨의 `app-releases/`. 올리기는 운영 도구가 같은 서비스 · 저장소를 다시 엮어 쓴다(modules/ops/ops.module.ts).
* 이 모듈은 다른 기능 모듈을 가져오지 않는다
*/
@Module({
imports: [TypeOrmModule.forFeature([AppReleaseEntity])],
controllers: [AppReleasesController],
providers: [
AppReleasesRepository,
{
provide: AppReleaseStorage,
useFactory: () => new AppReleaseStorage(),
},
AppReleasesService,
],
exports: [AppReleasesService],
})
export class AppReleasesModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { AppReleaseEntity } from './app-release.entity';
import type { AppReleasePlatform } from './app-releases.constants';
/** 늦게 올린 것부터 — 같은 시각이면 id 로 차례를 정한다(늘 같은 답) */
const NEWEST_FIRST = { createdAt: 'DESC', id: 'DESC' } as const;
/** 휴대폰 앱 판 표 (app_releases) */
@Injectable()
export class AppReleasesRepository {
constructor(
@InjectRepository(AppReleaseEntity)
private readonly rows: Repository<AppReleaseEntity>,
) {}
/** 그 플랫폼에서 가장 늦게 올린 판 — 없으면 null */
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
return this.rows.findOne({ where: { platform }, order: NEWEST_FIRST });
}
/** 늦게 올린 것부터 `limit` 개 */
recent(
platform: AppReleasePlatform,
limit: number,
): Promise<AppReleaseEntity[]> {
return this.rows.find({
where: { platform },
order: NEWEST_FIRST,
take: limit,
});
}
async insert(release: AppReleaseEntity): Promise<void> {
await this.rows.insert(release);
}
}
@@ -0,0 +1,358 @@
import { Logger } from '@nestjs/common';
import { randomBytes } from 'crypto';
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import type { Request, Response } from 'express';
import { tmpdir } from 'os';
import { join } from 'path';
import { PassThrough } from 'stream';
import { BusinessException } from '@/common/errors/business.exception';
import { inspectApk, type InspectedApk } from './apk-file';
import { AppReleaseStorage } from './app-release-storage';
import { AppReleaseEntity } from './app-release.entity';
import { APK_ZIP_SIGNATURE } from './app-releases.constants';
import type { AppReleasesRepository } from './app-releases.repository';
import { AppReleasesService } from './app-releases.service';
/** 표 대역 — 늦게 올린 것부터 (실제 차례는 e2e 가 PostgreSQL 로 본다) */
class FakeRepository {
readonly rows: AppReleaseEntity[] = [];
insertFailure: Error | null = null;
private newestFirst(platform: string): AppReleaseEntity[] {
return this.rows
.filter((row) => row.platform === platform)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
latest(platform: string) {
return Promise.resolve(this.newestFirst(platform)[0] ?? null);
}
recent(platform: string, limit: number) {
return Promise.resolve(this.newestFirst(platform).slice(0, limit));
}
insert(release: AppReleaseEntity) {
if (this.insertFailure) return Promise.reject(this.insertFailure);
this.rows.push(release);
return Promise.resolve();
}
}
/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */
class FakeResponse extends PassThrough {
statusCode = 200;
readonly headers = new Map<string, string>();
readonly received: Buffer[] = [];
constructor() {
super();
this.on('data', (part: Buffer) => this.received.push(part));
}
status(code: number): this {
this.statusCode = code;
return this;
}
setHeader(name: string, value: string): this {
this.headers.set(name.toLowerCase(), value);
return this;
}
get body(): Buffer {
return Buffer.concat(this.received);
}
}
const request = (method: string, headers: Record<string, string> = {}) =>
({ method, headers }) as unknown as Request;
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
try {
await run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('AppReleasesService — 휴대폰 앱 받기 (사내 배포)', () => {
let root: string;
let releaseDir: string;
let repository: FakeRepository;
let service: AppReleasesService;
let errors: string[];
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(4096)]);
/** 운영자가 넣은 것처럼 — 임시 폴더의 APK 를 검사해 연 파일째 */
async function inspected(data: Buffer = content): Promise<InspectedApk> {
const path = join(root, `in-${randomBytes(4).toString('hex')}.apk`);
writeFileSync(path, data);
const inspection = await inspectApk(path);
if (!inspection.ok) throw new Error(inspection.problem);
return inspection.apk;
}
async function publish(
version: string,
at: Date,
data: Buffer = content,
): Promise<AppReleaseEntity> {
const apk = await inspected(data);
try {
return await service.publish(
apk,
{ platform: 'android', version, uploadedBy: '운영자 김' },
at,
);
} finally {
await apk.close();
}
}
async function serve(req: Request): Promise<FakeResponse> {
const res = new FakeResponse();
await service.serveLatestFile(req, res as unknown as Response, 'android');
return res;
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'app-releases-'));
releaseDir = join(root, 'app-releases');
repository = new FakeRepository();
service = new AppReleasesService(
repository as unknown as AppReleasesRepository,
new AppReleaseStorage(releaseDir),
);
errors = [];
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation((message: unknown) => {
errors.push(String(message));
});
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
it('최신 판 정보 — 올린 판이 없으면 404 RES_001, 있으면 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
expect(await codeOf(() => service.latestInfo('android'))).toBe('RES_001');
await publish('1.2.0', new Date('2026-11-18T01:00:00.000Z'));
const older = await publish('1.1.0', new Date('2026-11-19T01:23:45.000Z'));
await expect(service.latestInfo('android')).resolves.toEqual({
version: '1.1.0',
sizeBytes: content.length,
releasedAt: older.createdAt.toISOString(),
});
});
it('올리기 — 볼륨에 <file_key>.apk 하나 · 표에 한 줄(크기 · SHA-256 · 올린 이)', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-18T01:23:45.000Z'),
);
expect(repository.rows).toEqual([release]);
expect(release).toMatchObject({
platform: 'android',
version: '1.0.0',
sizeBytes: content.length,
uploadedBy: '운영자 김',
});
expect(release.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(release.fileKey).not.toBe(release.id);
expect(readdirSync(releaseDir)).toEqual([`${release.fileKey}.apk`]);
});
it('올리기 — 표에 넣지 못하면 들인 파일을 지우고 던진다(반쯤 올린 판을 남기지 않는다)', async () => {
repository.insertFailure = new Error('db down');
const apk = await inspected();
try {
await expect(
service.publish(apk, {
platform: 'android',
version: '1.0.0',
uploadedBy: '운영자 김',
}),
).rejects.toThrow('db down');
} finally {
await apk.close();
}
expect(repository.rows).toEqual([]);
expect(readdirSync(releaseDir)).toEqual([]);
});
it('올리기 — 버전 글이 아니면 아무것도 하지 않고 던진다(부르는 쪽이 먼저 본다)', async () => {
const apk = await inspected();
try {
await expect(
service.publish(apk, {
platform: 'android',
version: '1.0',
uploadedBy: '운영자 김',
}),
).rejects.toThrow('휴대폰 앱 버전 형식이 올바르지 않습니다.');
} finally {
await apk.close();
}
expect(existsSync(releaseDir)).toBe(false);
});
it('올리기 — 멈춘 올리기가 남긴 한 시간 넘은 임시 파일을 먼저 지운다', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await publish('1.0.0', new Date('2026-11-18T01:00:00.000Z'));
const stale = join(
releaseDir,
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
);
writeFileSync(stale, 'half');
// 지금 시각(파일의 고친 때)보다 두 시간 뒤에 올린다
await publish('1.0.1', new Date(Date.now() + 2 * 60 * 60 * 1000));
expect(existsSync(stale)).toBe(false);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('임시 파일 1개를 지웠습니다'),
);
});
describe('파일 응답 (Range · HEAD)', () => {
let release: AppReleaseEntity;
beforeEach(async () => {
release = await publish('1.0.0', new Date('2026-11-18T01:23:45.000Z'));
});
it('전체 200 — APK 형식 · 이름 · 대용량 받기와 같은 머리글(nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
const res = await serve(request('GET'));
expect(res.statusCode).toBe(200);
expect(res.body.equals(content)).toBe(true);
expect(Object.fromEntries(res.headers)).toEqual({
'content-type': 'application/vnd.android.package-archive',
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'accept-ranges': 'bytes',
etag: `"${release.id}"`,
'last-modified': release.createdAt.toUTCString(),
'x-accel-buffering': 'no',
'content-length': String(content.length),
});
});
it('범위 하나 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 전체 200 · HEAD 는 머리글만', async () => {
const partial = await serve(request('GET', { range: 'bytes=0-99' }));
expect(partial.statusCode).toBe(206);
expect(partial.body.equals(content.subarray(0, 100))).toBe(true);
expect(partial.headers.get('content-range')).toBe(
`bytes 0-99/${content.length}`,
);
expect(partial.headers.get('content-length')).toBe('100');
const suffix = await serve(request('GET', { range: 'bytes=-10' }));
expect(suffix.statusCode).toBe(206);
expect(suffix.body.equals(content.subarray(content.length - 10))).toBe(
true,
);
const unsatisfiable = await serve(
request('GET', { range: `bytes=${content.length}-` }),
);
expect(unsatisfiable.statusCode).toBe(416);
expect(unsatisfiable.headers.get('content-range')).toBe(
`bytes */${content.length}`,
);
expect(unsatisfiable.headers.get('content-length')).toBe('0');
expect(unsatisfiable.body.length).toBe(0);
const changed = await serve(
request('GET', { range: 'bytes=0-9', 'if-range': '"other"' }),
);
expect(changed.statusCode).toBe(200);
expect(changed.body.length).toBe(content.length);
const resumed = await serve(
request('GET', { range: 'bytes=10-', 'if-range': `"${release.id}"` }),
);
expect(resumed.statusCode).toBe(206);
expect(resumed.body.equals(content.subarray(10))).toBe(true);
const head = await serve(request('HEAD', { range: 'bytes=0-1' }));
expect(head.statusCode).toBe(206);
expect(head.headers.get('content-length')).toBe('2');
expect(head.body.length).toBe(0);
});
it('표에는 있는데 파일이 없거나 크기가 다르면 404 RES_001 — 머리글을 쓰지 않고, 운영자가 알게 error 로그', async () => {
writeFileSync(join(releaseDir, `${release.fileKey}.apk`), 'cut');
const res = new FakeResponse();
expect(
await codeOf(() =>
service.serveLatestFile(
request('GET'),
res as unknown as Response,
'android',
),
),
).toBe('RES_001');
expect(res.headers.size).toBe(0);
expect(errors.join('\n')).toContain('크기가 표와 다름');
rmSync(join(releaseDir, `${release.fileKey}.apk`));
expect(await codeOf(() => serve(request('GET')))).toBe('RES_001');
expect(errors.join('\n')).toContain('파일 없음');
expect(errors.join('\n')).toContain(`release=${release.id}`);
});
it('올린 판이 없으면 404 RES_001 (머리글을 쓰기 전에)', async () => {
repository.rows.length = 0;
const res = new FakeResponse();
expect(
await codeOf(() =>
service.serveLatestFile(
request('GET'),
res as unknown as Response,
'android',
),
),
).toBe('RES_001');
expect(res.headers.size).toBe(0);
});
it('서버 종료가 시작되면 새 받기는 시작하지 않는다(연결을 끊는다 — 받는 쪽이 이어 받는다)', async () => {
service.beforeApplicationShutdown();
const res = await serve(request('GET'));
expect(res.destroyed).toBe(true);
expect(res.body.length).toBe(0);
});
it('목록 — 늦게 올린 것부터, 디스크에 파일이 있는지 함께', async () => {
const newer = await publish(
'1.0.1',
new Date('2026-11-19T01:00:00.000Z'),
);
rmSync(join(releaseDir, `${release.fileKey}.apk`));
await expect(service.listRecent('android', 10)).resolves.toEqual([
{ release: newer, hasFile: true },
{ release, hasFile: false },
]);
await expect(service.listRecent('android', 1)).resolves.toEqual([
{ release: newer, hasFile: true },
]);
});
});
});
@@ -0,0 +1,325 @@
import {
type BeforeApplicationShutdown,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Request, Response } from 'express';
import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { BusinessException } from '@/common/errors/business.exception';
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
import { downloadHeaders } from '@/shared/utils/download-headers';
import { decideRange } from '@/shared/utils/http-range';
import { isStreamAbortError } from '@/shared/utils/stream-abort';
import { inspectApk, type ApkInspection, type InspectedApk } from './apk-file';
import {
AppReleaseStorage,
type OpenedAppReleaseFile,
} from './app-release-storage';
import { appReleaseFileName, isAppVersion } from './app-release-version';
import { AppReleaseEntity } from './app-release.entity';
import {
APK_CONTENT_TYPE,
APP_RELEASE_STALE_PART_MS,
type AppReleasePlatform,
} from './app-releases.constants';
import { AppReleasesRepository } from './app-releases.repository';
import type {
AppReleaseInfo,
AppReleaseListing,
AppReleasePublish,
} from './app-releases.types';
/** 버전 글이 아닌 줄(표를 손으로 고침)의 내려받는 이름 — 따옴표 안에 바깥 글이 들어가지 않게 */
const FALLBACK_FILE_NAME = 'ARA-SYSTEM.apk';
/** 머리글 하나 (여럿이면 첫 값) */
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function reasonOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 직원은 최신 판 정보 · 파일을 받고(로그인 필요), 운영 도구가 판을 올린다.
* "최신" = 그 플랫폼에서 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길).
*
* 파일 응답은 대용량 첨부 받기(file-sharing.service.ts 의 serveFile)와 같은 꼴이다 — 머리글은 shared/utils/download-headers.ts, 범위는 http-range.ts.
* 로그: 내려받기 성공은 남기지 않는다. 표에는 있는데 파일이 없거나 크기가 다르면 error(운영자가 다시 올린다)
*/
@Injectable()
export class AppReleasesService implements BeforeApplicationShutdown {
private readonly logger = new Logger(AppReleasesService.name);
/** 지금 흘려보내는 파일 응답 — 서버 종료 때 끊는다 */
private readonly streaming = new Set<Response>();
private shuttingDown = false;
constructor(
private readonly repository: AppReleasesRepository,
private readonly storage: AppReleaseStorage,
) {}
/**
* 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 느린 내려받기가 종료 유예(30초)를 다 쓰지 않게 —
* 브라우저는 Range 로 이어 받는다(대용량 첨부 받기와 같은 결). 그 뒤에 온 받기는 시작하지 않는다
*/
beforeApplicationShutdown(): void {
this.shuttingDown = true;
for (const res of this.streaming) res.destroy();
this.streaming.clear();
}
// ── 직원 (로그인 필요 — app-releases.controller.ts) ────────────────────────────
/** 최신 판 정보 — 올린 판이 없으면 404 RES_001 */
async latestInfo(platform: AppReleasePlatform): Promise<AppReleaseInfo> {
const release = await this.requireLatest(platform);
return {
version: release.version,
sizeBytes: release.sizeBytes,
releasedAt: release.createdAt.toISOString(),
};
}
/**
* 최신 판 파일을 내려준다 (GET · HEAD). 실패는 머리글을 쓰기 **전에** 던진다(전역 필터가 표준 실패 형식으로):
* 올린 판이 없음 · 파일이 없거나 표의 크기와 다름 404 RES_001.
* 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만
*/
async serveLatestFile(
req: Request,
res: Response,
platform: AppReleasePlatform,
): Promise<void> {
const release = await this.requireLatest(platform);
const file = await this.openFileOf(release);
let handedOver = false;
try {
const size = release.sizeBytes;
const etag = `"${release.id}"`;
const decision = decideRange(
firstHeader(req.headers.range),
firstHeader(req.headers['if-range']),
size,
{ etag, lastModified: release.createdAt },
);
this.writeFileHeaders(res, release, etag);
if (decision.kind === 'unsatisfiable') {
res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
res.setHeader('Content-Range', `bytes */${size}`);
res.setHeader('Content-Length', '0');
res.end();
return;
}
const range =
decision.kind === 'partial'
? { start: decision.start, end: decision.end }
: undefined;
res.status(range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);
res.setHeader(
'Content-Length',
String(range ? range.end - range.start + 1 : size),
);
if (range) {
res.setHeader(
'Content-Range',
`bytes ${range.start}-${range.end}/${size}`,
);
}
if (req.method === 'HEAD') {
res.end();
return;
}
if (this.shuttingDown) {
// 종료 중 — 시작하지 않는다. 연결을 끊으면 받는 쪽이 다시 시도한다
res.destroy();
return;
}
// 흐름이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
const stream = file.handle.createReadStream(
range ? { start: range.start, end: range.end } : {},
);
handedOver = true;
await this.stream(stream, res, release);
} finally {
if (!handedOver) await file.handle.close();
}
}
// ── 운영 도구 (modules/ops/app-release-commands.ts) ─────────────────────────────
/** 운영자가 넣은 경로의 APK 를 검사한다 — 통과하면 연 파일째(부르는 쪽이 닫는다) */
inspect(path: string): Promise<ApkInspection> {
return inspectApk(path);
}
/** 그 플랫폼의 최신 판 — 없으면 null */
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
return this.repository.latest(platform);
}
/** 늦게 올린 것부터 `limit` 개 — 디스크에 파일이 있는지 함께 */
async listRecent(
platform: AppReleasePlatform,
limit: number,
): Promise<AppReleaseListing[]> {
const releases = await this.repository.recent(platform, limit);
return Promise.all(
releases.map(async (release) => ({
release,
hasFile: await this.storage.exists(release.fileKey),
})),
);
}
/**
* 판을 올린다 — 검사한 APK 를 볼륨에 들이고(임시 이름 → 디스크에 내려쓰기 → 이름 바꾸기) 표에 한 줄 넣는다. 넣은 순간 최신이 된다.
* 들이는 동안 원본이 바뀌었으면 AppReleaseFileChangedError(파일도 표도 그대로). 표에 넣지 못하면 들인 파일을 지우고 던진다 —
* 반쯤 올린 판을 남기지 않는다. 시작할 때 멈춘 올리기가 남긴 오래된 임시 파일을 지운다
*/
async publish(
apk: InspectedApk,
input: AppReleasePublish,
now: Date = new Date(),
): Promise<AppReleaseEntity> {
if (!isAppVersion(input.version)) {
// 부르는 쪽(운영 도구)이 먼저 본다 — 여기까지 오면 버그
throw new Error('휴대폰 앱 버전 형식이 올바르지 않습니다.');
}
await this.removeStaleParts(now);
const release = Object.assign(new AppReleaseEntity(), {
id: randomUUID(),
platform: input.platform,
version: input.version,
sizeBytes: apk.sizeBytes,
sha256: apk.sha256,
fileKey: randomUUID(),
uploadedBy: input.uploadedBy,
createdAt: now,
} satisfies AppReleaseEntity);
await this.storage.importFile(apk.handle, release.fileKey, {
sizeBytes: apk.sizeBytes,
sha256: apk.sha256,
});
try {
await this.repository.insert(release);
} catch (error) {
// 표에 없는 파일은 누구도 받지 않는다 — 지워 둔다
await this.storage
.remove(release.fileKey)
.catch((removeError: unknown) =>
this.logger.error(
`[SYS_001] 표에 넣지 못한 휴대폰 앱 파일을 지우지도 못했습니다 — 볼륨에 남습니다: file=${release.fileKey}, reason=${reasonOf(removeError)}`,
),
);
throw error;
}
this.logger.log(
`휴대폰 앱을 올렸습니다: platform=${release.platform}, version=${release.version}, release=${release.id}, size=${release.sizeBytes}`,
);
return release;
}
// ── 내부 ────────────────────────────────────────────────────────────────
private async requireLatest(
platform: AppReleasePlatform,
): Promise<AppReleaseEntity> {
const release = await this.repository.latest(platform);
if (release === null) {
throw new BusinessException('RES_001', {
context: { reason: 'app-release-none', platform },
});
}
return release;
}
/** 그 판의 파일 — 없거나 표의 크기와 다르면 error 로그 + 404 RES_001 (직원에게는 올린 판이 없는 것과 같다) */
private async openFileOf(
release: AppReleaseEntity,
): Promise<OpenedAppReleaseFile> {
const file = await this.storage.open(release.fileKey);
if (file !== null && file.sizeBytes === release.sizeBytes) return file;
if (file !== null) await file.handle.close();
const missing = file === null;
this.logger.error(
`[RES_001]${traceTag()} 휴대폰 앱 파일을 내려줄 수 없습니다(${missing ? '파일 없음' : '크기가 표와 다름'}) — 운영 도구로 다시 올려 주세요: release=${release.id}, platform=${release.platform}, version=${release.version}`,
);
throw new BusinessException('RES_001', {
context: {
reason: missing
? 'app-release-file-missing'
: 'app-release-file-size-mismatch',
release: release.id,
},
});
}
/** 파일 응답의 머리글 — 대용량 첨부 받기와 같은 꼴이고 형식만 APK 다 (shared/utils/download-headers.ts) */
private writeFileHeaders(
res: Response,
release: AppReleaseEntity,
etag: string,
): void {
const fileName = isAppVersion(release.version)
? appReleaseFileName(release.version)
: FALLBACK_FILE_NAME;
for (const [header, value] of downloadHeaders({
contentType: APK_CONTENT_TYPE,
// 이름은 검사한 버전으로 만든 ASCII 뿐이다 — RFC 5987 이름(filename*)이 필요 없다
contentDisposition: `attachment; filename="${fileName}"`,
etag,
lastModified: release.createdAt,
})) {
res.setHeader(header, value);
}
}
private async stream(
stream: Readable,
res: Response,
release: AppReleaseEntity,
): Promise<void> {
// 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다
const traceId = currentRequestId() ?? 'unknown';
this.streaming.add(res);
try {
// 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다)
if (this.shuttingDown) res.destroy();
await pipeline(stream, res);
} catch (error) {
// 받는 쪽이 끊었거나 종료로 끊었다 — 장애가 아니다. 디스크 오류 등만 남긴다
if (!isStreamAbortError(error)) {
this.logger.error(
`[SYS_001][${traceId}] 휴대폰 앱 전송이 멈췄습니다: release=${release.id}, reason=${reasonOf(error)}`,
);
}
if (!res.destroyed) res.destroy();
} finally {
this.streaming.delete(res);
}
}
/** 멈춘 올리기가 남긴 오래된 임시 파일 — 정리하지 못해도 올리기는 이어 한다 */
private async removeStaleParts(now: Date): Promise<void> {
try {
const removed = await this.storage.removeStaleParts(
new Date(now.getTime() - APP_RELEASE_STALE_PART_MS),
);
if (removed > 0) {
this.logger.warn(
`멈춘 휴대폰 앱 올리기가 남긴 임시 파일 ${removed}개를 지웠습니다.`,
);
}
} catch (error) {
this.logger.warn(
`멈춘 휴대폰 앱 올리기의 임시 파일을 정리하지 못했습니다 — 올리기는 이어 합니다: reason=${reasonOf(error)}`,
);
}
}
}
@@ -0,0 +1,24 @@
import type { AppReleaseEntity } from './app-release.entity';
import type { AppReleasePlatform } from './app-releases.constants';
/** 최신 판 정보 — `GET /api/app-releases/android/latest` 의 data (계약 1절 "휴대폰 앱 받기") */
export interface AppReleaseInfo {
readonly version: string;
readonly sizeBytes: number;
/** 올린 때 — ISO 8601 UTC */
readonly releasedAt: string;
}
/** 운영 도구가 올리는 판 */
export interface AppReleasePublish {
readonly platform: AppReleasePlatform;
readonly version: string;
/** 운영자 이름 — 운영 도구를 시작할 때 넣은 이름 */
readonly uploadedBy: string;
}
/** 운영 도구 「휴대폰 앱 목록」의 한 줄 — 디스크에 파일이 있는지 함께 */
export interface AppReleaseListing {
readonly release: AppReleaseEntity;
readonly hasFile: boolean;
}
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import type { AppReleaseInfo } from '../app-releases.types';
/** GET /api/app-releases/android/latest 의 응답 (계약 1절 "휴대폰 앱 받기") */
export class AppReleaseDto implements AppReleaseInfo {
@ApiProperty({
description:
'버전 — 앱의 versionName(`숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 내려받는 파일 이름은 `ARA-SYSTEM-<버전>.apk`',
example: '1.0.0',
})
version!: string;
@ApiProperty({
description: 'APK 파일 크기(바이트)',
example: 32_505_856,
})
sizeBytes!: number;
@ApiProperty({
description:
'올린 때 (ISO 8601 UTC) — 화면의 "올린 날". 최신 = 가장 늦게 올린 판(버전 번호가 아니다)',
example: '2026-11-18T01:23:45.000Z',
})
releasedAt!: string;
}
@@ -0,0 +1,450 @@
import { Logger } from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants';
import type { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import {
APP_VERSION_RULE_TEXT,
AppReleaseCommands,
apkProblemText,
} from './app-release-commands';
import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service';
import { OpsInputClosed, type OpsIo } from './ops-io';
const OPERATOR = '운영자 김';
/** 답 — 글이면 그대로, 함수면 부른 값(묻는 사이에 무언가를 바꿔 본다) */
type Answer = string | (() => string);
/** 줄 입력 흉내 — 답을 차례로 주고, 보인 줄을 모은다. 답이 떨어지면 입력이 끝난 것으로 */
class ScriptedIo implements OpsIo {
readonly interactive = true;
readonly printed: string[] = [];
readonly asked: string[] = [];
constructor(private readonly answers: Answer[]) {}
print(line = ''): void {
this.printed.push(line);
}
ask(question: string): Promise<string> {
this.asked.push(question);
return this.next().then((answer) => answer.trim());
}
askSecret(question: string): Promise<string> {
this.asked.push(question);
return this.next();
}
async confirm(question: string): Promise<boolean> {
this.asked.push(question);
return ['y', 'yes', '예'].includes((await this.next()).toLowerCase());
}
close(): void {}
get output(): string {
return this.printed.join('\n');
}
private next(): Promise<string> {
const answer = this.answers.shift();
if (answer === undefined) return Promise.reject(new OpsInputClosed());
return Promise.resolve(typeof answer === 'function' ? answer() : answer);
}
}
/** 표 대역 — 늦게 올린 것부터 */
class FakeRepository {
readonly rows: AppReleaseEntity[] = [];
insertFailure: Error | null = null;
private newestFirst(): AppReleaseEntity[] {
return [...this.rows].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
}
latest() {
return Promise.resolve(this.newestFirst()[0] ?? null);
}
recent(_platform: string, limit: number) {
return Promise.resolve(this.newestFirst().slice(0, limit));
}
insert(release: AppReleaseEntity) {
if (this.insertFailure) return Promise.reject(this.insertFailure);
this.rows.push(release);
return Promise.resolve();
}
}
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
describe('운영 도구 — 휴대폰 앱 올리기 · 목록 (사내 배포)', () => {
let root: string;
/** 운영자가 docker cp 로 넣은 자리 (컨테이너의 /tmp 흉내) */
let incoming: string;
/** 업로드 볼륨의 app-releases/ 흉내 */
let releaseDir: string;
let repository: FakeRepository;
let service: AppReleasesService;
let audit: { entries: OpsAuditEntry[]; record: jest.Mock };
let commands: AppReleaseCommands;
let errorLogs: string[];
const apkBytes = () =>
Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(8 * 1024)]);
/** 운영자가 넣은 APK — 경로를 준다 */
const dropApk = (name: string, data: Buffer = apkBytes()) => {
const path = join(incoming, name);
writeFileSync(path, data);
return path;
};
/** 볼륨에 남은 것 (없으면 빈 목록) */
const stored = () => (existsSync(releaseDir) ? readdirSync(releaseDir) : []);
/** 이미 올린 판 하나 (표 · 볼륨) */
const existing = async (version: string, at: Date) => {
const inspection = await service.inspect(dropApk(`old-${version}.apk`));
if (!inspection.ok) throw new Error(inspection.problem);
try {
return await service.publish(
inspection.apk,
{ platform: 'android', version, uploadedBy: '먼저 올린 사람' },
at,
);
} finally {
await inspection.apk.close();
}
};
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'ops-app-release-'));
incoming = join(root, 'tmp');
mkdirSync(incoming);
releaseDir = join(root, 'uploads', 'app-releases');
repository = new FakeRepository();
service = new AppReleasesService(
repository as unknown as AppReleasesRepository,
new AppReleaseStorage(releaseDir),
);
const entries: OpsAuditEntry[] = [];
audit = {
entries,
record: jest.fn((entry: OpsAuditEntry) => {
entries.push(entry);
return Promise.resolve(true);
}),
};
commands = new AppReleaseCommands(
service,
audit as unknown as OpsAuditService,
);
errorLogs = [];
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(
(...args: unknown[]) => void errorLogs.push(args.map(String).join(' ')),
);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
describe('올리기', () => {
it('경로 → 버전(파일 이름의 버전이 기본값) → 바뀔 것(버전 · 크기 · SHA-256) y → 볼륨 · 표 · 실행 기록, 끝에 SHA-256 을 한 번 더', async () => {
const data = apkBytes();
const path = dropApk('ARA-SYSTEM-1.0.0.apk', data);
const io = new ScriptedIo([path, '', 'y']);
await commands.upload(io, OPERATOR);
expect(io.asked).toContain('버전 (빈 값이면 파일 이름의 1.0.0): ');
expect(io.output).toContain(' 휴대폰 앱 올리기 — 바뀔 것');
expect(io.output).toContain(` 파일 ${path}`);
expect(io.output).toContain(' 버전 1.0.0 (처음 올리는 앱)');
expect(io.output).toContain(
`(${data.length.toLocaleString('en-US')}바이트)`,
);
expect(io.output).toContain(` SHA-256 ${sha256(data)}`);
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.0.apk');
expect(io.output).toContain(
' [완료] 올렸습니다 — 버전 1.0.0 · 8 KB. 직원은 지금부터 이 버전을 받습니다.',
);
// 끝에 한 번 더 — 빌드한 PC 의 값과 견준다
expect(io.printed).toContain(` SHA-256 ${sha256(data)}`);
expect(io.output).toContain(
'certutil -hashfile ARA-SYSTEM-1.0.0.apk SHA256',
);
const [release] = repository.rows;
expect(release).toMatchObject({
platform: 'android',
version: '1.0.0',
sizeBytes: data.length,
sha256: sha256(data),
uploadedBy: OPERATOR,
});
expect(stored()).toEqual([`${release.fileKey}.apk`]);
expect(audit.entries).toEqual([
{
operator: OPERATOR,
command: 'app-release-upload',
target: 'android 1.0.0',
result: 'success',
detail: `id=${release.id} · ${data.length}바이트 · sha256=${sha256(data)} · 파일 ${path}`,
},
]);
});
it('검사한 연 파일은 성공 · 실패 모두 닫는다', async () => {
const closes: string[] = [];
const inspect = service.inspect.bind(service);
jest.spyOn(service, 'inspect').mockImplementation(async (path) => {
const inspection = await inspect(path);
if (!inspection.ok) return inspection;
const { apk } = inspection;
return {
ok: true,
apk: {
...apk,
close: () => {
closes.push(apk.fileName);
return apk.close();
},
},
};
});
await commands.upload(
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']),
OPERATOR,
);
await commands.upload(
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), 'x']),
OPERATOR,
);
expect(closes).toEqual(['ARA-SYSTEM-1.0.0.apk', 'ARA-SYSTEM-1.0.1.apk']);
});
it('바뀔 것에서 N 이면 아무것도 바꾸지 않는다 (실행 기록도 없다)', async () => {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain('하지 않았습니다.');
expect(repository.rows).toEqual([]);
expect(stored()).toEqual([]);
expect(audit.entries).toEqual([]);
});
it('버전 — 파일 이름에 없으면 예시로 묻고, 규칙에 맞지 않으면 알리고 멈춘다', async () => {
const noVersion = new ScriptedIo([dropApk('app-release.apk'), '']);
await commands.upload(noVersion, OPERATOR);
expect(noVersion.asked).toContain('버전 (예: 1.0.0): ');
expect(noVersion.output).toContain(APP_VERSION_RULE_TEXT);
for (const version of ['1.0', 'v1.0.0', '01.0.0', '1.0.0-beta']) {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), version]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(APP_VERSION_RULE_TEXT);
}
expect(repository.rows).toEqual([]);
expect(audit.entries).toEqual([]);
});
it('넣은 버전이 기본값과 다르면 넣은 것 — 파일 이름보다 운영자의 답이 먼저다', async () => {
const io = new ScriptedIo([
dropApk('ARA-SYSTEM-1.0.0.apk'),
'1.0.2',
'y',
]);
await commands.upload(io, OPERATOR);
expect(repository.rows[0]?.version).toBe('1.0.2');
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.2.apk');
});
it('서버가 받는 가장 낮은 버전(app-config)보다 낮으면 올리지 않는다', async () => {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-0.9.0.apk'), '']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
'서버가 받는 가장 낮은 버전은 1.0.0 입니다 — 그보다 낮은 버전은 올리지 않습니다',
);
expect(repository.rows).toEqual([]);
expect(io.asked).not.toContain('그래도 올릴까요?');
});
it('지금 최신보다 낮으면 알리고 묻는다 — N 이면 그대로, y 면 바뀔 것을 거쳐 올린다(되돌리기 길)', async () => {
// 이미 올린 판은 지난 날 — 지금 올리는 판(지금 시각)이 더 늦게 올린 것이 된다
await existing('1.2.0', new Date('2026-11-01T01:00:00.000Z'));
const declined = new ScriptedIo([
dropApk('ARA-SYSTEM-1.1.0.apk'),
'',
'n',
]);
await commands.upload(declined, OPERATOR);
expect(declined.output).toContain(
'주의: 넣은 버전(1.1.0)이 지금 직원이 받는 버전(1.2.0 · 2026년 11월 1일 (일) 오전 10:00 올림)보다 낮습니다.',
);
expect(declined.output).toContain('앱을 지우고 다시 깔아야 합니다');
expect(declined.output).toContain('하지 않았습니다.');
expect(repository.rows).toHaveLength(1);
const accepted = new ScriptedIo([
dropApk('ARA-SYSTEM-1.1.0.apk'),
'',
'y',
'y',
]);
await commands.upload(accepted, OPERATOR);
expect(accepted.output).toContain(
' 버전 1.1.0 (지금 직원이 받는 버전 1.2.0)',
);
expect(repository.rows).toHaveLength(2);
await expect(service.latestInfo('android')).resolves.toMatchObject({
version: '1.1.0',
});
});
it('지금 최신과 같으면 같다고 알리고 묻는다 · 높으면 묻지 않는다', async () => {
await existing('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
const same = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
await commands.upload(same, OPERATOR);
expect(same.output).toContain('과 같습니다.');
const higher = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), '', 'y']);
await commands.upload(higher, OPERATOR);
expect(higher.asked).not.toContain('그래도 올릴까요?');
expect(repository.rows).toHaveLength(2);
});
it('파일 검사에 걸리면 까닭을 알리고 버전을 묻지 않는다', async () => {
const cases: [string, string][] = [
[join(incoming, 'none.apk'), apkProblemText('missing')],
[dropApk('page.apk', Buffer.alloc(4096)), apkProblemText('not-zip')],
[dropApk('tiny.apk', APK_ZIP_SIGNATURE), apkProblemText('too-small')],
[dropApk('ARA-SYSTEM-1.0.0.zip'), apkProblemText('not-apk-name')],
];
for (const [path, text] of cases) {
const io = new ScriptedIo([path]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(text);
expect(io.asked).toHaveLength(1);
}
const empty = new ScriptedIo(['']);
await commands.upload(empty, OPERATOR);
expect(empty.output).toContain('경로를 넣어 주세요.');
expect(audit.entries).toEqual([]);
});
it('검사한 뒤 원본이 바뀌면(묻는 사이 다시 복사) 올리지 않고 반쯤 쓴 파일도 남기지 않는다 — 실패를 실행 기록에', async () => {
const path = dropApk('ARA-SYSTEM-1.0.0.apk');
const io = new ScriptedIo([
path,
'',
() => {
// y/N 을 묻는 사이 같은 자리에 다른 내용을 쓴다
writeFileSync(path, apkBytes());
return 'y';
},
]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
' [못 함] 파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다.',
);
expect(repository.rows).toEqual([]);
expect(stored()).toEqual([]);
expect(audit.entries).toEqual([
expect.objectContaining({
command: 'app-release-upload',
target: 'android 1.0.0',
result: 'failure',
detail: expect.stringMatching(
/^AppReleaseFileChangedError · \d+바이트 · sha256=[0-9a-f]{64} · 파일 /,
) as unknown,
}),
]);
});
it('표에 넣지 못하면 볼륨에 아무것도 남기지 않고 실패를 알린다(모르는 오류는 오류 로그에)', async () => {
repository.insertFailure = new TypeError('db down');
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'y']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
' [못 함] 처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.',
);
expect(stored()).toEqual([]);
expect(errorLogs.join('\n')).toContain('[SYS_001]');
expect(audit.entries).toEqual([
expect.objectContaining({
result: 'failure',
detail: expect.stringContaining('TypeError · ') as unknown,
}),
]);
});
});
describe('목록', () => {
it('올린 것이 없으면 알린다', async () => {
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
});
it('최근 판 — 버전 · 크기 · 올린 때 · 올린 이 · SHA-256 앞부분, 맨 위(▶)가 직원이 받는 판 · 파일이 없으면 알린다 (기록하지 않는다)', async () => {
const first = await existing(
'1.0.0',
new Date('2026-10-20T06:20:00.000Z'),
);
const second = await existing(
'1.0.1',
new Date('2026-11-02T01:23:00.000Z'),
);
rmSync(join(releaseDir, `${first.fileKey}.apk`));
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain(
' 휴대폰 앱 (안드로이드) — 최근 2개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)',
);
const lines = io.printed.filter((line) => line.includes('SHA-256'));
// 시각은 한국 시각으로 보인다 (시험을 도는 기계의 시간대와 상관없다)
expect(lines).toEqual([
`▶ 1.0.1 · 8 KB · 2026년 11월 2일 (월) 오전 10:23 · 먼저 올린 사람 · SHA-256 ${second.sha256.slice(0, 12)}…`,
` 1.0.0 · 8 KB · 2026년 10월 20일 (화) 오후 3:20 · 먼저 올린 사람 · SHA-256 ${first.sha256.slice(0, 12)}… · 파일 없음 — 다시 올리세요`,
]);
expect(audit.entries).toEqual([]);
});
it('읽지 못하면 알린다(모르는 오류는 오류 로그에)', async () => {
jest
.spyOn(service, 'listRecent')
.mockRejectedValueOnce(new TypeError('db down'));
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain('오류 로그');
expect(errorLogs.join('\n')).toContain('[SYS_001]');
});
});
});
@@ -0,0 +1,294 @@
import { Injectable } from '@nestjs/common';
import { MIN_SUPPORTED_APP_VERSION } from '@/modules/app-config/app-config.constants';
import type {
ApkInspection,
ApkProblem,
InspectedApk,
} from '@/modules/app-releases/apk-file';
import { AppReleaseFileChangedError } from '@/modules/app-releases/app-release-storage';
import {
appReleaseFileName,
compareAppVersions,
isAppVersion,
versionInFileName,
} from '@/modules/app-releases/app-release-version';
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import {
APK_MAX_BYTES,
APK_MIN_BYTES,
APP_RELEASE_LIST_LIMIT,
type AppReleasePlatform,
} from '@/modules/app-releases/app-releases.constants';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { OpsAuditService } from './ops-audit.service';
import type { OpsIo } from './ops-io';
import { RULE, confirmPlan, failureLine, step } from './ops-prompts';
import { bytesText, failureDetail, timeText } from './ops-text';
/** 올리는 플랫폼 — 지금은 안드로이드만 */
const PLATFORM: AppReleasePlatform = 'android';
/** 목록 줄의 SHA-256 앞부분 길이 */
const SHA256_PREFIX_LENGTH = 12;
/** 천 단위 쉼표 — 기계 설정과 상관없이 같은 글 */
const COUNT_FORMAT = new Intl.NumberFormat('en-US');
/** 버전 규칙 안내 */
export const APP_VERSION_RULE_TEXT =
'버전은 1.0.0 처럼 숫자 셋을 점으로 이어 넣어 주세요(칸마다 1~4자리 · 앞자리 0 없이).';
/** 검사에 걸린 까닭 → 운영자에게 보일 글 (무엇이 왜 안 되는지와 다음에 할 일) */
export function apkProblemText(problem: ApkProblem): string {
switch (problem) {
case 'not-apk-name':
return '파일 이름이 .apk 로 끝나지 않습니다.';
case 'missing':
return '그 경로에 파일이 없습니다 — sudo docker cp 로 이 컨테이너 안에 넣었는지, 경로가 맞는지 확인하세요.';
case 'symlink':
return '바로가기(심볼릭 링크)는 따라가지 않습니다 — 파일 자체를 docker cp 로 넣어 주세요.';
case 'not-file':
return '일반 파일이 아닙니다(폴더 · 장치 등).';
case 'unreadable':
return '파일을 읽을 권한이 없습니다 — 서버에서 chmod 644 로 바꾼 뒤 다시 docker cp 하세요.';
case 'too-small':
return `파일이 너무 작습니다(${bytesText(APK_MIN_BYTES)} 미만) — APK 가 아닙니다.`;
case 'too-large':
return `파일이 너무 큽니다(${bytesText(APK_MAX_BYTES)} 초과) — 올리지 않습니다.`;
case 'not-zip':
return 'APK 가 아닙니다 — 파일 머리가 zip(PK) 이 아닙니다.';
case 'changed':
return '읽는 동안 파일 크기가 바뀌었습니다 — 복사가 끝난 뒤 다시 하세요.';
}
}
/** 바이트 수 — "32,505,856바이트" */
function byteCount(bytes: number): string {
return `${COUNT_FORMAT.format(bytes)}바이트`;
}
/** 실행 기록의 덧붙이는 말 — 판 id · 크기 · SHA-256 을 앞에(길면 넣은 경로 쪽이 잘린다). 비밀은 없다 */
function uploadDetail(
apk: InspectedApk,
release: AppReleaseEntity | null,
failure: string | null,
): string {
return [
...(failure === null ? [] : [failure]),
...(release === null ? [] : [`id=${release.id}`]),
`${apk.sizeBytes}바이트`,
`sha256=${apk.sha256}`,
`파일 ${apk.path}`,
].join(' · ');
}
/** 목록의 한 줄 */
export function releaseLine(
release: AppReleaseEntity,
hasFile: boolean,
isCurrent: boolean,
): string {
return [
`${isCurrent ? '▶' : ' '} ${release.version}`,
bytesText(release.sizeBytes),
timeText(release.createdAt),
release.uploadedBy,
`SHA-256 ${release.sha256.slice(0, SHA256_PREFIX_LENGTH)}…`,
...(hasFile ? [] : ['파일 없음 — 다시 올리세요']),
].join(' · ');
}
/**
* 운영 도구 — 휴대폰 앱 (사내 배포 — 계약 1절 "휴대폰 앱 받기" · 6절 "운영 도구"): 올리기 · 목록.
* 올리기는 운영자가 `sudo docker cp` 로 컨테이너 안에 넣은 APK 를 검사해 업로드 볼륨과 표에 들이고 실행 기록을 남긴다(넣은 경로는 감사용으로 남긴다).
* 목록은 읽기만 한다(기록하지 않는다). 비밀은 다루지 않는다
*/
@Injectable()
export class AppReleaseCommands {
constructor(
private readonly releases: AppReleasesService,
private readonly audit: OpsAuditService,
) {}
/**
* 휴대폰 앱 올리기 — 경로 → 검사(일반 파일 · .apk · zip 머리 · 1KB~200MB · SHA-256) → 버전(파일 이름의 x.y.z 를 기본값으로) →
* 서버가 받는 가장 낮은 버전보다 낮으면 하지 않음 · 지금 최신보다 낮거나 같으면 y/N → 바뀔 것(버전 · 크기 · SHA-256) y/N → 들이기 → 실행 기록.
* 끝에 SHA-256 을 한 번 더 보여 빌드한 파일과 견주게 한다
*/
async upload(io: OpsIo, operator: string): Promise<void> {
io.print(
'서버에서 APK 를 먼저 이 컨테이너 안에 넣어 두세요 — sudo docker cp <파일> <PROJECT_NAME>-backend:/tmp/',
);
const path = await io.ask(
'컨테이너 안 APK 경로 (예: /tmp/ARA-SYSTEM-1.0.0.apk): ',
);
if (path === '') {
io.print('경로를 넣어 주세요.');
return;
}
let inspection: ApkInspection;
try {
inspection = await this.releases.inspect(path);
} catch (error) {
io.print(failureLine(error));
return;
}
if (!inspection.ok) {
io.print(apkProblemText(inspection.problem));
return;
}
const { apk } = inspection;
try {
await this.uploadInspected(io, operator, apk);
} finally {
await apk.close();
}
}
/** 휴대폰 앱 목록 — 최근 10개, 맨 위가 직원이 받는 버전 (읽기만 — 기록하지 않는다) */
async list(io: OpsIo): Promise<void> {
try {
const listings = await this.releases.listRecent(
PLATFORM,
APP_RELEASE_LIST_LIMIT,
);
if (listings.length === 0) {
io.print('올린 휴대폰 앱이 없습니다.');
return;
}
io.print('');
io.print(RULE);
io.print(
` 휴대폰 앱 (안드로이드) — 최근 ${listings.length}개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)`,
);
io.print(RULE);
listings.forEach(({ release, hasFile }, index) =>
io.print(releaseLine(release, hasFile, index === 0)),
);
io.print(RULE);
} catch (error) {
io.print(failureLine(error));
}
}
private async uploadInspected(
io: OpsIo,
operator: string,
apk: InspectedApk,
): Promise<void> {
const version = await this.askVersion(io, apk.fileName);
if (version === null) return;
const minimum = MIN_SUPPORTED_APP_VERSION[PLATFORM];
if (compareAppVersions(version, minimum) < 0) {
io.print(
`서버가 받는 가장 낮은 버전은 ${minimum} 입니다 — 그보다 낮은 버전은 올리지 않습니다(깔자마자 업데이트 화면이 뜹니다).`,
);
return;
}
const latest = await this.releases.latest(PLATFORM);
if (latest !== null && !(await this.confirmNotNewer(io, version, latest))) {
return;
}
const lines = [
`파일 ${apk.path}`,
`버전 ${version}${latest === null ? ' (처음 올리는 앱)' : ` (지금 직원이 받는 버전 ${latest.version})`}`,
`크기 ${bytesText(apk.sizeBytes)} (${byteCount(apk.sizeBytes)})`,
`SHA-256 ${apk.sha256}`,
`받는 이름 ${appReleaseFileName(version)}`,
'올리면 바로 직원이 받는 버전이 된다 — 웹메일 설정 › 휴대폰 앱 · 휴대폰의 <포털>/app',
];
if (!(await confirmPlan(io, '휴대폰 앱 올리기', lines))) return;
try {
const release = await this.releases.publish(apk, {
platform: PLATFORM,
version,
uploadedBy: operator,
});
step(
io,
true,
`올렸습니다 — 버전 ${version} · ${bytesText(apk.sizeBytes)}. 직원은 지금부터 이 버전을 받습니다.`,
);
this.showChecksum(io, version, apk.sha256);
await this.audit.record({
operator,
command: 'app-release-upload',
target: `${PLATFORM} ${version}`,
result: 'success',
detail: uploadDetail(apk, release, null),
});
} catch (error) {
step(
io,
false,
error instanceof AppReleaseFileChangedError
? '파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다. 복사가 끝난 뒤 다시 하세요.'
: failureLine(error),
);
await this.audit.record({
operator,
command: 'app-release-upload',
target: `${PLATFORM} ${version}`,
result: 'failure',
detail: uploadDetail(apk, null, failureDetail(error)),
});
}
}
/** 버전을 묻는다 — 빈 값이면 파일 이름의 버전. 규칙에 맞지 않으면 알리고 null */
private async askVersion(
io: OpsIo,
fileName: string,
): Promise<string | null> {
const suggested = versionInFileName(fileName);
const answer = await io.ask(
suggested === null
? '버전 (예: 1.0.0): '
: `버전 (빈 값이면 파일 이름의 ${suggested}): `,
);
const version = answer === '' ? suggested : answer;
if (version === null || !isAppVersion(version)) {
io.print(APP_VERSION_RULE_TEXT);
return null;
}
return version;
}
/** 넣은 버전이 지금 최신보다 높지 않으면 알리고 묻는다 — 높으면 묻지 않고 true */
private async confirmNotNewer(
io: OpsIo,
version: string,
latest: AppReleaseEntity,
): Promise<boolean> {
const order = compareAppVersions(version, latest.version);
if (order > 0) return true;
io.print(
`주의: 넣은 버전(${version})이 지금 직원이 받는 버전(${latest.version} · ${timeText(latest.createdAt)} 올림)${order === 0 ? '과 같습니다' : '보다 낮습니다'}.`,
);
io.print(
order === 0
? ' 올리면 이 파일이 직원이 받는 파일이 됩니다(같은 버전을 고쳐 다시 빌드했을 때).'
: ' 올리면 이 파일이 직원이 받는 버전이 됩니다(되돌릴 때 쓰는 길). 이미 더 높은 버전을 깐 휴대폰은 안드로이드가 낮은 버전으로 덮어 깔지 않아, 앱을 지우고 다시 깔아야 합니다(로그인도 다시).',
);
const yes = await io.confirm('그래도 올릴까요?');
if (!yes) io.print('하지 않았습니다.');
return yes;
}
/** 끝에 SHA-256 을 한 번 더 — 빌드한 PC 의 값과 견주게 한다 */
private showChecksum(io: OpsIo, version: string, sha256: string): void {
const fileName = appReleaseFileName(version);
io.print('');
io.print(RULE);
io.print(` SHA-256 ${sha256}`);
io.print(' 빌드한 PC 에서 같은 값인지 견주세요.');
io.print(` Windows certutil -hashfile ${fileName} SHA256`);
io.print(` macOS · 리눅스 shasum -a 256 ${fileName}`);
io.print(
' 컨테이너에 넣은 파일은 이제 지워도 됩니다 — 볼륨에 사본이 있습니다(mail/README "휴대폰 앱 올리기").',
);
io.print(RULE);
}
}
@@ -31,7 +31,9 @@ export type OpsCommand =
/** 메일 이전 › 이전용 비밀번호 다시 만들기 */
| 'migration-password'
/** 메일 이전 › 이전 마치기 — 직원용 임시 비밀번호 */
| 'migration-finish';
| 'migration-finish'
/** 휴대폰 앱 올리기 (사내 배포 — 2026-11-08) — 대상은 `android <버전>`, detail 에 판 id · 크기 · SHA-256 · 넣은 경로 */
| 'app-release-upload';
/**
* 비밀번호를 거는 명령 — 메일 이전 중 판정이 그 주소의 마지막 기록(성공 · 이전용 명령의 실패 — ops-data.service.ts 의 countsForMigration)을 본다.
@@ -75,7 +77,7 @@ export class OpsAuditLogEntity {
@Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH })
operator!: string;
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish) */
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish · app-release-upload) */
@Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH })
command!: string;
+9 -1
View File
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -21,7 +22,7 @@ const OPERATOR_CONTROL = /[\u0000-\u001f]/;
/** 관리자 비밀번호를 다시 묻는 횟수 */
const SIGN_IN_ATTEMPTS = 3;
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열한 개와 차례 그대로) */
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열세 개와 차례 그대로) */
export const OPS_MENU = [
['1', '입사'],
['2', '비밀번호 재설정'],
@@ -34,6 +35,8 @@ export const OPS_MENU = [
['9', '현황'],
['10', '정책 · 설정'],
['11', '메일 이전'],
['12', '휴대폰 앱 올리기'],
['13', '휴대폰 앱 목록'],
['0', '끝내기'],
] as const;
@@ -51,6 +54,7 @@ export class OpsCli {
private readonly admin: AdminCommands,
private readonly migration: MigrationCommands,
private readonly migrationPassword: MigrationPasswordSession,
private readonly appReleases: AppReleaseCommands,
) {}
/** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */
@@ -207,6 +211,10 @@ export class OpsCli {
return this.admin.policy(io, operator);
case '11':
return this.migration.menu(io, operator);
case '12':
return this.appReleases.upload(io, operator);
case '13':
return this.appReleases.list(io);
default:
io.print('메뉴의 번호를 넣어 주세요.');
}
+28 -1
View File
@@ -1,5 +1,6 @@
import { Logger } from '@nestjs/common';
import type { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { SystemMailService } from '@/modules/system-mail/system-mail.service';
@@ -11,6 +12,7 @@ import {
import { kstDateString } from '@/shared/utils/kst-date';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -337,13 +339,24 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
let locks: LockCommands;
let admin: AdminCommands;
let migration: MigrationCommands;
/** 휴대폰 앱 — 올리기 · 목록의 자세한 시험은 app-release-commands.spec.ts (여기서는 메뉴만) */
let appReleaseService: { listRecent: jest.Mock };
let appReleases: AppReleaseCommands;
/** 엔진 · 서비스가 불린 차례 (한 줄로) */
const order = (): string[] => [...engine.calls];
/** 도구 한 번 실행 */
const cli = () =>
new OpsCli(engine, accounts, locks, admin, migration, migrationPassword);
new OpsCli(
engine,
accounts,
locks,
admin,
migration,
migrationPassword,
appReleases,
);
let errorLogs: string[];
let warnLogs: string[];
@@ -509,6 +522,11 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
audit as unknown as OpsAuditService,
migrationPassword,
);
appReleaseService = { listRecent: jest.fn().mockResolvedValue([]) };
appReleases = new AppReleaseCommands(
appReleaseService as unknown as AppReleasesService,
audit as unknown as OpsAuditService,
);
});
afterEach(() => jest.restoreAllMocks());
@@ -2612,6 +2630,15 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
expect(io.output).toContain('2단계 인증이 켜져 있어');
});
it('메뉴 12 · 13 — 휴대폰 앱 올리기 · 목록 (메뉴는 명령 열셋 + 끝내기)', async () => {
const io = new ScriptedIo([OPERATOR, 'pw', '13', '0']);
await expect(cli().run(io)).resolves.toBe(0);
expect(io.output).toContain('12. 휴대폰 앱 올리기');
expect(io.output).toContain('13. 휴대폰 앱 목록');
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
expect(appReleaseService.listRecent).toHaveBeenCalledWith('android', 10);
});
it('입력이 끝나면(Ctrl+D) 조용히 끝낸다', async () => {
await expect(cli().run(new ScriptedIo([OPERATOR, 'pw']))).resolves.toBe(
0,
+14
View File
@@ -3,6 +3,10 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository';
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
@@ -39,6 +43,7 @@ import { buildDatabaseOptions } from '@/shared/config/database.config';
import { validateEnv } from '@/shared/config/env.validation';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -85,6 +90,7 @@ const OPS_DB_POOL = {
StoredFileChunkEntity,
ShareLinkEntity,
ShareLinkFileEntity,
AppReleaseEntity,
]),
// JmapClient 가 쓰는 캐시 (세션 기억)
CacheModule.register({ isGlobal: true }),
@@ -132,6 +138,13 @@ const OPS_DB_POOL = {
provide: SignatureImageStorage,
useFactory: () => new SignatureImageStorage(),
},
// 휴대폰 앱 — 올리기 · 목록 (서버와 같은 표 · 같은 업로드 볼륨 폴더)
AppReleasesRepository,
{
provide: AppReleaseStorage,
useFactory: () => new AppReleaseStorage(),
},
AppReleasesService,
OpsDataService,
OpsAuditService,
// 이번 실행의 이전용 비밀번호 — 이 프로세스의 메모리에만(한 실행 = 한 프로세스), 끝낼 때 OpsCli 가 지운다
@@ -140,6 +153,7 @@ const OPS_DB_POOL = {
LockCommands,
AdminCommands,
MigrationCommands,
AppReleaseCommands,
OpsCli,
],
})