feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -15,6 +16,7 @@ import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/repositories"
)
@@ -24,6 +26,9 @@ type answer struct {
var Answer = &answer{}
func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchRequest) (*response.KnowledgeSearchResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(req.Question) == "" {
return nil, errorsx.InvalidParamI18n("error.e0340")
}
@@ -59,6 +64,9 @@ func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchReq
}
func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerRequest, operator *dto.AuthPrincipal) (*response.KnowledgeAnswerResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(req.Question) == "" {
return nil, errorsx.InvalidParamI18n("error.e0340")
}
@@ -77,7 +85,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
}
retrieveMs := time.Since(retrieveStartedAt).Milliseconds()
knowledgeBase := s.resolveAnswerKnowledgeBase(req.KnowledgeBaseIDs, results)
contextResults := buildContextHits(Retrieve.SelectContextResults(results, 4000))
contextResults := buildContextHits(Retrieve.SelectContextResultsWithContext(ctx, results, 4000))
hits := make([]response.KnowledgeSearchResult, 0, len(results))
topScore := 0.0
@@ -114,36 +122,18 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
fallbackMode := enums.AIAgentFallbackModeNoAnswer
answerStatus := enums.KnowledgeAnswerStatusNormal
answer := ""
modelName := ""
promptTokens := 0
completionTokens := 0
generateStartedAt := time.Now()
if len(hits) == 0 {
answerStatus = enums.KnowledgeAnswerStatusNoAnswer
answer = buildFallbackAnswer(fallbackMode)
} else {
contextText := Retrieve.BuildContext(ctx, results, 4000)
systemPrompt := buildAnswerSystemPrompt(answerMode)
userPrompt := fmt.Sprintf("用户问题:%s\n\n参考资料:\n%s", req.Question, contextText)
llmResult, llmErr := ai.LLM.Chat(ctx, systemPrompt, userPrompt)
if llmErr != nil {
answerStatus = enums.KnowledgeAnswerStatusFallback
answer = buildFallbackAnswer(fallbackMode)
} else {
answer = llmResult.Content
modelName = llmResult.ModelName
promptTokens = llmResult.PromptTokens
completionTokens = llmResult.CompletionTokens
if strings.TrimSpace(answer) == "" {
answerStatus = enums.KnowledgeAnswerStatusFallback
answer = buildFallbackAnswer(fallbackMode)
}
}
generated, err := generateKnowledgeAnswer(ctx, req.Question, results, answerMode, fallbackMode, ai.LLM.Chat)
if err != nil {
return nil, err
}
answerStatus := generated.Status
answer := generated.Content
modelName := generated.ModelName
promptTokens := generated.PromptTokens
completionTokens := generated.CompletionTokens
generateMs := time.Since(generateStartedAt).Milliseconds()
rerankLimit := 0
chunkProvider := ""
chunkTargetTokens := 0
@@ -191,7 +181,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
Question: req.Question,
Answer: answer,
AnswerStatus: int(answerStatus),
AnswerStatusName: getAnswerStatusName(answerStatus),
AnswerStatusName: getAnswerStatusName(ctx, answerStatus),
Citations: citations,
Hits: hits,
HitCount: len(hits),
@@ -376,20 +366,20 @@ func resolveDefaultRerankLimit(items []models.KnowledgeBase) int {
return limit
}
func buildAnswerSystemPrompt(answerMode enums.KnowledgeAnswerMode) string {
func buildAnswerSystemPrompt(ctx context.Context, answerMode enums.KnowledgeAnswerMode) string {
key := "knowledge.rag.system.strict"
if answerMode == enums.KnowledgeAnswerModeAssist {
return "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。"
key = "knowledge.rag.system.assist"
}
return "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。"
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
func buildFallbackAnswer(fallbackMode enums.AIAgentFallbackMode) string {
switch fallbackMode {
case enums.AIAgentFallbackModeSuggestRetry:
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
default:
return "当前知识库暂无明确信息。"
func buildFallbackAnswer(ctx context.Context, fallbackMode enums.AIAgentFallbackMode) string {
key := "knowledge.rag.fallback.noAnswer"
if fallbackMode == enums.AIAgentFallbackModeSuggestRetry {
key = "knowledge.rag.fallback.suggestRetry"
}
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
func defaultRetrieveChannel(channel string) string {
@@ -406,17 +396,52 @@ func defaultRetrieveScene(scene string) string {
return scene
}
func getAnswerStatusName(status enums.KnowledgeAnswerStatus) string {
func getAnswerStatusName(ctx context.Context, status enums.KnowledgeAnswerStatus) string {
key := "knowledge.labels.unknown"
switch status {
case enums.KnowledgeAnswerStatusNormal:
return "正常"
case enums.KnowledgeAnswerStatusNoAnswer:
return "无答案"
case enums.KnowledgeAnswerStatusFallback:
return "兜底"
case enums.KnowledgeAnswerStatusBlocked:
return "风控拦截"
default:
return "未知"
case enums.KnowledgeAnswerStatusNormal, enums.KnowledgeAnswerStatusNoAnswer, enums.KnowledgeAnswerStatusFallback, enums.KnowledgeAnswerStatusBlocked:
key = fmt.Sprintf("knowledge.labels.answer.%d", status)
}
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
type generatedKnowledgeAnswer struct {
ai.ChatCompletionResult
Status enums.KnowledgeAnswerStatus
}
type knowledgeChatFunc func(context.Context, string, string) (*ai.ChatCompletionResult, error)
// Keep generation independent of persistence so fallback and cancellation paths
// can be tested without a configured model, external service, or database.
func generateKnowledgeAnswer(ctx context.Context, question string, results []RetrieveResult, mode enums.KnowledgeAnswerMode, fallbackMode enums.AIAgentFallbackMode, chat knowledgeChatFunc) (*generatedKnowledgeAnswer, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
result := &generatedKnowledgeAnswer{Status: enums.KnowledgeAnswerStatusNoAnswer}
result.Content = buildFallbackAnswer(ctx, fallbackMode)
if len(results) == 0 {
return result, nil
}
contextText := Retrieve.BuildContext(ctx, results, 4000)
systemPrompt := buildAnswerSystemPrompt(ctx, mode)
userPrompt := i18nx.Getf(i18nx.LocaleFromContext(ctx), "knowledge.rag.userPrompt", question, contextText)
llmResult, err := chat(ctx, systemPrompt, userPrompt)
// An interrupted request is not a model failure and must not be logged as a
// completed fallback answer after the client has gone away.
if contextErr := ctx.Err(); contextErr != nil {
return nil, contextErr
}
if err != nil || llmResult == nil {
result.Status = enums.KnowledgeAnswerStatusFallback
return result, nil
}
result.ChatCompletionResult = *llmResult
if strings.TrimSpace(llmResult.Content) == "" {
result.Status = enums.KnowledgeAnswerStatusFallback
result.Content = buildFallbackAnswer(ctx, fallbackMode)
return result, nil
}
result.Status = enums.KnowledgeAnswerStatusNormal
return result, nil
}

View File

@@ -1,8 +1,12 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"testing"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
@@ -27,17 +31,17 @@ func TestBuildFallbackAnswer(t *testing.T) {
}
for _, tt := range tests {
if got := buildFallbackAnswer(tt.mode); got != tt.expected {
if got := buildFallbackAnswer(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), tt.mode); got != tt.expected {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.expected, got)
}
}
}
func TestGetAnswerStatusName(t *testing.T) {
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
t.Fatalf("expected no-answer label, got %q", got)
}
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusFallback); got != "兜底" {
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusFallback); got != i18nx.Getf(i18nx.LocaleZhCN, "knowledge.labels.answer.3") {
t.Fatalf("expected fallback label, got %q", got)
}
}

View File

@@ -0,0 +1,82 @@
package rag
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/i18nx"
)
// faqContent keeps application-owned labels separate from source text. Never
// translate, trim, or replace substrings in the source question or answer.
type faqContent struct {
question string
similarQuestions []string
answer string
}
func sourceFAQContent(faq models.KnowledgeFAQ) faqContent {
content := faqContent{question: faq.Question, answer: faq.Answer}
if faq.SimilarQuestions != "" {
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &content.similarQuestions)
}
return content
}
func (content faqContent) render(locale string) string {
parts := []string{i18nx.Getf(locale, "knowledge.rag.faq.question", content.question)}
if len(content.similarQuestions) > 0 {
similar := joinNonEmptySimilarQuestions(content.similarQuestions, i18nx.Getf(locale, "knowledge.rag.faq.separator"))
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.similarQuestions", similar))
}
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.answer", content.answer))
return strings.Join(parts, "\n")
}
// Index generation deliberately uses the configured default, not the locale of
// whichever administrator requested a build. A locale read never re-embeds data.
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
return sourceFAQContent(faq).render(i18nx.DefaultLocale)
}
// legacyChinese renders the exact pre-localization serialization. This is a
// recognition format, not UI copy. Keep it frozen when catalog wording changes.
func (content faqContent) legacyChinese() string {
parts := []string{fmt.Sprintf("问题:%s", content.question)}
if len(content.similarQuestions) > 0 {
parts = append(parts, "相似问:"+joinNonEmptySimilarQuestions(content.similarQuestions, ";"))
}
parts = append(parts, "回答:"+content.answer)
return strings.Join(parts, "\n")
}
// Only a full byte-for-byte match against a generated representation of the
// current source proves which text belongs to us. Stale/manual/malformed chunks
// stay as recorded. In particular, do not infer labels by splitting on Chinese
// words: the source content may itself contain those words or arbitrary lines.
// This function is for live retrieval only, never historical log replay.
func renderVerifiedFAQChunkContent(ctx context.Context, stored string, faq models.KnowledgeFAQ) string {
content := sourceFAQContent(faq)
if stored == content.legacyChinese() {
return content.render(i18nx.LocaleFromContext(ctx))
}
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
if stored == content.render(locale) {
return content.render(i18nx.LocaleFromContext(ctx))
}
}
return stored
}
func joinNonEmptySimilarQuestions(items []string, separator string) string {
nonEmpty := make([]string, 0, len(items))
for _, item := range items {
if item != "" {
nonEmpty = append(nonEmpty, item)
}
}
return strings.Join(nonEmpty, separator)
}

View File

@@ -1,10 +1,10 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -47,6 +47,9 @@ var Index = &index{
}
func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
if err := ctx.Err(); err != nil {
return err
}
document, err := s.loadDocumentByID(documentID)
if err != nil {
return err
@@ -55,6 +58,9 @@ func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
}
func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocument) error {
if err := ctx.Err(); err != nil {
return err
}
start := time.Now()
if err := s.markDocumentIndexPending(document.ID); err != nil {
slog.Error("Failed to mark knowledge document index as pending", "document_id", document.ID, "error", err)
@@ -92,6 +98,9 @@ func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocu
}
func (s *index) IndexFAQByID(ctx context.Context, faqID int64) error {
if err := ctx.Err(); err != nil {
return err
}
faq, err := s.loadFAQByID(faqID)
if err != nil {
return err
@@ -288,43 +297,6 @@ func (s *index) RebuildKnowledgeBaseIndex(ctx context.Context, knowledgeBaseID i
return nil
}
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
parts := []string{fmt.Sprintf("问题:%s", faq.Question)}
var similarQuestions []string
if faq.SimilarQuestions != "" {
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &similarQuestions)
}
if len(similarQuestions) > 0 {
parts = append(parts, fmt.Sprintf("相似问:%s", joinSimilarQuestions(similarQuestions)))
}
parts = append(parts, fmt.Sprintf("回答:%s", faq.Answer))
content := ""
for _, part := range parts {
if part == "" {
continue
}
if content != "" {
content += "\n"
}
content += part
}
return content
}
func joinSimilarQuestions(items []string) string {
result := ""
for _, item := range items {
if item == "" {
continue
}
if result != "" {
result += ";"
}
result += item
}
return result
}
func (s *index) resetKnowledgeBaseIndexStorage(ctx context.Context, knowledgeBaseID int64) error {
chunks := repositories.KnowledgeChunkRepository.FindByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
return s.cleanupKnowledgeBaseChunks(ctx, knowledgeBaseID, chunks)

View File

@@ -1,9 +1,13 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"strings"
"testing"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/models"
)
@@ -14,7 +18,7 @@ func TestBuildFAQChunkContent(t *testing.T) {
Answer: "在订单页发起退款。",
}
content := buildFAQChunkContent(faq)
content := sourceFAQContent(faq).render(i18nx.LocaleZhCN)
if !strings.Contains(content, "问题:如何退款") {
t.Fatalf("expected question in content, got %q", content)
}
@@ -70,7 +74,7 @@ func TestNormalizeContextResultsMergesAndDedupes(t *testing.T) {
}
func TestBuildContextChunkText(t *testing.T) {
faqText := buildContextChunkText(RetrieveResult{
faqText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
FaqID: 1,
FaqQuestion: "如何退款",
Content: "在订单页发起退款。",
@@ -79,7 +83,7 @@ func TestBuildContextChunkText(t *testing.T) {
t.Fatalf("unexpected faq context text: %q", faqText)
}
docText := buildContextChunkText(RetrieveResult{
docText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
DocumentID: 2,
DocumentTitle: "退款文档",
SectionPath: "售后/退款",

View File

@@ -0,0 +1,175 @@
package rag
import (
"context"
"errors"
"strings"
"testing"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
)
func TestKnowledgeAnswerLocalizedFallbackPaths(t *testing.T) {
tests := []struct{ locale, fallback, retry, noAnswerStatus string }{
{i18nx.LocaleEnUS, "The knowledge base currently has no clear information.", "The knowledge base did not contain enough clear information. Try asking a more specific question.", "No answer"},
{i18nx.LocaleRuRU, "В базе знаний пока нет однозначной информации.", "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос.", "Нет ответа"},
{i18nx.LocaleZhCN, "当前知识库暂无明确信息。", "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。", "无答案"},
}
for _, tt := range tests {
t.Run(tt.locale, func(t *testing.T) {
ctx := i18nx.WithLocale(context.Background(), tt.locale)
called := false
failedChat := func(context.Context, string, string) (*ai.ChatCompletionResult, error) {
called = true
return nil, errors.New("provider failed")
}
noHits, err := generateKnowledgeAnswer(ctx, "问题 не переводить", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, failedChat)
if err != nil || called || noHits.Status != enums.KnowledgeAnswerStatusNoAnswer || noHits.Content != tt.fallback {
t.Fatalf("no-hit result = %#v, %v; model called = %v", noHits, err, called)
}
if got := getAnswerStatusName(ctx, noHits.Status); got != tt.noAnswerStatus {
t.Fatalf("status = %q", got)
}
hits := []RetrieveResult{{DocumentID: 1, Content: "Reference content"}}
failed, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeAssist, enums.AIAgentFallbackModeNoAnswer, failedChat)
if err != nil || !called || failed.Status != enums.KnowledgeAnswerStatusFallback || failed.Content != tt.fallback {
t.Fatalf("failed model result = %#v, %v", failed, err)
}
for _, empty := range []*ai.ChatCompletionResult{nil, {}, {Content: " \n\t"}} {
got, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeSuggestRetry, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != tt.retry {
t.Fatalf("empty result fallback = %#v, %v", got, err)
}
}
})
}
}
func TestKnowledgeGenerationPreservesUserAndModelContent(t *testing.T) {
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
ctx := i18nx.WithLocale(context.Background(), locale)
question := " 问题:User's question\n回答:not a wrapper "
body := " 回答:body\n相似问:literal user text\n "
output := &ai.ChatCompletionResult{Content: " 模型回答\n", ModelName: "test-model", PromptTokens: 4, CompletionTokens: 2}
got, err := generateKnowledgeAnswer(ctx, question, []RetrieveResult{{DocumentID: 1, DocumentTitle: "用户标题", Content: body}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
if i18nx.LocaleFromContext(modelCtx) != locale {
t.Fatal("locale not forwarded to model")
}
if systemPrompt != i18nx.Getf(locale, "knowledge.rag.system.strict") || !strings.Contains(userPrompt, question) || !strings.Contains(userPrompt, body) {
t.Fatalf("incorrect prompts: %q, %q", systemPrompt, userPrompt)
}
return output, nil
})
if err != nil || got.Status != enums.KnowledgeAnswerStatusNormal || got.ChatCompletionResult != *output {
t.Fatalf("model output was changed: %#v, %v", got, err)
}
}
}
func TestKnowledgeCancellationBeforePersistenceOrProviderCalls(t *testing.T) {
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
cancel()
if _, err := Answer.DebugSearch(ctx, request.KnowledgeSearchRequest{Question: "Q"}); !errors.Is(err, context.Canceled) {
t.Fatalf("debug search ignored cancellation: %v", err)
}
if _, err := Answer.DebugAnswer(ctx, request.KnowledgeAnswerRequest{Question: "Q"}, nil); !errors.Is(err, context.Canceled) {
t.Fatalf("debug answer ignored cancellation: %v", err)
}
if err := Index.IndexDocumentByID(ctx, 1); !errors.Is(err, context.Canceled) {
t.Fatalf("document build ignored cancellation: %v", err)
}
if err := Index.IndexFAQByID(ctx, 1); !errors.Is(err, context.Canceled) {
t.Fatalf("FAQ build ignored cancellation: %v", err)
}
if _, err := Retrieve.Retrieve(ctx, RetrieveRequest{Query: "Q"}); !errors.Is(err, context.Canceled) {
t.Fatalf("retrieval ignored cancellation: %v", err)
}
}
func TestKnowledgeGenerationCancellationIsNotFallback(t *testing.T) {
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
defer cancel()
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "source"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, _, _ string) (*ai.ChatCompletionResult, error) {
cancel()
return nil, modelCtx.Err()
})
if got != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled model should not become a saved fallback: %#v, %v", got, err)
}
}
func TestFAQGeneratedWrappersPreserveSourceContent(t *testing.T) {
faq := models.KnowledgeFAQ{
Question: " 问题:What?\n回答:part of question ",
SimilarQuestions: `["相似问:literal; text", "", " another\n问题:line "]`,
Answer: " 回答:user answer\n相似问:inside answer\n问题:last line ",
}
original := faq
source := sourceFAQContent(faq)
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
ctx := i18nx.WithLocale(context.Background(), locale)
expected := source.render(locale)
for _, stored := range []string{source.legacyChinese(), source.render(i18nx.LocaleEnUS), source.render(i18nx.LocaleRuRU), source.render(i18nx.LocaleZhCN)} {
got := renderVerifiedFAQChunkContent(ctx, stored, faq)
if got != expected || !strings.Contains(got, faq.Question) || !strings.Contains(got, faq.Answer) || !strings.Contains(got, " another\n问题:line ") {
t.Fatalf("%s changed source text: %q", locale, got)
}
}
}
if faq != original {
t.Fatal("source model mutated")
}
if got := buildFAQChunkContent(faq); got != source.render(i18nx.DefaultLocale) {
t.Fatalf("index did not use configured locale: %q", got)
}
}
func TestFAQUnknownAndStaleDerivedContentStaysRecorded(t *testing.T) {
faq := models.KnowledgeFAQ{Question: "Current Q", Answer: "Current A"}
ctx := i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU)
for _, stored := range []string{
"问题:Old Q\n回答:Historical A",
"问题:Current Q\n回答:Current A\n",
"问题:Current Q\n回答:User-authored custom answer",
"相似问:not a generated chunk",
"{malformed:derived}",
"",
} {
if got := renderVerifiedFAQChunkContent(ctx, stored, faq); got != stored {
t.Fatalf("unverified stored content was rewritten: %q => %q", stored, got)
}
}
}
func TestContextWrappersAreLocalizedWithoutChangingSource(t *testing.T) {
content := " 原始文档\n回答:literal\n "
tests := []struct{ locale, label string }{
{i18nx.LocaleEnUS, "[Document: Document#7 | Section: 用户章节]"},
{i18nx.LocaleRuRU, "[Документ: Документ №7 | Раздел: 用户章节]"},
{i18nx.LocaleZhCN, "【文档:文档#7|章节:用户章节】"},
}
for _, tt := range tests {
ctx := i18nx.WithLocale(context.Background(), tt.locale)
got := Retrieve.BuildContext(ctx, []RetrieveResult{{DocumentID: 7, SectionPath: "用户章节", Content: content}}, 4000)
if got != tt.label+"\n"+content+"\n\n" {
t.Fatalf("%s unexpected context: %q", tt.locale, got)
}
}
}
func TestKnowledgeGenerationDefaultLocaleAndEmptyResponseMetrics(t *testing.T) {
ctx := context.Background()
noHits, err := generateKnowledgeAnswer(ctx, "Q", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, nil)
if err != nil || noHits.Content != i18nx.Get("knowledge.rag.fallback.noAnswer") {
t.Fatalf("default-locale caller changed behavior: %#v, %v", noHits, err)
}
empty := &ai.ChatCompletionResult{Content: " \n", ModelName: "local-test-model", PromptTokens: 12, CompletionTokens: 3}
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "reference"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != noHits.Content || got.ModelName != empty.ModelName || got.PromptTokens != empty.PromptTokens || got.CompletionTokens != empty.CompletionTokens {
t.Fatalf("empty completion must retain usage metrics: %#v, %v", got, err)
}
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -32,6 +33,9 @@ type RetrieveTrace struct {
func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) ([]RetrieveResult, *RetrieveTrace, error) {
trace := newRetrieveTrace()
if err := ctx.Err(); err != nil {
return nil, trace, err
}
retrievableKnowledgeBases, _, ok := s.prepareRetrievableKnowledgeBases(req, trace)
if !ok {
return nil, trace, nil
@@ -47,7 +51,7 @@ func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) (
if len(searchResults) == 0 {
return nil, trace, nil
}
results, hydrateMs := s.hydrateRetrieveResults(searchResults)
results, hydrateMs := s.hydrateRetrieveResults(ctx, searchResults)
trace.HydrateMs = hydrateMs
return results, trace, nil

View File

@@ -1,12 +1,19 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"fmt"
"strings"
"agent-desk/internal/pkg/i18nx"
)
func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int) []RetrieveResult {
return s.SelectContextResultsWithContext(context.Background(), results, maxTokens)
}
func (s *retrieve) SelectContextResultsWithContext(ctx context.Context, results []RetrieveResult, maxTokens int) []RetrieveResult {
if len(results) == 0 {
return nil
}
@@ -20,7 +27,7 @@ func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int)
if documentUsage[item.DocumentID] >= 2 {
continue
}
chunkText := buildContextChunkText(item)
chunkText := buildContextChunkText(ctx, item)
estimatedTokens := len(chunkText) / 2
if totalTokens+estimatedTokens > maxTokens {
break
@@ -32,15 +39,15 @@ func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int)
return selected
}
func (s *retrieve) BuildContext(_ context.Context, results []RetrieveResult, maxTokens int) string {
func (s *retrieve) BuildContext(ctx context.Context, results []RetrieveResult, maxTokens int) string {
if len(results) == 0 {
return ""
}
normalizedResults := s.SelectContextResults(results, maxTokens)
normalizedResults := s.SelectContextResultsWithContext(ctx, results, maxTokens)
var builder strings.Builder
for _, r := range normalizedResults {
builder.WriteString(buildContextChunkText(r))
builder.WriteString(buildContextChunkText(ctx, r))
}
return builder.String()
@@ -125,26 +132,27 @@ func buildSectionKey(item RetrieveResult) string {
return fmt.Sprintf("%d|chunk:%d", item.DocumentID, item.ChunkNo)
}
func buildContextChunkText(item RetrieveResult) string {
func buildContextChunkText(ctx context.Context, item RetrieveResult) string {
locale := i18nx.LocaleFromContext(ctx)
if item.FaqID > 0 {
title := strings.TrimSpace(item.FaqQuestion)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
title = fmt.Sprintf("FAQ#%d", item.FaqID)
title = i18nx.Getf(locale, "knowledge.rag.context.faqTitle", item.FaqID)
}
return fmt.Sprintf("【FAQ:%s】\n%s\n\n", title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.faq", title, item.Content)
}
title := strings.TrimSpace(item.DocumentTitle)
if title == "" {
title = fmt.Sprintf("文档#%d", item.DocumentID)
title = i18nx.Getf(locale, "knowledge.rag.context.documentTitle", item.DocumentID)
}
if item.SectionPath != "" {
return fmt.Sprintf("【文档:%s|章节:%s】\n%s\n\n", title, item.SectionPath, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.documentSection", title, item.SectionPath, item.Content)
}
if item.Title != "" {
return fmt.Sprintf("【文档:%s|标题:%s】\n%s\n\n", title, item.Title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.documentHeading", title, item.Title, item.Content)
}
return fmt.Sprintf("【文档:%s】\n%s\n\n", title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.document", title, item.Content)
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -72,7 +73,7 @@ func (s *retrieve) searchKnowledgeBaseVectors(ctx context.Context, req RetrieveR
return searchResults, trace, nil
}
func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
if len(searchResults) == 0 {
return nil, 0
}
@@ -128,6 +129,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
documentTitle := ""
faqQuestion := ""
content := chunk.Content
if chunk.DocumentID > 0 {
document := documentByID[chunk.DocumentID]
if document == nil || document.Status != enums.StatusOk {
@@ -141,6 +143,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
continue
}
faqQuestion = faq.Question
content = renderVerifiedFAQChunkContent(ctx, chunk.Content, *faq)
}
results = append(results, RetrieveResult{
@@ -153,7 +156,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
ChunkNo: chunk.ChunkNo,
Title: chunk.Title,
SectionPath: chunk.SectionPath,
Content: chunk.Content,
Content: content,
Score: sr.Score,
ChunkType: extractChunkType(sr.Payload),
})

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package bootstrap
import (
@@ -26,6 +29,7 @@ import (
type publicRateLimits struct {
login *ratelimit.Limiter
supportRegister *ratelimit.Limiter
supportStatus *ratelimit.Limiter
sessionExchange *ratelimit.Limiter
upload *ratelimit.Limiter
docFeedback *ratelimit.Limiter
@@ -39,6 +43,7 @@ type publicRateLimits struct {
const (
limitLogin = 20
limitSupportRegister = 10
limitSupportStatus = 120
limitSessionExchange = 120
limitUpload = 30
limitDocFeedback = 20
@@ -57,6 +62,7 @@ func newPublicRateLimits(cfg config.RateLimitConfig) publicRateLimits {
return publicRateLimits{
login: ratelimit.New(limitLogin, window),
supportRegister: ratelimit.New(limitSupportRegister, window),
supportStatus: ratelimit.New(limitSupportStatus, window),
sessionExchange: ratelimit.New(limitSessionExchange, window),
upload: ratelimit.New(limitUpload, window),
docFeedback: ratelimit.New(limitDocFeedback, window),
@@ -87,6 +93,7 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) {
func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange)
group.POST("/session_refresh", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_refresh)
}
func registerApiConversationRoutes(group *gin.RouterGroup) {
@@ -112,6 +119,7 @@ func registerApiMessageRoutes(group *gin.RouterGroup, limits publicRateLimits) {
func registerApiSupportRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.GET("/config", api.SupportConfigGetConfig)
group.GET("/status", middleware.RateLimit(limits.supportStatus), api.SupportGetStatus)
group.POST("/auth/register", middleware.RateLimit(limits.supportRegister), api.SupportAuthPostRegister)
group.GET("/me", api.SupportGetMe)
group.Any("/doc-page/list", api.DocPageAnyList)
@@ -522,3 +530,13 @@ func registerThirdEmailRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/request", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
group.POST("/request/:channel_id", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
}
func registerDashboardSupportAvailabilityRoutes(group *gin.RouterGroup) {
group.GET("/me", dashboard.SupportAvailabilityGetMe)
group.POST("/me", dashboard.SupportAvailabilityPostMe)
group.GET("/agents", dashboard.SupportAvailabilityGetAgents)
group.POST("/agent", dashboard.SupportAvailabilityPostAgent)
group.GET("/calendar", dashboard.SupportAvailabilityGetCalendar)
group.POST("/calendar", dashboard.SupportAvailabilityPostCalendar)
group.POST("/calendar/preview", dashboard.SupportAvailabilityPostCalendarPreview)
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package bootstrap
import (
@@ -192,6 +193,7 @@ func addRouter(app *gin.Engine) {
dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware)
registerDashboardDashboardRoutes(dashboardGroup.Group("/dashboard"))
registerDashboardSetupRoutes(dashboardGroup.Group("/setup"))
registerDashboardSupportAvailabilityRoutes(dashboardGroup.Group("/support_availability"))
registerDashboardUserRoutes(dashboardGroup.Group("/user"))
registerDashboardOrganizationRoutes(dashboardGroup.Group("/organization"))
registerDashboardCompanyRoutes(dashboardGroup.Group("/company"))

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package builders
import (
@@ -45,9 +47,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
ClosedBy: item.ClosedBy,
CloseReason: item.CloseReason,
}
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil {
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
ret.CustomerOnline = services.WsService.IsCustomerOnline(item.CustomerID, item.ChannelID)
if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname

View File

@@ -1,21 +1,23 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
"encoding/json"
"fmt"
)
func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseResponse {
func BuildKnowledgeBase(item *models.KnowledgeBase, locales ...string) response.KnowledgeBaseResponse {
return response.KnowledgeBaseResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
KnowledgeType: item.KnowledgeType,
KnowledgeTypeName: enums.GetKnowledgeBaseTypeLabel(enums.KnowledgeBaseType(item.KnowledgeType)),
KnowledgeTypeName: knowledgeLabel(locales, "type", item.KnowledgeType),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
DefaultTopK: item.DefaultTopK,
DefaultScoreThreshold: item.DefaultScoreThreshold,
DefaultRerankLimit: item.DefaultRerankLimit,
@@ -24,7 +26,7 @@ func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseRespon
ChunkMaxTokens: item.ChunkMaxTokens,
ChunkOverlapTokens: item.ChunkOverlapTokens,
AnswerMode: item.AnswerMode,
AnswerModeName: enums.GetKnowledgeAnswerModeLabel(enums.KnowledgeAnswerMode(item.AnswerMode)),
AnswerModeName: knowledgeLabel(locales, "mode", item.AnswerMode),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -33,18 +35,18 @@ func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseRespon
}
}
func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDocumentResponse {
func BuildKnowledgeDocument(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentResponse {
return response.KnowledgeDocumentResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
ContentHash: item.ContentHash,
ContentType: item.ContentType,
Content: item.Content,
@@ -55,18 +57,18 @@ func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDo
}
}
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.KnowledgeDocumentListResponse {
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentListResponse {
return response.KnowledgeDocumentListResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
ContentHash: item.ContentHash,
ContentType: item.ContentType,
CreatedAt: item.CreatedAt,
@@ -76,7 +78,7 @@ func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.Knowled
}
}
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse {
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ, locales ...string) response.KnowledgeFAQResponse {
return response.KnowledgeFAQResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
@@ -85,11 +87,11 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse
Answer: item.Answer,
SimilarQuestions: parseSimilarQuestions(item.SimilarQuestions),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -98,7 +100,7 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse
}
}
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.KnowledgeDirectoryResponse {
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory, locales ...string) response.KnowledgeDirectoryResponse {
return response.KnowledgeDirectoryResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
@@ -106,7 +108,7 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.Knowledge
Name: item.Name,
SortNo: item.SortNo,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -116,14 +118,14 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.Knowledge
}
}
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.KnowledgeRetrieveLogResponse {
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog, locales ...string) response.KnowledgeRetrieveLogResponse {
return response.KnowledgeRetrieveLogResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
Channel: item.Channel,
ChannelName: enums.GetKnowledgeRetrieveChannelLabel(enums.KnowledgeRetrieveChannel(item.Channel)),
ChannelName: knowledgeLabel(locales, "channel", item.Channel),
Scene: item.Scene,
SceneName: enums.GetKnowledgeRetrieveSceneLabel(enums.KnowledgeRetrieveScene(item.Scene)),
SceneName: knowledgeLabel(locales, "scene", item.Scene),
SessionID: item.SessionID,
ConversationID: item.ConversationID,
RequestID: item.RequestID,
@@ -131,7 +133,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.Knowl
RewriteQuestion: item.RewriteQuestion,
Answer: item.Answer,
AnswerStatus: item.AnswerStatus,
AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(enums.KnowledgeAnswerStatus(item.AnswerStatus)),
AnswerStatusName: knowledgeLabel(locales, "answer", item.AnswerStatus),
HitCount: item.HitCount,
TopScore: item.TopScore,
ChunkProvider: item.ChunkProvider,
@@ -153,7 +155,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.Knowl
}
}
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) response.KnowledgeRetrieveHitResponse {
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit, locales ...string) response.KnowledgeRetrieveHitResponse {
return response.KnowledgeRetrieveHitResponse{
ID: item.ID,
RetrieveLogID: item.RetrieveLogID,
@@ -167,7 +169,7 @@ func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) respon
Title: item.Title,
SectionPath: item.SectionPath,
ChunkType: item.ChunkType,
ChunkTypeName: enums.GetKnowledgeChunkTypeLabel(enums.KnowledgeChunkType(item.ChunkType)),
ChunkTypeName: knowledgeLabel(locales, "chunk", item.ChunkType),
Provider: item.Provider,
RankNo: item.RankNo,
Score: item.Score,
@@ -189,3 +191,41 @@ func parseSimilarQuestions(raw string) []string {
}
return items
}
var knowledgeLabelCodes = map[string]map[string]bool{
"type": {"document": true, "faq": true},
"status": {"0": true, "1": true, "2": true},
"index": {"pending": true, "indexed": true, "failed": true},
"mode": {"1": true, "2": true},
"channel": {"im": true, "agent_assist": true, "api": true, "debug": true},
"scene": {"first_response": true, "assist": true, "qa": true},
"answer": {"1": true, "2": true, "3": true, "4": true},
"chunk": {"text": true, "faq": true, "table": true, "code": true},
}
// Labels are application-owned; titles, content, snippets and historical answers are not.
func knowledgeLabel(locales []string, group string, value any) string {
code := fmt.Sprint(value)
key := "knowledge.labels.unknown"
if knowledgeLabelCodes[group][code] {
key = "knowledge.labels." + group + "." + code
}
return i18nx.Getf(knowledgeLocale(locales), key)
}
func knowledgeLocale(locales []string) string {
if len(locales) > 0 {
return i18nx.NormalizeLocale(locales[0])
}
return i18nx.DefaultLocale
}
// Legacy index_error values can contain SQL, credentials or provider response bodies.
// Keep those values out of the public DTO; this does not rewrite persisted diagnostics.
func knowledgeIndexError(locales []string, stored string) string {
if stored == "" {
return ""
}
return i18nx.Getf(knowledgeLocale(locales), "error.knowledge.indexFailed")
}

View File

@@ -0,0 +1,47 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
"strings"
"testing"
)
func TestKnowledgeBuildersLocalizeLabelsWithoutChangingContent(t *testing.T) {
for _, locale := range []string{"en-US", "ru-RU", "zh-CN"} {
t.Run(locale, func(t *testing.T) {
kb := BuildKnowledgeBase(&models.KnowledgeBase{Name: "用户 название", KnowledgeType: "faq", Status: enums.StatusOk, AnswerMode: 1}, locale)
if kb.Name != "用户 название" || kb.KnowledgeTypeName != i18nx.Getf(locale, "knowledge.labels.type.faq") || kb.StatusName != i18nx.Getf(locale, "knowledge.labels.status.0") {
t.Fatalf("unexpected labels/content: %+v", kb)
}
raw := " 原文 answer\n回答: keep this "
faq := BuildKnowledgeFAQ(&models.KnowledgeFAQ{Question: raw, Answer: raw, Status: enums.StatusDisabled, IndexStatus: enums.KnowledgeDocumentIndexStatusFailed, IndexError: "SQL failed password=fixture-only"}, locale)
if faq.Question != raw || faq.Answer != raw {
t.Fatal("authored content changed")
}
if faq.IndexStatusName != i18nx.Getf(locale, "knowledge.labels.index.failed") || faq.StatusName != i18nx.Getf(locale, "knowledge.labels.status.1") {
t.Fatalf("unexpected labels: %+v", faq)
}
if faq.IndexError != i18nx.Getf(locale, "error.knowledge.indexFailed") || strings.Contains(faq.IndexError, "fixture-only") {
t.Fatalf("unsafe index error %q", faq.IndexError)
}
doc := BuildKnowledgeDocument(&models.KnowledgeDocument{Content: raw, IndexError: "provider raw error"}, locale)
list := BuildKnowledgeDocumentList(&models.KnowledgeDocument{IndexError: "provider raw error"}, locale)
if doc.Content != raw || doc.IndexError != faq.IndexError || list.IndexError != faq.IndexError {
t.Fatal("document content or safe error mismatch")
}
log := BuildKnowledgeRetrieveLog(&models.KnowledgeRetrieveLog{Answer: raw, Question: raw, AnswerStatus: 2, TraceData: raw}, locale)
hit := BuildKnowledgeRetrieveHitResponse(&models.KnowledgeRetrieveHit{Snippet: raw, ChunkType: "faq"}, locale)
if log.Answer != raw || log.Question != raw || log.TraceData != raw || hit.Snippet != raw {
t.Fatal("historical data was rewritten")
}
if log.AnswerStatusName != i18nx.Getf(locale, "knowledge.labels.answer.2") {
t.Fatalf("answer label = %q", log.AnswerStatusName)
}
})
}
if BuildKnowledgeFAQ(&models.KnowledgeFAQ{}, "en-US").IndexError != "" {
t.Fatal("empty error should remain empty")
}
}

View File

@@ -1,7 +1,13 @@
// Modified in the independent hardening fork; see docs/hardening.
package api
import (
"time"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services"
@@ -17,10 +23,11 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
}
identity := config.Current().Identity
externalUser, err := openidentity.GetExternalUser(ctx, openidentity.Secrets{
UserToken: services.ChannelService.GetUserTokenSecret(channel),
IdentityHMAC: identity.Secret,
IdentityMaxAge: identity.MaxAge(),
ChannelID: channel.ChannelID,
UserToken: services.ChannelService.GetUserTokenSecret(channel),
IdentityHMAC: identity.Secret,
IdentityMaxAge: identity.MaxAge(),
ChannelID: channel.ChannelID,
RequireSignedIdentity: services.ChannelService.RequiresSignedIdentity(channel),
})
if err != nil {
httpx.WriteJSON(ctx, err)
@@ -33,3 +40,29 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
}
httpx.WriteJSON(ctx, resp)
}
// Refresh requires possession of the still-valid customer credential; unlike
// unsigned session exchange it never creates or changes an anonymous identity.
func CustomerPostSession_refresh(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209"))
return
}
result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
services.CustomerSessionService.SetRefreshHeaders(ctx, result)
source := "guest:"
if result.ExternalUser.ExternalSource == enums.ExternalSourceUser {
source = "user:"
}
httpx.WriteJSON(ctx, &response.CustomerSessionExchangeResponse{
CustomerSessionToken: result.Token,
ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339),
IdentityKey: source + result.ExternalUser.ExternalID,
Customer: response.CustomerSessionCustomerResponse{ID: result.CustomerID, Name: result.ExternalUser.ExternalName},
})
}

View File

@@ -0,0 +1,22 @@
package api
import (
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
)
func SupportGetStatus(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209"))
return
}
result, err := services.SupportAvailabilityService.PublicStatus(channel)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
httpx.WriteJSON(ctx, result)
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package dashboard
import (
@@ -18,13 +19,18 @@ func AgentAnyList(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
list, paging := services.AgentProfileService.FindPageByCnd(params.NewPagedSqlCnd(ctx,
cnd := params.NewPagedSqlCnd(ctx,
params.QueryFilter{ParamName: "userId"},
params.QueryFilter{ParamName: "teamId"},
params.QueryFilter{ParamName: "serviceStatus"},
params.QueryFilter{ParamName: "agentCode", Op: params.Like},
params.QueryFilter{ParamName: "displayName", Op: params.Like},
).Desc("id"))
).Desc("id")
status, _ := params.Get(ctx, "serviceStatus")
if err := services.AgentProfileService.FilterEffectiveServiceStatus(cnd, status); err != nil {
httpx.WriteJSON(ctx, err)
return
}
list, paging := services.AgentProfileService.FindPageByCnd(cnd)
results := builders.BuildAgentProfileList(list)
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
}
@@ -34,12 +40,17 @@ func AgentGetList_all(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
list := services.AgentProfileService.Find(params.NewPagedSqlCnd(ctx,
cnd := params.NewPagedSqlCnd(ctx,
params.QueryFilter{ParamName: "userId"},
params.QueryFilter{ParamName: "teamId"},
params.QueryFilter{ParamName: "serviceStatus"},
params.QueryFilter{ParamName: "agentCode", Op: params.Like},
).Desc("id"))
).Desc("id")
status, _ := params.Get(ctx, "serviceStatus")
if err := services.AgentProfileService.FilterEffectiveServiceStatus(cnd, status); err != nil {
httpx.WriteJSON(ctx, err)
return
}
list := services.AgentProfileService.Find(cnd)
httpx.WriteJSON(ctx, builders.BuildAgentProfileList(list))
}

View File

@@ -1,7 +1,9 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"context"
"log/slog"
@@ -35,7 +37,7 @@ func KnowledgeBaseAnyList(ctx *gin.Context) {
for _, item := range list {
docCount := repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
faqCount := repositories.KnowledgeFAQRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
resp := builders.BuildKnowledgeBase(&item)
resp := builders.BuildKnowledgeBase(&item, i18nx.Locale(ctx))
resp.DocumentCount = docCount
resp.FAQCount = faqCount
results = append(results, resp)
@@ -54,7 +56,7 @@ func KnowledgeBaseAnyList_all(ctx *gin.Context) {
).Asc("sort_no").Desc("id"))
results := make([]response.KnowledgeBaseResponse, 0, len(list))
for _, item := range list {
resp := builders.BuildKnowledgeBase(&item)
resp := builders.BuildKnowledgeBase(&item, i18nx.Locale(ctx))
results = append(results, resp)
}
httpx.WriteJSON(ctx, results)
@@ -75,7 +77,7 @@ func KnowledgeBaseGetBy(ctx *gin.Context) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0283"))
return
}
resp := builders.BuildKnowledgeBase(item)
resp := builders.BuildKnowledgeBase(item, i18nx.Locale(ctx))
resp.DocumentCount = repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
resp.FAQCount = repositories.KnowledgeFAQRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
httpx.WriteJSON(ctx, resp)
@@ -98,7 +100,7 @@ func KnowledgeBasePostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildKnowledgeBase(item))
httpx.WriteJSON(ctx, builders.BuildKnowledgeBase(item, i18nx.Locale(ctx)))
}
func KnowledgeBasePostUpdate(ctx *gin.Context) {

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
@@ -9,6 +10,7 @@ import (
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
@@ -25,7 +27,7 @@ func KnowledgeDirectoryGetList_all(ctx *gin.Context) {
return
}
list := services.KnowledgeDirectoryService.FindAllByKnowledgeBaseID(knowledgeBaseID)
httpx.WriteJSON(ctx, buildKnowledgeDirectoryTreeResponses(list))
httpx.WriteJSON(ctx, buildKnowledgeDirectoryTreeResponses(list, i18nx.Locale(ctx)))
}
func KnowledgeDirectoryPostCreate(ctx *gin.Context) {
@@ -44,7 +46,7 @@ func KnowledgeDirectoryPostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
resp := builders.BuildKnowledgeDirectory(item)
resp := builders.BuildKnowledgeDirectory(item, i18nx.Locale(ctx))
httpx.WriteJSON(ctx, &resp)
}
@@ -104,7 +106,7 @@ func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) {
httpx.WriteJSON(ctx, nil)
}
func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory) []response.KnowledgeDirectoryResponse {
func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory, locales ...string) []response.KnowledgeDirectoryResponse {
childrenByParent := make(map[int64][]models.KnowledgeDirectory, len(list))
for _, item := range list {
childrenByParent[item.ParentID] = append(childrenByParent[item.ParentID], item)
@@ -114,7 +116,7 @@ func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory) []re
items := childrenByParent[parentID]
ret := make([]response.KnowledgeDirectoryResponse, 0, len(items))
for _, item := range items {
resp := builders.BuildKnowledgeDirectory(&item)
resp := builders.BuildKnowledgeDirectory(&item, knowledgeDirectoryLocale(locales))
resp.Children = build(item.ID)
ret = append(ret, resp)
}
@@ -122,3 +124,10 @@ func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory) []re
}
return build(0)
}
func knowledgeDirectoryLocale(locales []string) string {
if len(locales) > 0 {
return locales[0]
}
return i18nx.DefaultLocale
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
@@ -7,6 +8,7 @@ import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"agent-desk/internal/pkg/httpx/params"
@@ -47,7 +49,7 @@ func KnowledgeDocumentAnyList(ctx *gin.Context) {
directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID)
results := make([]response.KnowledgeDocumentListResponse, 0, len(list))
for _, item := range list {
resp := builders.BuildKnowledgeDocumentList(&item)
resp := builders.BuildKnowledgeDocumentList(&item, i18nx.Locale(ctx))
fillKnowledgeDocumentDirectory(&resp, directoryPaths)
results = append(results, resp)
}
@@ -69,7 +71,7 @@ func KnowledgeDocumentGetBy(ctx *gin.Context) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0218"))
return
}
resp := builders.BuildKnowledgeDocument(item)
resp := builders.BuildKnowledgeDocument(item, i18nx.Locale(ctx))
fillKnowledgeDocumentDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID))
httpx.WriteJSON(ctx, resp)
}
@@ -102,7 +104,7 @@ func KnowledgeDocumentPostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildKnowledgeDocument(item))
httpx.WriteJSON(ctx, builders.BuildKnowledgeDocument(item, i18nx.Locale(ctx)))
}
func KnowledgeDocumentPostUpdate(ctx *gin.Context) {

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
@@ -23,7 +24,7 @@ func KnowledgeFAQGetImport_template(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate(i18nx.Locale(ctx))
if err != nil {
httpx.WriteJSON(ctx, err)
return
@@ -41,7 +42,7 @@ func KnowledgeFAQGetExport(ctx *gin.Context) {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0283"))
return
}
file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID)
file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID, i18nx.Locale(ctx))
if err != nil {
httpx.WriteJSON(ctx, err)
return
@@ -111,7 +112,7 @@ func KnowledgeFAQAnyList(ctx *gin.Context) {
directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID)
results := make([]response.KnowledgeFAQResponse, 0, len(list))
for _, item := range list {
resp := builders.BuildKnowledgeFAQ(&item)
resp := builders.BuildKnowledgeFAQ(&item, i18nx.Locale(ctx))
fillKnowledgeFAQDirectory(&resp, directoryPaths)
results = append(results, resp)
}
@@ -133,7 +134,7 @@ func KnowledgeFAQGetBy(ctx *gin.Context) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0025"))
return
}
resp := builders.BuildKnowledgeFAQ(item)
resp := builders.BuildKnowledgeFAQ(item, i18nx.Locale(ctx))
fillKnowledgeFAQDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID))
httpx.WriteJSON(ctx, resp)
}
@@ -154,7 +155,7 @@ func KnowledgeFAQPostCreate(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item))
httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item, i18nx.Locale(ctx)))
}
func KnowledgeFAQPostUpdate(ctx *gin.Context) {

View File

@@ -1,15 +1,18 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
"agent-desk/internal/pkg/httpx"
"context"
"errors"
"agent-desk/internal/ai/rag"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/services"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
)
@@ -26,9 +29,9 @@ func KnowledgeRetrievePostDebugSearch(ctx *gin.Context) {
return
}
resp, err := rag.Answer.DebugSearch(context.Background(), req)
resp, err := rag.Answer.DebugSearch(knowledgeRequestContext(ctx), req)
if err != nil {
httpx.WriteJSON(ctx, err)
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.retrieveFailed"))
return
}
httpx.WriteJSON(ctx, resp)
@@ -47,9 +50,9 @@ func KnowledgeRetrievePostDebugAnswer(ctx *gin.Context) {
return
}
resp, err := rag.Answer.DebugAnswer(context.Background(), req, operator)
resp, err := rag.Answer.DebugAnswer(knowledgeRequestContext(ctx), req, operator)
if err != nil {
httpx.WriteJSON(ctx, err)
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.retrieveFailed"))
return
}
httpx.WriteJSON(ctx, resp)
@@ -70,8 +73,8 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
if err := rag.Answer.BuildDocumentIndex(context.Background(), req.DocumentID); err != nil {
httpx.WriteJSON(ctx, err)
if err := rag.Answer.BuildDocumentIndex(knowledgeRequestContext(ctx), req.DocumentID); err != nil {
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.indexFailed"))
return
}
httpx.WriteJSON(ctx, nil)
@@ -83,8 +86,8 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
if err := rag.Index.IndexFAQByID(context.Background(), req.FAQID); err != nil {
httpx.WriteJSON(ctx, err)
if err := rag.Index.IndexFAQByID(knowledgeRequestContext(ctx), req.FAQID); err != nil {
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.indexFailed"))
return
}
httpx.WriteJSON(ctx, nil)
@@ -93,3 +96,29 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0066"))
}
func knowledgeRequestContext(ctx *gin.Context) context.Context {
parent := context.Background()
if ctx != nil && ctx.Request != nil {
parent = ctx.Request.Context()
}
return i18nx.WithLocale(parent, i18nx.Locale(ctx))
}
// Only catalog-owned errors without arbitrary diagnostic arguments are safe to
// return from index/embedding/vector providers. Unwrap the application error so
// a surrounding SQL/provider message never reaches the response.
func safeKnowledgeOperationError(err error, fallbackKey string) error {
if err == nil {
return nil
}
var appErr *errorsx.I18nError
if errors.As(err, &appErr) && appErr != nil && len(appErr.Args) == 0 && i18nx.Get(appErr.Key) != appErr.Key {
return appErr
}
var localizedErr *i18nx.Error
if errors.As(err, &localizedErr) && localizedErr != nil && len(localizedErr.Args) == 0 && i18nx.Get(localizedErr.Key) != localizedErr.Key {
return i18nx.Errorf(localizedErr.Key)
}
return errorsx.BusinessErrorI18n(0, fallbackKey)
}

View File

@@ -0,0 +1,77 @@
package dashboard
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web"
)
func TestKnowledgeRequestContextPreservesLocaleAndCancellation(t *testing.T) {
parent, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/knowledge_retrieve/debug_answer", nil).WithContext(parent)
ctx.Request.Header.Set("X-Locale", i18nx.LocaleZhCN)
ctx.Request.Header.Set("Accept-Language", i18nx.LocaleZhCN)
i18nx.SetLocale(ctx, i18nx.LocaleRuRU)
derived := knowledgeRequestContext(ctx)
if i18nx.LocaleFromContext(derived) != i18nx.LocaleRuRU {
t.Fatal("request locale was lost")
}
parentDeadline, _ := parent.Deadline()
if deadline, ok := derived.Deadline(); !ok || !deadline.Equal(parentDeadline) {
t.Fatal("request deadline was lost")
}
cancel()
if !errors.Is(derived.Err(), context.Canceled) {
t.Fatal("request cancellation was lost")
}
}
func TestKnowledgeOperationErrorsHideProviderDiagnostics(t *testing.T) {
const diagnostic = "synthetic-private-diagnostic"
for _, key := range []string{"error.knowledge.indexFailed", "error.knowledge.retrieveFailed"} {
for _, cause := range []error{
errors.New("SQL failed: " + diagnostic),
fmt.Errorf("provider request failed: %w", errors.New(diagnostic)),
web.NewError(2000, diagnostic),
errorsx.BusinessErrorI18n(0, "error.e0340", diagnostic),
errorsx.BusinessErrorI18n(0, "unknown.translation."+diagnostic),
} {
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
i18nx.SetLocale(ctx, locale)
httpx.WriteJSON(ctx, safeKnowledgeOperationError(cause, key))
body := recorder.Body.String()
if strings.Contains(body, diagnostic) || !strings.Contains(body, i18nx.Getf(locale, key)) {
t.Fatalf("unsafe/unlocalized %s response: %s", key, body)
}
}
}
}
}
func TestKnowledgeOperationErrorPreservesCatalogApplicationError(t *testing.T) {
original := errorsx.InvalidParamI18n("error.e0340")
wrapped := fmt.Errorf("private provider details: %w", original)
if got := safeKnowledgeOperationError(wrapped, "error.knowledge.retrieveFailed"); got != original {
t.Fatalf("application error lost: %v", got)
}
localized := i18nx.Errorf("error.embeddingModel.noneEnabled")
got := safeKnowledgeOperationError(fmt.Errorf("private: %w", localized), "error.knowledge.indexFailed")
if got.Error() != localized.Error() || strings.Contains(got.Error(), "private") {
t.Fatalf("localized application error changed/leaked: %v", got)
}
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package dashboard
import (
@@ -5,6 +6,7 @@ import (
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"agent-desk/internal/pkg/httpx/params"
@@ -39,7 +41,7 @@ func KnowledgeRetrieveLogAnyList(ctx *gin.Context) {
list, paging := services.KnowledgeRetrieveLogService.FindPageByParams(queryParams)
results := make([]response.KnowledgeRetrieveLogResponse, 0, len(list))
for _, item := range list {
resp := builders.BuildKnowledgeRetrieveLog(&item)
resp := builders.BuildKnowledgeRetrieveLog(&item, i18nx.Locale(ctx))
if knowledgeBase := services.KnowledgeBaseService.Get(item.KnowledgeBaseID); knowledgeBase != nil {
resp.KnowledgeBaseName = knowledgeBase.Name
}
@@ -64,7 +66,7 @@ func KnowledgeRetrieveLogGetBy(ctx *gin.Context) {
return
}
logResp := builders.BuildKnowledgeRetrieveLog(logItem)
logResp := builders.BuildKnowledgeRetrieveLog(logItem, i18nx.Locale(ctx))
if knowledgeBase := services.KnowledgeBaseService.Get(logItem.KnowledgeBaseID); knowledgeBase != nil {
logResp.KnowledgeBaseName = knowledgeBase.Name
}
@@ -72,7 +74,7 @@ func KnowledgeRetrieveLogGetBy(ctx *gin.Context) {
hits := services.KnowledgeRetrieveLogService.FindHitsByRetrieveLogID(id)
hitResults := make([]response.KnowledgeRetrieveHitResponse, 0, len(hits))
for _, item := range hits {
hitResults = append(hitResults, builders.BuildKnowledgeRetrieveHitResponse(&item))
hitResults = append(hitResults, builders.BuildKnowledgeRetrieveHitResponse(&item, i18nx.Locale(ctx)))
}
httpx.WriteJSON(ctx, response.KnowledgeRetrieveLogDetailResponse{

View File

@@ -0,0 +1,152 @@
package dashboard
import (
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
"slices"
"strconv"
)
func SupportAvailabilityGetMe(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
principal := services.AuthService.GetAuthPrincipal(ctx)
if principal == nil {
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
item, err := services.SupportAvailabilityService.ForUser(principal.UserID, true)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, item)
}
func SupportAvailabilityPostMe(ctx *gin.Context) {
principal := services.AuthService.GetAuthPrincipal(ctx)
if principal == nil {
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
req := request.SupportAvailabilityRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
// Never accept a target from a self-service request.
item, err := services.SupportAvailabilityService.Update(principal.UserID, req, principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, item)
}
func SupportAvailabilityGetAgents(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
principal := services.AuthService.GetAuthPrincipal(ctx)
if principal == nil {
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
if !slices.Contains(principal.Permissions, constants.PermissionAgentView.Code) && !slices.Contains(principal.Permissions, constants.PermissionConversationAssign.Code) && !slices.Contains(principal.Permissions, constants.PermissionConversationTransfer.Code) {
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.e0225"))
return
}
id, _ := strconv.ParseInt(ctx.Query("conversationId"), 10, 64)
if id <= 0 && !slices.Contains(principal.Permissions, constants.PermissionAgentView.Code) {
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.e0225"))
return
}
items, err := services.SupportAvailabilityService.List(id, principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, items)
}
func SupportAvailabilityPostAgent(ctx *gin.Context) {
principal, err := services.AuthService.RequirePermission(ctx, constants.PermissionAgentUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.SupportAvailabilityRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportAvailabilityService.Update(req.UserID, req, principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, item)
}
func supportCalendarPermission(ctx *gin.Context, teamID int64, write bool) (*dto.AuthPrincipal, error) {
if teamID == 0 {
if write {
return services.AuthService.RequirePermission(ctx, constants.PermissionSupportConfigUpdate)
}
return services.AuthService.RequirePermission(ctx, constants.PermissionSupportConfigView)
}
if write {
return services.AuthService.RequirePermission(ctx, constants.PermissionAgentTeamScheduleUpdate)
}
return services.AuthService.RequirePermission(ctx, constants.PermissionAgentTeamScheduleView)
}
func SupportAvailabilityGetCalendar(ctx *gin.Context) {
teamID, err := strconv.ParseInt(ctx.DefaultQuery("teamId", "0"), 10, 64)
if err != nil || teamID < 0 {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.supportAvailability.invalidCalendar"))
return
}
if _, err := supportCalendarPermission(ctx, teamID, false); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportCalendarService.Get(teamID)
if err != nil {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.supportAvailability.persistence"))
return
}
httpx.WriteJSON(ctx, item)
}
func SupportAvailabilityPostCalendar(ctx *gin.Context) {
req := request.SupportCalendarRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if _, err := supportCalendarPermission(ctx, req.TeamID, true); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportCalendarService.Save(req)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, item)
}
func SupportAvailabilityPostCalendarPreview(ctx *gin.Context) {
req := request.SupportCalendarPreviewRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if _, err := supportCalendarPermission(ctx, req.Calendar.TeamID, false); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportCalendarService.Preview(req)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, item)
}

View File

@@ -0,0 +1,121 @@
package dashboard
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func availabilityHandlerDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "availability.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.User{}, &models.AgentProfile{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.Conversation{}); err != nil {
t.Fatal(err)
}
previous := sqls.DB()
sqls.SetDB(db)
t.Cleanup(func() { sqls.SetDB(previous); conn, _ := db.DB(); conn.Close() })
for _, row := range []any{&models.User{ID: 1, Username: "one", UserType: enums.UserTypeEmployee}, &models.User{ID: 2, Username: "two", UserType: enums.UserTypeEmployee}, &models.AgentTeam{ID: 1, Name: "Support"}, &models.AgentProfile{ID: 1, UserID: 1, TeamID: 1, AgentCode: "one"}, &models.AgentProfile{ID: 2, UserID: 2, TeamID: 1, AgentCode: "two"}} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func callAvailabilityHandler(t *testing.T, handler gin.HandlerFunc, principal *dto.AuthPrincipal, body string) map[string]any {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
if principal != nil {
c.Set("authPrincipal", principal)
}
c.Next()
})
router.POST("/test", handler)
req := httptest.NewRequest("POST", "/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var result map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func TestSupportAvailabilitySelfCannotTargetAnotherUser(t *testing.T) {
db := availabilityHandlerDB(t)
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", UserType: enums.UserTypeEmployee}
got := callAvailabilityHandler(t, SupportAvailabilityPostMe, principal, `{"userId":2,"serviceStatus":2}`)
if got["success"] != true {
t.Fatalf("self update: %v", got)
}
var own, other models.AgentProfile
db.First(&own, 1)
db.First(&other, 2)
if own.ServiceStatus != enums.ServiceStatusBreak || other.ServiceStatus != enums.ServiceStatusIdle {
t.Fatal("self endpoint changed another profile")
}
}
func TestSupportAvailabilitySelfDoesNotProvisionMissingProfile(t *testing.T) {
db := availabilityHandlerDB(t)
principal := &dto.AuthPrincipal{UserID: 99, Username: "missing", UserType: enums.UserTypeEmployee}
got := callAvailabilityHandler(t, SupportAvailabilityPostMe, principal, `{"serviceStatus":0}`)
if got["success"] == true {
t.Fatal("missing operator gained profile")
}
var count int64
db.Model(&models.AgentProfile{}).Count(&count)
if count != 2 {
t.Fatalf("profile count %d", count)
}
}
func TestSupportAvailabilityAdminRequiresExactPermission(t *testing.T) {
db := availabilityHandlerDB(t)
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", Permissions: []string{constants.PermissionAgentView.Code}}
got := callAvailabilityHandler(t, SupportAvailabilityPostAgent, principal, `{"userId":2,"serviceStatus":3}`)
if got["success"] == true {
t.Fatal("read permission changed another operator")
}
var other models.AgentProfile
db.First(&other, 2)
if other.ServiceStatus != enums.ServiceStatusIdle {
t.Fatal("unauthorized mutation")
}
principal.Permissions = []string{constants.PermissionAgentUpdate.Code}
got = callAvailabilityHandler(t, SupportAvailabilityPostAgent, principal, `{"userId":2,"serviceStatus":3}`)
if got["success"] != true {
t.Fatalf("authorized update: %v", got)
}
}
func TestSupportCalendarDeploymentAndTeamPermissionsDoNotOverlap(t *testing.T) {
db := availabilityHandlerDB(t)
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", Permissions: []string{constants.PermissionAgentTeamScheduleUpdate.Code}}
got := callAvailabilityHandler(t, SupportAvailabilityPostCalendar, principal, `{"teamId":0,"mode":"recurring","timezone":"Europe/Berlin","weekly":[],"exceptions":[]}`)
if got["success"] == true {
t.Fatal("team editor changed deployment default")
}
principal.Permissions = []string{constants.PermissionSupportConfigUpdate.Code}
got = callAvailabilityHandler(t, SupportAvailabilityPostCalendar, principal, `{"teamId":1,"mode":"recurring","timezone":"Europe/Berlin","weekly":[],"exceptions":[]}`)
if got["success"] == true {
t.Fatal("deployment editor changed team schedule")
}
var count int64
db.Model(&models.SupportCalendar{}).Count(&count)
if count != 0 {
t.Fatal("denied mutations persisted")
}
}

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package models
import (
@@ -45,6 +48,8 @@ var Models = []any{
&AgentProfile{},
&AgentTeam{},
&AgentTeamSchedule{},
&SupportCalendar{},
&AgentPresenceLease{},
&AIConfig{},
&KnowledgeBase{},
&KnowledgeDirectory{},
@@ -250,6 +255,10 @@ type Customer struct {
// CustomerIdentity 客户第三方身份映射表。
type CustomerIdentity struct {
// Empty scope is legacy/internal integration data; public sessions never fall back to it.
IdentityScope string `gorm:"type:varchar(80);not null;default:'';index:idx_customer_identity_scope"`
// Nullable for legacy rows; all new public scoped identities claim a unique digest.
ScopedKey *string `gorm:"type:varchar(64);uniqueIndex:uk_customer_scoped_key"`
ID int64 `gorm:"primaryKey;autoIncrement"`
CustomerID int64 `gorm:"type:bigint;not null;uniqueIndex:uk_customer_external"` // 为所属客户ID。
ExternalSource enums.ExternalSource `gorm:"type:varchar(30);uniqueIndex:uk_customer_external"` // 为外部身份来源
@@ -777,21 +786,25 @@ type TicketProgress struct {
// AgentProfile 客服档案。
type AgentProfile struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为客服档案主键。
UserID int64 `gorm:"type:bigint;not null;uniqueIndex"` // UserID 关联后台用户,一名用户只允许一份客服档案。
TeamID int64 `gorm:"type:bigint;not null;default:0;index"` // TeamID 为客服所属客服组。
AgentCode string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // AgentCode 为客服工号,用于业务侧识别客服。
DisplayName string `gorm:"type:varchar(100);not null;default:'';index"` // DisplayName 为客服展示名,可区别于后台昵称。
Avatar string `gorm:"type:varchar(1024);not null;default:''"` // Avatar 为客服头像 URL。
ServiceStatus enums.ServiceStatus `gorm:"type:int;not null;default:0;index"` // ServiceStatus 表示客服服务状态:0空闲 1忙碌。
MaxConcurrentCount int `gorm:"type:int;not null;default:0"` // MaxConcurrentCount 表示客服最大并发接待数。
PriorityLevel int `gorm:"type:int;not null;default:0;index"` // PriorityLevel 表示自动分配优先级,值越大越优先。
AutoAssignEnabled bool `gorm:"not null;default:true;index"` // AutoAssignEnabled 表示是否参与自动分配。
ReceiveOfflineMessage bool `gorm:"not null;default:false"` // ReceiveOfflineMessage 表示离线时是否仍接收离线消息或转接消息。
LastOnlineAt *time.Time `gorm:"index"` // LastOnlineAt 记录最近一次在线时间。
LastStatusAt *time.Time `gorm:"index"` // LastStatusAt 记录最近一次状态变更时间。
Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 表示客服档案状态
Remark string `gorm:"type:text"` // Remark 记录客服备注信息。
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为客服档案主键。
UserID int64 `gorm:"type:bigint;not null;uniqueIndex"` // UserID 关联后台用户,一名用户只允许一份客服档案。
TeamID int64 `gorm:"type:bigint;not null;default:0;index"` // TeamID 为客服所属客服组。
AgentCode string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // AgentCode 为客服工号,用于业务侧识别客服。
DisplayName string `gorm:"type:varchar(100);not null;default:'';index"` // DisplayName 为客服展示名,可区别于后台昵称。
Avatar string `gorm:"type:varchar(1024);not null;default:''"` // Avatar 为客服头像 URL。
AvailabilityReturnStatus enums.ServiceStatus `gorm:"not null;default:0"`
AbsenceStartAt *time.Time `gorm:"index"`
AbsenceEndAt *time.Time `gorm:"index"`
AssignmentVersion int64 `gorm:"not null;default:0"`
ServiceStatus enums.ServiceStatus `gorm:"type:int;not null;default:0;index"` // ServiceStatus stores working state; effective vacation state is evaluated from its dated interval.
MaxConcurrentCount int `gorm:"type:int;not null;default:0"` // MaxConcurrentCount limits active conversations; zero is unlimited.
PriorityLevel int `gorm:"type:int;not null;default:0;index"` // PriorityLevel 表示自动分配优先级,值越大越优先。
AutoAssignEnabled bool `gorm:"not null;default:true;index"` // AutoAssignEnabled 表示是否参与自动分配。
ReceiveOfflineMessage bool `gorm:"not null;default:false"` // ReceiveOfflineMessage is deprecated compatibility data; it never bypasses live eligibility.
LastOnlineAt *time.Time `gorm:"index"` // LastOnlineAt is historical metadata, not the source of live platform presence.
LastStatusAt *time.Time `gorm:"index"` // LastStatusAt 记录最近一次状态变更时间。
Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 表示客服档案状态
Remark string `gorm:"type:text"` // Remark 记录客服备注信息。
AuditFields
}

View File

@@ -0,0 +1,24 @@
package models
import "time"
// SupportCalendar is opt-in. Absence of a team row preserves dated schedules.
// TeamID zero stores the deployment-wide timezone, not another organization.
type SupportCalendar struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TeamID int64 `gorm:"not null;uniqueIndex"`
Mode string `gorm:"type:varchar(20);not null;default:'legacy'"`
Timezone string `gorm:"type:varchar(100);not null;default:''"`
WeeklyJSON string `gorm:"type:text;not null"`
ExceptionsJSON string `gorm:"type:text;not null"`
UpdatedAt time.Time
}
// AgentPresenceLease contains no bearer credential or client address. Multiple
// independent connections may lease presence for the same authenticated user.
type AgentPresenceLease struct {
ID string `gorm:"type:varchar(64);primaryKey"`
UserID int64 `gorm:"not null;index"`
LoginSessionID int64 `gorm:"not null;index"`
ExpiresAt time.Time `gorm:"not null;index"`
}

View File

@@ -1,6 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package dto
import "agent-desk/internal/pkg/enums"
import (
"agent-desk/internal/pkg/enums"
"time"
)
type AuthPrincipal struct {
UserID int64
@@ -11,16 +16,22 @@ type AuthPrincipal struct {
Status enums.Status
Roles []string
Permissions []string
// Session metadata stays internal and binds long-lived connections to the
// original login session and its absolute deadline.
LoginSessionID int64 `json:"-"`
ExpiresAt time.Time `json:"-"`
}
type WebChannelConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
}
type TelegramChannelConfig struct {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package request
type SetupSimulateRouteRequest struct {
@@ -6,6 +7,8 @@ type SetupSimulateRouteRequest struct {
}
type SetupQuickFixRequest struct {
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Apply bool `json:"apply" form:"apply"`
PreviewToken string `json:"preview_token" form:"preview_token"`
}

View File

@@ -0,0 +1,34 @@
package request
import "agent-desk/internal/pkg/enums"
type SupportCalendarPeriod struct {
Start string `json:"start"`
End string `json:"end"`
}
type SupportCalendarWeeklyPeriod struct {
Weekday int `json:"weekday"`
SupportCalendarPeriod
}
type SupportCalendarException struct {
Date string `json:"date"`
Closed bool `json:"closed"`
Periods []SupportCalendarPeriod `json:"periods"`
}
type SupportCalendarRequest struct {
TeamID int64 `json:"teamId"`
Mode string `json:"mode"`
Timezone string `json:"timezone"`
Weekly []SupportCalendarWeeklyPeriod `json:"weekly"`
Exceptions []SupportCalendarException `json:"exceptions"`
}
type SupportCalendarPreviewRequest struct {
Calendar SupportCalendarRequest `json:"calendar"`
At string `json:"at"`
}
type SupportAvailabilityRequest struct {
UserID int64 `json:"userId"`
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
AbsenceStartAt string `json:"absenceStartAt"`
AbsenceEndAt string `json:"absenceEndAt"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package response
type SetupIssueItem struct {
@@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct {
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
}
type SetupQuickFixResponse struct {
AppliedActions []string `json:"applied_actions"`
Readiness SetupReadinessResponse `json:"readiness"`
type SetupQuickFixChange struct {
EntityType string `json:"entity_type"`
EntityID int64 `json:"entity_id"`
EntityName string `json:"entity_name"`
Operation string `json:"operation"`
Before map[string]any `json:"before"`
After map[string]any `json:"after"`
}
type SetupQuickFixResponse struct {
Applied bool `json:"applied"`
PreviewToken string `json:"preview_token"`
Changes []SetupQuickFixChange `json:"changes"`
WarningCodes []string `json:"warning_codes"`
AppliedActions []string `json:"applied_actions"`
Readiness *SetupReadinessResponse `json:"readiness,omitempty"`
}

View File

@@ -0,0 +1,41 @@
package response
import (
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
)
type SupportCalendarResponse struct {
request.SupportCalendarRequest
EffectiveTimezone string `json:"effectiveTimezone"`
DeploymentTimezone string `json:"deploymentTimezone"`
}
type SupportCalendarPreviewResponse struct {
Open bool `json:"open"`
Timezone string `json:"timezone"`
Mode string `json:"mode"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}
type SupportAvailabilityResponse struct {
UserID int64 `json:"userId"`
ProfileID int64 `json:"profileId"`
TeamID int64 `json:"teamId"`
DisplayName string `json:"displayName"`
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
ReturnServiceStatus enums.ServiceStatus `json:"returnServiceStatus"`
EffectiveServiceStatus enums.ServiceStatus `json:"effectiveServiceStatus"`
AbsenceStartAt string `json:"absenceStartAt,omitempty"`
AbsenceEndAt string `json:"absenceEndAt,omitempty"`
Online bool `json:"online"`
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
Calendar SupportCalendarResponse `json:"calendar"`
CalendarOpen bool `json:"calendarOpen"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}
type PublicSupportStatusResponse struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Timezone string `json:"timezone"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package enums
type AgentRunResolutionStatus string
@@ -31,18 +32,27 @@ var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{
type ServiceStatus int
const (
ServiceStatusIdle ServiceStatus = 0
ServiceStatusBusy ServiceStatus = 1
ServiceStatusIdle ServiceStatus = 0
ServiceStatusBusy ServiceStatus = 1
ServiceStatusBreak ServiceStatus = 2
ServiceStatusUnavailable ServiceStatus = 3
ServiceStatusVacation ServiceStatus = 4
)
var ServiceStatusValues = []ServiceStatus{
ServiceStatusIdle,
ServiceStatusBusy,
ServiceStatusBreak,
ServiceStatusUnavailable,
ServiceStatusVacation,
}
var serviceStatusLabelMap = map[ServiceStatus]string{
ServiceStatusIdle: "空闲",
ServiceStatusBusy: "忙碌",
ServiceStatusIdle: "空闲",
ServiceStatusBusy: "忙碌",
ServiceStatusBreak: "休息",
ServiceStatusUnavailable: "不可用",
ServiceStatusVacation: "休假",
}
func IsValidServiceStatus(status ServiceStatus) bool {

View File

@@ -1,3 +1,4 @@
// Modified by the independent AgentDesk fork for support reliability and availability.
package eventbus
import (
@@ -28,6 +29,7 @@ type Bus[T any] struct {
// 用于限制 PublishAsync 的 goroutine 并发数;nil 表示不限制
asyncSem chan struct{}
asyncWG sync.WaitGroup
}
func New[T any](opts ...Option[T]) *Bus[T] {
@@ -130,10 +132,21 @@ func (b *Bus[T]) Publish(ctx context.Context, event T) error {
func (b *Bus[T]) PublishAsync(ctx context.Context, event T) {
handlers := b.snapshotHandlers()
for _, h := range handlers {
go b.callHandlerAsync(ctx, h, event)
b.asyncWG.Add(1)
go func() {
defer b.asyncWG.Done()
b.callHandlerAsync(ctx, h, event)
}()
}
}
// Wait blocks until already-published asynchronous handlers and their error
// callbacks finish. Stop external publishers before calling Wait; a handler
// may still publish nested work while its own invocation is pending.
func (b *Bus[T]) Wait() {
b.asyncWG.Wait()
}
func (b *Bus[T]) HandlerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()

View File

@@ -1,3 +1,4 @@
// Modified by the independent AgentDesk fork for support reliability and availability.
package eventbus
import (
@@ -197,3 +198,21 @@ func TestPublishAsyncAllowsReentrantPublishWithConcurrencyLimit(t *testing.T) {
t.Fatalf("expected reentrant async publish to complete")
}
}
func TestWaitJoinsNestedAsyncHandlersAndErrorCallbacks(t *testing.T) {
var handled atomic.Int64
bus := New[testEvent](WithAsyncConcurrency[testEvent](1), WithErrorHandler[testEvent](func(context.Context, error) {
handled.Add(1)
}))
bus.Subscribe(func(ctx context.Context, event testEvent) error {
if event.ID == 1 {
bus.PublishAsync(ctx, testEvent{ID: 2})
}
return errors.New("expected fixture error")
})
bus.PublishAsync(context.Background(), testEvent{ID: 1})
bus.Wait()
if handled.Load() != 2 {
t.Fatalf("Wait returned before nested work: %d error callbacks", handled.Load())
}
}

View File

@@ -1,6 +1,11 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package i18nx
import "github.com/gin-gonic/gin"
import (
"context"
"github.com/gin-gonic/gin"
)
const contextLocaleKey = "i18nx.locale"
@@ -25,3 +30,27 @@ func SetLocale(ctx *gin.Context, locale string) {
}
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
}
// localeContextKey is deliberately distinct from Gin's string key.
type localeContextKey struct{}
// WithLocale carries an explicitly selected application locale without losing
// the parent's cancellation, deadline, or values. It does not detect a locale
// from HTTP headers or change the configured default locale.
func WithLocale(ctx context.Context, locale string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, localeContextKey{}, NormalizeLocale(locale))
}
// LocaleFromContext preserves the configured-default behavior of callers that
// do not provide an explicit locale (including background jobs).
func LocaleFromContext(ctx context.Context) string {
if ctx != nil {
if locale, ok := ctx.Value(localeContextKey{}).(string); ok {
return NormalizeLocale(locale)
}
}
return DefaultLocale
}

View File

@@ -0,0 +1,45 @@
package i18nx
import (
"context"
"errors"
"testing"
"time"
)
func TestWithLocalePreservesContext(t *testing.T) {
type testKey struct{}
deadline := time.Now().Add(time.Minute)
parent, cancel := context.WithDeadline(context.WithValue(context.Background(), testKey{}, "request-value"), deadline)
defer cancel()
localized := WithLocale(parent, "ru")
if got := LocaleFromContext(localized); got != LocaleRuRU {
t.Fatalf("locale = %q", got)
}
if got := localized.Value(testKey{}); got != "request-value" {
t.Fatalf("parent value lost: %v", got)
}
if got, ok := localized.Deadline(); !ok || !got.Equal(deadline) {
t.Fatalf("deadline lost: %v, %v", got, ok)
}
cancel()
if !errors.Is(localized.Err(), context.Canceled) {
t.Fatalf("cancellation lost: %v", localized.Err())
}
}
func TestLocaleFromContextDefaultsAndIsolation(t *testing.T) {
for _, ctx := range []context.Context{nil, context.Background(), context.WithValue(context.Background(), "i18nx.locale", LocaleZhCN)} {
if got := LocaleFromContext(ctx); got != DefaultLocale {
t.Fatalf("missing explicit locale = %q, want default %q", got, DefaultLocale)
}
}
english := WithLocale(context.Background(), LocaleEnUS)
russian := WithLocale(english, LocaleRuRU)
if LocaleFromContext(english) != LocaleEnUS || LocaleFromContext(russian) != LocaleRuRU {
t.Fatal("child locale changed parent locale")
}
if got := LocaleFromContext(WithLocale(nil, "unsupported")); got != DefaultLocale {
t.Fatalf("invalid locale = %q", got)
}
}

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent not found."
error.e0003: "AI Agent not found or disabled."
@@ -567,3 +568,77 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "The user's expected outcom
tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional."
error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v"
error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes"
error.setup.previewRequired: "Review a setup preview and explicitly confirm its token before applying changes."
error.setup.previewChanged: "Configuration changed since the preview. Review a new preview before applying."
error.setup.targetRequired: "Select a specific existing record, or use its settings to choose the intended configuration."
error.setup.targetUnavailable: "The selected record is unavailable, deleted or ineligible. It was not restored."
error.setup.persistenceFailed: "Setup configuration could not be read or saved. No incomplete repair was committed."
error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately."
error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again."
error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again."
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "Unknown"
knowledge.labels.type.document: "Document knowledge base"
knowledge.labels.type.faq: "FAQ knowledge base"
knowledge.labels.status.0: "Enabled"
knowledge.labels.status.1: "Disabled"
knowledge.labels.status.2: "Deleted"
knowledge.labels.index.pending: "Pending"
knowledge.labels.index.indexed: "Indexed"
knowledge.labels.index.failed: "Index failed"
knowledge.labels.mode.1: "Strict mode"
knowledge.labels.mode.2: "Assist mode"
knowledge.labels.channel.im: "Conversations"
knowledge.labels.channel.agent_assist: "Agent assist"
knowledge.labels.channel.api: "API"
knowledge.labels.channel.debug: "Debug"
knowledge.labels.scene.first_response: "First response"
knowledge.labels.scene.assist: "Assisted reply"
knowledge.labels.scene.qa: "Q&A"
knowledge.labels.answer.1: "Answered"
knowledge.labels.answer.2: "No answer"
knowledge.labels.answer.3: "Fallback"
knowledge.labels.answer.4: "Blocked"
knowledge.labels.chunk.text: "Text"
knowledge.labels.chunk.faq: "Q&A"
knowledge.labels.chunk.table: "Table"
knowledge.labels.chunk.code: "Code"
error.knowledge.indexFailed: "Indexing failed. Check the indexing configuration and try again."
error.knowledge.retrieveFailed: "Knowledge retrieval failed. Check the retrieval configuration and try again."
error.knowledge.workflowReference: "This knowledge base is used by workflow “%s”. Remove it from the knowledge retrieval node first."
error.knowledge.workflowReferences: "This knowledge base is used by %d workflows. Remove it from their knowledge retrieval nodes first."
knowledge.rag.fallback.noAnswer: "The knowledge base currently has no clear information."
knowledge.rag.fallback.suggestRetry: "The knowledge base did not contain enough clear information. Try asking a more specific question."
knowledge.rag.system.assist: "You are a customer support knowledge base assistant. Prioritize the supplied knowledge excerpts. You may summarize them, but do not invent facts that were not provided. Respond in English."
knowledge.rag.system.strict: "You are a strict customer support knowledge base assistant. Answer only from the supplied knowledge excerpts. If they are insufficient, explicitly state that the knowledge base has no clear information. Respond in English."
knowledge.rag.userPrompt: "User question: %s\n\nReference material:\n%s"
knowledge.excel.question: "Primary question"
knowledge.excel.answer: "Answer"
knowledge.excel.similarQuestions: "Similar questions"
knowledge.excel.remark: "Notes"
knowledge.excel.exampleQuestion: "How do I reset my password?"
knowledge.excel.exampleAnswer: "Open your profile settings and select Reset password."
knowledge.excel.exampleSimilar: "I forgot my password\nWhere can I reset my password?"
knowledge.excel.exampleRemark: "Account"
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "Document#%d"
knowledge.rag.context.documentSection: "[Document: %s | Section: %s]\n%s\n\n"
knowledge.rag.context.documentHeading: "[Document: %s | Title: %s]\n%s\n\n"
knowledge.rag.context.document: "[Document: %s]\n%s\n\n"
knowledge.rag.faq.question: "Question: %s"
knowledge.rag.faq.similarQuestions: "Similar questions: %s"
knowledge.rag.faq.answer: "Answer: %s"
knowledge.rag.faq.separator: "; "
# Support availability validation.
error.supportAvailability.invalidCalendar: "Enter a valid timezone, weekly hours, and date exceptions."
error.supportAvailability.invalidAbsence: "Vacation requires a start and end with an explicit timezone; the end must be after the start."
error.supportAvailability.unavailable: "This operator is not currently eligible for new conversations. Refresh availability and try again."
error.supportAvailability.persistence: "Support availability could not be loaded or saved. Please try again."
error.supportAvailability.noProfile: "An enabled support operator profile is required."
error.supportAvailability.invalidStatus: "Choose a valid working availability status."
error.supportAvailability.invalidTime: "Enter a timestamp including its timezone offset."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "ИИ-агент не найден"
error.e0002: "ИИ-агент не найден."
error.e0003: "ИИ-агент не найден или отключён."
@@ -566,4 +567,77 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи
tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя."
tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно."
error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.previewRequired: "Перед применением изменений просмотрите план и явно подтвердите его токен."
error.setup.previewChanged: "После предварительного просмотра настройки изменились. Просмотрите новый план."
error.setup.targetRequired: "Выберите конкретную запись или задайте нужную конфигурацию в её настройках."
error.setup.targetUnavailable: "Выбранная запись недоступна, удалена или не подходит. Она не была восстановлена."
error.setup.persistenceFailed: "Не удалось прочитать или сохранить настройки. Неполное исправление не было сохранено."
error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно."
error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением."
error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз."
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "Неизвестно"
knowledge.labels.type.document: "База знаний документов"
knowledge.labels.type.faq: "База знаний FAQ"
knowledge.labels.status.0: "Включено"
knowledge.labels.status.1: "Отключено"
knowledge.labels.status.2: "Удалено"
knowledge.labels.index.pending: "Ожидает индексации"
knowledge.labels.index.indexed: "Проиндексировано"
knowledge.labels.index.failed: "Ошибка индексации"
knowledge.labels.mode.1: "Строгий режим"
knowledge.labels.mode.2: "Вспомогательный режим"
knowledge.labels.channel.im: "Диалоги"
knowledge.labels.channel.agent_assist: "Помощник оператора"
knowledge.labels.channel.api: "API"
knowledge.labels.channel.debug: "Отладка"
knowledge.labels.scene.first_response: "Первый ответ"
knowledge.labels.scene.assist: "Подсказка ответа"
knowledge.labels.scene.qa: "Вопросы и ответы"
knowledge.labels.answer.1: "Ответ получен"
knowledge.labels.answer.2: "Нет ответа"
knowledge.labels.answer.3: "Резервный ответ"
knowledge.labels.answer.4: "Заблокирован"
knowledge.labels.chunk.text: "Текст"
knowledge.labels.chunk.faq: "Вопросы и ответы"
knowledge.labels.chunk.table: "Таблица"
knowledge.labels.chunk.code: "Код"
error.knowledge.indexFailed: "Не удалось выполнить индексацию. Проверьте настройки индексации и повторите попытку."
error.knowledge.retrieveFailed: "Не удалось выполнить поиск по базе знаний. Проверьте настройки поиска и повторите попытку."
error.knowledge.workflowReference: "Эта база знаний используется в процессе «%s». Сначала удалите её из узла поиска по базе знаний."
error.knowledge.workflowReferences: "Эта база знаний используется в %d процессах. Сначала удалите её из узлов поиска по базе знаний."
knowledge.rag.fallback.noAnswer: "В базе знаний пока нет однозначной информации."
knowledge.rag.fallback.suggestRetry: "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос."
knowledge.rag.system.assist: "Вы помощник службы поддержки по базе знаний. Отвечайте прежде всего на основе предоставленных фрагментов. Можно обобщать их, но нельзя придумывать отсутствующие факты. Отвечайте на русском языке."
knowledge.rag.system.strict: "Вы помощник службы поддержки, строго использующий базу знаний. Отвечайте только на основе предоставленных фрагментов. Если их недостаточно, прямо сообщите, что в базе знаний нет однозначной информации. Отвечайте на русском языке."
knowledge.rag.userPrompt: "Вопрос пользователя: %s\n\nСправочные материалы:\n%s"
knowledge.excel.question: "Основной вопрос"
knowledge.excel.answer: "Ответ"
knowledge.excel.similarQuestions: "Похожие вопросы"
knowledge.excel.remark: "Примечания"
knowledge.excel.exampleQuestion: "Как сбросить пароль?"
knowledge.excel.exampleAnswer: "Откройте настройки профиля и выберите «Сбросить пароль»."
knowledge.excel.exampleSimilar: "Я забыл пароль\nГде можно сбросить пароль?"
knowledge.excel.exampleRemark: "Учётная запись"
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "Документ №%d"
knowledge.rag.context.documentSection: "[Документ: %s | Раздел: %s]\n%s\n\n"
knowledge.rag.context.documentHeading: "[Документ: %s | Заголовок: %s]\n%s\n\n"
knowledge.rag.context.document: "[Документ: %s]\n%s\n\n"
knowledge.rag.faq.question: "Вопрос: %s"
knowledge.rag.faq.similarQuestions: "Похожие вопросы: %s"
knowledge.rag.faq.answer: "Ответ: %s"
knowledge.rag.faq.separator: "; "
# Support availability validation.
error.supportAvailability.invalidCalendar: "Укажите корректный часовой пояс, часы работы по дням недели и исключения по датам."
error.supportAvailability.invalidAbsence: "Для отпуска укажите начало и окончание с часовым поясом. Окончание должно быть позже начала."
error.supportAvailability.unavailable: "Этот оператор сейчас не может принимать новые обращения. Обновите данные о доступности и повторите попытку."
error.supportAvailability.persistence: "Не удалось загрузить или сохранить настройки доступности поддержки. Повторите попытку."
error.supportAvailability.noProfile: "Требуется активный профиль оператора поддержки."
error.supportAvailability.invalidStatus: "Выберите корректный рабочий статус доступности."
error.supportAvailability.invalidTime: "Укажите дату и время со смещением часового пояса."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent 不存在"
error.e0003: "AI Agent 不存在或已停用"
@@ -567,3 +568,77 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结
tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。"
error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v"
error.setup.authRequired: "请先登录以执行配置诊断或一键修复"
error.setup.previewRequired: "应用更改前,请先预览设置并明确确认预览令牌。"
error.setup.previewChanged: "预览后配置已更改。请重新预览后再应用。"
error.setup.targetRequired: "请选择具体记录,或在对应设置中选择所需配置。"
error.setup.targetUnavailable: "所选记录不可用、已删除或不符合条件,未进行恢复。"
error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不完整的修复。"
error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。"
error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。"
error.channel.configUpdateFailed: "无法保存渠道配置,请重试。"
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "未知"
knowledge.labels.type.document: "文档知识库"
knowledge.labels.type.faq: "FAQ知识库"
knowledge.labels.status.0: "启用"
knowledge.labels.status.1: "禁用"
knowledge.labels.status.2: "已删除"
knowledge.labels.index.pending: "待索引"
knowledge.labels.index.indexed: "已索引"
knowledge.labels.index.failed: "索引失败"
knowledge.labels.mode.1: "严格模式"
knowledge.labels.mode.2: "辅助模式"
knowledge.labels.channel.im: "客服会话"
knowledge.labels.channel.agent_assist: "客服助手"
knowledge.labels.channel.api: "API接口"
knowledge.labels.channel.debug: "调试"
knowledge.labels.scene.first_response: "首次回复"
knowledge.labels.scene.assist: "辅助回复"
knowledge.labels.scene.qa: "问答"
knowledge.labels.answer.1: "正常"
knowledge.labels.answer.2: "无答案"
knowledge.labels.answer.3: "兜底回复"
knowledge.labels.answer.4: "已屏蔽"
knowledge.labels.chunk.text: "文本"
knowledge.labels.chunk.faq: "问答"
knowledge.labels.chunk.table: "表格"
knowledge.labels.chunk.code: "代码"
error.knowledge.indexFailed: "索引失败。请检查索引配置后重试。"
error.knowledge.retrieveFailed: "知识检索失败。请检查检索配置后重试。"
error.knowledge.workflowReference: "知识库正在被流程「%s」使用,请先从知识检索节点中移除"
error.knowledge.workflowReferences: "知识库正在被 %d 个流程使用,请先从知识检索节点中移除"
knowledge.rag.fallback.noAnswer: "当前知识库暂无明确信息。"
knowledge.rag.fallback.suggestRetry: "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
knowledge.rag.system.assist: "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。请使用中文回答。"
knowledge.rag.system.strict: "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。请使用中文回答。"
knowledge.rag.userPrompt: "用户问题:%s\n\n参考资料:\n%s"
knowledge.excel.question: "标准问题"
knowledge.excel.answer: "答案"
knowledge.excel.similarQuestions: "相似问"
knowledge.excel.remark: "备注"
knowledge.excel.exampleQuestion: "如何重置密码?"
knowledge.excel.exampleAnswer: "进入个人设置后点击重置密码。"
knowledge.excel.exampleSimilar: "忘记密码怎么办\n重置密码在哪里"
knowledge.excel.exampleRemark: "账号"
knowledge.rag.context.faq: "【FAQ:%s】\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "文档#%d"
knowledge.rag.context.documentSection: "【文档:%s|章节:%s】\n%s\n\n"
knowledge.rag.context.documentHeading: "【文档:%s|标题:%s】\n%s\n\n"
knowledge.rag.context.document: "【文档:%s】\n%s\n\n"
knowledge.rag.faq.question: "问题:%s"
knowledge.rag.faq.similarQuestions: "相似问:%s"
knowledge.rag.faq.answer: "回答:%s"
knowledge.rag.faq.separator: ";"
# Support availability validation.
error.supportAvailability.invalidCalendar: "请输入有效的时区、每周工作时间和特殊日期规则。"
error.supportAvailability.invalidAbsence: "休假需要包含明确时区的开始和结束时间,结束时间必须晚于开始时间。"
error.supportAvailability.unavailable: "该客服当前无法接收新会话。请刷新可用状态后重试。"
error.supportAvailability.persistence: "无法加载或保存客服可用状态。请重试。"
error.supportAvailability.noProfile: "需要已启用的客服资料。"
error.supportAvailability.invalidStatus: "请选择有效的工作状态。"
error.supportAvailability.invalidTime: "请输入包含时区偏移的日期和时间。"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -269,3 +271,33 @@ func flipLastChar(signature string) string {
func itoa(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) {
for _, signature := range []string{"", "invalid"} {
ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature})
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil {
t.Fatal("signed-only policy accepted an unsigned or invalid identity")
}
}
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now()))
user, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true})
if err != nil || user.IdentityScope != IdentityScope(testChannelID, "hmac") {
t.Fatal("valid signed identity did not retain its HMAC scope")
}
}
func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) {
seen := map[string]bool{}
for _, channel := range []string{"channel-one", "channel-two"} {
for _, issuer := range []string{"guest", "hmac", "jwt"} {
scope := IdentityScope(channel, issuer)
if scope == "" || seen[scope] {
t.Fatal("identity scopes collided")
}
seen[scope] = true
}
}
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" {
t.Fatal("invalid scope inputs accepted")
}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -24,6 +26,9 @@ type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
}
type UserTokenClaims struct {
@@ -46,7 +51,8 @@ type Secrets struct {
IdentityMaxAge time.Duration
// ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another.
ChannelID string
ChannelID string
RequireSignedIdentity bool
}
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable
@@ -65,9 +71,8 @@ const identityFutureSkewTolerance = 5 * time.Minute
//
// issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
//
// - The channel id is included because CustomerIdentity is keyed by
// (source, externalId) globally, so without it a signature minted for one
// channel would assert the same identity on every other channel.
// - The channel id is included so signatures cannot be replayed on a
// different channel, even when a deployment shares an HMAC signing key.
// - The timestamp is inside the signed bytes even though it also travels as its
// own field, so it cannot be re-dated without breaking the signature. This
// bounds how long a leaked pair stays usable; it is not a defence against key
@@ -126,6 +131,7 @@ func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID,
ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil
}
return getExternalIDUser(ctx, secrets)
@@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error)
ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil
}
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil
}
@@ -267,3 +278,30 @@ func decodeExternalDisplayName(s string) string {
}
return strings.TrimSpace(dec)
}
// IdentityScope separates independent identity issuers, trust levels and channels.
// It deliberately does not depend on a secret so ordinary key rotation preserves
// the issuer's history. Reassigning a channel to a different issuer requires a new
// channel, not silently reusing that issuer's namespace.
func IdentityScope(channelCode, issuer string) string {
if strings.TrimSpace(channelCode) == "" {
return ""
}
switch issuer {
case "jwt", "hmac", "guest":
default:
return ""
}
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
}
func ValidIdentityScope(channelCode string, external ExternalUser) bool {
if external.ExternalSource == enums.ExternalSourceGuest {
return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest")
}
if external.ExternalSource == enums.ExternalSourceUser {
return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac"))
}
return false
}

View File

@@ -0,0 +1,68 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"time"
"gorm.io/gorm"
)
// These handwritten queries supplement the generated CRUD repositories.
func (r *roleRepository) FindEnabledByUser(db *gorm.DB, userID int64) ([]models.Role, error) {
roles := make([]models.Role, 0)
err := db.Table("t_role AS r").
Select("r.*").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
Order("r.sort_no ASC, r.id ASC").
Scan(&roles).Error
return roles, err
}
func (r *permissionRepository) FindEnabledCodesByUserRoles(db *gorm.DB, userID int64) ([]string, error) {
codes := make([]string, 0)
err := db.Table("t_permission AS p").
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
Joins("JOIN t_role AS r ON r.id = rp.role_id").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ? AND p.status = ?", userID, enums.StatusOk, enums.StatusOk).
Distinct().Pluck("p.code", &codes).Error
return codes, err
}
type UserPermissionOverride struct {
Code string
Effect int
}
func (r *userPermissionRepository) FindActiveEnabledOverrides(db *gorm.DB, userID int64, now time.Time) ([]UserPermissionOverride, error) {
overrides := make([]UserPermissionOverride, 0)
err := db.Table("t_user_permission AS up").
Select("p.code, up.effect").
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
Where("up.user_id = ? AND p.status = ?", userID, enums.StatusOk).
Where("up.expired_at IS NULL OR up.expired_at > ?", now).
Scan(&overrides).Error
return overrides, err
}
// GetAuthState deliberately excludes the bearer token; revalidation only needs
// the identity binding, revocation status, and absolute expiry.
func (r *loginSessionRepository) GetAuthState(db *gorm.DB, sessionID, userID int64) (*models.LoginSession, error) {
session := &models.LoginSession{}
if err := db.Select("id", "user_id", "expired_at", "revoked_at").
Take(session, "id = ? AND user_id = ?", sessionID, userID).Error; err != nil {
return nil, err
}
return session, nil
}
func (r *userRepository) GetAuthState(db *gorm.DB, userID int64) (*models.User, error) {
user := &models.User{}
if err := db.Select("id", "username", "nickname", "avatar", "user_type", "status", "deleted_at").
Take(user, "id = ?", userID).Error; err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,22 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// UpdatesConfigIfUnchanged prevents a stale read/modify/write from replacing a
// concurrently tightened authentication policy or rotated channel secret. HEX is
// available in SQLite and MySQL and compares exact bytes even when MySQL's text
// column uses a case-insensitive or trailing-space-insensitive collation.
func (r *channelRepository) UpdatesConfigIfUnchanged(db *gorm.DB, expected *models.Channel, columns map[string]any) (bool, error) {
if expected == nil || expected.ID <= 0 {
return false, nil
}
result := db.Model(&models.Channel{}).
Where("id = ? AND status <> ? AND updated_at = ?", expected.ID, enums.StatusDeleted, expected.UpdatedAt).
Where("HEX(COALESCE(config_json, '')) = HEX(?)", expected.ConfigJSON).
Updates(columns)
return result.RowsAffected == 1, result.Error
}

View File

@@ -0,0 +1,17 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// FindPendingDispatchBatch returns a bounded, checked page. A failed read must
// not be mistaken for an empty queue by setup or the periodic dispatcher.
func (r *conversationRepository) FindPendingDispatchBatch(db *gorm.DB, limit int) ([]models.Conversation, error) {
conversations := make([]models.Conversation, 0)
err := db.Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusPending, 0).
Order("id DESC").Limit(limit).Find(&conversations).Error
return conversations, err
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package repositories
import (
@@ -6,7 +8,6 @@ import (
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
@@ -36,14 +37,13 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo
return ret
}
// GetBy 按外部来源 + 外部用户标识查询身份映射(与 uk_customer_external 一致)。
// GetBy is the legacy/internal-integration lookup. Ambiguous mappings fail closed.
func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity {
if strs.IsAnyBlank(string(externalSource), externalID) {
identity, err := r.GetUniqueBy(db, "", externalSource, externalID)
if err != nil {
return nil
}
return r.FindOne(db, sqls.NewCnd().
Eq("external_source", externalSource).
Eq("external_id", externalID))
return identity
}
func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity {

View File

@@ -0,0 +1,63 @@
package repositories
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// ErrAmbiguousCustomerIdentity requires deliberate operator reconciliation. Never
// choose the first row: the historical unique index also includes customer_id.
var ErrAmbiguousCustomerIdentity = errors.New("ambiguous customer identity")
// GetUniqueBy includes disabled rows when detecting conflicts. A disabled mapping
// must not let a caller create a replacement customer and reacquire the identity.
func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, source enums.ExternalSource, externalID string) (*models.CustomerIdentity, error) {
if strings.TrimSpace(string(source)) == "" || strings.TrimSpace(externalID) == "" {
return nil, errors.New("invalid customer identity")
}
var identities []models.CustomerIdentity
query := db.Where("identity_scope = ? AND external_source = ? AND external_id = ?", scope, source, strings.TrimSpace(externalID))
if key := ScopedCustomerIdentityKey(scope, source, externalID); key != nil {
// Digest comparison is independent of MySQL's external_id collation. The
// NULL branch also detects manually migrated/inconsistent scoped mappings.
query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID))
}
err := query.Limit(2).Find(&identities).Error
if err != nil {
return nil, err
}
if len(identities) > 1 {
return nil, ErrAmbiguousCustomerIdentity
}
if len(identities) == 0 {
return nil, nil
}
if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) {
return nil, ErrAmbiguousCustomerIdentity
}
if identities[0].Status != enums.StatusOk {
return nil, errors.New("inactive customer identity")
}
return &identities[0], nil
}
// ScopedCustomerIdentityKey gives new scoped registrations a database-enforced
// claim without imposing a new unique constraint on dirty legacy data. Both
// SQLite and MySQL permit multiple NULL values in a unique index. Encoding an
// array avoids delimiter collisions and hashing avoids collation ambiguity.
func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string {
if scope == "" {
return nil
}
encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)})
digest := sha256.Sum256(encoded)
key := hex.EncodeToString(digest[:])
return &key
}

View File

@@ -0,0 +1,153 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"errors"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
var SupportAvailabilityRepository = &supportAvailabilityRepository{}
type supportAvailabilityRepository struct{}
func (r *supportAvailabilityRepository) Calendar(db *gorm.DB, teamID int64) (*models.SupportCalendar, error) {
var item models.SupportCalendar
err := db.Where("team_id = ?", teamID).Take(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &item, err
}
func (r *supportAvailabilityRepository) SaveCalendar(db *gorm.DB, item *models.SupportCalendar) error {
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "team_id"}}, DoUpdates: clause.AssignmentColumns([]string{"mode", "timezone", "weekly_json", "exceptions_json", "updated_at"})}).Create(item).Error
}
func (r *supportAvailabilityRepository) RenewPresence(db *gorm.DB, item *models.AgentPresenceLease) error {
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"expires_at"})}).Create(item).Error
}
func (r *supportAvailabilityRepository) EndPresence(db *gorm.DB, id string, at time.Time) error {
return db.Model(&models.AgentPresenceLease{}).Where("id = ? AND expires_at > ?", id, at).Update("expires_at", at).Error
}
func (r *supportAvailabilityRepository) PrunePresence(db *gorm.DB, now time.Time) error {
return db.Where("expires_at <= ?", now).Delete(&models.AgentPresenceLease{}).Error
}
func (r *supportAvailabilityRepository) OnlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) {
result := map[int64]bool{}
if len(userIDs) == 0 {
return result, nil
}
var leases []models.AgentPresenceLease
if err := db.Where("user_id IN ? AND expires_at > ?", userIDs, now).Find(&leases).Error; err != nil {
return nil, err
}
sessionIDs := make([]int64, 0, len(leases))
for _, lease := range leases {
sessionIDs = append(sessionIDs, lease.LoginSessionID)
}
if len(sessionIDs) == 0 {
return result, nil
}
var sessions []models.LoginSession
if err := db.Select("id", "user_id", "expired_at", "revoked_at").Where("id IN ? AND expired_at > ? AND revoked_at IS NULL", sessionIDs, now).Find(&sessions).Error; err != nil {
return nil, err
}
valid := map[int64]int64{}
for _, session := range sessions {
valid[session.ID] = session.UserID
}
for _, lease := range leases {
if valid[lease.LoginSessionID] == lease.UserID {
result[lease.UserID] = true
}
}
return result, nil
}
// A portable write acquires the profile row's write lock on MySQL and the
// transaction write lock on SQLite. All assignment paths take this before count.
func (r *supportAvailabilityRepository) LockProfile(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
result := db.Model(&models.AgentProfile{}).Where("user_id = ?", userID).UpdateColumn("assignment_version", gorm.Expr("assignment_version + 1"))
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
var item models.AgentProfile
err := db.Where("user_id = ?", userID).Take(&item).Error
return &item, err
}
func (r *supportAvailabilityRepository) ActiveCount(db *gorm.DB, userID, excludeConversationID int64) (int, error) {
var count int64
q := db.Model(&models.Conversation{}).Where("current_assignee_id = ? AND status = ?", userID, enums.IMConversationStatusActive)
if excludeConversationID > 0 {
q = q.Where("id <> ?", excludeConversationID)
}
err := q.Count(&count).Error
return int(count), err
}
func (r *supportAvailabilityRepository) LegacySchedules(db *gorm.DB, teamID int64, now time.Time) ([]models.AgentTeamSchedule, error) {
var items []models.AgentTeamSchedule
err := db.Where("team_id = ? AND status = ? AND end_at > ?", teamID, enums.StatusOk, now).Order("start_at ASC").Find(&items).Error
return items, err
}
func (r *supportAvailabilityRepository) Profiles(db *gorm.DB, teamIDs []int64) ([]models.AgentProfile, error) {
var items []models.AgentProfile
q := db.Where("status <> ?", enums.StatusDeleted)
if teamIDs != nil {
if len(teamIDs) == 0 {
return []models.AgentProfile{}, nil
}
q = q.Where("team_id IN ?", teamIDs)
}
err := q.Order("id ASC").Find(&items).Error
return items, err
}
func (r *supportAvailabilityRepository) User(db *gorm.DB, id int64) (*models.User, error) {
var item models.User
err := db.First(&item, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &item, err
}
func (r *supportAvailabilityRepository) Team(db *gorm.DB, id int64) (*models.AgentTeam, error) {
var item models.AgentTeam
err := db.First(&item, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &item, err
}
func (r *supportAvailabilityRepository) AssignConversation(db *gorm.DB, previous *models.Conversation, values map[string]any) error {
result := db.Model(&models.Conversation{}).Where("id = ? AND status = ? AND current_assignee_id = ?", previous.ID, previous.Status, previous.CurrentAssigneeID).Updates(values)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrInvalidTransaction
}
return nil
}
func (r *supportAvailabilityRepository) ProfileByUser(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
var item models.AgentProfile
err := db.Where("user_id = ?", userID).Take(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &item, err
}
// EffectiveStatusCondition mirrors the vacation-boundary policy for paginated
// list filters. Filtering the stored Vacation value would contradict the badge.
func (r *supportAvailabilityRepository) EffectiveStatusCondition(cnd *sqls.Cnd, status enums.ServiceStatus, now time.Time) {
cnd.Where(`(CASE WHEN service_status <> ? THEN service_status
WHEN absence_start_at IS NOT NULL AND absence_end_at IS NOT NULL AND absence_start_at <= ? AND absence_end_at > ? THEN ?
WHEN availability_return_status >= ? AND availability_return_status <= ? THEN availability_return_status
ELSE ? END) = ?`, enums.ServiceStatusVacation, now, now, enums.ServiceStatusVacation, enums.ServiceStatusIdle, enums.ServiceStatusUnavailable, enums.ServiceStatusUnavailable, status)
}

View File

@@ -0,0 +1,77 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"log/slog"
"slices"
"time"
)
const agentPresenceLeaseTTL = 90 * time.Second
const agentPresenceDisconnectGrace = 15 * time.Second
const agentPresenceRenewInterval = 15 * time.Second
func (s *wsService) renewAgentPresence(session *ClientSession) {
if session == nil || session.Closed.Load() || session.Principal == nil || (session.Role != realtimeRoleAdmin && session.Role != realtimeRoleNotification) {
return
}
session.presenceMu.Lock()
dispatchPending := false
defer func() {
session.presenceMu.Unlock()
// Finish the presence write before dispatching, and keep ownership in
// this call rather than launching an untracked database goroutine.
if dispatchPending {
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
}
}()
if session.PresenceEnded {
return
}
principal := session.Principal
if principal.UserType != enums.UserTypeEmployee || !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
return
}
now := time.Now()
last := session.PresenceRenewedAt.Load()
if now.Unix()-last < int64(agentPresenceRenewInterval/time.Second) {
return
}
if !session.PresenceRenewedAt.CompareAndSwap(last, now.Unix()) {
return
}
// The internal ID is generated independently per connection, across processes.
expires := now.Add(agentPresenceLeaseTTL)
if principal.ExpiresAt.Before(expires) {
expires = principal.ExpiresAt
}
lease := models.AgentPresenceLease{ID: session.PresenceLeaseID, UserID: principal.UserID, LoginSessionID: principal.LoginSessionID, ExpiresAt: expires}
if lease.ID == "" {
return
}
if err := repositories.SupportAvailabilityRepository.RenewPresence(sqls.DB(), &lease); err != nil {
slog.Warn("support presence renewal failed")
return
}
if last == 0 {
dispatchPending = true
}
}
func (s *wsService) endAgentPresence(session *ClientSession) {
if session == nil {
return
}
session.presenceMu.Lock()
defer session.presenceMu.Unlock()
session.PresenceEnded = true
if session.PresenceLeaseID == "" || session.PresenceRenewedAt.Load() == 0 {
return
}
if err := repositories.SupportAvailabilityRepository.EndPresence(sqls.DB(), session.PresenceLeaseID, time.Now().Add(agentPresenceDisconnectGrace)); err != nil {
slog.Warn("support presence disconnect update failed")
}
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package services
import (
@@ -8,6 +9,7 @@ import (
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"strconv"
"strings"
"time"
@@ -79,11 +81,18 @@ func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 {
// GetDispatchAgents 获取可用于分配会话的客服
func (s *agentProfileService) GetDispatchAgents(teamIds []int64) []models.AgentProfile {
return AgentProfileService.Find(sqls.NewCnd().
In("team_id", teamIds).
Eq("status", enums.StatusOk).
Eq("auto_assign_enabled", true).
Eq("service_status", enums.ServiceStatusIdle))
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIds)
if err != nil {
return nil
}
result := make([]models.AgentProfile, 0, len(profiles))
for _, profile := range profiles {
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, time.Now(), 0)
if err == nil && eligibility.Eligible {
result = append(result, profile)
}
}
return result
}
func (s *agentProfileService) CreateAgentProfile(req request.CreateAgentProfileRequest, operator *dto.AuthPrincipal) (*models.AgentProfile, error) {
@@ -115,12 +124,12 @@ func (s *agentProfileService) UpdateAgentProfile(req request.UpdateAgentProfileR
return err
}
if err := repositories.AgentProfileRepository.Updates(sqls.DB(), req.ID, map[string]any{
"user_id": item.UserID,
"team_id": item.TeamID,
"agent_code": item.AgentCode,
"display_name": item.DisplayName,
"avatar": item.Avatar,
"service_status": item.ServiceStatus,
"user_id": item.UserID,
"team_id": item.TeamID,
"agent_code": item.AgentCode,
"display_name": item.DisplayName,
"avatar": item.Avatar,
// Availability is managed only through the dedicated guarded endpoint.
"max_concurrent_count": item.MaxConcurrentCount,
"priority_level": item.PriorityLevel,
"auto_assign_enabled": item.AutoAssignEnabled,
@@ -169,7 +178,7 @@ func (s *agentProfileService) buildProfileModel(id int64, req request.CreateAgen
if exists := s.Take("agent_code = ? AND id <> ?", req.AgentCode, id); exists != nil {
return nil, errorsx.InvalidParamI18n("error.e0163")
}
if !enums.IsValidServiceStatus(req.ServiceStatus) {
if !enums.IsValidServiceStatus(req.ServiceStatus) || (id == 0 && req.ServiceStatus == enums.ServiceStatusVacation) {
return nil, errorsx.InvalidParamI18n("error.e0165")
}
if req.MaxConcurrentCount < 0 {
@@ -197,11 +206,23 @@ func (s *agentProfileService) dispatchPendingConversationsIfEligible(item *model
if item.Status != enums.StatusOk {
return
}
if !item.AutoAssignEnabled || item.MaxConcurrentCount <= 0 {
if !item.AutoAssignEnabled {
return
}
if item.ServiceStatus != enums.ServiceStatusIdle {
if effectiveSupportStatus(*item, time.Now()) != enums.ServiceStatusIdle {
return
}
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
}
func (s *agentProfileService) FilterEffectiveServiceStatus(cnd *sqls.Cnd, value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
number, err := strconv.Atoi(value)
if err != nil || !enums.IsValidServiceStatus(enums.ServiceStatus(number)) {
return errorsx.InvalidParamI18n("error.supportAvailability.invalidStatus")
}
repositories.SupportAvailabilityRepository.EffectiveStatusCondition(cnd, enums.ServiceStatus(number), time.Now())
return nil
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -50,21 +52,27 @@ func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal {
return nil
}
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal {
principal := &dto.AuthPrincipal{
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Avatar: user.Avatar,
UserType: user.UserType,
Status: user.Status,
Roles: roles,
Permissions: permissions,
}
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal {
principal := buildAuthPrincipal(user, session, roles, permissions)
ctx.Set(authPrincipalContextKey, principal)
return principal
}
func buildAuthPrincipal(user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal {
return &dto.AuthPrincipal{
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Avatar: user.Avatar,
UserType: user.UserType,
Status: user.Status,
Roles: slices.Clone(roles),
Permissions: slices.Clone(permissions),
LoginSessionID: session.ID,
ExpiresAt: session.ExpiredAt,
}
}
func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) {
if principal = s.GetAuthPrincipal(ctx); principal == nil {
if principal, err = s.Authenticate(ctx); err != nil {
@@ -96,7 +104,7 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig,
}
user := UserService.GetByUsername(username)
if user == nil || user.Status != enums.StatusOk {
if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found")
return nil, errorsx.InvalidAccountI18n("error.e0260")
}
@@ -165,7 +173,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
}
user := UserService.Get(session.UserID)
if user == nil || user.Status != enums.StatusOk {
if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
return nil, errorsx.UnauthorizedI18n("error.e0256")
}
@@ -173,7 +181,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
if err != nil {
return nil, err
}
principal := s.setAuthPrincipal(ctx, user, roles, permissions)
principal := s.setAuthPrincipal(ctx, user, session, roles, permissions)
now := time.Now()
_ = LoginSessionService.Updates(session.ID, map[string]any{
@@ -184,6 +192,39 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
return principal, nil
}
// RevalidatePrincipal refreshes a long-lived connection's authorization without
// keeping the bearer token. The original snapshot is never mutated and its
// absolute deadline cannot be extended by revalidation.
func (s *authService) RevalidatePrincipal(principal *dto.AuthPrincipal) (*dto.AuthPrincipal, error) {
return s.revalidatePrincipal(sqls.DB(), principal, time.Now())
}
func (s *authService) revalidatePrincipal(db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) (*dto.AuthPrincipal, error) {
if principal == nil || principal.UserID <= 0 || principal.LoginSessionID <= 0 || !now.Before(principal.ExpiresAt) {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
session, err := repositories.LoginSessionRepository.GetAuthState(db, principal.LoginSessionID, principal.UserID)
if err != nil || session == nil || session.RevokedAt != nil || !now.Before(session.ExpiredAt) {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
user, err := repositories.UserRepository.GetAuthState(db, principal.UserID)
if err != nil || user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
return nil, errorsx.UnauthorizedI18n("error.e0256")
}
roles, permissions, err := s.loadUserAuthScope(db, user.ID)
if err != nil {
// Database failures must close the connection, not retain its old scope or
// expose persistence details through a transport error.
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
fresh := buildAuthPrincipal(user, session, roles, permissions)
if principal.ExpiresAt.Before(fresh.ExpiresAt) {
fresh.ExpiresAt = principal.ExpiresAt
}
return fresh, nil
}
func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool {
principal := s.GetAuthPrincipal(ctx)
if principal == nil {
@@ -341,7 +382,7 @@ func (s *authService) validateSessionToken(token string) (*models.LoginSession,
if session.RevokedAt != nil {
return nil, errorsx.InvalidTokenI18n("error.e0267")
}
if time.Now().After(session.ExpiredAt) {
if !time.Now().Before(session.ExpiredAt) {
return nil, errorsx.InvalidTokenI18n("error.e0268")
}
return session, nil
@@ -372,49 +413,16 @@ func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, er
}
func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) {
roles := make([]models.Role, 0)
if err := tx.
Table("t_role AS r").
Select("r.*").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
Order("r.sort_no ASC, r.id ASC").
Scan(&roles).Error; err != nil {
return nil, err
}
return roles, nil
return repositories.RoleRepository.FindEnabledByUser(tx, userID)
}
func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) {
permissionRows := make([]struct {
Code string
}, 0)
db := tx.Table("t_permission AS p").
Select("DISTINCT p.code").
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id").
Where("ur.user_id = ?", userID).
Where("p.status = ?", enums.StatusOk)
if err := db.Scan(&permissionRows).Error; err != nil {
permissionCodes, err := repositories.PermissionRepository.FindEnabledCodesByUserRoles(tx, userID)
if err != nil {
return nil, err
}
permissionCodes := make([]string, 0, len(permissionRows))
for _, permission := range permissionRows {
permissionCodes = append(permissionCodes, permission.Code)
}
overrideRows := make([]struct {
Code string
Effect int
}, 0)
if err := tx.
Table("t_user_permission AS up").
Select("p.code, up.effect").
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()).
Scan(&overrideRows).Error; err != nil {
overrideRows, err := repositories.UserPermissionRepository.FindActiveEnabledOverrides(tx, userID, time.Now())
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,260 @@
package services
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"slices"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
type authSessionFixture struct {
db *gorm.DB
now time.Time
user models.User
session models.LoginSession
role models.Role
permission models.Permission
principal *dto.AuthPrincipal
}
func newAuthSessionFixture(t *testing.T) *authSessionFixture {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "auth.db")), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open isolated auth database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&models.User{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}); err != nil {
t.Fatalf("migrate isolated auth database: %v", err)
}
f := &authSessionFixture{db: db, now: time.Now().UTC().Truncate(time.Microsecond)}
f.user = models.User{Username: "staff", UserType: enums.UserTypeEmployee, Status: enums.StatusOk}
f.role = models.Role{Code: "support", Status: enums.StatusOk}
f.permission = models.Permission{Code: "conversation.read", Status: enums.StatusOk}
for _, item := range []any{&f.user, &f.role, &f.permission} {
if err := db.Create(item).Error; err != nil {
t.Fatalf("seed auth fixture: %v", err)
}
}
f.session = models.LoginSession{UserID: f.user.ID, Token: "ak_test_only_session", ExpiredAt: f.now.Add(time.Hour)}
for _, item := range []any{
&f.session,
&models.UserRole{UserID: f.user.ID, RoleID: f.role.ID},
&models.RolePermission{RoleID: f.role.ID, PermissionID: f.permission.ID},
} {
if err := db.Create(item).Error; err != nil {
t.Fatalf("seed auth fixture: %v", err)
}
}
f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, []string{f.permission.Code})
return f
}
func TestAuthScopeExcludesDisabledRolesAndPermissionOverrides(t *testing.T) {
f := newAuthSessionFixture(t)
disabledRole := models.Role{Code: "disabled", Status: enums.StatusDisabled}
if err := f.db.Create(&disabledRole).Error; err != nil {
t.Fatal(err)
}
if err := f.db.Create(&models.UserRole{UserID: f.user.ID, RoleID: disabledRole.ID}).Error; err != nil {
t.Fatal(err)
}
permissions := []models.Permission{
{Code: "disabled-role-only", Status: enums.StatusOk},
{Code: "disabled-permission", Status: enums.StatusDisabled},
{Code: "direct-allow", Status: enums.StatusOk},
{Code: "expired-allow", Status: enums.StatusOk},
}
if err := f.db.Create(&permissions).Error; err != nil {
t.Fatal(err)
}
for _, item := range []any{
&models.RolePermission{RoleID: disabledRole.ID, PermissionID: permissions[0].ID},
&models.RolePermission{RoleID: f.role.ID, PermissionID: permissions[1].ID},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[1].ID, Effect: 1},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[2].ID, Effect: 1},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[3].ID, Effect: 1, ExpiredAt: &f.now},
&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1},
} {
if err := f.db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
roles, codes, err := newAuthService().loadUserAuthScope(f.db, f.user.ID)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(roles, []string{"support"}) || !slices.Equal(codes, []string{"direct-allow"}) {
t.Fatalf("unexpected enabled authorization scope: roles=%v permissions=%v", roles, codes)
}
}
func TestRevalidatePrincipalRejectsRevokedExpiredOrUnboundIdentity(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
}{
{"nil principal", func(f *authSessionFixture) error { f.principal = nil; return nil }},
{"missing session binding", func(f *authSessionFixture) error { f.principal.LoginSessionID = 0; return nil }},
{"missing user binding", func(f *authSessionFixture) error { f.principal.UserID = 0; return nil }},
{"missing expiry", func(f *authSessionFixture) error { f.principal.ExpiresAt = time.Time{}; return nil }},
{"original deadline reached", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now; return nil }},
{"original deadline passed", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now.Add(-time.Second); return nil }},
{"revoked session", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", f.now).Error }},
{"session deadline reached", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now).Error }},
{"session deadline passed", func(f *authSessionFixture) error {
return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error
}},
{"deleted session", func(f *authSessionFixture) error { return f.db.Delete(&f.session).Error }},
{"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID+1).Error }},
{"disabled user", func(f *authSessionFixture) error {
return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error
}},
{"soft-deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", f.now).Error }},
{"deleted user", func(f *authSessionFixture) error { return f.db.Delete(&f.user).Error }},
{"failed session query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.LoginSession{}) }},
{"failed scope query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.Permission{}) }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAuthSessionFixture(t)
if err := tc.change(f); err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) {
t.Fatalf("expected fail-closed unauthorized result, got principal=%v err=%v", fresh != nil, err)
}
})
}
}
func TestRevalidatePrincipalRefreshesScopeWithoutMutatingSnapshot(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
wantRoles []string
}{
{"disabled role", func(f *authSessionFixture) error {
return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error
}, nil},
{"removed role", func(f *authSessionFixture) error {
return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error
}, nil},
{"removed role permission", func(f *authSessionFixture) error {
return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error
}, []string{"support"}},
{"disabled permission", func(f *authSessionFixture) error {
return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error
}, []string{"support"}},
{"direct deny", func(f *authSessionFixture) error {
return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error
}, []string{"support"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAuthSessionFixture(t)
if err := tc.change(f); err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if err != nil {
t.Fatal(err)
}
if fresh == f.principal || len(fresh.Permissions) != 0 || !slices.Equal(fresh.Roles, tc.wantRoles) {
t.Fatalf("expected a fresh reduced scope: roles=%v permissions=%v", fresh.Roles, fresh.Permissions)
}
if !slices.Equal(f.principal.Roles, []string{"support"}) || !slices.Equal(f.principal.Permissions, []string{"conversation.read"}) {
t.Fatal("revalidation mutated the original snapshot")
}
})
}
}
func TestRevalidatePrincipalCannotExtendAbsoluteExpiry(t *testing.T) {
for _, delta := range []time.Duration{-time.Minute, time.Minute} {
t.Run(delta.String(), func(t *testing.T) {
f := newAuthSessionFixture(t)
storedExpiry := f.session.ExpiredAt.Add(delta)
if err := f.db.Model(&f.session).Update("expired_at", storedExpiry).Error; err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if err != nil {
t.Fatal(err)
}
want := f.principal.ExpiresAt
if storedExpiry.Before(want) {
want = storedExpiry
}
if !fresh.ExpiresAt.Equal(want) {
t.Fatal("revalidation did not preserve the earliest absolute deadline")
}
fresh.Roles[0] = "changed"
fresh.Permissions[0] = "changed"
if f.principal.Roles[0] != "support" || f.principal.Permissions[0] != "conversation.read" {
t.Fatal("fresh authorization slices alias the original snapshot")
}
})
}
}
func TestAuthenticateBindsPrincipalToSessionWithoutExposingMetadata(t *testing.T) {
f := newAuthSessionFixture(t)
previousDB := sqls.DB()
sqls.SetDB(f.db)
t.Cleanup(func() { sqls.SetDB(previousDB) })
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/", nil)
ctx.Request.Header.Set("Authorization", "Bearer "+f.session.Token)
principal, err := newAuthService().Authenticate(ctx)
if err != nil {
t.Fatal(err)
}
if principal.LoginSessionID != f.session.ID || !principal.ExpiresAt.Equal(f.session.ExpiredAt) {
t.Fatal("authenticated principal is not bound to its original session")
}
if _, err := newAuthService().RevalidatePrincipal(principal); err != nil {
t.Fatalf("valid session failed public revalidation API: %v", err)
}
encoded, err := json.Marshal(principal)
if err != nil {
t.Fatal(err)
}
var publicFields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &publicFields); err != nil {
t.Fatal(err)
}
for _, name := range []string{"LoginSessionID", "ExpiresAt", "Token"} {
if _, ok := publicFields[name]; ok {
t.Fatalf("internal auth metadata leaked into JSON: %s", name)
}
}
if err := newAuthService().Logout("Bearer " + f.session.Token); err != nil {
t.Fatal(err)
}
if fresh, err := newAuthService().RevalidatePrincipal(principal); fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) {
t.Fatalf("logout left the authenticated snapshot valid: principal=%v err=%v", fresh != nil, err)
}
}

View File

@@ -0,0 +1,118 @@
package services
import (
"encoding/json"
"sync/atomic"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestChannelConfigCompareAndSwapRejectsStalePolicyAndSecretWrites(t *testing.T) {
for _, action := range []string{"editor", "secret-reset"} {
t.Run(action, func(t *testing.T) {
previousDB := sqls.DB()
db := setupChannelServiceTestDB(t)
t.Cleanup(func() { sqls.SetDB(previousDB) })
agent := createChannelServiceTestAgent(t, db, 1001)
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Policy channel", Status: int(enums.StatusOk), ConfigJSON: `{"requireSignedIdentity":false,"userTokenSecret":"test-old-secret"}`}, channelServiceTestOperator())
if err != nil {
t.Fatal(err)
}
var injected atomic.Bool
callbackName := "test:concurrent_channel_policy"
// Commit another writer after the stale service read, just before its SQL.
// Preserve updated_at deliberately: bytewise JSON CAS must also catch this.
if err := db.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Schema == nil || tx.Statement.Schema.Table != "t_channel" || !injected.CompareAndSwap(false, true) {
return
}
cfg, parseErr := ChannelService.ParseWebChannelConfig(channel.ConfigJSON)
if parseErr != nil {
tx.AddError(parseErr)
return
}
cfg.RequireSignedIdentity = true
cfg.UserTokenSecret = "test-new-secret"
encoded, encodeErr := json.Marshal(cfg)
if encodeErr != nil {
tx.AddError(encodeErr)
return
}
if writeErr := db.Session(&gorm.Session{NewDB: true}).Model(&models.Channel{}).Where("id = ?", channel.ID).Updates(map[string]any{"config_json": string(encoded), "updated_at": channel.UpdatedAt}).Error; writeErr != nil {
tx.AddError(writeErr)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
switch action {
case "editor":
err = ChannelService.UpdateChannel(request.UpdateChannelRequest{ID: channel.ID, CreateChannelRequest: request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Old editor title", Status: int(enums.StatusOk), ConfigJSON: `{"title":"old editor"}`}}, channelServiceTestOperator())
case "secret-reset":
var secret string
secret, err = ChannelService.ResetUserTokenSecret(channel.ID, channelServiceTestOperator())
if secret != "" {
t.Fatal("failed reset returned an uncommitted secret")
}
}
if !injected.Load() || err == nil {
t.Fatal("stale writer was not rejected")
}
current := ChannelService.Get(channel.ID)
cfg, parseErr := ChannelService.ParseWebChannelConfig(current.ConfigJSON)
if parseErr != nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret != "test-new-secret" {
t.Fatal("stale writer replaced the concurrent policy/secret")
}
})
}
}
func TestChannelConfigCompareAndSwapUsesExactBytes(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
channel.ConfigJSON = `{"userTokenSecret":"CaseSensitive"}`
if err := db.Model(channel).Updates(map[string]any{"config_json": channel.ConfigJSON, "updated_at": time.Now()}).Error; err != nil {
t.Fatal(err)
}
expected := *channel
if err := db.Model(channel).UpdateColumn("config_json", `{"userTokenSecret":"casesensitive"}`).Error; err != nil {
t.Fatal(err)
}
updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(db, &expected, map[string]any{"config_json": `{"requireSignedIdentity":false}`})
if err != nil {
t.Fatal(err)
}
if updated {
t.Fatal("case-sensitive secret change was not treated as a conflict")
}
}
func TestChannelConfigCompareAndSwapAllowsCurrentEditorAndReset(t *testing.T) {
previousDB := sqls.DB()
db := setupChannelServiceTestDB(t)
t.Cleanup(func() { sqls.SetDB(previousDB) })
agent := createChannelServiceTestAgent(t, db, 1001)
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Current policy", Status: int(enums.StatusOk), ConfigJSON: `{"requireSignedIdentity":true}`}, channelServiceTestOperator())
if err != nil {
t.Fatal(err)
}
err = ChannelService.UpdateChannel(request.UpdateChannelRequest{ID: channel.ID, CreateChannelRequest: request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Current policy edited", Status: int(enums.StatusOk), ConfigJSON: `{"title":"Updated title"}`}}, channelServiceTestOperator())
if err != nil {
t.Fatal(err)
}
secret, err := ChannelService.ResetUserTokenSecret(channel.ID, channelServiceTestOperator())
if err != nil || secret == "" {
t.Fatal("current secret reset failed")
}
cfg, err := ChannelService.ParseWebChannelConfig(ChannelService.Get(channel.ID).ConfigJSON)
if err != nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret != secret {
t.Fatal("current update/reset lost policy or returned unsaved secret")
}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -98,6 +100,15 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if current == nil || current.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0208")
}
if current.ChannelType == enums.ChannelTypeWeb && strings.TrimSpace(req.ChannelType) == enums.ChannelTypeWeb {
// Older dashboard clients do not expose this policy yet. Omitting the field
// during an unrelated edit must not silently turn authenticated-only off.
merged, err := s.preserveWebIdentityPolicy(req.ConfigJSON, current.ConfigJSON)
if err != nil {
return err
}
req.ConfigJSON = merged
}
item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest)
if err != nil {
return err
@@ -118,7 +129,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent
}
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns)
return s.updateConfigIfUnchanged(current, columns)
}
// RollbackChannelAIAgentRollout restores the last channel-level rollout value
@@ -196,6 +207,13 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
return nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return nil, err
}
if policy, ok := fields["requireSignedIdentity"]; ok && string(policy) != "true" && string(policy) != "false" {
return nil, errorsx.InvalidParamI18n("error.e0060")
}
}
cfg.Title = strings.TrimSpace(cfg.Title)
if cfg.Title == "" {
@@ -459,7 +477,7 @@ func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.Aut
}
configJSON = string(raw)
}
if err := repositories.ChannelRepository.Updates(sqls.DB(), channelID, map[string]any{
if err := s.updateConfigIfUnchanged(channel, map[string]any{
"config_json": configJSON,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -683,3 +701,54 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
Remark: strings.TrimSpace(req.Remark),
}, nil
}
// RequiresSignedIdentity enforces the channel policy on the server, not only in
// the embedding SDK. Invalid web configuration fails closed.
func (s *channelService) RequiresSignedIdentity(channel *models.Channel) bool {
if channel == nil {
return true
}
if channel.ChannelType != enums.ChannelTypeWeb {
return false
}
cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON)
return err != nil || cfg.RequireSignedIdentity
}
func (s *channelService) preserveWebIdentityPolicy(raw, previousRaw string) (string, error) {
fields := map[string]json.RawMessage{}
if strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return "", errorsx.InvalidParamI18n("error.e0060")
}
}
if _, present := fields["requireSignedIdentity"]; present {
return raw, nil
}
previous, err := s.ParseWebChannelConfig(previousRaw)
if err != nil {
return "", errorsx.InvalidParamI18n("error.e0060")
}
if !previous.RequireSignedIdentity {
return raw, nil
}
if fields == nil {
fields = map[string]json.RawMessage{}
}
fields["requireSignedIdentity"] = json.RawMessage("true")
encoded, err := json.Marshal(fields)
return string(encoded), err
}
// Reject rather than retry a stale complete config document: retrying could turn
// an old explicit field into authority to overwrite the new policy/secret.
func (s *channelService) updateConfigIfUnchanged(expected *models.Channel, columns map[string]any) error {
updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(sqls.DB(), expected, columns)
if err != nil {
return errorsx.BusinessErrorI18n(0, "error.channel.configUpdateFailed")
}
if !updated {
return errorsx.InvalidParamI18n("error.channel.configChanged")
}
return nil
}

View File

@@ -1,3 +1,5 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services
import (
@@ -151,10 +153,12 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
if limit <= 0 {
limit = pendingDispatchBatchLimit
}
conversations := ConversationService.Find(sqls.NewCnd().
Eq("status", enums.IMConversationStatusPending).
Eq("current_assignee_id", 0).
Desc("id"))
// Preserve the existing newest-first batch/periodic retry policy, but
// bound the SQL read and distinguish failure from an empty queue.
conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit)
if err != nil {
return 0, err
}
if len(conversations) == 0 {
return 0, nil
}
@@ -197,6 +201,7 @@ func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Durat
)
for {
_ = repositories.SupportAvailabilityRepository.PrunePresence(sqls.DB(), time.Now())
if _, err := s.DispatchPendingConversations(0); err != nil {
slog.Warn("dispatch pending conversations loop failed", "error", err)
}
@@ -211,50 +216,29 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
RequestedTeamIDs: append([]int64(nil), teamIDs...),
}
// 1. filter teams with active schedule
activeTeamIDs := s.findActiveScheduleTeamIDs(teamIDs, now)
report.ActiveScheduleTeams = activeTeamIDs
if len(activeTeamIDs) == 0 {
report.Reason = "no_active_schedule_team"
return nil, report, nil
}
// 2. find agent profiles for the active teams
profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs)
report.MatchedProfiles = len(profiles)
if len(profiles) == 0 {
report.Reason = "no_matched_profile"
return nil, report, nil
}
enabledProfiles, enabledUserIDs, reason := s.filterEnabledDispatchProfiles(profiles)
if reason != "" {
report.Reason = reason
return nil, report, nil
}
report.EligibleProfiles = len(enabledProfiles)
activeCounts, err := s.findActiveConversationCountMap(enabledUserIDs)
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
if err != nil {
return nil, report, err
}
candidates := make([]dispatchCandidate, 0, len(enabledProfiles))
for _, profile := range enabledProfiles {
activeCount := activeCounts[profile.UserID]
if profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
report.MatchedProfiles = len(profiles)
report.ActiveScheduleTeams = s.findActiveScheduleTeamIDs(teamIDs, now)
candidates := make([]dispatchCandidate, 0, len(profiles))
report.Reason = "no_eligible_operator"
for _, profile := range profiles {
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, now, 0)
if err != nil {
return nil, report, err
}
if !eligibility.Eligible {
report.Reason = eligibility.Reason
continue
}
loadRate := float64(activeCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
candidates = append(candidates, dispatchCandidate{
profile: profile,
activeCount: activeCount,
loadRate: loadRate,
})
report.EligibleProfiles++
loadRate := float64(eligibility.ActiveCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
candidates = append(candidates, dispatchCandidate{profile: profile, activeCount: eligibility.ActiveCount, loadRate: loadRate})
}
report.CandidateCount = len(candidates)
if len(candidates) == 0 {
report.Reason = "all_candidates_at_capacity"
return nil, report, nil
}
@@ -298,86 +282,20 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
return candidates, report, nil
}
func (s *conversationDispatchService) filterEnabledDispatchProfiles(profiles []models.AgentProfile) ([]models.AgentProfile, []int64, string) {
userIDs := make([]int64, 0, len(profiles))
for _, profile := range profiles {
if profile.UserID > 0 {
userIDs = append(userIDs, profile.UserID)
}
}
if len(userIDs) == 0 {
return nil, nil, "no_profile_with_capacity_config"
}
enabledUsers := UserService.Find(sqls.NewCnd().
In("id", userIDs).
Eq("status", enums.StatusOk))
if len(enabledUsers) == 0 {
return nil, nil, "no_enabled_user"
}
enabledUserSet := make(map[int64]struct{}, len(enabledUsers))
for _, user := range enabledUsers {
enabledUserSet[user.ID] = struct{}{}
}
enabledProfiles := make([]models.AgentProfile, 0, len(profiles))
enabledUserIDs := make([]int64, 0, len(profiles))
for _, profile := range profiles {
if _, exists := enabledUserSet[profile.UserID]; !exists {
continue
}
enabledProfiles = append(enabledProfiles, profile)
enabledUserIDs = append(enabledUserIDs, profile.UserID)
}
if len(enabledProfiles) == 0 {
return nil, nil, "no_profile_for_enabled_user"
}
return enabledProfiles, enabledUserIDs, ""
}
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
if len(teamIDs) == 0 {
return nil
}
teams := AgentTeamService.Find(sqls.NewCnd().
In("id", teamIDs).
Eq("status", enums.StatusOk))
if len(teams) == 0 {
return nil
}
enabledTeamIDs := make([]int64, 0, len(teams))
for _, team := range teams {
enabledTeamIDs = append(enabledTeamIDs, team.ID)
}
schedules := AgentTeamScheduleService.Find(sqls.NewCnd().
In("team_id", enabledTeamIDs).
Eq("status", enums.StatusOk).
Lte("start_at", now).
Gt("end_at", now))
activeSet := make(map[int64]struct{}, len(schedules))
for _, schedule := range schedules {
activeSet[schedule.TeamID] = struct{}{}
}
ret := make([]int64, 0, len(teamIDs))
seen := make(map[int64]struct{})
for _, teamID := range teamIDs {
if _, active := activeSet[teamID]; !active {
result := make([]int64, 0, len(teamIDs))
for _, teamID := range uniquePositiveInt64s(teamIDs) {
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), teamID)
if err != nil || team == nil || team.Status != enums.StatusOk {
continue
}
if _, exists := seen[teamID]; exists {
continue
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), teamID, now)
if err == nil && calendar.Open {
result = append(result, teamID)
}
seen[teamID] = struct{}{}
ret = append(ret, teamID)
}
return ret
return result
}
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
@@ -409,6 +327,10 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
operator := systemDispatchPrincipal()
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, candidate.UserID); err != nil {
return err
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
return errConversationDispatchConflict
@@ -417,6 +339,12 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
return errConversationDispatchConflict
}
guarded, guardErr := SupportAvailabilityService.GuardAssignment(ctx.Tx, candidate.UserID, true, conversation, time.Now())
if guardErr != nil {
return guardErr
}
candidate = *guarded
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
return err
}
@@ -443,6 +371,9 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason))
})
if errors.Is(err, errSupportAssignmentIneligible) {
return nil, nil
}
if err != nil {
return nil, err
}

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -6,8 +9,11 @@ import (
"testing"
"time"
"agent-desk/internal/events"
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/eventbus"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
@@ -112,11 +118,13 @@ func findHumanDispatchRealtimeEvent(t *testing.T, session *ClientSession, eventT
func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession {
t.Helper()
principal := seedRealtimeStaffPrincipal(t, sqls.DB())
session := &ClientSession{
ID: "test-session",
Role: realtimeRoleAdmin,
Topics: map[string]struct{}{},
Send: make(chan []byte, 32),
ID: "test-session",
Role: realtimeRoleAdmin,
Principal: principal,
Topics: map[string]struct{}{},
Send: make(chan []byte, 32),
}
WsService.manager.Register(session, topics)
return session
@@ -124,6 +132,7 @@ func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *Client
func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB, previousWs := sqls.DB(), WsService
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
@@ -150,6 +159,7 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
&models.AgentTeam{},
&models.AgentTeamSchedule{},
&models.AgentProfile{},
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
&models.Conversation{},
&models.ConversationParticipant{},
&models.ConversationAssignment{},
@@ -161,6 +171,13 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
t.Cleanup(func() {
// Assignment notifications run asynchronously. Join them before replacing
// the global fixture DB/service or closing its SQLite connection.
eventbus.Get[events.ConversationAssignedEvent]().Wait()
sqls.SetDB(previousDB)
WsService = previousWs
})
return db
}
@@ -205,6 +222,7 @@ func createHumanDispatchRealtimeAgentProfile(t *testing.T, db *gorm.DB, userID,
Username: "agent",
Nickname: "客服",
Status: enums.StatusOk,
UserType: enums.UserTypeEmployee,
}).Error; err != nil {
t.Fatalf("create user error = %v", err)
}
@@ -220,6 +238,25 @@ func createHumanDispatchRealtimeAgentProfile(t *testing.T, db *gorm.DB, userID,
}).Error; err != nil {
t.Fatalf("create profile error = %v", err)
}
role := models.Role{Code: "test-live-agent", Status: enums.StatusOk}
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
session := models.LoginSession{UserID: userID, Token: "ak_isolated_routing_fixture", ExpiredAt: time.Now().Add(time.Hour)}
// The observing WebSocket already seeds this shared permission. Reuse it
// without changing its state, while the assigned operator gets its own role.
if err := db.Where("code = ?", permission.Code).FirstOrCreate(&permission).Error; err != nil {
t.Fatal(err)
}
for _, item := range []any{&role, &session} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
for _, item := range []any{&models.UserRole{UserID: userID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "isolated-routing-presence", UserID: userID, LoginSessionID: session.ID, ExpiresAt: time.Now().Add(time.Minute)}} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
}
func createHumanDispatchRealtimeConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package services_test
import (
@@ -6,6 +7,7 @@ import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/openidentity"
@@ -260,6 +262,7 @@ func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB {
&models.AgentTeam{},
&models.AgentTeamSchedule{},
&models.AgentProfile{},
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
&models.Conversation{},
&models.ConversationParticipant{},
&models.ConversationAssignment{},
@@ -315,6 +318,7 @@ func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID i
Username: "agent",
Nickname: "客服",
Status: enums.StatusOk,
UserType: enums.UserTypeEmployee,
}).Error; err != nil {
t.Fatalf("create user error = %v", err)
}
@@ -330,6 +334,20 @@ func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID i
}).Error; err != nil {
t.Fatalf("create profile error = %v", err)
}
role := models.Role{Code: "test-live-agent", Status: enums.StatusOk}
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
session := models.LoginSession{UserID: userID, Token: "ak_isolated_routing_fixture", ExpiredAt: time.Now().Add(time.Hour)}
for _, item := range []any{&role, &permission, &session} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
for _, item := range []any{&models.UserRole{UserID: userID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "isolated-routing-presence", UserID: userID, LoginSessionID: session.ID, ExpiresAt: time.Now().Add(time.Minute)}} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
}
func createHumanDispatchConversation(t *testing.T, db *gorm.DB, aiAgentID int64, status enums.IMConversationStatus) models.Conversation {

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -317,17 +320,16 @@ func (s *conversationService) CreateNew(externalUser openidentity.ExternalUser,
// activity first, so the widget can offer a request list. A visitor who has not
// talked to us yet has no identity row and therefore an empty list, not an error.
//
// Scoping is by customer, not by channel: a CustomerIdentity is keyed by
// (external_source, external_id) globally, so one signed visitor reaching us
// through two channels is one person with one request history. For a guest the
// external id is issued per channel, so their identities cannot collide across
// channels anyway. Narrowing this to the current channel would silently hide the
// visitor's earlier requests.
// Public identities are scoped to a server-verified issuer and channel. Legacy
// internal integrations may still use their explicitly unscoped identity mapping.
func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) {
if cnd == nil {
cnd = sqls.NewCnd()
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
if err != nil {
return nil, nil, errorsx.UnauthorizedI18n("error.e0161")
}
if identity == nil {
paging := &sqls.Paging{}
if cnd.Paging != nil {
@@ -336,6 +338,9 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
return []models.Conversation{}, paging, nil
}
cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id")
if externalUser.ChannelID > 0 {
cnd.Eq("channel_id", externalUser.ChannelID)
}
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
if list == nil {
list = []models.Conversation{}
@@ -353,6 +358,10 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
}
var assignedEvent events.ConversationAssignedEvent
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, req.AssigneeID); err != nil {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
if conversation == nil {
return errorsx.InvalidParamI18n("error.e0116")
@@ -361,14 +370,23 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
return errorsx.InvalidParamI18n("error.e0135")
}
now := time.Now()
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, req.AssigneeID, false, conversation, now)
if err != nil {
if err == errSupportAssignmentIneligible {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
return err
}
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
return err
}
if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil {
return err
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
"current_assignee_id": req.AssigneeID,
"current_team_id": targetProfile.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -447,6 +465,10 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
}
var assignedEvent events.ConversationAssignedEvent
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, toUserID); err != nil {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
return errorsx.InvalidParamI18n("error.e0116")
@@ -464,14 +486,23 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
return errorsx.InvalidParamI18n("error.e0277")
}
now := time.Now()
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, toUserID, false, conversation, now)
if err != nil {
if err == errSupportAssignmentIneligible {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
return err
}
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
return err
}
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil {
return err
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
"current_assignee_id": toUserID,
"current_team_id": targetProfile.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -832,8 +863,11 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
return false
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
if identity == nil {
if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID {
return false
}
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID)
if err != nil || identity == nil {
return false
}
return identity.CustomerID == conversation.CustomerID

View File

@@ -0,0 +1,450 @@
package services
import (
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/repositories"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func setupIdentitySecurityDB(t *testing.T) (*gorm.DB, *models.Channel) {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "identity.db")), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil {
t.Fatal(err)
}
previousDB, previousConfig := sqls.DB(), config.GetCurrent()
sqls.SetDB(db)
config.SetCurrent(&config.Config{CustomerSession: config.CustomerSessionConfig{Secret: "test-only-customer-session-key", TTLMinutes: 120}})
t.Cleanup(func() {
sqls.SetDB(previousDB)
config.SetCurrent(previousConfig)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
})
channel := &models.Channel{ChannelID: "channel-a", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
if err := db.Create(channel).Error; err != nil {
t.Fatal(err)
}
return db, channel
}
func scopedTestExternal(channel *models.Channel, issuer, id string) openidentity.ExternalUser {
source := enums.ExternalSourceUser
if issuer == "guest" {
source = enums.ExternalSourceGuest
}
return openidentity.ExternalUser{ExternalSource: source, ExternalID: id, IdentityScope: openidentity.IdentityScope(channel.ChannelID, issuer), ChannelID: channel.ID}
}
func verifySecurityCustomerSession(t *testing.T, channel *models.Channel, token string) *CustomerSessionVerifyResult {
t.Helper()
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/api/ws/open", nil)
ctx.Request.Header.Set("Authorization", "Bearer "+token)
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
t.Fatalf("verify customer credential: %v", err)
}
return result
}
func TestCustomerIdentitySeparatesTrustIssuerAndChannel(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
otherChannel := &models.Channel{ChannelID: "channel-b", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
if err := db.Create(otherChannel).Error; err != nil {
t.Fatal(err)
}
customerIDs := map[int64]bool{}
for _, input := range []struct {
channel *models.Channel
issuer string
}{{channel, "hmac"}, {channel, "jwt"}, {channel, "guest"}, {otherChannel, "hmac"}} {
exchanged, err := CustomerSessionService.Exchange(input.channel, scopedTestExternal(input.channel, input.issuer, "same-id"))
if err != nil {
t.Fatal(err)
}
if customerIDs[exchanged.Customer.ID] {
t.Fatal("independent identity scopes were merged")
}
customerIDs[exchanged.Customer.ID] = true
result := verifySecurityCustomerSession(t, input.channel, exchanged.CustomerSessionToken)
if !CustomerSessionService.VerifyConnection(result) {
t.Fatal("fresh scoped connection rejected")
}
wrongChannel := &models.Conversation{CustomerID: result.CustomerID, ChannelID: otherChannel.ID}
if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) {
t.Fatal("customer acquired a different channel's conversation")
}
}
}
func TestGuestBootstrapCannotReclaimKnownAnonymousIdentity(t *testing.T) {
_, channel := setupIdentitySecurityDB(t)
first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "caller-chosen-id"))
if err != nil {
t.Fatal(err)
}
stolenID := strings.TrimPrefix(first.IdentityKey, "guest:")
if len(stolenID) != 64 || stolenID == "caller-chosen-id" {
t.Fatal("guest identity was not server-generated")
}
second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", stolenID))
if err != nil {
t.Fatal(err)
}
if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey {
t.Fatal("public guest bootstrap reclaimed another guest identity")
}
original := verifySecurityCustomerSession(t, channel, first.CustomerSessionToken)
if original.CustomerID != first.Customer.ID {
t.Fatal("signed guest credential did not retain its owner")
}
}
func TestCustomerIdentityDuplicatesFailClosedWithoutCreatingAnotherCustomer(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
external := scopedTestExternal(channel, "hmac", "duplicate")
for i := 0; i < 2; i++ {
customer := &models.Customer{Status: enums.StatusOk}
if err := db.Create(customer).Error; err != nil {
t.Fatal(err)
}
identity := &models.CustomerIdentity{CustomerID: customer.ID, IdentityScope: external.IdentityScope, ExternalSource: external.ExternalSource, ExternalID: external.ExternalID, Status: enums.StatusOk}
if err := db.Create(identity).Error; err != nil {
t.Fatal(err)
}
}
if _, err := CustomerSessionService.Exchange(channel, external); err == nil {
t.Fatal("ambiguous identity was accepted")
}
var count int64
if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("ambiguous mapping created another customer: %d", count)
}
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, external.IdentityScope, external.ExternalSource, external.ExternalID)
if err == nil || identity != nil {
t.Fatal("duplicate lookup did not fail closed")
}
}
func TestCustomerSessionsRejectLegacyScopeAndLivePolicyChanges(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest"))
if err != nil {
t.Fatal(err)
}
current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken)
claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
if err != nil {
t.Fatal(err)
}
claims.IdentityScope = ""
legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err != nil {
t.Fatal(err)
}
if _, err := CustomerSessionService.verifyToken(legacy); err == nil {
t.Fatal("unscoped legacy credential accepted")
}
if err := db.Model(channel).Update("config_json", `{"requireSignedIdentity":true}`).Error; err != nil {
t.Fatal(err)
}
if CustomerSessionService.VerifyConnection(current) {
t.Fatal("existing guest retained access after signed-only policy change")
}
channel.ConfigJSON = `{"requireSignedIdentity":true}`
if _, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest")); err == nil {
t.Fatal("signed-only channel accepted guest exchange")
}
}
func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *testing.T) {
for _, scenario := range []string{"expiry", "disabled-customer", "disabled-channel", "disabled-identity", "rebound-identity", "duplicate-identity"} {
t.Run(scenario, func(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "signed"))
if err != nil {
t.Fatal(err)
}
current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken)
switch scenario {
case "expiry":
current.ExpiresAt = time.Now()
case "disabled-customer":
err = db.Model(&models.Customer{}).Where("id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error
case "disabled-channel":
err = db.Model(channel).Update("status", enums.StatusDisabled).Error
case "disabled-identity":
err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error
case "rebound-identity":
err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("customer_id", current.CustomerID+100).Error
case "duplicate-identity":
err = db.Create(&models.CustomerIdentity{CustomerID: current.CustomerID + 100, ExternalSource: current.ExternalUser.ExternalSource, ExternalID: current.ExternalUser.ExternalID, IdentityScope: current.ExternalUser.IdentityScope, Status: enums.StatusOk}).Error
}
if err != nil {
t.Fatal(err)
}
if CustomerSessionService.VerifyConnection(current) {
t.Fatal("invalidated customer connection retained authority")
}
})
}
}
func TestCustomerRealtimeTopicsAndDeliveryRejectCrossScopeCollisions(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
ws := newWsService()
signed, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "known-id"))
if err != nil {
t.Fatal(err)
}
guest, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "known-id"))
if err != nil {
t.Fatal(err)
}
signedAuth := verifySecurityCustomerSession(t, channel, signed.CustomerSessionToken)
guestAuth := verifySecurityCustomerSession(t, channel, guest.CustomerSessionToken)
conversation := &models.Conversation{CustomerID: signed.Customer.ID, ChannelID: channel.ID}
if err := db.Create(conversation).Error; err != nil {
t.Fatal(err)
}
owner := &ClientSession{ID: "owner", Role: realtimeRoleUser, External: signedAuth.ExternalUser, CustomerSession: signedAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)}
attacker := &ClientSession{ID: "guest", Role: realtimeRoleUser, External: guestAuth.ExternalUser, CustomerSession: guestAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)}
if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(guest.Customer.ID, channel.ID) {
t.Fatal("trust namespaces collided")
}
if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(signed.Customer.ID, channel.ID+1) {
t.Fatal("channel namespaces collided")
}
ws.manager.Register(owner, ws.defaultTopics(owner))
// Defense in depth: even an accidentally stale/wrong topic registration may
// not grant the guest authorization to the signed customer's conversation.
ws.manager.Register(attacker, ws.routeConversationTopics(conversation))
ws.PublishAgentActivity(conversation, "step", "", "running")
select {
case <-owner.Send:
default:
t.Fatal("owner did not receive its event")
}
select {
case <-attacker.Send:
t.Fatal("guest received another customer's event")
default:
}
if ws.canSubscribeConversation(attacker, conversation.ID) {
t.Fatal("explicit foreign conversation subscription allowed")
}
}
func TestScopedIdentityUniqueClaimPreservesLegacyRows(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
source := enums.ExternalSourceUser
// Dirty legacy mappings remain migratable because their unique claim is NULL.
for _, customerID := range []int64{100, 101} {
if err := db.Create(&models.CustomerIdentity{CustomerID: customerID, ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil {
t.Fatal(err)
}
}
scope := openidentity.IdentityScope(channel.ChannelID, "hmac")
first := &models.CustomerIdentity{CustomerID: 102, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}
if err := db.Create(first).Error; err != nil {
t.Fatal(err)
}
competing := &models.CustomerIdentity{CustomerID: 103, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}
if err := db.Create(competing).Error; err == nil {
t.Fatal("database allowed a competing scoped identity claim")
}
otherScope := openidentity.IdentityScope(channel.ChannelID, "jwt")
if err := db.Create(&models.CustomerIdentity{CustomerID: 104, IdentityScope: otherScope, ScopedKey: repositories.ScopedCustomerIdentityKey(otherScope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil {
t.Fatal("different issuer's claim was conflated")
}
found, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, scope, source, "same")
if err != nil || found == nil || found.CustomerID != 102 {
t.Fatal("legacy mappings interfered with scoped identity")
}
}
func TestWebIdentityPolicySurvivesOlderClientEdits(t *testing.T) {
svc := newChannelService()
raw, err := svc.preserveWebIdentityPolicy(`{"title":"New title"}`, `{"requireSignedIdentity":true}`)
if err != nil {
t.Fatal(err)
}
cfg, err := svc.ParseWebChannelConfig(raw)
if err != nil || !cfg.RequireSignedIdentity {
t.Fatal("older client edit silently disabled signed-only policy")
}
raw, err = svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`, `{"requireSignedIdentity":true}`)
if err != nil {
t.Fatal(err)
}
cfg, err = svc.ParseWebChannelConfig(raw)
if err != nil || cfg.RequireSignedIdentity {
t.Fatal("explicit policy change was not honored")
}
for _, raw := range []string{`{"requireSignedIdentity":null}`, `{"requireSignedIdentity":"false"}`, `{"requireSignedIdentity":0}`} {
if _, err := svc.ParseWebChannelConfig(raw); err == nil {
t.Fatal("non-boolean authentication policy accepted")
}
}
}
func TestCustomerSessionNearExpiryRefreshPreservesIdentity(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "local-context"))
if err != nil {
t.Fatal(err)
}
claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
if err != nil {
t.Fatal(err)
}
originalKey, originalScope, originalCustomer := claims.IdentityKey, claims.IdentityScope, claims.CustomerID
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(20 * time.Second))
nearExpiry, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err != nil {
t.Fatal(err)
}
refreshed := verifySecurityCustomerSession(t, channel, nearExpiry)
if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token == nearExpiry {
t.Fatal("near-expiry credential was not refreshed")
}
refreshedClaims, err := CustomerSessionService.verifyToken(refreshed.Token)
if err != nil {
t.Fatal(err)
}
if refreshedClaims.IdentityKey != originalKey || refreshedClaims.IdentityScope != originalScope || refreshedClaims.CustomerID != originalCustomer {
t.Fatal("refresh changed anonymous identity")
}
var customers int64
if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil {
t.Fatal(err)
}
if customers != 1 {
t.Fatal("refresh allocated another customer")
}
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Second))
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err != nil {
t.Fatal(err)
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/api/customer/session_refresh", nil)
ctx.Request.Header.Set("Authorization", "Bearer "+expired)
if _, err := CustomerSessionService.VerifyRequest(ctx, channel); err == nil {
t.Fatal("expired anonymous credential was silently renewed")
}
}
func TestConcurrentCustomerSessionExchangesKeepOneScopedOwner(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
t.Fatal(err)
}
pool, err := db.DB()
if err != nil {
t.Fatal(err)
}
pool.SetMaxOpenConns(4)
external := scopedTestExternal(channel, "hmac", "concurrent-id")
var queries atomic.Int32
arrived := make(chan struct{}, 2)
release := make(chan struct{})
var releaseOnce sync.Once
releaseBoth := func() { releaseOnce.Do(func() { close(release) }) }
var exchanges sync.WaitGroup
defer func() { releaseBoth(); exchanges.Wait() }()
callbackName := "test:concurrent_scoped_lookup"
if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "t_customer_identity" || tx.Error != nil || tx.RowsAffected != 0 {
return
}
if queries.Add(1) <= 2 {
arrived <- struct{}{}
<-release
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) })
type result struct {
value *response.CustomerSessionExchangeResponse
err error
}
results := make(chan result, 2)
exchanges.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer exchanges.Done()
value, err := CustomerSessionService.Exchange(channel, external)
results <- result{value, err}
}()
}
for i := 0; i < 2; i++ {
select {
case <-arrived:
case <-time.After(2 * time.Second):
releaseBoth()
t.Fatal("concurrent transactions did not both reach initial identity lookup")
}
}
releaseBoth()
successes := 0
var owner int64
for i := 0; i < 2; i++ {
select {
case result := <-results:
if result.err == nil {
successes++
owner = result.value.Customer.ID
}
case <-time.After(2 * time.Second):
t.Fatal("concurrent exchange did not finish")
}
}
if successes != 1 {
t.Fatalf("expected one transaction winner, got %d", successes)
}
var customers, identities int64
if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&models.CustomerIdentity{}).Count(&identities).Error; err != nil {
t.Fatal(err)
}
if customers != 1 || identities != 1 {
t.Fatal("losing transaction left an orphan or competing customer mapping")
}
retry, err := CustomerSessionService.Exchange(channel, external)
if err != nil || retry.Customer.ID != owner {
t.Fatal("retry did not resolve the unique winning owner")
}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -124,7 +126,15 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
return 0, errorsx.UnauthorizedI18n("error.e0149")
}
now := time.Now()
if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil {
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(ctx.Tx, externalUser.IdentityScope, externalSource, externalID)
if err != nil {
return 0, errorsx.UnauthorizedI18n("error.e0161")
}
if identity != nil {
customer := repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID)
if customer == nil || customer.Status != enums.StatusOk {
return 0, errorsx.UnauthorizedI18n("error.e0161")
}
updates := map[string]any{
"last_active_at": now,
"updated_at": now,
@@ -161,12 +171,16 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
}
if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{
CustomerID: customer.ID,
IdentityScope: externalUser.IdentityScope,
ScopedKey: repositories.ScopedCustomerIdentityKey(externalUser.IdentityScope, externalSource, externalID),
ExternalSource: externalSource,
ExternalID: externalID,
Status: enums.StatusOk,
AuditFields: utils.BuildAuditFields(nil),
}); err != nil {
return 0, err
// A concurrent scoped claim rolls the transaction back; retrying the
// exchange resolves the winning mapping. Do not expose SQL/key data.
return 0, errorsx.UnauthorizedI18n("error.e0161")
}
return customer.ID, nil
}

View File

@@ -1,6 +1,10 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
@@ -36,16 +40,19 @@ type customerSessionService struct {
}
type customerSessionClaims struct {
TokenType string `json:"typ"`
ChannelID int64 `json:"channelId"`
ChannelCode string `json:"channelCode"`
CustomerID int64 `json:"customerId"`
CustomerName string `json:"customerName"`
IdentityKey string `json:"identityKey"`
TokenType string `json:"typ"`
ChannelID int64 `json:"channelId"`
ChannelCode string `json:"channelCode"`
CustomerID int64 `json:"customerId"`
CustomerName string `json:"customerName"`
IdentityKey string `json:"identityKey"`
IdentityScope string `json:"identityScope"`
jwt.RegisteredClaims
}
type CustomerSessionVerifyResult struct {
CustomerID int64
ChannelID int64
ExternalUser *openidentity.ExternalUser
Token string
ExpiresAt time.Time
@@ -56,6 +63,19 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
if channel == nil || channel.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0209")
}
if !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) || (ChannelService.RequiresSignedIdentity(channel) && externalUser.ExternalSource != enums.ExternalSourceUser) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
externalUser.ChannelID = channel.ID
if externalUser.ExternalSource == enums.ExternalSourceGuest {
// A public caller's guest ID is not proof of possession. Only the signed
// customer session can resume this server-issued anonymous identity.
var id [32]byte
if _, err := rand.Read(id[:]); err != nil {
return nil, err
}
externalUser.ExternalID = hex.EncodeToString(id[:])
}
var customerID int64
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
@@ -68,9 +88,13 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
return nil, err
}
customer := CustomerService.Get(customerID)
if customer == nil || customer.Status == enums.StatusDeleted {
if customer == nil || customer.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0155")
}
identity, identityErr := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, externalUser.ExternalID)
if identityErr != nil || identity == nil || identity.CustomerID != customer.ID {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
token, expiresAt, err := s.Sign(channel, customer, externalUser)
if err != nil {
return nil, err
@@ -92,18 +116,19 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
if secret == "" {
return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing")
}
if channel == nil || customer == nil {
if channel == nil || customer == nil || !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) {
return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158")
}
now := time.Now()
expiresAt := now.Add(time.Duration(cfg.TTL()) * time.Minute)
claims := customerSessionClaims{
TokenType: customerSessionTokenType,
ChannelID: channel.ID,
ChannelCode: strings.TrimSpace(channel.ChannelID),
CustomerID: customer.ID,
CustomerName: strings.TrimSpace(customer.Name),
IdentityKey: s.identityKey(externalUser),
TokenType: customerSessionTokenType,
ChannelID: channel.ID,
ChannelCode: strings.TrimSpace(channel.ChannelID),
CustomerID: customer.ID,
CustomerName: strings.TrimSpace(customer.Name),
IdentityKey: s.identityKey(externalUser),
IdentityScope: externalUser.IdentityScope,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
@@ -125,27 +150,13 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models
if err != nil {
return nil, err
}
if channel == nil || channel.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0209")
}
if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
customer := CustomerService.Get(claims.CustomerID)
if customer == nil || customer.Status == enums.StatusDeleted {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
external, err := s.externalUserFromClaims(claims, customer)
result, err := s.verifyClaims(claims, channel)
if err != nil {
return nil, err
}
result := &CustomerSessionVerifyResult{
ExternalUser: external,
Token: token,
ExpiresAt: claims.ExpiresAt.Time,
}
result.Token = token
if s.shouldRefresh(claims.ExpiresAt.Time) {
newToken, expiresAt, err := s.Sign(channel, customer, *external)
newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser)
if err != nil {
return nil, err
}
@@ -190,7 +201,7 @@ func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionC
if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" {
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" || strings.TrimSpace(claims.IdentityScope) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return claims, nil
@@ -211,8 +222,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
default:
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1])
if identity == nil || identity.CustomerID != claims.CustomerID {
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), claims.IdentityScope, source, parts[1])
if err != nil || identity == nil || identity.CustomerID != claims.CustomerID {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
name := strings.TrimSpace(claims.CustomerName)
@@ -223,6 +234,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
ExternalSource: source,
ExternalID: parts[1],
ExternalName: name,
IdentityScope: claims.IdentityScope,
ChannelID: claims.ChannelID,
}, nil
}
@@ -250,3 +263,38 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin
token, _ := params.Get(ctx, "customerSessionToken")
return strings.TrimSpace(token)
}
// VerifyConnection rechecks the signed credential, expiry, channel policy and
// current identity mapping. It never refreshes an idle connection indefinitely.
func (s *customerSessionService) VerifyConnection(result *CustomerSessionVerifyResult) bool {
if result == nil || !time.Now().Before(result.ExpiresAt) {
return false
}
claims, err := s.verifyToken(result.Token)
if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID {
return false
}
verified, err := s.verifyClaims(claims, ChannelService.Get(claims.ChannelID))
return err == nil && verified.ExternalUser != nil && result.ExternalUser != nil &&
verified.ExternalUser.IdentityScope == result.ExternalUser.IdentityScope &&
verified.ExternalUser.ExternalSource == result.ExternalUser.ExternalSource &&
verified.ExternalUser.ExternalID == result.ExternalUser.ExternalID
}
func (s *customerSessionService) verifyClaims(claims *customerSessionClaims, channel *models.Channel) (*CustomerSessionVerifyResult, error) {
if channel == nil || channel.Status != enums.StatusOk || claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
customer := CustomerService.Get(claims.CustomerID)
if customer == nil || customer.Status != enums.StatusOk {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
external, err := s.externalUserFromClaims(claims, customer)
if err != nil {
return nil, err
}
if !openidentity.ValidIdentityScope(channel.ChannelID, *external) || (ChannelService.RequiresSignedIdentity(channel) && external.ExternalSource != enums.ExternalSourceUser) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &CustomerSessionVerifyResult{CustomerID: customer.ID, ChannelID: channel.ID, ExternalUser: external, ExpiresAt: claims.ExpiresAt.Time}, nil
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package services
import (
@@ -117,11 +118,18 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons
}
func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTeam, profiles []models.AgentProfile, schedules []models.AgentTeamSchedule, conversations []models.Conversation) (int64, int64, int64, []response.DashboardTeamLoadResponse) {
const onlineWindow = 15 * time.Minute
userIDs := make([]int64, 0, len(profiles))
for _, profile := range profiles {
userIDs = append(userIDs, profile.UserID)
}
online, _ := SupportAvailabilityService.onlineUsers(sqls.DB(), userIDs, now)
scheduledTeamIDs := make(map[int64]bool, len(schedules))
for _, item := range schedules {
scheduledTeamIDs[item.TeamID] = true
for _, team := range teams {
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), team.ID, now)
if err == nil {
scheduledTeamIDs[team.ID] = calendar.Open
}
}
type teamCounter struct {
@@ -151,10 +159,10 @@ func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTe
}
counter.totalAgents++
counter.maxConcurrentCapacity += int64(profile.MaxConcurrentCount)
if profile.LastOnlineAt != nil && now.Sub(*profile.LastOnlineAt) <= onlineWindow {
if online[profile.UserID] {
counter.onlineAgents++
onlineAgents++
if profile.ServiceStatus == enums.ServiceStatusBusy {
if effectiveSupportStatus(profile, now) == enums.ServiceStatusBusy {
counter.busyAgents++
busyAgents++
}
@@ -254,8 +262,11 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
}
scheduledTeamIDs := make(map[int64]bool, len(schedules))
for _, item := range schedules {
scheduledTeamIDs[item.TeamID] = true
for _, team := range teams {
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), team.ID, now)
if err == nil {
scheduledTeamIDs[team.ID] = calendar.Open
}
}
var scheduleMissingCount int64
for _, team := range teams {

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package services
import (
@@ -136,9 +137,9 @@ func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error {
referencingWorkflows := s.findWorkflowReferencesByKnowledgeBaseID(id)
if len(referencingWorkflows) > 0 {
if len(referencingWorkflows) == 1 {
return errorsx.Forbidden(fmt.Sprintf("知识库正在被流程「%s」使用,请先从知识检索节点中移除", referencingWorkflows[0]))
return errorsx.ForbiddenI18n("error.knowledge.workflowReference", referencingWorkflows[0])
}
return errorsx.Forbidden(fmt.Sprintf("知识库正在被 %d 个流程使用,请先从知识检索节点中移除", len(referencingWorkflows)))
return errorsx.ForbiddenI18n("error.knowledge.workflowReferences", len(referencingWorkflows))
}
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package services
import (
@@ -10,6 +11,7 @@ import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
"github.com/glebarez/sqlite"
@@ -203,3 +205,36 @@ func assertKnowledgeBaseServiceTestCount(t *testing.T, model any, query string,
t.Fatalf("count %T = %d, want %d", model, count, want)
}
}
func TestDeleteKnowledgeBaseWorkflowErrorsLocalized(t *testing.T) {
for _, count := range []int{1, 2} {
t.Run(string(rune('0'+count)), func(t *testing.T) {
setupKnowledgeBaseServiceTestDB(t)
kb := createKnowledgeBaseServiceTestBase(t, "Keep this knowledge base")
for i := 0; i < count; i++ {
createKnowledgeBaseServiceTestWorkflow(t, "流程 %s / Процесс"+string(rune('A'+i)), knowledgeBaseServiceTestWorkflowDefinition([]int64{kb.ID}))
}
err := KnowledgeBaseService.DeleteKnowledgeBase(kb.ID)
localized, ok := err.(*errorsx.I18nError)
if !ok {
t.Fatalf("expected localized application error, got %T: %v", err, err)
}
key := "error.knowledge.workflowReference"
if count > 1 {
key = "error.knowledge.workflowReferences"
}
if localized.Key != key {
t.Fatalf("error key = %q", localized.Key)
}
for _, locale := range []string{"en-US", "ru-RU"} {
message := localized.Message(locale)
if strings.Contains(message, "%!") || strings.Contains(message, "error.knowledge") || strings.Contains(message, "知识库正在") {
t.Fatalf("bad localized message %q", message)
}
if count == 1 && !strings.Contains(message, "流程 %s / Процесс") {
t.Fatalf("workflow name changed: %q", message)
}
}
})
}
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package services
import (
@@ -43,27 +44,27 @@ type knowledgeFAQImportRow struct {
Remark string
}
func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate() (*response.KnowledgeFAQExportedFile, error) {
func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate(locales ...string) (*response.KnowledgeFAQExportedFile, error) {
workbook := excelize.NewFile()
defer workbook.Close()
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
if err := writeKnowledgeFAQWorkbookHeader(workbook, locales...); err != nil {
return nil, err
}
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", "如何重置密码?")
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", "进入个人设置后点击重置密码。")
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", "忘记密码怎么办\n重置密码在哪里")
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", "账号")
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleQuestion"))
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleAnswer"))
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleSimilar"))
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleRemark"))
return buildKnowledgeFAQExcelFile("knowledge-faq-import-template.xlsx", workbook)
}
func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64) (*response.KnowledgeFAQExportedFile, error) {
func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64, locales ...string) (*response.KnowledgeFAQExportedFile, error) {
if _, err := s.requireFAQKnowledgeBase(knowledgeBaseID); err != nil {
return nil, err
}
list := repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
workbook := excelize.NewFile()
defer workbook.Close()
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
if err := writeKnowledgeFAQWorkbookHeader(workbook, locales...); err != nil {
return nil, err
}
for index, item := range list {
@@ -182,7 +183,7 @@ func (s *knowledgeFAQService) ImportKnowledgeFAQs(req request.ImportKnowledgeFAQ
return result, nil
}
func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error {
func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File, locales ...string) error {
sheet := workbook.GetSheetName(0)
if sheet == "" {
sheet = knowledgeFAQExcelSheetName
@@ -192,7 +193,13 @@ func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error {
return err
}
}
headers := []string{"标准问题", "答案", "相似问", "备注"}
locale := knowledgeFAQExcelLocale(locales)
headers := []string{
i18nx.Getf(locale, "knowledge.excel.question"),
i18nx.Getf(locale, "knowledge.excel.answer"),
i18nx.Getf(locale, "knowledge.excel.similarQuestions"),
i18nx.Getf(locale, "knowledge.excel.remark"),
}
for index, header := range headers {
cell, err := excelize.CoordinatesToCellName(index+1, 1)
if err != nil {
@@ -274,7 +281,7 @@ func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFA
SimilarQuestions: parseKnowledgeFAQSimilarQuestions(getKnowledgeFAQExcelCell(current, headerMap["similarQuestions"])),
Remark: getKnowledgeFAQExcelCell(current, headerMap["remark"]),
}
if row.Question == "" {
if strings.TrimSpace(row.Question) == "" {
result.Failed++
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0340")})
continue
@@ -284,7 +291,7 @@ func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFA
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0341")})
continue
}
if row.Answer == "" {
if strings.TrimSpace(row.Answer) == "" {
result.Failed++
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0292")})
continue
@@ -302,22 +309,37 @@ func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFA
func buildKnowledgeFAQHeaderMap(headerRow []string) map[string]int {
accepted := map[string]string{
"标准问题": "question",
"问题": "question",
"question": "question",
"答案": "answer",
"answer": "answer",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
"similarquestions": "similarQuestions",
"备注": "remark",
"remark": "remark",
"标准问题": "question",
"问题": "question",
"question": "question",
"primary question": "question",
"standard question": "question",
"основной вопрос": "question",
"стандартный вопрос": "question",
"вопрос": "question",
"答案": "answer",
"answer": "answer",
"ответ": "answer",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
"similarquestions": "similarQuestions",
"similar questions": "similarQuestions",
"similar_questions": "similarQuestions",
"похожие вопросы": "similarQuestions",
"похожие вопрос": "similarQuestions",
"схожие вопросы": "similarQuestions",
"备注": "remark",
"remark": "remark",
"remarks": "remark",
"notes": "remark",
"примечание": "remark",
"примечания": "remark",
}
result := make(map[string]int)
result := map[string]int{"similarQuestions": -1, "remark": -1}
for index, header := range headerRow {
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\uFEFF")))
if field, ok := accepted[key]; ok {
if _, exists := result[field]; !exists {
if existing, exists := result[field]; !exists || existing < 0 {
result[field] = index
}
}
@@ -329,7 +351,8 @@ func getKnowledgeFAQExcelCell(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
// Keep authored Q/A and notes verbatim; trim only for validation and headers.
return row[index]
}
func isKnowledgeFAQExcelRowEmpty(row []string) bool {
@@ -346,8 +369,8 @@ func parseKnowledgeFAQSimilarQuestions(value string) []string {
items := make([]string, 0, len(lines))
seen := make(map[string]struct{}, len(lines))
for _, line := range lines {
item := strings.TrimSpace(strings.ReplaceAll(line, "\r", ""))
if item == "" {
item := strings.TrimSuffix(line, "\r")
if strings.TrimSpace(item) == "" {
continue
}
if _, ok := seen[item]; ok {
@@ -369,3 +392,10 @@ func decodeKnowledgeFAQSimilarQuestions(value string) []string {
}
return items
}
func knowledgeFAQExcelLocale(locales []string) string {
if len(locales) > 0 {
return i18nx.NormalizeLocale(locales[0])
}
return i18nx.DefaultLocale
}

View File

@@ -1,8 +1,10 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package services
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"agent-desk/internal/models"
@@ -18,7 +20,7 @@ import (
)
func TestKnowledgeFAQExcelTemplateContainsExpectedHeaders(t *testing.T) {
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate("zh-CN")
if err != nil {
t.Fatalf("BuildKnowledgeFAQImportTemplate() error = %v", err)
}
@@ -247,3 +249,82 @@ func stubKnowledgeFAQImportIndex(t *testing.T) func() {
indexKnowledgeFAQAfterImport = original
}
}
func TestKnowledgeFAQExcelLocalizedTemplatesRoundTrip(t *testing.T) {
for _, tc := range []struct {
locale string
headers []string
question string
}{
{"en-US", []string{"Primary question", "Answer", "Similar questions", "Notes"}, "How do I reset my password?"},
{"ru-RU", []string{"Основной вопрос", "Ответ", "Похожие вопросы", "Примечания"}, "Как сбросить пароль?"},
{"zh-CN", []string{"标准问题", "答案", "相似问", "备注"}, "如何重置密码?"},
} {
t.Run(tc.locale, func(t *testing.T) {
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate(tc.locale)
if err != nil {
t.Fatal(err)
}
workbook, err := excelize.OpenReader(bytes.NewReader(file.Data))
if err != nil {
t.Fatal(err)
}
defer workbook.Close()
table, err := workbook.GetRows("FAQ")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(table[0], tc.headers) || table[1][0] != tc.question {
t.Fatalf("wrong template: %#v", table)
}
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(file.Data), tc.locale)
if err != nil || result.Failed != 0 || len(rows) != 1 || rows[0].Question != tc.question {
t.Fatalf("round trip = %+v, %+v, %v", rows, result, err)
}
})
}
}
func TestKnowledgeFAQExcelImportAcceptsLegacyAndRussianHeaders(t *testing.T) {
for _, headers := range [][]string{
{"question", "answer", "similarQuestions", "remark"},
{"标准问题", "答案", "相似问", "备注"},
{"\ufeffОсновной вопрос", "ОТВЕТ", "Похожие вопросы", "Примечания"},
{"Вопрос", "Ответ"},
} {
question, answer := " Author question 原文 ", "\n Answer 回答:原文\nnext "
data := buildKnowledgeFAQExcelWorkbook(t, [][]string{headers, {question, answer}})
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(data), "ru-RU")
if err != nil || result.Failed != 0 || len(rows) != 1 {
t.Fatalf("parse %v = %+v, %v", headers, result, err)
}
if rows[0].Question != question || rows[0].Answer != answer {
t.Fatalf("authored data modified: %+v", rows[0])
}
if len(rows[0].SimilarQuestions) != 0 || rows[0].Remark != "" {
t.Fatalf("missing optional columns copied question: %+v", rows[0])
}
}
}
func TestKnowledgeFAQExcelLocalizedExportPreservesRows(t *testing.T) {
setupKnowledgeFAQExcelTestDB(t)
kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk)
item := &models.KnowledgeFAQ{KnowledgeBaseID: kb.ID, Question: " Original 问题 ", Answer: "\nОтвет <tag>原文</tag> ", SimilarQuestions: `[" same question ","相似问"]`, Remark: " Notes 原文 ", Status: enums.StatusOk}
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil {
t.Fatal(err)
}
for _, locale := range []string{"en-US", "ru-RU"} {
file, err := KnowledgeFAQService.ExportKnowledgeFAQs(kb.ID, locale)
if err != nil {
t.Fatal(err)
}
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(file.Data), locale)
if err != nil || result.Failed != 0 || len(rows) != 1 {
t.Fatalf("export round trip: %+v, %v", result, err)
}
if rows[0].Question != item.Question || rows[0].Answer != item.Answer || rows[0].Remark != item.Remark || !reflect.DeepEqual(rows[0].SimilarQuestions, []string{" same question ", "相似问"}) {
t.Fatalf("row changed: %+v", rows[0])
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,11 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services_test
import (
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
@@ -9,6 +14,7 @@ import (
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/services"
@@ -44,6 +50,7 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
&models.AgentTeam{},
&models.AgentTeamSchedule{},
&models.AgentProfile{},
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
&models.Channel{},
&models.Conversation{},
&models.ConversationParticipant{},
@@ -59,378 +66,483 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
return db
}
func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) {
db := setupSetupReadinessTestDB(t)
admin := models.User{
Username: "admin",
Nickname: "Admin Operator",
Status: enums.StatusOk,
func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
t.Helper()
user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("create admin error = %v", err)
}
principal := &dto.AuthPrincipal{
UserID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Roles: []string{constants.RoleCodeSuperAdmin},
}
readiness, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
}
if readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = false on empty DB")
}
if readiness.ReadySteps != 0 {
t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps)
}
if readiness.TotalSteps != 6 {
t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps)
}
if readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB")
}
fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "quick_setup_all",
}, principal)
if err != nil {
t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err)
}
if !fixResp.Readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps)
}
if fixResp.Readiness.ReadySteps != 6 {
t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps)
}
if !fixResp.Readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes)
}
// Second call must be idempotent
fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "quick_setup_all",
}, principal)
if err != nil {
t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err)
}
if !fixResp2.Readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all")
}
var teamCount int64
db.Model(&models.AgentTeam{}).Count(&teamCount)
if teamCount != 1 {
t.Fatalf("expected idempotent 1 team, got %d", teamCount)
}
}
func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) {
db := setupSetupReadinessTestDB(t)
now := time.Now()
opUser := models.User{
Username: "operator1",
Nickname: "Operator One",
Status: enums.StatusOk,
}
if err := db.Create(&opUser).Error; err != nil {
t.Fatalf("create user error = %v", err)
}
team := models.AgentTeam{
Name: "Support Team",
Status: enums.StatusOk,
}
if err := db.Create(&team).Error; err != nil {
t.Fatalf("create team error = %v", err)
}
// Schedule in the past (off-hours now)
pastSchedule := models.AgentTeamSchedule{
TeamID: team.ID,
StartAt: now.Add(-48 * time.Hour),
EndAt: now.Add(-24 * time.Hour),
Status: enums.StatusOk,
}
if err := db.Create(&pastSchedule).Error; err != nil {
t.Fatalf("create past schedule error = %v", err)
}
// Operator is Busy and AutoAssignEnabled = false
profile := models.AgentProfile{
UserID: opUser.ID,
TeamID: team.ID,
AgentCode: "OP-001",
DisplayName: "Operator One",
ServiceStatus: enums.ServiceStatusBusy,
MaxConcurrentCount: 5,
AutoAssignEnabled: false,
Status: enums.StatusOk,
}
if err := db.Create(&profile).Error; err != nil {
t.Fatalf("create profile error = %v", err)
}
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil {
t.Fatalf("update auto_assign_enabled error = %v", err)
}
principal := &dto.AuthPrincipal{
UserID: opUser.ID,
Username: opUser.Username,
Nickname: opUser.Nickname,
Roles: []string{constants.RoleCodeAdmin},
}
readiness, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
}
if readiness.CurrentOperator.TeamHasScheduleNow {
t.Fatalf("expected TeamHasScheduleNow = false for past schedule")
}
if readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours")
}
// Fix operator profile and schedule
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "enable_my_operator_profile",
}, principal)
if err != nil {
t.Fatalf("enable_my_operator_profile error = %v", err)
}
readinessAfter, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness after fix error = %v", err)
}
if !readinessAfter.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes)
}
}
func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) {
db := setupSetupReadinessTestDB(t)
opUser := models.User{
Username: "op2",
Nickname: "Operator Two",
Status: enums.StatusOk,
}
db.Create(&opUser)
principal := &dto.AuthPrincipal{
UserID: opUser.ID,
Username: opUser.Username,
Nickname: opUser.Nickname,
Roles: []string{constants.RoleCodeAdmin},
}
// Setup team + active schedule + idle operator
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "enable_my_operator_profile",
}, principal)
if err != nil {
t.Fatalf("enable_my_operator_profile error = %v", err)
}
// Create AI Agent in AIOnly mode and empty TeamIDs
agent := models.AIAgent{
Name: "AI Bot",
Status: enums.StatusOk,
ServiceMode: enums.IMConversationServiceModeAIOnly,
RolloutPercent: 100,
TeamIDs: "",
}
db.Create(&agent)
channel := models.Channel{
Name: "Web Widget",
ChannelType: "web",
ChannelID: "web-test-1",
AIAgentID: agent.ID,
AIAgentRolloutPercent: 100,
Status: enums.StatusOk,
}
db.Create(&channel)
sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 1 error = %v", err)
}
if sim1.WillReachOperator {
t.Fatalf("expected WillReachOperator = false when AIOnly")
}
if sim1.FinalOutcomeCode != "blocked_ai_only_mode" {
t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode)
}
// Switch to HumanOnly but still empty TeamIDs
db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly)
sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 2 error = %v", err)
}
if sim2.WillReachOperator {
t.Fatalf("expected WillReachOperator = false when TeamIDs is empty")
}
if sim2.FinalOutcomeCode != "blocked_agent_no_teams" {
t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode)
}
// Fix AI Agent handoff via quick fix
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "fix_ai_agent_handoff",
EntityID: agent.ID,
}, principal)
if err != nil {
t.Fatalf("fix_ai_agent_handoff error = %v", err)
}
sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 3 error = %v", err)
}
if !sim3.WillReachOperator {
t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode)
}
if sim3.FinalOutcomeCode != "assigned_to_operator" {
t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode)
}
if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID {
t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator)
}
}
func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) {
db := setupSetupReadinessTestDB(t)
csUser := models.User{
Username: "cs_agent",
Nickname: "Support Agent",
Status: enums.StatusOk,
}
if err := db.Create(&csUser).Error; err != nil {
t.Fatalf("create csUser error = %v", err)
}
// Regular cs_user only has view permissions
csPrincipal := &dto.AuthPrincipal{
UserID: csUser.ID,
Username: csUser.Username,
Nickname: csUser.Nickname,
Roles: []string{constants.RoleCodeCsUser},
return &dto.AuthPrincipal{
UserID: user.ID, Username: user.Username, Nickname: user.Nickname,
Roles: []string{constants.RoleCodeAdmin},
Permissions: []string{
constants.PermissionAgentView.Code,
constants.PermissionAgentTeamView.Code,
constants.PermissionAgentTeamScheduleView.Code,
constants.PermissionAIAgentView.Code,
constants.PermissionChannelView.Code,
constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code,
constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code,
constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code,
constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code,
constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code,
constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code,
},
}
}
// 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions
readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal)
func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse {
t.Helper()
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
t.Fatalf("preview: %v", err)
}
for _, step := range readiness.Steps {
if len(step.QuickFixActions) != 0 {
t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions)
if preview.Applied || preview.PreviewToken == "" {
t.Fatalf("expected read-only preview: %+v", preview)
}
req.Apply, req.PreviewToken = true, preview.PreviewToken
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !applied.Applied {
t.Fatal("reviewed changes were not applied")
}
return applied
}
func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) {
t.Helper()
var count int64
if err := db.Model(model).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != want {
t.Fatalf("%T count = %d, want %d", model, count, want)
}
}
func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatal(err)
}
if preview.Applied || len(preview.Changes) != 3 {
t.Fatalf("expected three proposed draft setup records: %+v", preview)
}
for _, change := range preview.Changes {
if change.EntityName == "" || len(change.After) == 0 {
t.Fatalf("unnamed or unspecified change: %+v", change)
}
}
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} {
assertSetupCount(t, db, model, 0)
}
req.Apply = true
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
t.Fatal("apply without preview must fail")
}
req.PreviewToken = preview.PreviewToken
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatal(err)
}
if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow {
t.Fatalf("configuration must not claim shift coverage: %+v", applied)
}
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} {
assertSetupCount(t, db, model, 1)
}
assertSetupCount(t, db, &models.Channel{}, 0)
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
if len(second.Changes) != 0 {
t.Fatalf("existing configuration must be preserved: %+v", second.Changes)
}
}
func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatal(err)
}
if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil {
t.Fatal(err)
}
req.Apply, req.PreviewToken = true, preview.PreviewToken
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
t.Fatal("stale preview must fail")
}
assertSetupCount(t, db, &models.AgentTeam{}, 1)
assertSetupCount(t, db, &models.AgentProfile{}, 0)
assertSetupCount(t, db, &models.AIAgent{}, 0)
assertSetupCount(t, db, &models.Channel{}, 0)
}
func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatal(err)
}
if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" {
tx.AddError(errors.New("injected agent write failure"))
}
}); err != nil {
t.Fatal(err)
}
req.Apply, req.PreviewToken = true, preview.PreviewToken
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
t.Fatal("expected injected transaction failure")
}
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} {
assertSetupCount(t, db, model, 0)
}
}
func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
team := models.AgentTeam{Name: "Deleted team", Status: enums.StatusDeleted}
disabledTeam := models.AgentTeam{Name: "Disabled team", Status: enums.StatusDisabled}
profile := models.AgentProfile{UserID: principal.UserID, AgentCode: "deleted-profile", Status: enums.StatusDeleted}
deletedAgent := models.AIAgent{Name: "Deleted agent", Status: enums.StatusDeleted}
disabledAgent := models.AIAgent{Name: "Disabled agent", Status: enums.StatusDisabled, RolloutPercent: 100}
zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100}
deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted}
disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100}
for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} {
if err := db.Create(model).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil {
t.Fatal(err)
}
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
if len(result.Changes) != 0 {
t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes)
}
for _, req := range []request.SetupQuickFixRequest{
{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID},
{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID},
{Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID},
{Action: services.QuickFixActionEnableMyProfile},
} {
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
t.Fatalf("deleted target must not be restored: %+v", req)
}
}
for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} {
res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal)
if len(res.Changes) != 0 {
t.Fatalf("disabled/zero rollout changed: %+v", res)
}
}
if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil {
t.Fatal(err)
}
if zeroAgent.RolloutPercent != 0 {
t.Fatal("zero rollout was overwritten")
}
if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil {
t.Fatal(err)
}
if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 {
t.Fatal("disabled channel was overwritten")
}
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
}
func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"}
second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"}
for _, agent := range []*models.AIAgent{&first, &second} {
if err := db.Create(agent).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil {
t.Fatal(err)
}
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil {
t.Fatal("existing agents require a named target")
}
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal)
if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID {
t.Fatalf("wrong scope: %+v", result.Changes)
}
if err := db.First(&first, first.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&second, second.ID).Error; err != nil {
t.Fatal(err)
}
if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" {
t.Fatal("selected agent was not minimally updated")
}
if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" {
t.Fatal("unrelated agent was changed")
}
}
func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
now := time.Now()
team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk}
other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk}
for _, model := range []any{&team, &other} {
if err := db.Create(model).Error; err != nil {
t.Fatal(err)
}
}
schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24 * time.Hour), EndAt: now.Add(32 * time.Hour), Status: enums.StatusOk}
if err := db.Create(&schedule).Error; err != nil {
t.Fatal(err)
}
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal)
if len(result.Changes) != 0 {
t.Fatal("off-hours team must not be mutated")
}
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1)
if result.Readiness == nil {
t.Fatal("missing readiness")
}
foundOffHours, foundMissing := false, false
for _, step := range result.Readiness.Steps {
if step.Key != "schedules" {
continue
}
for _, issue := range step.Issues {
if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" {
foundOffHours = true
}
if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" {
foundMissing = true
}
if issue.QuickFixAction != "" {
t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction)
t.Fatal("shift changes require explicit scheduling")
}
}
}
// 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched
for _, action := range []string{
services.QuickFixActionQuickSetupAll,
services.QuickFixActionEnsureTeamSchedule,
services.QuickFixActionEnableMyProfile,
services.QuickFixActionFixAIAgentHandoff,
services.QuickFixActionEnsureDefaultChannel,
} {
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: action,
}, csPrincipal)
if err == nil {
t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action)
}
}
var teamCount, agentCount, channelCount, profileCount int64
db.Model(&models.AgentTeam{}).Count(&teamCount)
db.Model(&models.AIAgent{}).Count(&agentCount)
db.Model(&models.Channel{}).Count(&channelCount)
db.Model(&models.AgentProfile{}).Count(&profileCount)
if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 {
t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d",
teamCount, agentCount, channelCount, profileCount)
}
// 3. Seed an active team + active schedule + busy profile for csUser
now := time.Now()
team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk}
db.Create(&team)
db.Create(&models.AgentTeamSchedule{
TeamID: team.ID,
StartAt: now.Add(-1 * time.Hour),
EndAt: now.Add(24 * time.Hour),
Status: enums.StatusOk,
})
db.Create(&models.AgentProfile{
UserID: csUser.ID,
TeamID: team.ID,
AgentCode: "AG-CS1",
DisplayName: "Support Agent",
ServiceStatus: enums.ServiceStatusBusy,
MaxConcurrentCount: 5,
AutoAssignEnabled: true,
Status: enums.StatusOk,
})
// Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle,
// but still cannot modify AI agents or channels!
statusOnlyPrincipal := &dto.AuthPrincipal{
UserID: csUser.ID,
Username: csUser.Username,
Nickname: csUser.Nickname,
Roles: []string{constants.RoleCodeCsUser},
Permissions: []string{
constants.PermissionAgentView.Code,
constants.PermissionAgentUpdateStatus.Code,
},
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionFixAIAgentHandoff,
}, statusOnlyPrincipal); err == nil {
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff")
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionEnsureDefaultChannel,
}, statusOnlyPrincipal); err == nil {
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel")
}
fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionEnableMyProfile,
}, statusOnlyPrincipal)
if err != nil {
t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err)
}
if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes)
if !foundOffHours || !foundMissing {
t.Fatal("missing and off-hours schedules must be distinguished")
}
}
func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
principal.Permissions = nil
for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} {
principal.Roles = []string{role}
for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} {
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil {
t.Fatalf("role %s bypassed explicit permission list for %s", role, action)
}
}
}
assertSetupCount(t, db, &models.AgentTeam{}, 0)
}
func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk}
if err := db.Create(&team).Error; err != nil {
t.Fatal(err)
}
profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5}
if err := db.Create(&profile).Error; err != nil {
t.Fatal(err)
}
principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code}
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal)
if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 {
t.Fatalf("status-only permission exceeded: %+v", result.Changes)
}
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil {
t.Fatal(err)
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil {
t.Fatal("status permission must not authorize config edits")
}
}
func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
for _, channelID := range []int64{0, 9999} {
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encoded), `"candidate_operators":[]`) {
t.Fatalf("null candidates: %s", encoded)
}
}
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
var team models.AgentTeam
var agent models.AIAgent
if err := db.First(&team).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&agent).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil {
t.Fatal(err)
}
channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil {
t.Fatal(err)
}
// A configured profile alone is not live staffing. This specific success
// fixture supplies an employee account, current permission, and login lease;
// other setup fixtures retain their original visitor/permission boundaries.
seedSetupSimulationPresence(t, db, principal, now)
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil {
t.Fatal(err)
}
if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID {
t.Fatalf("human-only check failed: %+v", result)
}
if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil {
t.Fatal("unsupported bypass must be explicitly rejected")
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil {
t.Fatal(err)
}
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil {
t.Fatal(err)
}
if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" {
t.Fatalf("AI route must not promise assignment: %+v", result)
}
if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil {
t.Fatal(err)
}
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil {
t.Fatal(err)
}
if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" {
t.Fatalf("partial rollout must not promise assignment: %+v", result)
}
}
func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatal(err)
}
if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" {
tx.AddError(errors.New("injected dispatch read failure"))
}
}); err != nil {
t.Fatal(err)
}
req.Apply, req.PreviewToken = true, preview.PreviewToken
result, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil {
t.Fatalf("committed repair must not appear rolled back: %v", err)
}
if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") {
t.Fatalf("dispatch failure must be visible: %+v", result)
}
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} {
assertSetupCount(t, db, model, 1)
}
assertSetupCount(t, db, &models.Channel{}, 0)
}
func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") {
t.Fatalf("draft agent must not be bound implicitly: %+v", result)
}
assertSetupCount(t, db, &models.Channel{}, 0)
var agent models.AIAgent
if err := db.First(&agent).Error; err != nil {
t.Fatal(err)
}
// This unit fixture supplies the publication marker; revision publication itself
// belongs to the existing agent publishing service's tests.
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil {
t.Fatal(err)
}
result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" {
t.Fatalf("expected reviewed channel create: %+v", result.Changes)
}
assertSetupCount(t, db, &models.Channel{}, 1)
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
}
func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
var profile models.AgentProfile
if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil {
t.Fatal(err)
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil {
t.Fatal("my-profile action must not silently substitute a different named target")
}
}
func seedSetupSimulationPresence(t *testing.T, db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) {
t.Helper()
if err := db.Model(&models.User{}).Where("id = ?", principal.UserID).Update("user_type", enums.UserTypeEmployee).Error; err != nil {
t.Fatal(err)
}
role := models.Role{Code: "setup-simulation-operator", Status: enums.StatusOk}
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
session := models.LoginSession{UserID: principal.UserID, Token: "ak_isolated_setup_simulation", ExpiredAt: now.Add(time.Hour)}
if err := db.Where("code = ?", permission.Code).FirstOrCreate(&permission).Error; err != nil {
t.Fatal(err)
}
for _, item := range []any{&role, &session} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
for _, item := range []any{&models.UserRole{UserID: principal.UserID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "setup-simulation-presence", UserID: principal.UserID, LoginSessionID: session.ID, ExpiresAt: now.Add(time.Minute)}} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
principal.UserType = enums.UserTypeEmployee
principal.LoginSessionID = session.ID
principal.ExpiresAt = session.ExpiredAt
principal.Permissions = append(principal.Permissions, permission.Code)
}

View File

@@ -0,0 +1,208 @@
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestSupportReviewHalfHourDSTAndMissingWholePeriod(t *testing.T) {
cases := []struct {
name, start, end, at, next string
open bool
}{
{"half-hour gap", "02:10", "03:00", "2026-10-03T15:29:59Z", "2026-10-03T15:30:00Z", false},
{"gap opening", "02:10", "03:00", "2026-10-03T15:30:00Z", "", true},
{"whole period absent", "02:10", "02:20", "2026-10-03T15:30:00Z", "2026-10-10T15:10:00Z", false},
{"fold first occurrence", "01:45", "01:50", "2026-04-04T14:45:00Z", "", true},
{"fold second occurrence", "01:45", "01:50", "2026-04-04T15:19:59Z", "", true},
{"fold half-open end", "01:45", "01:50", "2026-04-04T15:20:00Z", "2026-04-11T15:15:00Z", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
calendar := calendarForTest("Australia/Lord_Howe", weeklyForTest(7, tc.start, tc.end))
got, err := evaluateRecurringSupportCalendar(calendar, instantForTest(tc.at))
if err != nil || got.Open != tc.open || supportTimeString(got.NextOpeningAt) != tc.next {
t.Fatalf("got open=%v next=%s error=%v; want open=%v next=%s", got.Open, supportTimeString(got.NextOpeningAt), err, tc.open, tc.next)
}
})
}
}
func TestSupportReviewNextOpeningKeepsItsTimezone(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
secondTeam := models.AgentTeam{Name: "Later opening", Status: enums.StatusOk}
if err := f.db.Create(&secondTeam).Error; err != nil {
t.Fatal(err)
}
if err := f.db.Model(&ai).Update("team_ids", fmt.Sprintf("%d,%d", p.TeamID, secondTeam.ID)).Error; err != nil {
t.Fatal(err)
}
for _, item := range []struct {
teamID int64
zone string
days int
}{{p.TeamID, "Pacific/Auckland", 2}, {secondTeam.ID, "America/New_York", 5}} {
loc, err := time.LoadLocation(item.zone)
if err != nil {
t.Fatal(err)
}
date := f.now.In(loc).AddDate(0, 0, item.days).Format(time.DateOnly)
exceptions, err := json.Marshal([]request.SupportCalendarException{{Date: date, Periods: []request.SupportCalendarPeriod{{Start: "09:00", End: "10:00"}}}})
if err != nil {
t.Fatal(err)
}
calendar := &models.SupportCalendar{TeamID: item.teamID, Mode: "recurring", Timezone: item.zone, WeeklyJSON: "[]", ExceptionsJSON: string(exceptions)}
if err := repositories.SupportAvailabilityRepository.SaveCalendar(f.db, calendar); err != nil {
t.Fatal(err)
}
}
want, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
if err != nil {
t.Fatal(err)
}
got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
if err != nil {
t.Fatal(err)
}
if got.Available || got.Reason != "outside_hours" || got.NextOpeningAt != supportTimeString(want.NextOpeningAt) || got.Timezone != want.Timezone {
t.Fatalf("next opening and timezone must belong to the same team: got %+v, want next=%s timezone=%s", got, supportTimeString(want.NextOpeningAt), want.Timezone)
}
}
func TestSupportReviewFallbackGuardUsesTransactionConnection(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
if err := f.db.Model(&ai).Update("team_ids", "").Error; err != nil {
t.Fatal(err)
}
conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
if err := f.db.Create(&conversation).Error; err != nil {
t.Fatal(err)
}
sqlDB, err := f.db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err = f.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, true, &conversation, f.now)
return err
})
if err != nil {
t.Fatalf("empty AI team IDs must resolve without escaping the held transaction: %v", err)
}
}
func TestSupportReviewDistinctSessionRevocationAndOwnerBinding(t *testing.T) {
f, p, _ := newAvailabilityFixture(t)
second := models.LoginSession{UserID: p.UserID, Token: "ak_test_only_second_session", ExpiredAt: f.now.Add(time.Hour)}
if err := f.db.Create(&second).Error; err != nil {
t.Fatal(err)
}
lease := models.AgentPresenceLease{ID: "separate-login", UserID: p.UserID, LoginSessionID: second.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}
if err := f.db.Create(&lease).Error; err != nil {
t.Fatal(err)
}
if err := f.db.Model(&f.session).Update("revoked_at", f.now).Error; err != nil {
t.Fatal(err)
}
users, err := SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
if err != nil || !users[p.UserID] {
t.Fatalf("revoking one login must not hide another valid login: %v %v", users, err)
}
if err := f.db.Model(&second).Update("user_id", p.UserID+1000).Error; err != nil {
t.Fatal(err)
}
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
if err != nil || users[p.UserID] {
t.Fatalf("lease must remain bound to login-session owner: %v %v", users, err)
}
}
func TestSupportReviewGuardRejectsDisabledProfileDespiteLiveSession(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
if err := f.db.Model(&p).Update("status", enums.StatusDisabled).Error; err != nil {
t.Fatal(err)
}
conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
if err := f.db.Create(&conversation).Error; err != nil {
t.Fatal(err)
}
err := f.db.Transaction(func(tx *gorm.DB) error {
_, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, false, &conversation, f.now)
return err
})
if !errors.Is(err, errSupportAssignmentIneligible) {
t.Fatalf("live session must not bypass disabled profile: %v", err)
}
}
func TestSupportReviewEffectiveStatusFilterAndPagination(t *testing.T) {
f, p, _ := newAvailabilityFixture(t)
before, now, after := f.now.Add(-time.Hour), f.now, f.now.Add(time.Hour)
earlier, later := before.Add(-time.Hour), after.Add(time.Hour)
cases := []struct {
name string
status, fallback enums.ServiceStatus
start, end *time.Time
want enums.ServiceStatus
}{
{"ordinary busy ignores absence", enums.ServiceStatusBusy, enums.ServiceStatusIdle, &before, &after, enums.ServiceStatusBusy},
{"future vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &after, &later, enums.ServiceStatusIdle},
{"active vacation", enums.ServiceStatusVacation, enums.ServiceStatusBusy, &before, &after, enums.ServiceStatusVacation},
{"inclusive vacation start", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &now, &after, enums.ServiceStatusVacation},
{"exclusive vacation end", enums.ServiceStatusVacation, enums.ServiceStatusBreak, &before, &now, enums.ServiceStatusBreak},
{"expired vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &earlier, &before, enums.ServiceStatusIdle},
{"missing bounds safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusUnavailable, nil, nil, enums.ServiceStatusUnavailable},
{"invalid return status safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusVacation, nil, nil, enums.ServiceStatusUnavailable},
}
expected := map[enums.ServiceStatus][]int64{enums.ServiceStatusIdle: {p.ID}}
for i, tc := range cases {
profile := models.AgentProfile{UserID: p.UserID + int64(i) + 10, TeamID: p.TeamID, AgentCode: fmt.Sprintf("review-%d", i), DisplayName: tc.name, Status: enums.StatusOk, ServiceStatus: tc.status, AvailabilityReturnStatus: tc.fallback, AbsenceStartAt: tc.start, AbsenceEndAt: tc.end}
if err := f.db.Create(&profile).Error; err != nil {
t.Fatal(err)
}
if got := effectiveSupportStatus(profile, now); got != tc.want {
t.Fatalf("%s: policy returned %v, want %v", tc.name, got, tc.want)
}
expected[tc.want] = append(expected[tc.want], profile.ID)
}
for _, status := range enums.ServiceStatusValues {
wantIDs := expected[status]
for page := 1; page <= len(wantIDs)+1; page++ {
cnd := sqls.NewCnd().Asc("id").Page(page, 1)
repositories.SupportAvailabilityRepository.EffectiveStatusCondition(cnd, status, now)
got, paging := repositories.AgentProfileRepository.FindPageByCnd(f.db, cnd)
if paging.Total != int64(len(wantIDs)) {
t.Fatalf("status %d page %d: total %d, want %d", status, page, paging.Total, len(wantIDs))
}
if page > len(wantIDs) {
if len(got) != 0 {
t.Fatalf("status %d: final page was not empty: %+v", status, got)
}
continue
}
if len(got) != 1 || got[0].ID != wantIDs[page-1] {
t.Fatalf("status %d page %d: got %+v, want id=%d", status, page, got, wantIDs[page-1])
}
}
}
for _, value := range []string{"99", "-1", "not-a-status"} {
if err := AgentProfileService.FilterEffectiveServiceStatus(sqls.NewCnd(), value); err == nil {
t.Fatalf("accepted invalid effective status %q", value)
}
}
}

View File

@@ -0,0 +1,329 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
"errors"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"slices"
"time"
)
var errSupportAssignmentIneligible = errors.New("support assignment ineligible")
var SupportAvailabilityService = &supportAvailabilityService{}
type supportAvailabilityService struct{}
type supportEligibility struct {
Eligible, Online bool
Reason string
EffectiveStatus enums.ServiceStatus
Calendar supportCalendarEvaluation
ActiveCount int
}
func effectiveSupportStatus(profile models.AgentProfile, now time.Time) enums.ServiceStatus {
if profile.ServiceStatus != enums.ServiceStatusVacation {
return profile.ServiceStatus
}
if profile.AbsenceStartAt != nil && profile.AbsenceEndAt != nil && !now.Before(*profile.AbsenceStartAt) && now.Before(*profile.AbsenceEndAt) {
return enums.ServiceStatusVacation
}
if profile.AvailabilityReturnStatus >= enums.ServiceStatusIdle && profile.AvailabilityReturnStatus <= enums.ServiceStatusUnavailable {
return profile.AvailabilityReturnStatus
}
return enums.ServiceStatusUnavailable
}
// eligibilityReason is the only policy decision for new human assignment. All
// automatic/manual/status/readiness callers provide facts to this evaluator.
func eligibilityReason(profile models.AgentProfile, user, team bool, online bool, calendarOpen bool, activeCount int, automatic bool, now time.Time) string {
switch {
case profile.Status != enums.StatusOk:
return "profile_disabled"
case !user:
return "user_disabled"
case !team:
return "team_disabled"
case !calendarOpen:
return "outside_hours"
case !online:
return "offline"
}
switch effectiveSupportStatus(profile, now) {
case enums.ServiceStatusBusy:
return "busy"
case enums.ServiceStatusBreak:
return "break"
case enums.ServiceStatusUnavailable:
return "unavailable"
case enums.ServiceStatusVacation:
return "vacation"
case enums.ServiceStatusIdle:
default:
return "unavailable"
}
if automatic && !profile.AutoAssignEnabled {
return "auto_assign_disabled"
}
if profile.MaxConcurrentCount < 0 || profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
return "capacity"
}
return "eligible"
}
func (s *supportAvailabilityService) Evaluate(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (supportEligibility, error) {
ret := supportEligibility{EffectiveStatus: effectiveSupportStatus(profile, now)}
user, err := repositories.SupportAvailabilityRepository.User(db, profile.UserID)
if err != nil {
return ret, err
}
team, err := repositories.SupportAvailabilityRepository.Team(db, profile.TeamID)
if err != nil {
return ret, err
}
calendar, err := SupportCalendarService.Evaluate(db, profile.TeamID, now)
if err != nil {
return ret, err
}
ret.Calendar = calendar
online, err := s.onlineUsers(db, []int64{profile.UserID}, now)
if err != nil {
return ret, err
}
ret.Online = online[profile.UserID]
count, err := repositories.SupportAvailabilityRepository.ActiveCount(db, profile.UserID, excludeConversationID)
if err != nil {
return ret, err
}
ret.ActiveCount = count
userEnabled := user != nil && user.DeletedAt == nil && user.Status == enums.StatusOk && user.UserType == enums.UserTypeEmployee
ret.Reason = eligibilityReason(profile, userEnabled, team != nil && team.Status == enums.StatusOk, ret.Online, calendar.Open, count, automatic, now)
ret.Eligible = ret.Reason == "eligible"
return ret, nil
}
func (s *supportAvailabilityService) onlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) {
online, err := repositories.SupportAvailabilityRepository.OnlineUsers(db, userIDs, now)
if err != nil {
return nil, err
}
// Current account/role/permission state is checked on every use, not only on
// lease creation, so revoked privileges cannot linger for the lease lifetime.
for userID := range online {
user, err := repositories.SupportAvailabilityRepository.User(db, userID)
if err != nil {
return nil, err
}
if user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee {
delete(online, userID)
continue
}
_, permissions, err := AuthService.loadUserAuthScope(db, userID)
if err != nil {
return nil, err
}
if !slices.Contains(permissions, constants.PermissionConversationView.Code) {
delete(online, userID)
}
}
return online, nil
}
func (s *supportAvailabilityService) ForUser(userID int64, automatic bool) (*response.SupportAvailabilityResponse, error) {
profile, err := repositories.SupportAvailabilityRepository.ProfileByUser(sqls.DB(), userID)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
if profile == nil {
return nil, nil
}
return s.build(sqls.DB(), *profile, automatic, time.Now(), 0)
}
func (s *supportAvailabilityService) build(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (*response.SupportAvailabilityResponse, error) {
eligibility, err := s.Evaluate(db, profile, automatic, now, excludeConversationID)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
calendar, err := SupportCalendarService.get(db, profile.TeamID)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
return &response.SupportAvailabilityResponse{UserID: profile.UserID, ProfileID: profile.ID, TeamID: profile.TeamID, DisplayName: profile.DisplayName, ServiceStatus: profile.ServiceStatus, ReturnServiceStatus: profile.AvailabilityReturnStatus, EffectiveServiceStatus: eligibility.EffectiveStatus, AbsenceStartAt: supportTimeString(profile.AbsenceStartAt), AbsenceEndAt: supportTimeString(profile.AbsenceEndAt), Online: eligibility.Online, Eligible: eligibility.Eligible, Reason: eligibility.Reason, Calendar: *calendar, CalendarOpen: eligibility.Calendar.Open, NextOpeningAt: supportTimeString(eligibility.Calendar.NextOpeningAt)}, nil
}
func (s *supportAvailabilityService) List(conversationID int64, operator *dto.AuthPrincipal) ([]response.SupportAvailabilityResponse, error) {
var teamIDs []int64
if conversationID > 0 {
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return nil, errorsx.InvalidParamI18n("error.e0116")
}
if !slices.Contains(operator.Permissions, constants.PermissionConversationView.Code) {
return nil, errorsx.ForbiddenI18n("error.e0225")
}
if conversation.Status == enums.IMConversationStatusActive && !ConversationService.canTransferConversation(conversation, operator) {
return nil, errorsx.ForbiddenI18n("error.e0223")
}
ai := AIAgentService.Get(conversation.AIAgentID)
if ai == nil || ai.Status != enums.StatusOk {
teamIDs = []int64{}
} else {
teamIDs = resolveDispatchTeamIDs(ai.TeamIDs)
}
}
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
result := make([]response.SupportAvailabilityResponse, 0, len(profiles))
now := time.Now()
for _, profile := range profiles {
item, err := s.build(sqls.DB(), profile, false, now, conversationID)
if err != nil {
return nil, err
}
result = append(result, *item)
}
return result, nil
}
func validateSupportAbsence(req request.SupportAvailabilityRequest) (*time.Time, *time.Time, error) {
if !enums.IsValidServiceStatus(req.ServiceStatus) {
return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidStatus")
}
if req.ServiceStatus != enums.ServiceStatusVacation {
return nil, nil, nil
}
start, err1 := time.Parse(time.RFC3339, req.AbsenceStartAt)
end, err2 := time.Parse(time.RFC3339, req.AbsenceEndAt)
if err1 != nil || err2 != nil || !end.After(start) {
return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidAbsence")
}
return &start, &end, nil
}
func (s *supportAvailabilityService) Update(userID int64, req request.SupportAvailabilityRequest, operator *dto.AuthPrincipal) (*response.SupportAvailabilityResponse, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
start, end, err := validateSupportAbsence(req)
if err != nil {
return nil, err
}
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
profile, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, userID)
if err != nil {
return errorsx.InvalidParamI18n("error.supportAvailability.noProfile")
}
user, err := repositories.SupportAvailabilityRepository.User(ctx.Tx, userID)
if err != nil {
return supportAvailabilityPersistenceError(err)
}
if profile.Status != enums.StatusOk || user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee {
return errorsx.InvalidParamI18n("error.supportAvailability.noProfile")
}
returnStatus := profile.AvailabilityReturnStatus
if req.ServiceStatus == enums.ServiceStatusVacation && profile.ServiceStatus != enums.ServiceStatusVacation {
returnStatus = profile.ServiceStatus
}
if req.ServiceStatus != enums.ServiceStatusVacation {
returnStatus = req.ServiceStatus
}
return supportAvailabilityPersistenceError(repositories.AgentProfileRepository.Updates(ctx.Tx, profile.ID, map[string]any{"service_status": req.ServiceStatus, "availability_return_status": returnStatus, "absence_start_at": start, "absence_end_at": end, "last_status_at": time.Now(), "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()}))
})
if err != nil {
return nil, err
}
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
return s.ForUser(userID, true)
}
func (s *supportAvailabilityService) GuardAssignment(db *gorm.DB, userID int64, automatic bool, conversation *models.Conversation, now time.Time) (*models.AgentProfile, error) {
profile, err := repositories.SupportAvailabilityRepository.LockProfile(db, userID)
if err != nil {
return nil, errSupportAssignmentIneligible
}
ai := repositories.AIAgentRepository.Get(db, conversation.AIAgentID)
if ai == nil || ai.Status != enums.StatusOk {
return nil, errSupportAssignmentIneligible
}
// Empty explicit team IDs mean all enabled teams. The shared evaluator
// checks this profile team using the transaction DB, not sqls.DB().
teamIDs := orderedPositiveIDs(ai.TeamIDs)
if len(teamIDs) > 0 && !slices.Contains(teamIDs, profile.TeamID) {
return nil, errSupportAssignmentIneligible
}
eligibility, err := s.Evaluate(db, *profile, automatic, now, conversation.ID)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
if !eligibility.Eligible {
return nil, errSupportAssignmentIneligible
}
return profile, nil
}
func (s *supportAvailabilityService) PublicStatus(channel *models.Channel) (*response.PublicSupportStatusResponse, error) {
result := &response.PublicSupportStatusResponse{Reason: "temporarily_absent", Timezone: "UTC"}
if channel == nil || channel.Status != enums.StatusOk {
return result, nil
}
ai := AIAgentService.Get(channel.AIAgentID)
if ai == nil || ai.Status != enums.StatusOk {
return result, nil
}
now := time.Now()
teams := resolveDispatchTeamIDs(ai.TeamIDs)
anyOpen := false
enabledTeamCount := 0
atCapacity := false
var next *time.Time
nextTimezone := ""
for _, id := range teams {
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), id)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
if team == nil || team.Status != enums.StatusOk {
continue
}
enabledTeamCount++
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), id, now)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
result.Timezone = calendar.Timezone
anyOpen = anyOpen || calendar.Open
if calendar.NextOpeningAt != nil && (next == nil || calendar.NextOpeningAt.Before(*next)) {
next = calendar.NextOpeningAt
nextTimezone = calendar.Timezone
}
}
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teams)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
for _, profile := range profiles {
eligibility, err := s.Evaluate(sqls.DB(), profile, true, now, 0)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
if eligibility.Eligible {
result.Available = true
result.Reason = "available"
result.Timezone = eligibility.Calendar.Timezone
return result, nil
}
atCapacity = atCapacity || eligibility.Reason == "capacity"
}
if enabledTeamCount > 0 && !anyOpen {
result.Reason = "outside_hours"
result.NextOpeningAt = supportTimeString(next)
if nextTimezone != "" {
result.Timezone = nextTimezone
}
} else if atCapacity {
result.Reason = "capacity"
}
return result, nil
}

View File

@@ -0,0 +1,384 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
)
func calendarForTest(zone string, periods ...request.SupportCalendarWeeklyPeriod) *response.SupportCalendarResponse {
return &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: 1, Mode: "recurring", Timezone: zone, Weekly: periods, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone}
}
func weeklyForTest(day int, start, end string) request.SupportCalendarWeeklyPeriod {
return request.SupportCalendarWeeklyPeriod{Weekday: day, SupportCalendarPeriod: request.SupportCalendarPeriod{Start: start, End: end}}
}
func instantForTest(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
func TestSupportCalendarSplitOvernightExceptionAndNextOpening(t *testing.T) {
c := calendarForTest("Europe/Berlin", weeklyForTest(1, "09:00", "12:00"), weeklyForTest(1, "13:00", "17:00"), weeklyForTest(5, "22:00", "06:00"))
tests := []struct {
at string
open bool
next string
}{
{"2026-10-12T08:00:00Z", true, ""}, {"2026-10-12T10:30:00Z", false, "2026-10-12T11:00:00Z"},
{"2026-10-16T21:00:00Z", true, ""}, {"2026-10-17T03:59:59Z", true, ""}, {"2026-10-17T04:00:00Z", false, "2026-10-19T07:00:00Z"},
}
for _, tt := range tests {
got, err := evaluateRecurringSupportCalendar(c, instantForTest(tt.at))
if err != nil || got.Open != tt.open || supportTimeString(got.NextOpeningAt) != tt.next {
t.Errorf("%s: %+v %v next %s", tt.at, got, err, supportTimeString(got.NextOpeningAt))
}
}
c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Closed: true}}
got, err := evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00"))
if err != nil || got.Open {
t.Fatalf("holiday must suppress overnight carry: %+v %v", got, err)
}
c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Periods: []request.SupportCalendarPeriod{{Start: "10:00", End: "11:00"}}}}
got, err = evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00"))
if err != nil || supportTimeString(got.NextOpeningAt) != "2026-10-17T08:00:00Z" {
t.Fatalf("date override: %+v %v", got, err)
}
}
func TestSupportCalendarDSTGapAndFold(t *testing.T) {
spring := calendarForTest("America/New_York", weeklyForTest(7, "02:30", "04:00"))
got, err := evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T06:59:00Z"))
if err != nil || got.Open || supportTimeString(got.NextOpeningAt) != "2026-03-08T07:00:00Z" {
t.Fatalf("gap boundary: %+v %v", got, err)
}
got, err = evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T07:00:00Z"))
if err != nil || !got.Open {
t.Fatalf("gap opening: %+v %v", got, err)
}
fold := calendarForTest("America/New_York", weeklyForTest(7, "01:30", "01:45"))
for _, at := range []string{"2026-11-01T05:30:00Z", "2026-11-01T06:15:00Z", "2026-11-01T06:44:59Z"} {
got, err = evaluateRecurringSupportCalendar(fold, instantForTest(at))
if err != nil || !got.Open {
t.Errorf("fold %s: %+v %v", at, got, err)
}
}
got, err = evaluateRecurringSupportCalendar(fold, instantForTest("2026-11-01T06:45:00Z"))
if err != nil || got.Open {
t.Fatalf("fold end: %+v %v", got, err)
}
}
func TestSupportCalendarValidationAndBoundedUnknownOpening(t *testing.T) {
for _, req := range []request.SupportCalendarRequest{
{TeamID: 0, Mode: "recurring"}, {TeamID: 1, Mode: "recurring", Timezone: "Local"}, {TeamID: 1, Mode: "recurring", Timezone: "Not/AZone"},
{TeamID: 1, Mode: "recurring", Weekly: []request.SupportCalendarWeeklyPeriod{weeklyForTest(1, "09:00", "09:00")}},
{TeamID: 1, Mode: "recurring", Exceptions: []request.SupportCalendarException{{Date: "2026-02-30", Closed: true}}},
} {
if validateSupportCalendar(req) == nil {
t.Errorf("accepted invalid config: %+v", req)
}
}
c := calendarForTest("UTC")
got, err := evaluateRecurringSupportCalendar(c, time.Now())
if err != nil || got.Open || got.NextOpeningAt != nil {
t.Fatalf("empty calendar invented opening: %+v %v", got, err)
}
}
func TestSupportAvailabilityEffectiveAbsenceAndUnlimitedCapacity(t *testing.T) {
start := instantForTest("2026-10-17T09:00:00Z")
end := start.Add(48 * time.Hour)
p := models.AgentProfile{Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusVacation, AvailabilityReturnStatus: enums.ServiceStatusBusy, AbsenceStartAt: &start, AbsenceEndAt: &end, AutoAssignEnabled: true}
for _, tt := range []struct {
at time.Time
want enums.ServiceStatus
}{{start.Add(-time.Second), enums.ServiceStatusBusy}, {start, enums.ServiceStatusVacation}, {end.Add(-time.Second), enums.ServiceStatusVacation}, {end, enums.ServiceStatusBusy}} {
if got := effectiveSupportStatus(p, tt.at); got != tt.want {
t.Errorf("effective at %v: %v", tt.at, got)
}
}
p.ServiceStatus = enums.ServiceStatusIdle
p.MaxConcurrentCount = 0
if got := eligibilityReason(p, true, true, true, true, 999, true, start); got != "eligible" {
t.Fatal(got)
}
p.MaxConcurrentCount = 1
if got := eligibilityReason(p, true, true, true, true, 1, true, start); got != "capacity" {
t.Fatal(got)
}
p.MaxConcurrentCount = 0
p.AutoAssignEnabled = false
if got := eligibilityReason(p, true, true, true, true, 0, true, start); got != "auto_assign_disabled" {
t.Fatal(got)
}
if got := eligibilityReason(p, true, true, true, true, 0, false, start); got != "eligible" {
t.Fatal(got)
}
p.ReceiveOfflineMessage = true
if got := eligibilityReason(p, true, true, false, true, 0, false, start); got != "offline" {
t.Fatal(got)
}
}
func TestSupportAvailabilityAbsenceRequiresOffsetAndBounds(t *testing.T) {
for _, req := range []request.SupportAvailabilityRequest{{ServiceStatus: 4}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-16T10:00", AbsenceEndAt: "2026-10-17T10:00"}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-17T10:00:00Z", AbsenceEndAt: "2026-10-16T10:00:00Z"}, {ServiceStatus: 99}} {
if _, _, err := validateSupportAbsence(req); err == nil {
t.Fatalf("accepted %+v", req)
}
}
}
func newAvailabilityFixture(t *testing.T) (*authSessionFixture, models.AgentProfile, models.AIAgent) {
t.Helper()
f := newWsAuthFixture(t, true)
if err := f.db.AutoMigrate(&models.AgentPresenceLease{}, &models.SupportCalendar{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Conversation{}, &models.ConversationAssignment{}, &models.ConversationEventLog{}); err != nil {
t.Fatal(err)
}
team := models.AgentTeam{Name: "Support", Status: enums.StatusOk}
if err := f.db.Create(&team).Error; err != nil {
t.Fatal(err)
}
p := models.AgentProfile{UserID: f.user.ID, TeamID: team.ID, AgentCode: "test", DisplayName: "Test", Status: enums.StatusOk, AutoAssignEnabled: true, MaxConcurrentCount: 1}
ai := models.AIAgent{Name: "Test", TeamIDs: fmt.Sprint(team.ID), Status: enums.StatusOk}
weekly := make([]request.SupportCalendarWeeklyPeriod, 0, 7)
for day := 1; day <= 7; day++ {
weekly = append(weekly, weeklyForTest(day, "00:00", "24:00"))
}
raw, _ := json.Marshal(weekly)
for _, item := range []any{&p, &ai, &models.SupportCalendar{TeamID: team.ID, Mode: "recurring", Timezone: "UTC", WeeklyJSON: string(raw), ExceptionsJSON: "[]"}, &models.AgentPresenceLease{ID: "one", UserID: f.user.ID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}} {
if err := f.db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
return f, p, ai
}
func TestSupportAvailabilityPresenceMultiTabAndRevocation(t *testing.T) {
f, p, _ := newAvailabilityFixture(t)
lease := models.AgentPresenceLease{ID: "second", UserID: p.UserID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}
if err := f.db.Create(&lease).Error; err != nil {
t.Fatal(err)
}
if err := repositories.SupportAvailabilityRepository.EndPresence(f.db, "one", f.now.Add(agentPresenceDisconnectGrace)); err != nil {
t.Fatal(err)
}
users, err := SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(20*time.Second))
if err != nil || !users[p.UserID] {
t.Fatalf("closing one tab hid another: %v %v", users, err)
}
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(agentPresenceLeaseTTL))
if err != nil || users[p.UserID] {
t.Fatalf("crashed sessions outlived TTL: %v %v", users, err)
}
if err := f.db.Model(&f.session).Update("revoked_at", f.now).Error; err != nil {
t.Fatal(err)
}
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
if err != nil || users[p.UserID] {
t.Fatalf("revoked session still present: %v %v", users, err)
}
}
func TestSupportAvailabilityPresencePermissionAndDisabledAccount(t *testing.T) {
for _, column := range []string{"permission", "disabled", "deleted", "expired"} {
t.Run(column, func(t *testing.T) {
f, p, _ := newAvailabilityFixture(t)
switch column {
case "permission":
f.db.Delete(&f.permission)
case "disabled":
f.db.Model(&f.user).Update("status", enums.StatusDisabled)
case "deleted":
f.db.Model(&f.user).Update("deleted_at", f.now)
case "expired":
f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second))
}
got, err := SupportAvailabilityService.Evaluate(f.db, p, true, f.now, 0)
if err != nil || got.Eligible || got.Online {
t.Fatalf("%s: %+v %v", column, got, err)
}
})
}
}
func TestSupportAvailabilityAbsenceDoesNotChangeExistingConversations(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
existing := models.Conversation{AIAgentID: ai.ID, CurrentAssigneeID: p.UserID, CurrentTeamID: p.TeamID, Status: enums.IMConversationStatusActive}
if err := f.db.Create(&existing).Error; err != nil {
t.Fatal(err)
}
req := request.SupportAvailabilityRequest{ServiceStatus: enums.ServiceStatusVacation, AbsenceStartAt: f.now.Add(-time.Minute).Format(time.RFC3339), AbsenceEndAt: f.now.Add(time.Hour).Format(time.RFC3339)}
result, err := SupportAvailabilityService.Update(p.UserID, req, f.principal)
if err != nil || !result.Online || result.Eligible || result.EffectiveServiceStatus != enums.ServiceStatusVacation {
t.Fatalf("vacation: %+v %v", result, err)
}
var current models.Conversation
if err := f.db.First(&current, existing.ID).Error; err != nil {
t.Fatal(err)
}
if current.Status != existing.Status || current.CurrentAssigneeID != existing.CurrentAssigneeID {
t.Fatal("vacation changed existing assignment")
}
}
func TestSupportAvailabilityLegacyCalendarIsNotReinterpreted(t *testing.T) {
f, p, _ := newAvailabilityFixture(t)
if err := f.db.Where("team_id = ?", p.TeamID).Delete(&models.SupportCalendar{}).Error; err != nil {
t.Fatal(err)
}
start := f.now.Add(-time.Hour)
end := f.now.Add(time.Hour)
schedule := models.AgentTeamSchedule{TeamID: p.TeamID, StartAt: start, EndAt: end, Status: enums.StatusOk}
if err := f.db.Create(&schedule).Error; err != nil {
t.Fatal(err)
}
global := models.SupportCalendar{TeamID: 0, Timezone: "Pacific/Auckland", Mode: "recurring", WeeklyJSON: "[]", ExceptionsJSON: "[]"}
if err := f.db.Create(&global).Error; err != nil {
t.Fatal(err)
}
got, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
if err != nil || !got.Open || got.Mode != "legacy" || got.Timezone != time.Local.String() {
t.Fatalf("legacy changed: %+v %v", got, err)
}
c := models.SupportCalendar{TeamID: p.TeamID, Mode: "recurring", Timezone: "", WeeklyJSON: "[]", ExceptionsJSON: "[]"}
if err := f.db.Create(&c).Error; err != nil {
t.Fatal(err)
}
got, err = SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
if err != nil || got.Open || got.Timezone != "Pacific/Auckland" {
t.Fatalf("explicit opt-in ignored: %+v %v", got, err)
}
}
func TestSupportAvailabilityGuardPreventsCapacityAndStaleVacation(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
pending := []models.Conversation{{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}, {AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}}
if err := f.db.Create(&pending).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := f.db.DB()
sqlDB.SetMaxOpenConns(1)
var wg sync.WaitGroup
results := make(chan *models.Conversation, 2)
errs := make(chan error, 2)
for _, conversation := range pending {
wg.Add(1)
go func(id int64) {
defer wg.Done()
got, err := ConversationDispatchService.tryAssignConversation(id, p, "test")
results <- got
errs <- err
}(conversation.ID)
}
wg.Wait()
close(results)
close(errs)
count := 0
for got := range results {
if got != nil {
count++
}
}
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if count != 1 {
t.Fatalf("capacity=1 admitted %d", count)
}
// A candidate selected before the operator changes availability cannot commit.
if err := f.db.Model(&models.Conversation{}).Where("current_assignee_id = ?", p.UserID).Update("status", enums.IMConversationStatusClosed).Error; err != nil {
t.Fatal(err)
}
start, end := f.now.Add(-time.Hour), f.now.Add(time.Hour)
if err := f.db.Model(&p).Updates(map[string]any{"service_status": enums.ServiceStatusVacation, "absence_start_at": start, "absence_end_at": end}).Error; err != nil {
t.Fatal(err)
}
third := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
f.db.Create(&third)
got, err := ConversationDispatchService.tryAssignConversation(third.ID, p, "stale")
if err != nil || got != nil {
t.Fatalf("stale vacation assigned: %+v %v", got, err)
}
}
func TestSupportAvailabilityPublicStatusIsChannelScopedAndMinimal(t *testing.T) {
f, p, ai := newAvailabilityFixture(t)
channel := &models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk}
got, err := SupportAvailabilityService.PublicStatus(channel)
if err != nil || !got.Available || got.Reason != "available" {
t.Fatalf("status %+v %v", got, err)
}
if err := f.db.Model(&p).Update("service_status", enums.ServiceStatusBusy).Error; err != nil {
t.Fatal(err)
}
got, err = SupportAvailabilityService.PublicStatus(channel)
if err != nil || got.Available || got.Reason != "temporarily_absent" {
t.Fatalf("busy aggregate %+v %v", got, err)
}
bytes, _ := json.Marshal(got)
var fields map[string]any
json.Unmarshal(bytes, &fields)
for key := range fields {
if key != "available" && key != "reason" && key != "timezone" && key != "nextOpeningAt" {
t.Fatalf("private status field %s", key)
}
}
other := &models.Channel{AIAgentID: ai.ID + 999, Status: enums.StatusOk}
got, err = SupportAvailabilityService.PublicStatus(other)
if err != nil || got.Available {
t.Fatalf("other channel leaked available operator: %+v %v", got, err)
}
}
func TestSupportAvailabilityNoEnabledRoutedTeamIsNotOutsideHours(t *testing.T) {
f, profile, ai := newAvailabilityFixture(t)
if err := f.db.Model(&models.AgentTeam{}).Where("id = ?", profile.TeamID).Update("status", enums.StatusDisabled).Error; err != nil {
t.Fatal(err)
}
got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" {
t.Fatalf("disabled team: %+v %v", got, err)
}
if err := f.db.Model(&ai).Update("team_ids", "9999").Error; err != nil {
t.Fatal(err)
}
got, err = SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" {
t.Fatalf("missing team: %+v %v", got, err)
}
}
func TestSupportAvailabilityWSLeaseDoesNotRenewAfterCloseOrFromVisitorSocket(t *testing.T) {
f, profile, _ := newAvailabilityFixture(t)
if err := f.db.Where("user_id = ?", profile.UserID).Delete(&models.AgentPresenceLease{}).Error; err != nil {
t.Fatal(err)
}
ws := newWsService()
session := &ClientSession{Principal: f.principal, Role: realtimeRoleNotification, PresenceLeaseID: "platform-connection"}
ws.renewAgentPresence(session)
var lease models.AgentPresenceLease
if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil {
t.Fatal(err)
}
if lease.ExpiresAt.After(time.Now().Add(agentPresenceLeaseTTL)) || !lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) {
t.Fatalf("unexpected lease expiry: %v", lease.ExpiresAt)
}
ws.endAgentPresence(session)
session.PresenceRenewedAt.Store(0)
ws.renewAgentPresence(session)
if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil {
t.Fatal(err)
}
if lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) {
t.Fatal("closed session was renewed")
}
visitor := &ClientSession{Principal: f.principal, Role: realtimeRoleUser, PresenceLeaseID: "visitor-connection"}
ws.renewAgentPresence(visitor)
var count int64
if err := f.db.Model(&models.AgentPresenceLease{}).Where("id = ?", visitor.PresenceLeaseID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("visitor socket established platform presence")
}
}

View File

@@ -0,0 +1,337 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
"encoding/json"
"fmt"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"sort"
"strings"
"time"
_ "time/tzdata"
)
const supportNextOpeningSearchDays = 366
var SupportCalendarService = &supportCalendarService{}
type supportCalendarService struct{}
type supportCalendarEvaluation struct {
Open bool
Timezone, Mode string
NextOpeningAt *time.Time
}
type supportCalendarInterval struct{ start, end time.Time }
func supportAvailabilityPersistenceError(err error) error {
if err == nil {
return nil
}
return errorsx.InvalidParamI18n("error.supportAvailability.persistence")
}
func (s *supportCalendarService) Get(teamID int64) (*response.SupportCalendarResponse, error) {
return s.get(sqls.DB(), teamID)
}
func (s *supportCalendarService) get(db *gorm.DB, teamID int64) (*response.SupportCalendarResponse, error) {
global, err := repositories.SupportAvailabilityRepository.Calendar(db, 0)
if err != nil {
return nil, err
}
zone := "UTC"
if global != nil && global.Timezone != "" {
zone = global.Timezone
}
item := global
if teamID != 0 {
item, err = repositories.SupportAvailabilityRepository.Calendar(db, teamID)
if err != nil {
return nil, err
}
}
ret := &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: teamID, Mode: "legacy", Weekly: []request.SupportCalendarWeeklyPeriod{}, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone, DeploymentTimezone: zone}
if teamID == 0 {
ret.Timezone = zone
ret.Mode = "recurring"
}
if item != nil {
ret.Mode = item.Mode
ret.Timezone = item.Timezone
if err = json.Unmarshal([]byte(item.WeeklyJSON), &ret.Weekly); err != nil {
return nil, err
}
if err = json.Unmarshal([]byte(item.ExceptionsJSON), &ret.Exceptions); err != nil {
return nil, err
}
if item.Timezone != "" {
ret.EffectiveTimezone = item.Timezone
}
}
// Dated schedules are absolute stored instants; their existing input/display
// convention is the process local zone, never the new recurring UTC default.
if ret.Mode == "legacy" {
ret.EffectiveTimezone = time.Local.String()
}
return ret, nil
}
func validateSupportCalendar(req request.SupportCalendarRequest) error {
invalid := func() error { return errorsx.InvalidParamI18n("error.supportAvailability.invalidCalendar") }
if req.TeamID < 0 || (req.Mode != "legacy" && req.Mode != "recurring") || len(req.Weekly) > 100 || len(req.Exceptions) > 730 {
return invalid()
}
if req.TeamID == 0 && (req.Timezone == "" || len(req.Weekly) > 0 || len(req.Exceptions) > 0) {
return invalid()
}
if req.Timezone != "" {
if req.Timezone == "Local" {
return invalid()
}
if _, err := time.LoadLocation(req.Timezone); err != nil {
return invalid()
}
}
for _, period := range req.Weekly {
if period.Weekday < 1 || period.Weekday > 7 || !validSupportPeriod(period.SupportCalendarPeriod) {
return invalid()
}
}
seen := map[string]bool{}
for _, exception := range req.Exceptions {
date, err := time.Parse(time.DateOnly, exception.Date)
if err != nil || date.Format(time.DateOnly) != exception.Date || seen[exception.Date] || len(exception.Periods) > 24 || exception.Closed && len(exception.Periods) > 0 {
return invalid()
}
seen[exception.Date] = true
for _, period := range exception.Periods {
if !validSupportPeriod(period) {
return invalid()
}
}
}
return nil
}
func supportClockMinutes(clock string, allow24 bool) (int, bool) {
if allow24 && clock == "24:00" {
return 1440, true
}
t, err := time.Parse("15:04", clock)
if err != nil || t.Format("15:04") != clock {
return 0, false
}
return t.Hour()*60 + t.Minute(), true
}
func validSupportPeriod(period request.SupportCalendarPeriod) bool {
a, ok := supportClockMinutes(period.Start, false)
b, ok2 := supportClockMinutes(period.End, true)
return ok && ok2 && a != b
}
func (s *supportCalendarService) Save(req request.SupportCalendarRequest) (*response.SupportCalendarResponse, error) {
req.Timezone = strings.TrimSpace(req.Timezone)
if err := validateSupportCalendar(req); err != nil {
return nil, err
}
if req.TeamID > 0 {
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), req.TeamID)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
if team == nil {
return nil, errorsx.InvalidParamI18n("error.e0169")
}
}
if req.TeamID == 0 {
req.Mode = "recurring"
}
if req.Weekly == nil {
req.Weekly = []request.SupportCalendarWeeklyPeriod{}
}
if req.Exceptions == nil {
req.Exceptions = []request.SupportCalendarException{}
}
weekly, _ := json.Marshal(req.Weekly)
exceptions, _ := json.Marshal(req.Exceptions)
item := &models.SupportCalendar{TeamID: req.TeamID, Mode: req.Mode, Timezone: req.Timezone, WeeklyJSON: string(weekly), ExceptionsJSON: string(exceptions), UpdatedAt: time.Now()}
if err := repositories.SupportAvailabilityRepository.SaveCalendar(sqls.DB(), item); err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
ret, err := s.Get(req.TeamID)
return ret, supportAvailabilityPersistenceError(err)
}
func (s *supportCalendarService) Preview(req request.SupportCalendarPreviewRequest) (*response.SupportCalendarPreviewResponse, error) {
if err := validateSupportCalendar(req.Calendar); err != nil {
return nil, err
}
now := time.Now()
if req.At != "" {
var err error
now, err = time.Parse(time.RFC3339, req.At)
if err != nil {
return nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidTime")
}
}
calendar := &response.SupportCalendarResponse{SupportCalendarRequest: req.Calendar, EffectiveTimezone: req.Calendar.Timezone}
if calendar.EffectiveTimezone == "" {
global, err := s.get(sqls.DB(), 0)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
calendar.EffectiveTimezone = global.EffectiveTimezone
}
result, err := s.evaluateCalendar(sqls.DB(), calendar, now)
if err != nil {
return nil, supportAvailabilityPersistenceError(err)
}
return &response.SupportCalendarPreviewResponse{Open: result.Open, Timezone: result.Timezone, Mode: result.Mode, NextOpeningAt: supportTimeString(result.NextOpeningAt)}, nil
}
func (s *supportCalendarService) Evaluate(db *gorm.DB, teamID int64, now time.Time) (supportCalendarEvaluation, error) {
calendar, err := s.get(db, teamID)
if err != nil {
return supportCalendarEvaluation{}, err
}
return s.evaluateCalendar(db, calendar, now)
}
func (s *supportCalendarService) evaluateCalendar(db *gorm.DB, calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) {
if calendar.Mode == "legacy" {
ret := supportCalendarEvaluation{Mode: "legacy", Timezone: time.Local.String()}
items, err := repositories.SupportAvailabilityRepository.LegacySchedules(db, calendar.TeamID, now)
if err != nil {
return ret, err
}
for _, item := range items {
if !item.StartAt.After(now) {
ret.Open = true
} else if ret.NextOpeningAt == nil {
next := item.StartAt
ret.NextOpeningAt = &next
}
}
if ret.Open {
ret.NextOpeningAt = nil
}
return ret, nil
}
return evaluateRecurringSupportCalendar(calendar, now)
}
func evaluateRecurringSupportCalendar(calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) {
ret := supportCalendarEvaluation{Mode: "recurring", Timezone: calendar.EffectiveTimezone}
loc, err := time.LoadLocation(calendar.EffectiveTimezone)
if err != nil {
return ret, err
}
day := now.In(loc)
year, month, date := day.Date()
day = time.Date(year, month, date, 12, 0, 0, 0, loc)
exceptions := map[string]request.SupportCalendarException{}
for _, item := range calendar.Exceptions {
exceptions[item.Date] = item
}
for offset := -1; offset <= supportNextOpeningSearchDays; offset++ {
current := day.AddDate(0, 0, offset)
periods := []request.SupportCalendarPeriod{}
if exception, ok := exceptions[current.Format(time.DateOnly)]; ok {
if !exception.Closed {
periods = exception.Periods
}
} else {
for _, p := range calendar.Weekly {
if p.Weekday == weekdayForBatchRequest(current) {
periods = append(periods, p.SupportCalendarPeriod)
}
}
}
intervals := []supportCalendarInterval{}
for _, period := range periods {
startMinute, ok := supportClockMinutes(period.Start, false)
endMinute, ok2 := supportClockMinutes(period.End, true)
if !ok || !ok2 || startMinute == endMinute {
return ret, fmt.Errorf("invalid calendar period")
}
endDay := current
if endMinute <= startMinute || endMinute == 1440 {
endDay = current.AddDate(0, 0, 1)
if endMinute == 1440 {
endMinute = 0
}
}
start, err := resolveSupportWallTime(current, startMinute, loc, false)
if err != nil {
return ret, err
}
end, err := resolveSupportWallTime(endDay, endMinute, loc, true)
if err != nil {
return ret, err
}
// Any exception owns its whole civil date, including an overnight carry.
if endDay.Format(time.DateOnly) != current.Format(time.DateOnly) {
if _, exists := exceptions[endDay.Format(time.DateOnly)]; exists {
end, err = resolveSupportWallTime(endDay, 0, loc, false)
if err != nil {
return ret, err
}
}
}
if end.After(start) {
intervals = append(intervals, supportCalendarInterval{start: start, end: end})
}
}
sort.Slice(intervals, func(i, j int) bool { return intervals[i].start.Before(intervals[j].start) })
for _, interval := range intervals {
if !now.Before(interval.start) && now.Before(interval.end) {
ret.Open = true
ret.NextOpeningAt = nil
return ret, nil
}
if interval.start.After(now) && (ret.NextOpeningAt == nil || interval.start.Before(*ret.NextOpeningAt)) {
next := interval.start
ret.NextOpeningAt = &next
}
}
if ret.NextOpeningAt != nil && offset >= 0 {
return ret, nil
}
}
return ret, nil
}
// At a DST fold the earlier occurrence starts a shift and the later occurrence
// ends it. Missing boundary minutes move forward to the first valid wall minute.
// This is deterministic across hosts and includes both occurrences of a fold.
func resolveSupportWallTime(day time.Time, minute int, loc *time.Location, end bool) (time.Time, error) {
y, m, d := day.Date()
wall := time.Date(y, m, d, minute/60, minute%60, 0, 0, time.UTC)
offsets := map[int]bool{}
for delta := -48; delta <= 48; delta += 6 {
_, offset := day.Add(time.Duration(delta) * time.Hour).Zone()
offsets[offset] = true
}
for shift := 0; shift <= 24*60; shift++ {
target := wall.Add(time.Duration(shift) * time.Minute)
var candidates []time.Time
for offset := range offsets {
instant := target.Add(-time.Duration(offset) * time.Second)
local := instant.In(loc)
if local.Format("2006-01-02 15:04") == target.Format("2006-01-02 15:04") {
candidates = append(candidates, instant)
}
}
if len(candidates) > 0 {
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Before(candidates[j]) })
if end {
return candidates[len(candidates)-1], nil
}
return candidates[0], nil
}
}
return time.Time{}, fmt.Errorf("unresolvable civil time")
}
func supportTimeString(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,78 @@
package services
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"agent-desk/internal/models"
)
// Service integration fixtures may enqueue outbound work asynchronously. Keep
// those clients inside this test process's loopback fixtures even when a test
// accidentally supplies an external URL. Production transports are unchanged.
type loopbackTestTransport struct {
base http.RoundTripper
}
func (t loopbackTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
host := req.URL.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return nil, fmt.Errorf("external HTTP transport disabled in service tests: %s", host)
}
return t.base.RoundTrip(req)
}
func TestMain(m *testing.M) {
// These service fixtures do not contain AI-runtime tables/configuration.
// Runtime behavior has its own suite; focused hook tests install their own
// spies. Never let a transitive runtime init launch unowned background work.
TriggerAIReplyAsyncHook = func(models.Conversation, models.Message) {}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
http.DefaultTransport = loopbackTestTransport{base: transport}
os.Exit(m.Run())
}
type testRoundTripper func(*http.Request) (*http.Response, error)
func (f testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestServiceTransportRejectsExternalHostsBeforeDial(t *testing.T) {
for _, address := range []string{"https://slack.com/api/chat.postMessage", "https://api.telegram.org/test", "http://example.invalid", "http://192.0.2.1", "http://[2001:db8::1]"} {
t.Run(address, func(t *testing.T) {
transport := loopbackTestTransport{base: testRoundTripper(func(*http.Request) (*http.Response, error) {
t.Fatal("external request reached the underlying transport")
return nil, nil
})}
req, err := http.NewRequest(http.MethodGet, address, nil)
if err != nil {
t.Fatal(err)
}
if _, err := transport.RoundTrip(req); err == nil {
t.Fatal("external request was not blocked")
}
})
}
}
func TestServiceTransportRejectsRedirectOutsideLoopback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://example.invalid/forbidden", http.StatusFound)
}))
defer server.Close()
response, err := http.Get(server.URL)
if response != nil {
response.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "external HTTP transport disabled") {
t.Fatalf("redirect did not hit the loopback guard: %v", err)
}
}

View File

@@ -0,0 +1,200 @@
package services
import (
"slices"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func newWsAuthFixture(t *testing.T, hasConversationView bool) *authSessionFixture {
t.Helper()
f := newAuthSessionFixture(t)
f.permission.Code = constants.PermissionConversationView.Code
if err := f.db.Model(&f.permission).Update("code", f.permission.Code).Error; err != nil {
t.Fatal(err)
}
permissions := []string{f.permission.Code}
if !hasConversationView {
if err := f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error; err != nil {
t.Fatal(err)
}
permissions = nil
}
f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, permissions)
previousDB := sqls.DB()
sqls.SetDB(f.db)
t.Cleanup(func() { sqls.SetDB(previousDB) })
return f
}
func newWsAuthClient(f *authSessionFixture, id, role string) *ClientSession {
return &ClientSession{
ID: id,
Role: role,
Principal: f.principal,
Topics: make(map[string]struct{}),
Send: make(chan []byte, 16),
}
}
func TestWsStaffWithoutViewPermissionHasNoConversationDefaults(t *testing.T) {
f := newWsAuthFixture(t, false)
ws := newWsService()
session := newWsAuthClient(f, "no-view", realtimeRoleAdmin)
if !ws.revalidateSession(session) {
t.Fatal("expected a valid authenticated staff session")
}
if topics := ws.defaultTopics(session); len(topics) != 0 {
t.Fatalf("staff without view permission received default topics: %v", topics)
}
if topics := ws.filterAllowedTopics(session, []string{realtimeTopicAdminAll, ws.adminTopic(f.user.ID), "conversation:100"}); len(topics) != 0 {
t.Fatalf("staff without view permission could subscribe: %v", topics)
}
}
func TestWsNotificationSessionOnlyReceivesOwnNotificationTopic(t *testing.T) {
for _, hasView := range []bool{false, true} {
name := "without view permission"
if hasView {
name = "with view permission"
}
t.Run(name, func(t *testing.T) {
f := newWsAuthFixture(t, hasView)
ws := newWsService()
session := newWsAuthClient(f, "notification", realtimeRoleNotification)
ownTopic := ws.notificationTopic(f.user.ID)
if got := ws.defaultTopics(session); !slices.Equal(got, []string{ownTopic}) {
t.Fatalf("unexpected notification defaults: %v", got)
}
requested := []string{ownTopic, ws.notificationTopic(f.user.ID + 1), realtimeTopicAdminAll, "conversation:100"}
if got := ws.filterAllowedTopics(session, requested); !slices.Equal(got, []string{ownTopic}) {
t.Fatalf("notification connection escaped its own topic: %v", got)
}
// Simulate stale or incorrectly registered membership. Delivery must
// still reject conversation events, including when view is granted.
ws.manager.Register(session, requested)
ws.PublishToTopic("conversation:100", ws.newEvent("conversation:100", RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
if len(session.Send) != 0 {
t.Fatal("notification connection received a conversation event")
}
ws.PublishToTopic(ownTopic, ws.newEvent(ownTopic, RealtimeNotificationCreatedEvent{}))
if len(session.Send) != 1 || session.Closed.Load() {
t.Fatal("valid notification connection did not receive its own notification")
}
})
}
}
func TestWsStaleAdminAllMembershipDoesNotGrantDelivery(t *testing.T) {
f := newWsAuthFixture(t, false)
ws := newWsService()
session := newWsAuthClient(f, "stale-member", realtimeRoleAdmin)
ws.manager.Register(session, []string{realtimeTopicAdminAll})
ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
if len(session.Send) != 0 {
t.Fatal("stale admin:all membership bypassed conversation-view permission")
}
}
func TestWsStaffRevocationClosesBeforeDeliveryOrIncomingMessage(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
}{
{"revoked login", func(f *authSessionFixture) error {
return f.db.Model(&f.session).Update("revoked_at", time.Now()).Error
}},
{"expired login", func(f *authSessionFixture) error {
return f.db.Model(&f.session).Update("expired_at", time.Now().Add(-time.Second)).Error
}},
{"disabled user", func(f *authSessionFixture) error {
return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error
}},
{"downgraded user type", func(f *authSessionFixture) error {
return f.db.Model(&f.user).Update("user_type", enums.UserTypeUser).Error
}},
{"deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", time.Now()).Error }},
{"removed role", func(f *authSessionFixture) error {
return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error
}},
{"disabled role", func(f *authSessionFixture) error {
return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error
}},
{"removed permission", func(f *authSessionFixture) error {
return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error
}},
{"disabled permission", func(f *authSessionFixture) error {
return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error
}},
}
for _, tc := range cases {
for _, trigger := range []string{"fanout", "incoming"} {
t.Run(tc.name+"/"+trigger, func(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := newWsAuthClient(f, "staff", realtimeRoleAdmin)
ws.manager.Register(session, ws.defaultTopics(session))
if !ws.revalidateSession(session) {
t.Fatal("expected valid staff session before access change")
}
if err := tc.change(f); err != nil {
t.Fatal(err)
}
event := ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated})
if trigger == "fanout" {
ws.PublishToTopic(realtimeTopicAdminAll, event)
} else {
ws.handleClientMessage(session, []byte(`{"type":"ping"}`))
}
if !session.Closed.Load() || ws.manager.HasTopic(realtimeTopicAdminAll) {
t.Fatal("stale staff connection remained registered")
}
if len(session.Send) != 0 {
t.Fatal("stale staff connection received an event or pong")
}
ws.handleClientMessage(session, []byte(`{"type":"subscribe","topics":["admin:all","conversation:100"]}`))
ws.PublishToTopic(realtimeTopicAdminAll, event)
if len(session.Send) != 0 || len(ws.manager.TopicList(session)) != 0 {
t.Fatal("closed connection processed later messages or delivery")
}
})
}
}
}
// Existing realtime integration tests also need real login-session bindings now
// that delivery revalidates authorization instead of trusting topic membership.
func seedRealtimeStaffPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
t.Helper()
if err := db.AutoMigrate(&models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}, &models.LoginSession{}); err != nil {
t.Fatal(err)
}
user := models.User{ID: 1001, Username: "realtime-observer", UserType: enums.UserTypeEmployee, Status: enums.StatusOk}
role := models.Role{Code: "realtime-observer", Status: enums.StatusOk}
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
for _, item := range []any{&user, &role, &permission} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
session := models.LoginSession{UserID: user.ID, Token: "ak_test_only_realtime", ExpiredAt: time.Now().Add(time.Hour)}
for _, item := range []any{
&session,
&models.UserRole{UserID: user.ID, RoleID: role.ID},
&models.RolePermission{RoleID: role.ID, PermissionID: permission.ID},
} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
return buildAuthPrincipal(&user, &session, []string{role.Code}, []string{permission.Code})
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import "sync"
@@ -23,6 +25,9 @@ func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []s
m.mu.Lock()
defer m.mu.Unlock()
if session.Closed.Load() {
return len(m.sessions)
}
m.sessions[session.ID] = session
for _, topic := range defaultTopics {
m.subscribeLocked(session, topic)
@@ -53,6 +58,9 @@ func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string)
m.mu.Lock()
defer m.mu.Unlock()
if session.Closed.Load() {
return nil
}
ret := make([]string, 0, len(topics))
for _, topic := range topics {
if _, exists := session.Topics[topic]; exists {
@@ -132,3 +140,16 @@ func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic st
}
delete(session.Topics, topic)
}
func (m *WsConnectionManager) TopicList(session *ClientSession) []string {
m.mu.RLock()
defer m.mu.RUnlock()
if session == nil {
return nil
}
topics := make([]string, 0, len(session.Topics))
for topic := range session.Topics {
topics = append(topics, topic)
}
return topics
}

View File

@@ -0,0 +1,139 @@
package services
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-desk/internal/pkg/enums"
"github.com/gorilla/websocket"
)
// The listener exists only inside the test. No app server or external service is
// involved; the DB fixture is temporary and every pump is joined before cleanup.
func newSecuritySocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn) {
t.Helper()
accepted := make(chan *websocket.Conn, 1)
failed := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
failed <- err
return
}
accepted <- conn
}))
t.Cleanup(server.Close)
client, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
var serverConn *websocket.Conn
select {
case serverConn = <-accepted:
case err := <-failed:
t.Fatal(err)
case <-time.After(time.Second):
t.Fatal("test websocket upgrade timed out")
}
t.Cleanup(func() { _ = client.Close(); _ = serverConn.Close() })
return serverConn, client
}
func startSecurityPumps(t *testing.T, ws *wsService, session *ClientSession, reader bool) {
t.Helper()
written := make(chan struct{})
go func() { defer close(written); ws.writePump(session) }()
var read chan struct{}
if reader {
read = make(chan struct{})
go func() { defer close(read); ws.readPump(session) }()
}
t.Cleanup(func() {
ws.closeSession(session)
select {
case <-written:
case <-time.After(time.Second):
t.Error("write pump did not stop")
}
if read != nil {
select {
case <-read:
case <-time.After(time.Second):
t.Error("read pump did not stop")
}
}
})
}
func requireSecuritySocketClosed(t *testing.T, client *websocket.Conn) {
t.Helper()
_ = client.SetReadDeadline(time.Now().Add(time.Second))
_, _, err := client.ReadMessage()
if err == nil {
t.Fatal("revoked connection delivered a buffered message")
}
if networkErr, ok := err.(interface{ Timeout() bool }); ok && networkErr.Timeout() {
t.Fatal("revoked connection stayed open until test deadline")
}
}
func TestWsWritePumpRejectsBufferedEventAfterRevocation(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := newWsAuthClient(f, "buffered", realtimeRoleAdmin)
serverConn, client := newSecuritySocketPair(t)
session.Conn = serverConn
ws.manager.Register(session, ws.defaultTopics(session))
session.enqueueEvent(ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
startSecurityPumps(t, ws, session, false)
requireSecuritySocketClosed(t, client)
if !session.Closed.Load() {
t.Fatal("writer did not revoke session")
}
}
func TestWsReadPumpRejectsIncomingFrameAfterRevocation(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := newWsAuthClient(f, "incoming", realtimeRoleAdmin)
serverConn, client := newSecuritySocketPair(t)
session.Conn = serverConn
ws.manager.Register(session, ws.defaultTopics(session))
startSecurityPumps(t, ws, session, true)
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
if err := client.WriteMessage(websocket.TextMessage, []byte(`{"type":"ping"}`)); err != nil {
t.Fatal(err)
}
requireSecuritySocketClosed(t, client)
if !session.Closed.Load() {
t.Fatal("reader did not revoke session")
}
}
func TestWsIdlePumpClosesRevokedConnection(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
// Same production timer path, shortened only on this isolated service instance.
ws.authCheckPeriod = 10 * time.Millisecond
session := newWsAuthClient(f, "idle", realtimeRoleAdmin)
serverConn, client := newSecuritySocketPair(t)
session.Conn = serverConn
ws.manager.Register(session, ws.defaultTopics(session))
startSecurityPumps(t, ws, session, false)
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
requireSecuritySocketClosed(t, client)
if !session.Closed.Load() {
t.Fatal("idle authorization timer did not revoke session")
}
}

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -14,11 +17,12 @@ import (
)
const (
realtimeWriteWait = 10 * time.Second
realtimePongWait = 60 * time.Second
realtimePingPeriod = 30 * time.Second
realtimeMaxMessageSize = 8 << 10
realtimeSendBufferSize = 64
realtimeWriteWait = 10 * time.Second
realtimePongWait = 60 * time.Second
realtimePingPeriod = 30 * time.Second
realtimeAuthCheckPeriod = 5 * time.Second
realtimeMaxMessageSize = 8 << 10
realtimeSendBufferSize = 64
)
const (
@@ -28,8 +32,7 @@ const (
)
const (
realtimeTopicUserPrefix = "user:"
realtimeTopicGuestPrefix = "guest:"
realtimeTopicCustomerPrefix = "customer:"
realtimeTopicAdminPrefix = "admin:"
realtimeTopicConversationPrefix = "conversation:"
realtimeTopicNotificationPrefix = "notification:"
@@ -311,21 +314,32 @@ type realtimeClientMessage struct {
}
type ClientSession struct {
ID string
Conn *websocket.Conn
Principal *dto.AuthPrincipal
External *openidentity.ExternalUser
Role string
TerminalType string
Topics map[string]struct{}
Send chan []byte
Closed atomic.Bool
LastActiveAt atomic.Int64
closeOnce sync.Once
presenceMu sync.Mutex
PresenceEnded bool
PresenceLeaseID string
PresenceRenewedAt atomic.Int64
ID string
Conn *websocket.Conn
Principal *dto.AuthPrincipal
External *openidentity.ExternalUser
CustomerSession *CustomerSessionVerifyResult
sendMu sync.RWMutex
Role string
TerminalType string
Topics map[string]struct{}
Send chan []byte
Closed atomic.Bool
LastActiveAt atomic.Int64
closeOnce sync.Once
}
func (s *ClientSession) enqueue(payload []byte) bool {
if s == nil || s.Closed.Load() {
if s == nil {
return false
}
s.sendMu.RLock()
defer s.sendMu.RUnlock()
if s.Closed.Load() {
return false
}
select {
@@ -350,14 +364,3 @@ func (s *ClientSession) touch() {
}
s.LastActiveAt.Store(time.Now().Unix())
}
func (s *ClientSession) topicList() []string {
if s == nil {
return nil
}
ret := make([]string, 0, len(s.Topics))
for topic := range s.Topics {
ret = append(ret, topic)
}
return ret
}

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -28,9 +31,10 @@ import (
var WsService = newWsService()
type wsService struct {
upgrader websocket.Upgrader
seq atomic.Uint64
manager *WsConnectionManager
upgrader websocket.Upgrader
seq atomic.Uint64
manager *WsConnectionManager
authCheckPeriod time.Duration
}
func newWsService() *wsService {
@@ -40,7 +44,8 @@ func newWsService() *wsService {
return true
},
},
manager: newWsConnectionManager(),
manager: newWsConnectionManager(),
authCheckPeriod: realtimeAuthCheckPeriod,
}
}
@@ -50,6 +55,10 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return
}
if !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonErrorCode(errorsx.CodeAuthForbidden, i18nx.T(ctx, "error.e0225")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort()
@@ -77,28 +86,34 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
return
}
var (
principal = AuthService.GetAuthPrincipal(ctx)
external *openidentity.ExternalUser
customerSessionInfo *CustomerSessionVerifyResult
)
if principal == nil {
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
external = result.ExternalUser
customerSessionInfo = result
// The customer endpoint always authenticates the customer credential. A staff
// cookie/bearer must not turn this into a second dashboard subscription path.
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID)
if err := s.upgradeConnection(ctx, nil, result.ExternalUser, realtimeRoleUser, result); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channel_id", channel.ID)
ctx.Abort()
return
}
}
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error {
if principal != nil {
fresh, err := AuthService.RevalidatePrincipal(principal)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return err
}
principal = fresh
if role == realtimeRoleAdmin && !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
err := errorsx.ForbiddenI18n("error.e0225")
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonError(err))
return err
}
}
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return err
@@ -114,12 +129,22 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
Topics: make(map[string]struct{}),
Send: make(chan []byte, realtimeSendBufferSize),
}
if len(customerSessionInfo) > 0 {
session.CustomerSession = customerSessionInfo[0]
}
session.touch()
if principal != nil && (role == realtimeRoleAdmin || role == realtimeRoleNotification) {
session.PresenceLeaseID, _ = randomToken("presence_")
}
conn.SetReadLimit(realtimeMaxMessageSize)
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
conn.SetPongHandler(func(string) error {
if !s.revalidateSession(session) {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
session.touch()
s.renewAgentPresence(session)
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
})
@@ -139,13 +164,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
"userId", logUserID,
"externalId", logExternalID,
"terminalType", session.TerminalType,
"topicCount", len(session.Topics),
"topicCount", len(s.manager.TopicList(session)),
"sessionCount", sessionCount,
)
go s.writePump(session)
go s.readPump(session)
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
Payload: RealtimeConnectedPayload{
ConnID: session.ID,
@@ -153,7 +175,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
GuestID: logExternalID,
Role: role,
TerminalType: session.TerminalType,
Topics: session.topicList(),
Topics: s.manager.TopicList(session),
},
}))
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed {
@@ -164,6 +186,9 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
},
}))
}
s.renewAgentPresence(session)
go s.writePump(session)
go s.readPump(session)
return nil
}
@@ -181,6 +206,9 @@ func (s *wsService) readPump(session *ClientSession) {
}
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
if !s.revalidateSession(session) {
return
}
input := realtimeClientMessage{}
if err := json.Unmarshal(payload, &input); err != nil {
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
@@ -193,6 +221,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
switch strings.TrimSpace(input.Type) {
case enums.IMRealtimeClientTypePing:
s.renewAgentPresence(session)
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
case enums.IMRealtimeClientTypeSubscribe:
topics := s.subscribeTopics(session, input.Topics)
@@ -214,7 +243,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
"eventId", strings.TrimSpace(input.EventID),
)
case enums.IMRealtimeClientTypePresence:
if input.ConversationID > 0 && session.Principal != nil {
if input.ConversationID > 0 && session.Role == realtimeRoleAdmin && session.Principal != nil && s.canSubscribeConversation(session, input.ConversationID) {
action := strings.TrimSpace(input.Action)
if action == "" {
action = "viewing"
@@ -232,8 +261,10 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
func (s *wsService) writePump(session *ClientSession) {
ticker := time.NewTicker(realtimePingPeriod)
authTicker := time.NewTicker(s.authCheckPeriod)
defer func() {
ticker.Stop()
authTicker.Stop()
s.closeSession(session)
}()
@@ -245,9 +276,24 @@ func (s *wsService) writePump(session *ClientSession) {
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// Recheck after queueing as well: logout or permission revocation can
// happen while a slow client still has messages buffered.
if !s.revalidateSession(session) {
return
}
var envelope struct {
Topic string `json:"topic"`
}
if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) {
continue
}
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
case <-authTicker.C:
if !s.revalidateSession(session) {
return
}
case <-ticker.C:
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -262,10 +308,15 @@ func (s *wsService) closeSession(session *ClientSession) {
return
}
session.closeOnce.Do(func() {
s.endAgentPresence(session)
session.sendMu.Lock()
session.Closed.Store(true)
if session.Send != nil {
close(session.Send)
}
session.sendMu.Unlock()
remaining := s.manager.Unregister(session)
close(session.Send)
if session.Conn != nil {
_ = session.Conn.Close()
}
@@ -587,6 +638,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
}
for _, session := range targets {
if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) {
continue
}
if filter != nil && !filter(session) {
continue
}
@@ -603,12 +657,11 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
}
}
func (s *wsService) IsGuestOnline(guestID string) bool {
guestID = strings.TrimSpace(guestID)
if guestID == "" {
func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool {
if customerID <= 0 || channelID <= 0 {
return false
}
return s.manager.HasTopic(s.guestTopic(guestID))
return s.manager.HasTopic(s.customerTopic(customerID, channelID))
}
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
@@ -617,8 +670,8 @@ func (s *wsService) routeConversationTopics(conversation *models.Conversation) [
}
topics := []string{s.conversationTopic(conversation.ID)}
if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" {
topics = append(topics, s.guestTopic(identity.ExternalID))
if conversation.CustomerID > 0 && conversation.ChannelID > 0 {
topics = append(topics, s.customerTopic(conversation.CustomerID, conversation.ChannelID))
}
if conversation.CurrentAssigneeID > 0 {
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
@@ -640,17 +693,13 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
}
return []string{s.notificationTopic(session.Principal.UserID)}
case realtimeRoleAdmin:
if session.Principal == nil || session.Principal.UserID <= 0 {
return []string{realtimeTopicAdminAll}
if session.Principal == nil || session.Principal.UserID <= 0 || !slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) {
return nil
}
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
default:
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
return []string{s.guestTopic(session.External.ExternalID)}
}
if session.Principal != nil && session.Principal.UserID > 0 {
return []string{s.userTopic(session.Principal.UserID)}
if session.CustomerSession != nil && session.External != nil && session.CustomerSession.CustomerID > 0 && session.CustomerSession.ChannelID > 0 {
return []string{s.customerTopic(session.CustomerSession.CustomerID, session.CustomerSession.ChannelID)}
}
return nil
}
@@ -693,7 +742,7 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
}
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
if session == nil || conversationID <= 0 {
if session == nil || conversationID <= 0 || session.Role == realtimeRoleNotification {
return false
}
if session.Role == realtimeRoleAdmin {
@@ -751,12 +800,8 @@ func (s *wsService) nextID(prefix string) string {
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
}
func (s *wsService) userTopic(userID int64) string {
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10)
}
func (s *wsService) guestTopic(guestID string) string {
return realtimeTopicGuestPrefix + strings.TrimSpace(guestID)
func (s *wsService) customerTopic(customerID, channelID int64) string {
return realtimeTopicCustomerPrefix + strconv.FormatInt(channelID, 10) + ":" + strconv.FormatInt(customerID, 10)
}
func (s *wsService) adminTopic(userID int64) string {
@@ -817,3 +862,30 @@ func formatWsTime(t *time.Time) string {
}
return t.Format(time.DateTime)
}
// Topic membership is an optimization, never an authorization grant.
func (s *wsService) canReceiveEvent(session *ClientSession, routingTopics []string, eventTopic string) bool {
if eventTopic != "" {
return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0
}
return len(s.filterAllowedTopics(session, routingTopics)) > 0
}
// Principal snapshots are immutable. Closing when their scope changes avoids
// races between fanout/read/write goroutines and forces a fresh subscription set.
func (s *wsService) revalidateSession(session *ClientSession) bool {
if session == nil || session.Closed.Load() {
return false
}
valid := false
if session.Principal != nil {
fresh, err := AuthService.RevalidatePrincipal(session.Principal)
valid = err == nil && fresh.UserType == session.Principal.UserType && slices.Equal(fresh.Roles, session.Principal.Roles) && slices.Equal(fresh.Permissions, session.Principal.Permissions)
} else if session.Role == realtimeRoleUser {
valid = CustomerSessionService.VerifyConnection(session.CustomerSession)
}
if !valid {
s.closeSession(session)
}
return valid
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -6,7 +8,6 @@ import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
)
@@ -49,12 +50,9 @@ func TestWsAgentActivityEventType(t *testing.T) {
}
func TestPublishAgentActivity(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := &ClientSession{
ID: "test-sess",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
session := newWsAuthClient(f, "test-sess", realtimeRoleAdmin)
ws.manager.Register(session, []string{"conversation:100"})
conv := &models.Conversation{ID: 100}
@@ -100,27 +98,16 @@ func TestWsOperatorPresenceEventType(t *testing.T) {
}
func TestPublishOperatorPresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
subscriber := &ClientSession{
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ws.manager.Register(subscriber, []string{"conversation:100"})
sender := &ClientSession{
ID: "sender-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
ws.manager.Register(sender, []string{"conversation:100"})
principal := &dto.AuthPrincipal{
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
principal := f.principal
ws.PublishOperatorPresence(100, principal, "typing", sender.ID)
@@ -136,7 +123,7 @@ func TestPublishOperatorPresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type)
}
if event.Data.ConversationID != 100 || event.Data.UserID != 2 || event.Data.Username != "operator" || event.Data.Nickname != "Agent Smith" || event.Data.Action != "typing" {
if event.Data.ConversationID != 100 || event.Data.UserID != principal.UserID || event.Data.Username != principal.Username || event.Data.Nickname != principal.Nickname || event.Data.Action != "typing" {
t.Fatalf("unexpected data: %+v", event.Data)
}
case <-time.After(time.Second):
@@ -151,26 +138,13 @@ func TestPublishOperatorPresence(t *testing.T) {
}
func TestHandleClientMessagePresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
subscriber := &ClientSession{
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ws.manager.Register(subscriber, []string{"conversation:100"})
principal := &dto.AuthPrincipal{
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
sender := &ClientSession{
ID: "sender-conn",
Principal: principal,
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
ws.manager.Register(sender, []string{"conversation:100"})
msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`)
@@ -188,7 +162,7 @@ func TestHandleClientMessagePresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type)
}
if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != 2 {
if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != f.principal.UserID {
t.Fatalf("unexpected data: %+v", event.Data)
}
case <-time.After(time.Second):

View File

@@ -0,0 +1,34 @@
package services
import (
"sync"
"testing"
)
// Run with -race: close may be triggered independently by revocation, a reader,
// a writer, or slow-client eviction while broadcasts still hold a session pointer.
func TestWsConcurrentEnqueueCloseAndSubscribe(t *testing.T) {
ws := newWsService()
session := &ClientSession{ID: "concurrent", Topics: map[string]struct{}{}, Send: make(chan []byte, 64)}
ws.manager.Register(session, []string{"notification:1"})
var work sync.WaitGroup
for i := 0; i < 8; i++ {
work.Add(1)
go func() {
defer work.Done()
for j := 0; j < 100; j++ {
session.enqueue([]byte("test"))
ws.manager.Subscribe(session, []string{"notification:1"})
ws.manager.TopicList(session)
}
}()
}
ws.closeSession(session)
work.Wait()
if !session.Closed.Load() || ws.manager.HasTopic("notification:1") || len(ws.manager.TopicList(session)) != 0 {
t.Fatal("closed session was re-registered")
}
if session.enqueue([]byte("test")) {
t.Fatal("closed session accepted a message")
}
}