更新若干功能

This commit is contained in:
李琦
2026-08-14 07:52:01 +08:00
parent 153c7ed448
commit 89bc265f68
196 changed files with 17675 additions and 0 deletions

68
service/ai/ai_test.go Normal file
View File

@@ -0,0 +1,68 @@
package ai
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFactory(t *testing.T) {
if _, e := New("bogus", "k"); e == nil || e.Error() != "AI_PROVIDER_INVALID" {
t.Fatalf("expected AI_PROVIDER_INVALID, got %v", e)
}
if _, e := New(ProviderSpark, ""); e == nil || e.Error() != "AI_KEY_MISSING" {
t.Fatalf("expected AI_KEY_MISSING, got %v", e)
}
p, e := New(ProviderDeepSeek, "key")
if e != nil || p.Name() != ProviderDeepSeek {
t.Fatalf("unexpected: %v %v", p, e)
}
p, e = New(ProviderSpark, "key")
if e != nil || p.Name() != ProviderSpark {
t.Fatalf("unexpected: %v %v", p, e)
}
}
func TestChatStreamSSE(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("unexpected auth header %q", got)
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\",世界\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := &openAICompatible{name: "test", baseURL: srv.URL, model: "m", apiKey: "test-key", client: srv.Client()}
stream, e := p.ChatStream(context.Background(), []Message{{Role: "user", Content: "hi"}})
if e != nil {
t.Fatal(e)
}
var sb strings.Builder
for c := range stream {
if c.Err != nil {
t.Fatal(c.Err)
}
sb.WriteString(c.Content)
}
if sb.String() != "你好,世界" {
t.Fatalf("unexpected content %q", sb.String())
}
}
func TestChatStreamAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
p := &openAICompatible{name: "test", baseURL: srv.URL, model: "m", apiKey: "bad", client: srv.Client()}
if _, e := p.ChatStream(context.Background(), nil); e == nil || e.Error() != "AI_KEY_INVALID" {
t.Fatalf("expected AI_KEY_INVALID, got %v", e)
}
}

49
service/ai/factory.go Normal file
View File

@@ -0,0 +1,49 @@
package ai
import "errors"
// 支持的 Provider 标识。
const (
ProviderSpark = "spark"
ProviderDeepSeek = "deepseek"
)
// New 是 Provider 工厂:按标识与 API Key 创建对应实例。
func New(provider, apiKey string) (Provider, error) {
switch provider {
case ProviderSpark:
return newSpark(apiKey)
case ProviderDeepSeek:
return newDeepSeek(apiKey)
default:
return nil, errors.New("AI_PROVIDER_INVALID")
}
}
// newSpark 创建讯飞星火 LiteOpenAI 兼容 HTTP 端点APIPassword 鉴权Lite 免费)。
func newSpark(apiPassword string) (Provider, error) {
if apiPassword == "" {
return nil, errors.New("AI_KEY_MISSING")
}
return &openAICompatible{
name: ProviderSpark,
baseURL: "https://spark-api-open.xf-yun.com/v1",
model: "lite",
apiKey: apiPassword,
client: newHTTPClient(),
}, nil
}
// newDeepSeek 创建 DeepSeekOpenAI 兼容)。
func newDeepSeek(apiKey string) (Provider, error) {
if apiKey == "" {
return nil, errors.New("AI_KEY_MISSING")
}
return &openAICompatible{
name: ProviderDeepSeek,
baseURL: "https://api.deepseek.com",
model: "deepseek-chat",
apiKey: apiKey,
client: newHTTPClient(),
}, nil
}

117
service/ai/openai.go Normal file
View File

@@ -0,0 +1,117 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// openAICompatible 是 OpenAI Chat Completions 兼容端点的通用流式客户端;
// 讯飞星火 Lite 与 DeepSeek 均暴露该协议,只是 baseURL/model/key 不同。
type openAICompatible struct {
name string
baseURL string
model string
apiKey string
client *http.Client
}
func (p *openAICompatible) Name() string { return p.name }
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type chatDelta struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (<-chan Chunk, error) {
body, e := json.Marshal(chatRequest{Model: p.model, Messages: messages, Stream: true})
if e != nil {
return nil, e
}
req, e := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.baseURL, "/")+"/chat/completions", bytes.NewReader(body))
if e != nil {
return nil, e
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Accept", "text/event-stream")
resp, e := p.client.Do(req)
if e != nil {
return nil, errors.New("AI_NETWORK_ERROR")
}
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, errors.New("AI_KEY_INVALID")
}
return nil, fmt.Errorf("AI_HTTP_%d: %s", resp.StatusCode, truncate(string(raw), 300))
}
out := make(chan Chunk, 16)
go func() {
defer close(out)
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var d chatDelta
if json.Unmarshal([]byte(payload), &d) != nil {
continue
}
if d.Error != nil {
out <- Chunk{Err: errors.New(truncate(d.Error.Message, 300))}
return
}
if len(d.Choices) > 0 && d.Choices[0].Delta.Content != "" {
select {
case out <- Chunk{Content: d.Choices[0].Delta.Content}:
case <-ctx.Done():
return
}
}
}
if e := sc.Err(); e != nil && ctx.Err() == nil {
out <- Chunk{Err: errors.New("AI_STREAM_INTERRUPTED")}
}
}()
return out, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func newHTTPClient() *http.Client {
return &http.Client{Timeout: 5 * time.Minute}
}

