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 { if (isWeb) { try { return globalThis.localStorage?.getItem(key) ?? null } catch { return null } } return SecureStore.getItemAsync(key) } async function secureSet(key: string, value: string): Promise { if (isWeb) { try { globalThis.localStorage?.setItem(key, value) } catch { /* noop */ } return } await SecureStore.setItemAsync(key, value) } async function secureDelete(key: string): Promise { if (isWeb) { try { globalThis.localStorage?.removeItem(key) } catch { /* noop */ } return } await SecureStore.deleteItemAsync(key) } // ───────────────────────── 타입 ───────────────────────── export type RowMap = Record /** 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 { 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 { const res = await axios.post( `${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 | null = null async function doRefresh(): Promise { const refreshToken = await secureGet(ITMS_REFRESH_KEY) if (!refreshToken) throw new Error('no refresh token') const res = await axios.post( `${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 { await secureDelete(ITMS_ACCESS_KEY) await secureDelete(ITMS_REFRESH_KEY) await secureDelete(ITMS_PROFILE_KEY) } export async function hasItmsToken(): Promise { return !!(await secureGet(ITMS_ACCESS_KEY)) } export async function itmsProfile(): Promise { 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(path: string, body: RowMap = {}): Promise { const r = await client.post>(path, body) return (r.data?.data ?? ({} as T)) } /** PUT `/xxx.do`(예: EgovHeader) → data 언래핑. */ export async function apiPut(path: string, body: RowMap = {}): Promise { const r = await client.put>(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('/srm/getMyIncidents.do') export const itmsMyReports = () => apiPost('/srm/getMyReports.do') export const itmsMyTodo = () => apiPost('/srm/getMyTodo.do') export const itmsResourceStat = () => apiPost('/sta/getResouceTotalStat.do') /** 헤더 메뉴(PUT). 실패 시 화면은 폴백 처리. */ export const itmsHeaderMenu = () => apiPut('/main/EgovHeader.do') // 내 서비스 요청(SvcDeskReqController) export const itmsSearchReqIncidents = (filter: RowMap = {}) => apiPost('/srm/searchReqIncident.do', { ...paging, ...filter }) export const itmsReqIncidentDetail = (vcReqIncidentNum: string | number) => apiPost('/srm/getReqIncidentBySeq.do', { vcReqIncidentNum }) export interface ReqIncidentCreate { incidentReqVo: RowMap; fileList?: RowMap[] } export const itmsAddReqIncident = (body: ReqIncidentCreate) => apiPost('/srm/addReqIncident.do', body) // 서비스데스크 인시던트(SvcDeskController — 담당자/관리자) export const itmsSearchIncidents = (filter: RowMap = {}) => apiPost('/srm/searchIncident.do', { ...paging, ...filter }) export const itmsIncidentDetail = (inIncidentSeq: number) => apiPost('/srm/getIncidentBySeq.do', { inIncidentSeq }) export const itmsServiceCombo = () => apiPost('/srm/getServiceCombo.do') // 공지/게시판(BBSAdminManageController) export const itmsBoardList = (boardVO: RowMap = {}) => apiPost('/bbs/admin/selectBoardList.do', { boardVO: { ...paging, ...boardVO } }) export const itmsBoardArticle = (board: RowMap) => apiPost('/bbs/admin/selectBoardArticle.do', { board }) // 자산(ResourceManagerController) export const itmsSearchAssets = (filter: RowMap = {}) => apiPost('/rem/searchAsset.do', { ...paging, ...filter }) export const itmsAssetInfo = (params: RowMap) => apiPost('/rem/getAssetInfo.do', params) // 공통 코드(CmmUseController) — codeId 콤마목록 export const itmsCmmCodes = (codeId: string) => apiPost('/cmm/selectCmmCodeDetail.do', { codeId }) // 내 정보(UserManageController) — 로그인 로그(permitAll) export const itmsUserView = () => apiPost('/sys/UserSelectUpdtView.do')