refactor: remove unreachable legacy call logging package

This commit is contained in:
2026-09-30 17:19:35 +08:00
parent d1d9ac609b
commit 6fc55cee0d
3 changed files with 2 additions and 400 deletions
@@ -146,6 +146,8 @@
- 普通时段测试不再读取旧方案示例:`TestEvaluateApprovedTaskAndLineConfigShape` 改用 `contracts.Files` 嵌入的当前 task/SIP 正例;TDD 先验证旧 `trunk_details` 在当前 `trunks` 响应下产生预期失败,再绑定当前字段,周一 `09:30` 的允许终点为 `20:00`。`contracts.ReadCurrent` 只允许顶层 Schema/拓扑而**不**允许例子,这是原有严格边界,不为测试扩张;测试只读嵌入的当前正例。删除无人引用、带旧 `schema_version` 和 `agent_version_id` 字段的 `internal/testfixture/execute.go`,不清理数据、日志或上游历史原件。受影响模块 `go test -race`、全仓 `make check`(含三项实际 PASS 的隔离 MQ 测试)、`make release-check-local` 通过。本批仍需完成全仓残留/例外总账与 P08,外部验收未运行。
- 未接入运行路径的旧通话日志包:全仓活动 Go 代码中没有 `internal/calllog` 调用,独立 `internal/calllog/log.go`/单元测试仍包含旧 `schema_version`、旧任务条目等数据模型;删除该死代码而不是留下看似可用的第二条事实通道。Agent/Dispatcher 的现行失败关键路径仍有 `cmd/sip-go-agent/main.go` 的 `slog.Error` 与 `internal/rpc/` 的脱敏执行、录音授予、结束和结果提交日志,删包不删除任何已存在的日志/录音/恢复文件。`go test -race ./...`、`make check`(含三项真实隔离 MQ PASS)与 `make release-check-local` 均通过;不将旧日志格式留作兼容层。旧 AI 部署夹具与自有 `Current` 入口名称仍需另行审计,P07 尚未收口。
## 验收台账
P01–P06 的项目内隔离证据见上;P07 全仓命名与唯一入口尚在清理,A01–A12 和 K01–K16 的最终对照仍待 P08。不得用本地 Mock 冒充外部签收。
-270
View File
@@ -1,270 +0,0 @@
// Package calllog writes redacted, durable business facts for outbound calls.
// It never writes the original phone number, credentials, audio, prompts, or
// provider URLs. The phone reference is a keyed digest so one number can be
// correlated across tasks without making the log a contact database.
package calllog
import (
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var phonePattern = regexp.MustCompile(`^[0-9]{3,32}$`)
var eventTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`)
// Identity is the only phone identity exposed to log records.
type Identity struct {
Ref string
Mask string
}
// Event is the allow-listed input to Logger.Append. Phone is consumed to make
// a keyed identity and is never serialized.
type Event struct {
EventType string
Phone string
PhoneRef string
PhoneMask string
EventID string
OccurredAt time.Time
TenantID string
TraceID string
ExecutionID string
TaskID string
TaskItemID string
TaskRevision int64
AttemptID string
CallID string
AgentID string
CellID string
RoutePolicyID string
CallerProfileID string
TrunkID string
CallState string
AttemptState string
SIPStage string
SIPStatusCode int
SIPReason string
Status string
Result string
ReasonCode string
RecordingID string
RecordingState string
RecordingSize int64
RecordingDuration int64
RecordingSHA256 string
DurationMS int64
}
type record struct {
SchemaVersion string `json:"schema_version"`
EventID string `json:"event_id"`
OccurredAt string `json:"occurred_at"`
EventType string `json:"event_type"`
PhoneRef string `json:"phone_ref"`
PhoneMask string `json:"phone_mask"`
TenantID string `json:"tenant_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskItemID string `json:"task_item_id,omitempty"`
TaskRevision int64 `json:"task_revision,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
CallID string `json:"call_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
CellID string `json:"cell_id,omitempty"`
RoutePolicyID string `json:"route_policy_id,omitempty"`
CallerProfileID string `json:"caller_profile_id,omitempty"`
TrunkID string `json:"trunk_id,omitempty"`
CallState string `json:"call_state,omitempty"`
AttemptState string `json:"attempt_state,omitempty"`
SIPStage string `json:"sip_stage,omitempty"`
SIPStatusCode int `json:"sip_status_code,omitempty"`
SIPReason string `json:"sip_reason,omitempty"`
Status string `json:"status,omitempty"`
Result string `json:"result,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
RecordingID string `json:"recording_id,omitempty"`
RecordingState string `json:"recording_state,omitempty"`
RecordingSize int64 `json:"recording_size_bytes,omitempty"`
RecordingDuration int64 `json:"recording_duration_ms,omitempty"`
RecordingSHA256 string `json:"recording_sha256,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
}
// Logger appends one JSON object per line. A mutex and Sync keep concurrent
// call updates from interleaving and make a successful append durable enough
// for the Agent's file-backed recovery boundary.
type Logger struct {
path string
key []byte
now func() time.Time
mu sync.Mutex
}
func New(path string, key []byte, now func() time.Time) (*Logger, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("call log path is required")
}
if len(key) < 16 {
return nil, errors.New("call log phone key must contain at least 16 bytes")
}
if now == nil {
now = time.Now
}
return &Logger{path: path, key: append([]byte(nil), key...), now: now}, nil
}
func (l *Logger) Path() string { return l.path }
// Identity returns the stable, redacted reference for a contract callee.
func (l *Logger) Identity(phone string) (Identity, error) {
if err := validatePhone(phone); err != nil {
return Identity{}, err
}
mac := hmac.New(sha256.New, l.key)
_, _ = mac.Write([]byte(phone))
return Identity{Ref: "hmac-sha256:" + hex.EncodeToString(mac.Sum(nil)), Mask: maskPhone(phone)}, nil
}
func (l *Logger) Append(event Event) error {
if l == nil {
return errors.New("call logger is nil")
}
if !eventTypePattern.MatchString(event.EventType) {
return errors.New("event type is invalid")
}
identity := Identity{Ref: event.PhoneRef, Mask: event.PhoneMask}
if event.Phone != "" {
computed, err := l.Identity(event.Phone)
if err != nil {
return err
}
if identity.Ref != "" && identity.Ref != computed.Ref {
return errors.New("phone reference does not match phone")
}
identity = computed
}
if err := validateIdentity(identity); err != nil {
return err
}
if event.SIPStatusCode != 0 && (event.SIPStatusCode < 100 || event.SIPStatusCode > 699) {
return errors.New("SIP status code is invalid")
}
if event.TaskRevision < 0 || event.DurationMS < 0 || event.RecordingSize < 0 || event.RecordingDuration < 0 {
return errors.New("negative business log value")
}
if len(event.RecordingSHA256) > 0 && (len(event.RecordingSHA256) != 64 || !isLowerHex(event.RecordingSHA256)) {
return errors.New("recording SHA-256 is invalid")
}
occurredAt := event.OccurredAt
if occurredAt.IsZero() {
occurredAt = l.now()
}
eventID := event.EventID
if eventID == "" {
var random [16]byte
if _, err := cryptorand.Read(random[:]); err != nil {
return fmt.Errorf("generate call log event ID: %w", err)
}
eventID = hex.EncodeToString(random[:])
}
value := record{
SchemaVersion: "1.0", EventID: eventID, OccurredAt: occurredAt.UTC().Format(time.RFC3339Nano),
EventType: event.EventType, PhoneRef: identity.Ref, PhoneMask: identity.Mask,
TenantID: event.TenantID, TraceID: event.TraceID, ExecutionID: event.ExecutionID,
TaskID: event.TaskID, TaskItemID: event.TaskItemID, TaskRevision: event.TaskRevision,
AttemptID: event.AttemptID, CallID: event.CallID, AgentID: event.AgentID, CellID: event.CellID,
RoutePolicyID: event.RoutePolicyID, CallerProfileID: event.CallerProfileID, TrunkID: event.TrunkID,
CallState: event.CallState, AttemptState: event.AttemptState, SIPStage: event.SIPStage,
SIPStatusCode: event.SIPStatusCode, SIPReason: event.SIPReason, Status: event.Status,
Result: event.Result, ReasonCode: event.ReasonCode, RecordingID: event.RecordingID,
RecordingState: event.RecordingState, RecordingSize: event.RecordingSize,
RecordingDuration: event.RecordingDuration, RecordingSHA256: event.RecordingSHA256,
DurationMS: event.DurationMS,
}
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode call log event: %w", err)
}
l.mu.Lock()
defer l.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
return fmt.Errorf("create call log directory: %w", err)
}
file, err := os.OpenFile(l.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open call log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return fmt.Errorf("protect call log: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
_ = file.Close()
return fmt.Errorf("append call log: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync call log: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close call log: %w", err)
}
return nil
}
func validateIdentity(identity Identity) error {
if !strings.HasPrefix(identity.Ref, "hmac-sha256:") || len(identity.Ref) != len("hmac-sha256:")+64 || !isLowerHex(strings.TrimPrefix(identity.Ref, "hmac-sha256:")) {
return errors.New("redacted phone reference is invalid")
}
if len(identity.Mask) < 3 || strings.Contains(identity.Mask, " ") || strings.ContainsAny(identity.Mask, "\r\n") {
return errors.New("redacted phone mask is invalid")
}
for _, char := range identity.Mask {
if char != '*' && (char < '0' || char > '9') {
return errors.New("redacted phone mask is invalid")
}
}
if !strings.Contains(identity.Mask, "*") {
return errors.New("redacted phone mask must hide digits")
}
return nil
}
func validatePhone(phone string) error {
if !phonePattern.MatchString(phone) {
return errors.New("phone must be the original 3-32 digit callee")
}
return nil
}
func maskPhone(phone string) string {
if len(phone) <= 4 {
return strings.Repeat("*", len(phone))
}
return strings.Repeat("*", len(phone)-4) + phone[len(phone)-4:]
}
func isLowerHex(value string) bool {
for _, char := range value {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
return false
}
}
return true
}
-130
View File
@@ -1,130 +0,0 @@
package calllog
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestAppendCorrelatesPhoneWithoutWritingOriginal(t *testing.T) {
directory := t.TempDir()
logger, err := New(filepath.Join(directory, "logs", "calls.jsonl"), []byte("0123456789abcdef"), func() time.Time {
return time.Date(2026, 9, 19, 1, 2, 3, 4, time.UTC)
})
if err != nil {
t.Fatal(err)
}
phone := "15003164745"
if err := logger.Append(Event{
EventID: "event-1", EventType: "sip.status", Phone: phone,
ExecutionID: "exec-1", TaskID: "task-1", AttemptID: "attempt-1", CallID: "call-1",
RoutePolicyID: "route-sip-first", CallerProfileID: "caller-sip-first", TrunkID: "provider-primary",
SIPStage: "invite", SIPStatusCode: 183, Status: "ringing", ReasonCode: "provisional",
RecordingID: "recording-1", RecordingState: "pending", DurationMS: 120,
}); err != nil {
t.Fatal(err)
}
if err := logger.Append(Event{
EventID: "event-2", EventType: "call.finished", Phone: phone,
ExecutionID: "exec-1", CallID: "call-1", Result: "no_answer", ReasonCode: "provider_480",
RecordingState: "failed", DurationMS: 3000,
}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatal(err)
}
text := string(data)
if strings.Contains(text, phone) {
t.Fatalf("call log contains original phone: %s", text)
}
lines := strings.Split(strings.TrimSpace(text), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2", len(lines))
}
var first, second map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatal(err)
}
if first["phone_ref"] != second["phone_ref"] {
t.Fatalf("same phone must have one reference: %#v %#v", first["phone_ref"], second["phone_ref"])
}
if first["phone_mask"] != "*******4745" {
t.Fatalf("unexpected phone mask: %v", first["phone_mask"])
}
if first["trunk_id"] != "provider-primary" || first["sip_stage"] != "invite" || first["sip_status_code"] != float64(183) {
t.Fatalf("missing SIP correlation fields: %#v", first)
}
if first["recording_id"] != "recording-1" || second["result"] != "no_answer" {
t.Fatalf("missing recording/result fields: %#v %#v", first, second)
}
if mode := fileMode(t, logger.Path()); mode.Perm() != 0o600 {
t.Fatalf("log mode is %o, want 600", mode.Perm())
}
}
func TestAppendRejectsUnredactedOrInvalidIdentity(t *testing.T) {
logger, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("0123456789abcdef"), nil)
if err != nil {
t.Fatal(err)
}
for _, event := range []Event{
{EventType: "call.status"},
{EventType: "call.status", PhoneRef: "15003164745", PhoneMask: "15003164745"},
{EventType: "call.status", PhoneRef: "hmac-sha256:bad", PhoneMask: "*******4745"},
{EventType: "call.status", Phone: "15003164745", SIPStatusCode: 700},
} {
if err := logger.Append(event); err == nil {
t.Fatalf("event %#v was accepted", event)
}
}
}
func TestAppendSerializesConcurrentEvents(t *testing.T) {
logger, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("0123456789abcdef"), nil)
if err != nil {
t.Fatal(err)
}
var wait sync.WaitGroup
for index := 0; index < 32; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
if err := logger.Append(Event{EventType: "call.status", Phone: "15830461047", EventID: "event-" + string(rune('a'+index))}); err != nil {
t.Errorf("append %d: %v", index, err)
}
}(index)
}
wait.Wait()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatal(err)
}
if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 32 {
t.Fatalf("got %d serialized events, want 32", lines)
}
}
func TestNewRequiresPhoneKey(t *testing.T) {
if _, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("short"), nil); err == nil {
t.Fatal("short key was accepted")
}
}
func fileMode(t *testing.T, path string) os.FileMode {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.Mode()
}