224 lines
6.4 KiB
Go
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 "공시"
|
|
}
|