Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ solactl senderid list
# 발송 내역 조회
solactl messages list

# 발송 내역 export (CSV/JSON/JSONL)
solactl messages export --output messages.csv

# 일별 통계 export (CSV/JSON/JSONL)
solactl statistics export-daily --output stats.csv \
--start-date 2026-05-04 --end-date 2026-05-11

# 잔액 조회
solactl balance

Expand Down Expand Up @@ -120,3 +127,52 @@ solactl quota list-requests --status PENDING # 검토 대기 중인 요청만

> **주의** — 동일 계정에 PENDING 요청이 이미 있을 때 새 요청을 제출하면 이전 요청은 자동으로 REJECTED 처리됩니다.

## 발송 내역 / 통계 Export

대량 export는 messages-v4 부하를 줄이기 위해 다음 가드를 자동 적용합니다.

- **6개월(180일) 이전 데이터는 조회 불가** — 사내 DB에서 자동 삭제됩니다.
- **7일 초과 범위는 1일 단위 윈도우로 자동 분할** — UTC 자정 기준으로 잘게 호출.
- **`--throttle` (기본 500ms)** — 페이지/윈도우 호출 사이 sleep. 최소 100ms 강제.
- **`--page-size` 강제 상한** — `messages export` 200, `statistics export-daily` 100.
- **Ctrl+C 시 부분 결과 보존** — stderr에 `--resume-token` 안내. 다음 명령에 `--append --resume-token <토큰>`을 붙여 이어받을 수 있습니다.

### 메시지 내역 export

```bash
# 기본: 최근 7일, CSV, page-size 50, throttle 500ms
solactl messages export --output messages.csv

# 31일 범위 — 자동으로 31개 1일 윈도우로 분할
solactl messages export --output messages.csv \
--start-date 2026-04-02 --end-date 2026-05-03

# JSONL 포맷 + 필터
solactl messages export --output messages.jsonl --format jsonl \
--type SMS --status-code 4000 --from 029302266

# 중단 후 재개
solactl messages export --output messages.csv --append \
--resume-token eyJ2IjoxLCJ3IjoiMjAyNi0wNS0wMSJ9
```

CSV 컬럼: `messageId, type, status, statusCode, to, from, country, subject, dateCreated, dateUpdated, groupId, accountId, text, customFields`.

### 일별 통계 export

```bash
# 7일 범위
solactl statistics export-daily --output stats.csv \
--start-date 2026-05-04 --end-date 2026-05-11

# 31일 범위 — 자동 일별 분할
solactl statistics export-daily --output stats.csv \
--start-date 2026-04-02 --end-date 2026-05-03

# Windows Excel 한글 호환 (UTF-8 BOM)
solactl statistics export-daily --output stats.csv \
--start-date 2026-05-04 --end-date 2026-05-11 --bom
```

CSV는 고정 prefix (`date, accountId, prepaid, balance, point, profit, refundBalance, refundPoint`) + 응답에서 발견된 모든 `count.*` 키를 정렬해 컬럼화 (`count_SMS, count_LMS, count_MMS, ...`).

37 changes: 3 additions & 34 deletions cmd/balance.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package cmd
import (
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"

"github.com/solapi/solactl/pkg/types"
)

var balanceCmd = &cobra.Command{
Expand Down Expand Up @@ -52,35 +52,4 @@ func runBalance(cmd *cobra.Command, args []string) error {
return nil
}

// formatNumber formats an integer with thousand separators.
func formatNumber(n int) string {
negative := n < 0
if negative {
n = -n
}

s := strconv.Itoa(n)
if len(s) <= 3 {
if negative {
return "-" + s
}
return s
}

var b strings.Builder
offset := len(s) % 3
if offset > 0 {
b.WriteString(s[:offset])
}
for i := offset; i < len(s); i += 3 {
if b.Len() > 0 {
b.WriteByte(',')
}
b.WriteString(s[i : i+3])
}

if negative {
return "-" + b.String()
}
return b.String()
}
func formatNumber(n int) string { return types.FormatThousands(n) }
2 changes: 1 addition & 1 deletion cmd/balance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func TestBalance_JSON(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}

var parsed map[string]interface{}
var parsed map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
Expand Down
66 changes: 66 additions & 0 deletions cmd/export_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"
"time"

"github.com/solapi/solactl/pkg/progress"
)

// resolveProgressMode maps --progress / --no-progress flags to progress.Mode.
func resolveProgressMode(flag string, noProgress bool) (progress.Mode, error) {
if noProgress {
flag = "off"
}
switch flag {
case "auto":
return progress.ModeAuto, nil
case "on":
return progress.ModeOn, nil
case "off":
return progress.ModeOff, nil
}
return 0, fmt.Errorf("잘못된 --progress 값: %s (auto|on|off)", flag)
}

// parseExportDate accepts "2006-01-02", "2006-01-02T15:04:05Z", or RFC3339.
func parseExportDate(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("지원되는 날짜 형식: 2006-01-02, 2006-01-02T15:04:05Z, RFC3339")
}

// nopWriteCloser wraps stdout-like writers that must not be closed by the caller.
type nopWriteCloser struct{}

func (nopWriteCloser) Close() error { return nil }

// openExportOutput resolves the --output flag to (writer, closer). path == "-"
// returns the global stdout writer plus a no-op closer. append=false rejects an
// existing file via O_EXCL so users do not silently overwrite previous exports.
func openExportOutput(path string, appendMode bool) (io.Writer, io.Closer, error) {
if path == "-" {
return out(), nopWriteCloser{}, nil
}
if appendMode {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, nil, err
}
return f, f, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|os.O_EXCL, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil, nil, fmt.Errorf("출력 파일이 이미 존재: %s (--append를 사용하거나 파일을 삭제하세요)", path)
}
return nil, nil, err
}
return f, f, nil
}
Loading