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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
82
internal/ai/rag/faq_content.go
Normal file
82
internal/ai/rag/faq_content.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "售后/退款",
|
||||
|
||||
175
internal/ai/rag/localization_test.go
Normal file
175
internal/ai/rag/localization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
47
internal/builders/knowledge_builder_i18n_test.go
Normal file
47
internal/builders/knowledge_builder_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
22
internal/handlers/api/support_status_handler.go
Normal file
22
internal/handlers/api/support_status_handler.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
152
internal/handlers/dashboard/support_availability_handler.go
Normal file
152
internal/handlers/dashboard/support_availability_handler.go
Normal 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)
|
||||
}
|
||||
121
internal/handlers/dashboard/support_availability_handler_test.go
Normal file
121
internal/handlers/dashboard/support_availability_handler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
24
internal/models/support_availability.go
Normal file
24
internal/models/support_availability.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
34
internal/pkg/dto/request/support_availability_req.go
Normal file
34
internal/pkg/dto/request/support_availability_req.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
41
internal/pkg/dto/response/support_availability_resp.go
Normal file
41
internal/pkg/dto/response/support_availability_resp.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
45
internal/pkg/i18nx/context_test.go
Normal file
45
internal/pkg/i18nx/context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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: "Укажите дату и время со смещением часового пояса."
|
||||
|
||||
@@ -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: "请输入包含时区偏移的日期和时间。"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
68
internal/repositories/auth_scope_repository.go
Normal file
68
internal/repositories/auth_scope_repository.go
Normal 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
|
||||
}
|
||||
22
internal/repositories/channel_config_repository.go
Normal file
22
internal/repositories/channel_config_repository.go
Normal 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
|
||||
}
|
||||
17
internal/repositories/conversation_dispatch_queries.go
Normal file
17
internal/repositories/conversation_dispatch_queries.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
63
internal/repositories/customer_identity_scope.go
Normal file
63
internal/repositories/customer_identity_scope.go
Normal 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
|
||||
}
|
||||
153
internal/repositories/support_availability_repository.go
Normal file
153
internal/repositories/support_availability_repository.go
Normal 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)
|
||||
}
|
||||
77
internal/services/agent_presence_service.go
Normal file
77
internal/services/agent_presence_service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
260
internal/services/auth_session_security_test.go
Normal file
260
internal/services/auth_session_security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
118
internal/services/channel_policy_concurrency_test.go
Normal file
118
internal/services/channel_policy_concurrency_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
450
internal/services/customer_identity_security_test.go
Normal file
450
internal/services/customer_identity_security_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
208
internal/services/support_availability_edge_review_test.go
Normal file
208
internal/services/support_availability_edge_review_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
329
internal/services/support_availability_service.go
Normal file
329
internal/services/support_availability_service.go
Normal 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
|
||||
}
|
||||
384
internal/services/support_availability_service_test.go
Normal file
384
internal/services/support_availability_service_test.go
Normal 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(¤t, 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")
|
||||
}
|
||||
}
|
||||
337
internal/services/support_calendar_service.go
Normal file
337
internal/services/support_calendar_service.go
Normal 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)
|
||||
}
|
||||
78
internal/services/test_network_test.go
Normal file
78
internal/services/test_network_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
200
internal/services/ws_auth_security_test.go
Normal file
200
internal/services/ws_auth_security_test.go
Normal 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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
139
internal/services/ws_pump_security_test.go
Normal file
139
internal/services/ws_pump_security_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
34
internal/services/ws_session_concurrency_test.go
Normal file
34
internal/services/ws_session_concurrency_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user