Skip to content

feat(export): 메시지/통계 export 명령 추가#19

Open
Palbahngmiyine wants to merge 3 commits intosolapi:mainfrom
Palbahngmiyine:worktree-virtual-imagining-pancake
Open

feat(export): 메시지/통계 export 명령 추가#19
Palbahngmiyine wants to merge 3 commits intosolapi:mainfrom
Palbahngmiyine:worktree-virtual-imagining-pancake

Conversation

@Palbahngmiyine
Copy link
Copy Markdown
Member

Summary

  • solactl messages export / solactl statistics export-daily 추가 (CSV/JSON/JSONL)
  • 7일 초과 범위는 1일 단위 UTC 윈도우로 자동 분할 → 페이지/윈도우 호출 사이 --throttle(기본 500ms) sleep
  • 6개월 lookback, page-size 상한(messages 200/statistics 100), throttle 100ms 최소를 CLI 레벨에서 강제 → messages-v4 단일 큰 호출(limit=500+31일) 회피
  • --append, --resume-token, --bom, --progress auto|on|off, 부분 결과 보존 + stderr 재개 안내
  • 신규 패키지: pkg/output (CSV/JSON/JSONL writer), pkg/progress (한국어 진행률 UI, TTY 자동 감지), pkg/clock (테스트용 Clock 추상화), pkg/exporter (윈도우 분할 + 페이지 루프 + resume-token)
  • pkg/types.FormatThousandscmd/balance / pkg/progress의 천 단위 콤마 로직 단일화

Test plan

  • go build ./... && go vet ./... && go test -race -count=1 ./... — 13 패키지 모두 통과
  • Internal endpoint 회귀 차단: production 코드에 messages-internal/ 부분 문자열 부재 (회귀 테스트로 정적 검증)
  • 사용자 운영 로그 시나리오 재현: TestMessagesExport_MultiWindowAutoSplit, TestStatisticsExportDaily_OperationalLogScenario (31일 → 정확히 31개 1일 윈도우)
  • 6개월 lookback / page-size 상한 / throttle 100ms 최소 가드 회귀 테스트 PASS
  • CSV/JSON/JSONL, --append 헤더 mismatch 거부, BOM, resume-token round-trip
  • 실제 sandbox API로 dry-run (배포 전 수동 확인 권장)

🤖 Generated with Claude Code

Palbahngmiyine and others added 2 commits May 11, 2026 14:57
- messages export, statistics export-daily 신규 (CSV/JSON/JSONL)
- 7일 초과 범위 자동 1일 윈도우 분할 + 페이지/윈도우 throttle (기본 500ms)
- 6개월 lookback / page-size 상한 (messages 200, statistics 100) / 100ms throttle 최소
  강제로 messages-v4 단일 큰 호출 (limit=500+31일) 회피
- --append, --resume-token, --bom, --progress auto|on|off 지원
- 신규 패키지: pkg/output (CSV/JSON/JSONL writer), pkg/progress (한국어 진행률 UI),
  pkg/clock (테스트용 Clock 추상화), pkg/exporter (윈도우 분할 + 페이지 루프 엔진)
- types.FormatThousands로 cmd/balance, pkg/progress 천 단위 콤마 단일화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI golangci-lint v2.11.4의 ineffassign 검사기가 다음 라인을 잡았다:

  got = append(got, 7*time.Hour)  // 그 후 사용 안 함

원래 의도는 "반환된 슬라이스를 변조해도 내부 상태에 영향이 없어야 한다"는
defensive copy 검증이었으나, append 결과 변수를 사용하지 않아 lint 경고가
발생했다. append 결과의 길이를 명시적으로 검증하도록 변경해 의도를 명확히
하면서 linter도 통과한다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a comprehensive export system for message logs and daily statistics, allowing users to retrieve large datasets in CSV, JSON, or JSONL formats. Key features include automatic splitting of large date ranges into daily windows, configurable throttling to manage server load, and a resume-token mechanism to recover from interrupted processes. The review feedback identified several technical improvements: correctly handling UTF-8 BOMs during CSV header validation, using rune-based iteration for safe string sanitization, and mitigating potential memory issues when generating dynamic CSV headers for statistics. It was also suggested to move the FormatThousands utility to a more generic package to enhance code structure.

