itsm_ysm/mobile/app/itmsApi.ts
itms-merge-dev efc3e66635 feat(itms): 모바일 앱 신규 (Expo, 12화면, OAuth password grant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:10:46 +09:00

251 lines
12 KiB
TypeScript

import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { encode as btoa } from 'base-64'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { API_BASE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET } from '../constants/Config'
/*
* ITMS 전용 API 클라이언트 — 레거시 OAuth2 password grant(인가서버 :11000)
* + 리소스서버 REST(:11010, 모든 엔드포인트가 POST `/xxx.do` + Bearer JWT).
*
* 실측 계약(6개 저장소 소스 분석):
* - 토큰: POST {BASE}/oauth/token?grant_type=password&username&password&ssoYn=N
* Authorization: Basic base64(clientId:secretKey). 응답 JSON 은 표준 OAuth2 필드
* + CustomTokenEnhancer 가 평탄화한 사용자 필드(userName·authorities(단일 문자열,'-'구분)·orgnztId 등).
* - 리프레시: 동일 엔드포인트 grant_type=refresh_token&refresh_token=..
* - 업무 API: POST(예외 /main/EgovHeader.do 는 PUT), body=JSON(없으면 {}),
* 응답 봉투 { data:{...}, status:200, code, message }. 실 payload 는 data.
* 목록은 data.resultList(+totCnt/resultCnt), 변경은 data.resultCd(0 ok/-1 fail)+resultMsg.
* 보안: 토큰을 console 로 출력하지 않는다. 비밀번호는 평문 전송(웹과 동일, TLS 보호) — 클라 암호화 안 함.
*/
export const ITMS_ACCESS_KEY = 'itms_access'
export const ITMS_REFRESH_KEY = 'itms_refresh'
export const ITMS_PROFILE_KEY = 'itms_profile'
// 토큰/프로필 저장: 네이티브=expo-secure-store, 웹=localStorage 폴백(SDK51 secure-store 웹 미구현).
const isWeb = Platform.OS === 'web'
async function secureGet(key: string): Promise<string | null> {
if (isWeb) { try { return globalThis.localStorage?.getItem(key) ?? null } catch { return null } }
return SecureStore.getItemAsync(key)
}
async function secureSet(key: string, value: string): Promise<void> {
if (isWeb) { try { globalThis.localStorage?.setItem(key, value) } catch { /* noop */ } return }
await SecureStore.setItemAsync(key, value)
}
async function secureDelete(key: string): Promise<void> {
if (isWeb) { try { globalThis.localStorage?.removeItem(key) } catch { /* noop */ } return }
await SecureStore.deleteItemAsync(key)
}
// ───────────────────────── 타입 ─────────────────────────
export type RowMap = Record<string, any>
/** OAuth2 토큰 응답(표준 + CustomTokenEnhancer 평탄화 커스텀 필드). */
export interface OauthTokenResponse {
access_token: string
token_type: string
refresh_token?: string
expires_in?: number
scope?: string
userName?: string
strMEM_ID?: string
strMEM_NM?: string
strMEM_EML_ADDR?: string
strMDL_TELNO?: string
strMOVE_TELNO?: string
orgnztId?: string
uniqId?: string
authorities?: string
error?: string
error_description?: string
}
/** 앱 내부에서 쓰는 정규화 프로필. */
export interface ItmsProfile {
userId: string
userNm: string
email?: string
phone?: string
orgId?: string
uniqId?: string
roles: string[]
}
/** 리소스서버 응답 봉투. */
export interface ResultData<T = RowMap> { data: T; status: number; code?: string; message?: string; totalCount?: number | null }
/** 목록 payload(data 하위). */
export interface ListPayload { resultList?: RowMap[]; totCnt?: number; resultCnt?: number; [k: string]: any }
// ───────────────────────── 인증(OAuth2) ─────────────────────────
function basicAuth(): string { return `Basic ${btoa(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`)}` }
/** authorities 단일 문자열("ROLE_ADMIN-ROLE_USER-") → 배열. */
function parseRoles(authorities?: string): string[] {
if (!authorities) return []
return authorities.split('-').map((r) => r.trim()).filter(Boolean)
}
function toProfile(tk: OauthTokenResponse): ItmsProfile {
return {
userId: tk.strMEM_ID ?? '',
userNm: tk.userName ?? tk.strMEM_NM ?? tk.strMEM_ID ?? '',
email: tk.strMEM_EML_ADDR ?? undefined,
phone: tk.strMDL_TELNO ?? tk.strMOVE_TELNO ?? undefined,
orgId: tk.orgnztId ?? undefined,
uniqId: tk.uniqId ?? undefined,
roles: parseRoles(tk.authorities),
}
}
/** 로그인 — password grant. 성공 시 토큰+프로필 저장, 프로필 반환. */
export async function itmsLogin(userId: string, password: string): Promise<ItmsProfile> {
const res = await axios.post<OauthTokenResponse>(
`${API_BASE}/oauth/token`,
null,
{
params: { grant_type: 'password', username: userId, password, ssoYn: 'N' },
headers: { Authorization: basicAuth() },
timeout: 30000,
}
)
const tk = res.data
if (tk.error || !tk.access_token) throw new Error(tk.error_description || tk.error || '로그인에 실패했습니다.')
await secureSet(ITMS_ACCESS_KEY, tk.access_token)
if (tk.refresh_token) await secureSet(ITMS_REFRESH_KEY, tk.refresh_token)
const profile = toProfile(tk)
await secureSet(ITMS_PROFILE_KEY, JSON.stringify(profile))
return profile
}
let refreshPromise: Promise<string> | null = null
async function doRefresh(): Promise<string> {
const refreshToken = await secureGet(ITMS_REFRESH_KEY)
if (!refreshToken) throw new Error('no refresh token')
const res = await axios.post<OauthTokenResponse>(
`${API_BASE}/oauth/token`,
null,
{
params: { grant_type: 'refresh_token', refresh_token: refreshToken },
headers: { Authorization: basicAuth() },
timeout: 30000,
}
)
const tk = res.data
if (!tk.access_token) throw new Error('refresh failed')
await secureSet(ITMS_ACCESS_KEY, tk.access_token)
if (tk.refresh_token) await secureSet(ITMS_REFRESH_KEY, tk.refresh_token)
return tk.access_token
}
export async function clearItmsSession(): Promise<void> {
await secureDelete(ITMS_ACCESS_KEY)
await secureDelete(ITMS_REFRESH_KEY)
await secureDelete(ITMS_PROFILE_KEY)
}
export async function hasItmsToken(): Promise<boolean> { return !!(await secureGet(ITMS_ACCESS_KEY)) }
export async function itmsProfile(): Promise<ItmsProfile | null> {
const raw = await secureGet(ITMS_PROFILE_KEY)
if (!raw) return null
try { return JSON.parse(raw) as ItmsProfile } catch { return null }
}
// ───────────────────────── 리소스서버 클라이언트 ─────────────────────────
const client = axios.create({ baseURL: API_BASE, timeout: 30000, headers: { 'Content-Type': 'application/json' } })
client.interceptors.request.use(async (cfg: InternalAxiosRequestConfig) => {
const token = await secureGet(ITMS_ACCESS_KEY)
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
client.interceptors.response.use(
(r: AxiosResponse) => r,
async (error: AxiosError) => {
const original = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined
const status = error.response?.status
if (status === 401 && original && !original._retry) {
original._retry = true
try {
if (!refreshPromise) refreshPromise = doRefresh().finally(() => { refreshPromise = null })
const newToken = await refreshPromise
original.headers.Authorization = `Bearer ${newToken}`
return client(original)
} catch {
await clearItmsSession()
return Promise.reject(error)
}
}
if (status === 401) await clearItmsSession()
return Promise.reject(error)
}
)
/** POST `/xxx.do` → data payload 언래핑(없으면 빈 객체). */
export async function apiPost<T = RowMap>(path: string, body: RowMap = {}): Promise<T> {
const r = await client.post<ResultData<T>>(path, body)
return (r.data?.data ?? ({} as T))
}
/** PUT `/xxx.do`(예: EgovHeader) → data 언래핑. */
export async function apiPut<T = RowMap>(path: string, body: RowMap = {}): Promise<T> {
const r = await client.put<ResultData<T>>(path, body)
return (r.data?.data ?? ({} as T))
}
/** 목록 payload → resultList 배열 평탄화(방어적). */
export function listRows(p: ListPayload | RowMap | null | undefined): RowMap[] {
const d: any = p
if (Array.isArray(d)) return d as RowMap[]
if (d && Array.isArray(d.resultList)) return d.resultList as RowMap[]
return []
}
export function itmsErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as any
if (typeof data?.error_description === 'string') return data.error_description
if (typeof data?.message === 'string' && data.message !== 'Success') return data.message
if (error.response?.status === 401) return '인증이 만료되었습니다. 다시 로그인해 주세요.'
if (error.message) return error.message
}
if (error instanceof Error) return error.message
return '요청 처리 중 오류가 발생했습니다.'
}
// ───────────────────────── 업무 엔드포인트(MVP) ─────────────────────────
// 공통 페이징(eGov 계열 추정 — 서버가 무시하면 기본 목록 반환). 필터는 caller 가 스프레드.
const paging = { pageIndex: 1, pageUnit: 20, pageSize: 20 }
// 홈 대시보드(HomeController — user 는 토큰에서 도출, body {})
export const itmsMyIncidents = () => apiPost<ListPayload>('/srm/getMyIncidents.do')
export const itmsMyReports = () => apiPost<ListPayload>('/srm/getMyReports.do')
export const itmsMyTodo = () => apiPost<ListPayload>('/srm/getMyTodo.do')
export const itmsResourceStat = () => apiPost<ListPayload>('/sta/getResouceTotalStat.do')
/** 헤더 메뉴(PUT). 실패 시 화면은 폴백 처리. */
export const itmsHeaderMenu = () => apiPut<RowMap>('/main/EgovHeader.do')
// 내 서비스 요청(SvcDeskReqController)
export const itmsSearchReqIncidents = (filter: RowMap = {}) => apiPost<ListPayload>('/srm/searchReqIncident.do', { ...paging, ...filter })
export const itmsReqIncidentDetail = (vcReqIncidentNum: string | number) => apiPost<RowMap>('/srm/getReqIncidentBySeq.do', { vcReqIncidentNum })
export interface ReqIncidentCreate { incidentReqVo: RowMap; fileList?: RowMap[] }
export const itmsAddReqIncident = (body: ReqIncidentCreate) => apiPost<RowMap>('/srm/addReqIncident.do', body)
// 서비스데스크 인시던트(SvcDeskController — 담당자/관리자)
export const itmsSearchIncidents = (filter: RowMap = {}) => apiPost<ListPayload>('/srm/searchIncident.do', { ...paging, ...filter })
export const itmsIncidentDetail = (inIncidentSeq: number) => apiPost<RowMap>('/srm/getIncidentBySeq.do', { inIncidentSeq })
export const itmsServiceCombo = () => apiPost<ListPayload>('/srm/getServiceCombo.do')
// 공지/게시판(BBSAdminManageController)
export const itmsBoardList = (boardVO: RowMap = {}) => apiPost<ListPayload>('/bbs/admin/selectBoardList.do', { boardVO: { ...paging, ...boardVO } })
export const itmsBoardArticle = (board: RowMap) => apiPost<RowMap>('/bbs/admin/selectBoardArticle.do', { board })
// 자산(ResourceManagerController)
export const itmsSearchAssets = (filter: RowMap = {}) => apiPost<ListPayload>('/rem/searchAsset.do', { ...paging, ...filter })
export const itmsAssetInfo = (params: RowMap) => apiPost<RowMap>('/rem/getAssetInfo.do', params)
// 공통 코드(CmmUseController) — codeId 콤마목록
export const itmsCmmCodes = (codeId: string) => apiPost<RowMap>('/cmm/selectCmmCodeDetail.do', { codeId })
// 내 정보(UserManageController) — 로그인 로그(permitAll)
export const itmsUserView = () => apiPost<RowMap>('/sys/UserSelectUpdtView.do')