251 lines
12 KiB
TypeScript
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')
|