Files
stocksearch/services/dart_service.go
2026-03-31 19:32:59 +09:00

224 lines
6.4 KiB
Go

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 "공시"
}