更新若干功能
This commit is contained in:
68
service/ai/ai_test.go
Normal file
68
service/ai/ai_test.go
Normal 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
49
service/ai/factory.go
Normal 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 创建讯飞星火 Lite(OpenAI 兼容 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 创建 DeepSeek(OpenAI 兼容)。
|
||||
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
117
service/ai/openai.go
Normal 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
24
service/ai/provider.go
Normal 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
49
service/scheduler.go
Normal 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 已过当天 at(HH: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 | everyNHours;at 为 HH:MM;last 为上次执行时间(调用方保证非零)。
|
||||
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
69
service/scheduler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user