Comment thread pkg/output/csv.go
Comment on lines +95 to +119
// verifyAppendHeader는 reader의 첫 줄을 CSV로 파싱하여 headers와 strict 비교.
func verifyAppendHeader(reader io.Reader, headers []string) error {
// 빈 파일도 명시적으로 거부: 호출자가 별도 분기하도록 시그널링.
br := bufio.NewReader(reader)
if _, err := br.Peek(1); err != nil {
if errors.Is(err, io.EOF) {
return errors.New("CSV 헤더 불일치: 기존 파일이 비어 있음 (Append 대신 일반 모드 사용 필요)")
}
return fmt.Errorf("CSV 헤더 읽기 실패: %w", err)
}
r := csv.NewReader(br)
r.FieldsPerRecord = -1
existing, err := r.Read()
if err != nil {
return fmt.Errorf("CSV 헤더 파싱 실패: %w", err)
}
if len(existing) != len(headers) {
return fmt.Errorf("CSV 헤더 불일치: 기존=%v, 신규=%v", existing, headers)
}
for i := range existing {
if existing[i] != headers[i] {
return fmt.Errorf("CSV 헤더 불일치: 기존=%v, 신규=%v", existing, headers)
}
}
return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

기존 파일에 UTF-8 BOM이 포함되어 있는 경우, csv.NewReader는 이를 자동으로 건너뛰지 않습니다. 이로 인해 첫 번째 컬럼 헤더 비교 시 BOM 문자가 포함되어 불일치 오류가 발생할 수 있습니다. 헤더를 읽기 전에 BOM 존재 여부를 확인하고 건너뛰는 로직이 필요합니다.

func verifyAppendHeader(reader io.Reader, headers []string) error {
	br := bufio.NewReader(reader)
	if b, err := br.Peek(len(utf8BOM)); err == nil && string(b) == string(utf8BOM) {
		_, _ = br.Discard(len(utf8BOM))
	}
	if _, err := br.Peek(1); err != nil {
		if errors.Is(err, io.EOF) {
			return errors.New("CSV 헤더 불일치: 기존 파일이 비어 있음 (Append 대신 일반 모드 사용 필요)")
		}
		return fmt.Errorf("CSV 헤더 읽기 실패: %w", err)
	}
	r := csv.NewReader(br)
	r.FieldsPerRecord = -1
	existing, err := r.Read()
	if err != nil {
		return fmt.Errorf("CSV 헤더 파싱 실패: %w", err)
	}
	if len(existing) != len(headers) {
		return fmt.Errorf("CSV 헤더 불일치: 기존=%v, 신규=%v", existing, headers)
	}
	for i := range existing {
		if existing[i] != headers[i] {
			return fmt.Errorf("CSV 헤더 불일치: 기존=%v, 신규=%v", existing, headers)
		}
	}
	return nil
}

Comment thread pkg/output/csv.go
Comment on lines +139 to +146
func needsStrip(s string) bool {
for i := 0; i < len(s); i++ {
if isStripTarget(rune(s[i])) {
return true
}
}
return false
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

문자열을 바이트 단위로 순회하며 rune으로 캐스팅하는 방식은 멀티바이트 문자가 포함된 경우 의도치 않게 동작할 수 있습니다. Go의 range 문을 사용하여 룬(rune) 단위로 안전하게 순회하는 것이 권장됩니다.

Suggested change
func needsStrip(s string) bool {
for i := 0; i < len(s); i++ {
if isStripTarget(rune(s[i])) {
return true
}
}
return false
}
func needsStrip(s string) bool {
for _, r := range s {
if isStripTarget(r) {
return true
}
}
return false
}

Comment on lines +332 to +340
type statisticsCSVRowWriter struct {
w io.Writer
appendMode bool
bom bool
appendReader io.ReadCloser

records []*dailyStatRecord
countKeys map[string]struct{}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

statisticsCSVRowWriter는 CSV 헤더의 count.* 키 합집합(union-header)을 구하기 위해 모든 레코드를 메모리에 누적(s.records)합니다. 대상 계정이나 기간이 매우 큰 경우 과도한 메모리 사용으로 인해 프로세스가 종료될 위험이 있습니다. 대량 데이터 처리 시 메모리 사용량에 주의가 필요합니다.

Comment thread pkg/types/kakao.go
Comment on lines +40 to +71
func FormatThousands(n int) string {
neg := n < 0
if neg {
n = -n
}
s := strconv.Itoa(n)
if len(s) <= 3 {
if neg {
return "-" + s
}
return s
}
head := len(s) % 3
var b strings.Builder
b.Grow(len(s) + len(s)/3 + 1)
if neg {
b.WriteByte('-')
}
if head > 0 {
b.WriteString(s[:head])
if len(s) > head {
b.WriteByte(',')
}
}
for i := head; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
return b.String()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

FormatThousands 함수는 범용적인 숫자 포맷팅 유틸리티임에도 불구하고 kakao.go 파일에 위치해 있습니다. 코드의 응집도와 가독성을 위해 pkg/types/numbers.go와 같이 더 적절한 이름의 파일로 이동하는 것을 고려해 보세요.

Go 1.26.1 toolchain의 go fix가 다음을 적용:

- 수동 max-clamp 패턴(`if x > y { x = y }`)을 Go 1.21+ built-in `min(...)`으로
  대체 (cmd/send.go, internal/version/semver.go)
- `boolPtr(v bool) *bool { return &v }` helper에 `//go:fix inline` 디렉티브
  추가 + 호출처를 새 `new(literal)` 값-인자 형태로 인라인. helper는 더 이상
  참조되지 않아 함께 제거 (golangci-lint unused 회피).
- 테스트 파일 다수의 gofmt 정렬 조정 (struct 필드 패딩, 빈 줄 등).

검증: go build/vet/test -race ./..., golangci-lint run ./... 모두 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant