package services import ( "crypto/tls" "encoding/json" "encoding/xml" "fmt" "io" "log" "net/http" "os" "stocksearch/config" "stocksearch/models" "strings" "sync" "time" ) // normalizeStockCode 종목코드에서 6자리 숫자만 추출 (예: "018880_AL" → "018880") func normalizeStockCode(code string) string { if idx := strings.Index(code, "_"); idx >= 0 { return code[:idx] } return code } var ( dartSvcOnce sync.Once dartSvc *DartService ) // DartService DART 공시 조회 서비스 type DartService struct { httpClient *http.Client cache *CacheService apiKey string corpCodeMap map[string]string // stock_code → corp_code 인메모리 맵 corpCodeMu sync.RWMutex } // GetDartService 싱글턴 반환 func GetDartService() *DartService { dartSvcOnce.Do(func() { // opendart.fss.or.kr는 구형 TLS 암호화 스위트를 사용하므로 최소 버전을 TLS 1.0으로 설정 // Go 1.22+에서 RSA 키 교환 암호 스위트가 기본 제거됨 // opendart.fss.or.kr은 TLS_RSA_WITH_AES_128_GCM_SHA256을 사용하므로 명시적으로 추가 transport := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, //nolint:gosec CipherSuites: []uint16{ tls.TLS_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, }, }, } dartSvc = &DartService{ httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: transport, }, cache: GetCacheService(), apiKey: config.App.DartAPIKey, corpCodeMap: make(map[string]string), } // 서버 시작 시 기업코드 맵 로딩 if err := dartSvc.loadCorpCodeMap(); err != nil { log.Printf("DART 기업코드 맵 로딩 실패: %v", err) } }) return dartSvc } // loadCorpCodeMap 로컬 CORPCODE.xml 파일을 파싱하여 stock_code → corp_code 맵 구성 func (s *DartService) loadCorpCodeMap() error { f, err := os.Open("CORPCODE.xml") if err != nil { return fmt.Errorf("CORPCODE.xml 열기 실패: %w", err) } defer f.Close() type corpItem struct { CorpCode string `xml:"corp_code"` StockCode string `xml:"stock_code"` } type corpResult struct { List []corpItem `xml:"list"` } var result corpResult if err := xml.NewDecoder(f).Decode(&result); err != nil { return fmt.Errorf("CORPCODE.xml 디코딩 실패: %w", err) } s.corpCodeMu.Lock() for _, item := range result.List { sc := strings.TrimSpace(item.StockCode) cc := strings.TrimSpace(item.CorpCode) if sc != "" && cc != "" { s.corpCodeMap[sc] = cc } } s.corpCodeMu.Unlock() log.Printf("DART 기업코드 맵 로딩 완료: 총 %d건 (상장사 %d건)", len(result.List), len(s.corpCodeMap)) return nil } // GetDisclosures 종목코드로 최근 공시 10건 반환 (5분 캐시) // DART에 등록되지 않은 종목(ETF, 신규상장 등)은 빈 목록 반환 func (s *DartService) GetDisclosures(stockCode string) ([]models.Disclosure, error) { code := normalizeStockCode(stockCode) cacheKey := "disclosure:" + code if cached, ok := s.cache.Get(cacheKey); ok { return cached.([]models.Disclosure), nil } corpCode, err := s.getCorpCode(code) if err != nil { // corp_code 없음은 정상 케이스 (ETF, 신규상장 등) - 빈 목록 반환 s.cache.Set(cacheKey, []models.Disclosure{}, 30*time.Minute) return []models.Disclosure{}, nil } list, err := s.fetchList(corpCode) if err != nil { return nil, err } s.cache.Set(cacheKey, list, 5*time.Minute) return list, nil } // getCorpCode 종목코드로 DART 고유번호 반환 (인메모리 맵 조회) func (s *DartService) getCorpCode(stockCode string) (string, error) { s.corpCodeMu.RLock() corpCode, ok := s.corpCodeMap[stockCode] s.corpCodeMu.RUnlock() if ok { return corpCode, nil } return "", fmt.Errorf("corp_code 없음 (종목코드=%s, 맵 크기=%d)", stockCode, len(s.corpCodeMap)) } // fetchList DART 고유번호로 최근 공시 목록 조회 func (s *DartService) fetchList(corpCode string) ([]models.Disclosure, error) { listURL := "https://opendart.fss.or.kr/api/list.json" + "?crtfc_key=" + s.apiKey + "&corp_code=" + corpCode + "&page_no=1&page_count=10&sort=date&sort_mth=desc" resp, err := s.httpClient.Get(listURL) if err != nil { return nil, fmt.Errorf("DART list API 요청 실패: %w", err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) var result struct { Status string `json:"status"` Message string `json:"message"` List []struct { RceptNo string `json:"rcept_no"` CorpName string `json:"corp_name"` ReportNm string `json:"report_nm"` RceptDt string `json:"rcept_dt"` FlrNm string `json:"flr_nm"` } `json:"list"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("DART list API 파싱 실패: %w", err) } if result.Status != "000" && result.Status != "013" { // 013: 조회된 데이터가 없음 (정상 케이스) return nil, fmt.Errorf("DART list API 오류: %s", result.Message) } list := make([]models.Disclosure, 0, len(result.List)) for _, item := range result.List { list = append(list, models.Disclosure{ RceptNo: item.RceptNo, CorpName: item.CorpName, ReportNm: item.ReportNm, RceptDt: item.RceptDt, FlrNm: item.FlrNm, URL: "https://dart.fss.or.kr/dsaf001/main.do?rcpNo=" + item.RceptNo, Tag: tagDisclosure(item.ReportNm), }) } return list, nil } // tagDisclosure 보고서명 키워드로 이벤트 유형 태깅 func tagDisclosure(reportNm string) string { type rule struct { keywords []string tag string } // 우선순위 순서로 검사 (위에서 먼저 매칭되면 반환) rules := []rule{ {[]string{"사업보고서", "분기보고서", "반기보고서"}, "실적"}, {[]string{"유상증자"}, "유증"}, {[]string{"무상증자"}, "무증"}, {[]string{"단일판매", "공급계약", "수주"}, "수주"}, {[]string{"소송", "판결", "가처분"}, "소송"}, {[]string{"합병", "인수", "양수도", "M&A"}, "M&A"}, {[]string{"최대주주", "주요주주", "지분"}, "지분"}, {[]string{"자기주식"}, "자사주"}, {[]string{"임원", "이사회", "대표이사"}, "경영"}, {[]string{"전환사채", "신주인수권"}, "CB/BW"}, } for _, r := range rules { for _, kw := range r.keywords { if strings.Contains(reportNm, kw) { return r.tag } } } return "공시" }