From 5b18b8446028747cb3ac19d512c31fddd62f8ad1 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Wed, 27 May 2026 00:07:48 +0900 Subject: [PATCH 1/3] feat: add app version policy service --- server/infra/database/datasource.config.ts | 2 + .../entities/app-version-policy.entity.ts | 13 ++ server/infra/database/entities/index.ts | 1 + server/service/app-version.service.ts | 153 ++++++++++++++++++ sql/260526.sql | 20 +++ 5 files changed, 189 insertions(+) create mode 100644 server/infra/database/entities/app-version-policy.entity.ts create mode 100644 server/service/app-version.service.ts create mode 100644 sql/260526.sql diff --git a/server/infra/database/datasource.config.ts b/server/infra/database/datasource.config.ts index 64049db..9d92c70 100644 --- a/server/infra/database/datasource.config.ts +++ b/server/infra/database/datasource.config.ts @@ -3,6 +3,7 @@ import { SnakeNamingStrategy } from 'typeorm-naming-strategies' import { AccountEntity, AccountUserEntity, + AppVersionPolicyEntity, AnnouncementDismissEntity, AnnouncementEntity, ClubEntity, @@ -39,6 +40,7 @@ export const MAIN_DATA_SOURCE_OPTIONS: DataSourceOptions = { AccountUserEntity, ServiceUserEntity, DeviceEntity, + AppVersionPolicyEntity, AnnouncementEntity, AnnouncementDismissEntity, UserActivityLogEntity, diff --git a/server/infra/database/entities/app-version-policy.entity.ts b/server/infra/database/entities/app-version-policy.entity.ts new file mode 100644 index 0000000..db8ccc2 --- /dev/null +++ b/server/infra/database/entities/app-version-policy.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm' +import { BaseTimeStampMixin } from './TimeStampMixin' + +export type AppClientType = 'android' | 'ios' + +@Entity('app_version_policy') +export class AppVersionPolicyEntity extends BaseTimeStampMixin { + @PrimaryColumn({ type: 'varchar', length: 16, name: 'client_type' }) + clientType: AppClientType + + @Column({ type: 'varchar', length: 32, name: 'min_supported_version' }) + minSupportedVersion: string +} diff --git a/server/infra/database/entities/index.ts b/server/infra/database/entities/index.ts index 77c3032..a8ca813 100644 --- a/server/infra/database/entities/index.ts +++ b/server/infra/database/entities/index.ts @@ -3,6 +3,7 @@ export * from './user.entity' export * from './account-user.entity' export * from './service-user.entity' export * from './device.entity' +export * from './app-version-policy.entity' export * from './announcement.entity' export * from './announcement-dismiss.entity' export * from './club.entity' diff --git a/server/service/app-version.service.ts b/server/service/app-version.service.ts new file mode 100644 index 0000000..e73e1b8 --- /dev/null +++ b/server/service/app-version.service.ts @@ -0,0 +1,153 @@ +import { Repository } from 'typeorm' +import { AppVersionPolicyEntity, type AppClientType } from 'server/infra/database/entities' +import { getRedisClient } from 'server/infra/redis/client' +import { BadRequestError, NotFoundError } from 'server/domain/error' +import { InjectRepository, Service } from 'server/provider' + +const APP_VERSION_POLICY_CACHE_TTL_SECONDS = 24 * 60 * 60 +const STORE_URLS: Record = { + android: 'https://play.google.com/store/apps/details?id=com.padocorp.clubhouse.applicationId', + ios: 'https://apps.apple.com/kr/app/%ED%81%B4%EB%9F%BD%ED%95%98%EC%9A%B0%EC%8A%A4-%EC%9A%B0%EB%A6%AC-%ED%95%99%EA%B5%90-%EB%AA%A8%EB%93%A0-%EB%8F%99%EC%95%84%EB%A6%AC/id6461214029', +} + +type AppVersionPolicy = { + clientType: AppClientType + minSupportedVersion: string +} + +type AppVersionPolicyUpdate = { + clientType: AppClientType + minSupportedVersion: string +} + +type AppVersionCheckResult = AppVersionPolicy & { + updateRequired: boolean + storeUrl: string +} + +const appVersionPolicyCacheKey = (clientType: AppClientType) => + `app-version-policy:${clientType}` + +function parseVersion(version: string): number[] { + return version.split('.').map((part) => Number(part)) +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseVersion(left) + const rightParts = parseVersion(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const leftValue = leftParts[index] ?? 0 + const rightValue = rightParts[index] ?? 0 + if (leftValue !== rightValue) { + return leftValue > rightValue ? 1 : -1 + } + } + + return 0 +} + +@Service +export class AppVersionService { + @InjectRepository(AppVersionPolicyEntity) + private readonly appVersionPolicyRepository: Repository + + async checkVersion( + clientType: AppClientType, + appVersion: string, + ): Promise { + const policy = await this.getPolicy(clientType) + + return { + ...policy, + updateRequired: compareVersions(appVersion, policy.minSupportedVersion) < 0, + storeUrl: STORE_URLS[clientType], + } + } + + async upsertPolicy(policy: AppVersionPolicyUpdate): Promise { + await this.appVersionPolicyRepository.upsert(policy, { + conflictPaths: ['clientType'], + skipUpdateIfNoValuesChanged: true, + }) + await this.deleteCachedPolicy(policy.clientType) + const savedPolicy = await this.appVersionPolicyRepository.findOneBy({ + clientType: policy.clientType, + }) + if (!savedPolicy) { + throw new BadRequestError('failed to save app version policy') + } + return this.toPolicy(savedPolicy) + } + + private async getPolicy(clientType: AppClientType): Promise { + const cachedPolicy = await this.getCachedPolicy(clientType) + if (cachedPolicy) { + return cachedPolicy + } + + const policy = await this.appVersionPolicyRepository.findOneBy({ clientType }) + if (!policy) { + throw new NotFoundError('app version policy not found') + } + + const result = this.toPolicy(policy) + await this.cachePolicy(result) + return result + } + + private toPolicy(policy: AppVersionPolicyEntity): AppVersionPolicy { + return { + clientType: policy.clientType, + minSupportedVersion: policy.minSupportedVersion, + } + } + + private async getCachedPolicy(clientType: AppClientType): Promise { + try { + const redis = await getRedisClient() + if (!redis) { + return null + } + const rawPolicy = (await redis.sendCommand([ + 'GET', + appVersionPolicyCacheKey(clientType), + ])) as string | null + return rawPolicy ? (JSON.parse(rawPolicy) as AppVersionPolicy) : null + } catch (err) { + console.error('getCachedAppVersionPolicy error: ', err) + return null + } + } + + private async cachePolicy(policy: AppVersionPolicy): Promise { + try { + const redis = await getRedisClient() + if (!redis) { + return + } + await redis.sendCommand([ + 'SET', + appVersionPolicyCacheKey(policy.clientType), + JSON.stringify(policy), + 'EX', + String(APP_VERSION_POLICY_CACHE_TTL_SECONDS), + ]) + } catch (err) { + console.error('cacheAppVersionPolicy error: ', err) + } + } + + private async deleteCachedPolicy(clientType: AppClientType): Promise { + try { + const redis = await getRedisClient() + if (!redis) { + return + } + await redis.sendCommand(['DEL', appVersionPolicyCacheKey(clientType)]) + } catch (err) { + console.error('deleteCachedAppVersionPolicy error: ', err) + } + } +} diff --git a/sql/260526.sql b/sql/260526.sql new file mode 100644 index 0000000..be16929 --- /dev/null +++ b/sql/260526.sql @@ -0,0 +1,20 @@ +/* +- Android/iOS 앱 진입 시 강제 업데이트 여부를 판단하기 위한 최소 지원 버전 정책 테이블 +- client_type별로 하나의 정책만 유지한다 +*/ + +CREATE TABLE IF NOT EXISTS public.app_version_policy ( + client_type VARCHAR(16) PRIMARY KEY, + min_supported_version VARCHAR(32) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT ck_app_version_policy_client_type CHECK (client_type IN ('android', 'ios')), + CONSTRAINT ck_app_version_policy_min_supported_version + CHECK (min_supported_version ~ '^[0-9]+(\.[0-9]+){0,3}$') +); + +INSERT INTO public.app_version_policy (client_type, min_supported_version) +VALUES + ('android', '1.0.0'), + ('ios', '1.0.0') +ON CONFLICT (client_type) DO NOTHING; From 03f61fc71676050503b31f15348c486d30dcc2de Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Wed, 27 May 2026 00:08:02 +0900 Subject: [PATCH 2/3] feat: expose app version policy APIs --- pages/api/v2/app/version-policies/index.ts | 39 +++++++++++++ pages/api/v2/app/version/check.ts | 37 ++++++++++++ src/lib/openapi/register-paths.ts | 65 ++++++++++++++++++++++ src/lib/schemas/app-versions.ts | 51 +++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 pages/api/v2/app/version-policies/index.ts create mode 100644 pages/api/v2/app/version/check.ts create mode 100644 src/lib/schemas/app-versions.ts diff --git a/pages/api/v2/app/version-policies/index.ts b/pages/api/v2/app/version-policies/index.ts new file mode 100644 index 0000000..4ed0b7b --- /dev/null +++ b/pages/api/v2/app/version-policies/index.ts @@ -0,0 +1,39 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { ZodIssue, z } from 'zod' +import { BadRequestError } from 'server/domain/error' +import { Provider } from 'server/provider' +import { AppVersionService } from 'server/service/app-version.service' +import { + AppVersionPolicyResponse, + AppVersionPolicyUpdateSchema, +} from 'src/lib/schemas/app-versions' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + if (req.method === 'POST') { + const appVersionService = Provider.getService(AppVersionService) + const body = AppVersionPolicyUpdateSchema.parse(req.body) + const policy = await appVersionService.upsertPolicy(body) + + return res.status(200).json({ + success: true, + message: '앱 버전 정책이 저장되었습니다.', + data: policy, + }) + } + } catch (err) { + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } + if (err instanceof z.ZodError) { + return res.status(400).json(err.errors) + } + console.error('upsertAppVersionPolicy error: ', err) + return res.status(500).send('Internal Server Error') + } + + return res.status(405).end() +} diff --git a/pages/api/v2/app/version/check.ts b/pages/api/v2/app/version/check.ts new file mode 100644 index 0000000..65a1ae9 --- /dev/null +++ b/pages/api/v2/app/version/check.ts @@ -0,0 +1,37 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { ZodIssue, z } from 'zod' +import { Provider } from 'server/provider' +import { NotFoundError } from 'server/domain/error' +import { AppVersionService } from 'server/service/app-version.service' +import { AppVersionCheckResponse, AppVersionCheckSchema } from 'src/lib/schemas/app-versions' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + if (req.method === 'POST') { + const appVersionService = Provider.getService(AppVersionService) + const { clientType, appVersion } = AppVersionCheckSchema.parse(req.body) + const result = await appVersionService.checkVersion(clientType, appVersion) + + return res.status(200).json({ + updateRequired: result.updateRequired, + clientType: result.clientType, + minSupportedVersion: result.minSupportedVersion, + storeUrl: result.storeUrl, + }) + } + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).send('app version policy not found') + } + if (err instanceof z.ZodError) { + return res.status(400).json(err.errors) + } + console.error('checkAppVersion error: ', err) + return res.status(500).send('Internal Server Error') + } + + return res.status(405).end() +} diff --git a/src/lib/openapi/register-paths.ts b/src/lib/openapi/register-paths.ts index ac9f0c8..40fd0da 100644 --- a/src/lib/openapi/register-paths.ts +++ b/src/lib/openapi/register-paths.ts @@ -90,6 +90,12 @@ import { TestLoginResponseSchema, TestLoginSchema, } from 'src/lib/schemas/test' +import { + AppVersionCheckResponseSchema, + AppVersionCheckSchema, + AppVersionPolicyResponseSchema, + AppVersionPolicyUpdateSchema, +} from 'src/lib/schemas/app-versions' const ErrorMessageSchema = z.string() const NoContentResponse = { description: '성공적으로 처리되었습니다.' } @@ -1372,6 +1378,35 @@ registry.registerPath({ }, }) +registry.registerPath({ + method: 'post', + path: '/api/v2/app/version/check', + tags: ['App'], + summary: '앱 강제 업데이트 필요 여부 확인', + request: { + body: { + content: { + 'application/json': { + schema: AppVersionCheckSchema, + }, + }, + }, + }, + responses: { + 200: { + description: '확인 성공', + content: { + 'application/json': { + schema: AppVersionCheckResponseSchema, + }, + }, + }, + 400: validationErrorResponse, + 404: notFoundResponse, + 500: internalServerErrorResponse, + }, +}) + registry.registerPath({ method: 'get', path: '/api/v2/managers/me/clubs', @@ -1730,6 +1765,36 @@ registry.registerPath({ }, }) +registry.registerPath({ + method: 'post', + path: '/api/v2/app/version-policies', + tags: ['App'], + summary: '앱 버전 정책 저장', + description: + 'clientType별 최소 지원 버전을 저장합니다. 저장 후 해당 clientType의 Redis 캐시를 만료합니다.', + request: { + body: { + content: { + 'application/json': { + schema: AppVersionPolicyUpdateSchema, + }, + }, + }, + }, + responses: { + 200: { + description: '저장 성공', + content: { + 'application/json': { + schema: AppVersionPolicyResponseSchema, + }, + }, + }, + 400: validationErrorResponse, + 500: internalServerErrorResponse, + }, +}) + registry.registerPath({ method: 'get', path: '/api/v2/admin/clubs', diff --git a/src/lib/schemas/app-versions.ts b/src/lib/schemas/app-versions.ts new file mode 100644 index 0000000..97bcf44 --- /dev/null +++ b/src/lib/schemas/app-versions.ts @@ -0,0 +1,51 @@ +import { z } from 'src/lib/schemas/zod' + +export const AppClientTypeSchema = z.enum(['android', 'ios']).openapi('AppClientType') + +export const AppVersionStringSchema = z + .string() + .trim() + .regex(/^\d+(\.\d+){0,3}$/, 'version must be numeric dot-separated string') + .openapi('AppVersionString') + +export const AppVersionCheckSchema = z + .object({ + clientType: AppClientTypeSchema, + appVersion: AppVersionStringSchema, + }) + .openapi('AppVersionCheck') + +export type AppVersionCheck = z.infer + +export const AppVersionPolicyUpdateSchema = z + .object({ + clientType: AppClientTypeSchema, + minSupportedVersion: AppVersionStringSchema, + }) + .openapi('AppVersionPolicyUpdate') + +export type AppVersionPolicyUpdate = z.infer + +export const AppVersionCheckResponseSchema = z + .object({ + updateRequired: z.boolean(), + clientType: AppClientTypeSchema, + minSupportedVersion: AppVersionStringSchema, + storeUrl: z.string().url(), + }) + .openapi('AppVersionCheckResponse') + +export type AppVersionCheckResponse = z.infer + +export const AppVersionPolicyResponseSchema = z + .object({ + success: z.literal(true), + message: z.string(), + data: z.object({ + clientType: AppClientTypeSchema, + minSupportedVersion: AppVersionStringSchema, + }), + }) + .openapi('AppVersionPolicyResponse') + +export type AppVersionPolicyResponse = z.infer From 2e4d433d56af38eb0f0bf4c02787990a0078f711 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Wed, 27 May 2026 00:14:09 +0900 Subject: [PATCH 3/3] feat: protect app version policy updates --- pages/api/v2/app/version-policies/index.ts | 19 +++++++++++++++++++ server/ENV.ts | 3 +++ src/lib/openapi/register-paths.ts | 2 ++ src/lib/openapi/swagger.ts | 6 ++++++ 4 files changed, 30 insertions(+) diff --git a/pages/api/v2/app/version-policies/index.ts b/pages/api/v2/app/version-policies/index.ts index 4ed0b7b..5abde8f 100644 --- a/pages/api/v2/app/version-policies/index.ts +++ b/pages/api/v2/app/version-policies/index.ts @@ -1,6 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' +import { timingSafeEqual } from 'crypto' import { ZodIssue, z } from 'zod' import { BadRequestError } from 'server/domain/error' +import { ENV } from 'server/ENV' import { Provider } from 'server/provider' import { AppVersionService } from 'server/service/app-version.service' import { @@ -8,12 +10,29 @@ import { AppVersionPolicyUpdateSchema, } from 'src/lib/schemas/app-versions' +const isValidInternalApiKey = (apiKey: string | string[] | undefined): boolean => { + const expectedApiKey = ENV.APP_VERSION_POLICY.API_KEY + if (!expectedApiKey || !apiKey || Array.isArray(apiKey)) { + return false + } + + const expectedBuffer = new TextEncoder().encode(expectedApiKey) + const actualBuffer = new TextEncoder().encode(apiKey) + return ( + expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer) + ) +} + export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { try { if (req.method === 'POST') { + if (!isValidInternalApiKey(req.headers['x-internal-api-key'])) { + return res.status(401).send('unauthorized') + } + const appVersionService = Provider.getService(AppVersionService) const body = AppVersionPolicyUpdateSchema.parse(req.body) const policy = await appVersionService.upsertPolicy(body) diff --git a/server/ENV.ts b/server/ENV.ts index a64a720..7286670 100644 --- a/server/ENV.ts +++ b/server/ENV.ts @@ -23,6 +23,9 @@ export const ENV = { REDIS: { URL: REDIS_URL, }, + APP_VERSION_POLICY: { + API_KEY: process.env.APP_VERSION_POLICY_API_KEY ?? '', + }, R2: { ACCOUNT_ID: process.env.R2_ACCOUNT_ID ?? '', ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '', diff --git a/src/lib/openapi/register-paths.ts b/src/lib/openapi/register-paths.ts index 40fd0da..9dabe1b 100644 --- a/src/lib/openapi/register-paths.ts +++ b/src/lib/openapi/register-paths.ts @@ -1772,6 +1772,7 @@ registry.registerPath({ summary: '앱 버전 정책 저장', description: 'clientType별 최소 지원 버전을 저장합니다. 저장 후 해당 clientType의 Redis 캐시를 만료합니다.', + security: [{ internalApiKeyAuth: [] }], request: { body: { content: { @@ -1791,6 +1792,7 @@ registry.registerPath({ }, }, 400: validationErrorResponse, + 401: unauthorizedResponse, 500: internalServerErrorResponse, }, }) diff --git a/src/lib/openapi/swagger.ts b/src/lib/openapi/swagger.ts index 2c8f48f..dc15588 100644 --- a/src/lib/openapi/swagger.ts +++ b/src/lib/openapi/swagger.ts @@ -14,6 +14,12 @@ registry.registerComponent('securitySchemes', 'guestIdAuth', { name: 'x-guest-id', }) +registry.registerComponent('securitySchemes', 'internalApiKeyAuth', { + type: 'apiKey', + in: 'header', + name: 'x-internal-api-key', +}) + export function generateOpenApiDocument() { const generator = new OpenApiGeneratorV3(registry.definitions)