first commit
This commit is contained in:
115
handlers/auth_handler.go
Normal file
115
handlers/auth_handler.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"stocksearch/config"
|
||||
"stocksearch/middleware"
|
||||
"stocksearch/services"
|
||||
)
|
||||
|
||||
// AuthHandler 로그인/로그아웃 핸들러
|
||||
type AuthHandler struct {
|
||||
sessionSvc *services.SessionService
|
||||
loginTmpl *template.Template
|
||||
}
|
||||
|
||||
// NewAuthHandler 인증 핸들러 초기화
|
||||
func NewAuthHandler(sessionSvc *services.SessionService) *AuthHandler {
|
||||
tmpl, err := template.ParseFiles("templates/pages/login.html")
|
||||
if err != nil {
|
||||
log.Fatalf("로그인 템플릿 파싱 실패: %v", err)
|
||||
}
|
||||
return &AuthHandler{
|
||||
sessionSvc: sessionSvc,
|
||||
loginTmpl: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginPage GET /login — 로그인 폼 렌더링
|
||||
func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
// 이미 로그인된 경우 메인으로 리다이렉트
|
||||
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
||||
if h.sessionSvc.Validate(cookie.Value) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
// next 파라미터가 없으면 로그인 후 메인 페이지로
|
||||
next := r.URL.Query().Get("next")
|
||||
if next == "" {
|
||||
next = "/"
|
||||
}
|
||||
data := map[string]string{
|
||||
"Next": next,
|
||||
"Error": "",
|
||||
}
|
||||
h.renderLogin(w, data)
|
||||
}
|
||||
|
||||
// Login POST /login — ID/PW 검증 후 세션 발급
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "잘못된 요청입니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.FormValue("id")
|
||||
password := r.FormValue("password")
|
||||
next := r.FormValue("next")
|
||||
if next == "" {
|
||||
next = "/"
|
||||
}
|
||||
|
||||
// ID/PW 검증
|
||||
if id != config.App.AdminID || password != config.App.AdminPassword {
|
||||
data := map[string]string{
|
||||
"Next": next,
|
||||
"Error": "아이디 또는 비밀번호가 올바르지 않습니다.",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderLogin(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
// 세션 생성 및 쿠키 설정
|
||||
sessionID := h.sessionSvc.Create()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: middleware.SessionCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400, // 24시간
|
||||
})
|
||||
|
||||
http.Redirect(w, r, next, http.StatusFound)
|
||||
}
|
||||
|
||||
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
||||
h.sessionSvc.Delete(cookie.Value)
|
||||
}
|
||||
|
||||
// 쿠키 만료 처리
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: middleware.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// renderLogin 로그인 템플릿 렌더링 헬퍼
|
||||
func (h *AuthHandler) renderLogin(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.loginTmpl.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||
log.Printf("로그인 템플릿 렌더링 실패: %v", err)
|
||||
http.Error(w, "페이지를 표시할 수 없습니다.", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
148
handlers/autotrade_handler.go
Normal file
148
handlers/autotrade_handler.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"stocksearch/models"
|
||||
"stocksearch/services"
|
||||
)
|
||||
|
||||
// AutoTradeHandler 자동매매 REST API 핸들러
|
||||
type AutoTradeHandler struct {
|
||||
svc *services.AutoTradeService
|
||||
}
|
||||
|
||||
// NewAutoTradeHandler 핸들러 초기화
|
||||
func NewAutoTradeHandler(svc *services.AutoTradeService) *AutoTradeHandler {
|
||||
return &AutoTradeHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GetStatus GET /api/autotrade/status — 엔진 상태 + 오늘 통계
|
||||
func (h *AutoTradeHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
tradeCount, totalPL := h.svc.GetStats()
|
||||
activePositions := 0
|
||||
for _, p := range h.svc.GetPositions() {
|
||||
if p.Status == "pending" || p.Status == "open" {
|
||||
activePositions++
|
||||
}
|
||||
}
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"running": h.svc.IsRunning(),
|
||||
"activePositions": activePositions,
|
||||
"tradeCount": tradeCount,
|
||||
"totalPL": totalPL,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRules GET /api/autotrade/rules — 규칙 목록
|
||||
func (h *AutoTradeHandler) GetRules(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, h.svc.GetRules())
|
||||
}
|
||||
|
||||
// AddRule POST /api/autotrade/rules — 규칙 추가
|
||||
func (h *AutoTradeHandler) AddRule(w http.ResponseWriter, r *http.Request) {
|
||||
var rule models.AutoTradeRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created := h.svc.AddRule(rule)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonResponse(w, created)
|
||||
}
|
||||
|
||||
// UpdateRule PUT /api/autotrade/rules/{id} — 규칙 수정
|
||||
func (h *AutoTradeHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var rule models.AutoTradeRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.svc.UpdateRule(id, rule) {
|
||||
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonResponse(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteRule DELETE /api/autotrade/rules/{id} — 규칙 삭제
|
||||
func (h *AutoTradeHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !h.svc.DeleteRule(id) {
|
||||
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonResponse(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ToggleRule POST /api/autotrade/rules/{id}/toggle — 규칙 ON/OFF
|
||||
func (h *AutoTradeHandler) ToggleRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
ok, enabled := h.svc.ToggleRule(id)
|
||||
if !ok {
|
||||
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonResponse(w, map[string]bool{"enabled": enabled})
|
||||
}
|
||||
|
||||
// GetPositions GET /api/autotrade/positions — 포지션 목록
|
||||
func (h *AutoTradeHandler) GetPositions(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, h.svc.GetPositions())
|
||||
}
|
||||
|
||||
// GetLogs GET /api/autotrade/logs — 최근 로그 (?level=action 이면 debug 제외)
|
||||
func (h *AutoTradeHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logs := h.svc.GetLogs()
|
||||
if r.URL.Query().Get("level") == "action" {
|
||||
filtered := logs[:0:0]
|
||||
for _, l := range logs {
|
||||
if l.Level != "debug" {
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
}
|
||||
logs = filtered
|
||||
}
|
||||
jsonResponse(w, logs)
|
||||
}
|
||||
|
||||
// GetWatchSource GET /api/autotrade/watch-source — 감시 소스 조회
|
||||
func (h *AutoTradeHandler) GetWatchSource(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, h.svc.GetWatchSource())
|
||||
}
|
||||
|
||||
// SetWatchSource PUT /api/autotrade/watch-source — 감시 소스 설정
|
||||
func (h *AutoTradeHandler) SetWatchSource(w http.ResponseWriter, r *http.Request) {
|
||||
var ws models.AutoTradeWatchSource
|
||||
if err := json.NewDecoder(r.Body).Decode(&ws); err != nil {
|
||||
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.svc.SetWatchSource(ws)
|
||||
jsonResponse(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// Start POST /api/autotrade/start — 엔진 시작
|
||||
func (h *AutoTradeHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
h.svc.Start()
|
||||
jsonResponse(w, map[string]bool{"running": true})
|
||||
}
|
||||
|
||||
// Stop POST /api/autotrade/stop — 엔진 중지
|
||||
func (h *AutoTradeHandler) Stop(w http.ResponseWriter, r *http.Request) {
|
||||
h.svc.Stop()
|
||||
jsonResponse(w, map[string]bool{"running": false})
|
||||
}
|
||||
|
||||
// Emergency POST /api/autotrade/emergency — 긴급 전량 청산
|
||||
func (h *AutoTradeHandler) Emergency(w http.ResponseWriter, r *http.Request) {
|
||||
h.svc.EmergencyStop()
|
||||
jsonResponse(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// jsonResponse JSON 응답 헬퍼
|
||||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
186
handlers/order_handler.go
Normal file
186
handlers/order_handler.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"stocksearch/services"
|
||||
)
|
||||
|
||||
// OrderHandler 주문/계좌 REST API 핸들러
|
||||
type OrderHandler struct {
|
||||
orderSvc *services.OrderService
|
||||
accountSvc *services.AccountService
|
||||
}
|
||||
|
||||
// NewOrderHandler 핸들러 초기화
|
||||
func NewOrderHandler() *OrderHandler {
|
||||
return &OrderHandler{
|
||||
orderSvc: services.GetOrderService(),
|
||||
accountSvc: services.GetAccountService(),
|
||||
}
|
||||
}
|
||||
|
||||
// Buy POST /api/order/buy — 매수주문 (kt10000)
|
||||
func (h *OrderHandler) Buy(w http.ResponseWriter, r *http.Request) {
|
||||
var req services.OrderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Exchange == "" {
|
||||
req.Exchange = "KRX"
|
||||
}
|
||||
if req.TradeTP == "" {
|
||||
req.TradeTP = "0"
|
||||
}
|
||||
|
||||
result, err := h.orderSvc.Buy(req)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// Sell POST /api/order/sell — 매도주문 (kt10001)
|
||||
func (h *OrderHandler) Sell(w http.ResponseWriter, r *http.Request) {
|
||||
var req services.OrderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Exchange == "" {
|
||||
req.Exchange = "KRX"
|
||||
}
|
||||
if req.TradeTP == "" {
|
||||
req.TradeTP = "0"
|
||||
}
|
||||
|
||||
result, err := h.orderSvc.Sell(req)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// Modify PUT /api/order/modify — 정정주문 (kt10002)
|
||||
func (h *OrderHandler) Modify(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Exchange string `json:"exchange"`
|
||||
OrigOrdNo string `json:"origOrdNo"`
|
||||
Code string `json:"code"`
|
||||
Qty string `json:"qty"`
|
||||
Price string `json:"price"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Exchange == "" {
|
||||
req.Exchange = "KRX"
|
||||
}
|
||||
|
||||
result, err := h.orderSvc.Modify(req.Exchange, req.OrigOrdNo, req.Code, req.Qty, req.Price)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// Cancel DELETE /api/order — 취소주문 (kt10003)
|
||||
func (h *OrderHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Exchange string `json:"exchange"`
|
||||
OrigOrdNo string `json:"origOrdNo"`
|
||||
Code string `json:"code"`
|
||||
Qty string `json:"qty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Exchange == "" {
|
||||
req.Exchange = "KRX"
|
||||
}
|
||||
if req.Qty == "" {
|
||||
req.Qty = "0" // 전량취소
|
||||
}
|
||||
|
||||
result, err := h.orderSvc.Cancel(req.Exchange, req.OrigOrdNo, req.Code, req.Qty)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// GetBalance GET /api/account/balance — 계좌 잔고 (kt00018)
|
||||
func (h *OrderHandler) GetBalance(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.accountSvc.GetBalance()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// GetPending GET /api/account/pending — 미체결 주문 (ka10075)
|
||||
func (h *OrderHandler) GetPending(w http.ResponseWriter, r *http.Request) {
|
||||
orders, err := h.accountSvc.GetPendingOrders()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []services.PendingOrder{}
|
||||
}
|
||||
jsonOK(w, orders)
|
||||
}
|
||||
|
||||
// GetHistory GET /api/account/history — 체결내역 (ka10076)
|
||||
func (h *OrderHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
|
||||
history, err := h.accountSvc.GetOrderHistory()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if history == nil {
|
||||
history = []services.OrderHistory{}
|
||||
}
|
||||
jsonOK(w, history)
|
||||
}
|
||||
|
||||
// GetDeposit GET /api/account/deposit — 예수금 상세 조회 (kt00001)
|
||||
func (h *OrderHandler) GetDeposit(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.accountSvc.GetDepositDetail()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// GetOrderable GET /api/account/orderable?code=&price=&side= — 주문가능금액/수량 (kt00010)
|
||||
func (h *OrderHandler) GetOrderable(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
price := r.URL.Query().Get("price")
|
||||
side := r.URL.Query().Get("side") // buy 또는 sell
|
||||
if price == "" {
|
||||
price = "0"
|
||||
}
|
||||
|
||||
// 키움 API: 1=매도, 2=매수
|
||||
tradeTp := "2"
|
||||
if side == "sell" {
|
||||
tradeTp = "1"
|
||||
}
|
||||
|
||||
result, err := h.accountSvc.GetOrderable(code, price, tradeTp)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
213
handlers/page_handler.go
Normal file
213
handlers/page_handler.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"stocksearch/middleware"
|
||||
"stocksearch/services"
|
||||
)
|
||||
|
||||
// PageHandler HTML 페이지 렌더링 핸들러
|
||||
type PageHandler struct {
|
||||
// 페이지별 독립 템플릿 세트 (content 블록 충돌 방지)
|
||||
pageTemplates map[string]*template.Template
|
||||
stockService *services.StockService
|
||||
}
|
||||
|
||||
// NewPageHandler 페이지별 템플릿을 분리 파싱하여 핸들러 초기화
|
||||
func NewPageHandler() *PageHandler {
|
||||
base := "templates/layout/base.html"
|
||||
pages := []string{"index.html", "stock_detail.html", "theme.html", "kospi200.html", "asset.html", "autotrade.html"}
|
||||
|
||||
fns := templateFuncs()
|
||||
tmplMap := make(map[string]*template.Template, len(pages))
|
||||
for _, page := range pages {
|
||||
tmplMap[page] = template.Must(
|
||||
template.New("").Funcs(fns).ParseFiles(base, "templates/pages/"+page),
|
||||
)
|
||||
}
|
||||
return &PageHandler{
|
||||
pageTemplates: tmplMap,
|
||||
stockService: services.GetStockService(),
|
||||
}
|
||||
}
|
||||
|
||||
// IndexPage GET / - 메인 페이지
|
||||
func (h *PageHandler) IndexPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": "주식 시세",
|
||||
"ActiveMenu": "시세",
|
||||
})
|
||||
h.render(w, "index.html", data)
|
||||
}
|
||||
|
||||
// ThemePage GET /theme - 테마 분석 페이지
|
||||
func (h *PageHandler) ThemePage(w http.ResponseWriter, r *http.Request) {
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": "테마 분석 - 주식 시세",
|
||||
"ActiveMenu": "테마",
|
||||
})
|
||||
h.render(w, "theme.html", data)
|
||||
}
|
||||
|
||||
// Kospi200Page GET /kospi200 - 코스피200 종목 페이지
|
||||
func (h *PageHandler) Kospi200Page(w http.ResponseWriter, r *http.Request) {
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": "코스피200 - 주식 시세",
|
||||
"ActiveMenu": "코스피200",
|
||||
})
|
||||
h.render(w, "kospi200.html", data)
|
||||
}
|
||||
|
||||
// AssetPage GET /asset - 자산 현황 페이지
|
||||
func (h *PageHandler) AssetPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": "자산 현황 - 주식 시세",
|
||||
"ActiveMenu": "자산",
|
||||
})
|
||||
h.render(w, "asset.html", data)
|
||||
}
|
||||
|
||||
// AutoTradePage GET /autotrade - 자동매매 페이지
|
||||
func (h *PageHandler) AutoTradePage(w http.ResponseWriter, r *http.Request) {
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": "자동매매 - 주식 시세",
|
||||
"ActiveMenu": "자동매매",
|
||||
})
|
||||
h.render(w, "autotrade.html", data)
|
||||
}
|
||||
|
||||
// StockDetailPage GET /stock/{code} - 종목 상세 페이지
|
||||
func (h *PageHandler) StockDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
|
||||
price, err := h.stockService.GetCurrentPrice(code)
|
||||
if err != nil {
|
||||
log.Printf("현재가 조회 실패 [%s]: %v", code, err)
|
||||
http.Error(w, "종목 정보를 불러올 수 없습니다.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := withLoggedIn(r, map[string]interface{}{
|
||||
"Title": price.Name + " - 주식 시세",
|
||||
"Stock": price,
|
||||
"ActiveMenu": "",
|
||||
})
|
||||
|
||||
h.render(w, "stock_detail.html", data)
|
||||
}
|
||||
|
||||
// withLoggedIn 템플릿 데이터에 LoggedIn 필드 추가
|
||||
func withLoggedIn(r *http.Request, data map[string]interface{}) map[string]interface{} {
|
||||
data["LoggedIn"] = middleware.IsLoggedIn(r)
|
||||
return data
|
||||
}
|
||||
|
||||
// render 템플릿 렌더링 헬퍼
|
||||
func (h *PageHandler) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
tmpl, ok := h.pageTemplates[name]
|
||||
if !ok {
|
||||
http.Error(w, "페이지를 찾을 수 없습니다.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
log.Printf("템플릿 렌더링 실패 [%s]: %v", name, err)
|
||||
http.Error(w, "페이지를 표시할 수 없습니다.", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// templateFuncs 템플릿에서 사용할 커스텀 함수
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// 가격 포맷 (천 단위 콤마)
|
||||
"formatPrice": func(n int64) string {
|
||||
return formatNumber(n)
|
||||
},
|
||||
// 등락률 부호 포함 문자열
|
||||
"formatRate": func(f float64) string {
|
||||
if f >= 0 {
|
||||
return "+" + formatFloat(f) + "%"
|
||||
}
|
||||
return formatFloat(f) + "%"
|
||||
},
|
||||
// 등락에 따른 CSS 색상 클래스
|
||||
"priceClass": func(f float64) string {
|
||||
if f > 0 {
|
||||
return "text-red-500" // 상승: 빨강 (한국 관행)
|
||||
} else if f < 0 {
|
||||
return "text-blue-500" // 하락: 파랑 (한국 관행)
|
||||
}
|
||||
return "text-gray-500"
|
||||
},
|
||||
// 체결강도 CSS 클래스 (100 기준)
|
||||
"cntrClass": func(f float64) string {
|
||||
if f > 100 {
|
||||
return "text-red-500"
|
||||
} else if f < 100 && f > 0 {
|
||||
return "text-blue-500"
|
||||
}
|
||||
return "text-gray-400"
|
||||
},
|
||||
// 체결강도 포맷
|
||||
"formatCntr": func(f float64) string {
|
||||
return formatFloat(f)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatNumber(n int64) string {
|
||||
s := ""
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
for i, c := range reverse(itoa(n)) {
|
||||
if i > 0 && i%3 == 0 {
|
||||
s = "," + s
|
||||
}
|
||||
s = string(c) + s
|
||||
}
|
||||
if negative {
|
||||
s = "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func formatFloat(f float64) string {
|
||||
if f < 0 {
|
||||
f = -f
|
||||
}
|
||||
// 소수점 2자리
|
||||
result := ""
|
||||
intPart := int64(f)
|
||||
fracPart := int64((f - float64(intPart)) * 100)
|
||||
result = itoa(intPart)
|
||||
if fracPart < 10 {
|
||||
result += ".0" + itoa(fracPart)
|
||||
} else {
|
||||
result += "." + itoa(fracPart)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
s := ""
|
||||
for n > 0 {
|
||||
s = string(rune('0'+n%10)) + s
|
||||
n /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func reverse(s string) string {
|
||||
r := []rune(s)
|
||||
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
|
||||
r[i], r[j] = r[j], r[i]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
288
handlers/stock_handler.go
Normal file
288
handlers/stock_handler.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"stocksearch/models"
|
||||
"stocksearch/services"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StockHandler JSON REST API 핸들러
|
||||
type StockHandler struct {
|
||||
stockService *services.StockService
|
||||
scannerService *services.ScannerService
|
||||
indexService *services.IndexService
|
||||
searchService *services.SearchService
|
||||
dartService *services.DartService
|
||||
newsService *services.NewsService
|
||||
themeService *services.ThemeService
|
||||
kospi200Service *services.Kospi200Service
|
||||
watchlistSvc *services.WatchlistService
|
||||
}
|
||||
|
||||
// NewStockHandler 핸들러 초기화
|
||||
func NewStockHandler(watchlistSvc *services.WatchlistService) *StockHandler {
|
||||
return &StockHandler{
|
||||
stockService: services.GetStockService(),
|
||||
scannerService: services.GetScannerService(),
|
||||
indexService: services.GetIndexService(),
|
||||
searchService: services.GetSearchService(),
|
||||
dartService: services.GetDartService(),
|
||||
newsService: services.GetNewsService(),
|
||||
themeService: services.GetThemeService(),
|
||||
kospi200Service: services.GetKospi200Service(),
|
||||
watchlistSvc: watchlistSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentPrice GET /api/stock/{code} - 주식 현재가 JSON 반환
|
||||
func (h *StockHandler) GetCurrentPrice(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.TrimPrefix(r.PathValue("code"), "")
|
||||
if code == "" {
|
||||
jsonError(w, "종목코드가 필요합니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
price, err := h.stockService.GetCurrentPrice(code)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, price)
|
||||
}
|
||||
|
||||
// GetChart GET /api/stock/{code}/chart?period=daily|minute1|minute5 - 차트 데이터 JSON 반환
|
||||
func (h *StockHandler) GetDailyChart(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
jsonError(w, "종목코드가 필요합니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
var (
|
||||
candles []models.CandleData
|
||||
err error
|
||||
)
|
||||
|
||||
switch period {
|
||||
case "minute1":
|
||||
candles, err = h.stockService.GetMinuteChart(code, 1)
|
||||
case "minute5":
|
||||
candles, err = h.stockService.GetMinuteChart(code, 5)
|
||||
default:
|
||||
candles, err = h.stockService.GetDailyChart(code)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, candles)
|
||||
}
|
||||
|
||||
// GetScannerStatus GET /api/scanner/status - 스캐너 활성화 상태 반환
|
||||
func (h *StockHandler) GetScannerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, map[string]bool{"enabled": h.scannerService.IsEnabled()})
|
||||
}
|
||||
|
||||
// ToggleScanner POST /api/scanner/toggle - 스캐너 ON/OFF 토글 후 새 상태 반환
|
||||
func (h *StockHandler) ToggleScanner(w http.ResponseWriter, r *http.Request) {
|
||||
next := !h.scannerService.IsEnabled()
|
||||
h.scannerService.SetEnabled(next)
|
||||
jsonOK(w, map[string]bool{"enabled": next})
|
||||
}
|
||||
|
||||
// GetSignals GET /api/signal - 체결강도 상승 감지 시그널 종목 JSON 반환
|
||||
func (h *StockHandler) GetSignals(w http.ResponseWriter, r *http.Request) {
|
||||
signals := h.scannerService.GetSignals()
|
||||
jsonOK(w, signals)
|
||||
}
|
||||
|
||||
// GetWatchlistSignals GET /api/watchlist-signal?codes=005930,000660,... - 관심종목 복합 분석 JSON 반환
|
||||
func (h *StockHandler) GetWatchlistSignals(w http.ResponseWriter, r *http.Request) {
|
||||
raw := r.URL.Query().Get("codes")
|
||||
if raw == "" {
|
||||
jsonOK(w, []services.SignalStock{})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
// 최대 20개 제한
|
||||
codes := make([]string, 0, 20)
|
||||
for _, c := range parts {
|
||||
c = strings.TrimSpace(c)
|
||||
if len(c) == 6 {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
if len(codes) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
jsonOK(w, []services.SignalStock{})
|
||||
return
|
||||
}
|
||||
signals := h.scannerService.AnalyzeWatchlist(codes)
|
||||
if signals == nil {
|
||||
signals = []services.SignalStock{}
|
||||
}
|
||||
jsonOK(w, signals)
|
||||
}
|
||||
|
||||
// Search GET /api/search?q=... - 종목명·코드 검색 결과 JSON 반환
|
||||
func (h *StockHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
results := h.searchService.Search(q)
|
||||
if results == nil {
|
||||
results = []services.StockItem{}
|
||||
}
|
||||
jsonOK(w, results)
|
||||
}
|
||||
|
||||
// GetIndices GET /api/indices - 코스피·코스닥·다우·나스닥 지수 JSON 반환
|
||||
func (h *StockHandler) GetIndices(w http.ResponseWriter, r *http.Request) {
|
||||
quotes, err := h.indexService.GetIndices()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, quotes)
|
||||
}
|
||||
|
||||
// GetNews GET /api/news?name={stockName} - 종목 관련 최근 뉴스 JSON 반환
|
||||
func (h *StockHandler) GetNews(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
jsonError(w, "종목명이 필요합니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
news, err := h.newsService.GetNews(name)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, news)
|
||||
}
|
||||
|
||||
// GetDisclosures GET /api/disclosure?code={stockCode} - DART 최근 공시 JSON 반환
|
||||
func (h *StockHandler) GetDisclosures(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
if len(code) != 6 {
|
||||
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
disclosures, err := h.dartService.GetDisclosures(code)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, disclosures)
|
||||
}
|
||||
|
||||
// GetKospi200 GET /api/kospi200 - 코스피200 구성종목 JSON 반환
|
||||
func (h *StockHandler) GetKospi200(w http.ResponseWriter, r *http.Request) {
|
||||
stocks, err := h.kospi200Service.GetStocks()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, stocks)
|
||||
}
|
||||
|
||||
// GetThemes GET /api/themes?date=1&sort=3 - 테마그룹 목록 JSON 반환
|
||||
// sort: 3=상위등락률(기본), 1=상위기간수익률
|
||||
func (h *StockHandler) GetThemes(w http.ResponseWriter, r *http.Request) {
|
||||
dateTp := r.URL.Query().Get("date")
|
||||
if dateTp == "" {
|
||||
dateTp = "1"
|
||||
}
|
||||
sortTp := r.URL.Query().Get("sort")
|
||||
if sortTp == "" {
|
||||
sortTp = "3"
|
||||
}
|
||||
groups, err := h.themeService.GetThemes(dateTp, sortTp)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, groups)
|
||||
}
|
||||
|
||||
// GetThemeStocks GET /api/themes/{code}?date=1 - 테마구성종목 JSON 반환
|
||||
func (h *StockHandler) GetThemeStocks(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
jsonError(w, "테마코드가 필요합니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dateTp := r.URL.Query().Get("date")
|
||||
if dateTp == "" {
|
||||
dateTp = "1"
|
||||
}
|
||||
detail, err := h.themeService.GetThemeStocks(code, dateTp)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, detail)
|
||||
}
|
||||
|
||||
// GetWatchlist GET /api/watchlist — 관심종목 목록 JSON 반환
|
||||
func (h *StockHandler) GetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
list := h.watchlistSvc.GetAll()
|
||||
if list == nil {
|
||||
list = []services.WatchlistItem{}
|
||||
}
|
||||
jsonOK(w, list)
|
||||
}
|
||||
|
||||
// AddWatchlist POST /api/watchlist — body: {code, name} 관심종목 추가
|
||||
func (h *StockHandler) AddWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "잘못된 요청입니다.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Code) != 6 {
|
||||
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.watchlistSvc.Add(body.Code, body.Name); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// RemoveWatchlist DELETE /api/watchlist/{code} — 관심종목 삭제
|
||||
func (h *StockHandler) RemoveWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if len(code) != 6 {
|
||||
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.watchlistSvc.Remove(code); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- JSON 응답 헬퍼 ---
|
||||
|
||||
func jsonOK(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, message string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
21
handlers/websocket_handler.go
Normal file
21
handlers/websocket_handler.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
ws "stocksearch/websocket"
|
||||
)
|
||||
|
||||
// WSHandler WebSocket 핸들러
|
||||
type WSHandler struct {
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewWSHandler 핸들러 초기화
|
||||
func NewWSHandler(hub *ws.Hub) *WSHandler {
|
||||
return &WSHandler{hub: hub}
|
||||
}
|
||||
|
||||
// ServeWS GET /ws - WebSocket 연결 처리
|
||||
func (h *WSHandler) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.hub.ServeWS(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user