All checks were successful
build-and-push / build (push) Successful in 32s
기존 메일 569건은 summary 컬럼 추가 이전부터 있던 행이라 값이 NULL인데, 요약 대상 선택 쿼리가 summary='' 만 검색해서(SQL에서 NULL='' 는 거짓) 영원히 선택되지 않았다. COALESCE(summary,'')='' 로 NULL·빈문자열 모두 포함. 회당 상한 40→150으로 백로그 드레인 가속, 요약 실패는 silent skip 대신 로그로 남김. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"spin/internal/ai"
|
|
"spin/internal/mailsync"
|
|
"spin/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// syncInFlight prevents concurrent syncs of the same project.
|
|
var syncInFlight sync.Map // projectID -> struct{}
|
|
|
|
// isSyncing reports whether a sync is currently running for the project.
|
|
func isSyncing(projectID string) bool {
|
|
_, ok := syncInFlight.Load(projectID)
|
|
return ok
|
|
}
|
|
|
|
// syncProjectMail pulls the project's client-domain mail across ALL active member
|
|
// mailboxes (domain-wide delegation), upserts the headers into ProjectMailMsg, and
|
|
// records sync state. full=true pages the entire history; otherwise just the newest
|
|
// page per mailbox (cheap periodic top-up).
|
|
func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, aiKey, projectID string, full bool) error {
|
|
if _, busy := syncInFlight.LoadOrStore(projectID, struct{}{}); busy {
|
|
return nil
|
|
}
|
|
defer syncInFlight.Delete(projectID)
|
|
|
|
var p models.Project
|
|
if err := db.First(&p, "id = ?", projectID).Error; err != nil {
|
|
return err
|
|
}
|
|
domain := strings.TrimSpace(p.ClientDomain)
|
|
if !mailer.Enabled() || domain == "" {
|
|
return nil
|
|
}
|
|
|
|
var mailboxes []string
|
|
db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
|
|
|
|
perBox := 100 // periodic top-up: newest page per mailbox
|
|
if full {
|
|
perBox = 0 // whole history
|
|
}
|
|
msgs, ferr := mailer.FetchForDomain(ctx, mailboxes, domain, perBox)
|
|
|
|
for _, m := range msgs {
|
|
row := models.ProjectMailMsg{ProjectID: projectID, MessageID: m.ID}
|
|
// Upsert by (project, message-id); never clobber Hidden/HiddenBy.
|
|
db.Where("project_id = ? AND message_id = ?", projectID, m.ID).
|
|
Assign(map[string]interface{}{
|
|
"thread_id": m.ThreadID, "from_addr": m.From, "to_addr": m.To, "cc_addr": m.Cc,
|
|
"subject": m.Subject, "snippet": m.Snippet, "date_hdr": m.Date,
|
|
"mailbox": m.Mailbox, "ts": m.TS,
|
|
}).
|
|
FirstOrCreate(&row)
|
|
}
|
|
|
|
// AI 자동 요약: 아직 요약이 없는 메일에만 생성(긁어올 때마다 신규분에 채움). 비용·시간
|
|
// 폭주 방지로 회당 상한.
|
|
// 미생성 판정은 COALESCE(summary,'')='' — summary 컬럼 추가 이전에 저장된 행은 값이
|
|
// NULL이라 `summary = ''`로는 영원히 안 잡혔다(SQL에서 NULL = '' 는 거짓). NULL·빈문자열
|
|
// 둘 다 포함해야 기존 백로그도 채워진다.
|
|
if cl := ai.New(aiKey); cl.Enabled() {
|
|
var need []models.ProjectMailMsg
|
|
db.Where("project_id = ? AND COALESCE(summary, '') = '' AND snippet <> ''", projectID).Limit(150).Find(&need)
|
|
for _, row := range need {
|
|
sum, err := cl.Summarize(ctx, row.Subject+"\n"+row.Snippet)
|
|
if err != nil {
|
|
log.Printf("mailsync: AI 요약 실패 (project=%s msg=%s): %v", projectID, row.ID, err)
|
|
continue
|
|
}
|
|
if sum != "" {
|
|
db.Model(&models.ProjectMailMsg{}).Where("id = ?", row.ID).Update("summary", sum)
|
|
}
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
var cnt int64
|
|
db.Model(&models.ProjectMailMsg{}).Where("project_id = ?", projectID).Count(&cnt)
|
|
st := models.ProjectMailState{ProjectID: projectID, LastSyncedAt: &now, Count: int(cnt)}
|
|
if ferr != nil {
|
|
st.LastError = ferr.Error()
|
|
}
|
|
db.Save(&st)
|
|
return ferr
|
|
}
|
|
|
|
// StartMailSyncLoop periodically syncs every project that has a client domain.
|
|
// Projects never synced get a full-history backfill; the rest get a cheap top-up.
|
|
func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, aiKey string, interval time.Duration) {
|
|
if !mailer.Enabled() || interval <= 0 {
|
|
log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval)
|
|
return
|
|
}
|
|
log.Printf("mailsync: periodic sync every %s", interval)
|
|
go func() {
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
for {
|
|
var projects []models.Project
|
|
db.Where("client_domain <> ''").Find(&projects)
|
|
for _, p := range projects {
|
|
var st models.ProjectMailState
|
|
full := db.First(&st, "project_id = ?", p.ID).Error != nil
|
|
if err := syncProjectMail(ctx, db, mailer, aiKey, p.ID, full); err != nil {
|
|
log.Printf("mailsync: project %s sync error: %v", p.ID, err)
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}()
|
|
}
|