feat(export): 메시지/통계 export 명령 추가#19
Conversation
- 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>
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
기존 파일에 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
}| func needsStrip(s string) bool { | ||
| for i := 0; i < len(s); i++ { | ||
| if isStripTarget(rune(s[i])) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
문자열을 바이트 단위로 순회하며 rune으로 캐스팅하는 방식은 멀티바이트 문자가 포함된 경우 의도치 않게 동작할 수 있습니다. Go의 range 문을 사용하여 룬(rune) 단위로 안전하게 순회하는 것이 권장됩니다.
| 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 | |
| } |
| type statisticsCSVRowWriter struct { | ||
| w io.Writer | ||
| appendMode bool | ||
| bom bool | ||
| appendReader io.ReadCloser | ||
|
|
||
| records []*dailyStatRecord | ||
| countKeys map[string]struct{} | ||
| } |
| 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() | ||
| } |
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>
Summary
solactl messages export/solactl statistics export-daily추가 (CSV/JSON/JSONL)--throttle(기본 500ms) sleeplimit=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.FormatThousands로cmd/balance/pkg/progress의 천 단위 콤마 로직 단일화Test plan
go build ./... && go vet ./... && go test -race -count=1 ./...— 13 패키지 모두 통과messages-internal/부분 문자열 부재 (회귀 테스트로 정적 검증)TestMessagesExport_MultiWindowAutoSplit,TestStatisticsExportDaily_OperationalLogScenario(31일 → 정확히 31개 1일 윈도우)--append헤더 mismatch 거부, BOM, resume-token round-trip🤖 Generated with Claude Code