24
service/ai/provider.go Normal file
View File

@@ -0,0 +1,24 @@
// Package ai 定义 AI 聊天 Provider 抽象与工厂:
// 上层只依赖 Provider 接口,通过 New 按设置创建讯飞星火 Lite 或 DeepSeek 实例。
package ai
import "context"
// Message 是一条对话消息role: system | user | assistant
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Chunk 是流式返回的一段增量内容Err 非空表示流异常中止。
type Chunk struct {
Content string
Err error
}
// Provider 是 AI 服务商的统一抽象。
type Provider interface {
Name() string
// ChatStream 发起流式对话,返回增量内容通道;通道关闭即流结束。
ChatStream(ctx context.Context, messages []Message) (<-chan Chunk, error)
}

49
service/scheduler.go Normal file
View File

@@ -0,0 +1,49 @@
package service
import (
"strconv"
"strings"
"time"
)
// parseClock 解析 HH:MM非法输入回退 09:00。
func parseClock(at string) (int, int) {
parts := strings.SplitN(at, ":", 2)
if len(parts) != 2 {
return 9, 0
}
h, e1 := strconv.Atoi(parts[0])
m, e2 := strconv.Atoi(parts[1])
if e1 != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 9, 0
}
return h, m
}
// DigestDue 判断团队日报摘要是否到达自动生成时间now 已过当天 atHH:MM
func DigestDue(at string, now time.Time) bool {
hh, mm := parseClock(at)
due := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location())
return !now.Before(due)
}
// NextAutoUpdate 计算下一次自动更新的时间点(纯函数,便于测试)。
// mode: daily | everyNDays | everyNHoursat 为 HH:MMlast 为上次执行时间(调用方保证非零)。
func NextAutoUpdate(mode string, interval int, at string, last, now time.Time) time.Time {
if interval < 1 {
interval = 1
}
hh, mm := parseClock(at)
switch mode {
case "everyNHours":
return last.Add(time.Duration(interval) * time.Hour)
case "everyNDays":
return time.Date(last.Year(), last.Month(), last.Day(), hh, mm, 0, 0, last.Location()).AddDate(0, 0, interval)
default: // daily今天的 HH:MM若上次已在该时点之后执行过则推到明天。
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location())
if !last.Before(t) {
t = t.AddDate(0, 0, 1)
}
return t
}
}

69
service/scheduler_test.go Normal file
View File

@@ -0,0 +1,69 @@
package service
import (
"testing"
"time"
)
func at(day, clock string) time.Time {
t, _ := time.ParseInLocation("2006-01-02 15:04", day+" "+clock, time.Local)
return t
}
func TestNextAutoUpdateDaily(t *testing.T) {
// 昨天 09:00 跑过,现在是今天 08:00 → 下一次今天 09:00
next := NextAutoUpdate("daily", 1, "09:00", at("2026-08-10", "09:00"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("want today 09:00, got %v", next)
}
// 今天 09:01 已跑,现在 15:00 → 明天 09:00
next = NextAutoUpdate("daily", 1, "09:00", at("2026-08-11", "09:01"), at("2026-08-11", "15:00"))
if !next.Equal(at("2026-08-12", "09:00")) {
t.Fatalf("want tomorrow 09:00, got %v", next)
}
// 昨天跑过,现在 10:00已过 09:00→ 今天 09:00补跑
next = NextAutoUpdate("daily", 1, "09:00", at("2026-08-10", "09:00"), at("2026-08-11", "10:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("want catch-up today 09:00, got %v", next)
}
}
func TestNextAutoUpdateEveryNDays(t *testing.T) {
next := NextAutoUpdate("everyNDays", 3, "22:30", at("2026-08-08", "22:30"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "22:30")) {
t.Fatalf("want 08-11 22:30, got %v", next)
}
}
func TestNextAutoUpdateEveryNHours(t *testing.T) {
next := NextAutoUpdate("everyNHours", 4, "", at("2026-08-11", "06:15"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "10:15")) {
t.Fatalf("want 10:15, got %v", next)
}
}
func TestNextAutoUpdateBadClock(t *testing.T) {
next := NextAutoUpdate("daily", 1, "bogus", at("2026-08-10", "09:00"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("bad clock should fall back to 09:00, got %v", next)
}
}
func TestDigestDue(t *testing.T) {
if DigestDue("21:00", at("2026-08-11", "20:59")) {
t.Fatal("20:59 should not be due for 21:00")
}
if !DigestDue("21:00", at("2026-08-11", "21:00")) {
t.Fatal("21:00 sharp should be due")
}
if !DigestDue("21:00", at("2026-08-11", "23:40")) {
t.Fatal("23:40 should be due")
}
// 非法时间回退 09:00与 parseClock 兜底一致)
if DigestDue("bogus", at("2026-08-11", "08:00")) {
t.Fatal("bad clock falls back to 09:00, 08:00 not due")
}
if !DigestDue("bogus", at("2026-08-11", "09:30")) {
t.Fatal("bad clock falls back to 09:00, 09:30 due")
}
}